Add native self-hosted instance connection to fluxer_desktop
Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 + tools/ci) with a "Connect to a Different Server" menu item and popout that lets the desktop app switch to any self-hosted Fluxer instance, plus fixes for well-known discovery on single-domain self-hosted deployments and a false-positive ERR_ABORTED on same-origin client redirects during the switch. Defaults to chat.fluxr.chat and uses an isolated userData directory from the official build.
This commit is contained in:
+478
@@ -0,0 +1,478 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctor"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01334b89b69ff726750c5ce5073fc8bd860e99aa9a8fc5ca11b04730e3aee97a"
|
||||
|
||||
[[package]]
|
||||
name = "fluxer_encoder_ring"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluxer_gpu_rebuild"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"parking_lot",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluxer_screen_frame_bus"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"fluxer_gpu_rebuild",
|
||||
"parking_lot",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluxer_win_game_capture"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"fluxer_encoder_ring",
|
||||
"fluxer_screen_frame_bus",
|
||||
"napi",
|
||||
"napi-build",
|
||||
"napi-derive",
|
||||
"parking_lot",
|
||||
"windows",
|
||||
"windows-core",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||
|
||||
[[package]]
|
||||
name = "napi"
|
||||
version = "3.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad513ff22558f1830b595ea6eb4091da48145d09a222ce157e781896f78be0b9"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"ctor",
|
||||
"futures",
|
||||
"napi-build",
|
||||
"napi-sys",
|
||||
"nohash-hasher",
|
||||
"rustc-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-build"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1"
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive"
|
||||
version = "3.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89b3f766e04667e6da0e181e2da4f85475d5a6513b7cf6a80bea184e224a5b42"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"ctor",
|
||||
"napi-derive-backend",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive-backend"
|
||||
version = "5.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d5af30503edf933ce7377cf6d4c877a62b0f1107ea05585f1b5e430e88d5baf"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"semver",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-sys"
|
||||
version = "3.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f5bcdf71abd3a50d00b49c1c2c75251cb3c913777d6139cd37dabc093a5e400"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nohash-hasher"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[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.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core",
|
||||
"windows-future",
|
||||
"windows-numerics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
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-numerics"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
@@ -0,0 +1,56 @@
|
||||
[package]
|
||||
name = "fluxer_win_game_capture"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
napi = {version = "3.9.1", default-features = false, features = ["dyn-symbols", "napi8"]}
|
||||
napi-derive = "3.5.6"
|
||||
fluxer_screen_frame_bus = {path = "../screen-frame-bus"}
|
||||
fluxer_encoder_ring = {path = "../encoder-ring"}
|
||||
parking_lot = "0.12"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = {version = "0.62.2", features = [
|
||||
"Graphics_Capture",
|
||||
"Graphics_DirectX_Direct3D11",
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Direct3D",
|
||||
"Win32_Graphics_Direct3D11",
|
||||
"Win32_Graphics_Dxgi",
|
||||
"Win32_Graphics_Dxgi_Common",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_WinRT",
|
||||
"Win32_System_WinRT_Direct3D11",
|
||||
"Win32_System_WinRT_Graphics_Capture",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_Performance",
|
||||
]}
|
||||
windows-core = "0.62.2"
|
||||
windows-sys = {version = "0.61.2", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_Security",
|
||||
"Win32_System_Diagnostics_Debug",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Performance",
|
||||
"Win32_System_ProcessStatus",
|
||||
"Win32_System_Registry",
|
||||
"Win32_System_SystemInformation",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
]}
|
||||
|
||||
[build-dependencies]
|
||||
napi-build = "2.3.2"
|
||||
@@ -0,0 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
extern crate napi_build;
|
||||
|
||||
fn main() {
|
||||
napi_build::setup();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$comment": "Optional per-process game-capture injection policy override. Placed next to the native addon, it augments the deny/force-cpu lists compiled into the addon. Entries are matched case-insensitively by executable file name (e.g. game.exe); paths are reduced to their final component. 'deny' refuses injection; 'allow' opts a process back in past the built-in deny list; 'forceCpu' keeps injection but prefers CPU readback over the shared-texture fast path. A missing or malformed file falls back to the embedded defaults.",
|
||||
"deny": [],
|
||||
"allow": [],
|
||||
"forceCpu": []
|
||||
}
|
||||
+375
@@ -0,0 +1,375 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "fluxer_game_hook"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"retour",
|
||||
"windows",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iced-x86"
|
||||
version = "1.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c447cff8c7f384a7d4f741cfcff32f75f3ad02b406432e8d6c878d56b1edf6b"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mmap-fixed-fixed"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0681853891801e4763dc252e843672faf32bcfee27a0aa3b19733902af450acc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[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.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "region"
|
||||
version = "3.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6b6ebd13bc009aef9cd476c1310d49ac354d36e240cf1bd753290f3dc7199a7"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"libc",
|
||||
"mach2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "retour"
|
||||
version = "0.4.0-alpha.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ead4bc8e12d553ff70769c5f5c21f5f4f0e73c0018068a6bb5a3d7d3b9e57ec7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"generic-array",
|
||||
"iced-x86",
|
||||
"libc",
|
||||
"mmap-fixed-fixed",
|
||||
"once_cell",
|
||||
"region",
|
||||
"slice-pool2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slice-pool2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a3d689654af89bdfeba29a914ab6ac0236d382eb3b764f7454dde052f2821f8"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core",
|
||||
"windows-future",
|
||||
"windows-numerics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
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-numerics"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[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.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[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.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[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.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
@@ -0,0 +1,46 @@
|
||||
[package]
|
||||
name = "fluxer_game_hook"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
# `retour` only ships x86/x86_64 trampoline + patcher backends (its `arch`
|
||||
# module has no aarch64 variant), so it does not even compile for
|
||||
# aarch64-pc-windows-msvc. Gate it to the architectures it supports; the
|
||||
# aarch64 hook uses the in-crate `inline_hook::aarch64` backend instead.
|
||||
[target.'cfg(all(target_os = "windows", any(target_arch = "x86", target_arch = "x86_64")))'.dependencies]
|
||||
retour = "0.4.0-alpha.4"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = {version = "0.62.2", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Direct3D",
|
||||
"Win32_Graphics_Direct3D9",
|
||||
"Win32_Graphics_Direct3D10",
|
||||
"Win32_Graphics_Direct3D11",
|
||||
"Win32_Graphics_Direct3D11on12",
|
||||
"Win32_Graphics_Direct3D12",
|
||||
"Win32_Graphics_Dxgi",
|
||||
"Win32_Graphics_Dxgi_Common",
|
||||
"Win32_Graphics_Gdi",
|
||||
]}
|
||||
windows-sys = {version = "0.61.2", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_Graphics_OpenGL",
|
||||
"Win32_Security",
|
||||
"Win32_System_Diagnostics_Debug",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Performance",
|
||||
"Win32_System_SystemServices",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
]}
|
||||
@@ -0,0 +1,248 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub const STOLEN_BYTES: usize = 16;
|
||||
|
||||
pub const NOP: u32 = 0xD503_201F;
|
||||
|
||||
pub const LDR_X16_PC8: u32 = 0x5800_0050;
|
||||
pub const BR_X16: u32 = 0xD61F_0200;
|
||||
pub const BLR_X16: u32 = 0xD63F_0200;
|
||||
|
||||
pub fn is_b(insn: u32) -> bool {
|
||||
(insn & 0xFC00_0000) == 0x1400_0000
|
||||
}
|
||||
|
||||
pub fn is_bl(insn: u32) -> bool {
|
||||
(insn & 0xFC00_0000) == 0x9400_0000
|
||||
}
|
||||
|
||||
pub fn needs_absolute_island(insn: u32) -> bool {
|
||||
is_b(insn) || is_bl(insn)
|
||||
}
|
||||
|
||||
pub fn branch_target(insn: u32, src_pc: u64) -> Option<u64> {
|
||||
if !is_b(insn) && !is_bl(insn) {
|
||||
return None;
|
||||
}
|
||||
let imm26 = (insn & 0x03FF_FFFF) as i32;
|
||||
let off = ((imm26 << 6) >> 6) as i64 * 4;
|
||||
Some((src_pc as i64 + off) as u64)
|
||||
}
|
||||
|
||||
pub fn encode_imm26(byte_off: i64) -> Option<u32> {
|
||||
if byte_off & 0b11 != 0 {
|
||||
return None;
|
||||
}
|
||||
let words = byte_off >> 2;
|
||||
if !(-(1 << 25)..(1 << 25)).contains(&words) {
|
||||
return None;
|
||||
}
|
||||
Some((words as u32) & 0x03FF_FFFF)
|
||||
}
|
||||
|
||||
pub fn append_abs_branch(out: &mut Vec<u8>, addr: u64, link: bool) {
|
||||
let branch = if link { BLR_X16 } else { BR_X16 };
|
||||
out.extend_from_slice(&LDR_X16_PC8.to_le_bytes());
|
||||
out.extend_from_slice(&branch.to_le_bytes());
|
||||
out.extend_from_slice(&addr.to_le_bytes());
|
||||
}
|
||||
|
||||
pub fn adrp_target(insn: u32, src_pc: u64) -> Option<u64> {
|
||||
if (insn & 0x9F00_0000) != 0x9000_0000 {
|
||||
return None;
|
||||
}
|
||||
let immlo = ((insn >> 29) & 0x3) as i64;
|
||||
let immhi = ((insn >> 5) & 0x7FFFF) as i64;
|
||||
let raw = (immhi << 2) | immlo;
|
||||
let imm21 = (raw << 43) >> 43;
|
||||
let page = (src_pc & !0xFFF) as i64 + imm21 * 4096;
|
||||
Some(page as u64)
|
||||
}
|
||||
|
||||
fn ldr_unsigned_64(insn: u32) -> Option<(u32, u32, u64)> {
|
||||
if (insn & 0xFFC0_0000) != 0xF940_0000 {
|
||||
return None;
|
||||
}
|
||||
let imm12 = ((insn >> 10) & 0xFFF) as u64;
|
||||
let rn = (insn >> 5) & 0x1F;
|
||||
let rt = insn & 0x1F;
|
||||
Some((rt, rn, imm12 * 8))
|
||||
}
|
||||
|
||||
fn br_register(insn: u32) -> Option<u32> {
|
||||
if (insn & 0xFFFF_FC1F) != 0xD61F_0000 {
|
||||
return None;
|
||||
}
|
||||
Some((insn >> 5) & 0x1F)
|
||||
}
|
||||
|
||||
pub unsafe fn import_thunk_target(prologue: &[u8], src_base: u64) -> Option<u64> {
|
||||
if prologue.len() < 12 {
|
||||
return None;
|
||||
}
|
||||
let adrp = u32::from_le_bytes(prologue[0..4].try_into().ok()?);
|
||||
let ldr = u32::from_le_bytes(prologue[4..8].try_into().ok()?);
|
||||
let br = u32::from_le_bytes(prologue[8..12].try_into().ok()?);
|
||||
|
||||
let adrp_reg = adrp & 0x1F;
|
||||
let page = adrp_target(adrp, src_base)?;
|
||||
let (ldr_rt, ldr_rn, offset) = ldr_unsigned_64(ldr)?;
|
||||
let br_rn = br_register(br)?;
|
||||
if adrp_reg != ldr_rn || ldr_rt != br_rn {
|
||||
return None;
|
||||
}
|
||||
let pointer_addr = page.checked_add(offset)?;
|
||||
let target = unsafe { core::ptr::read_unaligned(pointer_addr as *const u64) };
|
||||
(target != 0).then_some(target)
|
||||
}
|
||||
|
||||
pub fn emit_branch_to_island(insn: u32, dst_pc: u64, island_addr: u64) -> Option<u32> {
|
||||
let link = is_bl(insn);
|
||||
let off = island_addr as i64 - dst_pc as i64;
|
||||
let imm = encode_imm26(off)?;
|
||||
let opc = if link { 0x9400_0000 } else { 0x1400_0000 };
|
||||
Some(opc | imm)
|
||||
}
|
||||
|
||||
pub fn island_for_branch(insn: u32, src_pc: u64) -> Option<Vec<u8>> {
|
||||
let target = branch_target(insn, src_pc)?;
|
||||
let mut bytes = Vec::new();
|
||||
append_abs_branch(&mut bytes, target, is_bl(insn));
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
pub fn relocate_instruction(insn: u32, src_pc: u64, dst_pc: u64) -> Option<u32> {
|
||||
if (insn & 0x9F00_0000) == 0x9000_0000 {
|
||||
return relocate_adr(insn, src_pc, dst_pc, true);
|
||||
}
|
||||
if (insn & 0x9F00_0000) == 0x1000_0000 {
|
||||
return relocate_adr(insn, src_pc, dst_pc, false);
|
||||
}
|
||||
if is_b(insn) || is_bl(insn) {
|
||||
let target = branch_target(insn, src_pc)?;
|
||||
let off = target as i64 - dst_pc as i64;
|
||||
let imm = encode_imm26(off)?;
|
||||
return Some((insn & 0xFC00_0000) | imm);
|
||||
}
|
||||
if (insn & 0xFF00_0010) == 0x5400_0000 {
|
||||
return relocate_imm19_at5(insn, src_pc, dst_pc);
|
||||
}
|
||||
if (insn & 0x7F00_0000) == 0x3400_0000 {
|
||||
return relocate_imm19_at5(insn, src_pc, dst_pc);
|
||||
}
|
||||
if (insn & 0x7F00_0000) == 0x3600_0000 {
|
||||
return relocate_tbz(insn, src_pc, dst_pc);
|
||||
}
|
||||
if (insn & 0x3B00_0000) == 0x1800_0000 {
|
||||
return relocate_imm19_at5(insn, src_pc, dst_pc);
|
||||
}
|
||||
Some(insn)
|
||||
}
|
||||
|
||||
fn relocate_adr(insn: u32, src_pc: u64, dst_pc: u64, page: bool) -> Option<u32> {
|
||||
let immlo = ((insn >> 29) & 0x3) as i64;
|
||||
let immhi = ((insn >> 5) & 0x7FFFF) as i64;
|
||||
let raw = (immhi << 2) | immlo;
|
||||
let imm21 = (raw << 43) >> 43;
|
||||
let (src_ref, dst_ref, scale) = if page {
|
||||
(src_pc & !0xFFF, dst_pc & !0xFFF, 4096i64)
|
||||
} else {
|
||||
(src_pc, dst_pc, 1i64)
|
||||
};
|
||||
let target = src_ref as i64 + imm21 * scale;
|
||||
let new_off = target - dst_ref as i64;
|
||||
if scale != 1 && new_off & 0xFFF != 0 {
|
||||
return None;
|
||||
}
|
||||
let scaled = new_off / scale;
|
||||
if !(-(1 << 20)..(1 << 20)).contains(&scaled) {
|
||||
return None;
|
||||
}
|
||||
let new_raw = (scaled as u32) & 0x1F_FFFF;
|
||||
let new_immlo = (new_raw & 0x3) << 29;
|
||||
let new_immhi = ((new_raw >> 2) & 0x7FFFF) << 5;
|
||||
Some((insn & 0x9F00_001F) | new_immlo | new_immhi)
|
||||
}
|
||||
|
||||
fn relocate_imm19_at5(insn: u32, src_pc: u64, dst_pc: u64) -> Option<u32> {
|
||||
let imm19 = ((insn >> 5) & 0x7FFFF) as i64;
|
||||
let off = ((imm19 << 45) >> 45) * 4;
|
||||
let target = src_pc as i64 + off;
|
||||
let new_off = target - dst_pc as i64;
|
||||
if new_off & 0b11 != 0 {
|
||||
return None;
|
||||
}
|
||||
let words = new_off >> 2;
|
||||
if !(-(1 << 18)..(1 << 18)).contains(&words) {
|
||||
return None;
|
||||
}
|
||||
let new_imm19 = ((words as u32) & 0x7FFFF) << 5;
|
||||
Some((insn & !(0x7FFFF << 5)) | new_imm19)
|
||||
}
|
||||
|
||||
fn relocate_tbz(insn: u32, src_pc: u64, dst_pc: u64) -> Option<u32> {
|
||||
let imm14 = ((insn >> 5) & 0x3FFF) as i64;
|
||||
let off = ((imm14 << 50) >> 50) * 4;
|
||||
let target = src_pc as i64 + off;
|
||||
let new_off = target - dst_pc as i64;
|
||||
if new_off & 0b11 != 0 {
|
||||
return None;
|
||||
}
|
||||
let words = new_off >> 2;
|
||||
if !(-(1 << 13)..(1 << 13)).contains(&words) {
|
||||
return None;
|
||||
}
|
||||
let new_imm14 = ((words as u32) & 0x3FFF) << 5;
|
||||
Some((insn & !(0x3FFF << 5)) | new_imm14)
|
||||
}
|
||||
|
||||
pub fn assemble_trampoline(
|
||||
prologue: &[u8],
|
||||
src_base: u64,
|
||||
dst_base: u64,
|
||||
resume: u64,
|
||||
) -> Option<Vec<u8>> {
|
||||
if !prologue.len().is_multiple_of(4) {
|
||||
return None;
|
||||
}
|
||||
let count = prologue.len() / 4;
|
||||
const RETURN_BRANCH_BYTES: usize = 16;
|
||||
const ISLAND_BYTES: usize = 16;
|
||||
|
||||
let islands_base = dst_base + (count * 4) as u64 + RETURN_BRANCH_BYTES as u64;
|
||||
|
||||
let mut prologue_out: Vec<u8> = Vec::with_capacity(count * 4);
|
||||
let mut islands_out: Vec<u8> = Vec::new();
|
||||
let mut next_island = islands_base;
|
||||
|
||||
for i in 0..count {
|
||||
let insn = u32::from_le_bytes(prologue[i * 4..i * 4 + 4].try_into().ok()?);
|
||||
let src_pc = src_base + (i * 4) as u64;
|
||||
let dst_pc = dst_base + (i * 4) as u64;
|
||||
if needs_absolute_island(insn) {
|
||||
let island_addr = next_island;
|
||||
next_island += ISLAND_BYTES as u64;
|
||||
let relocated = emit_branch_to_island(insn, dst_pc, island_addr)?;
|
||||
prologue_out.extend_from_slice(&relocated.to_le_bytes());
|
||||
let island = island_for_branch(insn, src_pc)?;
|
||||
debug_assert_eq!(island.len(), ISLAND_BYTES);
|
||||
islands_out.extend_from_slice(&island);
|
||||
} else {
|
||||
let relocated = relocate_instruction(insn, src_pc, dst_pc)?;
|
||||
prologue_out.extend_from_slice(&relocated.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = prologue_out;
|
||||
append_abs_branch(&mut out, resume, false);
|
||||
out.extend_from_slice(&islands_out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
pub fn relocated_prologue(prologue: &[u8], src_base: u64, dst_base: u64) -> Option<Vec<u8>> {
|
||||
let count = prologue.len() / 4;
|
||||
let body = assemble_trampoline(prologue, src_base, dst_base, src_base + STOLEN_BYTES as u64)?;
|
||||
Some(body[..count * 4].to_vec())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,584 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
GAME_CAPTURE_API_OPENGL, GAME_CAPTURE_FALLBACK_NONE,
|
||||
GAME_CAPTURE_FALLBACK_SHARED_TEXTURE_UNSUPPORTED, HookState, mark_present,
|
||||
publish_shared_texture_frame, set_capture_flags, set_fallback_reason, verbose_log,
|
||||
};
|
||||
use std::{
|
||||
ffi::c_void,
|
||||
ptr::null_mut,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use windows::{
|
||||
Win32::{
|
||||
Foundation::{HMODULE as WinHmodule, HWND as WinHwnd},
|
||||
Graphics::{
|
||||
Direct3D::D3D_DRIVER_TYPE_HARDWARE,
|
||||
Direct3D11::{
|
||||
D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE,
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_RESOURCE_MISC_SHARED, D3D11_SDK_VERSION,
|
||||
D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, D3D11CreateDeviceAndSwapChain,
|
||||
ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D,
|
||||
},
|
||||
Dxgi::{
|
||||
Common::{
|
||||
DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_MODE_DESC,
|
||||
DXGI_MODE_SCALING_UNSPECIFIED, DXGI_MODE_SCANLINE_ORDER_UNSPECIFIED,
|
||||
DXGI_RATIONAL, DXGI_SAMPLE_DESC,
|
||||
},
|
||||
DXGI_PRESENT, DXGI_SWAP_CHAIN_DESC, DXGI_SWAP_EFFECT_DISCARD,
|
||||
DXGI_USAGE_RENDER_TARGET_OUTPUT, IDXGIResource, IDXGISwapChain,
|
||||
},
|
||||
},
|
||||
},
|
||||
core::{BOOL as WinBool, Interface},
|
||||
};
|
||||
use windows_sys::Win32::{
|
||||
Foundation::HWND as SysHwnd,
|
||||
Graphics::OpenGL::{
|
||||
GL_COLOR_BUFFER_BIT, GL_LINEAR, GL_NEAREST, GL_NO_ERROR, GL_TEXTURE_2D,
|
||||
GL_TEXTURE_BINDING_2D, glBindTexture, glDeleteTextures, glFinish, glGenTextures,
|
||||
glGetError, glGetIntegerv, wglGetCurrentContext, wglGetProcAddress,
|
||||
},
|
||||
UI::WindowsAndMessaging::DestroyWindow,
|
||||
};
|
||||
|
||||
const WGL_ACCESS_READ_ONLY_NV: u32 = 0x0000;
|
||||
const WGL_ACCESS_READ_WRITE_NV: u32 = 0x0001;
|
||||
const WGL_ACCESS_WRITE_DISCARD_NV: u32 = 0x0002;
|
||||
|
||||
const GL_READ_FRAMEBUFFER: u32 = 0x8CA8;
|
||||
const GL_DRAW_FRAMEBUFFER: u32 = 0x8CA9;
|
||||
const GL_FRAMEBUFFER: u32 = 0x8D40;
|
||||
const GL_COLOR_ATTACHMENT0: u32 = 0x8CE0;
|
||||
const GL_FRAMEBUFFER_COMPLETE: u32 = 0x8CD5;
|
||||
const GL_READ_FRAMEBUFFER_BINDING: u32 = 0x8CAA;
|
||||
const GL_DRAW_FRAMEBUFFER_BINDING: u32 = 0x8CA6;
|
||||
|
||||
type DxOpenDeviceNvFn = unsafe extern "system" fn(dx_device: *mut c_void) -> *mut c_void;
|
||||
type DxCloseDeviceNvFn = unsafe extern "system" fn(device: *mut c_void) -> i32;
|
||||
type DxRegisterObjectNvFn = unsafe extern "system" fn(
|
||||
device: *mut c_void,
|
||||
dx_object: *mut c_void,
|
||||
name: u32,
|
||||
object_type: u32,
|
||||
access: u32,
|
||||
) -> *mut c_void;
|
||||
type DxUnregisterObjectNvFn =
|
||||
unsafe extern "system" fn(device: *mut c_void, object: *mut c_void) -> i32;
|
||||
type DxLockObjectsNvFn =
|
||||
unsafe extern "system" fn(device: *mut c_void, count: i32, objects: *const *mut c_void) -> i32;
|
||||
type DxUnlockObjectsNvFn =
|
||||
unsafe extern "system" fn(device: *mut c_void, count: i32, objects: *const *mut c_void) -> i32;
|
||||
|
||||
type GlGenFramebuffersFn = unsafe extern "system" fn(n: i32, framebuffers: *mut u32);
|
||||
type GlDeleteFramebuffersFn = unsafe extern "system" fn(n: i32, framebuffers: *const u32);
|
||||
type GlBindFramebufferFn = unsafe extern "system" fn(target: u32, framebuffer: u32);
|
||||
type GlFramebufferTexture2DFn = unsafe extern "system" fn(
|
||||
target: u32,
|
||||
attachment: u32,
|
||||
textarget: u32,
|
||||
texture: u32,
|
||||
level: i32,
|
||||
);
|
||||
type GlCheckFramebufferStatusFn = unsafe extern "system" fn(target: u32) -> u32;
|
||||
type GlBlitFramebufferFn = unsafe extern "system" fn(
|
||||
src_x0: i32,
|
||||
src_y0: i32,
|
||||
src_x1: i32,
|
||||
src_y1: i32,
|
||||
dst_x0: i32,
|
||||
dst_y0: i32,
|
||||
dst_x1: i32,
|
||||
dst_y1: i32,
|
||||
mask: u32,
|
||||
filter: u32,
|
||||
);
|
||||
|
||||
struct InteropProcs {
|
||||
open_device: DxOpenDeviceNvFn,
|
||||
close_device: DxCloseDeviceNvFn,
|
||||
register_object: DxRegisterObjectNvFn,
|
||||
unregister_object: DxUnregisterObjectNvFn,
|
||||
lock_objects: DxLockObjectsNvFn,
|
||||
unlock_objects: DxUnlockObjectsNvFn,
|
||||
gen_framebuffers: GlGenFramebuffersFn,
|
||||
delete_framebuffers: GlDeleteFramebuffersFn,
|
||||
bind_framebuffer: GlBindFramebufferFn,
|
||||
framebuffer_texture_2d: GlFramebufferTexture2DFn,
|
||||
check_framebuffer_status: GlCheckFramebufferStatusFn,
|
||||
blit_framebuffer: GlBlitFramebufferFn,
|
||||
}
|
||||
|
||||
pub(crate) struct GlInteropState {
|
||||
procs: InteropProcs,
|
||||
_device: ID3D11Device,
|
||||
_context: ID3D11DeviceContext,
|
||||
swap_chain: IDXGISwapChain,
|
||||
_texture: ID3D11Texture2D,
|
||||
dummy_hwnd: SysHwnd,
|
||||
shared_handle: u64,
|
||||
dx_device: *mut c_void,
|
||||
dx_object: *mut c_void,
|
||||
gl_texture: u32,
|
||||
draw_fbo: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
unsafe impl Send for GlInteropState {}
|
||||
|
||||
static GL_GPU_DISABLED: AtomicBool = AtomicBool::new(false);
|
||||
static GL_GPU_UNAVAILABLE_LOGGED: AtomicBool = AtomicBool::new(false);
|
||||
static GL_DUMMY_PRESENT_ACTIVE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
struct DummyPresentGuard;
|
||||
|
||||
impl DummyPresentGuard {
|
||||
fn enter() -> Self {
|
||||
GL_DUMMY_PRESENT_ACTIVE.store(true, Ordering::Release);
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DummyPresentGuard {
|
||||
fn drop(&mut self) {
|
||||
GL_DUMMY_PRESENT_ACTIVE.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
fn latch_disable(reason: &str) {
|
||||
if !GL_GPU_DISABLED.swap(true, Ordering::AcqRel) {
|
||||
verbose_log(&format!(
|
||||
"opengl interop: latch-disabling GPU path, falling back to glReadPixels CPU path ({reason})"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn gpu_path_disabled() -> bool {
|
||||
GL_GPU_DISABLED.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(crate) fn dummy_present_active() -> bool {
|
||||
GL_DUMMY_PRESENT_ACTIVE.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
unsafe fn load_proc<T>(name: &[u8]) -> Option<T> {
|
||||
debug_assert_eq!(
|
||||
name.last(),
|
||||
Some(&0),
|
||||
"wglGetProcAddress name must be NUL-terminated"
|
||||
);
|
||||
let proc = wglGetProcAddress(name.as_ptr());
|
||||
match proc {
|
||||
Some(proc) => Some(std::mem::transmute_copy::<_, T>(&proc)),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl InteropProcs {
|
||||
unsafe fn load() -> Option<Self> {
|
||||
if wglGetCurrentContext().is_null() {
|
||||
return None;
|
||||
}
|
||||
let open_device = load_proc::<DxOpenDeviceNvFn>(b"wglDXOpenDeviceNV\0")?;
|
||||
let close_device = load_proc::<DxCloseDeviceNvFn>(b"wglDXCloseDeviceNV\0")?;
|
||||
let register_object = load_proc::<DxRegisterObjectNvFn>(b"wglDXRegisterObjectNV\0")?;
|
||||
let unregister_object = load_proc::<DxUnregisterObjectNvFn>(b"wglDXUnregisterObjectNV\0")?;
|
||||
let lock_objects = load_proc::<DxLockObjectsNvFn>(b"wglDXLockObjectsNV\0")?;
|
||||
let unlock_objects = load_proc::<DxUnlockObjectsNvFn>(b"wglDXUnlockObjectsNV\0")?;
|
||||
let gen_framebuffers = load_proc::<GlGenFramebuffersFn>(b"glGenFramebuffers\0")?;
|
||||
let delete_framebuffers = load_proc::<GlDeleteFramebuffersFn>(b"glDeleteFramebuffers\0")?;
|
||||
let bind_framebuffer = load_proc::<GlBindFramebufferFn>(b"glBindFramebuffer\0")?;
|
||||
let framebuffer_texture_2d =
|
||||
load_proc::<GlFramebufferTexture2DFn>(b"glFramebufferTexture2D\0")?;
|
||||
let check_framebuffer_status =
|
||||
load_proc::<GlCheckFramebufferStatusFn>(b"glCheckFramebufferStatus\0")?;
|
||||
let blit_framebuffer = load_proc::<GlBlitFramebufferFn>(b"glBlitFramebuffer\0")?;
|
||||
Some(Self {
|
||||
open_device,
|
||||
close_device,
|
||||
register_object,
|
||||
unregister_object,
|
||||
lock_objects,
|
||||
unlock_objects,
|
||||
gen_framebuffers,
|
||||
delete_framebuffers,
|
||||
bind_framebuffer,
|
||||
framebuffer_texture_2d,
|
||||
check_framebuffer_status,
|
||||
blit_framebuffer,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn create_interop_d3d11_device()
|
||||
-> Option<(ID3D11Device, ID3D11DeviceContext, IDXGISwapChain, SysHwnd)> {
|
||||
let dummy_hwnd = crate::create_dummy_window();
|
||||
if dummy_hwnd.is_null() {
|
||||
verbose_log("opengl interop: failed to create dummy D3D11 flush window");
|
||||
return None;
|
||||
}
|
||||
|
||||
let desc = DXGI_SWAP_CHAIN_DESC {
|
||||
BufferDesc: DXGI_MODE_DESC {
|
||||
Width: 2,
|
||||
Height: 2,
|
||||
RefreshRate: DXGI_RATIONAL {
|
||||
Numerator: 60,
|
||||
Denominator: 1,
|
||||
},
|
||||
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
ScanlineOrdering: DXGI_MODE_SCANLINE_ORDER_UNSPECIFIED,
|
||||
Scaling: DXGI_MODE_SCALING_UNSPECIFIED,
|
||||
},
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT,
|
||||
BufferCount: 2,
|
||||
OutputWindow: WinHwnd(dummy_hwnd),
|
||||
Windowed: WinBool(1),
|
||||
SwapEffect: DXGI_SWAP_EFFECT_DISCARD,
|
||||
Flags: 0,
|
||||
};
|
||||
let mut swap_chain = None;
|
||||
let mut device = None;
|
||||
let mut context = None;
|
||||
let result = D3D11CreateDeviceAndSwapChain(
|
||||
None,
|
||||
D3D_DRIVER_TYPE_HARDWARE,
|
||||
WinHmodule(null_mut()),
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
None,
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&desc),
|
||||
Some(&mut swap_chain),
|
||||
Some(&mut device),
|
||||
None,
|
||||
Some(&mut context),
|
||||
);
|
||||
if result.is_err() {
|
||||
let _ = DestroyWindow(dummy_hwnd);
|
||||
return None;
|
||||
}
|
||||
match (device, context, swap_chain) {
|
||||
(Some(device), Some(context), Some(swap_chain)) => {
|
||||
Some((device, context, swap_chain, dummy_hwnd))
|
||||
}
|
||||
_ => {
|
||||
let _ = DestroyWindow(dummy_hwnd);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn create_shared_texture(
|
||||
device: &ID3D11Device,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Option<(ID3D11Texture2D, u64)> {
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: width,
|
||||
Height: height,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
|
||||
CPUAccessFlags: 0,
|
||||
MiscFlags: D3D11_RESOURCE_MISC_SHARED.0 as u32,
|
||||
};
|
||||
let mut texture = None;
|
||||
if device
|
||||
.CreateTexture2D(&desc, None, Some(&mut texture))
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let texture = texture?;
|
||||
let handle = texture
|
||||
.cast::<IDXGIResource>()
|
||||
.and_then(|resource| resource.GetSharedHandle())
|
||||
.ok()?;
|
||||
Some((texture, handle.0 as usize as u64))
|
||||
}
|
||||
|
||||
impl GlInteropState {
|
||||
unsafe fn create(width: u32, height: u32) -> Option<Self> {
|
||||
let procs = InteropProcs::load()?;
|
||||
let (device, context, swap_chain, dummy_hwnd) = create_interop_d3d11_device()?;
|
||||
let (texture, shared_handle) = match create_shared_texture(&device, width, height) {
|
||||
Some(texture) => texture,
|
||||
None => {
|
||||
let _ = DestroyWindow(dummy_hwnd);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if shared_handle == 0 {
|
||||
let _ = DestroyWindow(dummy_hwnd);
|
||||
return None;
|
||||
}
|
||||
|
||||
let dx_device = (procs.open_device)(device.as_raw());
|
||||
if dx_device.is_null() {
|
||||
verbose_log("opengl interop: wglDXOpenDeviceNV returned NULL");
|
||||
let _ = DestroyWindow(dummy_hwnd);
|
||||
return None;
|
||||
}
|
||||
verbose_log("opengl interop: wglDXOpenDeviceNV opened private D3D11 device");
|
||||
|
||||
let mut gl_texture = 0u32;
|
||||
glGenTextures(1, &mut gl_texture);
|
||||
if gl_texture == 0 {
|
||||
(procs.close_device)(dx_device);
|
||||
let _ = DestroyWindow(dummy_hwnd);
|
||||
return None;
|
||||
}
|
||||
|
||||
let dx_object = (procs.register_object)(
|
||||
dx_device,
|
||||
texture.as_raw(),
|
||||
gl_texture,
|
||||
GL_TEXTURE_2D,
|
||||
WGL_ACCESS_WRITE_DISCARD_NV,
|
||||
);
|
||||
if dx_object.is_null() {
|
||||
verbose_log("opengl interop: wglDXRegisterObjectNV returned NULL");
|
||||
glDeleteTextures(1, &gl_texture);
|
||||
(procs.close_device)(dx_device);
|
||||
let _ = DestroyWindow(dummy_hwnd);
|
||||
return None;
|
||||
}
|
||||
verbose_log(&format!(
|
||||
"opengl interop: registered D3D11 texture <-> GL texture {gl_texture} ({width}x{height} BGRA)"
|
||||
));
|
||||
|
||||
let mut draw_fbo = 0u32;
|
||||
(procs.gen_framebuffers)(1, &mut draw_fbo);
|
||||
if draw_fbo == 0 {
|
||||
(procs.unregister_object)(dx_device, dx_object);
|
||||
glDeleteTextures(1, &gl_texture);
|
||||
(procs.close_device)(dx_device);
|
||||
let _ = DestroyWindow(dummy_hwnd);
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
procs,
|
||||
_device: device,
|
||||
_context: context,
|
||||
swap_chain,
|
||||
_texture: texture,
|
||||
dummy_hwnd,
|
||||
shared_handle,
|
||||
dx_device,
|
||||
dx_object,
|
||||
gl_texture,
|
||||
draw_fbo,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
fn matches(&self, width: u32, height: u32) -> bool {
|
||||
self.width == width && self.height == height
|
||||
}
|
||||
|
||||
unsafe fn blit_default_framebuffer(&self) -> bool {
|
||||
let objects = [self.dx_object];
|
||||
|
||||
let mut prev_read_fbo = 0i32;
|
||||
let mut prev_draw_fbo = 0i32;
|
||||
glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &mut prev_read_fbo);
|
||||
glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &mut prev_draw_fbo);
|
||||
let mut prev_tex = 0i32;
|
||||
glGetIntegerv(GL_TEXTURE_BINDING_2D, &mut prev_tex);
|
||||
|
||||
if (self.procs.lock_objects)(self.dx_device, 1, objects.as_ptr()) == 0 {
|
||||
verbose_log("opengl interop: wglDXLockObjectsNV FAILED");
|
||||
return false;
|
||||
}
|
||||
|
||||
(self.procs.bind_framebuffer)(GL_DRAW_FRAMEBUFFER, self.draw_fbo);
|
||||
(self.procs.framebuffer_texture_2d)(
|
||||
GL_DRAW_FRAMEBUFFER,
|
||||
GL_COLOR_ATTACHMENT0,
|
||||
GL_TEXTURE_2D,
|
||||
self.gl_texture,
|
||||
0,
|
||||
);
|
||||
let status = (self.procs.check_framebuffer_status)(GL_DRAW_FRAMEBUFFER);
|
||||
if status != GL_FRAMEBUFFER_COMPLETE {
|
||||
verbose_log(&format!(
|
||||
"opengl interop: draw FBO incomplete (status 0x{status:04X}); unlocking and falling back"
|
||||
));
|
||||
(self.procs.framebuffer_texture_2d)(
|
||||
GL_DRAW_FRAMEBUFFER,
|
||||
GL_COLOR_ATTACHMENT0,
|
||||
GL_TEXTURE_2D,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
(self.procs.bind_framebuffer)(GL_DRAW_FRAMEBUFFER, prev_draw_fbo as u32);
|
||||
(self.procs.bind_framebuffer)(GL_READ_FRAMEBUFFER, prev_read_fbo as u32);
|
||||
let _ = (self.procs.unlock_objects)(self.dx_device, 1, objects.as_ptr());
|
||||
return false;
|
||||
}
|
||||
|
||||
(self.procs.bind_framebuffer)(GL_READ_FRAMEBUFFER, 0);
|
||||
let w = self.width as i32;
|
||||
let h = self.height as i32;
|
||||
(self.procs.blit_framebuffer)(
|
||||
0,
|
||||
0,
|
||||
w,
|
||||
h,
|
||||
0,
|
||||
h,
|
||||
w,
|
||||
0,
|
||||
GL_COLOR_BUFFER_BIT,
|
||||
if w == self.width as i32 && h == self.height as i32 {
|
||||
GL_NEAREST
|
||||
} else {
|
||||
GL_LINEAR
|
||||
},
|
||||
);
|
||||
let blit_err = glGetError();
|
||||
|
||||
(self.procs.framebuffer_texture_2d)(
|
||||
GL_DRAW_FRAMEBUFFER,
|
||||
GL_COLOR_ATTACHMENT0,
|
||||
GL_TEXTURE_2D,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
(self.procs.bind_framebuffer)(GL_DRAW_FRAMEBUFFER, prev_draw_fbo as u32);
|
||||
(self.procs.bind_framebuffer)(GL_READ_FRAMEBUFFER, prev_read_fbo as u32);
|
||||
glBindTexture(GL_TEXTURE_2D, prev_tex as u32);
|
||||
|
||||
glFinish();
|
||||
|
||||
if (self.procs.unlock_objects)(self.dx_device, 1, objects.as_ptr()) == 0 {
|
||||
verbose_log("opengl interop: wglDXUnlockObjectsNV FAILED");
|
||||
return false;
|
||||
}
|
||||
self._context.Flush();
|
||||
let present_result = {
|
||||
let _guard = DummyPresentGuard::enter();
|
||||
self.swap_chain.Present(0, DXGI_PRESENT(0))
|
||||
};
|
||||
if present_result.is_err() {
|
||||
verbose_log(&format!(
|
||||
"opengl interop: dummy D3D11 Present flush failed hr={:#010x}",
|
||||
present_result.0 as u32
|
||||
));
|
||||
return false;
|
||||
}
|
||||
|
||||
if blit_err != GL_NO_ERROR {
|
||||
verbose_log(&format!(
|
||||
"opengl interop: glBlitFramebuffer raised GL error 0x{blit_err:04X}"
|
||||
));
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GlInteropState {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
if !self.dx_object.is_null() {
|
||||
let _ = (self.procs.unregister_object)(self.dx_device, self.dx_object);
|
||||
}
|
||||
if self.draw_fbo != 0 {
|
||||
(self.procs.delete_framebuffers)(1, &self.draw_fbo);
|
||||
}
|
||||
if self.gl_texture != 0 {
|
||||
glDeleteTextures(1, &self.gl_texture);
|
||||
}
|
||||
if !self.dx_device.is_null() {
|
||||
let _ = (self.procs.close_device)(self.dx_device);
|
||||
}
|
||||
if !self.dummy_hwnd.is_null() {
|
||||
let _ = DestroyWindow(self.dummy_hwnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn interop_state_for_frame(
|
||||
state: &mut HookState,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Option<&mut GlInteropState> {
|
||||
let recreate = state
|
||||
.gl_interop
|
||||
.as_ref()
|
||||
.map(|interop| !interop.matches(width, height))
|
||||
.unwrap_or(true);
|
||||
if recreate {
|
||||
state.gl_interop = None;
|
||||
match GlInteropState::create(width, height) {
|
||||
Some(interop) => state.gl_interop = Some(interop),
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
state.gl_interop.as_mut()
|
||||
}
|
||||
|
||||
pub(crate) unsafe fn capture_opengl_frame_gpu(
|
||||
state: &mut HookState,
|
||||
hwnd: SysHwnd,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> bool {
|
||||
if gpu_path_disabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(interop) = interop_state_for_frame(state, width, height) else {
|
||||
if !GL_GPU_UNAVAILABLE_LOGGED.swap(true, Ordering::AcqRel) {
|
||||
verbose_log(
|
||||
"opengl interop: WGL_NV_DX_interop2 unavailable or pipeline creation failed",
|
||||
);
|
||||
}
|
||||
latch_disable("interop pipeline creation failed");
|
||||
set_fallback_reason(state, GAME_CAPTURE_FALLBACK_SHARED_TEXTURE_UNSUPPORTED);
|
||||
return false;
|
||||
};
|
||||
|
||||
let shared_handle = interop.shared_handle;
|
||||
let blitted = interop.blit_default_framebuffer();
|
||||
if !blitted {
|
||||
latch_disable("lock/blit failed after successful registration");
|
||||
set_fallback_reason(state, GAME_CAPTURE_FALLBACK_SHARED_TEXTURE_UNSUPPORTED);
|
||||
return false;
|
||||
}
|
||||
|
||||
mark_present(state, GAME_CAPTURE_API_OPENGL);
|
||||
set_capture_flags(state, 0);
|
||||
set_fallback_reason(state, GAME_CAPTURE_FALLBACK_NONE);
|
||||
let published = publish_shared_texture_frame(
|
||||
state,
|
||||
hwnd,
|
||||
width,
|
||||
height,
|
||||
DXGI_FORMAT(DXGI_FORMAT_B8G8R8A8_UNORM.0),
|
||||
shared_handle,
|
||||
);
|
||||
if published {
|
||||
verbose_log(&format!(
|
||||
"opengl interop: published shared-texture frame {width}x{height} (handle 0x{shared_handle:X})"
|
||||
));
|
||||
}
|
||||
published
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
#[cfg(not(target_arch = "aarch64"))]
|
||||
use retour::Function;
|
||||
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
pub(crate) use aarch64_function::Function;
|
||||
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
mod aarch64_function {
|
||||
pub(crate) trait Function: Copy + Sync + 'static {
|
||||
unsafe fn from_ptr(ptr: *const ()) -> Self;
|
||||
fn to_ptr(&self) -> *const ();
|
||||
}
|
||||
|
||||
macro_rules! impl_function {
|
||||
($($arg:ident),*) => {
|
||||
impl<Ret: 'static, $($arg: 'static),*> Function
|
||||
for unsafe extern "system" fn($($arg),*) -> Ret
|
||||
{
|
||||
unsafe fn from_ptr(ptr: *const ()) -> Self {
|
||||
core::mem::transmute(ptr)
|
||||
}
|
||||
fn to_ptr(&self) -> *const () {
|
||||
*self as *const ()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl_function!();
|
||||
impl_function!(A);
|
||||
impl_function!(A, B);
|
||||
impl_function!(A, B, C);
|
||||
impl_function!(A, B, C, D);
|
||||
impl_function!(A, B, C, D, E);
|
||||
impl_function!(A, B, C, D, E, F);
|
||||
}
|
||||
|
||||
pub(crate) struct Detour<T: Function> {
|
||||
inner: Inner<T>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "aarch64"))]
|
||||
enum Inner<T: Function> {
|
||||
Retour(retour::GenericDetour<T>),
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
enum Inner<T: Function> {
|
||||
Aarch64(aarch64::Aarch64Detour<T>),
|
||||
}
|
||||
|
||||
impl<T: Function> Detour<T> {
|
||||
pub(crate) unsafe fn new(target: T, detour: T) -> Result<Self, ()> {
|
||||
#[cfg(not(target_arch = "aarch64"))]
|
||||
{
|
||||
match retour::GenericDetour::<T>::new(target, detour) {
|
||||
Ok(detour) => Ok(Self {
|
||||
inner: Inner::Retour(detour),
|
||||
}),
|
||||
Err(_) => Err(()),
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
{
|
||||
aarch64::Aarch64Detour::<T>::new(target, detour).map(|detour| Self {
|
||||
inner: Inner::Aarch64(detour),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) unsafe fn enable(&self) -> Result<(), ()> {
|
||||
match &self.inner {
|
||||
#[cfg(not(target_arch = "aarch64"))]
|
||||
Inner::Retour(detour) => detour.enable().map_err(|_| ()),
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
Inner::Aarch64(detour) => detour.enable(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn trampoline_fn(&self) -> T {
|
||||
match &self.inner {
|
||||
#[cfg(not(target_arch = "aarch64"))]
|
||||
Inner::Retour(detour) => unsafe {
|
||||
T::from_ptr(detour.trampoline() as *const () as *const ())
|
||||
},
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
Inner::Aarch64(detour) => detour.trampoline_fn(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
mod aarch64 {
|
||||
|
||||
use super::Function;
|
||||
use crate::arm64_reloc::{
|
||||
NOP, STOLEN_BYTES, append_abs_branch, assemble_trampoline, import_thunk_target,
|
||||
};
|
||||
use core::marker::PhantomData;
|
||||
use std::ptr;
|
||||
use windows_sys::Win32::System::{
|
||||
Diagnostics::Debug::FlushInstructionCache,
|
||||
Memory::{
|
||||
MEM_COMMIT, MEM_RELEASE, MEM_RESERVE, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE,
|
||||
PAGE_PROTECTION_FLAGS, VirtualAlloc, VirtualFree, VirtualProtect,
|
||||
},
|
||||
Threading::GetCurrentProcess,
|
||||
};
|
||||
|
||||
const TRAMPOLINE_CAP: usize = 256;
|
||||
|
||||
pub(super) struct Aarch64Detour<T: Function> {
|
||||
target: *mut u8,
|
||||
detour: *const u8,
|
||||
trampoline: *mut u8,
|
||||
original_prologue: [u8; STOLEN_BYTES],
|
||||
enabled: std::cell::Cell<bool>,
|
||||
_marker: PhantomData<T>,
|
||||
}
|
||||
|
||||
unsafe impl<T: Function> Send for Aarch64Detour<T> {}
|
||||
unsafe impl<T: Function> Sync for Aarch64Detour<T> {}
|
||||
|
||||
impl<T: Function> Aarch64Detour<T> {
|
||||
pub(super) unsafe fn new(target: T, detour: T) -> Result<Self, ()> {
|
||||
let target_ptr = target.to_ptr() as *mut u8;
|
||||
let detour_ptr = detour.to_ptr() as *const u8;
|
||||
if target_ptr.is_null() || detour_ptr.is_null() {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
let mut original = [0u8; STOLEN_BYTES];
|
||||
ptr::copy_nonoverlapping(target_ptr, original.as_mut_ptr(), STOLEN_BYTES);
|
||||
|
||||
let trampoline = VirtualAlloc(
|
||||
ptr::null(),
|
||||
TRAMPOLINE_CAP,
|
||||
MEM_COMMIT | MEM_RESERVE,
|
||||
PAGE_EXECUTE_READWRITE,
|
||||
) as *mut u8;
|
||||
if trampoline.is_null() {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
let trampoline_addr = trampoline as u64;
|
||||
let resume = target_ptr as u64 + STOLEN_BYTES as u64;
|
||||
let body =
|
||||
match assemble_trampoline(&original, target_ptr as u64, trampoline_addr, resume) {
|
||||
Some(body) => body,
|
||||
None => match import_thunk_target(&original, target_ptr as u64) {
|
||||
Some(target) => {
|
||||
let mut body = Vec::new();
|
||||
append_abs_branch(&mut body, target, false);
|
||||
body
|
||||
}
|
||||
None => {
|
||||
VirtualFree(trampoline.cast(), 0, MEM_RELEASE);
|
||||
return Err(());
|
||||
}
|
||||
},
|
||||
};
|
||||
if body.len() > TRAMPOLINE_CAP {
|
||||
VirtualFree(trampoline.cast(), 0, MEM_RELEASE);
|
||||
return Err(());
|
||||
}
|
||||
ptr::copy_nonoverlapping(body.as_ptr(), trampoline, body.len());
|
||||
|
||||
let mut old = 0 as PAGE_PROTECTION_FLAGS;
|
||||
VirtualProtect(
|
||||
trampoline.cast(),
|
||||
TRAMPOLINE_CAP,
|
||||
PAGE_EXECUTE_READ,
|
||||
&mut old,
|
||||
);
|
||||
FlushInstructionCache(GetCurrentProcess(), trampoline.cast(), TRAMPOLINE_CAP);
|
||||
|
||||
Ok(Self {
|
||||
target: target_ptr,
|
||||
detour: detour_ptr,
|
||||
trampoline,
|
||||
original_prologue: original,
|
||||
enabled: std::cell::Cell::new(false),
|
||||
_marker: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) unsafe fn enable(&self) -> Result<(), ()> {
|
||||
if self.enabled.get() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut patch = Vec::new();
|
||||
append_abs_branch(&mut patch, self.detour as u64, false);
|
||||
if patch.len() > STOLEN_BYTES {
|
||||
return Err(());
|
||||
}
|
||||
while patch.len() < STOLEN_BYTES {
|
||||
patch.extend_from_slice(&NOP.to_le_bytes());
|
||||
}
|
||||
|
||||
let mut old = 0 as PAGE_PROTECTION_FLAGS;
|
||||
if VirtualProtect(
|
||||
self.target.cast(),
|
||||
STOLEN_BYTES,
|
||||
PAGE_EXECUTE_READWRITE,
|
||||
&mut old,
|
||||
) == 0
|
||||
{
|
||||
return Err(());
|
||||
}
|
||||
ptr::copy_nonoverlapping(patch.as_ptr(), self.target, STOLEN_BYTES);
|
||||
let mut restore = 0 as PAGE_PROTECTION_FLAGS;
|
||||
VirtualProtect(self.target.cast(), STOLEN_BYTES, old, &mut restore);
|
||||
FlushInstructionCache(GetCurrentProcess(), self.target.cast(), STOLEN_BYTES);
|
||||
self.enabled.set(true);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
unsafe fn disable(&self) {
|
||||
if !self.enabled.get() {
|
||||
return;
|
||||
}
|
||||
let mut old = 0 as PAGE_PROTECTION_FLAGS;
|
||||
if VirtualProtect(
|
||||
self.target.cast(),
|
||||
STOLEN_BYTES,
|
||||
PAGE_EXECUTE_READWRITE,
|
||||
&mut old,
|
||||
) != 0
|
||||
{
|
||||
ptr::copy_nonoverlapping(
|
||||
self.original_prologue.as_ptr(),
|
||||
self.target,
|
||||
STOLEN_BYTES,
|
||||
);
|
||||
let mut restore = 0 as PAGE_PROTECTION_FLAGS;
|
||||
VirtualProtect(self.target.cast(), STOLEN_BYTES, old, &mut restore);
|
||||
FlushInstructionCache(GetCurrentProcess(), self.target.cast(), STOLEN_BYTES);
|
||||
}
|
||||
self.enabled.set(false);
|
||||
}
|
||||
|
||||
pub(super) fn trampoline_fn(&self) -> T {
|
||||
unsafe { T::from_ptr(self.trampoline as *const ()) }
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Function> Drop for Aarch64Detour<T> {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
self.disable();
|
||||
if !self.trampoline.is_null() {
|
||||
VirtualFree(self.trampoline.cast(), 0, MEM_RELEASE);
|
||||
self.trampoline = ptr::null_mut();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,412 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[path = "../src/arm64_reloc.rs"]
|
||||
mod arm64_reloc;
|
||||
|
||||
use arm64_reloc::*;
|
||||
|
||||
fn adr(rd: u32, imm21: i32) -> u32 {
|
||||
let raw = (imm21 as u32) & 0x1F_FFFF;
|
||||
let immlo = (raw & 0x3) << 29;
|
||||
let immhi = ((raw >> 2) & 0x7FFFF) << 5;
|
||||
0x1000_0000 | immlo | immhi | (rd & 0x1F)
|
||||
}
|
||||
fn adrp(rd: u32, imm21: i32) -> u32 {
|
||||
let raw = (imm21 as u32) & 0x1F_FFFF;
|
||||
let immlo = (raw & 0x3) << 29;
|
||||
let immhi = ((raw >> 2) & 0x7FFFF) << 5;
|
||||
0x9000_0000 | immlo | immhi | (rd & 0x1F)
|
||||
}
|
||||
fn b(off_words: i32) -> u32 {
|
||||
0x1400_0000 | ((off_words as u32) & 0x03FF_FFFF)
|
||||
}
|
||||
fn bl(off_words: i32) -> u32 {
|
||||
0x9400_0000 | ((off_words as u32) & 0x03FF_FFFF)
|
||||
}
|
||||
fn bcond(cond: u32, off_words: i32) -> u32 {
|
||||
0x5400_0000 | (((off_words as u32) & 0x7FFFF) << 5) | (cond & 0xF)
|
||||
}
|
||||
fn cbz(rt: u32, off_words: i32) -> u32 {
|
||||
0xB400_0000 | (((off_words as u32) & 0x7FFFF) << 5) | (rt & 0x1F)
|
||||
}
|
||||
fn tbz(rt: u32, bit: u32, off_words: i32) -> u32 {
|
||||
let b5 = (bit & 0x20) << (31 - 5);
|
||||
let b40 = (bit & 0x1F) << 19;
|
||||
0x3600_0000 | b5 | b40 | (((off_words as u32) & 0x3FFF) << 5) | (rt & 0x1F)
|
||||
}
|
||||
fn ldr_lit(rt: u32, off_words: i32) -> u32 {
|
||||
0x5800_0000 | (((off_words as u32) & 0x7FFFF) << 5) | (rt & 0x1F)
|
||||
}
|
||||
|
||||
fn adr_target(insn: u32, pc: u64, page: bool) -> u64 {
|
||||
let immlo = ((insn >> 29) & 0x3) as i64;
|
||||
let immhi = ((insn >> 5) & 0x7FFFF) as i64;
|
||||
let raw = (immhi << 2) | immlo;
|
||||
let imm21 = (raw << 43) >> 43;
|
||||
if page {
|
||||
((pc & !0xFFF) as i64 + imm21 * 4096) as u64
|
||||
} else {
|
||||
(pc as i64 + imm21) as u64
|
||||
}
|
||||
}
|
||||
fn imm19_target(insn: u32, pc: u64) -> u64 {
|
||||
let imm19 = ((insn >> 5) & 0x7FFFF) as i64;
|
||||
let off = ((imm19 << 45) >> 45) * 4;
|
||||
(pc as i64 + off) as u64
|
||||
}
|
||||
fn imm14_target(insn: u32, pc: u64) -> u64 {
|
||||
let imm14 = ((insn >> 5) & 0x3FFF) as i64;
|
||||
let off = ((imm14 << 50) >> 50) * 4;
|
||||
(pc as i64 + off) as u64
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_pc_relative_copied_verbatim() {
|
||||
let stp = 0xA9BF_7BFD;
|
||||
assert_eq!(relocate_instruction(stp, 0x1000, 0x9000), Some(stp));
|
||||
let mov = 0xAA01_03E0;
|
||||
assert_eq!(relocate_instruction(mov, 0x1000, 0x9000), Some(mov));
|
||||
let sub = 0xD100_83FF;
|
||||
assert_eq!(relocate_instruction(sub, 0x1000, 0x9000), Some(sub));
|
||||
let mov_fp_sp = 0x9100_03FD;
|
||||
assert_eq!(
|
||||
relocate_instruction(mov_fp_sp, 0x1000, 0x9000),
|
||||
Some(mov_fp_sp)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adr_relocates_to_same_target() {
|
||||
let src_pc = 0x140_0010_0000u64;
|
||||
let dst_pc = 0x140_0010_8000u64;
|
||||
let insn = adr(0, 0x4000);
|
||||
let original = adr_target(insn, src_pc, false);
|
||||
let reloc = relocate_instruction(insn, src_pc, dst_pc).expect("in range");
|
||||
assert_eq!(adr_target(reloc, dst_pc, false), original);
|
||||
assert_eq!(reloc & 0x1F, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adr_negative_offset() {
|
||||
let src_pc = 0x140_0010_0000u64;
|
||||
let dst_pc = 0x140_0010_0010u64;
|
||||
let insn = adr(5, -0x100);
|
||||
let original = adr_target(insn, src_pc, false);
|
||||
let reloc = relocate_instruction(insn, src_pc, dst_pc).expect("in range");
|
||||
assert_eq!(adr_target(reloc, dst_pc, false), original);
|
||||
assert_eq!(reloc & 0x1F, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adr_out_of_range_refused() {
|
||||
let src_pc = 0x0000_0000_0000u64;
|
||||
let dst_pc = 0x0000_0080_0000u64;
|
||||
let insn = adr(0, 0x1000);
|
||||
assert_eq!(relocate_instruction(insn, src_pc, dst_pc), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adrp_relocates_to_same_page() {
|
||||
let src_pc = 0x140_0010_0000u64;
|
||||
let dst_pc = 0x140_0030_0000u64;
|
||||
let insn = adrp(9, 0x10);
|
||||
let original = adr_target(insn, src_pc, true);
|
||||
let reloc = relocate_instruction(insn, src_pc, dst_pc).expect("in range");
|
||||
assert_eq!(adr_target(reloc, dst_pc, true), original);
|
||||
assert_eq!(reloc & 0x1F, 9);
|
||||
assert_eq!(reloc & 0x9F00_0000, 0x9000_0000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adrp_negative() {
|
||||
let src_pc = 0x140_0090_0000u64;
|
||||
let dst_pc = 0x140_0050_0000u64;
|
||||
let insn = adrp(1, -0x20);
|
||||
let original = adr_target(insn, src_pc, true);
|
||||
let reloc = relocate_instruction(insn, src_pc, dst_pc).expect("in range");
|
||||
assert_eq!(adr_target(reloc, dst_pc, true), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bcond_relocates() {
|
||||
let src_pc = 0x10_0000u64;
|
||||
let dst_pc = 0x12_0000u64;
|
||||
let insn = bcond(0x0, 0x40);
|
||||
let original = imm19_target(insn, src_pc);
|
||||
let reloc = relocate_instruction(insn, src_pc, dst_pc).expect("in range");
|
||||
assert_eq!(imm19_target(reloc, dst_pc), original);
|
||||
assert_eq!(reloc & 0xF, 0x0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bcond_out_of_range_refused() {
|
||||
let src_pc = 0x0u64;
|
||||
let dst_pc = 0x20_0000u64;
|
||||
let insn = bcond(0x1, 0x10);
|
||||
assert_eq!(relocate_instruction(insn, src_pc, dst_pc), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cbz_relocates() {
|
||||
let src_pc = 0x10_0000u64;
|
||||
let dst_pc = 0x10_8000u64;
|
||||
let insn = cbz(3, -0x20);
|
||||
let original = imm19_target(insn, src_pc);
|
||||
let reloc = relocate_instruction(insn, src_pc, dst_pc).expect("in range");
|
||||
assert_eq!(imm19_target(reloc, dst_pc), original);
|
||||
assert_eq!(reloc & 0x1F, 3);
|
||||
assert_eq!(reloc & 0x8000_0000, 0x8000_0000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tbz_relocates() {
|
||||
let src_pc = 0x10_0000u64;
|
||||
let dst_pc = 0x10_1000u64;
|
||||
let insn = tbz(7, 5, 0x10);
|
||||
let original = imm14_target(insn, src_pc);
|
||||
let reloc = relocate_instruction(insn, src_pc, dst_pc).expect("in range");
|
||||
assert_eq!(imm14_target(reloc, dst_pc), original);
|
||||
assert_eq!(reloc & 0x1F, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tbz_out_of_range_refused() {
|
||||
let src_pc = 0x0u64;
|
||||
let dst_pc = 0x1_0000u64;
|
||||
let insn = tbz(0, 1, 0x8);
|
||||
assert_eq!(relocate_instruction(insn, src_pc, dst_pc), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ldr_literal_relocates() {
|
||||
let src_pc = 0x20_0000u64;
|
||||
let dst_pc = 0x20_4000u64;
|
||||
let insn = ldr_lit(2, 0x100);
|
||||
let original = imm19_target(insn, src_pc);
|
||||
let reloc = relocate_instruction(insn, src_pc, dst_pc).expect("in range");
|
||||
assert_eq!(imm19_target(reloc, dst_pc), original);
|
||||
assert_eq!(reloc & 0x1F, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_b_relocation_in_range() {
|
||||
let src_pc = 0x10_0000u64;
|
||||
let dst_pc = 0x14_0000u64;
|
||||
let insn = b(0x100);
|
||||
let target = branch_target(insn, src_pc).unwrap();
|
||||
let reloc = relocate_instruction(insn, src_pc, dst_pc).expect("in range");
|
||||
assert_eq!(branch_target(reloc, dst_pc).unwrap(), target);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_target_decode() {
|
||||
let pc = 0x10_0000u64;
|
||||
assert_eq!(branch_target(b(4), pc), Some(pc + 16));
|
||||
assert_eq!(branch_target(b(-4), pc), Some(pc - 16));
|
||||
assert_eq!(branch_target(bl(1), pc), Some(pc + 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abs_branch_encoding() {
|
||||
let mut bytes = Vec::new();
|
||||
append_abs_branch(&mut bytes, 0x1234_5678_9ABC_DEF0, false);
|
||||
assert_eq!(bytes.len(), 16);
|
||||
assert_eq!(
|
||||
u32::from_le_bytes(bytes[0..4].try_into().unwrap()),
|
||||
LDR_X16_PC8
|
||||
);
|
||||
assert_eq!(u32::from_le_bytes(bytes[4..8].try_into().unwrap()), BR_X16);
|
||||
assert_eq!(
|
||||
u64::from_le_bytes(bytes[8..16].try_into().unwrap()),
|
||||
0x1234_5678_9ABC_DEF0
|
||||
);
|
||||
let mut linked = Vec::new();
|
||||
append_abs_branch(&mut linked, 0xDEAD_BEEF, true);
|
||||
assert_eq!(
|
||||
u32::from_le_bytes(linked[4..8].try_into().unwrap()),
|
||||
BLR_X16
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_branches() {
|
||||
assert!(is_b(b(1)));
|
||||
assert!(!is_bl(b(1)));
|
||||
assert!(is_bl(bl(1)));
|
||||
assert!(!is_b(bl(1)));
|
||||
assert!(needs_absolute_island(b(1)));
|
||||
assert!(needs_absolute_island(bl(1)));
|
||||
assert!(!needs_absolute_island(adr(0, 1)));
|
||||
assert!(!needs_absolute_island(NOP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assemble_trampoline_relocates_prologue() {
|
||||
let stp = 0xA9BF_7BFDu32;
|
||||
let mov = 0x9100_03FDu32;
|
||||
let adrp_insn = adrp(8, 0x20);
|
||||
let mut prologue = Vec::new();
|
||||
for insn in [stp, mov, adrp_insn, NOP] {
|
||||
prologue.extend_from_slice(&insn.to_le_bytes());
|
||||
}
|
||||
let src_base = 0x140_0010_0000u64;
|
||||
let dst_base = 0x140_0030_0000u64;
|
||||
let resume = src_base + STOLEN_BYTES as u64;
|
||||
let body = assemble_trampoline(&prologue, src_base, dst_base, resume).expect("relocatable");
|
||||
assert_eq!(body.len(), 32);
|
||||
assert_eq!(u32::from_le_bytes(body[0..4].try_into().unwrap()), stp);
|
||||
assert_eq!(u32::from_le_bytes(body[4..8].try_into().unwrap()), mov);
|
||||
assert_eq!(u32::from_le_bytes(body[12..16].try_into().unwrap()), NOP);
|
||||
let orig_target = adr_target(adrp_insn, src_base + 8, true);
|
||||
let reloc_adrp = u32::from_le_bytes(body[8..12].try_into().unwrap());
|
||||
assert_eq!(adr_target(reloc_adrp, dst_base + 8, true), orig_target);
|
||||
assert_eq!(
|
||||
u32::from_le_bytes(body[16..20].try_into().unwrap()),
|
||||
LDR_X16_PC8
|
||||
);
|
||||
assert_eq!(u32::from_le_bytes(body[20..24].try_into().unwrap()), BR_X16);
|
||||
assert_eq!(u64::from_le_bytes(body[24..32].try_into().unwrap()), resume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assemble_trampoline_promotes_leading_branch() {
|
||||
let lead_b = b(0x4000);
|
||||
let mut prologue = Vec::new();
|
||||
for insn in [lead_b, NOP, NOP, NOP] {
|
||||
prologue.extend_from_slice(&insn.to_le_bytes());
|
||||
}
|
||||
let src_base = 0x140_0010_0000u64;
|
||||
let dst_base = 0x0000_7000_0000u64;
|
||||
let resume = src_base + STOLEN_BYTES as u64;
|
||||
let body = assemble_trampoline(&prologue, src_base, dst_base, resume).expect("island path");
|
||||
assert_eq!(body.len(), 48);
|
||||
let first = u32::from_le_bytes(body[0..4].try_into().unwrap());
|
||||
assert!(is_b(first));
|
||||
let island_addr = branch_target(first, dst_base).unwrap();
|
||||
assert_eq!(island_addr, dst_base + 16 + 16);
|
||||
let original_b_target = branch_target(lead_b, src_base).unwrap();
|
||||
assert_eq!(
|
||||
u32::from_le_bytes(body[32..36].try_into().unwrap()),
|
||||
LDR_X16_PC8
|
||||
);
|
||||
assert_eq!(u32::from_le_bytes(body[36..40].try_into().unwrap()), BR_X16);
|
||||
assert_eq!(
|
||||
u64::from_le_bytes(body[40..48].try_into().unwrap()),
|
||||
original_b_target
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assemble_trampoline_promotes_leading_bl_with_blr() {
|
||||
let lead_bl = bl(0x100);
|
||||
let mut prologue = Vec::new();
|
||||
for insn in [lead_bl, NOP, NOP, NOP] {
|
||||
prologue.extend_from_slice(&insn.to_le_bytes());
|
||||
}
|
||||
let src_base = 0x140_0010_0000u64;
|
||||
let dst_base = 0x0000_7000_0000u64;
|
||||
let resume = src_base + STOLEN_BYTES as u64;
|
||||
let body = assemble_trampoline(&prologue, src_base, dst_base, resume).expect("island path");
|
||||
assert_eq!(body.len(), 48);
|
||||
let first = u32::from_le_bytes(body[0..4].try_into().unwrap());
|
||||
assert!(is_bl(first));
|
||||
let original = branch_target(lead_bl, src_base).unwrap();
|
||||
assert_eq!(
|
||||
u32::from_le_bytes(body[36..40].try_into().unwrap()),
|
||||
BLR_X16
|
||||
);
|
||||
assert_eq!(
|
||||
u64::from_le_bytes(body[40..48].try_into().unwrap()),
|
||||
original
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assemble_trampoline_two_islands() {
|
||||
let b0 = b(0x10);
|
||||
let bl1 = bl(0x20);
|
||||
let mut prologue = Vec::new();
|
||||
for insn in [b0, NOP, bl1, NOP] {
|
||||
prologue.extend_from_slice(&insn.to_le_bytes());
|
||||
}
|
||||
let src_base = 0x140_0010_0000u64;
|
||||
let dst_base = 0x0000_7000_0000u64;
|
||||
let resume = src_base + STOLEN_BYTES as u64;
|
||||
let body = assemble_trampoline(&prologue, src_base, dst_base, resume).expect("islands");
|
||||
assert_eq!(body.len(), 64);
|
||||
let first = u32::from_le_bytes(body[0..4].try_into().unwrap());
|
||||
let third = u32::from_le_bytes(body[8..12].try_into().unwrap());
|
||||
let island0 = branch_target(first, dst_base).unwrap();
|
||||
let island1 = branch_target(third, dst_base + 8).unwrap();
|
||||
assert_eq!(island0, dst_base + 32);
|
||||
assert_eq!(island1, dst_base + 48);
|
||||
assert_eq!(
|
||||
u64::from_le_bytes(body[40..48].try_into().unwrap()),
|
||||
branch_target(b0, src_base).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
u64::from_le_bytes(body[56..64].try_into().unwrap()),
|
||||
branch_target(bl1, src_base + 8).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assemble_trampoline_refuses_unrelocatable_narrow_branch() {
|
||||
let cond = bcond(0x2, 0x10);
|
||||
let mut prologue = Vec::new();
|
||||
for insn in [NOP, cond, NOP, NOP] {
|
||||
prologue.extend_from_slice(&insn.to_le_bytes());
|
||||
}
|
||||
let src_base = 0x0u64;
|
||||
let dst_base = 0x0000_0080_0000u64;
|
||||
let resume = src_base + STOLEN_BYTES as u64;
|
||||
assert_eq!(
|
||||
assemble_trampoline(&prologue, src_base, dst_base, resume),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relocated_prologue_matches_assemble_prefix() {
|
||||
let stp = 0xA9BF_7BFDu32;
|
||||
let mut prologue = Vec::new();
|
||||
for insn in [stp, NOP, NOP, NOP] {
|
||||
prologue.extend_from_slice(&insn.to_le_bytes());
|
||||
}
|
||||
let src_base = 0x140_0010_0000u64;
|
||||
let dst_base = 0x140_0030_0000u64;
|
||||
let pro = relocated_prologue(&prologue, src_base, dst_base).expect("ok");
|
||||
assert_eq!(pro.len(), 16);
|
||||
assert_eq!(u32::from_le_bytes(pro[0..4].try_into().unwrap()), stp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn island_holds_absolute_branch_to_original_target() {
|
||||
let lead_bl = bl(0x100);
|
||||
let src_base = 0x140_0010_0000u64;
|
||||
let original_target = branch_target(lead_bl, src_base).unwrap();
|
||||
let island = island_for_branch(lead_bl, src_base).expect("island");
|
||||
assert_eq!(island.len(), 16);
|
||||
assert_eq!(
|
||||
u32::from_le_bytes(island[4..8].try_into().unwrap()),
|
||||
BLR_X16
|
||||
);
|
||||
assert_eq!(
|
||||
u64::from_le_bytes(island[8..16].try_into().unwrap()),
|
||||
original_target
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stolen_bytes_is_four_instructions() {
|
||||
assert_eq!(STOLEN_BYTES, 16);
|
||||
assert_eq!(STOLEN_BYTES % 4, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_imm26_rejects_unaligned_and_overflow() {
|
||||
assert_eq!(encode_imm26(3), None);
|
||||
assert_eq!(encode_imm26(4), Some(1));
|
||||
assert_eq!(encode_imm26(-4), Some((-1i32 as u32) & 0x03FF_FFFF));
|
||||
assert_eq!(encode_imm26(1 << 27), None);
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {EventEmitter} from 'node:events';
|
||||
|
||||
export type GameCaptureInjectionMethod = 'auto' | 'remote-thread' | 'set-windows-hook';
|
||||
|
||||
export type CaptureStrategyName = 'game-hook' | 'wgc' | 'dxgi-duplication' | 'window-gdi';
|
||||
|
||||
export interface ScreenCaptureRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ScreenCaptureOptions {
|
||||
sourceId: string;
|
||||
sourceKind: 'screen' | 'window' | 'game';
|
||||
width?: number;
|
||||
height?: number;
|
||||
frameRate?: number;
|
||||
hookDllPath?: string;
|
||||
hookDllPathX86?: string;
|
||||
injectionMethod?: GameCaptureInjectionMethod;
|
||||
captureId?: string;
|
||||
colorRange?: 'full' | 'limited';
|
||||
colorSpace?: 'rec709' | 'srgb';
|
||||
showCursorClicks?: boolean;
|
||||
captureRect?: ScreenCaptureRect;
|
||||
frameSinkHandle?: unknown;
|
||||
nativeFrameSinkRequired?: boolean;
|
||||
}
|
||||
|
||||
export interface ScreenCaptureStartResult {
|
||||
width: number;
|
||||
height: number;
|
||||
frameRate: number;
|
||||
pixelFormat: 'nv12' | 'bgra';
|
||||
}
|
||||
|
||||
export interface ScreenCaptureSourceDescriptor {
|
||||
kind: 'screen' | 'window' | 'game';
|
||||
id: string;
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
targetPid?: number;
|
||||
}
|
||||
|
||||
export interface AvailabilityInfo {
|
||||
available: boolean;
|
||||
backend: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CaptureDiagnostics {
|
||||
state: number;
|
||||
apiType: number;
|
||||
transport: number;
|
||||
fallbackReason: number;
|
||||
captureFlags: number;
|
||||
width: number;
|
||||
height: number;
|
||||
dxgiFormat: number;
|
||||
frameCounter: number;
|
||||
droppedFrameCounter: number;
|
||||
lastPresentTimestampUs: number;
|
||||
lastError: number;
|
||||
requestedInjectionMethod: GameCaptureInjectionMethod;
|
||||
injectionMethod: 'remote-thread' | 'set-windows-hook';
|
||||
activeStrategy: CaptureStrategyName;
|
||||
lastFallbackReason: string;
|
||||
startOptions: ScreenCaptureStartOptionsDiagnostics;
|
||||
frameSinkAccepted: number;
|
||||
frameSinkCoalesced: number;
|
||||
frameSinkRejected: number;
|
||||
mediaFramesDroppedWithoutSink: number;
|
||||
cpuFallbackFramesDropped: number;
|
||||
}
|
||||
|
||||
export interface ScreenCaptureStartOptionsDiagnostics {
|
||||
colorRange?: 'full' | 'limited';
|
||||
colorSpace?: 'rec709' | 'srgb';
|
||||
showCursorClicks?: boolean;
|
||||
captureRect?: ScreenCaptureRect;
|
||||
unsupportedOptions: Array<'showCursorClicks' | 'captureRect' | 'colorRange' | 'colorSpace'>;
|
||||
}
|
||||
|
||||
export interface SharedTextureHandleInfo {
|
||||
handle: bigint;
|
||||
width: number;
|
||||
height: number;
|
||||
dxgiFormat: number;
|
||||
timestampUs: number;
|
||||
}
|
||||
|
||||
export interface EncoderAttachDiagnostics {
|
||||
attached: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
capacity: number;
|
||||
framesSubmitted: number;
|
||||
framesDropped: number;
|
||||
ringFullEvents: number;
|
||||
failedBlits: number;
|
||||
}
|
||||
|
||||
export interface FrameSinkDiagnostics {
|
||||
accepted: number;
|
||||
coalesced: number;
|
||||
rejected: number;
|
||||
mediaFramesDroppedWithoutSink: number;
|
||||
cpuFallbackFramesDropped: number;
|
||||
}
|
||||
|
||||
export interface VulkanLayerRegistrationState {
|
||||
registered: boolean;
|
||||
manifestExists: boolean;
|
||||
dllExists: boolean;
|
||||
manifestPath: string | null;
|
||||
}
|
||||
|
||||
export declare interface ScreenCapture {
|
||||
on(event: 'error', listener: (err: Error) => void): this;
|
||||
on(event: 'closed', listener: () => void): this;
|
||||
on(event: 'stalled', listener: (message?: string) => void): this;
|
||||
on(event: 'diagnostic', listener: (message?: string) => void): this;
|
||||
on(event: string | symbol, listener: (...args: Array<unknown>) => void): this;
|
||||
|
||||
off(event: 'error', listener: (err: Error) => void): this;
|
||||
off(event: 'closed', listener: () => void): this;
|
||||
off(event: 'stalled', listener: (message?: string) => void): this;
|
||||
off(event: 'diagnostic', listener: (message?: string) => void): this;
|
||||
off(event: string | symbol, listener: (...args: Array<unknown>) => void): this;
|
||||
|
||||
emit(event: 'error', err: Error): boolean;
|
||||
emit(event: 'closed'): boolean;
|
||||
emit(event: 'stalled', message?: string): boolean;
|
||||
emit(event: 'diagnostic', message?: string): boolean;
|
||||
}
|
||||
|
||||
export declare class ScreenCapture extends EventEmitter {
|
||||
constructor(options?: ScreenCaptureOptions);
|
||||
start(): Promise<ScreenCaptureStartResult | undefined>;
|
||||
stop(): Promise<void>;
|
||||
getDiagnostics(): CaptureDiagnostics | null;
|
||||
getSharedTextureHandle(): SharedTextureHandleInfo | null;
|
||||
attachEncoder(width: number, height: number, frameRate?: number): void;
|
||||
detachEncoder(): void;
|
||||
isEncoderAttached(): boolean;
|
||||
encoderRingFullCount(): number;
|
||||
getEncoderAttachDiagnostics(): EncoderAttachDiagnostics | null;
|
||||
getFrameSinkDiagnostics(): FrameSinkDiagnostics;
|
||||
}
|
||||
|
||||
export declare function isSupported(): boolean;
|
||||
export declare function getAvailability(): AvailabilityInfo;
|
||||
export declare function listSources(): Promise<Array<ScreenCaptureSourceDescriptor>>;
|
||||
export declare function resolveGameHookPath(): string | null;
|
||||
export declare function resolveGameHookPathX86(): string | null;
|
||||
export declare function resolveVulkanLayerManifestPath(): string | null;
|
||||
export declare function registerVulkanLayerManifest(): boolean;
|
||||
export declare function unregisterVulkanLayerManifest(): boolean;
|
||||
export declare function getVulkanLayerRegistrationState(): VulkanLayerRegistrationState;
|
||||
export declare function parseFallbackRecommendation(message: string | undefined): CaptureStrategyName | 'none' | null;
|
||||
export declare function elevateGpuSchedulingPriority(processId?: number, priorityClass?: 'high' | 'realtime'): boolean;
|
||||
export declare function restoreGpuSchedulingPriority(processId?: number): boolean;
|
||||
export declare function __setBindingForTests(binding: unknown): void;
|
||||
export declare const loadError: Error | null;
|
||||
@@ -0,0 +1,469 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const {EventEmitter} = require('node:events');
|
||||
const {existsSync} = require('node:fs');
|
||||
const {join, sep} = require('node:path');
|
||||
const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs');
|
||||
|
||||
const MODULE_NAME = '@fluxer/win-game-capture';
|
||||
const WINDOWS_GAME_CAPTURE_MODULE_ENV = 'FLUXER_WINDOWS_GAME_CAPTURE_MODULE_ENABLED';
|
||||
|
||||
function resolveNativeRoot() {
|
||||
const asarSegment = `${sep}app.asar${sep}`;
|
||||
if (!__dirname.includes(asarSegment)) return __dirname;
|
||||
const unpackedDir = __dirname.replace(asarSegment, `${sep}app.asar.unpacked${sep}`);
|
||||
return existsSync(unpackedDir) ? unpackedDir : __dirname;
|
||||
}
|
||||
|
||||
function nativeFileName(arch) {
|
||||
switch (arch) {
|
||||
case 'x64':
|
||||
return 'win-game-capture.win32-x64-msvc.node';
|
||||
case 'arm64':
|
||||
return 'win-game-capture.win32-arm64-msvc.node';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let binding = null;
|
||||
let loadError = null;
|
||||
const nativeRoot = resolveNativeRoot();
|
||||
|
||||
if (process.platform === 'win32' && process.env[WINDOWS_GAME_CAPTURE_MODULE_ENV] !== 'true') {
|
||||
loadError = createNativeLoadError({
|
||||
moduleName: MODULE_NAME,
|
||||
nativeRoot,
|
||||
packageDir: __dirname,
|
||||
reason: 'Windows game capture is disabled in this build',
|
||||
});
|
||||
} else if (process.platform === 'win32') {
|
||||
const fileName = nativeFileName(process.arch);
|
||||
if (!fileName) {
|
||||
loadError = createNativeLoadError({
|
||||
moduleName: MODULE_NAME,
|
||||
nativeRoot,
|
||||
packageDir: __dirname,
|
||||
reason: `unsupported Windows architecture: ${process.arch}`,
|
||||
});
|
||||
} else {
|
||||
const nativePath = join(nativeRoot, fileName);
|
||||
const loaded = loadNativeBinding({
|
||||
moduleName: MODULE_NAME,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir: __dirname,
|
||||
probe: false,
|
||||
});
|
||||
binding = loaded.binding;
|
||||
loadError = loaded.loadError;
|
||||
}
|
||||
} else {
|
||||
loadError = createNativeLoadError({
|
||||
moduleName: MODULE_NAME,
|
||||
nativeRoot,
|
||||
packageDir: __dirname,
|
||||
reason: `not supported on platform ${process.platform}`,
|
||||
});
|
||||
}
|
||||
|
||||
function gameHookFileName(arch) {
|
||||
switch (arch) {
|
||||
case 'x64':
|
||||
return 'fluxer-game-hook.win32-x64-msvc.dll';
|
||||
case 'ia32':
|
||||
return 'fluxer-game-hook.win32-ia32-msvc.dll';
|
||||
case 'arm64':
|
||||
return 'fluxer-game-hook.win32-arm64-msvc.dll';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGameHookPathForArch(arch, root = nativeRoot) {
|
||||
if (process.platform !== 'win32') return null;
|
||||
const fileName = gameHookFileName(arch);
|
||||
if (!fileName) return null;
|
||||
const hookPath = join(root, fileName);
|
||||
return existsSync(hookPath) ? hookPath : null;
|
||||
}
|
||||
|
||||
function resolveGameHookPath(root = nativeRoot) {
|
||||
return resolveGameHookPathForArch(process.arch, root);
|
||||
}
|
||||
|
||||
function resolveGameHookPathX86(root = nativeRoot) {
|
||||
return resolveGameHookPathForArch('ia32', root);
|
||||
}
|
||||
|
||||
function vulkanLayerManifestFileName(arch) {
|
||||
switch (arch) {
|
||||
case 'x64':
|
||||
return 'fluxer-vulkan-layer.win32-x64-msvc.json';
|
||||
case 'ia32':
|
||||
return 'fluxer-vulkan-layer.win32-ia32-msvc.json';
|
||||
case 'arm64':
|
||||
return 'fluxer-vulkan-layer.win32-arm64-msvc.json';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveVulkanLayerManifestPath(root = nativeRoot) {
|
||||
if (process.platform !== 'win32') return null;
|
||||
const fileName = vulkanLayerManifestFileName(process.arch);
|
||||
if (!fileName) return null;
|
||||
const manifestPath = join(root, fileName);
|
||||
return existsSync(manifestPath) ? manifestPath : null;
|
||||
}
|
||||
|
||||
function registerVulkanLayerManifest(root = nativeRoot) {
|
||||
if (!binding?.registerVulkanLayerManifest) return false;
|
||||
const manifestPath = resolveVulkanLayerManifestPath(root);
|
||||
if (!manifestPath) return false;
|
||||
binding.registerVulkanLayerManifest(manifestPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
function unregisterVulkanLayerManifest(root = nativeRoot) {
|
||||
if (!binding?.unregisterVulkanLayerManifest) return false;
|
||||
const manifestPath = resolveVulkanLayerManifestPath(root);
|
||||
if (!manifestPath) return false;
|
||||
try {
|
||||
binding.unregisterVulkanLayerManifest(manifestPath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('[win-game-capture] unregisterVulkanLayerManifest failed:', error?.message || error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getVulkanLayerRegistrationState(root = nativeRoot) {
|
||||
const manifestPath = resolveVulkanLayerManifestPath(root);
|
||||
if (!binding?.getVulkanLayerRegistrationState) {
|
||||
return {registered: false, manifestExists: Boolean(manifestPath), dllExists: false, manifestPath};
|
||||
}
|
||||
try {
|
||||
return binding.getVulkanLayerRegistrationState(manifestPath ?? '');
|
||||
} catch (error) {
|
||||
console.warn('[win-game-capture] getVulkanLayerRegistrationState failed:', error?.message || error);
|
||||
return {registered: false, manifestExists: Boolean(manifestPath), dllExists: false, manifestPath};
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'win32' && binding) {
|
||||
try {
|
||||
registerVulkanLayerManifest();
|
||||
} catch (error) {
|
||||
console.warn('[win-game-capture] registerVulkanLayerManifest failed:', error?.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
class ScreenCapture extends EventEmitter {
|
||||
constructor(options = {}) {
|
||||
super();
|
||||
if (!binding) {
|
||||
throw loadError || new Error(`${MODULE_NAME} binding unavailable`);
|
||||
}
|
||||
this.sourceId = options.sourceId;
|
||||
this.sourceKind = options.sourceKind ?? 'window';
|
||||
this.width = options.width ?? 0;
|
||||
this.height = options.height ?? 0;
|
||||
this.frameRate = options.frameRate ?? 30;
|
||||
this.hookDllPath = options.hookDllPath ?? resolveGameHookPath();
|
||||
this.hookDllPathX86 = options.hookDllPathX86 ?? resolveGameHookPathX86();
|
||||
this.injectionMethod = options.injectionMethod ?? undefined;
|
||||
this.captureId = typeof options.captureId === 'string' ? options.captureId : undefined;
|
||||
this.colorRange = options.colorRange;
|
||||
this.colorSpace = options.colorSpace;
|
||||
this.showCursorClicks = options.showCursorClicks === true;
|
||||
this.captureRect = options.captureRect;
|
||||
this.frameSinkHandle = options.frameSinkHandle;
|
||||
this.nativeFrameSinkRequired = options.nativeFrameSinkRequired === true;
|
||||
this.started = false;
|
||||
this.stopped = false;
|
||||
this.closedEmitted = false;
|
||||
|
||||
this.native = new binding.ScreenCapture();
|
||||
|
||||
this.native.setLifecycleCallback((...lifecycleArgs) => {
|
||||
const [type, message] =
|
||||
lifecycleArgs.length === 1 && Array.isArray(lifecycleArgs[0]) ? lifecycleArgs[0] : lifecycleArgs;
|
||||
if (type === 'stalled') {
|
||||
if (this.stopped) return;
|
||||
this.emit('stalled', message);
|
||||
return;
|
||||
}
|
||||
if (type === 'diagnostic') {
|
||||
if (this.stopped) return;
|
||||
this.emit('diagnostic', message);
|
||||
return;
|
||||
}
|
||||
if (type === 'error') {
|
||||
this.emit('error', new Error(message || 'DXGI capture error'));
|
||||
return;
|
||||
}
|
||||
if (type === 'closed') {
|
||||
if (this.stopped) {
|
||||
this._emitClosedOnce();
|
||||
return;
|
||||
}
|
||||
this.stopped = true;
|
||||
try {
|
||||
this.native.stop();
|
||||
} catch {}
|
||||
this._emitClosedOnce();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.started || this.stopped) return undefined;
|
||||
if (this.sourceKind === 'game' && !this.hookDllPath) {
|
||||
throw new Error(`Game capture hook unavailable for ${process.platform}-${process.arch}`);
|
||||
}
|
||||
this.started = true;
|
||||
try {
|
||||
if (this.frameSinkHandle != null) {
|
||||
if (typeof this.native.setFrameSinkHandle !== 'function') {
|
||||
throw new Error(`${MODULE_NAME} native binding does not support native frame sink handles`);
|
||||
}
|
||||
this.native.setFrameSinkHandle(this.frameSinkHandle);
|
||||
} else if (this.nativeFrameSinkRequired) {
|
||||
throw new Error('Native frame sink handle is required for Windows screen capture');
|
||||
}
|
||||
const result = this.native.start(
|
||||
this.sourceId,
|
||||
this.sourceKind,
|
||||
this.width || undefined,
|
||||
this.height || undefined,
|
||||
this.frameRate || undefined,
|
||||
this.sourceKind === 'game' ? this.hookDllPath : undefined,
|
||||
this.sourceKind === 'game' ? (this.hookDllPathX86 ?? undefined) : undefined,
|
||||
this.sourceKind === 'game' ? (this.injectionMethod ?? undefined) : undefined,
|
||||
this.captureId,
|
||||
{
|
||||
colorRange: this.colorRange,
|
||||
colorSpace: this.colorSpace,
|
||||
showCursorClicks: this.showCursorClicks,
|
||||
captureRect: this.captureRect,
|
||||
},
|
||||
);
|
||||
return result
|
||||
? {
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
frameRate: result.frameRate,
|
||||
pixelFormat: result.pixelFormat,
|
||||
}
|
||||
: undefined;
|
||||
} catch (error) {
|
||||
this.stopped = true;
|
||||
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (this.stopped) return;
|
||||
this.stopped = true;
|
||||
try {
|
||||
this.native.stop();
|
||||
} finally {
|
||||
this._emitClosedOnce();
|
||||
}
|
||||
}
|
||||
|
||||
getDiagnostics() {
|
||||
if (!this.native || typeof this.native.getDiagnostics !== 'function') return null;
|
||||
try {
|
||||
return this.native.getDiagnostics() ?? null;
|
||||
} catch (error) {
|
||||
console.warn('[win-game-capture] getDiagnostics failed:', error?.message || error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getSharedTextureHandle() {
|
||||
if (!this.native || typeof this.native.getSharedTextureHandle !== 'function') return null;
|
||||
try {
|
||||
return this.native.getSharedTextureHandle() ?? null;
|
||||
} catch (error) {
|
||||
console.warn('[win-game-capture] getSharedTextureHandle failed:', error?.message || error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
attachEncoder(width, height) {
|
||||
if (!this.native || typeof this.native.attachEncoder !== 'function') {
|
||||
throw new Error(`${MODULE_NAME} native binding does not support encoder attachment`);
|
||||
}
|
||||
this.native.attachEncoder(width, height);
|
||||
}
|
||||
|
||||
detachEncoder() {
|
||||
if (!this.native || typeof this.native.detachEncoder !== 'function') return;
|
||||
this.native.detachEncoder();
|
||||
}
|
||||
|
||||
isEncoderAttached() {
|
||||
if (!this.native || typeof this.native.isEncoderAttached !== 'function') return false;
|
||||
return Boolean(this.native.isEncoderAttached());
|
||||
}
|
||||
|
||||
encoderRingFullCount() {
|
||||
if (!this.native || typeof this.native.encoderRingFullCount !== 'function') return 0;
|
||||
const count = this.native.encoderRingFullCount();
|
||||
return Number.isFinite(count) && count > 0 ? Math.floor(count) : 0;
|
||||
}
|
||||
|
||||
getEncoderAttachDiagnostics() {
|
||||
if (!this.native || typeof this.native.getEncoderAttachDiagnostics !== 'function') return null;
|
||||
try {
|
||||
return this.native.getEncoderAttachDiagnostics() ?? null;
|
||||
} catch (error) {
|
||||
console.warn('[win-game-capture] getEncoderAttachDiagnostics failed:', error?.message || error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getFrameSinkDiagnostics() {
|
||||
if (!this.native || typeof this.native.getFrameSinkDiagnostics !== 'function') {
|
||||
return {
|
||||
accepted: 0,
|
||||
coalesced: 0,
|
||||
rejected: 0,
|
||||
mediaFramesDroppedWithoutSink: 0,
|
||||
cpuFallbackFramesDropped: 0,
|
||||
};
|
||||
}
|
||||
try {
|
||||
return this.native.getFrameSinkDiagnostics();
|
||||
} catch (error) {
|
||||
console.warn('[win-game-capture] getFrameSinkDiagnostics failed:', error?.message || error);
|
||||
return {
|
||||
accepted: 0,
|
||||
coalesced: 0,
|
||||
rejected: 0,
|
||||
mediaFramesDroppedWithoutSink: 0,
|
||||
cpuFallbackFramesDropped: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
_emitClosedOnce() {
|
||||
if (this.closedEmitted) return;
|
||||
this.closedEmitted = true;
|
||||
queueMicrotask(() => this.emit('closed'));
|
||||
}
|
||||
}
|
||||
|
||||
const FALLBACK_STRATEGY_NAMES = new Set(['game-hook', 'wgc', 'dxgi-duplication', 'window-gdi', 'none']);
|
||||
function parseFallbackRecommendation(message) {
|
||||
if (typeof message !== 'string') return null;
|
||||
const match = message.match(/\[next-strategy=([a-z-]+)\]/);
|
||||
if (!match) return null;
|
||||
const name = match[1];
|
||||
return FALLBACK_STRATEGY_NAMES.has(name) ? name : null;
|
||||
}
|
||||
|
||||
async function listSources() {
|
||||
if (!binding || typeof binding.listSources !== 'function') return [];
|
||||
try {
|
||||
const sources = await binding.listSources();
|
||||
if (!Array.isArray(sources)) return [];
|
||||
return sources
|
||||
.filter((source) => {
|
||||
return (
|
||||
source &&
|
||||
(source.kind === 'screen' || source.kind === 'window' || source.kind === 'game') &&
|
||||
typeof source.id === 'string' &&
|
||||
source.id.length > 0
|
||||
);
|
||||
})
|
||||
.map((source) => ({
|
||||
kind: source.kind,
|
||||
id: source.id,
|
||||
name: typeof source.name === 'string' && source.name.length > 0 ? source.name : source.id,
|
||||
width: Number.isFinite(source.width) ? Math.max(0, Math.floor(source.width)) : 0,
|
||||
height: Number.isFinite(source.height) ? Math.max(0, Math.floor(source.height)) : 0,
|
||||
targetPid: Number.isFinite(source.targetPid) && source.targetPid > 0 ? Math.floor(source.targetPid) : undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn('[win-game-capture] listSources failed:', error?.message || error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function isSupported() {
|
||||
if (!binding) return false;
|
||||
return binding.isSupported();
|
||||
}
|
||||
|
||||
function getAvailability() {
|
||||
if (!binding) {
|
||||
return {available: false, backend: 'windows-game-capture', reason: 'load-failed'};
|
||||
}
|
||||
return binding.getAvailability();
|
||||
}
|
||||
|
||||
function normalizeProcessId(processId) {
|
||||
if (processId === undefined || processId === null) return undefined;
|
||||
if (Number.isInteger(processId) && processId > 0) return processId;
|
||||
throw new TypeError(`Invalid process id: ${processId}`);
|
||||
}
|
||||
|
||||
function normalizePriorityClass(priorityClass) {
|
||||
if (priorityClass === undefined || priorityClass === null) return undefined;
|
||||
const normalized = String(priorityClass).trim().toLowerCase();
|
||||
if (normalized === 'real-time') return 'realtime';
|
||||
if (normalized === 'high' || normalized === 'realtime') return normalized;
|
||||
throw new TypeError(`Invalid GPU scheduling priority class: ${priorityClass}`);
|
||||
}
|
||||
|
||||
function elevateGpuSchedulingPriority(processId, priorityClass) {
|
||||
if (!binding) return false;
|
||||
try {
|
||||
binding.elevateGpuSchedulingPriority(normalizeProcessId(processId), normalizePriorityClass(priorityClass));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('[win-game-capture] elevateGpuSchedulingPriority failed:', error?.message || error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreGpuSchedulingPriority(processId) {
|
||||
if (!binding) return false;
|
||||
try {
|
||||
binding.restoreGpuSchedulingPriority(normalizeProcessId(processId));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('[win-game-capture] restoreGpuSchedulingPriority failed:', error?.message || error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function __setBindingForTests(nextBinding) {
|
||||
binding = nextBinding;
|
||||
loadError = null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isSupported,
|
||||
getAvailability,
|
||||
resolveGameHookPath,
|
||||
resolveGameHookPathX86,
|
||||
resolveVulkanLayerManifestPath,
|
||||
registerVulkanLayerManifest,
|
||||
unregisterVulkanLayerManifest,
|
||||
getVulkanLayerRegistrationState,
|
||||
listSources,
|
||||
ScreenCapture,
|
||||
parseFallbackRecommendation,
|
||||
elevateGpuSchedulingPriority,
|
||||
restoreGpuSchedulingPriority,
|
||||
__setBindingForTests,
|
||||
get loadError() {
|
||||
return loadError;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,839 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {existsSync} from 'node:fs';
|
||||
import {createRequire} from 'node:module';
|
||||
import {afterEach, describe, test} from 'node:test';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const winGameCapture = require('./index.js');
|
||||
|
||||
const startupSupported = winGameCapture.isSupported();
|
||||
const startupAvailability = winGameCapture.getAvailability();
|
||||
const realBindingSkip = startupSupported
|
||||
? false
|
||||
: 'no native binding loaded at startup (binding-less platform / unbuilt addon)';
|
||||
|
||||
const hasBindingHook = typeof winGameCapture.__setBindingForTests === 'function';
|
||||
const injectionSkip = hasBindingHook ? false : 'no __setBindingForTests hook exported';
|
||||
|
||||
if (!hasBindingHook) {
|
||||
console.warn(
|
||||
'[index.test.mjs] __setBindingForTests not present on index.js; ' +
|
||||
'skipping binding-injection tests and running only the no-binding tests. ' +
|
||||
'This should only happen when testing an older or stripped loader.',
|
||||
);
|
||||
}
|
||||
|
||||
function makeFakeBinding() {
|
||||
const calls = [];
|
||||
const frameSinkHandleCalls = [];
|
||||
const priorityCalls = [];
|
||||
const natives = [];
|
||||
const diagnostics = {
|
||||
state: 1,
|
||||
apiType: 5,
|
||||
transport: 0,
|
||||
fallbackReason: 0,
|
||||
captureFlags: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
dxgiFormat: 87,
|
||||
frameCounter: 42,
|
||||
droppedFrameCounter: 0,
|
||||
lastPresentTimestampUs: 123456,
|
||||
lastError: 0,
|
||||
requestedInjectionMethod: 'auto',
|
||||
injectionMethod: 'remote-thread',
|
||||
activeStrategy: 'game-hook',
|
||||
lastFallbackReason: '',
|
||||
startOptions: {
|
||||
colorRange: 'full',
|
||||
colorSpace: 'rec709',
|
||||
showCursorClicks: true,
|
||||
captureRect: {x: 10, y: 20, width: 300, height: 200},
|
||||
unsupportedOptions: ['showCursorClicks', 'captureRect', 'colorRange', 'colorSpace'],
|
||||
},
|
||||
frameSinkAccepted: 0,
|
||||
frameSinkCoalesced: 0,
|
||||
frameSinkRejected: 0,
|
||||
mediaFramesDroppedWithoutSink: 0,
|
||||
cpuFallbackFramesDropped: 0,
|
||||
};
|
||||
const encoderDiagnostics = {
|
||||
attached: false,
|
||||
width: 0,
|
||||
height: 0,
|
||||
capacity: 0,
|
||||
framesSubmitted: 0,
|
||||
framesDropped: 0,
|
||||
ringFullEvents: 0,
|
||||
failedBlits: 0,
|
||||
};
|
||||
const frameSinkDiagnostics = {
|
||||
accepted: 0,
|
||||
coalesced: 0,
|
||||
rejected: 0,
|
||||
mediaFramesDroppedWithoutSink: 0,
|
||||
cpuFallbackFramesDropped: 0,
|
||||
};
|
||||
|
||||
class FakeNative {
|
||||
constructor() {
|
||||
this.lifecycleCallback = undefined;
|
||||
this.stopCount = 0;
|
||||
natives.push(this);
|
||||
}
|
||||
|
||||
setLifecycleCallback(callback) {
|
||||
this.lifecycleCallback = callback;
|
||||
}
|
||||
|
||||
setFrameSinkHandle(handle) {
|
||||
frameSinkHandleCalls.push(handle);
|
||||
}
|
||||
|
||||
start(
|
||||
sourceId,
|
||||
sourceKind,
|
||||
width,
|
||||
height,
|
||||
frameRate,
|
||||
hookDllPath,
|
||||
hookDllPathX86,
|
||||
injectionMethod,
|
||||
captureId,
|
||||
captureOptions,
|
||||
) {
|
||||
calls.push({
|
||||
sourceId,
|
||||
sourceKind,
|
||||
width,
|
||||
height,
|
||||
frameRate,
|
||||
hookDllPath,
|
||||
hookDllPathX86,
|
||||
injectionMethod,
|
||||
captureId,
|
||||
captureOptions,
|
||||
});
|
||||
return {
|
||||
width: width || 1920,
|
||||
height: height || 1080,
|
||||
frameRate: frameRate || 30,
|
||||
pixelFormat: 'bgra',
|
||||
};
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.stopCount += 1;
|
||||
}
|
||||
|
||||
getDiagnostics() {
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
attachEncoder(width, height) {
|
||||
encoderDiagnostics.attached = true;
|
||||
encoderDiagnostics.width = width;
|
||||
encoderDiagnostics.height = height;
|
||||
encoderDiagnostics.capacity = 8;
|
||||
}
|
||||
|
||||
detachEncoder() {
|
||||
encoderDiagnostics.attached = false;
|
||||
}
|
||||
|
||||
isEncoderAttached() {
|
||||
return encoderDiagnostics.attached;
|
||||
}
|
||||
|
||||
encoderRingFullCount() {
|
||||
return encoderDiagnostics.ringFullEvents;
|
||||
}
|
||||
|
||||
getEncoderAttachDiagnostics() {
|
||||
if (!encoderDiagnostics.attached) return null;
|
||||
return {...encoderDiagnostics};
|
||||
}
|
||||
|
||||
getFrameSinkDiagnostics() {
|
||||
return {...frameSinkDiagnostics};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
binding: {
|
||||
ScreenCapture: FakeNative,
|
||||
isSupported: () => true,
|
||||
getAvailability: () => ({available: true, backend: 'windows-game-capture'}),
|
||||
listSources: () => [
|
||||
{
|
||||
kind: 'screen',
|
||||
id: 'screen:0:0',
|
||||
name: 'Display 1',
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
},
|
||||
{
|
||||
kind: 'window',
|
||||
id: 'window:5050:0',
|
||||
name: 'Fixture',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
targetPid: 4242,
|
||||
},
|
||||
{
|
||||
kind: 'browser',
|
||||
id: 'screen:1:0',
|
||||
name: 'unsupported shape',
|
||||
},
|
||||
],
|
||||
elevateGpuSchedulingPriority: (processId, priorityClass) => {
|
||||
priorityCalls.push({type: 'elevate', processId, priorityClass});
|
||||
},
|
||||
restoreGpuSchedulingPriority: (processId) => {
|
||||
priorityCalls.push({type: 'restore', processId});
|
||||
},
|
||||
},
|
||||
calls,
|
||||
frameSinkHandleCalls,
|
||||
priorityCalls,
|
||||
natives,
|
||||
diagnostics,
|
||||
encoderDiagnostics,
|
||||
frameSinkDiagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (hasBindingHook) {
|
||||
winGameCapture.__setBindingForTests(null);
|
||||
}
|
||||
});
|
||||
|
||||
describe('win-game-capture loader wrapper -- binding-absent fallback path', () => {
|
||||
function forceNoBinding() {
|
||||
if (hasBindingHook) winGameCapture.__setBindingForTests(null);
|
||||
}
|
||||
|
||||
test(
|
||||
'isSupported() is false when no binding is loaded',
|
||||
{skip: hasBindingHook ? false : 'no __setBindingForTests hook'},
|
||||
() => {
|
||||
forceNoBinding();
|
||||
assert.equal(winGameCapture.isSupported(), false);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'getAvailability() returns the {available:false, ...} shape when no binding is loaded',
|
||||
{skip: hasBindingHook ? false : 'no __setBindingForTests hook'},
|
||||
() => {
|
||||
forceNoBinding();
|
||||
const availability = winGameCapture.getAvailability();
|
||||
assert.equal(availability.available, false);
|
||||
assert.equal(availability.backend, 'windows-game-capture');
|
||||
assert.equal(typeof availability.reason, 'string');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'constructing ScreenCapture without a binding throws',
|
||||
{skip: hasBindingHook ? false : 'no __setBindingForTests hook'},
|
||||
() => {
|
||||
forceNoBinding();
|
||||
assert.throws(() => new winGameCapture.ScreenCapture({sourceId: '1'}));
|
||||
try {
|
||||
new winGameCapture.ScreenCapture({sourceId: '1'});
|
||||
assert.fail('expected ScreenCapture constructor to throw without a binding');
|
||||
} catch (error) {
|
||||
assert.ok(error instanceof Error);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('win-game-capture loader wrapper -- arch path resolvers (platform-portable)', () => {
|
||||
test('resolveGameHookPath() is null or the host-arch hook path', () => {
|
||||
const r = winGameCapture.resolveGameHookPath();
|
||||
assert.ok(
|
||||
r === null || (typeof r === 'string' && /fluxer-game-hook\.win32-(x64|ia32|arm64)-msvc\.dll$/.test(r)),
|
||||
`unexpected resolveGameHookPath(): ${r}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveGameHookPathX86() is null or the ia32 hook path', () => {
|
||||
const r = winGameCapture.resolveGameHookPathX86();
|
||||
assert.ok(
|
||||
r === null || (typeof r === 'string' && r.endsWith('fluxer-game-hook.win32-ia32-msvc.dll')),
|
||||
`unexpected resolveGameHookPathX86(): ${r}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveVulkanLayerManifestPath() is null or the host-arch layer manifest path', () => {
|
||||
const r = winGameCapture.resolveVulkanLayerManifestPath();
|
||||
assert.ok(
|
||||
r === null || (typeof r === 'string' && /fluxer-vulkan-layer\.win32-(x64|ia32|arm64)-msvc\.json$/.test(r)),
|
||||
`unexpected resolveVulkanLayerManifestPath(): ${r}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('win-game-capture loader wrapper -- real native binding (built Windows box)', () => {
|
||||
test('getAvailability() reports available with the windows-game-capture backend', {skip: realBindingSkip}, () => {
|
||||
assert.equal(startupAvailability.available, true);
|
||||
assert.equal(startupAvailability.backend, 'windows-game-capture');
|
||||
});
|
||||
|
||||
test('resolveGameHookPath() points at an existing host-arch hook DLL', {skip: realBindingSkip}, () => {
|
||||
const r = winGameCapture.resolveGameHookPath();
|
||||
assert.equal(typeof r, 'string', 'expected a hook path on a built Windows box');
|
||||
assert.ok(existsSync(r), `hook DLL should exist on disk: ${r}`);
|
||||
});
|
||||
|
||||
test('resolveVulkanLayerManifestPath() points at an existing layer manifest', {skip: realBindingSkip}, () => {
|
||||
const r = winGameCapture.resolveVulkanLayerManifestPath();
|
||||
assert.equal(typeof r, 'string', 'expected a Vulkan layer manifest path on a built Windows box');
|
||||
assert.ok(existsSync(r), `Vulkan layer manifest should exist on disk: ${r}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('win-game-capture loader wrapper -- injected fake binding', () => {
|
||||
test(
|
||||
'listSources() forwards sanitized screen/window sources from the native binding',
|
||||
{skip: injectionSkip},
|
||||
async () => {
|
||||
const {binding} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
const sources = await winGameCapture.listSources();
|
||||
assert.deepEqual(sources, [
|
||||
{
|
||||
kind: 'screen',
|
||||
id: 'screen:0:0',
|
||||
name: 'Display 1',
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
targetPid: undefined,
|
||||
},
|
||||
{
|
||||
kind: 'window',
|
||||
id: 'window:5050:0',
|
||||
name: 'Fixture',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
targetPid: 4242,
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'start() forwards sourceId/kind/dims/frameRate and BOTH hook paths (6th + 7th args)',
|
||||
{skip: injectionSkip},
|
||||
async () => {
|
||||
const {binding, calls} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
const capture = new winGameCapture.ScreenCapture({
|
||||
sourceId: '987654',
|
||||
sourceKind: 'game',
|
||||
width: 1600,
|
||||
height: 900,
|
||||
frameRate: 60,
|
||||
hookDllPath: 'C:/hooks/fluxer-game-hook.win32-x64-msvc.dll',
|
||||
hookDllPathX86: 'C:/hooks/fluxer-game-hook.win32-ia32-msvc.dll',
|
||||
captureId: 'capture-1',
|
||||
colorRange: 'full',
|
||||
colorSpace: 'rec709',
|
||||
showCursorClicks: true,
|
||||
captureRect: {x: 10, y: 20, width: 300, height: 200},
|
||||
});
|
||||
capture.on('error', () => {});
|
||||
const result = await capture.start();
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(calls[0], {
|
||||
sourceId: '987654',
|
||||
sourceKind: 'game',
|
||||
width: 1600,
|
||||
height: 900,
|
||||
frameRate: 60,
|
||||
hookDllPath: 'C:/hooks/fluxer-game-hook.win32-x64-msvc.dll',
|
||||
hookDllPathX86: 'C:/hooks/fluxer-game-hook.win32-ia32-msvc.dll',
|
||||
injectionMethod: undefined,
|
||||
captureId: 'capture-1',
|
||||
captureOptions: {
|
||||
colorRange: 'full',
|
||||
colorSpace: 'rec709',
|
||||
showCursorClicks: true,
|
||||
captureRect: {x: 10, y: 20, width: 300, height: 200},
|
||||
},
|
||||
});
|
||||
assert.equal(result.pixelFormat, 'bgra');
|
||||
assert.equal(result.width, 1600);
|
||||
assert.equal(result.height, 900);
|
||||
assert.equal(result.frameRate, 60);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'start() forwards the injectionMethod option as the 8th arg for game capture',
|
||||
{skip: injectionSkip},
|
||||
async () => {
|
||||
const {binding, calls} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
const capture = new winGameCapture.ScreenCapture({
|
||||
sourceId: '555',
|
||||
sourceKind: 'game',
|
||||
hookDllPath: 'C:/hooks/fluxer-game-hook.win32-x64-msvc.dll',
|
||||
hookDllPathX86: 'C:/hooks/fluxer-game-hook.win32-ia32-msvc.dll',
|
||||
injectionMethod: 'set-windows-hook',
|
||||
});
|
||||
capture.on('error', () => {});
|
||||
await capture.start();
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].injectionMethod, 'set-windows-hook');
|
||||
},
|
||||
);
|
||||
|
||||
test('window sourceKind does not forward the injectionMethod', {skip: injectionSkip}, async () => {
|
||||
const {binding, calls} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
const capture = new winGameCapture.ScreenCapture({
|
||||
sourceId: '42',
|
||||
sourceKind: 'window',
|
||||
injectionMethod: 'set-windows-hook',
|
||||
});
|
||||
capture.on('error', () => {});
|
||||
await capture.start();
|
||||
assert.equal(calls[0].injectionMethod, undefined);
|
||||
});
|
||||
|
||||
test('window sourceKind does not forward hook paths', {skip: injectionSkip}, async () => {
|
||||
const {binding, calls} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
const capture = new winGameCapture.ScreenCapture({
|
||||
sourceId: '42',
|
||||
sourceKind: 'window',
|
||||
hookDllPath: 'C:/hooks/primary.dll',
|
||||
hookDllPathX86: 'C:/hooks/x86.dll',
|
||||
});
|
||||
capture.on('error', () => {});
|
||||
await capture.start();
|
||||
assert.equal(calls[0].sourceKind, 'window');
|
||||
assert.equal(calls[0].hookDllPath, undefined);
|
||||
assert.equal(calls[0].hookDllPathX86, undefined);
|
||||
});
|
||||
|
||||
test('screen sourceKind does not forward hook paths or the injectionMethod', {skip: injectionSkip}, async () => {
|
||||
const {binding, calls} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
const capture = new winGameCapture.ScreenCapture({
|
||||
sourceId: 'screen:0:0',
|
||||
sourceKind: 'screen',
|
||||
hookDllPath: 'C:/hooks/primary.dll',
|
||||
hookDllPathX86: 'C:/hooks/x86.dll',
|
||||
injectionMethod: 'set-windows-hook',
|
||||
});
|
||||
capture.on('error', () => {});
|
||||
await capture.start();
|
||||
assert.equal(calls[0].sourceKind, 'screen');
|
||||
assert.equal(calls[0].hookDllPath, undefined);
|
||||
assert.equal(calls[0].hookDllPathX86, undefined);
|
||||
assert.equal(calls[0].injectionMethod, undefined);
|
||||
});
|
||||
|
||||
test('getDiagnostics() exposes native start option state', {skip: injectionSkip}, () => {
|
||||
const {binding} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
const capture = new winGameCapture.ScreenCapture({sourceId: '42', sourceKind: 'window'});
|
||||
const diagnostics = capture.getDiagnostics();
|
||||
assert.deepEqual(diagnostics.startOptions, {
|
||||
colorRange: 'full',
|
||||
colorSpace: 'rec709',
|
||||
showCursorClicks: true,
|
||||
captureRect: {x: 10, y: 20, width: 300, height: 200},
|
||||
unsupportedOptions: ['showCursorClicks', 'captureRect', 'colorRange', 'colorSpace'],
|
||||
});
|
||||
});
|
||||
|
||||
test('encoder attachment diagnostics are forwarded by the wrapper', {skip: injectionSkip}, () => {
|
||||
const {binding, encoderDiagnostics} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
const capture = new winGameCapture.ScreenCapture({sourceId: '42', sourceKind: 'window'});
|
||||
|
||||
assert.equal(capture.isEncoderAttached(), false);
|
||||
assert.equal(capture.encoderRingFullCount(), 0);
|
||||
assert.equal(capture.getEncoderAttachDiagnostics(), null);
|
||||
|
||||
capture.attachEncoder(1280, 720);
|
||||
encoderDiagnostics.framesSubmitted = 3;
|
||||
encoderDiagnostics.framesDropped = 1;
|
||||
encoderDiagnostics.ringFullEvents = 1;
|
||||
encoderDiagnostics.failedBlits = 0;
|
||||
|
||||
assert.equal(capture.isEncoderAttached(), true);
|
||||
assert.equal(capture.encoderRingFullCount(), 1);
|
||||
assert.deepEqual(capture.getEncoderAttachDiagnostics(), {
|
||||
attached: true,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
capacity: 8,
|
||||
framesSubmitted: 3,
|
||||
framesDropped: 1,
|
||||
ringFullEvents: 1,
|
||||
failedBlits: 0,
|
||||
});
|
||||
|
||||
capture.detachEncoder();
|
||||
assert.equal(capture.isEncoderAttached(), false);
|
||||
});
|
||||
|
||||
test('frame sink diagnostics are forwarded by the wrapper', {skip: injectionSkip}, () => {
|
||||
const {binding, frameSinkDiagnostics} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
const capture = new winGameCapture.ScreenCapture({sourceId: '42', sourceKind: 'window'});
|
||||
|
||||
frameSinkDiagnostics.accepted = 7;
|
||||
frameSinkDiagnostics.coalesced = 2;
|
||||
frameSinkDiagnostics.rejected = 1;
|
||||
frameSinkDiagnostics.mediaFramesDroppedWithoutSink = 3;
|
||||
frameSinkDiagnostics.cpuFallbackFramesDropped = 4;
|
||||
|
||||
assert.deepEqual(capture.getFrameSinkDiagnostics(), {
|
||||
accepted: 7,
|
||||
coalesced: 2,
|
||||
rejected: 1,
|
||||
mediaFramesDroppedWithoutSink: 3,
|
||||
cpuFallbackFramesDropped: 4,
|
||||
});
|
||||
});
|
||||
|
||||
test('installs a native frame sink handle once before start', {skip: injectionSkip}, async () => {
|
||||
const {binding, calls, frameSinkHandleCalls} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
const frameSinkHandle = {native: true};
|
||||
const capture = new winGameCapture.ScreenCapture({
|
||||
sourceId: '42',
|
||||
sourceKind: 'window',
|
||||
frameSinkHandle,
|
||||
nativeFrameSinkRequired: true,
|
||||
});
|
||||
capture.on('error', () => {});
|
||||
|
||||
await capture.start();
|
||||
|
||||
assert.deepEqual(frameSinkHandleCalls, [frameSinkHandle]);
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test(
|
||||
'fails before native start when a native frame sink is required but missing',
|
||||
{skip: injectionSkip},
|
||||
async () => {
|
||||
const {binding, calls, frameSinkHandleCalls} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
const capture = new winGameCapture.ScreenCapture({
|
||||
sourceId: '42',
|
||||
sourceKind: 'window',
|
||||
nativeFrameSinkRequired: true,
|
||||
});
|
||||
capture.on('error', () => {});
|
||||
|
||||
await assert.rejects(() => capture.start(), /native frame sink handle is required/i);
|
||||
assert.deepEqual(frameSinkHandleCalls, []);
|
||||
assert.deepEqual(calls, []);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'game sourceKind without a hook path fails closed without WGC/browser fallback',
|
||||
{skip: injectionSkip},
|
||||
async () => {
|
||||
const {binding, calls} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
const capture = new winGameCapture.ScreenCapture({
|
||||
sourceId: '7',
|
||||
sourceKind: 'game',
|
||||
hookDllPath: '',
|
||||
hookDllPathX86: '',
|
||||
});
|
||||
capture.on('error', () => {});
|
||||
await assert.rejects(() => capture.start(), /Game capture hook unavailable/);
|
||||
assert.equal(calls.length, 0, 'native start must not be called when the game hook is unavailable');
|
||||
},
|
||||
);
|
||||
|
||||
test('lifecycle "error" emits an Error event', {skip: injectionSkip}, () => {
|
||||
const {binding, natives} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
const capture = new winGameCapture.ScreenCapture({sourceId: '1'});
|
||||
const errors = [];
|
||||
capture.on('error', (err) => errors.push(err));
|
||||
natives[0].lifecycleCallback('error', 'DXGI device removed');
|
||||
assert.equal(errors.length, 1);
|
||||
assert.ok(errors[0] instanceof Error);
|
||||
assert.equal(errors[0].message, 'DXGI device removed');
|
||||
});
|
||||
|
||||
test('lifecycle "closed" stops the native capture and emits closed once', {skip: injectionSkip}, async () => {
|
||||
const {binding, natives} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
const capture = new winGameCapture.ScreenCapture({sourceId: '1'});
|
||||
let closed = 0;
|
||||
capture.on('closed', () => {
|
||||
closed += 1;
|
||||
});
|
||||
natives[0].lifecycleCallback('closed', '');
|
||||
await Promise.resolve();
|
||||
await capture.stop();
|
||||
assert.equal(closed, 1);
|
||||
assert.equal(natives[0].stopCount, 1);
|
||||
});
|
||||
|
||||
test('non-fatal "stalled" lifecycle re-emits as a stalled event WITHOUT stopping', {skip: injectionSkip}, () => {
|
||||
const {binding, natives} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
const capture = new winGameCapture.ScreenCapture({sourceId: '1'});
|
||||
const stalls = [];
|
||||
let closed = 0;
|
||||
capture.on('stalled', (message) => stalls.push(message));
|
||||
capture.on('closed', () => {
|
||||
closed += 1;
|
||||
});
|
||||
natives[0].lifecycleCallback('stalled', 'frame counter frozen while focused');
|
||||
assert.deepEqual(stalls, ['frame counter frozen while focused']);
|
||||
assert.equal(natives[0].stopCount, 0);
|
||||
assert.equal(closed, 0);
|
||||
});
|
||||
|
||||
test(
|
||||
'non-fatal "diagnostic" lifecycle re-emits as a diagnostic event WITHOUT stopping',
|
||||
{skip: injectionSkip},
|
||||
() => {
|
||||
const {binding, natives} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
const capture = new winGameCapture.ScreenCapture({sourceId: '1'});
|
||||
const diags = [];
|
||||
let closed = 0;
|
||||
capture.on('diagnostic', (message) => diags.push(message));
|
||||
capture.on('closed', () => {
|
||||
closed += 1;
|
||||
});
|
||||
natives[0].lifecycleCallback('diagnostic', 'fell back to CPU readback');
|
||||
assert.deepEqual(diags, ['fell back to CPU readback']);
|
||||
assert.equal(natives[0].stopCount, 0);
|
||||
assert.equal(closed, 0);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'lifecycle "error" in the real napi [type, message] array shape emits an Error event',
|
||||
{skip: injectionSkip},
|
||||
() => {
|
||||
const {binding, natives} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
const capture = new winGameCapture.ScreenCapture({sourceId: '1'});
|
||||
const errors = [];
|
||||
capture.on('error', (err) => errors.push(err));
|
||||
natives[0].lifecycleCallback(['error', 'DXGI device removed']);
|
||||
assert.equal(errors.length, 1);
|
||||
assert.ok(errors[0] instanceof Error);
|
||||
assert.equal(errors[0].message, 'DXGI device removed');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'lifecycle "closed" in the napi array shape stops the native capture and emits closed once',
|
||||
{skip: injectionSkip},
|
||||
async () => {
|
||||
const {binding, natives} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
const capture = new winGameCapture.ScreenCapture({sourceId: '1'});
|
||||
let closed = 0;
|
||||
capture.on('closed', () => {
|
||||
closed += 1;
|
||||
});
|
||||
natives[0].lifecycleCallback(['closed', '']);
|
||||
await Promise.resolve();
|
||||
await capture.stop();
|
||||
assert.equal(closed, 1);
|
||||
assert.equal(natives[0].stopCount, 1);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'lifecycle "stalled" in the napi array shape re-emits as a stalled event WITHOUT stopping',
|
||||
{skip: injectionSkip},
|
||||
() => {
|
||||
const {binding, natives} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
const capture = new winGameCapture.ScreenCapture({sourceId: '1'});
|
||||
const stalls = [];
|
||||
let closed = 0;
|
||||
capture.on('stalled', (message) => stalls.push(message));
|
||||
capture.on('closed', () => {
|
||||
closed += 1;
|
||||
});
|
||||
natives[0].lifecycleCallback(['stalled', 'frame counter frozen while focused']);
|
||||
assert.deepEqual(stalls, ['frame counter frozen while focused']);
|
||||
assert.equal(natives[0].stopCount, 0);
|
||||
assert.equal(closed, 0);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'lifecycle "diagnostic" (injected via <method>) in the napi array shape re-emits WITHOUT stopping',
|
||||
{skip: injectionSkip},
|
||||
() => {
|
||||
const {binding, natives} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
const capture = new winGameCapture.ScreenCapture({sourceId: '1'});
|
||||
const diags = [];
|
||||
let closed = 0;
|
||||
capture.on('diagnostic', (message) => diags.push(message));
|
||||
capture.on('closed', () => {
|
||||
closed += 1;
|
||||
});
|
||||
natives[0].lifecycleCallback(['diagnostic', 'game capture injected via set-windows-hook']);
|
||||
assert.deepEqual(diags, ['game capture injected via set-windows-hook']);
|
||||
assert.equal(natives[0].stopCount, 0);
|
||||
assert.equal(closed, 0);
|
||||
},
|
||||
);
|
||||
|
||||
test('getDiagnostics() passes the native snapshot through', {skip: injectionSkip}, () => {
|
||||
const {binding, diagnostics} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
const capture = new winGameCapture.ScreenCapture({sourceId: '1'});
|
||||
const snapshot = capture.getDiagnostics();
|
||||
assert.deepEqual(snapshot, diagnostics);
|
||||
});
|
||||
|
||||
test('getDiagnostics() surfaces the activeStrategy + lastFallbackReason fields', {skip: injectionSkip}, () => {
|
||||
const {binding, diagnostics} = makeFakeBinding();
|
||||
diagnostics.activeStrategy = 'dxgi-duplication';
|
||||
diagnostics.lastFallbackReason =
|
||||
'game-hook capture could not inject its capture hook; switching to dxgi-duplication capture';
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
const capture = new winGameCapture.ScreenCapture({sourceId: '1'});
|
||||
const snapshot = capture.getDiagnostics();
|
||||
assert.equal(snapshot.activeStrategy, 'dxgi-duplication');
|
||||
assert.match(snapshot.lastFallbackReason, /switching to dxgi-duplication/);
|
||||
});
|
||||
|
||||
test('elevateGpuSchedulingPriority forwards the optional realtime priority class', {skip: injectionSkip}, () => {
|
||||
const {binding, priorityCalls} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
assert.equal(winGameCapture.elevateGpuSchedulingPriority(1234, 'realtime'), true);
|
||||
assert.deepEqual(priorityCalls, [{type: 'elevate', processId: 1234, priorityClass: 'realtime'}]);
|
||||
});
|
||||
|
||||
test('elevateGpuSchedulingPriority canonicalizes the real-time priority alias', {skip: injectionSkip}, () => {
|
||||
const {binding, priorityCalls} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
assert.equal(winGameCapture.elevateGpuSchedulingPriority(1234, 'real-time'), true);
|
||||
assert.deepEqual(priorityCalls, [{type: 'elevate', processId: 1234, priorityClass: 'realtime'}]);
|
||||
});
|
||||
|
||||
test('elevateGpuSchedulingPriority preserves the existing default priority class', {skip: injectionSkip}, () => {
|
||||
const {binding, priorityCalls} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
assert.equal(winGameCapture.elevateGpuSchedulingPriority(1234), true);
|
||||
assert.deepEqual(priorityCalls, [{type: 'elevate', processId: 1234, priorityClass: undefined}]);
|
||||
});
|
||||
|
||||
test(
|
||||
'elevateGpuSchedulingPriority rejects unsupported priority classes before binding calls',
|
||||
{skip: injectionSkip},
|
||||
() => {
|
||||
const {binding, priorityCalls} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
assert.equal(winGameCapture.elevateGpuSchedulingPriority(1234, 'normal'), false);
|
||||
assert.deepEqual(priorityCalls, []);
|
||||
},
|
||||
);
|
||||
|
||||
test('lifecycle fallback "error" (next-strategy=...) re-emits as a fatal error event', {skip: injectionSkip}, () => {
|
||||
const {binding, natives} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
const capture = new winGameCapture.ScreenCapture({sourceId: '1'});
|
||||
const errors = [];
|
||||
capture.on('error', (err) => errors.push(err));
|
||||
natives[0].lifecycleCallback([
|
||||
'error',
|
||||
'fallback: game-hook -> dxgi-duplication (game-hook capture could not inject its capture hook) [next-strategy=dxgi-duplication]',
|
||||
]);
|
||||
assert.equal(errors.length, 1);
|
||||
assert.ok(errors[0] instanceof Error);
|
||||
assert.equal(winGameCapture.parseFallbackRecommendation(errors[0].message), 'dxgi-duplication');
|
||||
});
|
||||
|
||||
test(
|
||||
'lifecycle fallback "diagnostic" (upgrade, next-strategy=...) re-emits WITHOUT stopping',
|
||||
{skip: injectionSkip},
|
||||
() => {
|
||||
const {binding, natives} = makeFakeBinding();
|
||||
winGameCapture.__setBindingForTests(binding);
|
||||
const capture = new winGameCapture.ScreenCapture({sourceId: '1'});
|
||||
const diags = [];
|
||||
let closed = 0;
|
||||
capture.on('diagnostic', (message) => diags.push(message));
|
||||
capture.on('closed', () => {
|
||||
closed += 1;
|
||||
});
|
||||
natives[0].lifecycleCallback([
|
||||
'diagnostic',
|
||||
'upgrade: window-gdi -> dxgi-duplication (window-gdi has been stable) [next-strategy=dxgi-duplication]',
|
||||
]);
|
||||
assert.equal(diags.length, 1);
|
||||
assert.equal(winGameCapture.parseFallbackRecommendation(diags[0]), 'dxgi-duplication');
|
||||
assert.equal(natives[0].stopCount, 0);
|
||||
assert.equal(closed, 0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('win-game-capture loader wrapper -- parseFallbackRecommendation', () => {
|
||||
test('extracts the recommended strategy from a transition error message', () => {
|
||||
assert.equal(
|
||||
winGameCapture.parseFallbackRecommendation(
|
||||
'fallback: game-hook -> window-gdi (reason) [next-strategy=window-gdi]',
|
||||
),
|
||||
'window-gdi',
|
||||
);
|
||||
});
|
||||
|
||||
test('returns "none" for an exhausted give-up message', () => {
|
||||
assert.equal(
|
||||
winGameCapture.parseFallbackRecommendation(
|
||||
'fallback exhausted: window-gdi was the last resort [next-strategy=none]',
|
||||
),
|
||||
'none',
|
||||
);
|
||||
});
|
||||
|
||||
test('returns null for an ordinary error with no recommendation', () => {
|
||||
assert.equal(winGameCapture.parseFallbackRecommendation('DXGI device removed'), null);
|
||||
assert.equal(winGameCapture.parseFallbackRecommendation(undefined), null);
|
||||
assert.equal(winGameCapture.parseFallbackRecommendation(''), null);
|
||||
});
|
||||
|
||||
test('returns null for an unrecognised strategy token', () => {
|
||||
assert.equal(winGameCapture.parseFallbackRecommendation('[next-strategy=teleporter]'), null);
|
||||
});
|
||||
|
||||
test('accepts the WGC fallback strategy token', () => {
|
||||
assert.equal(winGameCapture.parseFallbackRecommendation('[next-strategy=wgc]'), 'wgc');
|
||||
assert.equal(
|
||||
winGameCapture.parseFallbackRecommendation(
|
||||
'fallback: game-hook -> wgc (game-hook capture could not inject its capture hook) [next-strategy=wgc]',
|
||||
),
|
||||
'wgc',
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects browser fallback strategy tokens', () => {
|
||||
assert.equal(winGameCapture.parseFallbackRecommendation('[next-strategy=browser-display]'), null);
|
||||
assert.equal(winGameCapture.parseFallbackRecommendation('[next-strategy=desktop-capturer]'), null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "fluxer_inject_helper"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "fluxer_inject_helper"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
[[bin]]
|
||||
name = "fluxer-inject-helper"
|
||||
path = "src/main.rs"
|
||||
|
||||
[profile.release]
|
||||
# Keep the helper tiny: it is a one-shot injector exe shipped alongside the
|
||||
# hook DLLs, so optimise hard for size and strip symbols.
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-sys = {version = "0.61.2", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Security",
|
||||
"Win32_System_Diagnostics_Debug",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Threading",
|
||||
]}
|
||||
@@ -0,0 +1,242 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#![deny(clippy::all)]
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn main() {
|
||||
eprintln!("fluxer-inject-helper is only supported on Windows");
|
||||
std::process::exit(Stage::Unsupported as i32);
|
||||
}
|
||||
|
||||
#[repr(i32)]
|
||||
#[derive(Clone, Copy)]
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
enum Stage {
|
||||
Success = 0,
|
||||
BadArgs = 2,
|
||||
HookMissing = 3,
|
||||
OpenProcess = 4,
|
||||
Alloc = 5,
|
||||
Write = 6,
|
||||
Kernel32 = 7,
|
||||
LoadLibraryAddr = 8,
|
||||
CreateThread = 9,
|
||||
WaitTimeout = 10,
|
||||
LoadLibraryFailed = 11,
|
||||
#[cfg_attr(target_os = "windows", allow(dead_code))]
|
||||
Unsupported = 64,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn main() {
|
||||
let code = win::run();
|
||||
std::process::exit(code as i32);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod win {
|
||||
use super::Stage;
|
||||
use core::ffi::c_void;
|
||||
use std::ptr::{null, null_mut};
|
||||
use windows_sys::Win32::Foundation::{
|
||||
CloseHandle, GetLastError, HANDLE, INVALID_HANDLE_VALUE, WAIT_ABANDONED, WAIT_OBJECT_0,
|
||||
};
|
||||
use windows_sys::Win32::System::Diagnostics::Debug::{OutputDebugStringW, WriteProcessMemory};
|
||||
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleW, GetProcAddress};
|
||||
use windows_sys::Win32::System::Memory::{
|
||||
MEM_COMMIT, MEM_RELEASE, MEM_RESERVE, PAGE_READWRITE, VirtualAllocEx, VirtualFreeEx,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
CreateRemoteThread, GetExitCodeThread, INFINITE, OpenProcess, PROCESS_CREATE_THREAD,
|
||||
PROCESS_QUERY_INFORMATION, PROCESS_VM_OPERATION, PROCESS_VM_READ, PROCESS_VM_WRITE,
|
||||
WaitForSingleObject,
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: u32 = 10_000;
|
||||
|
||||
struct OwnedHandle(HANDLE);
|
||||
|
||||
impl OwnedHandle {
|
||||
fn raw(&self) -> HANDLE {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for OwnedHandle {
|
||||
fn drop(&mut self) {
|
||||
if !self.0.is_null() && self.0 != INVALID_HANDLE_VALUE {
|
||||
unsafe {
|
||||
CloseHandle(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn to_wide(value: &str) -> Vec<u16> {
|
||||
value.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
fn log(message: &str) {
|
||||
let text = format!("[fluxer-inject-helper] {message}");
|
||||
let wide = to_wide(&text);
|
||||
unsafe {
|
||||
OutputDebugStringW(wide.as_ptr());
|
||||
}
|
||||
eprintln!("{text}");
|
||||
}
|
||||
|
||||
fn fail(stage: Stage, context: &str) -> Stage {
|
||||
let err = unsafe { GetLastError() };
|
||||
log(&format!(
|
||||
"FAILED stage={} ({context}); GetLastError={err}",
|
||||
stage as i32
|
||||
));
|
||||
stage
|
||||
}
|
||||
|
||||
pub(super) fn run() -> Stage {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.len() < 2 || args.len() > 3 {
|
||||
log(&format!(
|
||||
"bad args: expected <pid> <hook-dll-path> [timeout-ms], got {} arg(s)",
|
||||
args.len()
|
||||
));
|
||||
return Stage::BadArgs;
|
||||
}
|
||||
let Ok(target_pid) = args[0].parse::<u32>() else {
|
||||
log(&format!("bad args: unparseable pid {:?}", args[0]));
|
||||
return Stage::BadArgs;
|
||||
};
|
||||
if target_pid == 0 {
|
||||
log("bad args: pid must be non-zero");
|
||||
return Stage::BadArgs;
|
||||
}
|
||||
let hook_path = args[1].as_str();
|
||||
let timeout_ms = match args.get(2) {
|
||||
None => DEFAULT_TIMEOUT_MS,
|
||||
Some(raw) => match raw.parse::<u32>() {
|
||||
Ok(0) => INFINITE,
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
log(&format!("bad args: unparseable timeout {raw:?}"));
|
||||
return Stage::BadArgs;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if !std::path::Path::new(hook_path).exists() {
|
||||
log(&format!("hook DLL missing: {hook_path}"));
|
||||
return Stage::HookMissing;
|
||||
}
|
||||
|
||||
log(&format!(
|
||||
"injecting (pid={target_pid}, hook={hook_path}, timeout_ms={timeout_ms}, \
|
||||
helper_bits={})",
|
||||
usize::BITS
|
||||
));
|
||||
|
||||
inject(target_pid, hook_path, timeout_ms)
|
||||
}
|
||||
|
||||
fn inject(target_pid: u32, hook_path: &str, timeout_ms: u32) -> Stage {
|
||||
let wide_path = to_wide(hook_path);
|
||||
let path_bytes = wide_path.len() * std::mem::size_of::<u16>();
|
||||
|
||||
unsafe {
|
||||
let process = OpenProcess(
|
||||
PROCESS_CREATE_THREAD
|
||||
| PROCESS_VM_OPERATION
|
||||
| PROCESS_VM_WRITE
|
||||
| PROCESS_VM_READ
|
||||
| PROCESS_QUERY_INFORMATION,
|
||||
0,
|
||||
target_pid,
|
||||
);
|
||||
if process.is_null() {
|
||||
return fail(Stage::OpenProcess, "OpenProcess returned null");
|
||||
}
|
||||
let process = OwnedHandle(process);
|
||||
|
||||
let remote_path = VirtualAllocEx(
|
||||
process.raw(),
|
||||
null(),
|
||||
path_bytes,
|
||||
MEM_COMMIT | MEM_RESERVE,
|
||||
PAGE_READWRITE,
|
||||
);
|
||||
if remote_path.is_null() {
|
||||
return fail(Stage::Alloc, "VirtualAllocEx returned null");
|
||||
}
|
||||
|
||||
let mut written: usize = 0;
|
||||
let write_ok = WriteProcessMemory(
|
||||
process.raw(),
|
||||
remote_path,
|
||||
wide_path.as_ptr().cast(),
|
||||
path_bytes,
|
||||
&mut written,
|
||||
) != 0;
|
||||
if !write_ok || written != path_bytes {
|
||||
let stage = fail(Stage::Write, "WriteProcessMemory failed/short");
|
||||
VirtualFreeEx(process.raw(), remote_path, 0, MEM_RELEASE);
|
||||
return stage;
|
||||
}
|
||||
|
||||
let kernel32_name = to_wide("kernel32.dll");
|
||||
let kernel32 = GetModuleHandleW(kernel32_name.as_ptr());
|
||||
if kernel32.is_null() {
|
||||
let stage = fail(Stage::Kernel32, "GetModuleHandleW(kernel32.dll)");
|
||||
VirtualFreeEx(process.raw(), remote_path, 0, MEM_RELEASE);
|
||||
return stage;
|
||||
}
|
||||
let load_library = GetProcAddress(kernel32, c"LoadLibraryW".as_ptr().cast());
|
||||
let Some(load_library) = load_library else {
|
||||
let stage = fail(Stage::LoadLibraryAddr, "GetProcAddress(LoadLibraryW)");
|
||||
VirtualFreeEx(process.raw(), remote_path, 0, MEM_RELEASE);
|
||||
return stage;
|
||||
};
|
||||
let start_routine: unsafe extern "system" fn(*mut c_void) -> u32 =
|
||||
std::mem::transmute(load_library);
|
||||
|
||||
let thread = CreateRemoteThread(
|
||||
process.raw(),
|
||||
null(),
|
||||
0,
|
||||
Some(start_routine),
|
||||
remote_path,
|
||||
0,
|
||||
null_mut(),
|
||||
);
|
||||
if thread.is_null() {
|
||||
let stage = fail(Stage::CreateThread, "CreateRemoteThread returned null");
|
||||
VirtualFreeEx(process.raw(), remote_path, 0, MEM_RELEASE);
|
||||
return stage;
|
||||
}
|
||||
let thread = OwnedHandle(thread);
|
||||
|
||||
let wait = WaitForSingleObject(thread.raw(), timeout_ms);
|
||||
if wait != WAIT_OBJECT_0 && wait != WAIT_ABANDONED {
|
||||
let stage = fail(Stage::WaitTimeout, "WaitForSingleObject did not signal");
|
||||
VirtualFreeEx(process.raw(), remote_path, 0, MEM_RELEASE);
|
||||
return stage;
|
||||
}
|
||||
|
||||
let mut exit_code: u32 = 0;
|
||||
let got_exit = GetExitCodeThread(thread.raw(), &mut exit_code) != 0;
|
||||
VirtualFreeEx(process.raw(), remote_path, 0, MEM_RELEASE);
|
||||
|
||||
if !got_exit {
|
||||
return fail(Stage::LoadLibraryFailed, "GetExitCodeThread failed");
|
||||
}
|
||||
if exit_code == 0 {
|
||||
log("remote LoadLibraryW returned NULL -- DLL failed to load in target");
|
||||
return Stage::LoadLibraryFailed;
|
||||
}
|
||||
|
||||
log(&format!(
|
||||
"injection succeeded (remote LoadLibraryW HMODULE low bits={exit_code:#010x})"
|
||||
));
|
||||
Stage::Success
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const {existsSync, readdirSync, readFileSync, statSync} = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const {basename} = require('node:path');
|
||||
const {spawnSync} = require('node:child_process');
|
||||
const NATIVE_LOAD_ERROR_MARKER = Symbol.for('fluxer.nativeLoadError');
|
||||
const MAX_TEXT_LENGTH = 6000;
|
||||
const MAX_DIRECTORY_ENTRIES = 80;
|
||||
|
||||
function trimText(value, limit = MAX_TEXT_LENGTH) {
|
||||
const text = Buffer.isBuffer(value) ? value.toString('utf8') : String(value ?? '');
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.length > limit ? `${trimmed.slice(0, limit)}\n...<truncated>` : trimmed;
|
||||
}
|
||||
|
||||
function errorDiagnostic(error) {
|
||||
if (!error) return null;
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: error.name || 'Error',
|
||||
message: error.message,
|
||||
code: error.code || null,
|
||||
stack: trimText(error.stack || error.message),
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: typeof error,
|
||||
message: trimText(String(error)),
|
||||
code: null,
|
||||
stack: null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatErrorDiagnostic(diagnostic) {
|
||||
if (!diagnostic) return null;
|
||||
const lines = [];
|
||||
if (diagnostic.code) lines.push(`code=${diagnostic.code}`);
|
||||
if (diagnostic.stack) lines.push(diagnostic.stack);
|
||||
else if (diagnostic.message) lines.push(diagnostic.message);
|
||||
return trimText(lines.join('\n'));
|
||||
}
|
||||
|
||||
function fileDiagnostic(filePath) {
|
||||
if (!filePath) return {path: null, exists: false, error: 'not resolved'};
|
||||
try {
|
||||
const stat = statSync(filePath);
|
||||
return {
|
||||
path: filePath,
|
||||
exists: true,
|
||||
size: stat.size,
|
||||
mode: `0${(stat.mode & 0o777).toString(8)}`,
|
||||
mtime: stat.mtime.toISOString(),
|
||||
isFile: stat.isFile(),
|
||||
isDirectory: stat.isDirectory(),
|
||||
};
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
return {path: filePath, exists: false, error: reason};
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileDiagnostic(diagnostic) {
|
||||
if (!diagnostic) return 'not resolved';
|
||||
if (!diagnostic.exists) return `exists=false, statError=${diagnostic.error || '<unknown>'}`;
|
||||
return [
|
||||
`exists=true`,
|
||||
`size=${diagnostic.size}`,
|
||||
`mode=${diagnostic.mode}`,
|
||||
`mtime=${diagnostic.mtime}`,
|
||||
`isFile=${diagnostic.isFile}`,
|
||||
].join(', ');
|
||||
}
|
||||
|
||||
function directoryDiagnostic(dirPath) {
|
||||
if (!dirPath) return {path: null, ok: false, error: 'not resolved', entries: [], total: 0, omitted: 0};
|
||||
try {
|
||||
const entries = readdirSync(dirPath, {withFileTypes: true}).map((entry) => ({
|
||||
name: entry.name,
|
||||
type: entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : 'other',
|
||||
}));
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const visible = entries.slice(0, MAX_DIRECTORY_ENTRIES);
|
||||
return {
|
||||
path: dirPath,
|
||||
ok: true,
|
||||
entries: visible,
|
||||
total: entries.length,
|
||||
omitted: Math.max(0, entries.length - visible.length),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
path: dirPath,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
entries: [],
|
||||
total: 0,
|
||||
omitted: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function formatDirectoryDiagnostic(diagnostic) {
|
||||
if (!diagnostic) return '<unavailable>';
|
||||
if (!diagnostic.ok) return `directory listing failed: ${diagnostic.error || '<unknown>'}`;
|
||||
const entries = diagnostic.entries.map((entry) => `${entry.name}${entry.type === 'directory' ? '/' : ''}`);
|
||||
const suffix = diagnostic.omitted > 0 ? [`...<${diagnostic.omitted} more entries>`] : [];
|
||||
return [...entries, ...suffix].join('\n') || '<empty>';
|
||||
}
|
||||
|
||||
function selectedEnvironmentNames(skipNativeProbeEnv) {
|
||||
const names = [
|
||||
'ELECTRON_RUN_AS_NODE',
|
||||
'FLUXER_NATIVE_MODULE_PREFLIGHT_CHILD',
|
||||
'LD_LIBRARY_PATH',
|
||||
'DYLD_LIBRARY_PATH',
|
||||
'DISPLAY',
|
||||
'WAYLAND_DISPLAY',
|
||||
'XDG_CURRENT_DESKTOP',
|
||||
'XDG_SESSION_TYPE',
|
||||
'DBUS_SESSION_BUS_ADDRESS',
|
||||
'PULSE_SERVER',
|
||||
'PIPEWIRE_REMOTE',
|
||||
'PATH',
|
||||
];
|
||||
if (skipNativeProbeEnv) names.push(skipNativeProbeEnv);
|
||||
return names;
|
||||
}
|
||||
|
||||
function environmentDiagnostics(skipNativeProbeEnv) {
|
||||
return Object.fromEntries(
|
||||
selectedEnvironmentNames(skipNativeProbeEnv).map((name) => [name, process.env[name] ?? null]),
|
||||
);
|
||||
}
|
||||
|
||||
function formatEnvironment(diagnostic) {
|
||||
return Object.entries(diagnostic)
|
||||
.map(([name, value]) => `${name}=${value ?? '<unset>'}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function runtimeDiagnostics() {
|
||||
const versions = process.versions || {};
|
||||
let reportHeader = null;
|
||||
if (process.report && typeof process.report.getReport === 'function') {
|
||||
try {
|
||||
reportHeader = process.report.getReport().header || null;
|
||||
} catch {
|
||||
reportHeader = null;
|
||||
}
|
||||
}
|
||||
const glibcRuntime = versions.glibcVersionRuntime || reportHeader?.glibcVersionRuntime || '<unknown>';
|
||||
const glibcCompiler = versions.glibcVersionCompiler || reportHeader?.glibcVersionCompiler || '<unknown>';
|
||||
return {
|
||||
node: versions.node || null,
|
||||
electron: versions.electron || null,
|
||||
modules: versions.modules || null,
|
||||
napi: versions.napi || null,
|
||||
v8: versions.v8 || null,
|
||||
uv: versions.uv || null,
|
||||
openssl: versions.openssl || null,
|
||||
glibcRuntime,
|
||||
glibcCompiler,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
osType: os.type(),
|
||||
osRelease: os.release(),
|
||||
osVersion: typeof os.version === 'function' ? os.version() : null,
|
||||
execPath: process.execPath,
|
||||
resourcesPath: process.resourcesPath || null,
|
||||
cwd: process.cwd(),
|
||||
};
|
||||
}
|
||||
|
||||
function formatRuntimeDiagnostics(diagnostic) {
|
||||
return [
|
||||
`node=${diagnostic.node || '<unknown>'}`,
|
||||
`electron=${diagnostic.electron || '<none>'}`,
|
||||
`modules=${diagnostic.modules || '<unknown>'}`,
|
||||
`napi=${diagnostic.napi || '<unknown>'}`,
|
||||
`v8=${diagnostic.v8 || '<unknown>'}`,
|
||||
`uv=${diagnostic.uv || '<unknown>'}`,
|
||||
`openssl=${diagnostic.openssl || '<unknown>'}`,
|
||||
`glibcRuntime=${diagnostic.glibcRuntime || '<unknown>'}`,
|
||||
`glibcCompiler=${diagnostic.glibcCompiler || '<unknown>'}`,
|
||||
`process=${diagnostic.platform}/${diagnostic.arch}`,
|
||||
`os=${diagnostic.osType} ${diagnostic.osRelease} ${diagnostic.osVersion || '<unknown>'}`,
|
||||
`execPath=${diagnostic.execPath}`,
|
||||
`resourcesPath=${diagnostic.resourcesPath || '<unknown>'}`,
|
||||
`cwd=${diagnostic.cwd}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const REDISTRIBUTABLE_RUNTIME_PATTERNS = [
|
||||
/^vcruntime\d+(?:_\d+)?\.dll$/i,
|
||||
/^msvcp\d+(?:_\d+)?\.dll$/i,
|
||||
/^msvcr\d+(?:_\d+)?\.dll$/i,
|
||||
/^concrt\d+\.dll$/i,
|
||||
/^vcamp\d+\.dll$/i,
|
||||
/^vcomp\d+\.dll$/i,
|
||||
];
|
||||
|
||||
function readPeImports(filePath) {
|
||||
let buffer;
|
||||
try {
|
||||
buffer = readFileSync(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (buffer.length < 0x40) return null;
|
||||
const peOffset = buffer.readUInt32LE(0x3c);
|
||||
if (peOffset <= 0 || peOffset + 24 >= buffer.length) return null;
|
||||
if (buffer.readUInt32LE(peOffset) !== 0x4550) return null;
|
||||
const coffOffset = peOffset + 4;
|
||||
const numberOfSections = buffer.readUInt16LE(coffOffset + 2);
|
||||
const sizeOfOptionalHeader = buffer.readUInt16LE(coffOffset + 16);
|
||||
const optionalHeaderOffset = coffOffset + 20;
|
||||
if (optionalHeaderOffset + sizeOfOptionalHeader > buffer.length) return null;
|
||||
const magic = buffer.readUInt16LE(optionalHeaderOffset);
|
||||
if (magic !== 0x10b && magic !== 0x20b) return null;
|
||||
const dataDirectoriesOffset = optionalHeaderOffset + (magic === 0x20b ? 112 : 96);
|
||||
const importEntryOffset = dataDirectoriesOffset + 8;
|
||||
if (importEntryOffset + 8 > buffer.length) return null;
|
||||
const importRva = buffer.readUInt32LE(importEntryOffset);
|
||||
if (importRva === 0) return [];
|
||||
const sections = [];
|
||||
const sectionTableOffset = optionalHeaderOffset + sizeOfOptionalHeader;
|
||||
for (let i = 0; i < numberOfSections; i++) {
|
||||
const base = sectionTableOffset + i * 40;
|
||||
if (base + 40 > buffer.length) return null;
|
||||
sections.push({
|
||||
virtualSize: buffer.readUInt32LE(base + 8),
|
||||
virtualAddress: buffer.readUInt32LE(base + 12),
|
||||
rawSize: buffer.readUInt32LE(base + 16),
|
||||
rawPointer: buffer.readUInt32LE(base + 20),
|
||||
});
|
||||
}
|
||||
const rvaToOffset = (rva) => {
|
||||
for (const s of sections) {
|
||||
const span = Math.max(s.virtualSize, s.rawSize);
|
||||
if (rva >= s.virtualAddress && rva < s.virtualAddress + span) {
|
||||
return rva - s.virtualAddress + s.rawPointer;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
const readCString = (offset) => {
|
||||
let end = offset;
|
||||
while (end < buffer.length && buffer[end] !== 0) end++;
|
||||
return buffer.toString('ascii', offset, end);
|
||||
};
|
||||
const importTableOffset = rvaToOffset(importRva);
|
||||
if (importTableOffset < 0) return [];
|
||||
const imports = new Set();
|
||||
for (let i = 0; i < 1024; i++) {
|
||||
const base = importTableOffset + i * 20;
|
||||
if (base + 20 > buffer.length) break;
|
||||
const lookupRva = buffer.readUInt32LE(base);
|
||||
const nameRva = buffer.readUInt32LE(base + 12);
|
||||
const iatRva = buffer.readUInt32LE(base + 16);
|
||||
if (lookupRva === 0 && nameRva === 0 && iatRva === 0) break;
|
||||
const nameOffset = rvaToOffset(nameRva);
|
||||
if (nameOffset < 0) continue;
|
||||
const name = readCString(nameOffset);
|
||||
if (name) imports.add(name);
|
||||
}
|
||||
return Array.from(imports);
|
||||
}
|
||||
|
||||
function windowsImportProbe(nativePath) {
|
||||
const imports = readPeImports(nativePath);
|
||||
if (imports === null) return null;
|
||||
const sortedImports = [...imports].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
const redistributable = sortedImports.filter((dll) =>
|
||||
REDISTRIBUTABLE_RUNTIME_PATTERNS.some((pattern) => pattern.test(dll)),
|
||||
);
|
||||
return {
|
||||
command: ['pe-imports', nativePath],
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
stdout: sortedImports.join('\n') || null,
|
||||
stderr: null,
|
||||
missing: [],
|
||||
redistributable,
|
||||
};
|
||||
}
|
||||
|
||||
function dependencyProbe(nativePath) {
|
||||
if (!nativePath || !existsSync(nativePath)) return null;
|
||||
if (process.platform === 'win32') return windowsImportProbe(nativePath);
|
||||
const command =
|
||||
process.platform === 'linux'
|
||||
? ['ldd', nativePath]
|
||||
: process.platform === 'darwin'
|
||||
? ['otool', '-L', nativePath]
|
||||
: null;
|
||||
if (!command) return null;
|
||||
const [bin, ...args] = command;
|
||||
const result = spawnSync(bin, args, {
|
||||
encoding: 'utf8',
|
||||
timeout: 4000,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const stdout = trimText(result.stdout);
|
||||
const stderr = trimText(result.stderr);
|
||||
const missing =
|
||||
process.platform === 'linux' && stdout
|
||||
? stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.includes('not found'))
|
||||
: [];
|
||||
return {
|
||||
command,
|
||||
status: result.status,
|
||||
signal: result.signal || null,
|
||||
error: result.error ? result.error.message : null,
|
||||
stdout,
|
||||
stderr,
|
||||
missing,
|
||||
redistributable: [],
|
||||
};
|
||||
}
|
||||
|
||||
function formatDependencyProbe(diagnostic) {
|
||||
if (!diagnostic) return null;
|
||||
const status = diagnostic.error
|
||||
? `error=${diagnostic.error}`
|
||||
: diagnostic.signal
|
||||
? `signal=${diagnostic.signal}`
|
||||
: `status=${diagnostic.status}`;
|
||||
return [
|
||||
`$ ${diagnostic.command.join(' ')}`,
|
||||
status,
|
||||
diagnostic.missing?.length ? `missing:\n${diagnostic.missing.join('\n')}` : null,
|
||||
diagnostic.redistributable?.length
|
||||
? `redistributableRuntimeImports (require VC++ redist on host):\n${diagnostic.redistributable.join('\n')}`
|
||||
: null,
|
||||
diagnostic.stdout ? `stdout:\n${diagnostic.stdout}` : null,
|
||||
diagnostic.stderr ? `stderr:\n${diagnostic.stderr}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function formatExtraDiagnostic(diagnostic) {
|
||||
if (!diagnostic) return null;
|
||||
if (typeof diagnostic === 'string') return diagnostic;
|
||||
if (typeof diagnostic === 'object' && diagnostic.name && diagnostic.text) {
|
||||
return `${diagnostic.name}:\n${diagnostic.text}`;
|
||||
}
|
||||
return `extra:\n${trimText(JSON.stringify(diagnostic, null, 2))}`;
|
||||
}
|
||||
|
||||
function collectNativeDiagnostics({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason,
|
||||
cause,
|
||||
skipNativeProbeEnv,
|
||||
extraDiagnostics = [],
|
||||
}) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
moduleName,
|
||||
reason,
|
||||
target: {
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
},
|
||||
packageDir: packageDir || null,
|
||||
nativeRoot: nativeRoot || null,
|
||||
nativePath: nativePath || null,
|
||||
nativeFile: nativePath ? basename(nativePath) : null,
|
||||
nativeFileStat: fileDiagnostic(nativePath),
|
||||
runtime: runtimeDiagnostics(),
|
||||
environment: environmentDiagnostics(skipNativeProbeEnv),
|
||||
nativeRootEntries: directoryDiagnostic(nativeRoot),
|
||||
dependencyProbe: dependencyProbe(nativePath),
|
||||
extraDiagnostics: extraDiagnostics.filter(Boolean),
|
||||
cause: errorDiagnostic(cause),
|
||||
};
|
||||
}
|
||||
|
||||
function formatNativeDiagnostics(diagnostics) {
|
||||
const sections = [
|
||||
`module=${diagnostics.moduleName}`,
|
||||
`reason=${diagnostics.reason}`,
|
||||
`target=${diagnostics.target.platform}/${diagnostics.target.arch}`,
|
||||
`packageDir=${diagnostics.packageDir || '<unknown>'}`,
|
||||
`nativeRoot=${diagnostics.nativeRoot || '<unknown>'}`,
|
||||
`nativePath=${diagnostics.nativePath || '<unknown>'}`,
|
||||
`nativeFile=${diagnostics.nativeFile || '<unknown>'}`,
|
||||
`nativeFileStat=${formatFileDiagnostic(diagnostics.nativeFileStat)}`,
|
||||
`runtime:\n${formatRuntimeDiagnostics(diagnostics.runtime)}`,
|
||||
`environment:\n${formatEnvironment(diagnostics.environment)}`,
|
||||
`nativeRootEntries:\n${formatDirectoryDiagnostic(diagnostics.nativeRootEntries)}`,
|
||||
...diagnostics.extraDiagnostics.map(formatExtraDiagnostic).filter(Boolean),
|
||||
];
|
||||
const dependencyOutput = formatDependencyProbe(diagnostics.dependencyProbe);
|
||||
if (dependencyOutput) sections.push(`dependencyProbe:\n${dependencyOutput}`);
|
||||
const causeText = formatErrorDiagnostic(diagnostics.cause);
|
||||
if (causeText) sections.push(`cause:\n${causeText}`);
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
function isNativeLoadError(error) {
|
||||
return Boolean(error?.[NATIVE_LOAD_ERROR_MARKER]);
|
||||
}
|
||||
|
||||
function createNativeLoadError({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason,
|
||||
cause,
|
||||
skipNativeProbeEnv,
|
||||
extraDiagnostics = [],
|
||||
}) {
|
||||
if (isNativeLoadError(cause)) return cause;
|
||||
const diagnostics = collectNativeDiagnostics({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason,
|
||||
cause,
|
||||
skipNativeProbeEnv,
|
||||
extraDiagnostics,
|
||||
});
|
||||
const error = new Error(`${moduleName} native module failed to load.\n${formatNativeDiagnostics(diagnostics)}`);
|
||||
error.name = 'NativeModuleLoadError';
|
||||
error[NATIVE_LOAD_ERROR_MARKER] = true;
|
||||
error.nativeDiagnostics = diagnostics;
|
||||
error.toJSON = () => ({
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
nativeDiagnostics: diagnostics,
|
||||
});
|
||||
if (cause) error.cause = cause;
|
||||
return error;
|
||||
}
|
||||
|
||||
function probeNativeBinary({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv, timeoutMs = 5000}) {
|
||||
if (!skipNativeProbeEnv || process.env[skipNativeProbeEnv] === '1') {
|
||||
return null;
|
||||
}
|
||||
const result = spawnSync(process.execPath, ['-e', 'require(process.argv[1])', nativePath], {
|
||||
env: {...process.env, ELECTRON_RUN_AS_NODE: '1', [skipNativeProbeEnv]: '1'},
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
if (result.status === 0) return null;
|
||||
const reason = result.error
|
||||
? result.error.message
|
||||
: result.signal
|
||||
? `safety probe terminated by signal ${result.signal}`
|
||||
: `safety probe exited with code ${result.status}`;
|
||||
return createNativeLoadError({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason,
|
||||
skipNativeProbeEnv,
|
||||
extraDiagnostics: [
|
||||
result.stdout ? {name: 'probeStdout', text: trimText(result.stdout)} : null,
|
||||
result.stderr ? {name: 'probeStderr', text: trimText(result.stderr)} : null,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function loadNativeBinding({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv, probe = true}) {
|
||||
if (!existsSync(nativePath)) {
|
||||
return {
|
||||
binding: null,
|
||||
loadError: createNativeLoadError({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason: 'native binary not found',
|
||||
skipNativeProbeEnv,
|
||||
}),
|
||||
};
|
||||
}
|
||||
const nativeProbeError = probe
|
||||
? probeNativeBinary({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv})
|
||||
: null;
|
||||
if (nativeProbeError) {
|
||||
return {binding: null, loadError: nativeProbeError};
|
||||
}
|
||||
try {
|
||||
return {binding: require(nativePath), loadError: null};
|
||||
} catch (error) {
|
||||
return {
|
||||
binding: null,
|
||||
loadError: createNativeLoadError({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason: 'require(nativePath) threw',
|
||||
cause: error,
|
||||
skipNativeProbeEnv,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
collectNativeDiagnostics,
|
||||
createNativeLoadError,
|
||||
formatNativeDiagnostics,
|
||||
isNativeLoadError,
|
||||
loadNativeBinding,
|
||||
probeNativeBinary,
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@fluxer/win-game-capture",
|
||||
"version": "0.0.0",
|
||||
"description": "Native Windows game capture fallback for fullscreen games that display capture cannot capture",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
],
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"files": [
|
||||
"index.js",
|
||||
"index.d.ts",
|
||||
"loader-diagnostics.cjs",
|
||||
"win-game-capture.win32-x64-msvc.node",
|
||||
"win-game-capture.win32-arm64-msvc.node",
|
||||
"fluxer-game-hook.win32-x64-msvc.dll",
|
||||
"fluxer-game-hook.win32-ia32-msvc.dll",
|
||||
"fluxer-game-hook.win32-arm64-msvc.dll",
|
||||
"fluxer-inject-helper.win32-x64-msvc.exe",
|
||||
"fluxer-inject-helper.win32-ia32-msvc.exe",
|
||||
"fluxer-inject-helper.win32-arm64-msvc.exe",
|
||||
"fluxer-vulkan-layer.win32-x64-msvc.dll",
|
||||
"fluxer-vulkan-layer.win32-x64-msvc.json",
|
||||
"fluxer-vulkan-layer.win32-ia32-msvc.dll",
|
||||
"fluxer-vulkan-layer.win32-ia32-msvc.json",
|
||||
"fluxer-vulkan-layer.win32-arm64-msvc.dll",
|
||||
"fluxer-vulkan-layer.win32-arm64-msvc.json"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "cargo run --locked --quiet --manifest-path ../../../tools/ci/Cargo.toml -- build-desktop-native-addon",
|
||||
"test": "cargo test --manifest-path Cargo.toml && node --test index.test.mjs",
|
||||
"test:fixtures": "node scripts/fixture-smoke.mjs",
|
||||
"test:loader": "node --test index.test.mjs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
#!/usr/bin/env node
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {spawn} from 'node:child_process';
|
||||
import {existsSync} from 'node:fs';
|
||||
import {createRequire} from 'node:module';
|
||||
import {dirname, join} from 'node:path';
|
||||
import {setTimeout as delay} from 'node:timers/promises';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const capture = require('../index.js');
|
||||
|
||||
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const DEFAULT_FIXTURES = ['d3d11-present-fixture'];
|
||||
const ALL_FIXTURES = [
|
||||
'd3d9-present-fixture',
|
||||
'd3d10-present-fixture',
|
||||
'd3d11-present-fixture',
|
||||
'd3d12-present-fixture',
|
||||
'opengl-swapbuffers-fixture',
|
||||
'vulkan-present-fixture',
|
||||
];
|
||||
const WIDTH = Number.parseInt(process.env.FLUXER_WIN_GAME_CAPTURE_FIXTURE_WIDTH ?? '640', 10);
|
||||
const HEIGHT = Number.parseInt(process.env.FLUXER_WIN_GAME_CAPTURE_FIXTURE_HEIGHT ?? '360', 10);
|
||||
const FRAME_RATE = Number.parseInt(process.env.FLUXER_WIN_GAME_CAPTURE_FIXTURE_FPS ?? '30', 10);
|
||||
const START_TIMEOUT_MS = Number.parseInt(process.env.FLUXER_WIN_GAME_CAPTURE_FIXTURE_START_TIMEOUT_MS ?? '15000', 10);
|
||||
const FRAME_TIMEOUT_MS = Number.parseInt(process.env.FLUXER_WIN_GAME_CAPTURE_FIXTURE_FRAME_TIMEOUT_MS ?? '15000', 10);
|
||||
|
||||
const TRANSPORT_MEMORY = 0;
|
||||
const TRANSPORT_SHARED_TEXTURE = 1;
|
||||
|
||||
const API_OPENGL = 1;
|
||||
const API_D3D9 = 3;
|
||||
const API_D3D10 = 4;
|
||||
const API_D3D11 = 5;
|
||||
const API_D3D12 = 6;
|
||||
const API_VULKAN = 7;
|
||||
|
||||
const FALLBACK_NONE = 0;
|
||||
const FALLBACK_SHARED_TEXTURE_UNSUPPORTED = 1;
|
||||
|
||||
const EXPECTED_DIAGNOSTICS = {
|
||||
'd3d9-present-fixture': {
|
||||
apiType: API_D3D9,
|
||||
transport: TRANSPORT_SHARED_TEXTURE,
|
||||
fallbackReason: FALLBACK_NONE,
|
||||
requiresDxgiFormat: true,
|
||||
},
|
||||
'd3d10-present-fixture': {
|
||||
apiType: API_D3D10,
|
||||
transport: TRANSPORT_SHARED_TEXTURE,
|
||||
fallbackReason: FALLBACK_NONE,
|
||||
requiresDxgiFormat: true,
|
||||
},
|
||||
'd3d11-present-fixture': {
|
||||
apiType: API_D3D11,
|
||||
transport: TRANSPORT_SHARED_TEXTURE,
|
||||
fallbackReason: FALLBACK_NONE,
|
||||
requiresDxgiFormat: true,
|
||||
},
|
||||
'd3d12-present-fixture': {
|
||||
apiType: API_D3D12,
|
||||
transport: TRANSPORT_SHARED_TEXTURE,
|
||||
fallbackReason: FALLBACK_NONE,
|
||||
requiresDxgiFormat: true,
|
||||
},
|
||||
'opengl-swapbuffers-fixture': {
|
||||
apiType: API_OPENGL,
|
||||
transportOneOf: [TRANSPORT_SHARED_TEXTURE, TRANSPORT_MEMORY],
|
||||
fallbackReasonOneOf: [FALLBACK_NONE, FALLBACK_SHARED_TEXTURE_UNSUPPORTED],
|
||||
requiresDxgiFormatWhenShared: true,
|
||||
},
|
||||
'vulkan-present-fixture': {
|
||||
apiType: API_VULKAN,
|
||||
transport: TRANSPORT_SHARED_TEXTURE,
|
||||
fallbackReason: FALLBACK_NONE,
|
||||
requiresDxgiFormat: true,
|
||||
},
|
||||
'i686-present-fixture': {
|
||||
apiType: API_D3D11,
|
||||
transport: TRANSPORT_SHARED_TEXTURE,
|
||||
fallbackReason: FALLBACK_NONE,
|
||||
requiresDxgiFormat: true,
|
||||
},
|
||||
};
|
||||
|
||||
function envFlag(name) {
|
||||
return /^(1|true|yes|on)$/i.test(process.env[name] ?? '');
|
||||
}
|
||||
|
||||
function selectedFixtures() {
|
||||
const raw = process.argv.slice(2).join(',') || process.env.FLUXER_WIN_GAME_CAPTURE_FIXTURES || '';
|
||||
if (!raw.trim()) return DEFAULT_FIXTURES;
|
||||
const names = raw
|
||||
.split(',')
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean);
|
||||
return names.flatMap((name) => (name === 'all' ? ALL_FIXTURES : [name]));
|
||||
}
|
||||
|
||||
function fixtureTarget(fixture) {
|
||||
return fixture === 'i686-present-fixture' ? 'i686-pc-windows-msvc' : null;
|
||||
}
|
||||
|
||||
function fixtureExePath(fixture) {
|
||||
const target = fixtureTarget(fixture);
|
||||
const targetDir = target
|
||||
? join(ROOT, 'test-apps', fixture, 'target', target, 'release')
|
||||
: join(ROOT, 'test-apps', fixture, 'target', 'release');
|
||||
return join(targetDir, `${fixture}.exe`);
|
||||
}
|
||||
|
||||
function envKeyForFixture(fixture) {
|
||||
return `FLUXER_WIN_GAME_CAPTURE_FIXTURE_ARGS_${fixture.toUpperCase().replaceAll(/[^A-Z0-9]+/g, '_')}`;
|
||||
}
|
||||
|
||||
function fixtureEnvValue(baseName, fixture) {
|
||||
const fixtureKey = `${baseName}_${fixture.toUpperCase().replaceAll(/[^A-Z0-9]+/g, '_')}`;
|
||||
return process.env[fixtureKey] ?? process.env[baseName];
|
||||
}
|
||||
|
||||
function splitExtraArgs(raw) {
|
||||
return (raw ?? '')
|
||||
.split(/\s+/)
|
||||
.map((arg) => arg.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function extraFixtureArgs(fixture) {
|
||||
return [
|
||||
...splitExtraArgs(process.env.FLUXER_WIN_GAME_CAPTURE_FIXTURE_ARGS),
|
||||
...splitExtraArgs(process.env[envKeyForFixture(fixture)]),
|
||||
];
|
||||
}
|
||||
|
||||
function diagnosticOverride(baseName, fixture) {
|
||||
const raw = fixtureEnvValue(baseName, fixture);
|
||||
if (raw === undefined || raw === '') return undefined;
|
||||
const value = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(value)) throw new Error(`Invalid ${baseName} override: ${raw}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function runCommand(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd ?? ROOT,
|
||||
env: process.env,
|
||||
stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: false,
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
if (options.echo) process.stdout.write(chunk);
|
||||
});
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
if (options.echo) process.stderr.write(chunk);
|
||||
});
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolve({stdout, stderr});
|
||||
return;
|
||||
}
|
||||
reject(new Error(`${command} ${args.join(' ')} failed with ${signal ?? code}\n${stderr || stdout}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function buildFixture(fixture) {
|
||||
const manifest = join(ROOT, 'test-apps', fixture, 'Cargo.toml');
|
||||
if (!existsSync(manifest)) throw new Error(`unknown fixture: ${fixture}`);
|
||||
const exe = fixtureExePath(fixture);
|
||||
if (envFlag('FLUXER_WIN_GAME_CAPTURE_FIXTURE_SKIP_BUILD') && existsSync(exe)) {
|
||||
console.log(`[fixture-smoke] using existing ${fixture}`);
|
||||
return exe;
|
||||
}
|
||||
const args = ['build', '--release', '--manifest-path', manifest];
|
||||
const target = fixtureTarget(fixture);
|
||||
if (target) args.push('--target', target);
|
||||
console.log(`[fixture-smoke] building ${fixture}`);
|
||||
await runCommand('cargo', args, {echo: envFlag('FLUXER_WIN_GAME_CAPTURE_FIXTURE_VERBOSE')});
|
||||
if (!existsSync(exe)) throw new Error(`fixture build did not produce ${exe}`);
|
||||
return exe;
|
||||
}
|
||||
|
||||
function waitForHwnd(child, fixture) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error(`${fixture} did not print HWND within ${START_TIMEOUT_MS}ms\n${stderr || stdout}`));
|
||||
}, START_TIMEOUT_MS);
|
||||
const finish = (hwnd) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(hwnd);
|
||||
};
|
||||
child.stdout.on('data', (chunk) => {
|
||||
const text = chunk.toString();
|
||||
stdout += text;
|
||||
const match = stdout.match(/HWND=(\d+)/);
|
||||
if (match) finish(match[1]);
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
if (envFlag('FLUXER_WIN_GAME_CAPTURE_FIXTURE_VERBOSE')) process.stderr.write(chunk);
|
||||
});
|
||||
child.once('exit', (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`${fixture} exited before capture started (${signal ?? code})\n${stderr || stdout}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function frameSignature(frame) {
|
||||
const bytes = frame.data;
|
||||
const width = Math.max(1, frame.width);
|
||||
const height = Math.max(1, frame.height);
|
||||
const stride = Math.max(1, frame.strideY || (frame.format === 'bgra' ? width * 4 : width));
|
||||
const rows = frame.format === 'bgra' ? Math.min(height, 12) : Math.min(height, 32);
|
||||
let hash = 2166136261;
|
||||
for (let y = 0; y < rows; y += 1) {
|
||||
const row = y * stride;
|
||||
const rowBytes = frame.format === 'bgra' ? Math.min(stride, width * 4, 256) : Math.min(stride, width, 256);
|
||||
for (let x = 0; x < rowBytes; x += 4) {
|
||||
hash ^= bytes[row + x] ?? 0;
|
||||
hash = Math.imul(hash, 16777619) >>> 0;
|
||||
}
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
async function waitForAdvancingFrames(screenCapture, fixture) {
|
||||
const signatures = new Set();
|
||||
let frameCount = 0;
|
||||
let lastDiagnostics = null;
|
||||
const onFrame = (frame) => {
|
||||
frameCount += 1;
|
||||
signatures.add(frameSignature(frame));
|
||||
lastDiagnostics = screenCapture.getDiagnostics?.() ?? lastDiagnostics;
|
||||
};
|
||||
screenCapture.on('frame', onFrame);
|
||||
const start = Date.now();
|
||||
try {
|
||||
while (Date.now() - start < FRAME_TIMEOUT_MS) {
|
||||
lastDiagnostics = screenCapture.getDiagnostics?.() ?? lastDiagnostics;
|
||||
const nativeFrames = Number(lastDiagnostics?.frameCounter ?? 0);
|
||||
if (frameCount >= 3 && (signatures.size >= 2 || nativeFrames >= 3)) {
|
||||
return {frameCount, signatures: signatures.size, diagnostics: lastDiagnostics};
|
||||
}
|
||||
await delay(100);
|
||||
}
|
||||
throw new Error(
|
||||
`${fixture} capture did not deliver advancing frames within ${FRAME_TIMEOUT_MS}ms (frames=${frameCount}, signatures=${signatures.size}, diagnostics=${JSON.stringify(lastDiagnostics)})`,
|
||||
);
|
||||
} finally {
|
||||
screenCapture.off('frame', onFrame);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEqualDiagnostic(fixture, diagnostics, key, expected) {
|
||||
if (diagnostics?.[key] !== expected) {
|
||||
throw new Error(`${fixture} expected diagnostics.${key}=${expected}, got ${JSON.stringify(diagnostics)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertOneOfDiagnostic(fixture, diagnostics, key, expected) {
|
||||
if (!expected.includes(diagnostics?.[key])) {
|
||||
throw new Error(
|
||||
`${fixture} expected diagnostics.${key} in [${expected.join(', ')}], got ${JSON.stringify(diagnostics)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertFixtureDiagnostics(fixture, diagnostics) {
|
||||
const expectedBase = EXPECTED_DIAGNOSTICS[fixture];
|
||||
if (!expectedBase) return;
|
||||
const expected = {...expectedBase};
|
||||
const apiOverride = diagnosticOverride('FLUXER_WIN_GAME_CAPTURE_EXPECT_API_TYPE', fixture);
|
||||
const transportOverride = diagnosticOverride('FLUXER_WIN_GAME_CAPTURE_EXPECT_TRANSPORT', fixture);
|
||||
const fallbackOverride = diagnosticOverride('FLUXER_WIN_GAME_CAPTURE_EXPECT_FALLBACK_REASON', fixture);
|
||||
if (apiOverride !== undefined) expected.apiType = apiOverride;
|
||||
if (transportOverride !== undefined) {
|
||||
expected.transport = transportOverride;
|
||||
delete expected.transportOneOf;
|
||||
expected.requiresDxgiFormat = expected.transport === TRANSPORT_SHARED_TEXTURE;
|
||||
}
|
||||
if (fallbackOverride !== undefined) {
|
||||
expected.fallbackReason = fallbackOverride;
|
||||
delete expected.fallbackReasonOneOf;
|
||||
}
|
||||
|
||||
assertEqualDiagnostic(fixture, diagnostics, 'apiType', expected.apiType);
|
||||
if (expected.transportOneOf) {
|
||||
assertOneOfDiagnostic(fixture, diagnostics, 'transport', expected.transportOneOf);
|
||||
} else {
|
||||
assertEqualDiagnostic(fixture, diagnostics, 'transport', expected.transport);
|
||||
}
|
||||
if (expected.fallbackReason !== undefined) {
|
||||
assertEqualDiagnostic(fixture, diagnostics, 'fallbackReason', expected.fallbackReason);
|
||||
} else if (expected.fallbackReasonOneOf) {
|
||||
assertOneOfDiagnostic(fixture, diagnostics, 'fallbackReason', expected.fallbackReasonOneOf);
|
||||
}
|
||||
if (diagnostics?.activeStrategy !== 'game-hook') {
|
||||
throw new Error(`${fixture} expected activeStrategy=game-hook, got ${JSON.stringify(diagnostics)}`);
|
||||
}
|
||||
if (
|
||||
(expected.requiresDxgiFormat ||
|
||||
(expected.requiresDxgiFormatWhenShared && diagnostics?.transport === TRANSPORT_SHARED_TEXTURE)) &&
|
||||
Number(diagnostics?.dxgiFormat ?? 0) === 0
|
||||
) {
|
||||
throw new Error(`${fixture} expected a non-zero shared texture DXGI format, got ${JSON.stringify(diagnostics)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runFixture(fixture) {
|
||||
const exe = await buildFixture(fixture);
|
||||
const args = ['--frames', '900', '--width', String(WIDTH), '--height', String(HEIGHT), '--windowed'];
|
||||
if (fixture === 'vulkan-present-fixture') args.push('--resize-at', '120');
|
||||
args.push(...extraFixtureArgs(fixture));
|
||||
const child = spawn(exe, args, {
|
||||
cwd: dirname(exe),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: false,
|
||||
});
|
||||
let started = false;
|
||||
try {
|
||||
const hwnd = await waitForHwnd(child, fixture);
|
||||
const screenCapture = new capture.ScreenCapture({
|
||||
sourceId: `window:${hwnd}:0`,
|
||||
sourceKind: 'game',
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
frameRate: FRAME_RATE,
|
||||
injectionMethod: process.env.FLUXER_WIN_GAME_CAPTURE_INJECTION_METHOD || 'auto',
|
||||
});
|
||||
let result;
|
||||
try {
|
||||
result = await screenCapture.start();
|
||||
started = true;
|
||||
const observed = await waitForAdvancingFrames(screenCapture, fixture);
|
||||
assertFixtureDiagnostics(fixture, observed.diagnostics);
|
||||
console.log(
|
||||
`[fixture-smoke] PASS ${fixture}: start=${JSON.stringify(result)} frames=${observed.frameCount} signatures=${observed.signatures} diagnostics=${JSON.stringify(observed.diagnostics)}`,
|
||||
);
|
||||
} finally {
|
||||
if (started) await screenCapture.stop().catch(() => {});
|
||||
}
|
||||
} finally {
|
||||
if (!child.killed) child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.platform !== 'win32') {
|
||||
console.log('[fixture-smoke] SKIP: Windows game-capture fixtures only run on Windows');
|
||||
return 0;
|
||||
}
|
||||
if (!capture.isSupported()) {
|
||||
throw new Error(`win-game-capture binding unavailable: ${capture.loadError?.message ?? 'unknown error'}`);
|
||||
}
|
||||
for (const fixture of selectedFixtures()) {
|
||||
await runFixture(fixture);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
process.exitCode = await main();
|
||||
} catch (error) {
|
||||
console.error(`[fixture-smoke] FAIL: ${error.stack ?? error.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum InjectionPolicy {
|
||||
Allow,
|
||||
ForceCpuReadback,
|
||||
Deny(String),
|
||||
}
|
||||
|
||||
const HARD_DENY_PROCESS_NAMES: &[(&str, &str)] = &[
|
||||
("easyanticheat.exe", "EasyAntiCheat"),
|
||||
("easyanticheat_eos.exe", "EasyAntiCheat"),
|
||||
("easyanticheat_launcher.exe", "EasyAntiCheat"),
|
||||
("eac.exe", "EasyAntiCheat"),
|
||||
("eac_launcher.exe", "EasyAntiCheat"),
|
||||
("beservice.exe", "BattlEye"),
|
||||
("beservice_x64.exe", "BattlEye"),
|
||||
("bedaisy.exe", "BattlEye"),
|
||||
("be_service.exe", "BattlEye"),
|
||||
("vgc.exe", "Riot Vanguard"),
|
||||
("vgk.exe", "Riot Vanguard"),
|
||||
("vgtray.exe", "Riot Vanguard"),
|
||||
("vanguard.exe", "Riot Vanguard"),
|
||||
("destiny2.exe", "Destiny 2 anti-cheat policy"),
|
||||
("equ8.exe", "EQU8 anti-cheat"),
|
||||
("equ8_service.exe", "EQU8 anti-cheat"),
|
||||
("gameguard.des", "nProtect GameGuard"),
|
||||
("gamemon.des", "nProtect GameGuard"),
|
||||
("gamemon64.des", "nProtect GameGuard"),
|
||||
("npggnt.des", "nProtect GameGuard"),
|
||||
("xigncode.exe", "XIGNCODE"),
|
||||
("xigncode3.exe", "XIGNCODE3"),
|
||||
("mhyprot.exe", "mhyprot anti-cheat"),
|
||||
("mhyprot2.exe", "mhyprot anti-cheat"),
|
||||
("anticheatexpert.exe", "Anti-Cheat Expert"),
|
||||
("ace-base.exe", "Anti-Cheat Expert"),
|
||||
("faceit.exe", "FACEIT Anti-cheat"),
|
||||
("faceitclient.exe", "FACEIT Anti-cheat"),
|
||||
("faceitservice.exe", "FACEIT Anti-cheat"),
|
||||
("esea.exe", "ESEA Anti-cheat"),
|
||||
("eseaclient.exe", "ESEA Anti-cheat"),
|
||||
("eseaservice.exe", "ESEA Anti-cheat"),
|
||||
("punkbuster.exe", "PunkBuster"),
|
||||
("pnkbstra.exe", "PunkBuster"),
|
||||
("pnkbstrb.exe", "PunkBuster"),
|
||||
("system", "Windows kernel process"),
|
||||
("csrss.exe", "Windows system process"),
|
||||
("smss.exe", "Windows system process"),
|
||||
("wininit.exe", "Windows system process"),
|
||||
("winlogon.exe", "Windows system process"),
|
||||
("services.exe", "Windows system process"),
|
||||
("svchost.exe", "Windows service host"),
|
||||
("dwm.exe", "Windows compositor"),
|
||||
("fontdrvhost.exe", "Windows font driver host"),
|
||||
("logonui.exe", "Windows secure desktop"),
|
||||
("consent.exe", "Windows secure desktop"),
|
||||
("secureuxhost.exe", "Windows secure desktop"),
|
||||
("lsass.exe", "Windows security process"),
|
||||
("lsaiso.exe", "Windows security process"),
|
||||
("msmpeng.exe", "Microsoft Defender"),
|
||||
("securityhealthservice.exe", "Windows Security"),
|
||||
("securityhealthsystray.exe", "Windows Security"),
|
||||
("audiodg.exe", "Windows protected audio graph"),
|
||||
("wudfhost.exe", "Windows driver host"),
|
||||
("taskhostw.exe", "Windows task host"),
|
||||
("dllhost.exe", "Windows COM surrogate"),
|
||||
("runtimebroker.exe", "Windows runtime broker"),
|
||||
("applicationframehost.exe", "Windows application frame host"),
|
||||
("lockapp.exe", "Windows lock screen"),
|
||||
("sihost.exe", "Windows shell infrastructure"),
|
||||
("startmenuexperiencehost.exe", "Windows shell"),
|
||||
("searchhost.exe", "Windows shell"),
|
||||
("searchapp.exe", "Windows shell"),
|
||||
("textinputhost.exe", "Windows shell"),
|
||||
("explorer.exe", "Windows shell"),
|
||||
("taskmgr.exe", "Windows administrative tool"),
|
||||
("regedit.exe", "Windows administrative tool"),
|
||||
("mmc.exe", "Windows administrative tool"),
|
||||
("obs32.exe", "capture application"),
|
||||
("obs64.exe", "capture application"),
|
||||
("fluxer.exe", "Fluxer application"),
|
||||
("fluxer-desktop.exe", "Fluxer application"),
|
||||
("fluxer_desktop.exe", "Fluxer application"),
|
||||
];
|
||||
|
||||
const COMPATIBILITY_DENY_PROCESS_NAMES: &[(&str, &str)] = &[
|
||||
("gta-sa.exe", "legacy D3D8/RenderWare compatibility"),
|
||||
("samp.exe", "legacy D3D8/RenderWare compatibility"),
|
||||
("leagueclientux.exe", "League of Legends launcher"),
|
||||
("steamwebhelper.exe", "Chromium-based launcher"),
|
||||
("epicgameslauncher.exe", "Chromium-based launcher"),
|
||||
("riotclientux.exe", "Riot client"),
|
||||
("riotclientservices.exe", "Riot client"),
|
||||
("battle.net.exe", "Chromium-based launcher"),
|
||||
("gamingservices.exe", "Xbox Gaming Services"),
|
||||
("gamingservicesnet.exe", "Xbox Gaming Services"),
|
||||
];
|
||||
|
||||
const COMPATIBILITY_DENY_WINDOW_CLASSES: &[(&str, &str)] = &[
|
||||
("chrome_widgetwin_0", "Chromium-based game window"),
|
||||
("chrome_widgetwin_1", "Chromium-based game window"),
|
||||
(
|
||||
"gamingservicesui_hosting_window_class",
|
||||
"Xbox Gaming Services",
|
||||
),
|
||||
];
|
||||
|
||||
const FORCE_CPU_PROCESS_NAMES: &[&str] = &["terraria.exe"];
|
||||
|
||||
const OVERRIDE_FILE_NAME: &str = "compatibility.json";
|
||||
|
||||
pub fn injection_policy(target_pid: u32) -> InjectionPolicy {
|
||||
let exe_name = match target_process_exe_name(target_pid) {
|
||||
Some(name) => name,
|
||||
None => {
|
||||
return InjectionPolicy::Allow;
|
||||
}
|
||||
};
|
||||
evaluate_policy(&exe_name, None, load_override())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn injection_policy_for_window(
|
||||
target_pid: u32,
|
||||
hwnd: windows_sys::Win32::Foundation::HWND,
|
||||
) -> InjectionPolicy {
|
||||
let exe_name = match target_process_exe_name(target_pid) {
|
||||
Some(name) => name,
|
||||
None => {
|
||||
return InjectionPolicy::Allow;
|
||||
}
|
||||
};
|
||||
evaluate_policy(
|
||||
&exe_name,
|
||||
target_window_class_name(hwnd).as_deref(),
|
||||
load_override(),
|
||||
)
|
||||
}
|
||||
|
||||
fn evaluate_policy(
|
||||
exe_name: &str,
|
||||
window_class: Option<&str>,
|
||||
override_lists: Option<OverrideLists>,
|
||||
) -> InjectionPolicy {
|
||||
let exe_name_lower = file_name_lower(exe_name);
|
||||
let exe_name_lower = exe_name_lower.as_str();
|
||||
let window_class_lower = window_class.map(|name| name.trim().to_ascii_lowercase());
|
||||
let window_class_lower = window_class_lower.as_deref();
|
||||
|
||||
if let Some(lists) = override_lists.as_ref() {
|
||||
if let Some(reason) = embedded_hard_deny_reason(exe_name_lower) {
|
||||
return reason;
|
||||
}
|
||||
if lists.deny.iter().any(|name| name == exe_name_lower) {
|
||||
return InjectionPolicy::Deny(format!(
|
||||
"{exe_name_lower} is on the local compatibility deny list; Fluxer will not inject \
|
||||
its game-capture hook"
|
||||
));
|
||||
}
|
||||
let allowed_by_override = lists.allow.iter().any(|name| name == exe_name_lower);
|
||||
if !allowed_by_override
|
||||
&& let Some(reason) = embedded_compatibility_deny_reason(exe_name_lower)
|
||||
{
|
||||
return reason;
|
||||
}
|
||||
if !allowed_by_override
|
||||
&& let Some(reason) = embedded_window_class_deny_reason(window_class_lower)
|
||||
{
|
||||
return reason;
|
||||
}
|
||||
if lists.force_cpu.iter().any(|name| name == exe_name_lower) {
|
||||
return InjectionPolicy::ForceCpuReadback;
|
||||
}
|
||||
if allowed_by_override {
|
||||
return InjectionPolicy::Allow;
|
||||
}
|
||||
} else {
|
||||
if let Some(reason) = embedded_hard_deny_reason(exe_name_lower) {
|
||||
return reason;
|
||||
}
|
||||
if let Some(reason) = embedded_compatibility_deny_reason(exe_name_lower) {
|
||||
return reason;
|
||||
}
|
||||
if let Some(reason) = embedded_window_class_deny_reason(window_class_lower) {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
|
||||
if FORCE_CPU_PROCESS_NAMES.contains(&exe_name_lower) {
|
||||
return InjectionPolicy::ForceCpuReadback;
|
||||
}
|
||||
InjectionPolicy::Allow
|
||||
}
|
||||
|
||||
fn embedded_hard_deny_reason(exe_name_lower: &str) -> Option<InjectionPolicy> {
|
||||
HARD_DENY_PROCESS_NAMES
|
||||
.iter()
|
||||
.find(|(name, _)| *name == exe_name_lower)
|
||||
.map(|(_, label)| {
|
||||
InjectionPolicy::Deny(format!(
|
||||
"{exe_name_lower} is protected by {label}; Fluxer will not inject its game-capture \
|
||||
hook into anti-cheat or security-sensitive processes"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn embedded_compatibility_deny_reason(exe_name_lower: &str) -> Option<InjectionPolicy> {
|
||||
COMPATIBILITY_DENY_PROCESS_NAMES
|
||||
.iter()
|
||||
.find(|(name, _)| *name == exe_name_lower)
|
||||
.map(|(_, label)| {
|
||||
InjectionPolicy::Deny(format!(
|
||||
"{exe_name_lower} has known game-capture compatibility issues ({label}); Fluxer \
|
||||
will not inject its game-capture hook by default"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn embedded_window_class_deny_reason(window_class_lower: Option<&str>) -> Option<InjectionPolicy> {
|
||||
let window_class_lower = window_class_lower?;
|
||||
COMPATIBILITY_DENY_WINDOW_CLASSES
|
||||
.iter()
|
||||
.find(|(name, _)| *name == window_class_lower)
|
||||
.map(|(_, label)| {
|
||||
InjectionPolicy::Deny(format!(
|
||||
"window class {window_class_lower} has known OBS game-capture compatibility issues \
|
||||
({label}); Fluxer will not inject its game-capture hook by default"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
struct OverrideLists {
|
||||
deny: Vec<String>,
|
||||
allow: Vec<String>,
|
||||
force_cpu: Vec<String>,
|
||||
}
|
||||
|
||||
impl OverrideLists {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.deny.is_empty() && self.allow.is_empty() && self.force_cpu.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
fn load_override() -> Option<OverrideLists> {
|
||||
let path = override_file_path()?;
|
||||
let contents = std::fs::read_to_string(&path).ok()?;
|
||||
let lists = parse_override_json(&contents);
|
||||
if lists.is_empty() { None } else { Some(lists) }
|
||||
}
|
||||
|
||||
fn override_file_path() -> Option<PathBuf> {
|
||||
if let Some(dir) = addon_directory() {
|
||||
let candidate = dir.join(OVERRIDE_FILE_NAME);
|
||||
if candidate.is_file() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
let exe_dir = std::env::current_exe().ok()?.parent()?.to_path_buf();
|
||||
let candidate = exe_dir.join(OVERRIDE_FILE_NAME);
|
||||
if candidate.is_file() {
|
||||
Some(candidate)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn addon_directory() -> Option<PathBuf> {
|
||||
use std::os::windows::ffi::OsStringExt;
|
||||
use windows_sys::Win32::Foundation::HMODULE;
|
||||
use windows_sys::Win32::System::LibraryLoader::{
|
||||
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
||||
GetModuleFileNameW, GetModuleHandleExW,
|
||||
};
|
||||
|
||||
let mut module: HMODULE = std::ptr::null_mut();
|
||||
let ok = unsafe {
|
||||
GetModuleHandleExW(
|
||||
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
||||
addon_directory as *const u16,
|
||||
&mut module,
|
||||
)
|
||||
};
|
||||
if ok == 0 || module.is_null() {
|
||||
return None;
|
||||
}
|
||||
let mut buffer = vec![0u16; 1024];
|
||||
let len = unsafe { GetModuleFileNameW(module, buffer.as_mut_ptr(), buffer.len() as u32) };
|
||||
if len == 0 || len as usize >= buffer.len() {
|
||||
return None;
|
||||
}
|
||||
buffer.truncate(len as usize);
|
||||
let module_path = PathBuf::from(std::ffi::OsString::from_wide(&buffer));
|
||||
module_path.parent().map(Path::to_path_buf)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn addon_directory() -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn target_process_exe_name(pid: u32) -> Option<String> {
|
||||
use windows_sys::Win32::Foundation::CloseHandle;
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
QueryFullProcessImageNameW,
|
||||
};
|
||||
|
||||
if pid == 0 {
|
||||
return None;
|
||||
}
|
||||
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||
if handle.is_null() {
|
||||
return None;
|
||||
}
|
||||
let mut buffer = vec![0u16; 1024];
|
||||
let mut size = buffer.len() as u32;
|
||||
let ok = unsafe {
|
||||
QueryFullProcessImageNameW(handle, PROCESS_NAME_WIN32, buffer.as_mut_ptr(), &mut size)
|
||||
};
|
||||
unsafe {
|
||||
CloseHandle(handle);
|
||||
}
|
||||
if ok == 0 || size == 0 || size as usize > buffer.len() {
|
||||
return None;
|
||||
}
|
||||
let full_path: String = String::from_utf16_lossy(&buffer[..size as usize]);
|
||||
Some(file_name_lower(&full_path))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn target_window_class_name(hwnd: windows_sys::Win32::Foundation::HWND) -> Option<String> {
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::GetClassNameW;
|
||||
|
||||
if hwnd.is_null() {
|
||||
return None;
|
||||
}
|
||||
let mut buffer = vec![0u16; 256];
|
||||
let len = unsafe { GetClassNameW(hwnd, buffer.as_mut_ptr(), buffer.len() as i32) };
|
||||
if len <= 0 {
|
||||
return None;
|
||||
}
|
||||
buffer.truncate(len as usize);
|
||||
Some(
|
||||
String::from_utf16_lossy(&buffer)
|
||||
.trim()
|
||||
.to_ascii_lowercase(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn target_process_exe_name(_pid: u32) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn file_name_lower(path: &str) -> String {
|
||||
path.rsplit(['\\', '/'])
|
||||
.next()
|
||||
.unwrap_or(path)
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn parse_override_json(text: &str) -> OverrideLists {
|
||||
let mut force_cpu = extract_string_array(text, "forceCpu");
|
||||
force_cpu.extend(extract_string_array(text, "force_cpu"));
|
||||
OverrideLists {
|
||||
deny: extract_string_array(text, "deny"),
|
||||
allow: extract_string_array(text, "allow"),
|
||||
force_cpu,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_string_array(text: &str, key: &str) -> Vec<String> {
|
||||
let needle = format!("\"{key}\"");
|
||||
let mut search_from = 0usize;
|
||||
while let Some(rel) = text[search_from..].find(&needle) {
|
||||
let key_pos = search_from + rel;
|
||||
let after_key = key_pos + needle.len();
|
||||
let rest = text[after_key..].trim_start();
|
||||
if let Some(rest) = rest.strip_prefix(':') {
|
||||
let rest = rest.trim_start();
|
||||
if let Some(array_body) = rest.strip_prefix('[')
|
||||
&& let Some(end) = array_body.find(']')
|
||||
{
|
||||
return parse_json_string_list(&array_body[..end]);
|
||||
}
|
||||
}
|
||||
search_from = after_key;
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn parse_json_string_list(body: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut chars = body.char_indices().peekable();
|
||||
while let Some((_, ch)) = chars.next() {
|
||||
if ch != '"' {
|
||||
continue;
|
||||
}
|
||||
let mut value = String::new();
|
||||
let mut closed = false;
|
||||
while let Some((_, c)) = chars.next() {
|
||||
match c {
|
||||
'\\' => {
|
||||
if let Some((_, escaped)) = chars.next() {
|
||||
match escaped {
|
||||
'n' => value.push('\n'),
|
||||
't' => value.push('\t'),
|
||||
'r' => value.push('\r'),
|
||||
other => value.push(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
'"' => {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
other => value.push(other),
|
||||
}
|
||||
}
|
||||
if closed {
|
||||
let normalised = file_name_lower(&value);
|
||||
if !normalised.is_empty() {
|
||||
out.push(normalised);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn embedded_deny_matches_anticheat() {
|
||||
match evaluate_policy("easyanticheat.exe", None, None) {
|
||||
InjectionPolicy::Deny(reason) => assert!(reason.contains("EasyAntiCheat")),
|
||||
other => panic!("expected Deny, got {other:?}"),
|
||||
}
|
||||
match evaluate_policy("beservice.exe", None, None) {
|
||||
InjectionPolicy::Deny(reason) => assert!(reason.contains("BattlEye")),
|
||||
other => panic!("expected Deny, got {other:?}"),
|
||||
}
|
||||
match evaluate_policy("vgc.exe", None, None) {
|
||||
InjectionPolicy::Deny(reason) => assert!(reason.contains("Vanguard")),
|
||||
other => panic!("expected Deny, got {other:?}"),
|
||||
}
|
||||
match evaluate_policy("destiny2.exe", None, None) {
|
||||
InjectionPolicy::Deny(reason) => assert!(reason.contains("Destiny 2")),
|
||||
other => panic!("expected Deny, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_deny_matches_security_sensitive_windows_processes() {
|
||||
for name in [
|
||||
"lsass.exe",
|
||||
"dwm.exe",
|
||||
"explorer.exe",
|
||||
"applicationframehost.exe",
|
||||
"obs64.exe",
|
||||
"fluxer-desktop.exe",
|
||||
] {
|
||||
match evaluate_policy(name, None, None) {
|
||||
InjectionPolicy::Deny(reason) => {
|
||||
assert!(reason.contains("security-sensitive") || reason.contains("protected"))
|
||||
}
|
||||
other => panic!("expected Deny for {name}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_process_is_allowed() {
|
||||
assert_eq!(
|
||||
evaluate_policy("mygame.exe", None, None),
|
||||
InjectionPolicy::Allow
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_deny_wins() {
|
||||
let lists = parse_override_json(r#"{ "deny": ["MyGame.exe"] }"#);
|
||||
match evaluate_policy("mygame.exe", None, Some(lists)) {
|
||||
InjectionPolicy::Deny(_) => {}
|
||||
other => panic!("expected Deny, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_allow_cannot_unblock_hard_deny() {
|
||||
let lists = parse_override_json(r#"{ "allow": ["easyanticheat.exe"] }"#);
|
||||
match evaluate_policy("easyanticheat.exe", None, Some(lists)) {
|
||||
InjectionPolicy::Deny(reason) => assert!(reason.contains("EasyAntiCheat")),
|
||||
other => panic!("expected hard Deny, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_allow_unblocks_soft_compatibility_deny() {
|
||||
let lists = parse_override_json(r#"{ "allow": ["LeagueClientUx.exe"] }"#);
|
||||
assert_eq!(
|
||||
evaluate_policy("LeagueClientUx.exe", None, Some(lists)),
|
||||
InjectionPolicy::Allow
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_allow_plus_force_cpu_unblocks_soft_deny_with_cpu_readback() {
|
||||
let lists = parse_override_json(
|
||||
r#"{ "allow": ["LeagueClientUx.exe"], "forceCpu": ["LeagueClientUx.exe"] }"#,
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate_policy("LeagueClientUx.exe", None, Some(lists)),
|
||||
InjectionPolicy::ForceCpuReadback
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_force_cpu_applies() {
|
||||
let lists = parse_override_json(r#"{ "forceCpu": ["weird.exe"] }"#);
|
||||
assert_eq!(
|
||||
evaluate_policy("weird.exe", None, Some(lists)),
|
||||
InjectionPolicy::ForceCpuReadback
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_force_cpu_applies_for_known_cross_adapter_case() {
|
||||
assert_eq!(
|
||||
evaluate_policy("Terraria.exe", None, None),
|
||||
InjectionPolicy::ForceCpuReadback
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_force_cpu_cannot_override_hard_deny() {
|
||||
let lists = parse_override_json(r#"{ "forceCpu": ["lsass.exe"] }"#);
|
||||
match evaluate_policy("lsass.exe", None, Some(lists)) {
|
||||
InjectionPolicy::Deny(reason) => assert!(reason.contains("security-sensitive")),
|
||||
other => panic!("expected hard Deny, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_cpu_snake_case_alias_parses() {
|
||||
let lists = parse_override_json(r#"{ "force_cpu": ["weird.exe"] }"#);
|
||||
assert!(lists.force_cpu.contains(&"weird.exe".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_window_class_deny_matches_obs_chromium_game_windows() {
|
||||
match evaluate_policy("game.exe", Some("Chrome_WidgetWin_1"), None) {
|
||||
InjectionPolicy::Deny(reason) => assert!(reason.contains("Chromium")),
|
||||
other => panic!("expected Deny, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_window_class_deny_matches_xbox_gaming_services() {
|
||||
match evaluate_policy(
|
||||
"game.exe",
|
||||
Some("GamingServicesUI_Hosting_Window_Class"),
|
||||
None,
|
||||
) {
|
||||
InjectionPolicy::Deny(reason) => assert!(reason.contains("Xbox Gaming Services")),
|
||||
other => panic!("expected Deny, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_allow_unblocks_soft_window_class_deny() {
|
||||
let lists = parse_override_json(r#"{ "allow": ["game.exe"] }"#);
|
||||
assert_eq!(
|
||||
evaluate_policy("game.exe", Some("Chrome_WidgetWin_0"), Some(lists)),
|
||||
InjectionPolicy::Allow
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_normalises_paths_and_ignores_garbage() {
|
||||
let lists = parse_override_json(
|
||||
r#"{ "deny": ["C:\\Games\\Foo\\Foo.exe", "/opt/bar/Bar.EXE", 123, null] }"#,
|
||||
);
|
||||
assert_eq!(lists.deny, vec!["foo.exe", "bar.exe"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_json_yields_empty() {
|
||||
let lists = parse_override_json("not json at all");
|
||||
assert!(lists.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_name_lower_handles_both_separators() {
|
||||
assert_eq!(file_name_lower("C:\\A\\B\\Game.EXE"), "game.exe");
|
||||
assert_eq!(file_name_lower("/a/b/Game.EXE"), "game.exe");
|
||||
assert_eq!(file_name_lower("bare.exe"), "bare.exe");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,523 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use parking_lot::{Condvar, Mutex};
|
||||
|
||||
use fluxer_encoder_ring::d3d11::D3D11SharedHandle;
|
||||
use fluxer_encoder_ring::{
|
||||
D3D11KeyedMutexBackend, EncoderFrameRate, EncoderInputRing, EncoderReady, RING_SIZE, RingError,
|
||||
TextureFormat,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use fluxer_encoder_ring::{EncoderDims, FillReservation, NvencD3D11Handoff};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use windows::Win32::Graphics::Direct3D11::{ID3D11DeviceContext, ID3D11Texture2D};
|
||||
|
||||
pub const READY_WAIT_TIMEOUT_CAP: Duration = Duration::from_millis(17);
|
||||
|
||||
const EVICTIONS_PER_RESERVE_MAX: u64 = 2;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const NVENC_BITRATE_BPS_DEFAULT: u32 = 8_000_000;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EncoderAttachError {
|
||||
AlreadyAttached,
|
||||
InvalidDimensions { width: u32, height: u32 },
|
||||
RingInitFailed,
|
||||
NotAttached,
|
||||
BlitFailed,
|
||||
SlotUnavailable,
|
||||
DeviceUnavailable,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for EncoderAttachError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::AlreadyAttached => write!(f, "encoder ring already attached"),
|
||||
Self::InvalidDimensions { width, height } => {
|
||||
write!(f, "encoder ring invalid dimensions {width}x{height}")
|
||||
}
|
||||
Self::RingInitFailed => write!(f, "encoder ring initialise failed"),
|
||||
Self::NotAttached => write!(f, "encoder ring not attached"),
|
||||
Self::BlitFailed => write!(f, "blit into ring slot failed"),
|
||||
Self::SlotUnavailable => write!(f, "no ring slot texture available"),
|
||||
Self::DeviceUnavailable => write!(f, "ring backend device unavailable"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for EncoderAttachError {}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct EncoderAttachStats {
|
||||
pub frames_submitted: u64,
|
||||
pub frames_dropped: u64,
|
||||
pub ring_full_events: u64,
|
||||
pub failed_blits: u64,
|
||||
}
|
||||
|
||||
pub struct EncoderAttachment {
|
||||
ring: Mutex<EncoderInputRing<D3D11KeyedMutexBackend>>,
|
||||
ready_condvar: Condvar,
|
||||
attached: AtomicBool,
|
||||
frames_submitted: AtomicU64,
|
||||
frames_dropped: AtomicU64,
|
||||
ring_full_events: AtomicU64,
|
||||
failed_blits: AtomicU64,
|
||||
width: u32,
|
||||
height: u32,
|
||||
frame_rate: EncoderFrameRate,
|
||||
#[cfg(target_os = "windows")]
|
||||
context: ID3D11DeviceContext,
|
||||
#[cfg(target_os = "windows")]
|
||||
nvenc: Mutex<Option<NvencD3D11Handoff>>,
|
||||
}
|
||||
|
||||
impl EncoderAttachment {
|
||||
pub fn try_new(width: u32, height: u32) -> Result<Arc<Self>, EncoderAttachError> {
|
||||
Self::try_new_with_frame_rate(width, height, EncoderFrameRate::default())
|
||||
}
|
||||
|
||||
pub fn try_new_with_frame_rate(
|
||||
width: u32,
|
||||
height: u32,
|
||||
frame_rate: EncoderFrameRate,
|
||||
) -> Result<Arc<Self>, EncoderAttachError> {
|
||||
if width == 0 || height == 0 {
|
||||
return Err(EncoderAttachError::InvalidDimensions { width, height });
|
||||
}
|
||||
assert!(frame_rate.numerator > 0, "frame rate numerator positive");
|
||||
assert!(
|
||||
frame_rate.denominator > 0,
|
||||
"frame rate denominator positive"
|
||||
);
|
||||
let backend =
|
||||
D3D11KeyedMutexBackend::new().map_err(|_| EncoderAttachError::RingInitFailed)?;
|
||||
let mut ring = EncoderInputRing::new(backend);
|
||||
ring.initialise(width, height, TextureFormat::Nv12)
|
||||
.map_err(|_| EncoderAttachError::RingInitFailed)?;
|
||||
#[cfg(target_os = "windows")]
|
||||
let context = ring
|
||||
.backend_mut()
|
||||
.context()
|
||||
.ok_or(EncoderAttachError::DeviceUnavailable)?;
|
||||
#[cfg(target_os = "windows")]
|
||||
let nvenc = init_nvenc_pre_registered(&mut ring, width, height, frame_rate);
|
||||
let attachment = Self {
|
||||
ring: Mutex::new(ring),
|
||||
ready_condvar: Condvar::new(),
|
||||
attached: AtomicBool::new(true),
|
||||
frames_submitted: AtomicU64::new(0),
|
||||
frames_dropped: AtomicU64::new(0),
|
||||
ring_full_events: AtomicU64::new(0),
|
||||
failed_blits: AtomicU64::new(0),
|
||||
width,
|
||||
height,
|
||||
frame_rate,
|
||||
#[cfg(target_os = "windows")]
|
||||
context,
|
||||
#[cfg(target_os = "windows")]
|
||||
nvenc: Mutex::new(nvenc),
|
||||
};
|
||||
assert!(
|
||||
attachment.attached.load(Ordering::Acquire),
|
||||
"attachment is attached"
|
||||
);
|
||||
assert!(attachment.width > 0, "attachment width positive");
|
||||
assert!(
|
||||
attachment.frame_rate.numerator > 0,
|
||||
"attachment fps positive"
|
||||
);
|
||||
Ok(Arc::new(attachment))
|
||||
}
|
||||
|
||||
pub fn width(&self) -> u32 {
|
||||
let w = self.width;
|
||||
assert!(w > 0, "attachment width positive");
|
||||
assert!(self.height > 0, "attachment height positive");
|
||||
w
|
||||
}
|
||||
|
||||
pub fn height(&self) -> u32 {
|
||||
let h = self.height;
|
||||
assert!(h > 0, "attachment height positive");
|
||||
assert!(self.width > 0, "attachment width positive");
|
||||
h
|
||||
}
|
||||
|
||||
pub fn frame_rate(&self) -> EncoderFrameRate {
|
||||
let rate = self.frame_rate;
|
||||
assert!(rate.numerator > 0, "attachment fps numerator positive");
|
||||
assert!(rate.denominator > 0, "attachment fps denominator positive");
|
||||
rate
|
||||
}
|
||||
|
||||
pub fn is_attached(&self) -> bool {
|
||||
let a = self.attached.load(Ordering::Acquire);
|
||||
assert!(self.width > 0, "width intact while reading attached");
|
||||
assert!(self.height > 0, "height intact while reading attached");
|
||||
a
|
||||
}
|
||||
|
||||
pub fn detach(&self) {
|
||||
self.attached.store(false, Ordering::Release);
|
||||
self.ready_condvar.notify_all();
|
||||
assert!(
|
||||
!self.attached.load(Ordering::Acquire),
|
||||
"detach flag cleared"
|
||||
);
|
||||
assert!(self.width > 0, "attachment intact across detach");
|
||||
}
|
||||
|
||||
pub fn capacity(&self) -> usize {
|
||||
let cap = RING_SIZE;
|
||||
assert!(cap > 0, "ring capacity positive");
|
||||
assert_eq!(cap, 8, "ring capacity matches RING_SIZE");
|
||||
cap
|
||||
}
|
||||
|
||||
pub fn note_ring_full(&self) {
|
||||
self.ring_full_events.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn nvenc_pre_registered(&self) -> bool {
|
||||
let registered = self.nvenc.lock().is_some();
|
||||
assert!(self.width > 0, "width intact while reading nvenc state");
|
||||
assert!(self.height > 0, "height intact while reading nvenc state");
|
||||
registered
|
||||
}
|
||||
|
||||
pub fn stats(&self) -> EncoderAttachStats {
|
||||
let stats = EncoderAttachStats {
|
||||
frames_submitted: self.frames_submitted.load(Ordering::Relaxed),
|
||||
frames_dropped: self.frames_dropped.load(Ordering::Relaxed),
|
||||
ring_full_events: self.ring_full_events.load(Ordering::Relaxed),
|
||||
failed_blits: self.failed_blits.load(Ordering::Relaxed),
|
||||
};
|
||||
assert!(
|
||||
stats.frames_submitted <= u64::MAX / 2,
|
||||
"submitted plausible"
|
||||
);
|
||||
assert!(stats.frames_dropped <= u64::MAX / 2, "dropped plausible");
|
||||
stats
|
||||
}
|
||||
|
||||
fn account_evictions(&self, evicted_delta: u64) {
|
||||
assert!(
|
||||
evicted_delta <= EVICTIONS_PER_RESERVE_MAX,
|
||||
"evictions bounded per reserve"
|
||||
);
|
||||
assert!(self.width > 0, "attachment intact while accounting");
|
||||
if evicted_delta == 0 {
|
||||
return;
|
||||
}
|
||||
self.frames_dropped
|
||||
.fetch_add(evicted_delta, Ordering::Relaxed);
|
||||
self.ring_full_events
|
||||
.fetch_add(evicted_delta, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn submit_notify(&self) -> Result<(), EncoderAttachError> {
|
||||
if !self.attached.load(Ordering::Acquire) {
|
||||
return Err(EncoderAttachError::NotAttached);
|
||||
}
|
||||
let mut ring = self.ring.lock();
|
||||
let pre_dropped = ring.dropped_count();
|
||||
let result: Result<(), RingError> = ring.submit_skip_oldest(|_handle| {});
|
||||
let evicted_delta = ring.dropped_count().saturating_sub(pre_dropped);
|
||||
drop(ring);
|
||||
self.account_evictions(evicted_delta);
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.frames_submitted.fetch_add(1, Ordering::Relaxed);
|
||||
self.ready_condvar.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
Err(RingError::FullDropped { .. }) => Ok(()),
|
||||
Err(_) => Err(EncoderAttachError::RingInitFailed),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wait_next_ready(&self, timeout: Duration) -> Option<EncoderReady<D3D11SharedHandle>> {
|
||||
assert!(self.width > 0, "attachment width positive");
|
||||
assert!(self.height > 0, "attachment height positive");
|
||||
if !self.attached.load(Ordering::Acquire) {
|
||||
return None;
|
||||
}
|
||||
let capped_timeout = timeout.min(READY_WAIT_TIMEOUT_CAP);
|
||||
let mut ring = self.ring.lock();
|
||||
if let Some(ready) = ring.poll_next_ready() {
|
||||
return Some(ready);
|
||||
}
|
||||
let _ = self.ready_condvar.wait_for(&mut ring, capped_timeout);
|
||||
if !self.attached.load(Ordering::Acquire) {
|
||||
return None;
|
||||
}
|
||||
ring.poll_next_ready()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn cancel_reservation(&self, reservation: FillReservation<D3D11SharedHandle>) {
|
||||
assert!(
|
||||
(reservation.slot_index() as usize) < RING_SIZE,
|
||||
"cancelled slot within ring"
|
||||
);
|
||||
assert!(self.width > 0, "attachment intact while cancelling");
|
||||
let mut ring = self.ring.lock();
|
||||
let _ = ring.cancel(reservation);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn submit_capture_frame_with_blit(
|
||||
&self,
|
||||
capture_texture: &ID3D11Texture2D,
|
||||
capture_width: u32,
|
||||
capture_height: u32,
|
||||
) -> Result<(), EncoderAttachError> {
|
||||
assert!(capture_width > 0, "capture width positive");
|
||||
assert!(capture_height > 0, "capture height positive");
|
||||
assert!(self.width > 0, "attachment width positive");
|
||||
assert!(self.height > 0, "attachment height positive");
|
||||
if !self.attached.load(Ordering::Acquire) {
|
||||
return Err(EncoderAttachError::NotAttached);
|
||||
}
|
||||
let mut ring = self.ring.lock();
|
||||
let pre_dropped = ring.dropped_count();
|
||||
let reserved = ring.reserve_skip_oldest();
|
||||
let evicted_delta = ring.dropped_count().saturating_sub(pre_dropped);
|
||||
let dest_texture = match &reserved {
|
||||
Ok(reservation) => ring
|
||||
.backend_mut()
|
||||
.texture_for_slot(reservation.slot_index()),
|
||||
Err(_) => None,
|
||||
};
|
||||
drop(ring);
|
||||
self.account_evictions(evicted_delta);
|
||||
let reservation = match reserved {
|
||||
Ok(reservation) => reservation,
|
||||
Err(RingError::FullDropped { .. }) => return Ok(()),
|
||||
Err(_) => return Err(EncoderAttachError::RingInitFailed),
|
||||
};
|
||||
let Some(dest_texture) = dest_texture else {
|
||||
self.cancel_reservation(reservation);
|
||||
self.failed_blits.fetch_add(1, Ordering::Relaxed);
|
||||
return Err(EncoderAttachError::BlitFailed);
|
||||
};
|
||||
unsafe {
|
||||
self.context.CopyResource(&dest_texture, capture_texture);
|
||||
}
|
||||
let mut ring = self.ring.lock();
|
||||
match ring.commit(reservation) {
|
||||
Ok(sequence) => {
|
||||
drop(ring);
|
||||
assert!(sequence > 0, "committed sequence positive");
|
||||
self.frames_submitted.fetch_add(1, Ordering::Relaxed);
|
||||
self.ready_condvar.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
Err(_) => Err(EncoderAttachError::RingInitFailed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn collect_ring_slot_handles(
|
||||
ring: &mut EncoderInputRing<D3D11KeyedMutexBackend>,
|
||||
) -> Option<Vec<D3D11SharedHandle>> {
|
||||
assert_eq!(
|
||||
ring.free_count(),
|
||||
RING_SIZE,
|
||||
"collect requires a fresh ring"
|
||||
);
|
||||
let mut reservations: Vec<FillReservation<D3D11SharedHandle>> = Vec::with_capacity(RING_SIZE);
|
||||
let mut handles: Vec<D3D11SharedHandle> = Vec::with_capacity(RING_SIZE);
|
||||
for _ in 0..RING_SIZE {
|
||||
let Ok(reservation) = ring.reserve() else {
|
||||
break;
|
||||
};
|
||||
handles.push(reservation.handle.clone());
|
||||
reservations.push(reservation);
|
||||
}
|
||||
let mut all_cancelled = true;
|
||||
for reservation in reservations.drain(..) {
|
||||
if ring.cancel(reservation).is_err() {
|
||||
all_cancelled = false;
|
||||
}
|
||||
}
|
||||
if !all_cancelled {
|
||||
return None;
|
||||
}
|
||||
assert_eq!(ring.free_count(), RING_SIZE, "all reservations returned");
|
||||
assert!(handles.len() <= RING_SIZE, "collected handles bounded");
|
||||
if handles.len() == RING_SIZE {
|
||||
Some(handles)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn init_nvenc_pre_registered(
|
||||
ring: &mut EncoderInputRing<D3D11KeyedMutexBackend>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
frame_rate: EncoderFrameRate,
|
||||
) -> Option<NvencD3D11Handoff> {
|
||||
assert!(width > 0, "nvenc init width positive");
|
||||
assert!(height > 0, "nvenc init height positive");
|
||||
assert!(frame_rate.numerator > 0, "nvenc frame rate positive");
|
||||
let device = ring.backend_mut().device()?;
|
||||
let handles = collect_ring_slot_handles(ring)?;
|
||||
assert_eq!(handles.len(), RING_SIZE, "pre-register covers whole ring");
|
||||
let dims = EncoderDims::new(width, height);
|
||||
let mut handoff =
|
||||
NvencD3D11Handoff::new_with_frame_rate(device, dims, NVENC_BITRATE_BPS_DEFAULT, frame_rate)
|
||||
.ok()?;
|
||||
handoff.pre_register_slots(&handles, dims).ok()?;
|
||||
Some(handoff)
|
||||
}
|
||||
|
||||
impl Drop for EncoderAttachment {
|
||||
fn drop(&mut self) {
|
||||
self.attached.store(false, Ordering::Release);
|
||||
self.ready_condvar.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_dimensions() {
|
||||
let err = EncoderAttachment::try_new(0, 720).err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
Some(EncoderAttachError::InvalidDimensions { .. })
|
||||
));
|
||||
let err2 = EncoderAttachment::try_new(640, 0).err();
|
||||
assert!(matches!(
|
||||
err2,
|
||||
Some(EncoderAttachError::InvalidDimensions { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ready_wait_timeout_cap_is_one_frame_interval() {
|
||||
assert!(READY_WAIT_TIMEOUT_CAP >= Duration::from_millis(1));
|
||||
assert!(READY_WAIT_TIMEOUT_CAP <= Duration::from_millis(33));
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[test]
|
||||
fn off_windows_init_fails() {
|
||||
let err = EncoderAttachment::try_new(640, 480).err();
|
||||
assert!(matches!(err, Some(EncoderAttachError::RingInitFailed)));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_attach_then_detach_smoke() {
|
||||
let attach = match EncoderAttachment::try_new(640, 480) {
|
||||
Ok(a) => a,
|
||||
Err(_) => return,
|
||||
};
|
||||
assert!(attach.is_attached());
|
||||
assert_eq!(attach.width(), 640);
|
||||
assert_eq!(attach.height(), 480);
|
||||
assert_eq!(attach.capacity(), 8);
|
||||
let stats = attach.stats();
|
||||
assert_eq!(stats.frames_submitted, 0, "fresh attach has 0 submissions");
|
||||
assert_eq!(stats.failed_blits, 0, "fresh attach has 0 failed blits");
|
||||
attach.detach();
|
||||
assert!(!attach.is_attached());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_submit_notify_records_stats() {
|
||||
let attach = match EncoderAttachment::try_new(64, 64) {
|
||||
Ok(a) => a,
|
||||
Err(_) => return,
|
||||
};
|
||||
for _ in 0..3 {
|
||||
attach.submit_notify().expect("notify ok");
|
||||
}
|
||||
let stats = attach.stats();
|
||||
assert_eq!(stats.frames_submitted, 3);
|
||||
assert_eq!(stats.frames_dropped, 0);
|
||||
assert_eq!(stats.ring_full_events, 0);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_submit_rejected_when_detached() {
|
||||
let attach = match EncoderAttachment::try_new(64, 64) {
|
||||
Ok(a) => a,
|
||||
Err(_) => return,
|
||||
};
|
||||
attach.detach();
|
||||
let err = attach.submit_notify().err();
|
||||
assert!(matches!(err, Some(EncoderAttachError::NotAttached)));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_wait_next_ready_returns_committed_frame() {
|
||||
let attach = match EncoderAttachment::try_new(64, 64) {
|
||||
Ok(a) => a,
|
||||
Err(_) => return,
|
||||
};
|
||||
attach.submit_notify().expect("notify ok");
|
||||
let ready = attach.wait_next_ready(Duration::from_millis(5));
|
||||
let ready = ready.expect("committed frame becomes ready");
|
||||
assert_eq!(ready.sequence, 1);
|
||||
assert_eq!(ready.duplicate_count, 0);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_wait_next_ready_empty_ring_times_out() {
|
||||
let attach = match EncoderAttachment::try_new(64, 64) {
|
||||
Ok(a) => a,
|
||||
Err(_) => return,
|
||||
};
|
||||
let ready = attach.wait_next_ready(Duration::from_millis(2));
|
||||
assert!(ready.is_none());
|
||||
assert!(attach.is_attached());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_wait_next_ready_after_detach_returns_none() {
|
||||
let attach = match EncoderAttachment::try_new(64, 64) {
|
||||
Ok(a) => a,
|
||||
Err(_) => return,
|
||||
};
|
||||
attach.detach();
|
||||
let ready = attach.wait_next_ready(Duration::from_millis(2));
|
||||
assert!(ready.is_none());
|
||||
assert!(!attach.is_attached());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_ring_usable_after_nvenc_pre_register_attempt() {
|
||||
let attach = match EncoderAttachment::try_new(64, 64) {
|
||||
Ok(a) => a,
|
||||
Err(_) => return,
|
||||
};
|
||||
let _ = attach.nvenc_pre_registered();
|
||||
attach.submit_notify().expect("notify ok");
|
||||
let stats = attach.stats();
|
||||
assert_eq!(stats.frames_submitted, 1);
|
||||
assert_eq!(stats.frames_dropped, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::game_capture_abi::{
|
||||
GAME_CAPTURE_FALLBACK_DEVICE_LOST, GAME_CAPTURE_FALLBACK_EXTERNAL_MEMORY_UNSUPPORTED,
|
||||
GAME_CAPTURE_FALLBACK_FORCED_CPU, GAME_CAPTURE_FALLBACK_FORMAT_UNSUPPORTED,
|
||||
GAME_CAPTURE_FALLBACK_MULTISAMPLED, GAME_CAPTURE_FALLBACK_NONE,
|
||||
GAME_CAPTURE_FALLBACK_SHARED_TEXTURE_UNSUPPORTED,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum CaptureStrategy {
|
||||
GameHook,
|
||||
Wgc,
|
||||
DxgiDuplication,
|
||||
WindowGdi,
|
||||
}
|
||||
|
||||
impl CaptureStrategy {
|
||||
pub const RANKED: [CaptureStrategy; 4] = [
|
||||
CaptureStrategy::GameHook,
|
||||
CaptureStrategy::Wgc,
|
||||
CaptureStrategy::DxgiDuplication,
|
||||
CaptureStrategy::WindowGdi,
|
||||
];
|
||||
|
||||
pub fn rank(self) -> u8 {
|
||||
match self {
|
||||
CaptureStrategy::GameHook => 0,
|
||||
CaptureStrategy::Wgc => 1,
|
||||
CaptureStrategy::DxgiDuplication => 2,
|
||||
CaptureStrategy::WindowGdi => 3,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_worse(self) -> Option<CaptureStrategy> {
|
||||
match self {
|
||||
CaptureStrategy::GameHook => Some(CaptureStrategy::Wgc),
|
||||
CaptureStrategy::Wgc => Some(CaptureStrategy::DxgiDuplication),
|
||||
CaptureStrategy::DxgiDuplication => Some(CaptureStrategy::WindowGdi),
|
||||
CaptureStrategy::WindowGdi => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
CaptureStrategy::GameHook => "game-hook",
|
||||
CaptureStrategy::Wgc => "wgc",
|
||||
CaptureStrategy::DxgiDuplication => "dxgi-duplication",
|
||||
CaptureStrategy::WindowGdi => "window-gdi",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum FailureSignature {
|
||||
InjectionFailed,
|
||||
AntiCheatDenied,
|
||||
NoFramesWithinTimeout,
|
||||
DeviceLost,
|
||||
UnsupportedTransport,
|
||||
UnsupportedFormat,
|
||||
WindowGone,
|
||||
FramesRecovered,
|
||||
}
|
||||
|
||||
impl FailureSignature {
|
||||
pub fn from_fallback_reason(reason: u32) -> Option<FailureSignature> {
|
||||
match reason {
|
||||
GAME_CAPTURE_FALLBACK_NONE => None,
|
||||
GAME_CAPTURE_FALLBACK_SHARED_TEXTURE_UNSUPPORTED
|
||||
| GAME_CAPTURE_FALLBACK_EXTERNAL_MEMORY_UNSUPPORTED => {
|
||||
Some(FailureSignature::UnsupportedTransport)
|
||||
}
|
||||
GAME_CAPTURE_FALLBACK_FORMAT_UNSUPPORTED | GAME_CAPTURE_FALLBACK_MULTISAMPLED => {
|
||||
Some(FailureSignature::UnsupportedFormat)
|
||||
}
|
||||
GAME_CAPTURE_FALLBACK_DEVICE_LOST => Some(FailureSignature::DeviceLost),
|
||||
GAME_CAPTURE_FALLBACK_FORCED_CPU => None,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_terminal_for_all(self) -> bool {
|
||||
matches!(self, FailureSignature::WindowGone)
|
||||
}
|
||||
|
||||
fn permanently_disqualifies_current(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
FailureSignature::AntiCheatDenied
|
||||
| FailureSignature::InjectionFailed
|
||||
| FailureSignature::UnsupportedTransport
|
||||
| FailureSignature::UnsupportedFormat
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub const MAX_DEVICE_LOST_RETRIES: u32 = 3;
|
||||
|
||||
pub const STABLE_FRAMES_BEFORE_UPGRADE: u32 = 2;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FallbackDecision {
|
||||
Stay {
|
||||
retry_in_place: bool,
|
||||
},
|
||||
Transition {
|
||||
from: CaptureStrategy,
|
||||
to: CaptureStrategy,
|
||||
reason: String,
|
||||
},
|
||||
Upgrade {
|
||||
from: CaptureStrategy,
|
||||
to: CaptureStrategy,
|
||||
reason: String,
|
||||
},
|
||||
GiveUp {
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FallbackRanker {
|
||||
active: CaptureStrategy,
|
||||
disqualified: Vec<CaptureStrategy>,
|
||||
device_lost_streak: u32,
|
||||
stable_frame_streak: u32,
|
||||
last_fallback_reason: Option<String>,
|
||||
}
|
||||
|
||||
impl FallbackRanker {
|
||||
pub fn new(initial: CaptureStrategy) -> Self {
|
||||
Self {
|
||||
active: initial,
|
||||
disqualified: Vec::new(),
|
||||
device_lost_streak: 0,
|
||||
stable_frame_streak: 0,
|
||||
last_fallback_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active(&self) -> CaptureStrategy {
|
||||
self.active
|
||||
}
|
||||
|
||||
pub fn last_fallback_reason(&self) -> Option<&str> {
|
||||
self.last_fallback_reason.as_deref()
|
||||
}
|
||||
|
||||
pub fn is_disqualified(&self, strategy: CaptureStrategy) -> bool {
|
||||
self.disqualified.contains(&strategy)
|
||||
}
|
||||
|
||||
fn best_available_above(&self) -> Option<CaptureStrategy> {
|
||||
CaptureStrategy::RANKED.iter().copied().find(|candidate| {
|
||||
candidate.rank() < self.active.rank() && !self.is_disqualified(*candidate)
|
||||
})
|
||||
}
|
||||
|
||||
fn next_available_below(&self) -> Option<CaptureStrategy> {
|
||||
let mut candidate = self.active.next_worse();
|
||||
while let Some(strategy) = candidate {
|
||||
if !self.is_disqualified(strategy) {
|
||||
return Some(strategy);
|
||||
}
|
||||
candidate = strategy.next_worse();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn disqualify(&mut self, strategy: CaptureStrategy) {
|
||||
if !self.disqualified.contains(&strategy) {
|
||||
self.disqualified.push(strategy);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn observe(&mut self, signature: FailureSignature) -> FallbackDecision {
|
||||
if signature.is_terminal_for_all() {
|
||||
return FallbackDecision::GiveUp {
|
||||
reason: format!(
|
||||
"the capture target is gone; no capture strategy can continue (was using \
|
||||
{})",
|
||||
self.active.as_str()
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if signature == FailureSignature::FramesRecovered {
|
||||
self.device_lost_streak = 0;
|
||||
self.stable_frame_streak = self.stable_frame_streak.saturating_add(1);
|
||||
if self.stable_frame_streak >= STABLE_FRAMES_BEFORE_UPGRADE
|
||||
&& let Some(target) = self.best_available_above()
|
||||
{
|
||||
let from = self.active;
|
||||
let reason = format!(
|
||||
"{} has been stable for {} frames; attempting to upgrade back to the \
|
||||
preferred {} strategy",
|
||||
from.as_str(),
|
||||
self.stable_frame_streak,
|
||||
target.as_str()
|
||||
);
|
||||
self.active = target;
|
||||
self.stable_frame_streak = 0;
|
||||
self.last_fallback_reason = Some(reason.clone());
|
||||
return FallbackDecision::Upgrade {
|
||||
from,
|
||||
to: target,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
return FallbackDecision::Stay {
|
||||
retry_in_place: false,
|
||||
};
|
||||
}
|
||||
|
||||
self.stable_frame_streak = 0;
|
||||
|
||||
if signature == FailureSignature::DeviceLost {
|
||||
self.device_lost_streak = self.device_lost_streak.saturating_add(1);
|
||||
if self.device_lost_streak <= MAX_DEVICE_LOST_RETRIES {
|
||||
return FallbackDecision::Stay {
|
||||
retry_in_place: true,
|
||||
};
|
||||
}
|
||||
return self.fall_back(
|
||||
signature,
|
||||
&format!(
|
||||
"{} kept losing its capture device ({} consecutive recoveries failed); falling \
|
||||
back",
|
||||
self.active.as_str(),
|
||||
self.device_lost_streak
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
self.device_lost_streak = 0;
|
||||
|
||||
if signature.permanently_disqualifies_current() {
|
||||
self.disqualify(self.active);
|
||||
}
|
||||
|
||||
let reason = describe_failure(self.active, signature);
|
||||
self.fall_back(signature, &reason)
|
||||
}
|
||||
|
||||
fn fall_back(&mut self, signature: FailureSignature, reason: &str) -> FallbackDecision {
|
||||
self.device_lost_streak = 0;
|
||||
match self.next_available_below() {
|
||||
Some(target) => {
|
||||
let from = self.active;
|
||||
let full = format!("{reason}; switching to {} capture", target.as_str());
|
||||
self.active = target;
|
||||
self.last_fallback_reason = Some(full.clone());
|
||||
FallbackDecision::Transition {
|
||||
from,
|
||||
to: target,
|
||||
reason: full,
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let give_up = format!(
|
||||
"{reason}, and no lower-ranked capture strategy is available ({} was the last \
|
||||
resort, signature={:?})",
|
||||
self.active.as_str(),
|
||||
signature
|
||||
);
|
||||
self.last_fallback_reason = Some(give_up.clone());
|
||||
FallbackDecision::GiveUp { reason: give_up }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FallbackSnapshot {
|
||||
pub active_strategy: String,
|
||||
pub last_fallback_reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FallbackTracker {
|
||||
ranker: FallbackRanker,
|
||||
}
|
||||
|
||||
impl FallbackTracker {
|
||||
pub fn new(initial: CaptureStrategy) -> Self {
|
||||
Self {
|
||||
ranker: FallbackRanker::new(initial),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn observe(&mut self, signature: FailureSignature) -> FallbackDecision {
|
||||
self.ranker.observe(signature)
|
||||
}
|
||||
|
||||
pub fn active(&self) -> CaptureStrategy {
|
||||
self.ranker.active()
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> FallbackSnapshot {
|
||||
FallbackSnapshot {
|
||||
active_strategy: self.ranker.active().as_str().to_string(),
|
||||
last_fallback_reason: self.ranker.last_fallback_reason().unwrap_or("").to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decision_lifecycle(decision: &FallbackDecision) -> (&'static str, String) {
|
||||
match decision {
|
||||
FallbackDecision::Stay { retry_in_place } => (
|
||||
"diagnostic",
|
||||
if *retry_in_place {
|
||||
"capture hit a transient device loss; retrying the current strategy in place"
|
||||
.to_string()
|
||||
} else {
|
||||
"capture is healthy on the current strategy".to_string()
|
||||
},
|
||||
),
|
||||
FallbackDecision::Transition { from, to, reason } => (
|
||||
"error",
|
||||
format!(
|
||||
"fallback: {} -> {} ({reason}) [next-strategy={}]",
|
||||
from.as_str(),
|
||||
to.as_str(),
|
||||
to.as_str()
|
||||
),
|
||||
),
|
||||
FallbackDecision::Upgrade { from, to, reason } => (
|
||||
"diagnostic",
|
||||
format!(
|
||||
"upgrade: {} -> {} ({reason}) [next-strategy={}]",
|
||||
from.as_str(),
|
||||
to.as_str(),
|
||||
to.as_str()
|
||||
),
|
||||
),
|
||||
FallbackDecision::GiveUp { reason } => (
|
||||
"error",
|
||||
format!("fallback exhausted: {reason} [next-strategy=none]"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn describe_failure(strategy: CaptureStrategy, signature: FailureSignature) -> String {
|
||||
let what = match signature {
|
||||
FailureSignature::InjectionFailed => "could not inject its capture hook",
|
||||
FailureSignature::AntiCheatDenied => {
|
||||
"is protected by anti-cheat, so the capture hook must not be injected"
|
||||
}
|
||||
FailureSignature::NoFramesWithinTimeout => "produced no frames within the start timeout",
|
||||
FailureSignature::DeviceLost => "lost its capture device",
|
||||
FailureSignature::UnsupportedTransport => "used a frame transport this build cannot read",
|
||||
FailureSignature::UnsupportedFormat => "used a swapchain format the fast path cannot read",
|
||||
FailureSignature::WindowGone => "lost its capture target",
|
||||
FailureSignature::FramesRecovered => "recovered",
|
||||
};
|
||||
format!("{} capture {what}", strategy.as_str())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game_capture_abi::{
|
||||
GAME_CAPTURE_FALLBACK_DEVICE_LOST, GAME_CAPTURE_FALLBACK_EXTERNAL_MEMORY_UNSUPPORTED,
|
||||
GAME_CAPTURE_FALLBACK_FORCED_CPU, GAME_CAPTURE_FALLBACK_FORMAT_UNSUPPORTED,
|
||||
GAME_CAPTURE_FALLBACK_MULTISAMPLED, GAME_CAPTURE_FALLBACK_NONE,
|
||||
GAME_CAPTURE_FALLBACK_SHARED_TEXTURE_UNSUPPORTED,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn ranking_order_is_best_to_worst() {
|
||||
assert!(CaptureStrategy::GameHook.rank() < CaptureStrategy::Wgc.rank());
|
||||
assert!(CaptureStrategy::Wgc.rank() < CaptureStrategy::DxgiDuplication.rank());
|
||||
assert!(CaptureStrategy::DxgiDuplication.rank() < CaptureStrategy::WindowGdi.rank());
|
||||
assert_eq!(
|
||||
CaptureStrategy::GameHook.next_worse(),
|
||||
Some(CaptureStrategy::Wgc)
|
||||
);
|
||||
assert_eq!(
|
||||
CaptureStrategy::Wgc.next_worse(),
|
||||
Some(CaptureStrategy::DxgiDuplication)
|
||||
);
|
||||
assert_eq!(
|
||||
CaptureStrategy::DxgiDuplication.next_worse(),
|
||||
Some(CaptureStrategy::WindowGdi)
|
||||
);
|
||||
assert_eq!(CaptureStrategy::WindowGdi.next_worse(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injection_failure_falls_back_to_wgc() {
|
||||
let mut ranker = FallbackRanker::new(CaptureStrategy::GameHook);
|
||||
match ranker.observe(FailureSignature::InjectionFailed) {
|
||||
FallbackDecision::Transition { from, to, .. } => {
|
||||
assert_eq!(from, CaptureStrategy::GameHook);
|
||||
assert_eq!(to, CaptureStrategy::Wgc);
|
||||
}
|
||||
other => panic!("expected Transition, got {other:?}"),
|
||||
}
|
||||
assert_eq!(ranker.active(), CaptureStrategy::Wgc);
|
||||
assert!(ranker.is_disqualified(CaptureStrategy::GameHook));
|
||||
assert!(ranker.last_fallback_reason().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_frames_timeout_falls_back_but_does_not_disqualify() {
|
||||
let mut ranker = FallbackRanker::new(CaptureStrategy::GameHook);
|
||||
match ranker.observe(FailureSignature::NoFramesWithinTimeout) {
|
||||
FallbackDecision::Transition { to, .. } => {
|
||||
assert_eq!(to, CaptureStrategy::Wgc)
|
||||
}
|
||||
other => panic!("expected Transition, got {other:?}"),
|
||||
}
|
||||
assert!(!ranker.is_disqualified(CaptureStrategy::GameHook));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wgc_failure_falls_back_to_dxgi() {
|
||||
let mut ranker = FallbackRanker::new(CaptureStrategy::Wgc);
|
||||
match ranker.observe(FailureSignature::NoFramesWithinTimeout) {
|
||||
FallbackDecision::Transition { from, to, .. } => {
|
||||
assert_eq!(from, CaptureStrategy::Wgc);
|
||||
assert_eq!(to, CaptureStrategy::DxgiDuplication);
|
||||
}
|
||||
other => panic!("expected Transition, got {other:?}"),
|
||||
}
|
||||
assert!(!ranker.is_disqualified(CaptureStrategy::Wgc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anti_cheat_denied_disqualifies_game_hook() {
|
||||
let mut ranker = FallbackRanker::new(CaptureStrategy::GameHook);
|
||||
let decision = ranker.observe(FailureSignature::AntiCheatDenied);
|
||||
assert!(matches!(
|
||||
decision,
|
||||
FallbackDecision::Transition {
|
||||
to: CaptureStrategy::Wgc,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(ranker.is_disqualified(CaptureStrategy::GameHook));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_lost_retries_in_place_then_falls_back() {
|
||||
let mut ranker = FallbackRanker::new(CaptureStrategy::DxgiDuplication);
|
||||
for _ in 0..MAX_DEVICE_LOST_RETRIES {
|
||||
assert_eq!(
|
||||
ranker.observe(FailureSignature::DeviceLost),
|
||||
FallbackDecision::Stay {
|
||||
retry_in_place: true
|
||||
}
|
||||
);
|
||||
assert_eq!(ranker.active(), CaptureStrategy::DxgiDuplication);
|
||||
}
|
||||
match ranker.observe(FailureSignature::DeviceLost) {
|
||||
FallbackDecision::Transition { to, .. } => assert_eq!(to, CaptureStrategy::WindowGdi),
|
||||
other => panic!("expected Transition after retry budget, got {other:?}"),
|
||||
}
|
||||
assert!(!ranker.is_disqualified(CaptureStrategy::DxgiDuplication));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_recovered_resets_device_lost_streak() {
|
||||
let mut ranker = FallbackRanker::new(CaptureStrategy::DxgiDuplication);
|
||||
ranker.observe(FailureSignature::DeviceLost);
|
||||
ranker.observe(FailureSignature::DeviceLost);
|
||||
assert_eq!(
|
||||
ranker.observe(FailureSignature::FramesRecovered),
|
||||
FallbackDecision::Stay {
|
||||
retry_in_place: false
|
||||
}
|
||||
);
|
||||
for _ in 0..MAX_DEVICE_LOST_RETRIES {
|
||||
assert_eq!(
|
||||
ranker.observe(FailureSignature::DeviceLost),
|
||||
FallbackDecision::Stay {
|
||||
retry_in_place: true
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_gone_gives_up_from_any_strategy() {
|
||||
for start in CaptureStrategy::RANKED {
|
||||
let mut ranker = FallbackRanker::new(start);
|
||||
match ranker.observe(FailureSignature::WindowGone) {
|
||||
FallbackDecision::GiveUp { .. } => {}
|
||||
other => panic!("expected GiveUp from {start:?}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_resort_failure_gives_up() {
|
||||
let mut ranker = FallbackRanker::new(CaptureStrategy::WindowGdi);
|
||||
match ranker.observe(FailureSignature::NoFramesWithinTimeout) {
|
||||
FallbackDecision::GiveUp { reason } => assert!(reason.contains("window-gdi")),
|
||||
other => panic!("expected GiveUp at last resort, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_descent_game_to_wgc_to_dxgi_to_window_then_give_up() {
|
||||
let mut ranker = FallbackRanker::new(CaptureStrategy::GameHook);
|
||||
assert!(matches!(
|
||||
ranker.observe(FailureSignature::InjectionFailed),
|
||||
FallbackDecision::Transition {
|
||||
to: CaptureStrategy::Wgc,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(matches!(
|
||||
ranker.observe(FailureSignature::UnsupportedTransport),
|
||||
FallbackDecision::Transition {
|
||||
to: CaptureStrategy::DxgiDuplication,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(matches!(
|
||||
ranker.observe(FailureSignature::UnsupportedTransport),
|
||||
FallbackDecision::Transition {
|
||||
to: CaptureStrategy::WindowGdi,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(matches!(
|
||||
ranker.observe(FailureSignature::UnsupportedFormat),
|
||||
FallbackDecision::GiveUp { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_recovery_climbs_back_up_skipping_disqualified() {
|
||||
let mut ranker = FallbackRanker::new(CaptureStrategy::GameHook);
|
||||
ranker.observe(FailureSignature::InjectionFailed);
|
||||
for _ in 0..=MAX_DEVICE_LOST_RETRIES {
|
||||
ranker.observe(FailureSignature::DeviceLost);
|
||||
}
|
||||
assert_eq!(ranker.active(), CaptureStrategy::DxgiDuplication);
|
||||
|
||||
assert_eq!(
|
||||
ranker.observe(FailureSignature::FramesRecovered),
|
||||
FallbackDecision::Stay {
|
||||
retry_in_place: false
|
||||
}
|
||||
);
|
||||
match ranker.observe(FailureSignature::FramesRecovered) {
|
||||
FallbackDecision::Upgrade { from, to, .. } => {
|
||||
assert_eq!(from, CaptureStrategy::DxgiDuplication);
|
||||
assert_eq!(to, CaptureStrategy::Wgc);
|
||||
}
|
||||
other => panic!("expected Upgrade, got {other:?}"),
|
||||
}
|
||||
assert_eq!(ranker.active(), CaptureStrategy::Wgc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_upgrade_when_already_best() {
|
||||
let mut ranker = FallbackRanker::new(CaptureStrategy::GameHook);
|
||||
for _ in 0..(STABLE_FRAMES_BEFORE_UPGRADE + 2) {
|
||||
assert_eq!(
|
||||
ranker.observe(FailureSignature::FramesRecovered),
|
||||
FallbackDecision::Stay {
|
||||
retry_in_place: false
|
||||
}
|
||||
);
|
||||
}
|
||||
assert_eq!(ranker.active(), CaptureStrategy::GameHook);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgrade_does_not_revive_disqualified_better_strategy() {
|
||||
let mut ranker = FallbackRanker::new(CaptureStrategy::GameHook);
|
||||
ranker.observe(FailureSignature::AntiCheatDenied);
|
||||
assert_eq!(ranker.active(), CaptureStrategy::Wgc);
|
||||
for _ in 0..(STABLE_FRAMES_BEFORE_UPGRADE + 2) {
|
||||
assert_eq!(
|
||||
ranker.observe(FailureSignature::FramesRecovered),
|
||||
FallbackDecision::Stay {
|
||||
retry_in_place: false
|
||||
}
|
||||
);
|
||||
}
|
||||
assert_eq!(ranker.active(), CaptureStrategy::Wgc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_reason_maps_from_abi_reason() {
|
||||
assert_eq!(
|
||||
FailureSignature::from_fallback_reason(GAME_CAPTURE_FALLBACK_NONE),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
FailureSignature::from_fallback_reason(
|
||||
GAME_CAPTURE_FALLBACK_SHARED_TEXTURE_UNSUPPORTED
|
||||
),
|
||||
Some(FailureSignature::UnsupportedTransport)
|
||||
);
|
||||
assert_eq!(
|
||||
FailureSignature::from_fallback_reason(
|
||||
GAME_CAPTURE_FALLBACK_EXTERNAL_MEMORY_UNSUPPORTED
|
||||
),
|
||||
Some(FailureSignature::UnsupportedTransport)
|
||||
);
|
||||
assert_eq!(
|
||||
FailureSignature::from_fallback_reason(GAME_CAPTURE_FALLBACK_FORMAT_UNSUPPORTED),
|
||||
Some(FailureSignature::UnsupportedFormat)
|
||||
);
|
||||
assert_eq!(
|
||||
FailureSignature::from_fallback_reason(GAME_CAPTURE_FALLBACK_MULTISAMPLED),
|
||||
Some(FailureSignature::UnsupportedFormat)
|
||||
);
|
||||
assert_eq!(
|
||||
FailureSignature::from_fallback_reason(GAME_CAPTURE_FALLBACK_DEVICE_LOST),
|
||||
Some(FailureSignature::DeviceLost)
|
||||
);
|
||||
assert_eq!(
|
||||
FailureSignature::from_fallback_reason(GAME_CAPTURE_FALLBACK_FORCED_CPU),
|
||||
None
|
||||
);
|
||||
assert_eq!(FailureSignature::from_fallback_reason(u32::MAX), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_transport_disqualifies_current_strategy() {
|
||||
let mut ranker = FallbackRanker::new(CaptureStrategy::GameHook);
|
||||
match ranker.observe(FailureSignature::UnsupportedTransport) {
|
||||
FallbackDecision::Transition { from, to, reason } => {
|
||||
assert_eq!(from, CaptureStrategy::GameHook);
|
||||
assert_eq!(to, CaptureStrategy::Wgc);
|
||||
assert!(reason.contains("transport"));
|
||||
}
|
||||
other => panic!("expected Transition, got {other:?}"),
|
||||
}
|
||||
assert!(ranker.is_disqualified(CaptureStrategy::GameHook));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_cpu_reason_is_not_a_failure_signature() {
|
||||
let mut ranker = FallbackRanker::new(CaptureStrategy::GameHook);
|
||||
if let Some(signature) =
|
||||
FailureSignature::from_fallback_reason(GAME_CAPTURE_FALLBACK_FORCED_CPU)
|
||||
{
|
||||
ranker.observe(signature);
|
||||
}
|
||||
assert_eq!(ranker.active(), CaptureStrategy::GameHook);
|
||||
assert!(!ranker.is_disqualified(CaptureStrategy::GameHook));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strategy_names_are_stable_for_the_js_seam() {
|
||||
assert_eq!(CaptureStrategy::GameHook.as_str(), "game-hook");
|
||||
assert_eq!(CaptureStrategy::Wgc.as_str(), "wgc");
|
||||
assert_eq!(
|
||||
CaptureStrategy::DxgiDuplication.as_str(),
|
||||
"dxgi-duplication"
|
||||
);
|
||||
assert_eq!(CaptureStrategy::WindowGdi.as_str(), "window-gdi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracker_snapshot_tracks_active_and_reason() {
|
||||
let mut tracker = FallbackTracker::new(CaptureStrategy::GameHook);
|
||||
let snap = tracker.snapshot();
|
||||
assert_eq!(snap.active_strategy, "game-hook");
|
||||
assert_eq!(snap.last_fallback_reason, "", "no reason before a fallback");
|
||||
|
||||
tracker.observe(FailureSignature::InjectionFailed);
|
||||
let snap = tracker.snapshot();
|
||||
assert_eq!(snap.active_strategy, "wgc");
|
||||
assert!(snap.last_fallback_reason.contains("wgc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_lifecycle_maps_to_event_pairs() {
|
||||
let transition = FallbackDecision::Transition {
|
||||
from: CaptureStrategy::GameHook,
|
||||
to: CaptureStrategy::DxgiDuplication,
|
||||
reason: "game-hook capture could not inject its capture hook".to_string(),
|
||||
};
|
||||
let (kind, msg) = decision_lifecycle(&transition);
|
||||
assert_eq!(kind, "error");
|
||||
assert!(msg.contains("next-strategy=dxgi-duplication"));
|
||||
|
||||
let upgrade = FallbackDecision::Upgrade {
|
||||
from: CaptureStrategy::WindowGdi,
|
||||
to: CaptureStrategy::DxgiDuplication,
|
||||
reason: "stable".to_string(),
|
||||
};
|
||||
let (kind, msg) = decision_lifecycle(&upgrade);
|
||||
assert_eq!(kind, "diagnostic");
|
||||
assert!(msg.contains("next-strategy=dxgi-duplication"));
|
||||
|
||||
let give_up = FallbackDecision::GiveUp {
|
||||
reason: "window-gdi was the last resort".to_string(),
|
||||
};
|
||||
let (kind, msg) = decision_lifecycle(&give_up);
|
||||
assert_eq!(kind, "error");
|
||||
assert!(msg.contains("next-strategy=none"));
|
||||
|
||||
let (kind, _) = decision_lifecycle(&FallbackDecision::Stay {
|
||||
retry_in_place: true,
|
||||
});
|
||||
assert_eq!(kind, "diagnostic");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,307 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub const GAME_CAPTURE_MAGIC: u32 = 0x4658_4743;
|
||||
pub const GAME_CAPTURE_ABI_VERSION: u32 = 4;
|
||||
pub const GAME_CAPTURE_ABI_VERSION_PRESENT_CLOCK: u32 = 4;
|
||||
|
||||
pub const GAME_CAPTURE_PRESENT_CLOCK_WALL: u32 = 0;
|
||||
pub const GAME_CAPTURE_PRESENT_CLOCK_QPC: u32 = 1;
|
||||
|
||||
pub const GAME_CAPTURE_STATE_INIT: u32 = 0;
|
||||
pub const GAME_CAPTURE_STATE_ACTIVE: u32 = 1;
|
||||
pub const GAME_CAPTURE_STATE_STOPPED: u32 = 2;
|
||||
pub const GAME_CAPTURE_STATE_ERROR: u32 = 3;
|
||||
pub const GAME_CAPTURE_STATE_RESIZE_REQUIRED: u32 = 4;
|
||||
|
||||
pub const GAME_CAPTURE_BUFFER_COUNT: usize = 2;
|
||||
pub const GAME_CAPTURE_BYTES_PER_PIXEL: u32 = 4;
|
||||
pub const GAME_CAPTURE_TRANSPORT_MEMORY: u32 = 0;
|
||||
pub const GAME_CAPTURE_TRANSPORT_SHARED_TEXTURE: u32 = 1;
|
||||
|
||||
pub const GAME_CAPTURE_API_UNKNOWN: u32 = 0;
|
||||
pub const GAME_CAPTURE_API_OPENGL: u32 = 1;
|
||||
pub const GAME_CAPTURE_API_D3D8: u32 = 2;
|
||||
pub const GAME_CAPTURE_API_D3D9: u32 = 3;
|
||||
pub const GAME_CAPTURE_API_D3D10: u32 = 4;
|
||||
pub const GAME_CAPTURE_API_D3D11: u32 = 5;
|
||||
pub const GAME_CAPTURE_API_D3D12: u32 = 6;
|
||||
pub const GAME_CAPTURE_API_VULKAN: u32 = 7;
|
||||
|
||||
pub const GAME_CAPTURE_FALLBACK_NONE: u32 = 0;
|
||||
pub const GAME_CAPTURE_FALLBACK_SHARED_TEXTURE_UNSUPPORTED: u32 = 1;
|
||||
pub const GAME_CAPTURE_FALLBACK_FORMAT_UNSUPPORTED: u32 = 2;
|
||||
pub const GAME_CAPTURE_FALLBACK_FORCED_CPU: u32 = 3;
|
||||
pub const GAME_CAPTURE_FALLBACK_EXTERNAL_MEMORY_UNSUPPORTED: u32 = 4;
|
||||
pub const GAME_CAPTURE_FALLBACK_MULTISAMPLED: u32 = 5;
|
||||
pub const GAME_CAPTURE_FALLBACK_DEVICE_LOST: u32 = 6;
|
||||
|
||||
pub const GAME_CAPTURE_FLAG_HDR: u32 = 1 << 0;
|
||||
pub const GAME_CAPTURE_FLAG_MULTISAMPLED: u32 = 1 << 1;
|
||||
pub const GAME_CAPTURE_FLAG_FLIP_VERTICAL: u32 = 1 << 2;
|
||||
pub const GAME_CAPTURE_FLAG_PROTECTED: u32 = 1 << 3;
|
||||
pub const GAME_CAPTURE_FLAG_TEN_BIT: u32 = 1 << 4;
|
||||
|
||||
pub const ENV_DISABLE_HOOK: &str = "FLUXER_GAME_CAPTURE_DISABLE_HOOK";
|
||||
pub const ENV_DISABLE_D3D12: &str = "FLUXER_GAME_CAPTURE_DISABLE_D3D12";
|
||||
pub const ENV_DISABLE_VULKAN: &str = "DISABLE_FLUXER_VULKAN_CAPTURE";
|
||||
pub const ENV_FORCE_CPU: &str = "FLUXER_GAME_CAPTURE_FORCE_CPU";
|
||||
pub const ENV_FORCE_SHARED_TEXTURE: &str = "FLUXER_GAME_CAPTURE_FORCE_SHARED_TEXTURE";
|
||||
pub const ENV_ENABLE_OPENGL_SHARED_TEXTURE: &str =
|
||||
"FLUXER_GAME_CAPTURE_ENABLE_OPENGL_SHARED_TEXTURE";
|
||||
pub const ENV_DISABLE_OPENGL_SHARED_TEXTURE: &str =
|
||||
"FLUXER_GAME_CAPTURE_DISABLE_OPENGL_SHARED_TEXTURE";
|
||||
pub const ENV_VERBOSE: &str = "FLUXER_GAME_CAPTURE_VERBOSE";
|
||||
pub const ENV_INJECT_METHOD: &str = "FLUXER_GAME_CAPTURE_INJECT_METHOD";
|
||||
|
||||
pub const GAME_CAPTURE_CONTROL_DISABLE_SHARED_TEXTURE: u32 = 1 << 0;
|
||||
|
||||
pub fn env_flag_enabled(name: &str) -> bool {
|
||||
std::env::var_os(name)
|
||||
.map(|value| !value.is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub const GAME_CAPTURE_INFO_PREFIX: &str = "FluxerGameCapture_Info_";
|
||||
pub const GAME_CAPTURE_FRAME_PREFIX: &str = "FluxerGameCapture_Frame_";
|
||||
pub const GAME_CAPTURE_READY_PREFIX: &str = "FluxerGameCapture_Ready_";
|
||||
pub const GAME_CAPTURE_STOP_PREFIX: &str = "FluxerGameCapture_Stop_";
|
||||
pub const GAME_CAPTURE_KEEPALIVE_PREFIX: &str = "FluxerGameCapture_KeepAlive_";
|
||||
pub const GAME_CAPTURE_MUTEX_PREFIX: &str = "FluxerGameCapture_FrameMutex_";
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct GameCaptureSharedInfo {
|
||||
pub magic: u32,
|
||||
pub version: u32,
|
||||
pub state: u32,
|
||||
pub last_error: u32,
|
||||
pub hwnd: u64,
|
||||
pub max_width: u32,
|
||||
pub max_height: u32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub pitch: u32,
|
||||
pub frame_index: u32,
|
||||
pub frame_counter: u64,
|
||||
pub timestamp_us: i64,
|
||||
pub target_frame_interval_ns: u64,
|
||||
pub transport: u32,
|
||||
pub dxgi_format: u32,
|
||||
pub texture_handle: u64,
|
||||
pub api_type: u32,
|
||||
pub fallback_reason: u32,
|
||||
pub capture_flags: u32,
|
||||
pub dropped_frame_counter: u64,
|
||||
pub last_present_timestamp_us: i64,
|
||||
pub control: u32,
|
||||
pub present_clock: u32,
|
||||
pub reserved: [u32; 23],
|
||||
}
|
||||
|
||||
const _: () = assert!(std::mem::size_of::<GameCaptureSharedInfo>() == 224);
|
||||
|
||||
impl GameCaptureSharedInfo {
|
||||
pub fn new(hwnd: u64, max_width: u32, max_height: u32, frame_rate: u32) -> Self {
|
||||
Self {
|
||||
magic: GAME_CAPTURE_MAGIC,
|
||||
version: GAME_CAPTURE_ABI_VERSION,
|
||||
state: GAME_CAPTURE_STATE_INIT,
|
||||
last_error: 0,
|
||||
hwnd,
|
||||
max_width,
|
||||
max_height,
|
||||
width: 0,
|
||||
height: 0,
|
||||
pitch: max_width.saturating_mul(GAME_CAPTURE_BYTES_PER_PIXEL),
|
||||
frame_index: 0,
|
||||
frame_counter: 0,
|
||||
timestamp_us: 0,
|
||||
target_frame_interval_ns: frame_interval_ns(frame_rate),
|
||||
transport: GAME_CAPTURE_TRANSPORT_MEMORY,
|
||||
dxgi_format: 0,
|
||||
texture_handle: 0,
|
||||
api_type: GAME_CAPTURE_API_UNKNOWN,
|
||||
fallback_reason: GAME_CAPTURE_FALLBACK_NONE,
|
||||
capture_flags: 0,
|
||||
dropped_frame_counter: 0,
|
||||
last_present_timestamp_us: 0,
|
||||
control: 0,
|
||||
present_clock: GAME_CAPTURE_PRESENT_CLOCK_WALL,
|
||||
reserved: [0; 23],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn host_supports_present_clock(version: u32) -> bool {
|
||||
version >= GAME_CAPTURE_ABI_VERSION_PRESENT_CLOCK
|
||||
}
|
||||
|
||||
pub fn qpc_ticks_to_us(ticks: i64, frequency: i64) -> i64 {
|
||||
if frequency <= 0 {
|
||||
return 0;
|
||||
}
|
||||
if ticks <= 0 {
|
||||
return 0;
|
||||
}
|
||||
let whole_second_us = (ticks / frequency).saturating_mul(1_000_000);
|
||||
let fractional_us = (ticks % frequency).saturating_mul(1_000_000) / frequency;
|
||||
whole_second_us.saturating_add(fractional_us)
|
||||
}
|
||||
|
||||
pub fn presented_recently(
|
||||
present_clock: u32,
|
||||
last_present_us: i64,
|
||||
wall_now_us: i64,
|
||||
qpc_now_us: i64,
|
||||
window_us: i64,
|
||||
) -> bool {
|
||||
assert!(window_us > 0);
|
||||
if last_present_us <= 0 {
|
||||
return false;
|
||||
}
|
||||
let now_us = if present_clock == GAME_CAPTURE_PRESENT_CLOCK_QPC {
|
||||
qpc_now_us
|
||||
} else {
|
||||
wall_now_us
|
||||
};
|
||||
now_us.saturating_sub(last_present_us) < window_us
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn qpc_now_us() -> i64 {
|
||||
use windows_sys::Win32::System::Performance::{
|
||||
QueryPerformanceCounter, QueryPerformanceFrequency,
|
||||
};
|
||||
let mut ticks = 0i64;
|
||||
let mut frequency = 0i64;
|
||||
let counter_ok = unsafe { QueryPerformanceCounter(&mut ticks) };
|
||||
let frequency_ok = unsafe { QueryPerformanceFrequency(&mut frequency) };
|
||||
if counter_ok == 0 || frequency_ok == 0 {
|
||||
return 0;
|
||||
}
|
||||
qpc_ticks_to_us(ticks, frequency)
|
||||
}
|
||||
|
||||
pub fn frame_interval_ns(frame_rate: u32) -> u64 {
|
||||
let rate = frame_rate.clamp(1, 144) as u64;
|
||||
1_000_000_000 / rate
|
||||
}
|
||||
|
||||
pub fn frame_buffer_stride(width: u32) -> u32 {
|
||||
width.saturating_mul(GAME_CAPTURE_BYTES_PER_PIXEL)
|
||||
}
|
||||
|
||||
pub fn frame_buffer_size(width: u32, height: u32) -> Option<usize> {
|
||||
frame_buffer_stride(width)
|
||||
.checked_mul(height)?
|
||||
.try_into()
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn shared_frame_mapping_size(width: u32, height: u32) -> Option<usize> {
|
||||
frame_buffer_size(width, height)?.checked_mul(GAME_CAPTURE_BUFFER_COUNT)
|
||||
}
|
||||
|
||||
pub fn object_name(prefix: &str, pid: u32) -> String {
|
||||
format!("{prefix}{pid}")
|
||||
}
|
||||
|
||||
pub fn mutex_name(pid: u32, index: usize) -> String {
|
||||
format!("{GAME_CAPTURE_MUTEX_PREFIX}{index}_{pid}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn qpc_ticks_to_us_converts_whole_and_fractional_seconds() {
|
||||
assert_eq!(qpc_ticks_to_us(10_000_000, 10_000_000), 1_000_000);
|
||||
assert_eq!(qpc_ticks_to_us(15_000_000, 10_000_000), 1_500_000);
|
||||
assert_eq!(qpc_ticks_to_us(1, 10_000_000), 0);
|
||||
assert_eq!(qpc_ticks_to_us(10, 10_000_000), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qpc_ticks_to_us_does_not_overflow_for_long_uptimes() {
|
||||
let frequency = 3_800_000_000i64;
|
||||
let uptime_seconds = 400 * 24 * 60 * 60i64;
|
||||
let ticks = frequency * uptime_seconds;
|
||||
assert_eq!(
|
||||
qpc_ticks_to_us(ticks, frequency),
|
||||
uptime_seconds * 1_000_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qpc_ticks_to_us_rejects_invalid_inputs() {
|
||||
assert_eq!(qpc_ticks_to_us(123, 0), 0);
|
||||
assert_eq!(qpc_ticks_to_us(123, -1), 0);
|
||||
assert_eq!(qpc_ticks_to_us(-123, 10_000_000), 0);
|
||||
assert_eq!(qpc_ticks_to_us(0, 10_000_000), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_supports_present_clock_gates_on_abi_version() {
|
||||
assert!(!host_supports_present_clock(0));
|
||||
assert!(!host_supports_present_clock(3));
|
||||
assert!(host_supports_present_clock(4));
|
||||
assert!(host_supports_present_clock(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presented_recently_uses_wall_clock_for_legacy_hooks() {
|
||||
assert!(presented_recently(
|
||||
GAME_CAPTURE_PRESENT_CLOCK_WALL,
|
||||
1_000_000,
|
||||
1_500_000,
|
||||
i64::MAX,
|
||||
1_000_000
|
||||
));
|
||||
assert!(!presented_recently(
|
||||
GAME_CAPTURE_PRESENT_CLOCK_WALL,
|
||||
1_000_000,
|
||||
2_000_000,
|
||||
0,
|
||||
1_000_000
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presented_recently_uses_qpc_for_present_clock_hooks() {
|
||||
assert!(presented_recently(
|
||||
GAME_CAPTURE_PRESENT_CLOCK_QPC,
|
||||
1_000_000,
|
||||
i64::MAX,
|
||||
1_500_000,
|
||||
1_000_000
|
||||
));
|
||||
assert!(!presented_recently(
|
||||
GAME_CAPTURE_PRESENT_CLOCK_QPC,
|
||||
1_000_000,
|
||||
0,
|
||||
2_000_000,
|
||||
1_000_000
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presented_recently_requires_an_observed_present() {
|
||||
assert!(!presented_recently(
|
||||
GAME_CAPTURE_PRESENT_CLOCK_WALL,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1_000_000
|
||||
));
|
||||
assert!(!presented_recently(
|
||||
GAME_CAPTURE_PRESENT_CLOCK_QPC,
|
||||
-1,
|
||||
1,
|
||||
1,
|
||||
1_000_000
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{LazyLock, Mutex},
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use windows::{
|
||||
Win32::{
|
||||
Foundation::{CloseHandle, HANDLE},
|
||||
System::{
|
||||
LibraryLoader::{GetProcAddress, LoadLibraryW},
|
||||
Threading::{
|
||||
GetCurrentProcess, OpenProcess, PROCESS_QUERY_INFORMATION,
|
||||
PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SET_INFORMATION,
|
||||
},
|
||||
},
|
||||
},
|
||||
core::{s, w},
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const D3DKMT_SCHEDULINGPRIORITYCLASS_NORMAL: u32 = 2;
|
||||
#[cfg(target_os = "windows")]
|
||||
const D3DKMT_SCHEDULINGPRIORITYCLASS_HIGH: u32 = 4;
|
||||
#[cfg(target_os = "windows")]
|
||||
const D3DKMT_SCHEDULINGPRIORITYCLASS_REALTIME: u32 = 5;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
static SAVED_PRIORITIES: LazyLock<Mutex<HashMap<u32, u32>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
type SetSchedulingPriorityClassFn = unsafe extern "system" fn(HANDLE, u32) -> i32;
|
||||
#[cfg(target_os = "windows")]
|
||||
type GetSchedulingPriorityClassFn = unsafe extern "system" fn(HANDLE, *mut u32) -> i32;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
struct D3dkmtFns {
|
||||
set: SetSchedulingPriorityClassFn,
|
||||
get: GetSchedulingPriorityClassFn,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn load_d3dkmt_fns() -> Result<D3dkmtFns, String> {
|
||||
unsafe {
|
||||
let gdi32 =
|
||||
LoadLibraryW(w!("gdi32.dll")).map_err(|e| format!("LoadLibraryW(gdi32.dll): {e}"))?;
|
||||
let set_ptr = GetProcAddress(gdi32, s!("D3DKMTSetProcessSchedulingPriorityClass"))
|
||||
.ok_or("D3DKMTSetProcessSchedulingPriorityClass not exported by gdi32.dll")?;
|
||||
let get_ptr = GetProcAddress(gdi32, s!("D3DKMTGetProcessSchedulingPriorityClass"))
|
||||
.ok_or("D3DKMTGetProcessSchedulingPriorityClass not exported by gdi32.dll")?;
|
||||
Ok(D3dkmtFns {
|
||||
set: std::mem::transmute::<
|
||||
unsafe extern "system" fn() -> isize,
|
||||
SetSchedulingPriorityClassFn,
|
||||
>(set_ptr),
|
||||
get: std::mem::transmute::<
|
||||
unsafe extern "system" fn() -> isize,
|
||||
GetSchedulingPriorityClassFn,
|
||||
>(get_ptr),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn process_id_or_current(process_id: Option<u32>) -> Result<u32, String> {
|
||||
let pid = process_id.unwrap_or_else(std::process::id);
|
||||
if pid == 0 {
|
||||
return Err("Invalid process id 0".into());
|
||||
}
|
||||
Ok(pid)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn with_process_handle<T>(
|
||||
process_id: u32,
|
||||
operation: impl FnOnce(HANDLE) -> Result<T, String>,
|
||||
) -> Result<T, String> {
|
||||
if process_id == std::process::id() {
|
||||
return operation(unsafe { GetCurrentProcess() });
|
||||
}
|
||||
|
||||
let access =
|
||||
PROCESS_SET_INFORMATION | PROCESS_QUERY_INFORMATION | PROCESS_QUERY_LIMITED_INFORMATION;
|
||||
let handle = unsafe { OpenProcess(access, false, process_id) }
|
||||
.map_err(|e| format!("OpenProcess({process_id}): {e}"))?;
|
||||
let result = operation(handle);
|
||||
let _ = unsafe { CloseHandle(handle) };
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn scheduling_priority_class(
|
||||
priority_class: Option<String>,
|
||||
) -> Result<(u32, &'static str), String> {
|
||||
match priority_class
|
||||
.as_deref()
|
||||
.unwrap_or("high")
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"" | "high" => Ok((D3DKMT_SCHEDULINGPRIORITYCLASS_HIGH, "HIGH")),
|
||||
"realtime" | "real-time" => Ok((D3DKMT_SCHEDULINGPRIORITYCLASS_REALTIME, "REALTIME")),
|
||||
other => Err(format!(
|
||||
"Unsupported GPU scheduling priority class '{other}'; expected 'high' or 'realtime'"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn elevate(process_id: Option<u32>, priority_class: Option<String>) -> Result<(), String> {
|
||||
let fns = load_d3dkmt_fns()?;
|
||||
let process_id = process_id_or_current(process_id)?;
|
||||
let (priority_class, priority_label) = scheduling_priority_class(priority_class)?;
|
||||
|
||||
with_process_handle(process_id, |process| {
|
||||
let mut current: u32 = D3DKMT_SCHEDULINGPRIORITYCLASS_NORMAL;
|
||||
let status = unsafe { (fns.get)(process, &mut current) };
|
||||
if status == 0 {
|
||||
let mut saved = SAVED_PRIORITIES
|
||||
.lock()
|
||||
.map_err(|_| "GPU priority saved-state lock poisoned".to_string())?;
|
||||
saved.entry(process_id).or_insert(current);
|
||||
}
|
||||
|
||||
let status = unsafe { (fns.set)(process, priority_class) };
|
||||
if status != 0 {
|
||||
return Err(format!(
|
||||
"D3DKMTSetProcessSchedulingPriorityClass(pid={process_id}, {priority_label}) returned NTSTATUS 0x{:08X}",
|
||||
status as u32
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn restore(process_id: Option<u32>) -> Result<(), String> {
|
||||
let fns = load_d3dkmt_fns()?;
|
||||
let process_id = process_id_or_current(process_id)?;
|
||||
let target = {
|
||||
let mut saved = SAVED_PRIORITIES
|
||||
.lock()
|
||||
.map_err(|_| "GPU priority saved-state lock poisoned".to_string())?;
|
||||
saved
|
||||
.remove(&process_id)
|
||||
.unwrap_or(D3DKMT_SCHEDULINGPRIORITYCLASS_NORMAL)
|
||||
};
|
||||
|
||||
with_process_handle(process_id, |process| {
|
||||
let status = unsafe { (fns.set)(process, target) };
|
||||
if status != 0 {
|
||||
return Err(format!(
|
||||
"D3DKMTSetProcessSchedulingPriorityClass(pid={process_id}, restore={target}) returned NTSTATUS 0x{:08X}",
|
||||
status as u32
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn elevate(_process_id: Option<u32>, _priority_class: Option<String>) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn restore(_process_id: Option<u32>) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub const DXGI_FORMAT_R16G16B16A16_FLOAT: u32 = 10;
|
||||
pub const DXGI_FORMAT_R16G16B16A16_UNORM: u32 = 11;
|
||||
pub const DXGI_FORMAT_R10G10B10A2_UNORM: u32 = 24;
|
||||
pub const DXGI_FORMAT_R8G8B8A8_UNORM: u32 = 28;
|
||||
pub const DXGI_FORMAT_R8G8B8A8_UNORM_SRGB: u32 = 29;
|
||||
pub const DXGI_FORMAT_B8G8R8A8_UNORM: u32 = 87;
|
||||
pub const DXGI_FORMAT_B8G8R8X8_UNORM: u32 = 88;
|
||||
pub const DXGI_FORMAT_R10G10B10_XR_BIAS_A2_UNORM: u32 = 89;
|
||||
pub const DXGI_FORMAT_B8G8R8A8_UNORM_SRGB: u32 = 91;
|
||||
pub const DXGI_FORMAT_B8G8R8X8_UNORM_SRGB: u32 = 93;
|
||||
pub const DXGI_FORMAT_P010: u32 = 104;
|
||||
pub const DXGI_FORMAT_P016: u32 = 105;
|
||||
pub const DXGI_FORMAT_420_OPAQUE: u32 = 106;
|
||||
pub const DXGI_FORMAT_YUY2: u32 = 107;
|
||||
pub const DXGI_FORMAT_R32G32B32A32_FLOAT: u32 = 2;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SourceFormat {
|
||||
Bgra8,
|
||||
Rgba8,
|
||||
R10G10B10A2 { hdr: bool },
|
||||
Rgba16Float { hdr: bool },
|
||||
}
|
||||
|
||||
impl SourceFormat {
|
||||
pub fn classify(dxgi_format: u32, capture_flags: u32) -> Option<SourceFormat> {
|
||||
let hdr = capture_flags & crate::game_capture_abi::GAME_CAPTURE_FLAG_HDR != 0;
|
||||
match dxgi_format {
|
||||
DXGI_FORMAT_B8G8R8A8_UNORM
|
||||
| DXGI_FORMAT_B8G8R8A8_UNORM_SRGB
|
||||
| DXGI_FORMAT_B8G8R8X8_UNORM
|
||||
| DXGI_FORMAT_B8G8R8X8_UNORM_SRGB => Some(SourceFormat::Bgra8),
|
||||
DXGI_FORMAT_R8G8B8A8_UNORM | DXGI_FORMAT_R8G8B8A8_UNORM_SRGB => {
|
||||
Some(SourceFormat::Rgba8)
|
||||
}
|
||||
DXGI_FORMAT_R10G10B10A2_UNORM => Some(SourceFormat::R10G10B10A2 { hdr }),
|
||||
DXGI_FORMAT_R16G16B16A16_FLOAT => Some(SourceFormat::Rgba16Float { hdr }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_8bit(self) -> bool {
|
||||
matches!(self, SourceFormat::Bgra8 | SourceFormat::Rgba8)
|
||||
}
|
||||
|
||||
pub fn is_hdr(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
SourceFormat::R10G10B10A2 { hdr: true } | SourceFormat::Rgba16Float { hdr: true }
|
||||
)
|
||||
}
|
||||
|
||||
pub fn bytes_per_pixel(self) -> usize {
|
||||
match self {
|
||||
SourceFormat::Bgra8 | SourceFormat::Rgba8 | SourceFormat::R10G10B10A2 { .. } => 4,
|
||||
SourceFormat::Rgba16Float { .. } => 8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SDR_WHITE_NITS: f32 = 80.0;
|
||||
|
||||
pub fn r10g10b10a2_row_to_bgra(src_row: &[u8], width: usize, dst_row: &mut [u8], hdr: bool) {
|
||||
for x in 0..width {
|
||||
let so = x * 4;
|
||||
let dofs = x * 4;
|
||||
if so + 4 > src_row.len() || dofs + 4 > dst_row.len() {
|
||||
break;
|
||||
}
|
||||
let packed = u32::from_le_bytes([
|
||||
src_row[so],
|
||||
src_row[so + 1],
|
||||
src_row[so + 2],
|
||||
src_row[so + 3],
|
||||
]);
|
||||
let r10 = (packed & 0x3FF) as u16;
|
||||
let g10 = ((packed >> 10) & 0x3FF) as u16;
|
||||
let b10 = ((packed >> 20) & 0x3FF) as u16;
|
||||
let a2 = ((packed >> 30) & 0x3) as u8;
|
||||
let (r, g, b) = if hdr {
|
||||
tonemap_rec2020_pq_to_srgb8(r10, g10, b10)
|
||||
} else {
|
||||
(scale10_to_8(r10), scale10_to_8(g10), scale10_to_8(b10))
|
||||
};
|
||||
dst_row[dofs] = b;
|
||||
dst_row[dofs + 1] = g;
|
||||
dst_row[dofs + 2] = r;
|
||||
dst_row[dofs + 3] = (a2 as u16 * 255 / 3) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rgba16f_row_to_bgra(src_row: &[u8], width: usize, dst_row: &mut [u8], hdr: bool) {
|
||||
for x in 0..width {
|
||||
let so = x * 8;
|
||||
let dofs = x * 4;
|
||||
if so + 8 > src_row.len() || dofs + 4 > dst_row.len() {
|
||||
break;
|
||||
}
|
||||
let r = f16_to_f32(u16::from_le_bytes([src_row[so], src_row[so + 1]]));
|
||||
let g = f16_to_f32(u16::from_le_bytes([src_row[so + 2], src_row[so + 3]]));
|
||||
let b = f16_to_f32(u16::from_le_bytes([src_row[so + 4], src_row[so + 5]]));
|
||||
let a = f16_to_f32(u16::from_le_bytes([src_row[so + 6], src_row[so + 7]]));
|
||||
let (lr, lg, lb) = if hdr {
|
||||
(
|
||||
reinhard(r.max(0.0)),
|
||||
reinhard(g.max(0.0)),
|
||||
reinhard(b.max(0.0)),
|
||||
)
|
||||
} else {
|
||||
(r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0))
|
||||
};
|
||||
dst_row[dofs] = linear_to_srgb8(lb);
|
||||
dst_row[dofs + 1] = linear_to_srgb8(lg);
|
||||
dst_row[dofs + 2] = linear_to_srgb8(lr);
|
||||
dst_row[dofs + 3] = (a.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
fn scale10_to_8(v10: u16) -> u8 {
|
||||
((v10 as u32 * 255 + 511) / 1023) as u8
|
||||
}
|
||||
|
||||
fn reinhard(linear: f32) -> f32 {
|
||||
let v = linear.max(0.0);
|
||||
(v / (1.0 + v)).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn tonemap_rec2020_pq_to_srgb8(r10: u16, g10: u16, b10: u16) -> (u8, u8, u8) {
|
||||
let lr = pq_eotf(r10 as f32 / 1023.0);
|
||||
let lg = pq_eotf(g10 as f32 / 1023.0);
|
||||
let lb = pq_eotf(b10 as f32 / 1023.0);
|
||||
let scale = 10000.0 / SDR_WHITE_NITS;
|
||||
let map = |v: f32| reinhard((v * scale).max(0.0));
|
||||
(
|
||||
linear_to_srgb8(map(lr)),
|
||||
linear_to_srgb8(map(lg)),
|
||||
linear_to_srgb8(map(lb)),
|
||||
)
|
||||
}
|
||||
|
||||
fn pq_eotf(e: f32) -> f32 {
|
||||
const M1: f64 = 0.1593017578125;
|
||||
const M2: f64 = 78.84375;
|
||||
const C1: f64 = 0.8359375;
|
||||
const C2: f64 = 18.8515625;
|
||||
const C3: f64 = 18.6875;
|
||||
let e = (e.clamp(0.0, 1.0)) as f64;
|
||||
let ep = e.powf(1.0 / M2);
|
||||
let num = (ep - C1).max(0.0);
|
||||
let den = C2 - C3 * ep;
|
||||
if den <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
(num / den).powf(1.0 / M1) as f32
|
||||
}
|
||||
|
||||
fn linear_to_srgb8(linear: f32) -> u8 {
|
||||
let l = linear.clamp(0.0, 1.0);
|
||||
let srgb = if l <= 0.0031308 {
|
||||
l * 12.92
|
||||
} else {
|
||||
1.055 * l.powf(1.0 / 2.4) - 0.055
|
||||
};
|
||||
(srgb.clamp(0.0, 1.0) * 255.0 + 0.5) as u8
|
||||
}
|
||||
|
||||
pub fn f16_to_f32(h: u16) -> f32 {
|
||||
let sign = (h >> 15) & 0x1;
|
||||
let exp = (h >> 10) & 0x1F;
|
||||
let mant = h & 0x3FF;
|
||||
let sign_f = if sign == 1 { -1.0f32 } else { 1.0f32 };
|
||||
if exp == 0 {
|
||||
sign_f * (mant as f32) * 2f32.powi(-24)
|
||||
} else if exp == 0x1F {
|
||||
if mant == 0 { sign_f * 65504.0 } else { 0.0 }
|
||||
} else {
|
||||
sign_f * (1.0 + (mant as f32) / 1024.0) * 2f32.powi(exp as i32 - 15)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game_capture_abi::{GAME_CAPTURE_FLAG_HDR, GAME_CAPTURE_FLAG_TEN_BIT};
|
||||
|
||||
#[test]
|
||||
fn classifies_8bit_formats() {
|
||||
assert_eq!(
|
||||
SourceFormat::classify(DXGI_FORMAT_B8G8R8A8_UNORM, 0),
|
||||
Some(SourceFormat::Bgra8)
|
||||
);
|
||||
assert_eq!(
|
||||
SourceFormat::classify(DXGI_FORMAT_R8G8B8A8_UNORM, 0),
|
||||
Some(SourceFormat::Rgba8)
|
||||
);
|
||||
assert!(
|
||||
SourceFormat::classify(DXGI_FORMAT_B8G8R8A8_UNORM, 0)
|
||||
.unwrap()
|
||||
.is_8bit()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_obs_8bit_alias_set() {
|
||||
for format in [
|
||||
DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
DXGI_FORMAT_B8G8R8A8_UNORM_SRGB,
|
||||
DXGI_FORMAT_B8G8R8X8_UNORM,
|
||||
DXGI_FORMAT_B8G8R8X8_UNORM_SRGB,
|
||||
] {
|
||||
assert_eq!(SourceFormat::classify(format, 0), Some(SourceFormat::Bgra8));
|
||||
assert_eq!(
|
||||
SourceFormat::classify(format, GAME_CAPTURE_FLAG_HDR),
|
||||
Some(SourceFormat::Bgra8),
|
||||
"8-bit format {format} must not become HDR just because the flag is set"
|
||||
);
|
||||
}
|
||||
|
||||
for format in [DXGI_FORMAT_R8G8B8A8_UNORM, DXGI_FORMAT_R8G8B8A8_UNORM_SRGB] {
|
||||
assert_eq!(SourceFormat::classify(format, 0), Some(SourceFormat::Rgba8));
|
||||
assert_eq!(
|
||||
SourceFormat::classify(format, GAME_CAPTURE_FLAG_HDR),
|
||||
Some(SourceFormat::Rgba8),
|
||||
"8-bit format {format} must not become HDR just because the flag is set"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_10bit_and_hdr_flag() {
|
||||
assert_eq!(
|
||||
SourceFormat::classify(DXGI_FORMAT_R10G10B10A2_UNORM, GAME_CAPTURE_FLAG_TEN_BIT),
|
||||
Some(SourceFormat::R10G10B10A2 { hdr: false })
|
||||
);
|
||||
assert_eq!(
|
||||
SourceFormat::classify(
|
||||
DXGI_FORMAT_R10G10B10A2_UNORM,
|
||||
GAME_CAPTURE_FLAG_TEN_BIT | GAME_CAPTURE_FLAG_HDR
|
||||
),
|
||||
Some(SourceFormat::R10G10B10A2 { hdr: true })
|
||||
);
|
||||
assert!(
|
||||
SourceFormat::classify(DXGI_FORMAT_R10G10B10A2_UNORM, GAME_CAPTURE_FLAG_HDR)
|
||||
.unwrap()
|
||||
.is_hdr()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_precision_formats_have_expected_byte_widths() {
|
||||
assert_eq!(SourceFormat::Bgra8.bytes_per_pixel(), 4);
|
||||
assert_eq!(SourceFormat::Rgba8.bytes_per_pixel(), 4);
|
||||
assert_eq!(
|
||||
SourceFormat::R10G10B10A2 { hdr: false }.bytes_per_pixel(),
|
||||
4
|
||||
);
|
||||
assert_eq!(SourceFormat::R10G10B10A2 { hdr: true }.bytes_per_pixel(), 4);
|
||||
assert_eq!(
|
||||
SourceFormat::Rgba16Float { hdr: false }.bytes_per_pixel(),
|
||||
8
|
||||
);
|
||||
assert_eq!(SourceFormat::Rgba16Float { hdr: true }.bytes_per_pixel(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_fp16_scrgb() {
|
||||
assert_eq!(
|
||||
SourceFormat::classify(DXGI_FORMAT_R16G16B16A16_FLOAT, GAME_CAPTURE_FLAG_HDR),
|
||||
Some(SourceFormat::Rgba16Float { hdr: true })
|
||||
);
|
||||
assert_eq!(
|
||||
SourceFormat::classify(DXGI_FORMAT_R16G16B16A16_FLOAT, 0),
|
||||
Some(SourceFormat::Rgba16Float { hdr: false })
|
||||
);
|
||||
assert_eq!(
|
||||
SourceFormat::Rgba16Float { hdr: false }.bytes_per_pixel(),
|
||||
8
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_format_is_none() {
|
||||
assert_eq!(SourceFormat::classify(104, 0), None);
|
||||
assert_eq!(SourceFormat::classify(0, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_and_opaque_formats_stay_unsupported_even_with_hdr_flags() {
|
||||
for format in [
|
||||
DXGI_FORMAT_P010,
|
||||
DXGI_FORMAT_P016,
|
||||
DXGI_FORMAT_420_OPAQUE,
|
||||
DXGI_FORMAT_YUY2,
|
||||
] {
|
||||
assert_eq!(
|
||||
SourceFormat::classify(format, GAME_CAPTURE_FLAG_TEN_BIT | GAME_CAPTURE_FLAG_HDR),
|
||||
None,
|
||||
"DXGI video/opaque format {format} is not a directly readable game backbuffer"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn obs_high_precision_formats_without_reader_conversion_stay_unsupported() {
|
||||
assert_eq!(
|
||||
SourceFormat::classify(DXGI_FORMAT_R16G16B16A16_UNORM, GAME_CAPTURE_FLAG_HDR),
|
||||
None,
|
||||
"OBS maps RGBA16 UNORM, but Fluxer needs a row converter before claiming support"
|
||||
);
|
||||
assert_eq!(
|
||||
SourceFormat::classify(DXGI_FORMAT_R32G32B32A32_FLOAT, GAME_CAPTURE_FLAG_HDR),
|
||||
None,
|
||||
"OBS maps RGBA32F, but Fluxer has no reader-side conversion for it yet"
|
||||
);
|
||||
assert_eq!(
|
||||
SourceFormat::classify(
|
||||
DXGI_FORMAT_R10G10B10_XR_BIAS_A2_UNORM,
|
||||
GAME_CAPTURE_FLAG_TEN_BIT | GAME_CAPTURE_FLAG_HDR
|
||||
),
|
||||
None,
|
||||
"XR-bias 10A2 is not equivalent to regular R10G10B10A2 UNORM"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f16_round_trips_known_values() {
|
||||
assert_eq!(f16_to_f32(0x0000), 0.0);
|
||||
assert_eq!(f16_to_f32(0x3C00), 1.0);
|
||||
assert_eq!(f16_to_f32(0x4000), 2.0);
|
||||
assert_eq!(f16_to_f32(0x3800), 0.5);
|
||||
assert!((f16_to_f32(0xC000) + 2.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f16_special_values_are_bounded_for_sdr_fallback() {
|
||||
assert_eq!(f16_to_f32(0x7C00), 65504.0);
|
||||
assert_eq!(f16_to_f32(0xFC00), -65504.0);
|
||||
assert_eq!(f16_to_f32(0x7E00), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r10_sdr_unpack_scales_extremes() {
|
||||
let packed: u32 = 0x3FF | (0x3FF << 10) | (0x3FF << 20) | (0x3 << 30);
|
||||
let src = packed.to_le_bytes();
|
||||
let mut dst = [0u8; 4];
|
||||
r10g10b10a2_row_to_bgra(&src, 1, &mut dst, false);
|
||||
assert_eq!(dst, [255, 255, 255, 255]);
|
||||
|
||||
let src0 = 0u32.to_le_bytes();
|
||||
let mut dst0 = [0u8; 4];
|
||||
r10g10b10a2_row_to_bgra(&src0, 1, &mut dst0, false);
|
||||
assert_eq!(dst0, [0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r10_sdr_unpack_scales_midpoints_and_alpha() {
|
||||
let packed: u32 = 0x200 | (0x100 << 10) | (0x080 << 20) | (0x2 << 30);
|
||||
let src = packed.to_le_bytes();
|
||||
let mut dst = [0u8; 4];
|
||||
r10g10b10a2_row_to_bgra(&src, 1, &mut dst, false);
|
||||
assert!((31..=33).contains(&dst[0]), "B was {}", dst[0]);
|
||||
assert!((63..=65).contains(&dst[1]), "G was {}", dst[1]);
|
||||
assert!((127..=129).contains(&dst[2]), "R was {}", dst[2]);
|
||||
assert_eq!(dst[3], 170);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r10_unpack_respects_short_rows() {
|
||||
let src = [0xFF, 0xFF, 0xFF];
|
||||
let mut dst = [9u8; 8];
|
||||
r10g10b10a2_row_to_bgra(&src, 2, &mut dst, false);
|
||||
assert_eq!(dst, [9u8; 8]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r10_unpack_channel_order_is_bgra() {
|
||||
let packed: u32 = 0x3FF;
|
||||
let src = packed.to_le_bytes();
|
||||
let mut dst = [0u8; 4];
|
||||
r10g10b10a2_row_to_bgra(&src, 1, &mut dst, false);
|
||||
assert_eq!(dst[0], 0, "B should be 0");
|
||||
assert_eq!(dst[1], 0, "G should be 0");
|
||||
assert_eq!(dst[2], 255, "R should be 255");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r10_hdr_unpack_is_bounded_and_nonzero() {
|
||||
let packed: u32 = 0x3FF | (0x200 << 10) | (0x100 << 20) | (0x3 << 30);
|
||||
let src = packed.to_le_bytes();
|
||||
let mut dst = [0u8; 4];
|
||||
r10g10b10a2_row_to_bgra(&src, 1, &mut dst, true);
|
||||
assert!(dst[2] > 0, "bright red PQ should map to a visible value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r10_hdr_unpack_is_monotonic_after_tonemap() {
|
||||
let pack = |v: u32| -> [u8; 4] { (v | (v << 10) | (v << 20) | (0x3 << 30)).to_le_bytes() };
|
||||
let mut low = [0u8; 4];
|
||||
let mut mid = [0u8; 4];
|
||||
let mut high = [0u8; 4];
|
||||
r10g10b10a2_row_to_bgra(&pack(0x100), 1, &mut low, true);
|
||||
r10g10b10a2_row_to_bgra(&pack(0x200), 1, &mut mid, true);
|
||||
r10g10b10a2_row_to_bgra(&pack(0x3FF), 1, &mut high, true);
|
||||
assert!(low[2] <= mid[2] && mid[2] <= high[2]);
|
||||
assert_eq!(high[3], 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fp16_sdr_clamps_and_encodes_srgb() {
|
||||
let mut src = [0u8; 8];
|
||||
src[0..2].copy_from_slice(&0x3C00u16.to_le_bytes());
|
||||
src[6..8].copy_from_slice(&0x3C00u16.to_le_bytes());
|
||||
let mut dst = [0u8; 4];
|
||||
rgba16f_row_to_bgra(&src, 1, &mut dst, false);
|
||||
assert_eq!(dst[0], 0, "B");
|
||||
assert_eq!(dst[1], 0, "G");
|
||||
assert_eq!(dst[2], 255, "R linear 1.0 -> sRGB 255");
|
||||
assert_eq!(dst[3], 255, "A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fp16_sdr_clamps_negative_and_above_one_channels() {
|
||||
let mut src = [0u8; 8];
|
||||
src[0..2].copy_from_slice(&0xBC00u16.to_le_bytes());
|
||||
src[2..4].copy_from_slice(&0x4000u16.to_le_bytes());
|
||||
src[4..6].copy_from_slice(&0x3800u16.to_le_bytes());
|
||||
src[6..8].copy_from_slice(&0x4000u16.to_le_bytes());
|
||||
let mut dst = [0u8; 4];
|
||||
rgba16f_row_to_bgra(&src, 1, &mut dst, false);
|
||||
assert!((180..=196).contains(&dst[0]), "B linear 0.5 was {}", dst[0]);
|
||||
assert_eq!(dst[1], 255, "G linear 2.0 clamps to white");
|
||||
assert_eq!(dst[2], 0, "R negative clamps to black");
|
||||
assert_eq!(dst[3], 255, "A above 1.0 clamps opaque");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fp16_unpack_respects_short_rows() {
|
||||
let src = [0x00, 0x3C, 0x00, 0x3C];
|
||||
let mut dst = [7u8; 8];
|
||||
rgba16f_row_to_bgra(&src, 2, &mut dst, false);
|
||||
assert_eq!(dst, [7u8; 8]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fp16_hdr_above_one_is_tonemapped_not_clipped_to_garbage() {
|
||||
let mut src = [0u8; 8];
|
||||
src[0..2].copy_from_slice(&0x4400u16.to_le_bytes());
|
||||
src[6..8].copy_from_slice(&0x3C00u16.to_le_bytes());
|
||||
let mut dst = [0u8; 4];
|
||||
rgba16f_row_to_bgra(&src, 1, &mut dst, true);
|
||||
assert!(dst[2] > 100, "HDR highlight should be bright after tonemap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srgb_encode_endpoints() {
|
||||
assert_eq!(linear_to_srgb8(0.0), 0);
|
||||
assert_eq!(linear_to_srgb8(1.0), 255);
|
||||
let mid = linear_to_srgb8(0.5);
|
||||
assert!((180..=196).contains(&mid), "sRGB(0.5) was {mid}");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,365 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11_BIND_RENDER_TARGET, D3D11_RESOURCE_MISC_SHARED, D3D11_TEXTURE2D_DESC,
|
||||
D3D11_USAGE_DEFAULT, D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE, D3D11_VIDEO_PROCESSOR_CONTENT_DESC,
|
||||
D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC, D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC_0,
|
||||
D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC, D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC_0,
|
||||
D3D11_VIDEO_PROCESSOR_STREAM, D3D11_VIDEO_USAGE_PLAYBACK_NORMAL,
|
||||
D3D11_VPIV_DIMENSION_TEXTURE2D, D3D11_VPOV_DIMENSION_TEXTURE2D, ID3D11Device,
|
||||
ID3D11DeviceContext, ID3D11Texture2D, ID3D11VideoContext, ID3D11VideoContext1,
|
||||
ID3D11VideoDevice, ID3D11VideoProcessor, ID3D11VideoProcessorEnumerator,
|
||||
ID3D11VideoProcessorInputView, ID3D11VideoProcessorOutputView,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709, DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709,
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020, DXGI_COLOR_SPACE_TYPE,
|
||||
DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709, DXGI_FORMAT_NV12, DXGI_RATIONAL, DXGI_SAMPLE_DESC,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::IDXGIResource;
|
||||
use windows::core::Interface;
|
||||
|
||||
use crate::hdr;
|
||||
|
||||
fn vlog(msg: &str) {
|
||||
if crate::game_capture_abi::env_flag_enabled(crate::game_capture_abi::ENV_VERBOSE) {
|
||||
use std::io::Write;
|
||||
let _ = writeln!(std::io::stderr(), "[fluxer-nv12] {msg}");
|
||||
}
|
||||
}
|
||||
|
||||
pub const NV12_OUTPUT_SLOT_COUNT: usize = 3;
|
||||
|
||||
struct Nv12OutputSlot {
|
||||
_texture: ID3D11Texture2D,
|
||||
view: ID3D11VideoProcessorOutputView,
|
||||
handle: u64,
|
||||
}
|
||||
|
||||
pub struct Nv12GpuConverter {
|
||||
_video_device: ID3D11VideoDevice,
|
||||
video_context: ID3D11VideoContext,
|
||||
processor: ID3D11VideoProcessor,
|
||||
_enumerator: ID3D11VideoProcessorEnumerator,
|
||||
input_view: ID3D11VideoProcessorInputView,
|
||||
output_slots: [Nv12OutputSlot; NV12_OUTPUT_SLOT_COUNT],
|
||||
slot_cursor: usize,
|
||||
context: ID3D11DeviceContext,
|
||||
out_width: u32,
|
||||
out_height: u32,
|
||||
}
|
||||
|
||||
pub struct Nv12SharedTextureFrame {
|
||||
pub handle: u64,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub dxgi_format: u32,
|
||||
}
|
||||
|
||||
impl Nv12GpuConverter {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
device: &ID3D11Device,
|
||||
context: &ID3D11DeviceContext,
|
||||
input: &ID3D11Texture2D,
|
||||
in_width: u32,
|
||||
in_height: u32,
|
||||
out_width: u32,
|
||||
out_height: u32,
|
||||
source_format: hdr::SourceFormat,
|
||||
) -> Option<Self> {
|
||||
let out_width = (out_width & !1).max(2);
|
||||
let out_height = (out_height & !1).max(2);
|
||||
let video_device = device
|
||||
.cast::<ID3D11VideoDevice>()
|
||||
.inspect_err(|e| vlog(&format!("cast ID3D11VideoDevice failed: {e:?}")))
|
||||
.ok()?;
|
||||
let video_context = context
|
||||
.cast::<ID3D11VideoContext>()
|
||||
.inspect_err(|e| vlog(&format!("cast ID3D11VideoContext failed: {e:?}")))
|
||||
.ok()?;
|
||||
|
||||
let content_desc = D3D11_VIDEO_PROCESSOR_CONTENT_DESC {
|
||||
InputFrameFormat: D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE,
|
||||
InputFrameRate: DXGI_RATIONAL {
|
||||
Numerator: 60,
|
||||
Denominator: 1,
|
||||
},
|
||||
InputWidth: in_width,
|
||||
InputHeight: in_height,
|
||||
OutputFrameRate: DXGI_RATIONAL {
|
||||
Numerator: 60,
|
||||
Denominator: 1,
|
||||
},
|
||||
OutputWidth: out_width,
|
||||
OutputHeight: out_height,
|
||||
Usage: D3D11_VIDEO_USAGE_PLAYBACK_NORMAL,
|
||||
};
|
||||
let enumerator = unsafe { video_device.CreateVideoProcessorEnumerator(&content_desc) }
|
||||
.inspect_err(|e| vlog(&format!("CreateVideoProcessorEnumerator: {e:?}")))
|
||||
.ok()?;
|
||||
let processor = unsafe { video_device.CreateVideoProcessor(&enumerator, 0) }
|
||||
.inspect_err(|e| vlog(&format!("CreateVideoProcessor: {e:?}")))
|
||||
.ok()?;
|
||||
|
||||
if let Ok(vctx1) = video_context.cast::<ID3D11VideoContext1>() {
|
||||
let input_cs = input_colour_space(source_format);
|
||||
unsafe {
|
||||
vctx1.VideoProcessorSetStreamColorSpace1(&processor, 0, input_cs);
|
||||
vctx1.VideoProcessorSetOutputColorSpace1(
|
||||
&processor,
|
||||
DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709,
|
||||
);
|
||||
}
|
||||
vlog(&format!(
|
||||
"video processor colour space set: input={} -> output=YCbCr studio Rec.709",
|
||||
input_cs.0
|
||||
));
|
||||
} else {
|
||||
vlog("ID3D11VideoContext1 unavailable; using default SDR Rec.709 colour space");
|
||||
}
|
||||
|
||||
let output_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: out_width,
|
||||
Height: out_height,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_NV12,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
||||
CPUAccessFlags: 0,
|
||||
MiscFlags: D3D11_RESOURCE_MISC_SHARED.0 as u32,
|
||||
};
|
||||
let mut output_slots = Vec::with_capacity(NV12_OUTPUT_SLOT_COUNT);
|
||||
for _ in 0..NV12_OUTPUT_SLOT_COUNT {
|
||||
output_slots.push(create_output_slot(
|
||||
device,
|
||||
&video_device,
|
||||
&enumerator,
|
||||
&output_desc,
|
||||
)?);
|
||||
}
|
||||
assert_eq!(
|
||||
output_slots.len(),
|
||||
NV12_OUTPUT_SLOT_COUNT,
|
||||
"all NV12 output slots created"
|
||||
);
|
||||
let Ok(output_slots) = <[Nv12OutputSlot; NV12_OUTPUT_SLOT_COUNT]>::try_from(output_slots)
|
||||
else {
|
||||
vlog("NV12 output slot count mismatch");
|
||||
return None;
|
||||
};
|
||||
|
||||
let input_view_desc = D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC {
|
||||
FourCC: 0,
|
||||
ViewDimension: D3D11_VPIV_DIMENSION_TEXTURE2D,
|
||||
Anonymous: D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC_0 {
|
||||
Texture2D: windows::Win32::Graphics::Direct3D11::D3D11_TEX2D_VPIV {
|
||||
MipSlice: 0,
|
||||
ArraySlice: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
let mut input_view = None;
|
||||
unsafe {
|
||||
video_device.CreateVideoProcessorInputView(
|
||||
input,
|
||||
&enumerator,
|
||||
&input_view_desc,
|
||||
Some(&mut input_view),
|
||||
)
|
||||
}
|
||||
.inspect_err(|e| vlog(&format!("CreateVideoProcessorInputView: {e:?}")))
|
||||
.ok()?;
|
||||
let input_view = input_view?;
|
||||
vlog(&format!(
|
||||
"NV12 converter built OK ({in_width}x{in_height} -> {out_width}x{out_height})"
|
||||
));
|
||||
|
||||
Some(Self {
|
||||
_video_device: video_device,
|
||||
video_context,
|
||||
processor,
|
||||
_enumerator: enumerator,
|
||||
input_view,
|
||||
output_slots,
|
||||
slot_cursor: 0,
|
||||
context: context.clone(),
|
||||
out_width,
|
||||
out_height,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn dxgi_format(&self) -> u32 {
|
||||
DXGI_FORMAT_NV12.0 as u32
|
||||
}
|
||||
|
||||
pub fn convert_shared_texture(&mut self) -> Result<Nv12SharedTextureFrame, String> {
|
||||
assert!(
|
||||
self.slot_cursor < NV12_OUTPUT_SLOT_COUNT,
|
||||
"slot cursor in range"
|
||||
);
|
||||
assert!(self.out_width >= 2, "output width at least 2");
|
||||
let slot_index = self.slot_cursor;
|
||||
self.slot_cursor = (slot_index + 1) % NV12_OUTPUT_SLOT_COUNT;
|
||||
self.run_video_processor(slot_index)?;
|
||||
unsafe {
|
||||
self.context.Flush();
|
||||
}
|
||||
Ok(Nv12SharedTextureFrame {
|
||||
handle: self.output_slots[slot_index].handle,
|
||||
width: self.out_width,
|
||||
height: self.out_height,
|
||||
dxgi_format: self.dxgi_format(),
|
||||
})
|
||||
}
|
||||
|
||||
fn run_video_processor(&self, slot_index: usize) -> Result<(), String> {
|
||||
assert!(slot_index < NV12_OUTPUT_SLOT_COUNT, "slot index in range");
|
||||
let mut stream = D3D11_VIDEO_PROCESSOR_STREAM {
|
||||
Enable: windows::core::BOOL(1),
|
||||
OutputIndex: 0,
|
||||
InputFrameOrField: 0,
|
||||
PastFrames: 0,
|
||||
FutureFrames: 0,
|
||||
ppPastSurfaces: std::ptr::null_mut(),
|
||||
pInputSurface: std::mem::ManuallyDrop::new(Some(self.input_view.clone())),
|
||||
ppFutureSurfaces: std::ptr::null_mut(),
|
||||
ppPastSurfacesRight: std::ptr::null_mut(),
|
||||
pInputSurfaceRight: std::mem::ManuallyDrop::new(None),
|
||||
ppFutureSurfacesRight: std::ptr::null_mut(),
|
||||
};
|
||||
let blt = unsafe {
|
||||
self.video_context.VideoProcessorBlt(
|
||||
&self.processor,
|
||||
&self.output_slots[slot_index].view,
|
||||
0,
|
||||
std::slice::from_ref(&stream),
|
||||
)
|
||||
};
|
||||
unsafe {
|
||||
std::mem::ManuallyDrop::drop(&mut stream.pInputSurface);
|
||||
}
|
||||
blt.inspect_err(|e| vlog(&format!("VideoProcessorBlt RGB->NV12: {e:?}")))
|
||||
.map_err(|e| format!("VideoProcessorBlt RGB->NV12: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn create_output_slot(
|
||||
device: &ID3D11Device,
|
||||
video_device: &ID3D11VideoDevice,
|
||||
enumerator: &ID3D11VideoProcessorEnumerator,
|
||||
output_desc: &D3D11_TEXTURE2D_DESC,
|
||||
) -> Option<Nv12OutputSlot> {
|
||||
assert!(output_desc.Width >= 2, "output width at least 2");
|
||||
assert!(output_desc.Height >= 2, "output height at least 2");
|
||||
let mut output_texture = None;
|
||||
unsafe { device.CreateTexture2D(output_desc, None, Some(&mut output_texture)) }
|
||||
.inspect_err(|e| vlog(&format!("CreateTexture2D NV12 output: {e:?}")))
|
||||
.ok()?;
|
||||
let output_texture = output_texture?;
|
||||
let resource: IDXGIResource = output_texture
|
||||
.cast()
|
||||
.inspect_err(|e| {
|
||||
vlog(&format!(
|
||||
"QueryInterface IDXGIResource for NV12 output: {e:?}"
|
||||
))
|
||||
})
|
||||
.ok()?;
|
||||
let shared_handle = unsafe { resource.GetSharedHandle() }
|
||||
.inspect_err(|e| vlog(&format!("GetSharedHandle NV12 output: {e:?}")))
|
||||
.ok()?;
|
||||
if shared_handle.is_invalid() {
|
||||
vlog("GetSharedHandle NV12 output returned an invalid handle");
|
||||
return None;
|
||||
}
|
||||
let shared_handle = shared_handle.0 as usize as u64;
|
||||
|
||||
let output_view_desc = D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC {
|
||||
ViewDimension: D3D11_VPOV_DIMENSION_TEXTURE2D,
|
||||
Anonymous: D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC_0 {
|
||||
Texture2D: windows::Win32::Graphics::Direct3D11::D3D11_TEX2D_VPOV { MipSlice: 0 },
|
||||
},
|
||||
};
|
||||
let mut output_view = None;
|
||||
unsafe {
|
||||
video_device.CreateVideoProcessorOutputView(
|
||||
&output_texture,
|
||||
enumerator,
|
||||
&output_view_desc,
|
||||
Some(&mut output_view),
|
||||
)
|
||||
}
|
||||
.inspect_err(|e| vlog(&format!("CreateVideoProcessorOutputView: {e:?}")))
|
||||
.ok()?;
|
||||
let output_view = output_view?;
|
||||
|
||||
Some(Nv12OutputSlot {
|
||||
_texture: output_texture,
|
||||
view: output_view,
|
||||
handle: shared_handle,
|
||||
})
|
||||
}
|
||||
|
||||
fn input_colour_space(source_format: hdr::SourceFormat) -> DXGI_COLOR_SPACE_TYPE {
|
||||
match source_format {
|
||||
hdr::SourceFormat::R10G10B10A2 { hdr: true } => DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020,
|
||||
hdr::SourceFormat::Rgba16Float { hdr: true } => DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709,
|
||||
_ => DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709,
|
||||
}
|
||||
}
|
||||
|
||||
unsafe impl Send for Nv12GpuConverter {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cs_value(source_format: hdr::SourceFormat) -> i32 {
|
||||
input_colour_space(source_format).0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eight_bit_sources_use_sdr_rec709_colour_space() {
|
||||
assert_eq!(
|
||||
cs_value(hdr::SourceFormat::Bgra8),
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709.0
|
||||
);
|
||||
assert_eq!(
|
||||
cs_value(hdr::SourceFormat::Rgba8),
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709.0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unflagged_high_precision_sources_stay_sdr_rec709() {
|
||||
assert_eq!(
|
||||
cs_value(hdr::SourceFormat::R10G10B10A2 { hdr: false }),
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709.0
|
||||
);
|
||||
assert_eq!(
|
||||
cs_value(hdr::SourceFormat::Rgba16Float { hdr: false }),
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709.0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_bit_hdr_uses_pq_rec2020_input_space() {
|
||||
assert_eq!(
|
||||
cs_value(hdr::SourceFormat::R10G10B10A2 { hdr: true }),
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020.0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fp16_hdr_uses_linear_extended_rec709_input_space() {
|
||||
assert_eq!(
|
||||
cs_value(hdr::SourceFormat::Rgba16Float { hdr: true }),
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709.0
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::ScreenCaptureSourceDescriptor;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::ptr::null_mut;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use windows_sys::Win32::{
|
||||
Foundation::{HWND, LPARAM, RECT},
|
||||
Graphics::Gdi::{EnumDisplayMonitors, HMONITOR},
|
||||
System::Threading::GetCurrentProcessId,
|
||||
UI::WindowsAndMessaging::{
|
||||
EnumWindows, GW_OWNER, GWL_STYLE, GetWindow, GetWindowLongPtrW, GetWindowRect,
|
||||
GetWindowTextLengthW, GetWindowTextW, GetWindowThreadProcessId, IsWindowVisible, WS_CHILD,
|
||||
},
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const MONITOR_SOURCE_LIMIT: usize = 16;
|
||||
|
||||
pub fn list_sources() -> Vec<ScreenCaptureSourceDescriptor> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let mut sources = enumerate_monitor_sources();
|
||||
sources.extend(enumerate_window_sources());
|
||||
sources
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn enumerate_monitor_sources() -> Vec<ScreenCaptureSourceDescriptor> {
|
||||
unsafe extern "system" fn enum_monitor(
|
||||
_monitor: HMONITOR,
|
||||
_hdc: windows_sys::Win32::Graphics::Gdi::HDC,
|
||||
rect: *mut RECT,
|
||||
param: LPARAM,
|
||||
) -> i32 {
|
||||
let monitors = &mut *(param as *mut Vec<RECT>);
|
||||
if monitors.len() >= MONITOR_SOURCE_LIMIT {
|
||||
return 0;
|
||||
}
|
||||
if !rect.is_null() {
|
||||
monitors.push(*rect);
|
||||
}
|
||||
1
|
||||
}
|
||||
|
||||
let mut monitors: Vec<RECT> = Vec::new();
|
||||
unsafe {
|
||||
EnumDisplayMonitors(
|
||||
null_mut(),
|
||||
null_mut(),
|
||||
Some(enum_monitor),
|
||||
&mut monitors as *mut _ as LPARAM,
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
monitors.len() <= MONITOR_SOURCE_LIMIT,
|
||||
"monitor source enumeration bounded"
|
||||
);
|
||||
|
||||
monitors
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, rect)| {
|
||||
let width = (rect.right - rect.left).max(0) as u32;
|
||||
let height = (rect.bottom - rect.top).max(0) as u32;
|
||||
ScreenCaptureSourceDescriptor {
|
||||
kind: "screen".to_string(),
|
||||
id: format!("screen:{index}:0"),
|
||||
name: format!("Display {}", index + 1),
|
||||
width,
|
||||
height,
|
||||
target_pid: None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn enumerate_window_sources() -> Vec<ScreenCaptureSourceDescriptor> {
|
||||
struct EnumState {
|
||||
own_pid: u32,
|
||||
sources: Vec<ScreenCaptureSourceDescriptor>,
|
||||
}
|
||||
|
||||
unsafe extern "system" fn enum_window(hwnd: HWND, param: LPARAM) -> i32 {
|
||||
let state = &mut *(param as *mut EnumState);
|
||||
if let Some(source) = describe_window_source(hwnd, state.own_pid) {
|
||||
state.sources.push(source);
|
||||
}
|
||||
1
|
||||
}
|
||||
|
||||
let mut state = EnumState {
|
||||
own_pid: unsafe { GetCurrentProcessId() },
|
||||
sources: Vec::new(),
|
||||
};
|
||||
unsafe {
|
||||
EnumWindows(Some(enum_window), &mut state as *mut _ as LPARAM);
|
||||
}
|
||||
state.sources
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn describe_window_source(hwnd: HWND, own_pid: u32) -> Option<ScreenCaptureSourceDescriptor> {
|
||||
if hwnd.is_null() || unsafe { IsWindowVisible(hwnd) } == 0 {
|
||||
return None;
|
||||
}
|
||||
if !unsafe { GetWindow(hwnd, GW_OWNER) }.is_null() {
|
||||
return None;
|
||||
}
|
||||
let style = unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) } as u32;
|
||||
if style & WS_CHILD != 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut pid = 0u32;
|
||||
unsafe {
|
||||
GetWindowThreadProcessId(hwnd, &mut pid);
|
||||
}
|
||||
if pid == 0 || pid == own_pid {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut rect = RECT::default();
|
||||
if unsafe { GetWindowRect(hwnd, &mut rect) } == 0 {
|
||||
return None;
|
||||
}
|
||||
let width = (rect.right - rect.left).max(0) as u32;
|
||||
let height = (rect.bottom - rect.top).max(0) as u32;
|
||||
if width == 0 || height == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let title = window_title(hwnd).unwrap_or_else(|| format!("Window {pid}"));
|
||||
Some(ScreenCaptureSourceDescriptor {
|
||||
kind: "window".to_string(),
|
||||
id: format!("window:{}:0", hwnd as usize),
|
||||
name: title,
|
||||
width,
|
||||
height,
|
||||
target_pid: Some(pid),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn window_title(hwnd: HWND) -> Option<String> {
|
||||
let len = unsafe { GetWindowTextLengthW(hwnd) };
|
||||
if len <= 0 {
|
||||
return None;
|
||||
}
|
||||
let mut buf = vec![0u16; (len + 1) as usize];
|
||||
let copied = unsafe { GetWindowTextW(hwnd, buf.as_mut_ptr(), buf.len() as i32) };
|
||||
if copied <= 0 {
|
||||
return None;
|
||||
}
|
||||
let title = String::from_utf16_lossy(&buf[..copied as usize])
|
||||
.trim()
|
||||
.to_string();
|
||||
if title.is_empty() { None } else { Some(title) }
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::ptr::{null, null_mut};
|
||||
use windows_sys::Win32::{
|
||||
Foundation::{ERROR_FILE_NOT_FOUND, ERROR_SUCCESS},
|
||||
System::Registry::{
|
||||
HKEY, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_QUERY_VALUE, KEY_SET_VALUE, REG_DWORD,
|
||||
REG_OPTION_NON_VOLATILE, REG_VALUE_TYPE, RegCloseKey, RegCreateKeyExW, RegDeleteValueW,
|
||||
RegOpenKeyExW, RegQueryValueExW, RegSetValueExW,
|
||||
},
|
||||
};
|
||||
|
||||
const VULKAN_IMPLICIT_LAYERS_KEY: &str = "Software\\Khronos\\Vulkan\\ImplicitLayers";
|
||||
|
||||
const HKCU: HKEY = HKEY_CURRENT_USER;
|
||||
const HKLM: HKEY = HKEY_LOCAL_MACHINE;
|
||||
|
||||
pub struct RegistrationState {
|
||||
pub registered: bool,
|
||||
pub manifest_exists: bool,
|
||||
pub dll_exists: bool,
|
||||
pub manifest_path: String,
|
||||
}
|
||||
|
||||
fn wide(value: &str) -> Vec<u16> {
|
||||
value.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
fn manifest_dll_path(manifest_path: &Path) -> Option<PathBuf> {
|
||||
let dir = manifest_path.parent()?;
|
||||
if let Ok(contents) = std::fs::read_to_string(manifest_path)
|
||||
&& let Some(library) = extract_library_path(&contents)
|
||||
{
|
||||
let candidate = Path::new(&library);
|
||||
if candidate.is_absolute() {
|
||||
return Some(candidate.to_path_buf());
|
||||
}
|
||||
return Some(dir.join(candidate));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_library_path(contents: &str) -> Option<String> {
|
||||
let key = "\"library_path\"";
|
||||
let key_pos = contents.find(key)?;
|
||||
let rest = contents[key_pos + key.len()..].trim_start();
|
||||
let rest = rest.strip_prefix(':')?.trim_start();
|
||||
let rest = rest.strip_prefix('"')?;
|
||||
let end = rest.find('"')?;
|
||||
let raw = &rest[..end];
|
||||
Some(raw.replace("\\\\", "\\"))
|
||||
}
|
||||
|
||||
pub fn register_manifest(manifest_path: &str) -> Result<(), String> {
|
||||
if manifest_path.trim().is_empty() {
|
||||
return Err("Vulkan layer manifest path is empty".into());
|
||||
}
|
||||
let manifest = Path::new(manifest_path);
|
||||
if !manifest.is_file() {
|
||||
return Err(format!(
|
||||
"Vulkan layer manifest does not exist: {}",
|
||||
manifest.display()
|
||||
));
|
||||
}
|
||||
if let Some(dll) = manifest_dll_path(manifest)
|
||||
&& !dll.is_file()
|
||||
{
|
||||
return Err(format!(
|
||||
"Vulkan layer DLL referenced by manifest does not exist: {}",
|
||||
dll.display()
|
||||
));
|
||||
}
|
||||
|
||||
set_value_under(HKCU, manifest_path)?;
|
||||
let _ = set_value_under(HKLM, manifest_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_value_under(root: HKEY, manifest_path: &str) -> Result<(), String> {
|
||||
let subkey = wide(VULKAN_IMPLICIT_LAYERS_KEY);
|
||||
let value_name = wide(manifest_path);
|
||||
let enabled: u32 = 0;
|
||||
let mut key: HKEY = null_mut();
|
||||
let create_status = unsafe {
|
||||
RegCreateKeyExW(
|
||||
root,
|
||||
subkey.as_ptr(),
|
||||
0,
|
||||
null(),
|
||||
REG_OPTION_NON_VOLATILE,
|
||||
KEY_SET_VALUE,
|
||||
null(),
|
||||
&mut key,
|
||||
null_mut(),
|
||||
)
|
||||
};
|
||||
if create_status != ERROR_SUCCESS {
|
||||
return Err(format!(
|
||||
"RegCreateKeyExW Vulkan implicit layers failed: {create_status}"
|
||||
));
|
||||
}
|
||||
let set_status = unsafe {
|
||||
RegSetValueExW(
|
||||
key,
|
||||
value_name.as_ptr(),
|
||||
0,
|
||||
REG_DWORD,
|
||||
(&enabled as *const u32).cast(),
|
||||
std::mem::size_of::<u32>() as u32,
|
||||
)
|
||||
};
|
||||
unsafe {
|
||||
RegCloseKey(key);
|
||||
}
|
||||
if set_status != ERROR_SUCCESS {
|
||||
return Err(format!(
|
||||
"RegSetValueExW Vulkan implicit layer manifest failed: {set_status}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn unregister_manifest(manifest_path: &str) -> Result<(), String> {
|
||||
if manifest_path.trim().is_empty() {
|
||||
return Err("Vulkan layer manifest path is empty".into());
|
||||
}
|
||||
let hkcu = delete_value_under(HKCU, manifest_path);
|
||||
let _ = delete_value_under(HKLM, manifest_path);
|
||||
hkcu
|
||||
}
|
||||
|
||||
fn delete_value_under(root: HKEY, manifest_path: &str) -> Result<(), String> {
|
||||
let subkey = wide(VULKAN_IMPLICIT_LAYERS_KEY);
|
||||
let value_name = wide(manifest_path);
|
||||
let mut key: HKEY = null_mut();
|
||||
let open_status = unsafe { RegOpenKeyExW(root, subkey.as_ptr(), 0, KEY_SET_VALUE, &mut key) };
|
||||
if open_status == ERROR_FILE_NOT_FOUND {
|
||||
return Ok(());
|
||||
}
|
||||
if open_status != ERROR_SUCCESS {
|
||||
return Err(format!(
|
||||
"RegOpenKeyExW Vulkan implicit layers failed: {open_status}"
|
||||
));
|
||||
}
|
||||
let delete_status = unsafe { RegDeleteValueW(key, value_name.as_ptr()) };
|
||||
unsafe {
|
||||
RegCloseKey(key);
|
||||
}
|
||||
if delete_status == ERROR_SUCCESS || delete_status == ERROR_FILE_NOT_FOUND {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"RegDeleteValueW Vulkan implicit layer manifest failed: {delete_status}"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn registration_state(manifest_path: &str) -> RegistrationState {
|
||||
let manifest = Path::new(manifest_path);
|
||||
let manifest_exists = manifest.is_file();
|
||||
let dll_exists = manifest_dll_path(manifest)
|
||||
.map(|dll| dll.is_file())
|
||||
.unwrap_or(false);
|
||||
let registered = registry_value_present(manifest_path);
|
||||
RegistrationState {
|
||||
registered,
|
||||
manifest_exists,
|
||||
dll_exists,
|
||||
manifest_path: manifest_path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn registry_value_present(manifest_path: &str) -> bool {
|
||||
if manifest_path.trim().is_empty() {
|
||||
return false;
|
||||
}
|
||||
registry_value_present_under(HKCU, manifest_path)
|
||||
|| registry_value_present_under(HKLM, manifest_path)
|
||||
}
|
||||
|
||||
fn registry_value_present_under(root: HKEY, manifest_path: &str) -> bool {
|
||||
let subkey = wide(VULKAN_IMPLICIT_LAYERS_KEY);
|
||||
let value_name = wide(manifest_path);
|
||||
let mut key: HKEY = null_mut();
|
||||
let open_status = unsafe { RegOpenKeyExW(root, subkey.as_ptr(), 0, KEY_QUERY_VALUE, &mut key) };
|
||||
if open_status != ERROR_SUCCESS {
|
||||
return false;
|
||||
}
|
||||
let mut value_type: REG_VALUE_TYPE = 0;
|
||||
let query_status = unsafe {
|
||||
RegQueryValueExW(
|
||||
key,
|
||||
value_name.as_ptr(),
|
||||
null(),
|
||||
&mut value_type,
|
||||
null_mut(),
|
||||
null_mut(),
|
||||
)
|
||||
};
|
||||
unsafe {
|
||||
RegCloseKey(key);
|
||||
}
|
||||
query_status == ERROR_SUCCESS
|
||||
}
|
||||
@@ -0,0 +1,885 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::sync::{Arc, atomic::Ordering};
|
||||
|
||||
use windows::Graphics::Capture::{
|
||||
Direct3D11CaptureFrame, Direct3D11CaptureFramePool, GraphicsCaptureItem, GraphicsCaptureSession,
|
||||
};
|
||||
use windows::Graphics::DirectX::Direct3D11::IDirect3DDevice;
|
||||
use windows::Graphics::DirectX::DirectXPixelFormat;
|
||||
use windows::Graphics::SizeInt32;
|
||||
use windows::Win32::Foundation::{HWND, LPARAM, RECT, RPC_E_CHANGED_MODE};
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11_BIND_SHADER_RESOURCE, D3D11_BOX, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, ID3D11Device,
|
||||
ID3D11DeviceContext, ID3D11Resource, ID3D11Texture2D,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_SAMPLE_DESC,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::IDXGIDevice;
|
||||
use windows::Win32::Graphics::Gdi::{EnumDisplayMonitors, HDC, HMONITOR};
|
||||
use windows::Win32::System::WinRT::Direct3D11::{
|
||||
CreateDirect3D11DeviceFromDXGIDevice, IDirect3DDxgiInterfaceAccess,
|
||||
};
|
||||
use windows::Win32::System::WinRT::Graphics::Capture::IGraphicsCaptureItemInterop;
|
||||
use windows::Win32::System::WinRT::{RO_INIT_MULTITHREADED, RoInitialize};
|
||||
use windows::Win32::UI::WindowsAndMessaging::IsWindow;
|
||||
use windows::core::{BOOL, Interface};
|
||||
|
||||
use crate::dxgi_capture::{
|
||||
SharedTextureOutput, capture_timestamp_us, create_shared_output_texture,
|
||||
pacing_sleep_and_next_deadline, resolve_output_size,
|
||||
};
|
||||
use crate::nv12_gpu::Nv12GpuConverter;
|
||||
use crate::{
|
||||
CaptureInner, emit_lifecycle, emit_shared_texture_frame, note_media_frame_without_sink,
|
||||
resolve_frame_sink,
|
||||
};
|
||||
|
||||
const WGC_FRAME_POOL_BUFFERS: i32 = 2;
|
||||
const WGC_FRAME_DRAIN_LIMIT: u32 = 4;
|
||||
const WGC_MONITOR_ENUM_LIMIT: usize = 16;
|
||||
|
||||
fn ensure_winrt_initialized() {
|
||||
let result = unsafe { RoInitialize(RO_INIT_MULTITHREADED) };
|
||||
if let Err(error) = result {
|
||||
assert!(
|
||||
error.code() == RPC_E_CHANGED_MODE,
|
||||
"RoInitialize failed unexpectedly: {error:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wgc_capture_supported() -> bool {
|
||||
ensure_winrt_initialized();
|
||||
GraphicsCaptureSession::IsSupported().unwrap_or(false)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum WgcCaptureTarget {
|
||||
Window(HWND),
|
||||
Monitor(HMONITOR),
|
||||
}
|
||||
|
||||
impl WgcCaptureTarget {
|
||||
fn create_item(
|
||||
self,
|
||||
interop: &IGraphicsCaptureItemInterop,
|
||||
) -> Result<GraphicsCaptureItem, String> {
|
||||
match self {
|
||||
Self::Window(hwnd) => unsafe { interop.CreateForWindow(hwnd) }
|
||||
.map_err(|e| format!("IGraphicsCaptureItemInterop::CreateForWindow: {e}")),
|
||||
Self::Monitor(monitor) => unsafe { interop.CreateForMonitor(monitor) }
|
||||
.map_err(|e| format!("IGraphicsCaptureItemInterop::CreateForMonitor: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_alive(self) -> bool {
|
||||
match self {
|
||||
Self::Window(hwnd) => unsafe { IsWindow(Some(hwnd)) }.as_bool(),
|
||||
Self::Monitor(monitor) => !monitor.is_invalid(),
|
||||
}
|
||||
}
|
||||
|
||||
fn closed_message(self) -> &'static str {
|
||||
match self {
|
||||
Self::Window(_) => "window closed",
|
||||
Self::Monitor(_) => "monitor capture target unavailable",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_monitor_source_id(source_id: &str, source_kind: &str) -> Option<HMONITOR> {
|
||||
if source_kind != "screen" {
|
||||
return None;
|
||||
}
|
||||
let mut parts = source_id.split(':');
|
||||
if parts.next()? != "screen" {
|
||||
return None;
|
||||
}
|
||||
let index = parts.next()?.parse::<usize>().ok()?;
|
||||
let sub_id = parts.next()?;
|
||||
if sub_id != "0" && sub_id != "1" {
|
||||
return None;
|
||||
}
|
||||
if parts.next().is_some() || index >= WGC_MONITOR_ENUM_LIMIT {
|
||||
return None;
|
||||
}
|
||||
enumerate_monitors().get(index).copied()
|
||||
}
|
||||
|
||||
fn enumerate_monitors() -> Vec<HMONITOR> {
|
||||
struct MonitorEnumState {
|
||||
monitors: Vec<HMONITOR>,
|
||||
}
|
||||
|
||||
unsafe extern "system" fn enum_monitor(
|
||||
monitor: HMONITOR,
|
||||
_hdc: HDC,
|
||||
_rect: *mut RECT,
|
||||
param: LPARAM,
|
||||
) -> BOOL {
|
||||
let state = unsafe { &mut *(param.0 as *mut MonitorEnumState) };
|
||||
if state.monitors.len() >= WGC_MONITOR_ENUM_LIMIT {
|
||||
return BOOL(0);
|
||||
}
|
||||
if !monitor.is_invalid() {
|
||||
state.monitors.push(monitor);
|
||||
}
|
||||
BOOL(1)
|
||||
}
|
||||
|
||||
let mut state = MonitorEnumState {
|
||||
monitors: Vec::with_capacity(WGC_MONITOR_ENUM_LIMIT),
|
||||
};
|
||||
unsafe {
|
||||
let _ = EnumDisplayMonitors(
|
||||
None,
|
||||
None,
|
||||
Some(enum_monitor),
|
||||
LPARAM((&mut state as *mut MonitorEnumState) as isize),
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
state.monitors.len() <= WGC_MONITOR_ENUM_LIMIT,
|
||||
"monitor enumeration bounded"
|
||||
);
|
||||
state.monitors
|
||||
}
|
||||
|
||||
pub struct WgcCaptureSession {
|
||||
device: ID3D11Device,
|
||||
context: ID3D11DeviceContext,
|
||||
d3d_device: IDirect3DDevice,
|
||||
item: GraphicsCaptureItem,
|
||||
target: WgcCaptureTarget,
|
||||
output_width: u32,
|
||||
output_height: u32,
|
||||
requested_width: Option<u32>,
|
||||
requested_height: Option<u32>,
|
||||
}
|
||||
|
||||
unsafe impl Send for WgcCaptureSession {}
|
||||
|
||||
impl WgcCaptureSession {
|
||||
pub fn new(
|
||||
hwnd: HWND,
|
||||
requested_width: Option<u32>,
|
||||
requested_height: Option<u32>,
|
||||
) -> Result<Self, String> {
|
||||
Self::new_for_target(
|
||||
WgcCaptureTarget::Window(hwnd),
|
||||
requested_width,
|
||||
requested_height,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn new_monitor(
|
||||
monitor: HMONITOR,
|
||||
requested_width: Option<u32>,
|
||||
requested_height: Option<u32>,
|
||||
) -> Result<Self, String> {
|
||||
Self::new_for_target(
|
||||
WgcCaptureTarget::Monitor(monitor),
|
||||
requested_width,
|
||||
requested_height,
|
||||
)
|
||||
}
|
||||
|
||||
fn new_for_target(
|
||||
target: WgcCaptureTarget,
|
||||
requested_width: Option<u32>,
|
||||
requested_height: Option<u32>,
|
||||
) -> Result<Self, String> {
|
||||
ensure_winrt_initialized();
|
||||
let (device, context) = crate::game_capture::create_shared_texture_device(None)?;
|
||||
let dxgi_device: IDXGIDevice = device
|
||||
.cast()
|
||||
.map_err(|e| format!("IDXGIDevice cast: {e}"))?;
|
||||
let inspectable = unsafe { CreateDirect3D11DeviceFromDXGIDevice(&dxgi_device) }
|
||||
.map_err(|e| format!("CreateDirect3D11DeviceFromDXGIDevice: {e}"))?;
|
||||
let d3d_device: IDirect3DDevice = inspectable
|
||||
.cast()
|
||||
.map_err(|e| format!("IDirect3DDevice cast: {e}"))?;
|
||||
let interop = windows::core::factory::<GraphicsCaptureItem, IGraphicsCaptureItemInterop>()
|
||||
.map_err(|e| format!("GraphicsCaptureItem interop factory: {e}"))?;
|
||||
let item = target.create_item(&interop)?;
|
||||
let size = item
|
||||
.Size()
|
||||
.map_err(|e| format!("GraphicsCaptureItem.Size: {e}"))?;
|
||||
let content_width = size.Width.max(1) as u32;
|
||||
let content_height = size.Height.max(1) as u32;
|
||||
let (output_width, output_height) = resolve_output_size(
|
||||
content_width,
|
||||
content_height,
|
||||
requested_width,
|
||||
requested_height,
|
||||
);
|
||||
assert!(output_width > 0, "WGC output width positive");
|
||||
assert!(output_height > 0, "WGC output height positive");
|
||||
Ok(Self {
|
||||
device,
|
||||
context,
|
||||
d3d_device,
|
||||
item,
|
||||
target,
|
||||
output_width,
|
||||
output_height,
|
||||
requested_width,
|
||||
requested_height,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn capture_width(&self) -> u32 {
|
||||
assert!(self.output_width > 0, "WGC output width positive");
|
||||
assert!(self.output_height > 0, "WGC output height positive");
|
||||
self.output_width
|
||||
}
|
||||
|
||||
pub fn capture_height(&self) -> u32 {
|
||||
assert!(self.output_height > 0, "WGC output height positive");
|
||||
assert!(self.output_width > 0, "WGC output width positive");
|
||||
self.output_height
|
||||
}
|
||||
}
|
||||
|
||||
struct WgcState {
|
||||
frame_pool: Direct3D11CaptureFramePool,
|
||||
session: GraphicsCaptureSession,
|
||||
output_pipeline: Option<WgcOutputPipeline>,
|
||||
pixel_format: DirectXPixelFormat,
|
||||
content_width: u32,
|
||||
content_height: u32,
|
||||
out_w: u32,
|
||||
out_h: u32,
|
||||
}
|
||||
|
||||
impl WgcState {
|
||||
fn close(self) {
|
||||
let _ = self.session.Close();
|
||||
let _ = self.frame_pool.Close();
|
||||
}
|
||||
}
|
||||
|
||||
struct WgcNv12Pipeline {
|
||||
input_resource: ID3D11Resource,
|
||||
converter: Nv12GpuConverter,
|
||||
}
|
||||
|
||||
enum WgcOutputPipeline {
|
||||
Bgra(SharedTextureOutput),
|
||||
Nv12(WgcNv12Pipeline),
|
||||
}
|
||||
|
||||
enum WgcFrameResult {
|
||||
Ok,
|
||||
NoFrame,
|
||||
Resized { width: u32, height: u32 },
|
||||
Error(String),
|
||||
}
|
||||
|
||||
enum LoopStep {
|
||||
Paced,
|
||||
Restart,
|
||||
}
|
||||
|
||||
struct WgcLoopContext {
|
||||
device: ID3D11Device,
|
||||
context: ID3D11DeviceContext,
|
||||
d3d_device: IDirect3DDevice,
|
||||
item: GraphicsCaptureItem,
|
||||
target: WgcCaptureTarget,
|
||||
requested_width: Option<u32>,
|
||||
requested_height: Option<u32>,
|
||||
}
|
||||
|
||||
fn wgc_loop_context(inner: &Arc<CaptureInner>) -> Option<WgcLoopContext> {
|
||||
let guard = inner.wgc_session.lock();
|
||||
let session = guard.as_ref()?;
|
||||
Some(WgcLoopContext {
|
||||
device: session.device.clone(),
|
||||
context: session.context.clone(),
|
||||
d3d_device: session.d3d_device.clone(),
|
||||
item: session.item.clone(),
|
||||
target: session.target,
|
||||
requested_width: session.requested_width,
|
||||
requested_height: session.requested_height,
|
||||
})
|
||||
}
|
||||
|
||||
fn teardown_wgc_state(wgc_state: &mut Option<WgcState>) {
|
||||
if let Some(state) = wgc_state.take() {
|
||||
state.close();
|
||||
}
|
||||
}
|
||||
|
||||
fn sleep_with_backoff(backoff: &mut std::time::Duration) {
|
||||
assert!(
|
||||
*backoff >= std::time::Duration::from_millis(100),
|
||||
"backoff at least base interval"
|
||||
);
|
||||
assert!(
|
||||
*backoff <= std::time::Duration::from_secs(2),
|
||||
"backoff bounded"
|
||||
);
|
||||
std::thread::sleep(*backoff);
|
||||
*backoff = (*backoff * 2).min(std::time::Duration::from_secs(2));
|
||||
}
|
||||
|
||||
pub fn capture_loop(inner: &Arc<CaptureInner>, frame_interval: std::time::Duration) {
|
||||
ensure_winrt_initialized();
|
||||
let Some(ctx) = wgc_loop_context(inner) else {
|
||||
emit_lifecycle(inner, "closed-clean", "no WGC session");
|
||||
return;
|
||||
};
|
||||
|
||||
let capture_id = inner.capture_id.lock().clone();
|
||||
let mut wgc_state: Option<WgcState> = None;
|
||||
let mut recreate_backoff = std::time::Duration::from_millis(100);
|
||||
let capture_start = std::time::Instant::now();
|
||||
let mut next_frame_deadline = capture_start + frame_interval;
|
||||
let mut frames_dropped_coalesced: u64 = 0;
|
||||
|
||||
while inner.running.load(Ordering::Acquire) {
|
||||
if !ctx.target.is_alive() {
|
||||
emit_lifecycle(inner, "closed", ctx.target.closed_message());
|
||||
break;
|
||||
}
|
||||
|
||||
if wgc_state.is_none() {
|
||||
match setup_wgc_state(&ctx) {
|
||||
Ok(state) => {
|
||||
wgc_state = Some(state);
|
||||
}
|
||||
Err(e) => {
|
||||
if !inner.running.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
emit_lifecycle(
|
||||
inner,
|
||||
"error",
|
||||
&format!("Failed to create WGC capture session: {e}"),
|
||||
);
|
||||
sleep_with_backoff(&mut recreate_backoff);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(state) = wgc_state.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
let result = poll_and_emit_frame(
|
||||
inner,
|
||||
&ctx.context,
|
||||
state,
|
||||
capture_id.as_deref(),
|
||||
capture_start,
|
||||
&mut frames_dropped_coalesced,
|
||||
);
|
||||
match handle_frame_result(inner, &ctx, &mut wgc_state, result, &mut recreate_backoff) {
|
||||
LoopStep::Paced => {}
|
||||
LoopStep::Restart => continue,
|
||||
}
|
||||
|
||||
let now = std::time::Instant::now();
|
||||
let (sleep_duration, deadline) =
|
||||
pacing_sleep_and_next_deadline(now, next_frame_deadline, frame_interval);
|
||||
next_frame_deadline = deadline;
|
||||
if sleep_duration > std::time::Duration::ZERO {
|
||||
std::thread::sleep(sleep_duration);
|
||||
}
|
||||
}
|
||||
|
||||
teardown_wgc_state(&mut wgc_state);
|
||||
inner.running.store(false, Ordering::Release);
|
||||
emit_lifecycle(inner, "closed-clean", "capture stopped");
|
||||
}
|
||||
|
||||
fn handle_frame_result(
|
||||
inner: &Arc<CaptureInner>,
|
||||
ctx: &WgcLoopContext,
|
||||
wgc_state: &mut Option<WgcState>,
|
||||
result: WgcFrameResult,
|
||||
recreate_backoff: &mut std::time::Duration,
|
||||
) -> LoopStep {
|
||||
match result {
|
||||
WgcFrameResult::Ok => {
|
||||
*recreate_backoff = std::time::Duration::from_millis(100);
|
||||
LoopStep::Paced
|
||||
}
|
||||
WgcFrameResult::NoFrame => LoopStep::Paced,
|
||||
WgcFrameResult::Resized { width, height } => {
|
||||
let Some(state) = wgc_state.as_mut() else {
|
||||
return LoopStep::Restart;
|
||||
};
|
||||
match resize_wgc_state(ctx, state, width, height) {
|
||||
Ok(()) => LoopStep::Paced,
|
||||
Err(e) => {
|
||||
emit_lifecycle(
|
||||
inner,
|
||||
"error",
|
||||
&format!("WGC frame pool resize failed: {e}"),
|
||||
);
|
||||
teardown_wgc_state(wgc_state);
|
||||
sleep_with_backoff(recreate_backoff);
|
||||
LoopStep::Restart
|
||||
}
|
||||
}
|
||||
}
|
||||
WgcFrameResult::Error(e) => {
|
||||
emit_lifecycle(inner, "error", &e);
|
||||
{
|
||||
let mut guard = inner.fallback.lock();
|
||||
if let Some(tracker) = guard.as_mut() {
|
||||
let _ = tracker.observe(crate::fallback::FailureSignature::DeviceLost);
|
||||
}
|
||||
}
|
||||
teardown_wgc_state(wgc_state);
|
||||
sleep_with_backoff(recreate_backoff);
|
||||
LoopStep::Restart
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_wgc_state(ctx: &WgcLoopContext) -> Result<WgcState, String> {
|
||||
let size = ctx
|
||||
.item
|
||||
.Size()
|
||||
.map_err(|e| format!("GraphicsCaptureItem.Size: {e}"))?;
|
||||
let content_width = size.Width.max(1) as u32;
|
||||
let content_height = size.Height.max(1) as u32;
|
||||
let (out_w, out_h) = resolve_output_size(
|
||||
content_width,
|
||||
content_height,
|
||||
ctx.requested_width,
|
||||
ctx.requested_height,
|
||||
);
|
||||
create_wgc_state_for_format(
|
||||
ctx,
|
||||
DirectXPixelFormat::R16G16B16A16Float,
|
||||
content_width,
|
||||
content_height,
|
||||
out_w,
|
||||
out_h,
|
||||
)
|
||||
.or_else(|_| {
|
||||
create_wgc_state_for_format(
|
||||
ctx,
|
||||
DirectXPixelFormat::B8G8R8A8UIntNormalized,
|
||||
content_width,
|
||||
content_height,
|
||||
out_w,
|
||||
out_h,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn create_wgc_state_for_format(
|
||||
ctx: &WgcLoopContext,
|
||||
pixel_format: DirectXPixelFormat,
|
||||
content_width: u32,
|
||||
content_height: u32,
|
||||
out_w: u32,
|
||||
out_h: u32,
|
||||
) -> Result<WgcState, String> {
|
||||
let output_pipeline = create_wgc_output_pipeline(
|
||||
ctx,
|
||||
pixel_format,
|
||||
content_width,
|
||||
content_height,
|
||||
out_w,
|
||||
out_h,
|
||||
)?;
|
||||
let frame_pool = Direct3D11CaptureFramePool::CreateFreeThreaded(
|
||||
&ctx.d3d_device,
|
||||
pixel_format,
|
||||
WGC_FRAME_POOL_BUFFERS,
|
||||
SizeInt32 {
|
||||
Width: content_width as i32,
|
||||
Height: content_height as i32,
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Direct3D11CaptureFramePool::CreateFreeThreaded: {e}"))?;
|
||||
let session = frame_pool
|
||||
.CreateCaptureSession(&ctx.item)
|
||||
.map_err(|e| format!("Direct3D11CaptureFramePool.CreateCaptureSession: {e}"))?;
|
||||
session
|
||||
.StartCapture()
|
||||
.map_err(|e| format!("GraphicsCaptureSession.StartCapture: {e}"))?;
|
||||
Ok(WgcState {
|
||||
frame_pool,
|
||||
session,
|
||||
output_pipeline,
|
||||
pixel_format,
|
||||
content_width,
|
||||
content_height,
|
||||
out_w,
|
||||
out_h,
|
||||
})
|
||||
}
|
||||
|
||||
fn resize_wgc_state(
|
||||
ctx: &WgcLoopContext,
|
||||
state: &mut WgcState,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<(), String> {
|
||||
assert!(width > 0, "WGC resize width positive");
|
||||
assert!(height > 0, "WGC resize height positive");
|
||||
let (out_w, out_h) =
|
||||
resolve_output_size(width, height, ctx.requested_width, ctx.requested_height);
|
||||
match resize_wgc_state_for_format(ctx, state, state.pixel_format, width, height, out_w, out_h) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(first_error) if state.pixel_format == DirectXPixelFormat::R16G16B16A16Float => {
|
||||
resize_wgc_state_for_format(
|
||||
ctx,
|
||||
state,
|
||||
DirectXPixelFormat::B8G8R8A8UIntNormalized,
|
||||
width,
|
||||
height,
|
||||
out_w,
|
||||
out_h,
|
||||
)
|
||||
.map_err(|fallback_error| {
|
||||
format!(
|
||||
"HDR resize failed ({first_error}); BGRA fallback failed ({fallback_error})"
|
||||
)
|
||||
})
|
||||
}
|
||||
Err(first_error) => Err(first_error),
|
||||
}
|
||||
}
|
||||
|
||||
fn resize_wgc_state_for_format(
|
||||
ctx: &WgcLoopContext,
|
||||
state: &mut WgcState,
|
||||
pixel_format: DirectXPixelFormat,
|
||||
width: u32,
|
||||
height: u32,
|
||||
out_w: u32,
|
||||
out_h: u32,
|
||||
) -> Result<(), String> {
|
||||
let output_pipeline =
|
||||
create_wgc_output_pipeline(ctx, pixel_format, width, height, out_w, out_h)?;
|
||||
state
|
||||
.frame_pool
|
||||
.Recreate(
|
||||
&ctx.d3d_device,
|
||||
pixel_format,
|
||||
WGC_FRAME_POOL_BUFFERS,
|
||||
SizeInt32 {
|
||||
Width: width as i32,
|
||||
Height: height as i32,
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Direct3D11CaptureFramePool.Recreate: {e}"))?;
|
||||
state.content_width = width;
|
||||
state.content_height = height;
|
||||
state.out_w = out_w;
|
||||
state.out_h = out_h;
|
||||
state.pixel_format = pixel_format;
|
||||
state.output_pipeline = output_pipeline;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_wgc_output_pipeline(
|
||||
ctx: &WgcLoopContext,
|
||||
pixel_format: DirectXPixelFormat,
|
||||
content_width: u32,
|
||||
content_height: u32,
|
||||
out_w: u32,
|
||||
out_h: u32,
|
||||
) -> Result<Option<WgcOutputPipeline>, String> {
|
||||
if pixel_format == DirectXPixelFormat::R16G16B16A16Float {
|
||||
return create_wgc_nv12_pipeline(
|
||||
ctx,
|
||||
DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||||
content_width,
|
||||
content_height,
|
||||
out_w,
|
||||
out_h,
|
||||
crate::hdr::SourceFormat::Rgba16Float { hdr: true },
|
||||
)
|
||||
.map(WgcOutputPipeline::Nv12)
|
||||
.map(Some);
|
||||
}
|
||||
if out_w != content_width || out_h != content_height {
|
||||
return create_wgc_nv12_pipeline(
|
||||
ctx,
|
||||
DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
content_width,
|
||||
content_height,
|
||||
out_w,
|
||||
out_h,
|
||||
crate::hdr::SourceFormat::Bgra8,
|
||||
)
|
||||
.map(WgcOutputPipeline::Nv12)
|
||||
.map(Some);
|
||||
}
|
||||
Ok(
|
||||
create_shared_output_texture(&ctx.device, content_width, content_height)
|
||||
.ok()
|
||||
.map(WgcOutputPipeline::Bgra),
|
||||
)
|
||||
}
|
||||
|
||||
fn create_wgc_nv12_pipeline(
|
||||
ctx: &WgcLoopContext,
|
||||
input_format: DXGI_FORMAT,
|
||||
content_width: u32,
|
||||
content_height: u32,
|
||||
out_w: u32,
|
||||
out_h: u32,
|
||||
source_format: crate::hdr::SourceFormat,
|
||||
) -> Result<WgcNv12Pipeline, String> {
|
||||
assert!(content_width > 0, "WGC NV12 input width positive");
|
||||
assert!(content_height > 0, "WGC NV12 input height positive");
|
||||
let input_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: content_width,
|
||||
Height: content_height,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: input_format,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
||||
CPUAccessFlags: 0,
|
||||
MiscFlags: 0,
|
||||
};
|
||||
let mut input_texture = None;
|
||||
unsafe {
|
||||
ctx.device
|
||||
.CreateTexture2D(&input_desc, None, Some(&mut input_texture))
|
||||
}
|
||||
.map_err(|e| format!("CreateTexture2D WGC NV12 input: {e}"))?;
|
||||
let input_texture =
|
||||
input_texture.ok_or_else(|| "CreateTexture2D WGC NV12 input returned null".to_string())?;
|
||||
let input_resource: ID3D11Resource = input_texture
|
||||
.cast()
|
||||
.map_err(|e| format!("ID3D11Resource WGC NV12 input cast: {e}"))?;
|
||||
let converter = Nv12GpuConverter::new(
|
||||
&ctx.device,
|
||||
&ctx.context,
|
||||
&input_texture,
|
||||
content_width,
|
||||
content_height,
|
||||
out_w,
|
||||
out_h,
|
||||
source_format,
|
||||
)
|
||||
.ok_or_else(|| "WGC NV12 converter unavailable".to_string())?;
|
||||
Ok(WgcNv12Pipeline {
|
||||
input_resource,
|
||||
converter,
|
||||
})
|
||||
}
|
||||
|
||||
fn poll_and_emit_frame(
|
||||
inner: &Arc<CaptureInner>,
|
||||
context: &ID3D11DeviceContext,
|
||||
state: &mut WgcState,
|
||||
capture_id: Option<&str>,
|
||||
capture_start: std::time::Instant,
|
||||
frames_dropped_coalesced: &mut u64,
|
||||
) -> WgcFrameResult {
|
||||
let mut newest: Option<Direct3D11CaptureFrame> = None;
|
||||
let mut drained: u32 = 0;
|
||||
while drained < WGC_FRAME_DRAIN_LIMIT {
|
||||
let Ok(frame) = state.frame_pool.TryGetNextFrame() else {
|
||||
break;
|
||||
};
|
||||
if let Some(previous) = newest.replace(frame) {
|
||||
*frames_dropped_coalesced += 1;
|
||||
let _ = previous.Close();
|
||||
}
|
||||
drained += 1;
|
||||
}
|
||||
assert!(drained <= WGC_FRAME_DRAIN_LIMIT, "frame drain bounded");
|
||||
let Some(frame) = newest else {
|
||||
return WgcFrameResult::NoFrame;
|
||||
};
|
||||
let result = emit_wgc_frame(inner, context, state, capture_id, &frame, capture_start);
|
||||
let _ = frame.Close();
|
||||
result
|
||||
}
|
||||
|
||||
fn wgc_frame_source_resource(frame: &Direct3D11CaptureFrame) -> Result<ID3D11Resource, String> {
|
||||
let surface = frame
|
||||
.Surface()
|
||||
.map_err(|e| format!("Direct3D11CaptureFrame.Surface: {e}"))?;
|
||||
let access: IDirect3DDxgiInterfaceAccess = surface
|
||||
.cast()
|
||||
.map_err(|e| format!("IDirect3DDxgiInterfaceAccess cast: {e}"))?;
|
||||
let source_texture: ID3D11Texture2D =
|
||||
unsafe { access.GetInterface() }.map_err(|e| format!("WGC surface GetInterface: {e}"))?;
|
||||
source_texture
|
||||
.cast()
|
||||
.map_err(|e| format!("ID3D11Resource WGC surface cast: {e}"))
|
||||
}
|
||||
|
||||
fn emit_wgc_frame(
|
||||
inner: &Arc<CaptureInner>,
|
||||
context: &ID3D11DeviceContext,
|
||||
state: &mut WgcState,
|
||||
capture_id: Option<&str>,
|
||||
frame: &Direct3D11CaptureFrame,
|
||||
capture_start: std::time::Instant,
|
||||
) -> WgcFrameResult {
|
||||
let content = match frame.ContentSize() {
|
||||
Ok(size) => size,
|
||||
Err(e) => {
|
||||
return WgcFrameResult::Error(format!("Direct3D11CaptureFrame.ContentSize: {e}"));
|
||||
}
|
||||
};
|
||||
let content_width = content.Width.max(1) as u32;
|
||||
let content_height = content.Height.max(1) as u32;
|
||||
if content_width != state.content_width || content_height != state.content_height {
|
||||
return WgcFrameResult::Resized {
|
||||
width: content_width,
|
||||
height: content_height,
|
||||
};
|
||||
}
|
||||
let Some(frame_sink) = resolve_frame_sink(inner, capture_id) else {
|
||||
note_media_frame_without_sink(
|
||||
inner,
|
||||
"WGC frame dropped because no native frame sink is registered",
|
||||
);
|
||||
return WgcFrameResult::Ok;
|
||||
};
|
||||
let source_resource = match wgc_frame_source_resource(frame) {
|
||||
Ok(resource) => resource,
|
||||
Err(e) => return WgcFrameResult::Error(e),
|
||||
};
|
||||
let timestamp_us = capture_timestamp_us(capture_start);
|
||||
let Some(output_pipeline) = state.output_pipeline.as_mut() else {
|
||||
return WgcFrameResult::Error("WGC shared texture output unavailable".into());
|
||||
};
|
||||
match output_pipeline {
|
||||
WgcOutputPipeline::Bgra(shared_output) => emit_wgc_bgra_frame(
|
||||
inner,
|
||||
context,
|
||||
&frame_sink,
|
||||
shared_output,
|
||||
&source_resource,
|
||||
content_width,
|
||||
content_height,
|
||||
timestamp_us,
|
||||
),
|
||||
WgcOutputPipeline::Nv12(pipeline) => emit_wgc_nv12_frame(
|
||||
inner,
|
||||
context,
|
||||
&frame_sink,
|
||||
pipeline,
|
||||
&source_resource,
|
||||
content_width,
|
||||
content_height,
|
||||
timestamp_us,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn emit_wgc_bgra_frame(
|
||||
inner: &Arc<CaptureInner>,
|
||||
context: &ID3D11DeviceContext,
|
||||
frame_sink: &crate::FrameSinkRef,
|
||||
shared_output: &mut SharedTextureOutput,
|
||||
source_resource: &ID3D11Resource,
|
||||
content_width: u32,
|
||||
content_height: u32,
|
||||
timestamp_us: i64,
|
||||
) -> WgcFrameResult {
|
||||
if shared_output.width != content_width || shared_output.height != content_height {
|
||||
return WgcFrameResult::Error(
|
||||
"WGC BGRA shared output size does not match content size".into(),
|
||||
);
|
||||
}
|
||||
let slot_index = shared_output.next_slot_index();
|
||||
let slot = &shared_output.slots[slot_index];
|
||||
let output_resource: ID3D11Resource = match slot.texture.cast() {
|
||||
Ok(resource) => resource,
|
||||
Err(e) => return WgcFrameResult::Error(format!("ID3D11Resource shared output cast: {e}")),
|
||||
};
|
||||
let src_box = D3D11_BOX {
|
||||
left: 0,
|
||||
top: 0,
|
||||
front: 0,
|
||||
right: content_width,
|
||||
bottom: content_height,
|
||||
back: 1,
|
||||
};
|
||||
unsafe {
|
||||
context.CopySubresourceRegion(
|
||||
&output_resource,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
source_resource,
|
||||
0,
|
||||
Some(&src_box),
|
||||
);
|
||||
context.Flush();
|
||||
}
|
||||
let _ = emit_shared_texture_frame(
|
||||
inner,
|
||||
frame_sink,
|
||||
slot.handle,
|
||||
shared_output.width,
|
||||
shared_output.height,
|
||||
shared_output.dxgi_format,
|
||||
timestamp_us,
|
||||
);
|
||||
WgcFrameResult::Ok
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn emit_wgc_nv12_frame(
|
||||
inner: &Arc<CaptureInner>,
|
||||
context: &ID3D11DeviceContext,
|
||||
frame_sink: &crate::FrameSinkRef,
|
||||
pipeline: &mut WgcNv12Pipeline,
|
||||
source_resource: &ID3D11Resource,
|
||||
content_width: u32,
|
||||
content_height: u32,
|
||||
timestamp_us: i64,
|
||||
) -> WgcFrameResult {
|
||||
let src_box = D3D11_BOX {
|
||||
left: 0,
|
||||
top: 0,
|
||||
front: 0,
|
||||
right: content_width,
|
||||
bottom: content_height,
|
||||
back: 1,
|
||||
};
|
||||
unsafe {
|
||||
context.CopySubresourceRegion(
|
||||
&pipeline.input_resource,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
source_resource,
|
||||
0,
|
||||
Some(&src_box),
|
||||
);
|
||||
}
|
||||
let frame = match pipeline.converter.convert_shared_texture() {
|
||||
Ok(frame) => frame,
|
||||
Err(error) => return WgcFrameResult::Error(error),
|
||||
};
|
||||
let _ = emit_shared_texture_frame(
|
||||
inner,
|
||||
frame_sink,
|
||||
frame.handle,
|
||||
frame.width,
|
||||
frame.height,
|
||||
frame.dxgi_format,
|
||||
timestamp_us,
|
||||
);
|
||||
WgcFrameResult::Ok
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "d3d10-present-fixture"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"windows",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[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.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core",
|
||||
"windows-future",
|
||||
"windows-numerics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
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-numerics"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
[package]
|
||||
name = "d3d10-present-fixture"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
publish = false
|
||||
|
||||
# Empty workspace table keeps this fixture out of any parent Cargo workspace,
|
||||
# matching the sibling crates (hook/, vulkan-layer/) in this package.
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
[[bin]]
|
||||
name = "d3d10-present-fixture"
|
||||
path = "src/main.rs"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = {version = "0.62.2", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Direct3D",
|
||||
"Win32_Graphics_Direct3D10",
|
||||
"Win32_Graphics_Dxgi",
|
||||
"Win32_Graphics_Dxgi_Common",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
]}
|
||||
windows-core = "0.62.2"
|
||||
@@ -0,0 +1,389 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn main() {
|
||||
eprintln!("d3d10-present-fixture is only functional on Windows");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn main() -> windows_core::Result<()> {
|
||||
windows_impl::run()
|
||||
}
|
||||
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
mod pattern {
|
||||
pub const BARS: [(u8, u8, u8); 8] = [
|
||||
(255, 255, 255),
|
||||
(255, 255, 0),
|
||||
(0, 255, 255),
|
||||
(0, 255, 0),
|
||||
(255, 0, 255),
|
||||
(255, 0, 0),
|
||||
(0, 0, 255),
|
||||
(0, 0, 0),
|
||||
];
|
||||
|
||||
pub const COUNTER_BLOCK: u32 = 16;
|
||||
|
||||
pub fn bar_colour(x: u32, width: u32) -> (u8, u8, u8) {
|
||||
let width = width.max(1);
|
||||
let index = ((x as u64 * BARS.len() as u64) / width as u64) as usize;
|
||||
BARS[index.min(BARS.len() - 1)]
|
||||
}
|
||||
|
||||
pub fn counter_colour(frame_index: u64) -> (u8, u8, u8) {
|
||||
let low = (frame_index & 0x00FF_FFFF) as u32;
|
||||
(
|
||||
(low & 0xFF) as u8,
|
||||
((low >> 8) & 0xFF) as u8,
|
||||
((low >> 16) & 0xFF) as u8,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod cli {
|
||||
pub struct Options {
|
||||
pub frames: Option<u64>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub borderless: bool,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
frames: None,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
borderless: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Options {
|
||||
pub fn from_env() -> Self {
|
||||
let mut options = Options::default();
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--frames" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.frames = Some(value);
|
||||
}
|
||||
}
|
||||
"--width" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.width = value;
|
||||
}
|
||||
}
|
||||
"--height" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.height = value;
|
||||
}
|
||||
}
|
||||
"--borderless" => options.borderless = true,
|
||||
"--windowed" => options.borderless = false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
options
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows_impl {
|
||||
use super::{cli::Options, pattern};
|
||||
use windows::Win32::Foundation::{HMODULE, HWND, LPARAM, LRESULT, WPARAM};
|
||||
use windows::Win32::Graphics::Direct3D10::{
|
||||
D3D10_DRIVER_TYPE_HARDWARE, D3D10_MAPPED_TEXTURE2D, D3D10_SDK_VERSION, D3D10CreateDevice,
|
||||
ID3D10Device, ID3D10RenderTargetView, ID3D10Resource, ID3D10Texture2D,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_R8G8B8A8_UNORM, DXGI_MODE_DESC,
|
||||
DXGI_RATIONAL, DXGI_SAMPLE_DESC,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::{
|
||||
DXGI_PRESENT, DXGI_SWAP_CHAIN_DESC, DXGI_SWAP_EFFECT_DISCARD,
|
||||
DXGI_USAGE_RENDER_TARGET_OUTPUT, IDXGIDevice, IDXGIFactory, IDXGISwapChain,
|
||||
};
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
CS_HREDRAW, CS_VREDRAW, CW_USEDEFAULT, CreateWindowExW, DefWindowProcW, DispatchMessageW,
|
||||
GetClientRect, MSG, PM_REMOVE, PeekMessageW, PostQuitMessage, RegisterClassExW, SW_SHOW,
|
||||
ShowWindow, TranslateMessage, WM_DESTROY, WM_QUIT, WNDCLASSEXW, WS_OVERLAPPEDWINDOW,
|
||||
WS_POPUP, WS_VISIBLE,
|
||||
};
|
||||
use windows_core::{BOOL, Interface, Result, w};
|
||||
|
||||
extern "system" fn wnd_proc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||
unsafe {
|
||||
match msg {
|
||||
WM_DESTROY => {
|
||||
PostQuitMessage(0);
|
||||
LRESULT(0)
|
||||
}
|
||||
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn create_window(width: u32, height: u32, borderless: bool) -> Result<HWND> {
|
||||
unsafe {
|
||||
let instance = GetModuleHandleW(None)?;
|
||||
let class_name = w!("FluxerD3D10Fixture");
|
||||
let wc = WNDCLASSEXW {
|
||||
cbSize: size_of::<WNDCLASSEXW>() as u32,
|
||||
style: CS_HREDRAW | CS_VREDRAW,
|
||||
lpfnWndProc: Some(wnd_proc),
|
||||
hInstance: instance.into(),
|
||||
lpszClassName: class_name,
|
||||
..Default::default()
|
||||
};
|
||||
RegisterClassExW(&wc);
|
||||
let style = if borderless {
|
||||
WS_POPUP | WS_VISIBLE
|
||||
} else {
|
||||
WS_OVERLAPPEDWINDOW | WS_VISIBLE
|
||||
};
|
||||
let hwnd = CreateWindowExW(
|
||||
Default::default(),
|
||||
class_name,
|
||||
w!("Fluxer D3D10 Present Fixture"),
|
||||
style,
|
||||
CW_USEDEFAULT,
|
||||
CW_USEDEFAULT,
|
||||
width as i32,
|
||||
height as i32,
|
||||
None,
|
||||
None,
|
||||
Some(instance.into()),
|
||||
None,
|
||||
)?;
|
||||
let _ = ShowWindow(hwnd, SW_SHOW);
|
||||
Ok(hwnd)
|
||||
}
|
||||
}
|
||||
|
||||
struct Renderer {
|
||||
device: ID3D10Device,
|
||||
swap_chain: IDXGISwapChain,
|
||||
rtv: ID3D10RenderTargetView,
|
||||
width: u32,
|
||||
height: u32,
|
||||
staging: ID3D10Texture2D,
|
||||
format: DXGI_FORMAT,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
unsafe fn new(hwnd: HWND, width: u32, height: u32) -> Result<Self> {
|
||||
unsafe {
|
||||
let mut device: Option<ID3D10Device> = None;
|
||||
D3D10CreateDevice(
|
||||
None,
|
||||
D3D10_DRIVER_TYPE_HARDWARE,
|
||||
HMODULE::default(),
|
||||
0,
|
||||
D3D10_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
)?;
|
||||
let device = device.expect("D3D10CreateDeviceAndSwapChain yielded no device");
|
||||
|
||||
let dxgi_device: IDXGIDevice = device.cast()?;
|
||||
let adapter = dxgi_device.GetAdapter()?;
|
||||
let factory: IDXGIFactory = adapter.GetParent()?;
|
||||
let (swap_chain, format) =
|
||||
Self::make_swap_chain(&factory, &device, hwnd, width, height)?;
|
||||
|
||||
let rtv = Self::make_rtv(&device, &swap_chain)?;
|
||||
let staging = Self::make_staging(&device, width, height, format)?;
|
||||
|
||||
Ok(Self {
|
||||
device,
|
||||
swap_chain,
|
||||
rtv,
|
||||
width,
|
||||
height,
|
||||
staging,
|
||||
format,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn make_swap_chain(
|
||||
factory: &IDXGIFactory,
|
||||
device: &ID3D10Device,
|
||||
hwnd: HWND,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<(IDXGISwapChain, DXGI_FORMAT)> {
|
||||
unsafe {
|
||||
let mut last = windows_core::HRESULT(0);
|
||||
for format in [DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_R8G8B8A8_UNORM] {
|
||||
let desc = DXGI_SWAP_CHAIN_DESC {
|
||||
BufferDesc: DXGI_MODE_DESC {
|
||||
Width: width,
|
||||
Height: height,
|
||||
RefreshRate: DXGI_RATIONAL {
|
||||
Numerator: 0,
|
||||
Denominator: 0,
|
||||
},
|
||||
Format: format,
|
||||
..Default::default()
|
||||
},
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT,
|
||||
BufferCount: 1,
|
||||
OutputWindow: hwnd,
|
||||
Windowed: BOOL(1),
|
||||
SwapEffect: DXGI_SWAP_EFFECT_DISCARD,
|
||||
Flags: 0,
|
||||
};
|
||||
let mut swap_chain: Option<IDXGISwapChain> = None;
|
||||
let hr = factory.CreateSwapChain(device, &desc, &mut swap_chain);
|
||||
if hr.is_ok() {
|
||||
return Ok((
|
||||
swap_chain.expect("CreateSwapChain yielded no swapchain"),
|
||||
format,
|
||||
));
|
||||
}
|
||||
last = hr;
|
||||
}
|
||||
last.ok()?;
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn make_rtv(
|
||||
device: &ID3D10Device,
|
||||
swap_chain: &IDXGISwapChain,
|
||||
) -> Result<ID3D10RenderTargetView> {
|
||||
unsafe {
|
||||
let back_buffer: ID3D10Texture2D = swap_chain.GetBuffer(0)?;
|
||||
let resource: ID3D10Resource = back_buffer.cast()?;
|
||||
let mut rtv: Option<ID3D10RenderTargetView> = None;
|
||||
device.CreateRenderTargetView(&resource, None, Some(&mut rtv))?;
|
||||
Ok(rtv.expect("CreateRenderTargetView yielded no view"))
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn make_staging(
|
||||
device: &ID3D10Device,
|
||||
width: u32,
|
||||
height: u32,
|
||||
format: DXGI_FORMAT,
|
||||
) -> Result<ID3D10Texture2D> {
|
||||
use windows::Win32::Graphics::Direct3D10::{
|
||||
D3D10_CPU_ACCESS_WRITE, D3D10_TEXTURE2D_DESC, D3D10_USAGE_STAGING,
|
||||
};
|
||||
unsafe {
|
||||
let desc = D3D10_TEXTURE2D_DESC {
|
||||
Width: width,
|
||||
Height: height,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: format,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D10_USAGE_STAGING,
|
||||
BindFlags: 0,
|
||||
CPUAccessFlags: D3D10_CPU_ACCESS_WRITE.0 as u32,
|
||||
MiscFlags: 0,
|
||||
};
|
||||
device.CreateTexture2D(&desc, None)
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn render_frame(&self, frame_index: u64) -> Result<()> {
|
||||
use windows::Win32::Graphics::Direct3D10::D3D10_MAP_WRITE;
|
||||
unsafe {
|
||||
let (cr, cg, cb) =
|
||||
pattern::BARS[(frame_index % pattern::BARS.len() as u64) as usize];
|
||||
let clear = [cr as f32 / 255.0, cg as f32 / 255.0, cb as f32 / 255.0, 1.0];
|
||||
self.device.ClearRenderTargetView(&self.rtv, &clear);
|
||||
|
||||
let mapped: D3D10_MAPPED_TEXTURE2D = self.staging.Map(0, D3D10_MAP_WRITE, 0)?;
|
||||
let row_pitch = mapped.RowPitch as usize;
|
||||
let base = mapped.pData as *mut u8;
|
||||
let counter = pattern::counter_colour(frame_index);
|
||||
let luma = (frame_index & 0xFF) as u8;
|
||||
for y in 0..self.height {
|
||||
let row = base.add(y as usize * row_pitch);
|
||||
for x in 0..self.width {
|
||||
let (r, g, b) = if x < pattern::COUNTER_BLOCK && y < pattern::COUNTER_BLOCK
|
||||
{
|
||||
counter
|
||||
} else {
|
||||
pattern::bar_colour(x, self.width)
|
||||
};
|
||||
let px = row.add(x as usize * 4);
|
||||
if self.format == DXGI_FORMAT_R8G8B8A8_UNORM {
|
||||
*px = r;
|
||||
*px.add(1) = g;
|
||||
*px.add(2) = b.saturating_add(luma / 4);
|
||||
} else {
|
||||
*px = b.saturating_add(luma / 4);
|
||||
*px.add(1) = g;
|
||||
*px.add(2) = r;
|
||||
}
|
||||
*px.add(3) = 255;
|
||||
}
|
||||
}
|
||||
self.staging.Unmap(0);
|
||||
|
||||
let back_buffer: ID3D10Texture2D = self.swap_chain.GetBuffer(0)?;
|
||||
let dst: ID3D10Resource = back_buffer.cast()?;
|
||||
let src: ID3D10Resource = self.staging.cast()?;
|
||||
self.device.CopyResource(&dst, &src);
|
||||
self.swap_chain.Present(1, DXGI_PRESENT(0)).ok()?;
|
||||
let _ = &self.device;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() -> Result<()> {
|
||||
let options = Options::from_env();
|
||||
unsafe {
|
||||
let hwnd = create_window(options.width, options.height, options.borderless)?;
|
||||
|
||||
let mut rect = Default::default();
|
||||
let _ = GetClientRect(hwnd, &mut rect);
|
||||
let width = ((rect.right - rect.left).max(1)) as u32;
|
||||
let height = ((rect.bottom - rect.top).max(1)) as u32;
|
||||
|
||||
let renderer = Renderer::new(hwnd, width, height)?;
|
||||
|
||||
println!("HWND={}", hwnd.0 as isize);
|
||||
use std::io::Write;
|
||||
let _ = std::io::stdout().flush();
|
||||
|
||||
let mut frame_index: u64 = 0;
|
||||
let mut msg = MSG::default();
|
||||
loop {
|
||||
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
|
||||
if msg.message == WM_QUIT {
|
||||
return Ok(());
|
||||
}
|
||||
let _ = TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
|
||||
renderer.render_frame(frame_index)?;
|
||||
frame_index += 1;
|
||||
|
||||
if let Some(limit) = options.frames {
|
||||
if frame_index >= limit {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "d3d11-present-fixture"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"windows",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[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.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core",
|
||||
"windows-future",
|
||||
"windows-numerics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
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-numerics"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
[package]
|
||||
name = "d3d11-present-fixture"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
publish = false
|
||||
|
||||
# Empty workspace table keeps this fixture out of any parent Cargo workspace,
|
||||
# matching the sibling crates (hook/, vulkan-layer/) in this package.
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
[[bin]]
|
||||
name = "d3d11-present-fixture"
|
||||
path = "src/main.rs"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = {version = "0.62.2", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Direct3D",
|
||||
"Win32_Graphics_Direct3D11",
|
||||
"Win32_Graphics_Dxgi",
|
||||
"Win32_Graphics_Dxgi_Common",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
]}
|
||||
windows-core = "0.62.2"
|
||||
@@ -0,0 +1,496 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn main() {
|
||||
eprintln!("d3d11-present-fixture is only functional on Windows");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn main() -> windows_core::Result<()> {
|
||||
windows_impl::run()
|
||||
}
|
||||
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
mod pattern {
|
||||
pub const BARS: [(u8, u8, u8); 8] = [
|
||||
(255, 255, 255),
|
||||
(255, 255, 0),
|
||||
(0, 255, 255),
|
||||
(0, 255, 0),
|
||||
(255, 0, 255),
|
||||
(255, 0, 0),
|
||||
(0, 0, 255),
|
||||
(0, 0, 0),
|
||||
];
|
||||
|
||||
pub const COUNTER_BLOCK: u32 = 16;
|
||||
|
||||
pub fn bar_colour(x: u32, width: u32) -> (u8, u8, u8) {
|
||||
let width = width.max(1);
|
||||
let index = ((x as u64 * BARS.len() as u64) / width as u64) as usize;
|
||||
BARS[index.min(BARS.len() - 1)]
|
||||
}
|
||||
|
||||
pub fn counter_colour(frame_index: u64) -> (u8, u8, u8) {
|
||||
let low = (frame_index & 0x00FF_FFFF) as u32;
|
||||
(
|
||||
(low & 0xFF) as u8,
|
||||
((low >> 8) & 0xFF) as u8,
|
||||
((low >> 16) & 0xFF) as u8,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
mod cli {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PresentFormat {
|
||||
Bgra8,
|
||||
Rgba8,
|
||||
R10G10B10A2,
|
||||
Rgba16Float,
|
||||
}
|
||||
|
||||
impl PresentFormat {
|
||||
fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"bgra8" | "b8g8r8a8" => Some(Self::Bgra8),
|
||||
"rgba8" | "r8g8b8a8" => Some(Self::Rgba8),
|
||||
"r10g10b10a2" | "rgb10a2" | "10bit" => Some(Self::R10G10B10A2),
|
||||
"rgba16f" | "r16g16b16a16f" | "fp16" => Some(Self::Rgba16Float),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Options {
|
||||
pub frames: Option<u64>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub borderless: bool,
|
||||
pub format: PresentFormat,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
frames: None,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
borderless: false,
|
||||
format: PresentFormat::Bgra8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Options {
|
||||
pub fn from_env() -> Self {
|
||||
let mut options = Options::default();
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--frames" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.frames = Some(value);
|
||||
}
|
||||
}
|
||||
"--width" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.width = value;
|
||||
}
|
||||
}
|
||||
"--height" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.height = value;
|
||||
}
|
||||
}
|
||||
"--borderless" => options.borderless = true,
|
||||
"--windowed" => options.borderless = false,
|
||||
"--format" => {
|
||||
if let Some(format) = args.next().and_then(|v| PresentFormat::parse(&v)) {
|
||||
options.format = format;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
options
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PresentFormat;
|
||||
|
||||
#[test]
|
||||
fn parses_explicit_format_modes() {
|
||||
assert_eq!(PresentFormat::parse("bgra8"), Some(PresentFormat::Bgra8));
|
||||
assert_eq!(PresentFormat::parse("rgba8"), Some(PresentFormat::Rgba8));
|
||||
assert_eq!(
|
||||
PresentFormat::parse("r10g10b10a2"),
|
||||
Some(PresentFormat::R10G10B10A2)
|
||||
);
|
||||
assert_eq!(
|
||||
PresentFormat::parse("fp16"),
|
||||
Some(PresentFormat::Rgba16Float)
|
||||
);
|
||||
assert_eq!(PresentFormat::parse("unknown"), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows_impl {
|
||||
use super::{
|
||||
cli::{Options, PresentFormat},
|
||||
pattern,
|
||||
};
|
||||
use windows::Win32::Foundation::{HMODULE, HWND, LPARAM, LRESULT, WPARAM};
|
||||
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_HARDWARE;
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_MAPPED_SUBRESOURCE, D3D11_SDK_VERSION,
|
||||
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11RenderTargetView,
|
||||
ID3D11Resource, ID3D11Texture2D,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_ALPHA_MODE_IGNORE, DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
DXGI_FORMAT_R8G8B8A8_UNORM, DXGI_FORMAT_R10G10B10A2_UNORM, DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||||
DXGI_SAMPLE_DESC,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::{
|
||||
DXGI_SCALING_STRETCH, DXGI_SWAP_CHAIN_DESC1, DXGI_SWAP_EFFECT_FLIP_DISCARD,
|
||||
DXGI_USAGE_RENDER_TARGET_OUTPUT, IDXGIDevice, IDXGIFactory2, IDXGISwapChain1,
|
||||
};
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
CS_HREDRAW, CS_VREDRAW, CW_USEDEFAULT, CreateWindowExW, DefWindowProcW, DispatchMessageW,
|
||||
GetClientRect, MSG, PM_REMOVE, PeekMessageW, PostQuitMessage, RegisterClassExW, SW_SHOW,
|
||||
ShowWindow, TranslateMessage, WM_DESTROY, WM_QUIT, WNDCLASSEXW, WS_OVERLAPPEDWINDOW,
|
||||
WS_POPUP, WS_VISIBLE,
|
||||
};
|
||||
use windows_core::{Interface, Result, w};
|
||||
|
||||
impl PresentFormat {
|
||||
fn dxgi(self) -> DXGI_FORMAT {
|
||||
match self {
|
||||
PresentFormat::Bgra8 => DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
PresentFormat::Rgba8 => DXGI_FORMAT_R8G8B8A8_UNORM,
|
||||
PresentFormat::R10G10B10A2 => DXGI_FORMAT_R10G10B10A2_UNORM,
|
||||
PresentFormat::Rgba16Float => DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||||
}
|
||||
}
|
||||
|
||||
fn bytes_per_pixel(self) -> usize {
|
||||
match self {
|
||||
PresentFormat::Bgra8 | PresentFormat::Rgba8 | PresentFormat::R10G10B10A2 => 4,
|
||||
PresentFormat::Rgba16Float => 8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extern "system" fn wnd_proc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||
unsafe {
|
||||
match msg {
|
||||
WM_DESTROY => {
|
||||
PostQuitMessage(0);
|
||||
LRESULT(0)
|
||||
}
|
||||
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn create_window(width: u32, height: u32, borderless: bool) -> Result<HWND> {
|
||||
unsafe {
|
||||
let instance = GetModuleHandleW(None)?;
|
||||
let class_name = w!("FluxerD3D11Fixture");
|
||||
let wc = WNDCLASSEXW {
|
||||
cbSize: size_of::<WNDCLASSEXW>() as u32,
|
||||
style: CS_HREDRAW | CS_VREDRAW,
|
||||
lpfnWndProc: Some(wnd_proc),
|
||||
hInstance: instance.into(),
|
||||
lpszClassName: class_name,
|
||||
..Default::default()
|
||||
};
|
||||
RegisterClassExW(&wc);
|
||||
let style = if borderless {
|
||||
WS_POPUP | WS_VISIBLE
|
||||
} else {
|
||||
WS_OVERLAPPEDWINDOW | WS_VISIBLE
|
||||
};
|
||||
let hwnd = CreateWindowExW(
|
||||
Default::default(),
|
||||
class_name,
|
||||
w!("Fluxer D3D11 Present Fixture"),
|
||||
style,
|
||||
CW_USEDEFAULT,
|
||||
CW_USEDEFAULT,
|
||||
width as i32,
|
||||
height as i32,
|
||||
None,
|
||||
None,
|
||||
Some(instance.into()),
|
||||
None,
|
||||
)?;
|
||||
let _ = ShowWindow(hwnd, SW_SHOW);
|
||||
Ok(hwnd)
|
||||
}
|
||||
}
|
||||
|
||||
struct Renderer {
|
||||
device: ID3D11Device,
|
||||
context: ID3D11DeviceContext,
|
||||
swap_chain: IDXGISwapChain1,
|
||||
rtv: ID3D11RenderTargetView,
|
||||
width: u32,
|
||||
height: u32,
|
||||
staging: ID3D11Texture2D,
|
||||
format: PresentFormat,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
unsafe fn new(hwnd: HWND, width: u32, height: u32, format: PresentFormat) -> Result<Self> {
|
||||
unsafe {
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
let mut context: Option<ID3D11DeviceContext> = None;
|
||||
D3D11CreateDevice(
|
||||
None,
|
||||
D3D_DRIVER_TYPE_HARDWARE,
|
||||
HMODULE::default(),
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
None,
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
None,
|
||||
Some(&mut context),
|
||||
)?;
|
||||
let device = device.expect("D3D11CreateDevice yielded no device");
|
||||
let context = context.expect("D3D11CreateDevice yielded no context");
|
||||
|
||||
let dxgi_device: IDXGIDevice = device.cast()?;
|
||||
let adapter = dxgi_device.GetAdapter()?;
|
||||
let factory: IDXGIFactory2 = adapter.GetParent()?;
|
||||
|
||||
let desc = DXGI_SWAP_CHAIN_DESC1 {
|
||||
Width: width,
|
||||
Height: height,
|
||||
Format: format.dxgi(),
|
||||
Stereo: false.into(),
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT,
|
||||
BufferCount: 2,
|
||||
Scaling: DXGI_SCALING_STRETCH,
|
||||
SwapEffect: DXGI_SWAP_EFFECT_FLIP_DISCARD,
|
||||
AlphaMode: DXGI_ALPHA_MODE_IGNORE,
|
||||
Flags: 0,
|
||||
};
|
||||
let swap_chain =
|
||||
factory.CreateSwapChainForHwnd(&device, hwnd, &desc, None, None)?;
|
||||
|
||||
let rtv = Self::make_rtv(&device, &swap_chain)?;
|
||||
let staging = Self::make_staging(&device, width, height, format)?;
|
||||
|
||||
Ok(Self {
|
||||
device,
|
||||
context,
|
||||
swap_chain,
|
||||
rtv,
|
||||
width,
|
||||
height,
|
||||
staging,
|
||||
format,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn make_rtv(
|
||||
device: &ID3D11Device,
|
||||
swap_chain: &IDXGISwapChain1,
|
||||
) -> Result<ID3D11RenderTargetView> {
|
||||
unsafe {
|
||||
let back_buffer: ID3D11Texture2D = swap_chain.GetBuffer(0)?;
|
||||
let resource: ID3D11Resource = back_buffer.cast()?;
|
||||
let mut rtv: Option<ID3D11RenderTargetView> = None;
|
||||
device.CreateRenderTargetView(&resource, None, Some(&mut rtv))?;
|
||||
Ok(rtv.expect("CreateRenderTargetView yielded no view"))
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn make_staging(
|
||||
device: &ID3D11Device,
|
||||
width: u32,
|
||||
height: u32,
|
||||
format: PresentFormat,
|
||||
) -> Result<ID3D11Texture2D> {
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11_CPU_ACCESS_WRITE, D3D11_TEXTURE2D_DESC, D3D11_USAGE_STAGING,
|
||||
};
|
||||
unsafe {
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: width,
|
||||
Height: height,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: format.dxgi(),
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_STAGING,
|
||||
BindFlags: 0,
|
||||
CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
|
||||
MiscFlags: 0,
|
||||
};
|
||||
let mut staging: Option<ID3D11Texture2D> = None;
|
||||
device.CreateTexture2D(&desc, None, Some(&mut staging))?;
|
||||
Ok(staging.expect("CreateTexture2D yielded no staging texture"))
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn render_frame(&self, frame_index: u64) -> Result<()> {
|
||||
use windows::Win32::Graphics::Direct3D11::D3D11_MAP_WRITE;
|
||||
unsafe {
|
||||
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
|
||||
self.context
|
||||
.Map(&self.staging, 0, D3D11_MAP_WRITE, 0, Some(&mut mapped))?;
|
||||
let row_pitch = mapped.RowPitch as usize;
|
||||
let base = mapped.pData as *mut u8;
|
||||
let counter = pattern::counter_colour(frame_index);
|
||||
let luma = (frame_index & 0xFF) as u8;
|
||||
let bpp = self.format.bytes_per_pixel();
|
||||
for y in 0..self.height {
|
||||
let row = base.add(y as usize * row_pitch);
|
||||
for x in 0..self.width {
|
||||
let (r, g, b) = if x < pattern::COUNTER_BLOCK && y < pattern::COUNTER_BLOCK
|
||||
{
|
||||
counter
|
||||
} else {
|
||||
pattern::bar_colour(x, self.width)
|
||||
};
|
||||
let px = row.add(x as usize * bpp);
|
||||
write_pixel(self.format, px, r, g, b.saturating_add(luma / 4));
|
||||
}
|
||||
}
|
||||
self.context.Unmap(&self.staging, 0);
|
||||
|
||||
let back_buffer: ID3D11Texture2D = self.swap_chain.GetBuffer(0)?;
|
||||
let dst: ID3D11Resource = back_buffer.cast()?;
|
||||
let src: ID3D11Resource = self.staging.cast()?;
|
||||
self.context.CopyResource(&dst, &src);
|
||||
let _ = &self.rtv;
|
||||
self.swap_chain.Present(1, Default::default()).ok()?;
|
||||
let _ = &self.device;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn write_pixel(format: PresentFormat, px: *mut u8, r: u8, g: u8, b: u8) {
|
||||
unsafe {
|
||||
match format {
|
||||
PresentFormat::Bgra8 => {
|
||||
*px = b;
|
||||
*px.add(1) = g;
|
||||
*px.add(2) = r;
|
||||
*px.add(3) = 255;
|
||||
}
|
||||
PresentFormat::Rgba8 => {
|
||||
*px = r;
|
||||
*px.add(1) = g;
|
||||
*px.add(2) = b;
|
||||
*px.add(3) = 255;
|
||||
}
|
||||
PresentFormat::R10G10B10A2 => {
|
||||
let packed = scale8_to_10(r)
|
||||
| (scale8_to_10(g) << 10)
|
||||
| (scale8_to_10(b) << 20)
|
||||
| (0x3 << 30);
|
||||
std::ptr::copy_nonoverlapping(packed.to_le_bytes().as_ptr(), px, 4);
|
||||
}
|
||||
PresentFormat::Rgba16Float => {
|
||||
let highlight = if r == 255 && g == 255 && b > 240 {
|
||||
1.25
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let values = [
|
||||
f32_to_f16((r as f32 / 255.0) * highlight),
|
||||
f32_to_f16((g as f32 / 255.0) * highlight),
|
||||
f32_to_f16((b as f32 / 255.0) * highlight),
|
||||
f32_to_f16(1.0),
|
||||
];
|
||||
for (index, value) in values.iter().enumerate() {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
value.to_le_bytes().as_ptr(),
|
||||
px.add(index * 2),
|
||||
2,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scale8_to_10(value: u8) -> u32 {
|
||||
(value as u32 * 1023 + 127) / 255
|
||||
}
|
||||
|
||||
fn f32_to_f16(value: f32) -> u16 {
|
||||
let value = value.clamp(0.0, 65504.0);
|
||||
if value == 0.0 {
|
||||
return 0;
|
||||
}
|
||||
let bits = value.to_bits();
|
||||
let exp = ((bits >> 23) & 0xFF) as i32 - 127 + 15;
|
||||
let mant = bits & 0x7F_FFFF;
|
||||
if exp <= 0 {
|
||||
return 0;
|
||||
}
|
||||
if exp >= 31 {
|
||||
return 0x7BFF;
|
||||
}
|
||||
((exp as u16) << 10) | (((mant + 0x1000) >> 13) as u16)
|
||||
}
|
||||
|
||||
pub fn run() -> Result<()> {
|
||||
let options = Options::from_env();
|
||||
unsafe {
|
||||
let hwnd = create_window(options.width, options.height, options.borderless)?;
|
||||
|
||||
let mut rect = Default::default();
|
||||
let _ = GetClientRect(hwnd, &mut rect);
|
||||
let width = ((rect.right - rect.left).max(1)) as u32;
|
||||
let height = ((rect.bottom - rect.top).max(1)) as u32;
|
||||
|
||||
let renderer = Renderer::new(hwnd, width, height, options.format)?;
|
||||
|
||||
println!("HWND={}", hwnd.0 as isize);
|
||||
use std::io::Write;
|
||||
let _ = std::io::stdout().flush();
|
||||
|
||||
let mut frame_index: u64 = 0;
|
||||
let mut msg = MSG::default();
|
||||
loop {
|
||||
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
|
||||
if msg.message == WM_QUIT {
|
||||
return Ok(());
|
||||
}
|
||||
let _ = TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
|
||||
renderer.render_frame(frame_index)?;
|
||||
frame_index += 1;
|
||||
|
||||
if let Some(limit) = options.frames {
|
||||
if frame_index >= limit {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "d3d12-present-fixture"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"windows",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[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.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core",
|
||||
"windows-future",
|
||||
"windows-numerics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
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-numerics"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
[package]
|
||||
name = "d3d12-present-fixture"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
publish = false
|
||||
|
||||
# Empty workspace table keeps this fixture out of any parent Cargo workspace,
|
||||
# matching the sibling crates (hook/, vulkan-layer/) in this package.
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
[[bin]]
|
||||
name = "d3d12-present-fixture"
|
||||
path = "src/main.rs"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = {version = "0.62.2", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Direct3D",
|
||||
"Win32_Graphics_Direct3D12",
|
||||
"Win32_Graphics_Dxgi",
|
||||
"Win32_Graphics_Dxgi_Common",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_Security",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
]}
|
||||
windows-core = "0.62.2"
|
||||
@@ -0,0 +1,582 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn main() {
|
||||
eprintln!("d3d12-present-fixture is only functional on Windows");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn main() -> windows_core::Result<()> {
|
||||
windows_impl::run()
|
||||
}
|
||||
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
mod pattern {
|
||||
pub const BARS: [(u8, u8, u8); 8] = [
|
||||
(255, 255, 255),
|
||||
(255, 255, 0),
|
||||
(0, 255, 255),
|
||||
(0, 255, 0),
|
||||
(255, 0, 255),
|
||||
(255, 0, 0),
|
||||
(0, 0, 255),
|
||||
(0, 0, 0),
|
||||
];
|
||||
|
||||
pub const COUNTER_BLOCK: u32 = 16;
|
||||
|
||||
pub fn bar_colour(x: u32, width: u32) -> (u8, u8, u8) {
|
||||
let width = width.max(1);
|
||||
let index = ((x as u64 * BARS.len() as u64) / width as u64) as usize;
|
||||
BARS[index.min(BARS.len() - 1)]
|
||||
}
|
||||
|
||||
pub fn counter_colour(frame_index: u64) -> (u8, u8, u8) {
|
||||
let low = (frame_index & 0x00FF_FFFF) as u32;
|
||||
(
|
||||
(low & 0xFF) as u8,
|
||||
((low >> 8) & 0xFF) as u8,
|
||||
((low >> 16) & 0xFF) as u8,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
mod cli {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PresentFormat {
|
||||
Bgra8,
|
||||
Rgba8,
|
||||
R10G10B10A2,
|
||||
Rgba16Float,
|
||||
}
|
||||
|
||||
impl PresentFormat {
|
||||
fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"bgra8" | "b8g8r8a8" => Some(Self::Bgra8),
|
||||
"rgba8" | "r8g8b8a8" => Some(Self::Rgba8),
|
||||
"r10g10b10a2" | "rgb10a2" | "10bit" => Some(Self::R10G10B10A2),
|
||||
"rgba16f" | "r16g16b16a16f" | "fp16" => Some(Self::Rgba16Float),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Options {
|
||||
pub frames: Option<u64>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub borderless: bool,
|
||||
pub backbuffers: u32,
|
||||
pub format: PresentFormat,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
frames: None,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
borderless: false,
|
||||
backbuffers: 2,
|
||||
format: PresentFormat::Bgra8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Options {
|
||||
pub fn from_env() -> Self {
|
||||
let mut options = Options::default();
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--frames" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.frames = Some(value);
|
||||
}
|
||||
}
|
||||
"--width" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.width = value;
|
||||
}
|
||||
}
|
||||
"--height" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.height = value;
|
||||
}
|
||||
}
|
||||
"--backbuffers" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.backbuffers = value;
|
||||
}
|
||||
}
|
||||
"--borderless" => options.borderless = true,
|
||||
"--windowed" => options.borderless = false,
|
||||
"--format" => {
|
||||
if let Some(format) = args.next().and_then(|v| PresentFormat::parse(&v)) {
|
||||
options.format = format;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
options.backbuffers = options.backbuffers.max(2);
|
||||
options
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PresentFormat;
|
||||
|
||||
#[test]
|
||||
fn parses_explicit_format_modes() {
|
||||
assert_eq!(PresentFormat::parse("bgra8"), Some(PresentFormat::Bgra8));
|
||||
assert_eq!(PresentFormat::parse("rgba8"), Some(PresentFormat::Rgba8));
|
||||
assert_eq!(
|
||||
PresentFormat::parse("r10g10b10a2"),
|
||||
Some(PresentFormat::R10G10B10A2)
|
||||
);
|
||||
assert_eq!(
|
||||
PresentFormat::parse("fp16"),
|
||||
Some(PresentFormat::Rgba16Float)
|
||||
);
|
||||
assert_eq!(PresentFormat::parse("unknown"), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows_impl {
|
||||
use super::cli::{Options, PresentFormat};
|
||||
use super::pattern;
|
||||
use windows::Win32::Foundation::RECT;
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE, HWND, LPARAM, LRESULT, WPARAM};
|
||||
use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_FEATURE_LEVEL_11_0};
|
||||
use windows::Win32::Graphics::Direct3D12::{
|
||||
D3D12_COMMAND_LIST_TYPE_DIRECT, D3D12_COMMAND_QUEUE_DESC, D3D12_COMMAND_QUEUE_FLAG_NONE,
|
||||
D3D12_CPU_DESCRIPTOR_HANDLE, D3D12_DESCRIPTOR_HEAP_DESC, D3D12_DESCRIPTOR_HEAP_FLAG_NONE,
|
||||
D3D12_DESCRIPTOR_HEAP_TYPE_RTV, D3D12_FENCE_FLAG_NONE, D3D12_RESOURCE_BARRIER,
|
||||
D3D12_RESOURCE_BARRIER_0, D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES,
|
||||
D3D12_RESOURCE_BARRIER_FLAG_NONE, D3D12_RESOURCE_BARRIER_TYPE_TRANSITION,
|
||||
D3D12_RESOURCE_STATE_PRESENT, D3D12_RESOURCE_STATE_RENDER_TARGET,
|
||||
D3D12_RESOURCE_TRANSITION_BARRIER, D3D12CreateDevice, ID3D12CommandAllocator,
|
||||
ID3D12CommandQueue, ID3D12DescriptorHeap, ID3D12Device, ID3D12Fence,
|
||||
ID3D12GraphicsCommandList, ID3D12PipelineState, ID3D12Resource,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_ALPHA_MODE_IGNORE, DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
DXGI_FORMAT_R8G8B8A8_UNORM, DXGI_FORMAT_R10G10B10A2_UNORM, DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||||
DXGI_SAMPLE_DESC,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::{
|
||||
CreateDXGIFactory2, DXGI_ADAPTER_FLAG, DXGI_ADAPTER_FLAG_SOFTWARE,
|
||||
DXGI_CREATE_FACTORY_FLAGS, DXGI_SCALING_STRETCH, DXGI_SWAP_CHAIN_DESC1,
|
||||
DXGI_SWAP_EFFECT_FLIP_DISCARD, DXGI_USAGE_RENDER_TARGET_OUTPUT, IDXGIAdapter1,
|
||||
IDXGIFactory4, IDXGISwapChain3,
|
||||
};
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::System::Threading::{CreateEventW, INFINITE, WaitForSingleObject};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
CS_HREDRAW, CS_VREDRAW, CW_USEDEFAULT, CreateWindowExW, DefWindowProcW, DispatchMessageW,
|
||||
GetClientRect, MSG, PM_REMOVE, PeekMessageW, PostQuitMessage, RegisterClassExW, SW_SHOW,
|
||||
ShowWindow, TranslateMessage, WM_DESTROY, WM_QUIT, WNDCLASSEXW, WS_OVERLAPPEDWINDOW,
|
||||
WS_POPUP, WS_VISIBLE,
|
||||
};
|
||||
use windows_core::{Interface, Result, w};
|
||||
|
||||
impl PresentFormat {
|
||||
fn dxgi(self) -> DXGI_FORMAT {
|
||||
match self {
|
||||
PresentFormat::Bgra8 => DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
PresentFormat::Rgba8 => DXGI_FORMAT_R8G8B8A8_UNORM,
|
||||
PresentFormat::R10G10B10A2 => DXGI_FORMAT_R10G10B10A2_UNORM,
|
||||
PresentFormat::Rgba16Float => DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extern "system" fn wnd_proc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||
unsafe {
|
||||
match msg {
|
||||
WM_DESTROY => {
|
||||
PostQuitMessage(0);
|
||||
LRESULT(0)
|
||||
}
|
||||
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn create_window(width: u32, height: u32, borderless: bool) -> Result<HWND> {
|
||||
unsafe {
|
||||
let instance = GetModuleHandleW(None)?;
|
||||
let class_name = w!("FluxerD3D12Fixture");
|
||||
let wc = WNDCLASSEXW {
|
||||
cbSize: size_of::<WNDCLASSEXW>() as u32,
|
||||
style: CS_HREDRAW | CS_VREDRAW,
|
||||
lpfnWndProc: Some(wnd_proc),
|
||||
hInstance: instance.into(),
|
||||
lpszClassName: class_name,
|
||||
..Default::default()
|
||||
};
|
||||
RegisterClassExW(&wc);
|
||||
let style = if borderless {
|
||||
WS_POPUP | WS_VISIBLE
|
||||
} else {
|
||||
WS_OVERLAPPEDWINDOW | WS_VISIBLE
|
||||
};
|
||||
let hwnd = CreateWindowExW(
|
||||
Default::default(),
|
||||
class_name,
|
||||
w!("Fluxer D3D12 Present Fixture"),
|
||||
style,
|
||||
CW_USEDEFAULT,
|
||||
CW_USEDEFAULT,
|
||||
width as i32,
|
||||
height as i32,
|
||||
None,
|
||||
None,
|
||||
Some(instance.into()),
|
||||
None,
|
||||
)?;
|
||||
let _ = ShowWindow(hwnd, SW_SHOW);
|
||||
Ok(hwnd)
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn create_device(factory: &IDXGIFactory4) -> Result<ID3D12Device> {
|
||||
unsafe {
|
||||
let mut index = 0u32;
|
||||
while let Ok(adapter) = factory.EnumAdapters1(index) {
|
||||
let adapter: IDXGIAdapter1 = adapter;
|
||||
let desc = adapter.GetDesc1()?;
|
||||
let is_software =
|
||||
(DXGI_ADAPTER_FLAG(desc.Flags as i32).0 & DXGI_ADAPTER_FLAG_SOFTWARE.0) != 0;
|
||||
if !is_software {
|
||||
let mut device: Option<ID3D12Device> = None;
|
||||
if D3D12CreateDevice(&adapter, D3D_FEATURE_LEVEL_11_0, &mut device).is_ok() {
|
||||
if let Some(device) = device {
|
||||
return Ok(device);
|
||||
}
|
||||
}
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
let mut device: Option<ID3D12Device> = None;
|
||||
let _ = D3D_DRIVER_TYPE_HARDWARE;
|
||||
D3D12CreateDevice(None, D3D_FEATURE_LEVEL_11_0, &mut device)?;
|
||||
device.ok_or_else(windows_core::Error::from_thread)
|
||||
}
|
||||
}
|
||||
|
||||
struct Renderer {
|
||||
device: ID3D12Device,
|
||||
queue: ID3D12CommandQueue,
|
||||
swap_chain: IDXGISwapChain3,
|
||||
rtv_heap: ID3D12DescriptorHeap,
|
||||
rtv_descriptor_size: usize,
|
||||
render_targets: Vec<ID3D12Resource>,
|
||||
allocators: Vec<ID3D12CommandAllocator>,
|
||||
list: ID3D12GraphicsCommandList,
|
||||
fence: ID3D12Fence,
|
||||
fence_event: HANDLE,
|
||||
fence_value: u64,
|
||||
frame_fence_values: Vec<u64>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
format: PresentFormat,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
unsafe fn new(
|
||||
hwnd: HWND,
|
||||
width: u32,
|
||||
height: u32,
|
||||
backbuffers: u32,
|
||||
format: PresentFormat,
|
||||
) -> Result<Self> {
|
||||
unsafe {
|
||||
let factory: IDXGIFactory4 = CreateDXGIFactory2(DXGI_CREATE_FACTORY_FLAGS(0))?;
|
||||
let device = create_device(&factory)?;
|
||||
|
||||
let queue_desc = D3D12_COMMAND_QUEUE_DESC {
|
||||
Type: D3D12_COMMAND_LIST_TYPE_DIRECT,
|
||||
Priority: 0,
|
||||
Flags: D3D12_COMMAND_QUEUE_FLAG_NONE,
|
||||
NodeMask: 0,
|
||||
};
|
||||
let queue: ID3D12CommandQueue = device.CreateCommandQueue(&queue_desc)?;
|
||||
|
||||
let desc = DXGI_SWAP_CHAIN_DESC1 {
|
||||
Width: width,
|
||||
Height: height,
|
||||
Format: format.dxgi(),
|
||||
Stereo: false.into(),
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT,
|
||||
BufferCount: backbuffers,
|
||||
Scaling: DXGI_SCALING_STRETCH,
|
||||
SwapEffect: DXGI_SWAP_EFFECT_FLIP_DISCARD,
|
||||
AlphaMode: DXGI_ALPHA_MODE_IGNORE,
|
||||
Flags: 0,
|
||||
};
|
||||
let swap_chain1 =
|
||||
factory.CreateSwapChainForHwnd(&queue, hwnd, &desc, None, None)?;
|
||||
let swap_chain: IDXGISwapChain3 = swap_chain1.cast()?;
|
||||
|
||||
let rtv_heap_desc = D3D12_DESCRIPTOR_HEAP_DESC {
|
||||
Type: D3D12_DESCRIPTOR_HEAP_TYPE_RTV,
|
||||
NumDescriptors: backbuffers,
|
||||
Flags: D3D12_DESCRIPTOR_HEAP_FLAG_NONE,
|
||||
NodeMask: 0,
|
||||
};
|
||||
let rtv_heap: ID3D12DescriptorHeap = device.CreateDescriptorHeap(&rtv_heap_desc)?;
|
||||
let rtv_descriptor_size = device
|
||||
.GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_RTV)
|
||||
as usize;
|
||||
|
||||
let heap_start = rtv_heap.GetCPUDescriptorHandleForHeapStart();
|
||||
let mut render_targets = Vec::with_capacity(backbuffers as usize);
|
||||
let mut allocators = Vec::with_capacity(backbuffers as usize);
|
||||
for i in 0..backbuffers {
|
||||
let back_buffer: ID3D12Resource = swap_chain.GetBuffer(i)?;
|
||||
let handle = D3D12_CPU_DESCRIPTOR_HANDLE {
|
||||
ptr: heap_start.ptr + i as usize * rtv_descriptor_size,
|
||||
};
|
||||
device.CreateRenderTargetView(&back_buffer, None, handle);
|
||||
render_targets.push(back_buffer);
|
||||
|
||||
let allocator: ID3D12CommandAllocator =
|
||||
device.CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_DIRECT)?;
|
||||
allocators.push(allocator);
|
||||
}
|
||||
|
||||
let list: ID3D12GraphicsCommandList = device.CreateCommandList(
|
||||
0,
|
||||
D3D12_COMMAND_LIST_TYPE_DIRECT,
|
||||
&allocators[0],
|
||||
None::<&ID3D12PipelineState>,
|
||||
)?;
|
||||
list.Close()?;
|
||||
|
||||
let fence: ID3D12Fence = device.CreateFence(0, D3D12_FENCE_FLAG_NONE)?;
|
||||
let fence_event = CreateEventW(None, false, false, None)?;
|
||||
|
||||
Ok(Self {
|
||||
device,
|
||||
queue,
|
||||
swap_chain,
|
||||
rtv_heap,
|
||||
rtv_descriptor_size,
|
||||
render_targets,
|
||||
frame_fence_values: vec![0; backbuffers as usize],
|
||||
allocators,
|
||||
list,
|
||||
fence,
|
||||
fence_event,
|
||||
fence_value: 0,
|
||||
width,
|
||||
height,
|
||||
format,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn transition(
|
||||
resource: &ID3D12Resource,
|
||||
before: windows::Win32::Graphics::Direct3D12::D3D12_RESOURCE_STATES,
|
||||
after: windows::Win32::Graphics::Direct3D12::D3D12_RESOURCE_STATES,
|
||||
) -> D3D12_RESOURCE_BARRIER {
|
||||
D3D12_RESOURCE_BARRIER {
|
||||
Type: D3D12_RESOURCE_BARRIER_TYPE_TRANSITION,
|
||||
Flags: D3D12_RESOURCE_BARRIER_FLAG_NONE,
|
||||
Anonymous: D3D12_RESOURCE_BARRIER_0 {
|
||||
Transition: std::mem::ManuallyDrop::new(D3D12_RESOURCE_TRANSITION_BARRIER {
|
||||
pResource: std::mem::ManuallyDrop::new(Some(resource.clone())),
|
||||
Subresource: D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES,
|
||||
StateBefore: before,
|
||||
StateAfter: after,
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn render_frame(&mut self, frame_index: u64) -> Result<()> {
|
||||
unsafe {
|
||||
let buffer_index = self.swap_chain.GetCurrentBackBufferIndex() as usize;
|
||||
|
||||
self.wait_for_fence_value(self.frame_fence_values[buffer_index])?;
|
||||
|
||||
let allocator = &self.allocators[buffer_index];
|
||||
let back_buffer = &self.render_targets[buffer_index];
|
||||
allocator.Reset()?;
|
||||
self.list.Reset(allocator, None::<&ID3D12PipelineState>)?;
|
||||
|
||||
let to_rt = Self::transition(
|
||||
back_buffer,
|
||||
D3D12_RESOURCE_STATE_PRESENT,
|
||||
D3D12_RESOURCE_STATE_RENDER_TARGET,
|
||||
);
|
||||
self.list.ResourceBarrier(&[to_rt]);
|
||||
|
||||
let rtv = D3D12_CPU_DESCRIPTOR_HANDLE {
|
||||
ptr: self.rtv_heap.GetCPUDescriptorHandleForHeapStart().ptr
|
||||
+ buffer_index * self.rtv_descriptor_size,
|
||||
};
|
||||
self.list.OMSetRenderTargets(1, Some(&rtv), false, None);
|
||||
|
||||
self.paint_pattern(rtv, frame_index);
|
||||
|
||||
let to_present = Self::transition(
|
||||
back_buffer,
|
||||
D3D12_RESOURCE_STATE_RENDER_TARGET,
|
||||
D3D12_RESOURCE_STATE_PRESENT,
|
||||
);
|
||||
self.list.ResourceBarrier(&[to_present]);
|
||||
|
||||
self.list.Close()?;
|
||||
|
||||
let command_list: windows::Win32::Graphics::Direct3D12::ID3D12CommandList =
|
||||
self.list.cast()?;
|
||||
self.queue.ExecuteCommandLists(&[Some(command_list)]);
|
||||
|
||||
self.swap_chain.Present(1, Default::default()).ok()?;
|
||||
|
||||
self.fence_value += 1;
|
||||
let signal = self.fence_value;
|
||||
self.queue.Signal(&self.fence, signal)?;
|
||||
self.frame_fence_values[buffer_index] = signal;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn paint_pattern(&self, rtv: D3D12_CPU_DESCRIPTOR_HANDLE, frame_index: u64) {
|
||||
unsafe {
|
||||
let width = self.width.max(1);
|
||||
let height = self.height as i32;
|
||||
let luma = (frame_index & 0xFF) as u8;
|
||||
|
||||
for bar in 0..pattern::BARS.len() as u32 {
|
||||
let x0 = ((bar as u64 * width as u64) / pattern::BARS.len() as u64) as i32;
|
||||
let x1 =
|
||||
(((bar + 1) as u64 * width as u64) / pattern::BARS.len() as u64) as i32;
|
||||
if x1 <= x0 {
|
||||
continue;
|
||||
}
|
||||
let (r, g, b) = pattern::bar_colour(x0 as u32, width);
|
||||
let colour = rgba(self.format, r, g, b.saturating_add(luma / 4));
|
||||
let rect = RECT {
|
||||
left: x0,
|
||||
top: 0,
|
||||
right: x1,
|
||||
bottom: height,
|
||||
};
|
||||
self.list.ClearRenderTargetView(rtv, &colour, Some(&[rect]));
|
||||
}
|
||||
|
||||
let (cr, cg, cb) = pattern::counter_colour(frame_index);
|
||||
let block = pattern::COUNTER_BLOCK as i32;
|
||||
let counter_rect = RECT {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: block.min(self.width as i32),
|
||||
bottom: block.min(height),
|
||||
};
|
||||
let counter_colour = rgba(self.format, cr, cg, cb);
|
||||
self.list
|
||||
.ClearRenderTargetView(rtv, &counter_colour, Some(&[counter_rect]));
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn wait_for_fence_value(&self, value: u64) -> Result<()> {
|
||||
unsafe {
|
||||
if value == 0 || self.fence.GetCompletedValue() >= value {
|
||||
return Ok(());
|
||||
}
|
||||
self.fence.SetEventOnCompletion(value, self.fence_event)?;
|
||||
WaitForSingleObject(self.fence_event, INFINITE);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn flush(&mut self) -> Result<()> {
|
||||
unsafe {
|
||||
self.fence_value += 1;
|
||||
let signal = self.fence_value;
|
||||
self.queue.Signal(&self.fence, signal)?;
|
||||
self.wait_for_fence_value(signal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Renderer {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
let _ = self.flush();
|
||||
let _ = CloseHandle(self.fence_event);
|
||||
let _ = &self.device;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rgba(format: PresentFormat, r: u8, g: u8, b: u8) -> [f32; 4] {
|
||||
let highlight =
|
||||
if matches!(format, PresentFormat::Rgba16Float) && r == 255 && g == 255 && b > 240 {
|
||||
1.25
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
[
|
||||
(r as f32 / 255.0) * highlight,
|
||||
(g as f32 / 255.0) * highlight,
|
||||
(b as f32 / 255.0) * highlight,
|
||||
1.0,
|
||||
]
|
||||
}
|
||||
|
||||
pub fn run() -> Result<()> {
|
||||
let options = Options::from_env();
|
||||
unsafe {
|
||||
let hwnd = create_window(options.width, options.height, options.borderless)?;
|
||||
|
||||
let mut rect = Default::default();
|
||||
let _ = GetClientRect(hwnd, &mut rect);
|
||||
let width = ((rect.right - rect.left).max(1)) as u32;
|
||||
let height = ((rect.bottom - rect.top).max(1)) as u32;
|
||||
|
||||
let mut renderer =
|
||||
Renderer::new(hwnd, width, height, options.backbuffers, options.format)?;
|
||||
|
||||
println!("HWND={}", hwnd.0 as isize);
|
||||
use std::io::Write;
|
||||
let _ = std::io::stdout().flush();
|
||||
|
||||
let mut frame_index: u64 = 0;
|
||||
let mut msg = MSG::default();
|
||||
loop {
|
||||
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
|
||||
if msg.message == WM_QUIT {
|
||||
return Ok(());
|
||||
}
|
||||
let _ = TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
|
||||
renderer.render_frame(frame_index)?;
|
||||
frame_index += 1;
|
||||
|
||||
if let Some(limit) = options.frames {
|
||||
if frame_index >= limit {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "d3d9-present-fixture"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"windows",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[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.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core",
|
||||
"windows-future",
|
||||
"windows-numerics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
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-numerics"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
[package]
|
||||
name = "d3d9-present-fixture"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
publish = false
|
||||
|
||||
# Empty workspace table keeps this fixture out of any parent Cargo workspace,
|
||||
# matching the sibling crates (hook/, vulkan-layer/) in this package.
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
[[bin]]
|
||||
name = "d3d9-present-fixture"
|
||||
path = "src/main.rs"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = {version = "0.62.2", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Direct3D9",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
]}
|
||||
windows-core = "0.62.2"
|
||||
@@ -0,0 +1,417 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn main() {
|
||||
eprintln!("d3d9-present-fixture is only functional on Windows");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn main() -> windows_core::Result<()> {
|
||||
windows_impl::run()
|
||||
}
|
||||
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
mod pattern {
|
||||
pub const BARS: [(u8, u8, u8); 8] = [
|
||||
(255, 255, 255),
|
||||
(255, 255, 0),
|
||||
(0, 255, 255),
|
||||
(0, 255, 0),
|
||||
(255, 0, 255),
|
||||
(255, 0, 0),
|
||||
(0, 0, 255),
|
||||
(0, 0, 0),
|
||||
];
|
||||
|
||||
pub const COUNTER_BLOCK: u32 = 16;
|
||||
|
||||
pub fn bar_colour(x: u32, width: u32) -> (u8, u8, u8) {
|
||||
let width = width.max(1);
|
||||
let index = ((x as u64 * BARS.len() as u64) / width as u64) as usize;
|
||||
BARS[index.min(BARS.len() - 1)]
|
||||
}
|
||||
|
||||
pub fn counter_colour(frame_index: u64) -> (u8, u8, u8) {
|
||||
let low = (frame_index & 0x00FF_FFFF) as u32;
|
||||
(
|
||||
(low & 0xFF) as u8,
|
||||
((low >> 8) & 0xFF) as u8,
|
||||
((low >> 16) & 0xFF) as u8,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod cli {
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PresentMode {
|
||||
Device,
|
||||
SwapChain,
|
||||
}
|
||||
|
||||
pub struct Options {
|
||||
pub frames: Option<u64>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub borderless: bool,
|
||||
pub multisample: bool,
|
||||
pub present_mode: PresentMode,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
frames: None,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
borderless: false,
|
||||
multisample: false,
|
||||
present_mode: PresentMode::Device,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Options {
|
||||
pub fn from_env() -> Self {
|
||||
let mut options = Options::default();
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--frames" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.frames = Some(value);
|
||||
}
|
||||
}
|
||||
"--width" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.width = value;
|
||||
}
|
||||
}
|
||||
"--height" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.height = value;
|
||||
}
|
||||
}
|
||||
"--borderless" => options.borderless = true,
|
||||
"--windowed" => options.borderless = false,
|
||||
"--multisample" => options.multisample = true,
|
||||
"--no-multisample" => options.multisample = false,
|
||||
"--swapchain-present" => options.present_mode = PresentMode::SwapChain,
|
||||
"--device-present" => options.present_mode = PresentMode::Device,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
options
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows_impl {
|
||||
use super::{
|
||||
cli::{Options, PresentMode},
|
||||
pattern,
|
||||
};
|
||||
use std::ptr::null;
|
||||
use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, WPARAM};
|
||||
use windows::Win32::Graphics::Direct3D9::{
|
||||
D3D_SDK_VERSION, D3DADAPTER_DEFAULT, D3DCLEAR_TARGET, D3DCREATE_HARDWARE_VERTEXPROCESSING,
|
||||
D3DCREATE_SOFTWARE_VERTEXPROCESSING, D3DDEVTYPE_HAL, D3DFMT_X8R8G8B8,
|
||||
D3DMULTISAMPLE_2_SAMPLES, D3DMULTISAMPLE_NONE, D3DPRESENT_PARAMETERS, D3DRECT,
|
||||
D3DSWAPEFFECT_DISCARD, Direct3DCreate9, IDirect3D9, IDirect3DDevice9,
|
||||
};
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
CS_HREDRAW, CS_VREDRAW, CW_USEDEFAULT, CreateWindowExW, DefWindowProcW, DispatchMessageW,
|
||||
GetClientRect, MSG, PM_REMOVE, PeekMessageW, PostQuitMessage, RegisterClassExW, SW_SHOW,
|
||||
ShowWindow, TranslateMessage, WM_DESTROY, WM_QUIT, WNDCLASSEXW, WS_OVERLAPPEDWINDOW,
|
||||
WS_POPUP, WS_VISIBLE,
|
||||
};
|
||||
use windows_core::{BOOL, Result, w};
|
||||
|
||||
const D3DERR_DEVICELOST: i32 = 0x8876_0868u32 as i32;
|
||||
|
||||
fn d3dcolor_xrgb(r: u8, g: u8, b: u8) -> u32 {
|
||||
0xFF00_0000 | ((r as u32) << 16) | ((g as u32) << 8) | (b as u32)
|
||||
}
|
||||
|
||||
extern "system" fn wnd_proc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||
unsafe {
|
||||
match msg {
|
||||
WM_DESTROY => {
|
||||
PostQuitMessage(0);
|
||||
LRESULT(0)
|
||||
}
|
||||
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn create_window(width: u32, height: u32, borderless: bool) -> Result<HWND> {
|
||||
unsafe {
|
||||
let instance = GetModuleHandleW(None)?;
|
||||
let class_name = w!("FluxerD3D9Fixture");
|
||||
let wc = WNDCLASSEXW {
|
||||
cbSize: size_of::<WNDCLASSEXW>() as u32,
|
||||
style: CS_HREDRAW | CS_VREDRAW,
|
||||
lpfnWndProc: Some(wnd_proc),
|
||||
hInstance: instance.into(),
|
||||
lpszClassName: class_name,
|
||||
..Default::default()
|
||||
};
|
||||
RegisterClassExW(&wc);
|
||||
let style = if borderless {
|
||||
WS_POPUP | WS_VISIBLE
|
||||
} else {
|
||||
WS_OVERLAPPEDWINDOW | WS_VISIBLE
|
||||
};
|
||||
let hwnd = CreateWindowExW(
|
||||
Default::default(),
|
||||
class_name,
|
||||
w!("Fluxer D3D9 Present Fixture"),
|
||||
style,
|
||||
CW_USEDEFAULT,
|
||||
CW_USEDEFAULT,
|
||||
width as i32,
|
||||
height as i32,
|
||||
None,
|
||||
None,
|
||||
Some(instance.into()),
|
||||
None,
|
||||
)?;
|
||||
let _ = ShowWindow(hwnd, SW_SHOW);
|
||||
Ok(hwnd)
|
||||
}
|
||||
}
|
||||
|
||||
fn present_params(
|
||||
hwnd: HWND,
|
||||
width: u32,
|
||||
height: u32,
|
||||
multisample: bool,
|
||||
) -> D3DPRESENT_PARAMETERS {
|
||||
D3DPRESENT_PARAMETERS {
|
||||
BackBufferWidth: width,
|
||||
BackBufferHeight: height,
|
||||
BackBufferFormat: D3DFMT_X8R8G8B8,
|
||||
BackBufferCount: 1,
|
||||
MultiSampleType: if multisample {
|
||||
D3DMULTISAMPLE_2_SAMPLES
|
||||
} else {
|
||||
D3DMULTISAMPLE_NONE
|
||||
},
|
||||
MultiSampleQuality: 0,
|
||||
SwapEffect: D3DSWAPEFFECT_DISCARD,
|
||||
hDeviceWindow: hwnd,
|
||||
Windowed: BOOL(1),
|
||||
EnableAutoDepthStencil: BOOL(0),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
struct Renderer {
|
||||
_d3d9: IDirect3D9,
|
||||
device: IDirect3DDevice9,
|
||||
hwnd: HWND,
|
||||
width: u32,
|
||||
height: u32,
|
||||
device_lost: bool,
|
||||
multisample: bool,
|
||||
present_mode: PresentMode,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
unsafe fn new(
|
||||
hwnd: HWND,
|
||||
width: u32,
|
||||
height: u32,
|
||||
multisample: bool,
|
||||
present_mode: PresentMode,
|
||||
) -> Result<Self> {
|
||||
unsafe {
|
||||
let d3d9 = Direct3DCreate9(D3D_SDK_VERSION)
|
||||
.ok_or_else(|| windows_core::Error::from_thread())?;
|
||||
|
||||
let mut params = present_params(hwnd, width, height, multisample);
|
||||
|
||||
let mut device: Option<IDirect3DDevice9> = None;
|
||||
let mut created = d3d9
|
||||
.CreateDevice(
|
||||
D3DADAPTER_DEFAULT,
|
||||
D3DDEVTYPE_HAL,
|
||||
hwnd,
|
||||
D3DCREATE_HARDWARE_VERTEXPROCESSING as u32,
|
||||
&mut params,
|
||||
&mut device,
|
||||
)
|
||||
.is_ok();
|
||||
if !created {
|
||||
created = d3d9
|
||||
.CreateDevice(
|
||||
D3DADAPTER_DEFAULT,
|
||||
D3DDEVTYPE_HAL,
|
||||
hwnd,
|
||||
D3DCREATE_SOFTWARE_VERTEXPROCESSING as u32,
|
||||
&mut params,
|
||||
&mut device,
|
||||
)
|
||||
.is_ok();
|
||||
}
|
||||
let device = if created {
|
||||
device.ok_or_else(|| windows_core::Error::from_thread())?
|
||||
} else {
|
||||
return Err(windows_core::Error::from_thread());
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
_d3d9: d3d9,
|
||||
device,
|
||||
hwnd,
|
||||
width,
|
||||
height,
|
||||
device_lost: false,
|
||||
multisample,
|
||||
present_mode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn try_reset(&mut self) {
|
||||
unsafe {
|
||||
let mut params =
|
||||
present_params(self.hwnd, self.width, self.height, self.multisample);
|
||||
if self.device.Reset(&mut params).is_ok() {
|
||||
self.device_lost = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn render_frame(&mut self, frame_index: u64) -> Result<()> {
|
||||
unsafe {
|
||||
if self.device_lost {
|
||||
self.try_reset();
|
||||
if self.device_lost {
|
||||
return self.present();
|
||||
}
|
||||
}
|
||||
|
||||
let width = self.width.max(1);
|
||||
let height = self.height as i32;
|
||||
|
||||
let bars = pattern::BARS.len() as u32;
|
||||
for bar in 0..bars {
|
||||
let x1 = ((bar as u64 * width as u64) / bars as u64) as i32;
|
||||
let x2 = (((bar + 1) as u64 * width as u64) / bars as u64) as i32;
|
||||
if x2 <= x1 {
|
||||
continue;
|
||||
}
|
||||
let mid = ((x1 + x2) / 2).max(0) as u32;
|
||||
let (r, g, b) = pattern::bar_colour(mid, width);
|
||||
let rect = D3DRECT {
|
||||
x1,
|
||||
y1: 0,
|
||||
x2,
|
||||
y2: height,
|
||||
};
|
||||
self.device.Clear(
|
||||
1,
|
||||
&rect,
|
||||
D3DCLEAR_TARGET as u32,
|
||||
d3dcolor_xrgb(r, g, b),
|
||||
1.0,
|
||||
0,
|
||||
)?;
|
||||
}
|
||||
|
||||
let block = pattern::COUNTER_BLOCK as i32;
|
||||
let block_x2 = block.min(width as i32);
|
||||
let block_y2 = block.min(height);
|
||||
if block_x2 > 0 && block_y2 > 0 {
|
||||
let (r, g, b) = pattern::counter_colour(frame_index);
|
||||
let rect = D3DRECT {
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: block_x2,
|
||||
y2: block_y2,
|
||||
};
|
||||
self.device.Clear(
|
||||
1,
|
||||
&rect,
|
||||
D3DCLEAR_TARGET as u32,
|
||||
d3dcolor_xrgb(r, g, b),
|
||||
1.0,
|
||||
0,
|
||||
)?;
|
||||
}
|
||||
|
||||
self.present()
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn present(&mut self) -> Result<()> {
|
||||
unsafe {
|
||||
let result = match self.present_mode {
|
||||
PresentMode::Device => {
|
||||
self.device.Present(null(), null(), HWND::default(), null())
|
||||
}
|
||||
PresentMode::SwapChain => self.device.GetSwapChain(0).and_then(|swap_chain| {
|
||||
swap_chain.Present(null(), null(), HWND::default(), null(), 0)
|
||||
}),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.code().0 == D3DERR_DEVICELOST => {
|
||||
self.device_lost = true;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() -> Result<()> {
|
||||
let options = Options::from_env();
|
||||
unsafe {
|
||||
let hwnd = create_window(options.width, options.height, options.borderless)?;
|
||||
|
||||
let mut rect = Default::default();
|
||||
let _ = GetClientRect(hwnd, &mut rect);
|
||||
let width = ((rect.right - rect.left).max(1)) as u32;
|
||||
let height = ((rect.bottom - rect.top).max(1)) as u32;
|
||||
|
||||
let mut renderer = Renderer::new(
|
||||
hwnd,
|
||||
width,
|
||||
height,
|
||||
options.multisample,
|
||||
options.present_mode,
|
||||
)?;
|
||||
|
||||
println!("HWND={}", hwnd.0 as isize);
|
||||
use std::io::Write;
|
||||
let _ = std::io::stdout().flush();
|
||||
|
||||
let mut frame_index: u64 = 0;
|
||||
let mut msg = MSG::default();
|
||||
loop {
|
||||
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
|
||||
if msg.message == WM_QUIT {
|
||||
return Ok(());
|
||||
}
|
||||
let _ = TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
|
||||
renderer.render_frame(frame_index)?;
|
||||
frame_index += 1;
|
||||
|
||||
if let Some(limit) = options.frames {
|
||||
if frame_index >= limit {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "i686-present-fixture"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"windows",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[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.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core",
|
||||
"windows-future",
|
||||
"windows-numerics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
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-numerics"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
[package]
|
||||
name = "i686-present-fixture"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
publish = false
|
||||
|
||||
# Empty workspace table keeps this fixture out of any parent Cargo workspace,
|
||||
# matching the sibling crates (hook/, vulkan-layer/) in this package.
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
[[bin]]
|
||||
name = "i686-present-fixture"
|
||||
path = "src/main.rs"
|
||||
|
||||
# This fixture is a deterministic Direct3D 11 present fixture whose ONLY purpose
|
||||
# is to be built for the 32-bit (i686 / WOW64) target so the harness can drive
|
||||
# the cross-bitness injection paths (an x64 host injecting the 32-bit hook into a
|
||||
# 32-bit game). The source is target-arch-agnostic -- what makes it "i686" is
|
||||
# building it for `i686-pc-windows-msvc`:
|
||||
#
|
||||
# cargo build --release --manifest-path test-apps/i686-present-fixture/Cargo.toml \
|
||||
# --target i686-pc-windows-msvc
|
||||
#
|
||||
# It also compile-checks for x86_64-pc-windows-msvc so the standard cargo-check
|
||||
# matrix stays uniform across fixtures.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = {version = "0.62.2", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Direct3D",
|
||||
"Win32_Graphics_Direct3D11",
|
||||
"Win32_Graphics_Dxgi",
|
||||
"Win32_Graphics_Dxgi_Common",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
]}
|
||||
windows-core = "0.62.2"
|
||||
@@ -0,0 +1,358 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn main() {
|
||||
eprintln!("i686-present-fixture is only functional on Windows");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn main() -> windows_core::Result<()> {
|
||||
windows_impl::run()
|
||||
}
|
||||
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
mod pattern {
|
||||
pub const BARS: [(u8, u8, u8); 8] = [
|
||||
(255, 255, 255),
|
||||
(255, 255, 0),
|
||||
(0, 255, 255),
|
||||
(0, 255, 0),
|
||||
(255, 0, 255),
|
||||
(255, 0, 0),
|
||||
(0, 0, 255),
|
||||
(0, 0, 0),
|
||||
];
|
||||
|
||||
pub const COUNTER_BLOCK: u32 = 16;
|
||||
|
||||
pub fn bar_colour(x: u32, width: u32) -> (u8, u8, u8) {
|
||||
let width = width.max(1);
|
||||
let index = ((x as u64 * BARS.len() as u64) / width as u64) as usize;
|
||||
BARS[index.min(BARS.len() - 1)]
|
||||
}
|
||||
|
||||
pub fn counter_colour(frame_index: u64) -> (u8, u8, u8) {
|
||||
let low = (frame_index & 0x00FF_FFFF) as u32;
|
||||
(
|
||||
(low & 0xFF) as u8,
|
||||
((low >> 8) & 0xFF) as u8,
|
||||
((low >> 16) & 0xFF) as u8,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod cli {
|
||||
pub struct Options {
|
||||
pub frames: Option<u64>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub borderless: bool,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
frames: None,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
borderless: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Options {
|
||||
pub fn from_env() -> Self {
|
||||
let mut options = Options::default();
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--frames" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.frames = Some(value);
|
||||
}
|
||||
}
|
||||
"--width" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.width = value;
|
||||
}
|
||||
}
|
||||
"--height" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.height = value;
|
||||
}
|
||||
}
|
||||
"--borderless" => options.borderless = true,
|
||||
"--windowed" => options.borderless = false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
options
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows_impl {
|
||||
use super::{cli::Options, pattern};
|
||||
use windows::Win32::Foundation::{HMODULE, HWND, LPARAM, LRESULT, WPARAM};
|
||||
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_HARDWARE;
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_MAPPED_SUBRESOURCE, D3D11_SDK_VERSION,
|
||||
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11RenderTargetView,
|
||||
ID3D11Resource, ID3D11Texture2D,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_ALPHA_MODE_IGNORE, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_SAMPLE_DESC,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::{
|
||||
DXGI_SCALING_STRETCH, DXGI_SWAP_CHAIN_DESC1, DXGI_SWAP_EFFECT_FLIP_DISCARD,
|
||||
DXGI_USAGE_RENDER_TARGET_OUTPUT, IDXGIDevice, IDXGIFactory2, IDXGISwapChain1,
|
||||
};
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
CS_HREDRAW, CS_VREDRAW, CW_USEDEFAULT, CreateWindowExW, DefWindowProcW, DispatchMessageW,
|
||||
GetClientRect, MSG, PM_REMOVE, PeekMessageW, PostQuitMessage, RegisterClassExW, SW_SHOW,
|
||||
ShowWindow, TranslateMessage, WM_DESTROY, WM_QUIT, WNDCLASSEXW, WS_OVERLAPPEDWINDOW,
|
||||
WS_POPUP, WS_VISIBLE,
|
||||
};
|
||||
use windows_core::{Interface, Result, w};
|
||||
|
||||
extern "system" fn wnd_proc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||
unsafe {
|
||||
match msg {
|
||||
WM_DESTROY => {
|
||||
PostQuitMessage(0);
|
||||
LRESULT(0)
|
||||
}
|
||||
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn create_window(width: u32, height: u32, borderless: bool) -> Result<HWND> {
|
||||
unsafe {
|
||||
let instance = GetModuleHandleW(None)?;
|
||||
let class_name = w!("FluxerI686Fixture");
|
||||
let wc = WNDCLASSEXW {
|
||||
cbSize: size_of::<WNDCLASSEXW>() as u32,
|
||||
style: CS_HREDRAW | CS_VREDRAW,
|
||||
lpfnWndProc: Some(wnd_proc),
|
||||
hInstance: instance.into(),
|
||||
lpszClassName: class_name,
|
||||
..Default::default()
|
||||
};
|
||||
RegisterClassExW(&wc);
|
||||
let style = if borderless {
|
||||
WS_POPUP | WS_VISIBLE
|
||||
} else {
|
||||
WS_OVERLAPPEDWINDOW | WS_VISIBLE
|
||||
};
|
||||
let hwnd = CreateWindowExW(
|
||||
Default::default(),
|
||||
class_name,
|
||||
w!("Fluxer i686 (WOW64) Present Fixture"),
|
||||
style,
|
||||
CW_USEDEFAULT,
|
||||
CW_USEDEFAULT,
|
||||
width as i32,
|
||||
height as i32,
|
||||
None,
|
||||
None,
|
||||
Some(instance.into()),
|
||||
None,
|
||||
)?;
|
||||
let _ = ShowWindow(hwnd, SW_SHOW);
|
||||
Ok(hwnd)
|
||||
}
|
||||
}
|
||||
|
||||
struct Renderer {
|
||||
device: ID3D11Device,
|
||||
context: ID3D11DeviceContext,
|
||||
swap_chain: IDXGISwapChain1,
|
||||
rtv: ID3D11RenderTargetView,
|
||||
width: u32,
|
||||
height: u32,
|
||||
staging: ID3D11Texture2D,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
unsafe fn new(hwnd: HWND, width: u32, height: u32) -> Result<Self> {
|
||||
unsafe {
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
let mut context: Option<ID3D11DeviceContext> = None;
|
||||
D3D11CreateDevice(
|
||||
None,
|
||||
D3D_DRIVER_TYPE_HARDWARE,
|
||||
HMODULE::default(),
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
None,
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
None,
|
||||
Some(&mut context),
|
||||
)?;
|
||||
let device = device.expect("D3D11CreateDevice yielded no device");
|
||||
let context = context.expect("D3D11CreateDevice yielded no context");
|
||||
|
||||
let dxgi_device: IDXGIDevice = device.cast()?;
|
||||
let adapter = dxgi_device.GetAdapter()?;
|
||||
let factory: IDXGIFactory2 = adapter.GetParent()?;
|
||||
|
||||
let desc = DXGI_SWAP_CHAIN_DESC1 {
|
||||
Width: width,
|
||||
Height: height,
|
||||
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
Stereo: false.into(),
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT,
|
||||
BufferCount: 2,
|
||||
Scaling: DXGI_SCALING_STRETCH,
|
||||
SwapEffect: DXGI_SWAP_EFFECT_FLIP_DISCARD,
|
||||
AlphaMode: DXGI_ALPHA_MODE_IGNORE,
|
||||
Flags: 0,
|
||||
};
|
||||
let swap_chain =
|
||||
factory.CreateSwapChainForHwnd(&device, hwnd, &desc, None, None)?;
|
||||
|
||||
let rtv = Self::make_rtv(&device, &swap_chain)?;
|
||||
let staging = Self::make_staging(&device, width, height)?;
|
||||
|
||||
Ok(Self {
|
||||
device,
|
||||
context,
|
||||
swap_chain,
|
||||
rtv,
|
||||
width,
|
||||
height,
|
||||
staging,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn make_rtv(
|
||||
device: &ID3D11Device,
|
||||
swap_chain: &IDXGISwapChain1,
|
||||
) -> Result<ID3D11RenderTargetView> {
|
||||
unsafe {
|
||||
let back_buffer: ID3D11Texture2D = swap_chain.GetBuffer(0)?;
|
||||
let resource: ID3D11Resource = back_buffer.cast()?;
|
||||
let mut rtv: Option<ID3D11RenderTargetView> = None;
|
||||
device.CreateRenderTargetView(&resource, None, Some(&mut rtv))?;
|
||||
Ok(rtv.expect("CreateRenderTargetView yielded no view"))
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn make_staging(
|
||||
device: &ID3D11Device,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<ID3D11Texture2D> {
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11_CPU_ACCESS_WRITE, D3D11_TEXTURE2D_DESC, D3D11_USAGE_STAGING,
|
||||
};
|
||||
unsafe {
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: width,
|
||||
Height: height,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_STAGING,
|
||||
BindFlags: 0,
|
||||
CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
|
||||
MiscFlags: 0,
|
||||
};
|
||||
let mut staging: Option<ID3D11Texture2D> = None;
|
||||
device.CreateTexture2D(&desc, None, Some(&mut staging))?;
|
||||
Ok(staging.expect("CreateTexture2D yielded no staging texture"))
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn render_frame(&self, frame_index: u64) -> Result<()> {
|
||||
use windows::Win32::Graphics::Direct3D11::D3D11_MAP_WRITE;
|
||||
unsafe {
|
||||
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
|
||||
self.context
|
||||
.Map(&self.staging, 0, D3D11_MAP_WRITE, 0, Some(&mut mapped))?;
|
||||
let row_pitch = mapped.RowPitch as usize;
|
||||
let base = mapped.pData as *mut u8;
|
||||
let counter = pattern::counter_colour(frame_index);
|
||||
let luma = (frame_index & 0xFF) as u8;
|
||||
for y in 0..self.height {
|
||||
let row = base.add(y as usize * row_pitch);
|
||||
for x in 0..self.width {
|
||||
let (r, g, b) = if x < pattern::COUNTER_BLOCK && y < pattern::COUNTER_BLOCK
|
||||
{
|
||||
counter
|
||||
} else {
|
||||
pattern::bar_colour(x, self.width)
|
||||
};
|
||||
let px = row.add(x as usize * 4);
|
||||
*px = b.saturating_add(luma / 4);
|
||||
*px.add(1) = g;
|
||||
*px.add(2) = r;
|
||||
*px.add(3) = 255;
|
||||
}
|
||||
}
|
||||
self.context.Unmap(&self.staging, 0);
|
||||
|
||||
let back_buffer: ID3D11Texture2D = self.swap_chain.GetBuffer(0)?;
|
||||
let dst: ID3D11Resource = back_buffer.cast()?;
|
||||
let src: ID3D11Resource = self.staging.cast()?;
|
||||
self.context.CopyResource(&dst, &src);
|
||||
let _ = &self.rtv;
|
||||
self.swap_chain.Present(1, Default::default()).ok()?;
|
||||
let _ = &self.device;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() -> Result<()> {
|
||||
let options = Options::from_env();
|
||||
unsafe {
|
||||
let hwnd = create_window(options.width, options.height, options.borderless)?;
|
||||
|
||||
let mut rect = Default::default();
|
||||
let _ = GetClientRect(hwnd, &mut rect);
|
||||
let width = ((rect.right - rect.left).max(1)) as u32;
|
||||
let height = ((rect.bottom - rect.top).max(1)) as u32;
|
||||
|
||||
let renderer = Renderer::new(hwnd, width, height)?;
|
||||
|
||||
println!("HWND={}", hwnd.0 as isize);
|
||||
use std::io::Write;
|
||||
let _ = std::io::stdout().flush();
|
||||
|
||||
let mut frame_index: u64 = 0;
|
||||
let mut msg = MSG::default();
|
||||
loop {
|
||||
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
|
||||
if msg.message == WM_QUIT {
|
||||
return Ok(());
|
||||
}
|
||||
let _ = TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
|
||||
renderer.render_frame(frame_index)?;
|
||||
frame_index += 1;
|
||||
|
||||
if let Some(limit) = options.frames {
|
||||
if frame_index >= limit {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "opengl-swapbuffers-fixture"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"windows",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[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.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core",
|
||||
"windows-future",
|
||||
"windows-numerics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
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-numerics"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
[package]
|
||||
name = "opengl-swapbuffers-fixture"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
publish = false
|
||||
|
||||
# Empty workspace table keeps this fixture out of any parent Cargo workspace,
|
||||
# matching the sibling crates (hook/, vulkan-layer/) in this package.
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
[[bin]]
|
||||
name = "opengl-swapbuffers-fixture"
|
||||
path = "src/main.rs"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = {version = "0.62.2", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_Graphics_OpenGL",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
]}
|
||||
windows-core = "0.62.2"
|
||||
+484
@@ -0,0 +1,484 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn main() {
|
||||
eprintln!("opengl-swapbuffers-fixture is only functional on Windows");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn main() -> windows_core::Result<()> {
|
||||
windows_impl::run()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
mod pattern {
|
||||
pub const BARS: [(u8, u8, u8); 8] = [
|
||||
(255, 255, 255),
|
||||
(255, 255, 0),
|
||||
(0, 255, 255),
|
||||
(0, 255, 0),
|
||||
(255, 0, 255),
|
||||
(255, 0, 0),
|
||||
(0, 0, 255),
|
||||
(0, 0, 0),
|
||||
];
|
||||
|
||||
pub const COUNTER_BLOCK: u32 = 16;
|
||||
|
||||
pub fn bar_colour(x: u32, width: u32) -> (u8, u8, u8) {
|
||||
let width = width.max(1);
|
||||
let index = ((x as u64 * BARS.len() as u64) / width as u64) as usize;
|
||||
BARS[index.min(BARS.len() - 1)]
|
||||
}
|
||||
|
||||
pub fn bar_x_range(index: usize, width: u32) -> (u32, u32) {
|
||||
let width = width.max(1) as u64;
|
||||
let len = BARS.len() as u64;
|
||||
let start = (index as u64 * width) / len;
|
||||
let end = ((index as u64 + 1) * width) / len;
|
||||
(start as u32, end.min(width) as u32)
|
||||
}
|
||||
|
||||
pub fn counter_colour(frame_index: u64) -> (u8, u8, u8) {
|
||||
let low = (frame_index & 0x00FF_FFFF) as u32;
|
||||
(
|
||||
(low & 0xFF) as u8,
|
||||
((low >> 8) & 0xFF) as u8,
|
||||
((low >> 16) & 0xFF) as u8,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn clear_colour(frame_index: u64) -> [f32; 3] {
|
||||
let (r, g, b) = BARS[(frame_index % BARS.len() as u64) as usize];
|
||||
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod cli {
|
||||
pub struct Options {
|
||||
pub frames: Option<u64>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub borderless: bool,
|
||||
pub layer: bool,
|
||||
pub stress_pack_state: bool,
|
||||
pub resize_at: Option<u64>,
|
||||
pub frame_delay_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
frames: None,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
borderless: false,
|
||||
layer: false,
|
||||
stress_pack_state: false,
|
||||
resize_at: None,
|
||||
frame_delay_ms: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Options {
|
||||
pub fn from_env() -> Self {
|
||||
let mut options = Options::default();
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--frames" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.frames = Some(value);
|
||||
}
|
||||
}
|
||||
"--width" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.width = value;
|
||||
}
|
||||
}
|
||||
"--height" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.height = value;
|
||||
}
|
||||
}
|
||||
"--borderless" => options.borderless = true,
|
||||
"--windowed" => options.borderless = false,
|
||||
"--layer" => options.layer = true,
|
||||
"--stress-pack-state" => options.stress_pack_state = true,
|
||||
"--resize-at" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.resize_at = Some(value);
|
||||
}
|
||||
}
|
||||
"--frame-delay-ms" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.frame_delay_ms = value;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
options
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows_impl {
|
||||
use super::cli::Options;
|
||||
use super::pattern;
|
||||
use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, WPARAM};
|
||||
use windows::Win32::Graphics::Gdi::{GetDC, HDC, ReleaseDC, WGL_SWAP_MAIN_PLANE};
|
||||
use windows::Win32::Graphics::OpenGL::{
|
||||
ChoosePixelFormat, GL_COLOR_BUFFER_BIT, GL_FRONT, GL_PACK_ALIGNMENT, GL_PACK_LSB_FIRST,
|
||||
GL_PACK_ROW_LENGTH, GL_PACK_SKIP_PIXELS, GL_PACK_SKIP_ROWS, GL_PACK_SWAP_BYTES,
|
||||
GL_READ_BUFFER, GL_SCISSOR_TEST, PFD_DOUBLEBUFFER, PFD_DRAW_TO_WINDOW, PFD_MAIN_PLANE,
|
||||
PFD_SUPPORT_OPENGL, PFD_TYPE_RGBA, PIXELFORMATDESCRIPTOR, SetPixelFormat, SwapBuffers,
|
||||
glClear, glClearColor, glDisable, glEnable, glGetIntegerv, glPixelStorei, glReadBuffer,
|
||||
glScissor, glViewport, wglCreateContext, wglDeleteContext, wglGetProcAddress,
|
||||
wglMakeCurrent, wglSwapLayerBuffers,
|
||||
};
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
CS_HREDRAW, CS_OWNDC, CS_VREDRAW, CW_USEDEFAULT, CreateWindowExW, DefWindowProcW,
|
||||
DispatchMessageW, GetClientRect, MSG, MoveWindow, PM_REMOVE, PeekMessageW, PostQuitMessage,
|
||||
RegisterClassExW, SW_SHOW, ShowWindow, TranslateMessage, WM_DESTROY, WM_QUIT, WNDCLASSEXW,
|
||||
WS_OVERLAPPEDWINDOW, WS_POPUP, WS_VISIBLE,
|
||||
};
|
||||
use windows_core::{PCSTR, Result, w};
|
||||
|
||||
const GL_PIXEL_PACK_BUFFER: u32 = 0x88EB;
|
||||
const GL_PIXEL_PACK_BUFFER_BINDING: u32 = 0x88ED;
|
||||
const GL_STREAM_READ: u32 = 0x88E1;
|
||||
|
||||
type GlGenBuffersFn = unsafe extern "system" fn(i32, *mut u32);
|
||||
type GlBindBufferFn = unsafe extern "system" fn(u32, u32);
|
||||
type GlBufferDataFn = unsafe extern "system" fn(u32, isize, *const core::ffi::c_void, u32);
|
||||
type GlDeleteBuffersFn = unsafe extern "system" fn(i32, *const u32);
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct BufferProcs {
|
||||
gen_buffers: GlGenBuffersFn,
|
||||
bind_buffer: GlBindBufferFn,
|
||||
buffer_data: GlBufferDataFn,
|
||||
delete_buffers: GlDeleteBuffersFn,
|
||||
}
|
||||
|
||||
struct PackStateStress {
|
||||
procs: Option<BufferProcs>,
|
||||
pbo: u32,
|
||||
}
|
||||
|
||||
impl PackStateStress {
|
||||
unsafe fn new() -> Self {
|
||||
let Some(procs) = (unsafe { BufferProcs::load() }) else {
|
||||
return Self {
|
||||
procs: None,
|
||||
pbo: 0,
|
||||
};
|
||||
};
|
||||
let mut pbo = 0u32;
|
||||
unsafe {
|
||||
(procs.gen_buffers)(1, &mut pbo);
|
||||
}
|
||||
if pbo != 0 {
|
||||
let storage = [0u8; 16];
|
||||
unsafe {
|
||||
(procs.bind_buffer)(GL_PIXEL_PACK_BUFFER, pbo);
|
||||
(procs.buffer_data)(
|
||||
GL_PIXEL_PACK_BUFFER,
|
||||
storage.len() as isize,
|
||||
storage.as_ptr().cast(),
|
||||
GL_STREAM_READ,
|
||||
);
|
||||
(procs.bind_buffer)(GL_PIXEL_PACK_BUFFER, 0);
|
||||
}
|
||||
}
|
||||
Self {
|
||||
procs: Some(procs),
|
||||
pbo,
|
||||
}
|
||||
}
|
||||
|
||||
fn pbo(&self) -> Option<u32> {
|
||||
(self.pbo != 0).then_some(self.pbo)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PackStateStress {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
if let Some(procs) = self.procs {
|
||||
if self.pbo != 0 {
|
||||
(procs.bind_buffer)(GL_PIXEL_PACK_BUFFER, 0);
|
||||
(procs.delete_buffers)(1, &self.pbo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BufferProcs {
|
||||
unsafe fn load() -> Option<Self> {
|
||||
Some(Self {
|
||||
gen_buffers: unsafe { load_gl_proc(b"glGenBuffers\0")? },
|
||||
bind_buffer: unsafe { load_gl_proc(b"glBindBuffer\0")? },
|
||||
buffer_data: unsafe { load_gl_proc(b"glBufferData\0")? },
|
||||
delete_buffers: unsafe { load_gl_proc(b"glDeleteBuffers\0")? },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn load_gl_proc<T>(name: &'static [u8]) -> Option<T> {
|
||||
let proc = unsafe { wglGetProcAddress(PCSTR(name.as_ptr()))? };
|
||||
let address = proc as usize;
|
||||
if address <= 3 || address == usize::MAX {
|
||||
return None;
|
||||
}
|
||||
Some(unsafe { std::mem::transmute_copy::<_, T>(&proc) })
|
||||
}
|
||||
|
||||
extern "system" fn wnd_proc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||
unsafe {
|
||||
match msg {
|
||||
WM_DESTROY => {
|
||||
PostQuitMessage(0);
|
||||
LRESULT(0)
|
||||
}
|
||||
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn create_window(width: u32, height: u32, borderless: bool) -> Result<HWND> {
|
||||
unsafe {
|
||||
let instance = GetModuleHandleW(None)?;
|
||||
let class_name = w!("FluxerOpenGLFixture");
|
||||
let wc = WNDCLASSEXW {
|
||||
cbSize: size_of::<WNDCLASSEXW>() as u32,
|
||||
style: CS_HREDRAW | CS_VREDRAW | CS_OWNDC,
|
||||
lpfnWndProc: Some(wnd_proc),
|
||||
hInstance: instance.into(),
|
||||
lpszClassName: class_name,
|
||||
..Default::default()
|
||||
};
|
||||
RegisterClassExW(&wc);
|
||||
let style = if borderless {
|
||||
WS_POPUP | WS_VISIBLE
|
||||
} else {
|
||||
WS_OVERLAPPEDWINDOW | WS_VISIBLE
|
||||
};
|
||||
let hwnd = CreateWindowExW(
|
||||
Default::default(),
|
||||
class_name,
|
||||
w!("Fluxer OpenGL SwapBuffers Fixture"),
|
||||
style,
|
||||
CW_USEDEFAULT,
|
||||
CW_USEDEFAULT,
|
||||
width as i32,
|
||||
height as i32,
|
||||
None,
|
||||
None,
|
||||
Some(instance.into()),
|
||||
None,
|
||||
)?;
|
||||
let _ = ShowWindow(hwnd, SW_SHOW);
|
||||
Ok(hwnd)
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn set_pixel_format(hdc: HDC) -> Result<()> {
|
||||
unsafe {
|
||||
let mut pfd = PIXELFORMATDESCRIPTOR {
|
||||
nSize: size_of::<PIXELFORMATDESCRIPTOR>() as u16,
|
||||
nVersion: 1,
|
||||
dwFlags: PFD_DRAW_TO_WINDOW | PFD_SUPPORT_OPENGL | PFD_DOUBLEBUFFER,
|
||||
iPixelType: PFD_TYPE_RGBA,
|
||||
cColorBits: 32,
|
||||
cDepthBits: 24,
|
||||
cStencilBits: 8,
|
||||
iLayerType: PFD_MAIN_PLANE.0 as u8,
|
||||
..Default::default()
|
||||
};
|
||||
let format = ChoosePixelFormat(hdc, &pfd);
|
||||
if format == 0 {
|
||||
return Err(windows_core::Error::from_thread());
|
||||
}
|
||||
SetPixelFormat(hdc, format, &mut pfd)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn clear_rect(colour: (u8, u8, u8)) {
|
||||
unsafe {
|
||||
let (r, g, b) = colour;
|
||||
glClearColor(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn render_pattern(frame_index: u64, width: u32, height: u32) {
|
||||
unsafe {
|
||||
glViewport(0, 0, width as i32, height as i32);
|
||||
|
||||
glEnable(GL_SCISSOR_TEST);
|
||||
for index in 0..pattern::BARS.len() {
|
||||
let (start, end) = pattern::bar_x_range(index, width);
|
||||
if end <= start {
|
||||
continue;
|
||||
}
|
||||
glScissor(start as i32, 0, (end - start) as i32, height as i32);
|
||||
clear_rect(pattern::BARS[index]);
|
||||
}
|
||||
|
||||
let block = pattern::COUNTER_BLOCK.min(width).min(height);
|
||||
if block > 0 {
|
||||
let y = height - block;
|
||||
glScissor(0, y as i32, block as i32, block as i32);
|
||||
clear_rect(pattern::counter_colour(frame_index));
|
||||
}
|
||||
|
||||
glDisable(GL_SCISSOR_TEST);
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn client_size(hwnd: HWND) -> (u32, u32) {
|
||||
unsafe {
|
||||
let mut rect = Default::default();
|
||||
let _ = GetClientRect(hwnd, &mut rect);
|
||||
(
|
||||
((rect.right - rect.left).max(1)) as u32,
|
||||
((rect.bottom - rect.top).max(1)) as u32,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn stress_pack_state(stress: &PackStateStress) {
|
||||
unsafe {
|
||||
glReadBuffer(GL_FRONT);
|
||||
glPixelStorei(GL_PACK_ALIGNMENT, 1);
|
||||
glPixelStorei(GL_PACK_ROW_LENGTH, 0);
|
||||
glPixelStorei(GL_PACK_SKIP_PIXELS, 0);
|
||||
glPixelStorei(GL_PACK_SKIP_ROWS, 0);
|
||||
glPixelStorei(GL_PACK_SWAP_BYTES, 1);
|
||||
glPixelStorei(GL_PACK_LSB_FIRST, 1);
|
||||
if let (Some(procs), Some(pbo)) = (stress.procs, stress.pbo()) {
|
||||
(procs.bind_buffer)(GL_PIXEL_PACK_BUFFER, pbo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn assert_stress_pack_state(stress: &PackStateStress) {
|
||||
unsafe {
|
||||
let mut read_buffer = 0i32;
|
||||
let mut pack_alignment = 0i32;
|
||||
let mut pack_row_length = 0i32;
|
||||
let mut pack_skip_pixels = 0i32;
|
||||
let mut pack_skip_rows = 0i32;
|
||||
let mut pack_swap_bytes = 0i32;
|
||||
let mut pack_lsb_first = 0i32;
|
||||
let mut pack_buffer = 0i32;
|
||||
|
||||
glGetIntegerv(GL_READ_BUFFER, &mut read_buffer);
|
||||
glGetIntegerv(GL_PACK_ALIGNMENT, &mut pack_alignment);
|
||||
glGetIntegerv(GL_PACK_ROW_LENGTH, &mut pack_row_length);
|
||||
glGetIntegerv(GL_PACK_SKIP_PIXELS, &mut pack_skip_pixels);
|
||||
glGetIntegerv(GL_PACK_SKIP_ROWS, &mut pack_skip_rows);
|
||||
glGetIntegerv(GL_PACK_SWAP_BYTES, &mut pack_swap_bytes);
|
||||
glGetIntegerv(GL_PACK_LSB_FIRST, &mut pack_lsb_first);
|
||||
glGetIntegerv(GL_PIXEL_PACK_BUFFER_BINDING, &mut pack_buffer);
|
||||
|
||||
assert_eq!(
|
||||
read_buffer as u32, GL_FRONT,
|
||||
"GL_READ_BUFFER was not restored"
|
||||
);
|
||||
assert_eq!(pack_alignment, 1, "GL_PACK_ALIGNMENT was not restored");
|
||||
assert_eq!(pack_row_length, 0, "GL_PACK_ROW_LENGTH was not restored");
|
||||
assert_eq!(pack_skip_pixels, 0, "GL_PACK_SKIP_PIXELS was not restored");
|
||||
assert_eq!(pack_skip_rows, 0, "GL_PACK_SKIP_ROWS was not restored");
|
||||
assert_eq!(pack_swap_bytes, 1, "GL_PACK_SWAP_BYTES was not restored");
|
||||
assert_eq!(pack_lsb_first, 1, "GL_PACK_LSB_FIRST was not restored");
|
||||
if let Some(pbo) = stress.pbo() {
|
||||
assert_eq!(
|
||||
pack_buffer as u32, pbo,
|
||||
"GL_PIXEL_PACK_BUFFER_BINDING was not restored"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() -> Result<()> {
|
||||
let options = Options::from_env();
|
||||
unsafe {
|
||||
let hwnd = create_window(options.width, options.height, options.borderless)?;
|
||||
|
||||
println!("HWND={}", hwnd.0 as isize);
|
||||
use std::io::Write;
|
||||
let _ = std::io::stdout().flush();
|
||||
|
||||
let hdc = GetDC(Some(hwnd));
|
||||
set_pixel_format(hdc)?;
|
||||
let context = wglCreateContext(hdc)?;
|
||||
wglMakeCurrent(hdc, context)?;
|
||||
let pack_state_stress = PackStateStress::new();
|
||||
|
||||
let mut frame_index: u64 = 0;
|
||||
let mut msg = MSG::default();
|
||||
let mut resized = false;
|
||||
'render: loop {
|
||||
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
|
||||
if msg.message == WM_QUIT {
|
||||
break 'render;
|
||||
}
|
||||
let _ = TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
|
||||
if let Some(resize_at) = options.resize_at {
|
||||
if !resized && frame_index >= resize_at {
|
||||
let width = options.width.saturating_add(160).max(1) as i32;
|
||||
let height = options.height.saturating_add(96).max(1) as i32;
|
||||
let _ = MoveWindow(hwnd, 0, 0, width, height, true);
|
||||
resized = true;
|
||||
}
|
||||
}
|
||||
|
||||
let (width, height) = client_size(hwnd);
|
||||
render_pattern(frame_index, width, height);
|
||||
if options.stress_pack_state {
|
||||
stress_pack_state(&pack_state_stress);
|
||||
}
|
||||
|
||||
if options.layer {
|
||||
wglSwapLayerBuffers(hdc, WGL_SWAP_MAIN_PLANE)?;
|
||||
} else {
|
||||
SwapBuffers(hdc)?;
|
||||
}
|
||||
|
||||
if options.stress_pack_state {
|
||||
assert_stress_pack_state(&pack_state_stress);
|
||||
}
|
||||
|
||||
frame_index += 1;
|
||||
if options.frame_delay_ms > 0 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(options.frame_delay_ms));
|
||||
}
|
||||
if let Some(limit) = options.frames {
|
||||
if frame_index >= limit {
|
||||
break 'render;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = wglMakeCurrent(
|
||||
HDC::default(),
|
||||
windows::Win32::Graphics::OpenGL::HGLRC::default(),
|
||||
);
|
||||
let _ = wglDeleteContext(context);
|
||||
ReleaseDC(Some(hwnd), hdc);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "ash"
|
||||
version = "0.38.0+1.3.281"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[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 = "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.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "vulkan-present-fixture"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"windows",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core",
|
||||
"windows-future",
|
||||
"windows-numerics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
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-numerics"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
[package]
|
||||
name = "vulkan-present-fixture"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
publish = false
|
||||
|
||||
# Empty workspace table keeps this fixture out of any parent Cargo workspace,
|
||||
# matching the sibling crates (hook/, vulkan-layer/) in this package.
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
[[bin]]
|
||||
name = "vulkan-present-fixture"
|
||||
path = "src/main.rs"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
ash = {version = "0.38.0", default-features = false, features = ["std", "loaded"]}
|
||||
windows = {version = "0.62.2", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
]}
|
||||
windows-core = "0.62.2"
|
||||
@@ -0,0 +1,681 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn main() {
|
||||
eprintln!("vulkan-present-fixture is only functional on Windows");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn main() {
|
||||
if let Err(error) = windows_impl::run() {
|
||||
eprintln!("vulkan-present-fixture failed: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
mod pattern {
|
||||
pub const BARS: [(u8, u8, u8); 8] = [
|
||||
(255, 255, 255),
|
||||
(255, 255, 0),
|
||||
(0, 255, 255),
|
||||
(0, 255, 0),
|
||||
(255, 0, 255),
|
||||
(255, 0, 0),
|
||||
(0, 0, 255),
|
||||
(0, 0, 0),
|
||||
];
|
||||
|
||||
pub fn clear_colour(frame_index: u64) -> [f32; 4] {
|
||||
let (r, g, b) = BARS[(frame_index % BARS.len() as u64) as usize];
|
||||
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod cli {
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FormatChoice {
|
||||
Bgra,
|
||||
Rgba,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PresentModeChoice {
|
||||
Fifo,
|
||||
Mailbox,
|
||||
Immediate,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PresentWaitChoice {
|
||||
Semaphore,
|
||||
None,
|
||||
}
|
||||
|
||||
pub struct Options {
|
||||
pub frames: Option<u64>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub borderless: bool,
|
||||
pub format: FormatChoice,
|
||||
pub present_mode: PresentModeChoice,
|
||||
pub present_wait: PresentWaitChoice,
|
||||
pub resize_at: Option<u64>,
|
||||
pub resize_width: Option<u32>,
|
||||
pub resize_height: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
frames: None,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
borderless: false,
|
||||
format: FormatChoice::Bgra,
|
||||
present_mode: PresentModeChoice::Fifo,
|
||||
present_wait: PresentWaitChoice::Semaphore,
|
||||
resize_at: None,
|
||||
resize_width: None,
|
||||
resize_height: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Options {
|
||||
pub fn from_env() -> Self {
|
||||
let mut options = Options::default();
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--frames" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.frames = Some(value);
|
||||
}
|
||||
}
|
||||
"--width" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.width = value;
|
||||
}
|
||||
}
|
||||
"--height" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.height = value;
|
||||
}
|
||||
}
|
||||
"--borderless" => options.borderless = true,
|
||||
"--windowed" => options.borderless = false,
|
||||
"--format" => {
|
||||
if let Some(value) = args.next() {
|
||||
options.format = match value.to_ascii_lowercase().as_str() {
|
||||
"rgba" => FormatChoice::Rgba,
|
||||
_ => FormatChoice::Bgra,
|
||||
};
|
||||
}
|
||||
}
|
||||
"--present-mode" => {
|
||||
if let Some(value) = args.next() {
|
||||
options.present_mode = match value.to_ascii_lowercase().as_str() {
|
||||
"mailbox" => PresentModeChoice::Mailbox,
|
||||
"immediate" => PresentModeChoice::Immediate,
|
||||
_ => PresentModeChoice::Fifo,
|
||||
};
|
||||
}
|
||||
}
|
||||
"--present-wait" => {
|
||||
if let Some(value) = args.next() {
|
||||
options.present_wait = match value.to_ascii_lowercase().as_str() {
|
||||
"none" => PresentWaitChoice::None,
|
||||
_ => PresentWaitChoice::Semaphore,
|
||||
};
|
||||
}
|
||||
}
|
||||
"--resize-at" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.resize_at = Some(value);
|
||||
}
|
||||
}
|
||||
"--resize-width" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.resize_width = Some(value);
|
||||
}
|
||||
}
|
||||
"--resize-height" => {
|
||||
if let Some(value) = args.next().and_then(|v| v.parse().ok()) {
|
||||
options.resize_height = Some(value);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
options
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows_impl {
|
||||
use super::cli::{FormatChoice, Options, PresentModeChoice, PresentWaitChoice};
|
||||
use super::pattern;
|
||||
use ash::{khr, vk};
|
||||
use std::error::Error;
|
||||
use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM};
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
CS_HREDRAW, CS_VREDRAW, CW_USEDEFAULT, CreateWindowExW, DefWindowProcW, DispatchMessageW,
|
||||
GetClientRect, MSG, MoveWindow, PM_REMOVE, PeekMessageW, PostQuitMessage, RegisterClassExW,
|
||||
SW_SHOW, ShowWindow, TranslateMessage, WM_DESTROY, WM_QUIT, WNDCLASSEXW,
|
||||
WS_OVERLAPPEDWINDOW, WS_POPUP, WS_VISIBLE,
|
||||
};
|
||||
use windows::core::w;
|
||||
|
||||
type DynError = Box<dyn Error>;
|
||||
|
||||
extern "system" fn wnd_proc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||
unsafe {
|
||||
match msg {
|
||||
WM_DESTROY => {
|
||||
PostQuitMessage(0);
|
||||
LRESULT(0)
|
||||
}
|
||||
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn create_window(
|
||||
width: u32,
|
||||
height: u32,
|
||||
borderless: bool,
|
||||
) -> Result<(HWND, isize), DynError> {
|
||||
unsafe {
|
||||
let instance = GetModuleHandleW(None)?;
|
||||
let class_name = w!("FluxerVulkanFixture");
|
||||
let wc = WNDCLASSEXW {
|
||||
cbSize: size_of::<WNDCLASSEXW>() as u32,
|
||||
style: CS_HREDRAW | CS_VREDRAW,
|
||||
lpfnWndProc: Some(wnd_proc),
|
||||
hInstance: instance.into(),
|
||||
lpszClassName: class_name,
|
||||
..Default::default()
|
||||
};
|
||||
RegisterClassExW(&wc);
|
||||
let style = if borderless {
|
||||
WS_POPUP | WS_VISIBLE
|
||||
} else {
|
||||
WS_OVERLAPPEDWINDOW | WS_VISIBLE
|
||||
};
|
||||
let hwnd = CreateWindowExW(
|
||||
Default::default(),
|
||||
class_name,
|
||||
w!("Fluxer Vulkan Present Fixture"),
|
||||
style,
|
||||
CW_USEDEFAULT,
|
||||
CW_USEDEFAULT,
|
||||
width as i32,
|
||||
height as i32,
|
||||
None,
|
||||
None,
|
||||
Some(instance.into()),
|
||||
None,
|
||||
)?;
|
||||
let _ = ShowWindow(hwnd, SW_SHOW);
|
||||
Ok((hwnd, instance.0 as isize))
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn client_extent(hwnd: HWND) -> vk::Extent2D {
|
||||
unsafe {
|
||||
let mut rect = RECT::default();
|
||||
let _ = GetClientRect(hwnd, &mut rect);
|
||||
vk::Extent2D {
|
||||
width: (rect.right - rect.left).max(1) as u32,
|
||||
height: (rect.bottom - rect.top).max(1) as u32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn resize_window(hwnd: HWND, width: u32, height: u32) {
|
||||
unsafe {
|
||||
let _ = MoveWindow(hwnd, 0, 0, width as i32, height as i32, true);
|
||||
}
|
||||
}
|
||||
|
||||
fn pick_surface_format(
|
||||
formats: &[vk::SurfaceFormatKHR],
|
||||
choice: FormatChoice,
|
||||
) -> Result<vk::SurfaceFormatKHR, DynError> {
|
||||
let preferred: [vk::Format; 2] = match choice {
|
||||
FormatChoice::Bgra => [vk::Format::B8G8R8A8_UNORM, vk::Format::B8G8R8A8_SRGB],
|
||||
FormatChoice::Rgba => [vk::Format::R8G8B8A8_UNORM, vk::Format::R8G8B8A8_SRGB],
|
||||
};
|
||||
let any_8bit = [
|
||||
vk::Format::B8G8R8A8_UNORM,
|
||||
vk::Format::R8G8B8A8_UNORM,
|
||||
vk::Format::B8G8R8A8_SRGB,
|
||||
vk::Format::R8G8B8A8_SRGB,
|
||||
];
|
||||
formats
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|f| preferred.contains(&f.format))
|
||||
.or_else(|| {
|
||||
formats
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|f| any_8bit.contains(&f.format))
|
||||
})
|
||||
.or_else(|| formats.first().copied())
|
||||
.ok_or_else(|| "surface advertised no formats".into())
|
||||
}
|
||||
|
||||
fn pick_present_mode(
|
||||
available: &[vk::PresentModeKHR],
|
||||
choice: PresentModeChoice,
|
||||
) -> vk::PresentModeKHR {
|
||||
let wanted = match choice {
|
||||
PresentModeChoice::Fifo => vk::PresentModeKHR::FIFO,
|
||||
PresentModeChoice::Mailbox => vk::PresentModeKHR::MAILBOX,
|
||||
PresentModeChoice::Immediate => vk::PresentModeKHR::IMMEDIATE,
|
||||
};
|
||||
if available.contains(&wanted) {
|
||||
wanted
|
||||
} else {
|
||||
vk::PresentModeKHR::FIFO
|
||||
}
|
||||
}
|
||||
|
||||
struct Swapchain {
|
||||
handle: vk::SwapchainKHR,
|
||||
images: Vec<vk::Image>,
|
||||
command_buffers: Vec<vk::CommandBuffer>,
|
||||
extent: vk::Extent2D,
|
||||
format: vk::Format,
|
||||
}
|
||||
|
||||
impl Swapchain {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
unsafe fn create(
|
||||
device: &ash::Device,
|
||||
swapchain_device: &khr::swapchain::Device,
|
||||
surface_instance: &khr::surface::Instance,
|
||||
physical_device: vk::PhysicalDevice,
|
||||
surface: vk::SurfaceKHR,
|
||||
command_pool: vk::CommandPool,
|
||||
surface_format: vk::SurfaceFormatKHR,
|
||||
present_mode: vk::PresentModeKHR,
|
||||
requested_extent: vk::Extent2D,
|
||||
old: vk::SwapchainKHR,
|
||||
) -> Result<Self, DynError> {
|
||||
unsafe {
|
||||
let surface_caps = surface_instance
|
||||
.get_physical_device_surface_capabilities(physical_device, surface)?;
|
||||
|
||||
let extent = if surface_caps.current_extent.width != u32::MAX {
|
||||
surface_caps.current_extent
|
||||
} else {
|
||||
vk::Extent2D {
|
||||
width: requested_extent.width.clamp(
|
||||
surface_caps.min_image_extent.width,
|
||||
surface_caps.max_image_extent.width,
|
||||
),
|
||||
height: requested_extent.height.clamp(
|
||||
surface_caps.min_image_extent.height,
|
||||
surface_caps.max_image_extent.height,
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
let image_count =
|
||||
surface_caps
|
||||
.min_image_count
|
||||
.max(2)
|
||||
.min(if surface_caps.max_image_count == 0 {
|
||||
u32::MAX
|
||||
} else {
|
||||
surface_caps.max_image_count
|
||||
});
|
||||
|
||||
let swapchain_info = vk::SwapchainCreateInfoKHR::default()
|
||||
.surface(surface)
|
||||
.min_image_count(image_count)
|
||||
.image_format(surface_format.format)
|
||||
.image_color_space(surface_format.color_space)
|
||||
.image_extent(extent)
|
||||
.image_array_layers(1)
|
||||
.image_usage(
|
||||
vk::ImageUsageFlags::COLOR_ATTACHMENT | vk::ImageUsageFlags::TRANSFER_DST,
|
||||
)
|
||||
.image_sharing_mode(vk::SharingMode::EXCLUSIVE)
|
||||
.pre_transform(surface_caps.current_transform)
|
||||
.composite_alpha(vk::CompositeAlphaFlagsKHR::OPAQUE)
|
||||
.present_mode(present_mode)
|
||||
.clipped(true)
|
||||
.old_swapchain(old);
|
||||
let handle = swapchain_device.create_swapchain(&swapchain_info, None)?;
|
||||
let images = swapchain_device.get_swapchain_images(handle)?;
|
||||
|
||||
let alloc_info = vk::CommandBufferAllocateInfo::default()
|
||||
.command_pool(command_pool)
|
||||
.level(vk::CommandBufferLevel::PRIMARY)
|
||||
.command_buffer_count(images.len() as u32);
|
||||
let command_buffers = device.allocate_command_buffers(&alloc_info)?;
|
||||
|
||||
Ok(Self {
|
||||
handle,
|
||||
images,
|
||||
command_buffers,
|
||||
extent,
|
||||
format: surface_format.format,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn destroy(
|
||||
self,
|
||||
device: &ash::Device,
|
||||
swapchain_device: &khr::swapchain::Device,
|
||||
command_pool: vk::CommandPool,
|
||||
) {
|
||||
unsafe {
|
||||
if !self.command_buffers.is_empty() {
|
||||
device.free_command_buffers(command_pool, &self.command_buffers);
|
||||
}
|
||||
swapchain_device.destroy_swapchain(self.handle, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() -> Result<(), DynError> {
|
||||
let options = Options::from_env();
|
||||
unsafe {
|
||||
let (hwnd, hinstance) =
|
||||
create_window(options.width, options.height, options.borderless)?;
|
||||
|
||||
println!("HWND={}", hwnd.0 as isize);
|
||||
use std::io::Write;
|
||||
let _ = std::io::stdout().flush();
|
||||
|
||||
let entry = ash::Entry::load()?;
|
||||
|
||||
let app_name = c"fluxer-vulkan-present-fixture";
|
||||
let app_info = vk::ApplicationInfo::default()
|
||||
.application_name(app_name)
|
||||
.api_version(vk::API_VERSION_1_1);
|
||||
let instance_extensions = [
|
||||
khr::surface::NAME.as_ptr(),
|
||||
khr::win32_surface::NAME.as_ptr(),
|
||||
];
|
||||
let instance_info = vk::InstanceCreateInfo::default()
|
||||
.application_info(&app_info)
|
||||
.enabled_extension_names(&instance_extensions);
|
||||
let instance = entry.create_instance(&instance_info, None)?;
|
||||
|
||||
let surface_instance = khr::surface::Instance::new(&entry, &instance);
|
||||
let win32_surface = khr::win32_surface::Instance::new(&entry, &instance);
|
||||
|
||||
let surface_info = vk::Win32SurfaceCreateInfoKHR::default()
|
||||
.hinstance(hinstance)
|
||||
.hwnd(hwnd.0 as isize);
|
||||
let surface = win32_surface.create_win32_surface(&surface_info, None)?;
|
||||
|
||||
let physical_devices = instance.enumerate_physical_devices()?;
|
||||
let (physical_device, queue_family_index) = physical_devices
|
||||
.iter()
|
||||
.find_map(|&physical_device| {
|
||||
let families =
|
||||
instance.get_physical_device_queue_family_properties(physical_device);
|
||||
families.iter().enumerate().find_map(|(index, family)| {
|
||||
let index = index as u32;
|
||||
let graphics = family.queue_flags.contains(vk::QueueFlags::GRAPHICS);
|
||||
let present = surface_instance
|
||||
.get_physical_device_surface_support(physical_device, index, surface)
|
||||
.unwrap_or(false);
|
||||
(graphics && present).then_some((physical_device, index))
|
||||
})
|
||||
})
|
||||
.ok_or("no Vulkan device with a graphics+present queue family")?;
|
||||
|
||||
let queue_priorities = [1.0_f32];
|
||||
let queue_info = vk::DeviceQueueCreateInfo::default()
|
||||
.queue_family_index(queue_family_index)
|
||||
.queue_priorities(&queue_priorities);
|
||||
let device_extensions = [khr::swapchain::NAME.as_ptr()];
|
||||
let queue_infos = [queue_info];
|
||||
let device_info = vk::DeviceCreateInfo::default()
|
||||
.queue_create_infos(&queue_infos)
|
||||
.enabled_extension_names(&device_extensions);
|
||||
let device = instance.create_device(physical_device, &device_info, None)?;
|
||||
let queue = device.get_device_queue(queue_family_index, 0);
|
||||
let swapchain_device = khr::swapchain::Device::new(&instance, &device);
|
||||
|
||||
let formats =
|
||||
surface_instance.get_physical_device_surface_formats(physical_device, surface)?;
|
||||
let surface_format = pick_surface_format(&formats, options.format)?;
|
||||
let available_present_modes = surface_instance
|
||||
.get_physical_device_surface_present_modes(physical_device, surface)?;
|
||||
let present_mode = pick_present_mode(&available_present_modes, options.present_mode);
|
||||
eprintln!(
|
||||
"vulkan-present-fixture: vkFormat={} vkColorSpace={} vkPresentMode={}",
|
||||
surface_format.format.as_raw(),
|
||||
surface_format.color_space.as_raw(),
|
||||
present_mode.as_raw()
|
||||
);
|
||||
|
||||
let pool_info = vk::CommandPoolCreateInfo::default()
|
||||
.queue_family_index(queue_family_index)
|
||||
.flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER);
|
||||
let command_pool = device.create_command_pool(&pool_info, None)?;
|
||||
|
||||
let mut swapchain = Swapchain::create(
|
||||
&device,
|
||||
&swapchain_device,
|
||||
&surface_instance,
|
||||
physical_device,
|
||||
surface,
|
||||
command_pool,
|
||||
surface_format,
|
||||
present_mode,
|
||||
client_extent(hwnd),
|
||||
vk::SwapchainKHR::null(),
|
||||
)?;
|
||||
|
||||
let image_available =
|
||||
device.create_semaphore(&vk::SemaphoreCreateInfo::default(), None)?;
|
||||
let render_finished =
|
||||
device.create_semaphore(&vk::SemaphoreCreateInfo::default(), None)?;
|
||||
let in_flight = device.create_fence(
|
||||
&vk::FenceCreateInfo::default().flags(vk::FenceCreateFlags::SIGNALED),
|
||||
None,
|
||||
)?;
|
||||
|
||||
let subresource_range = vk::ImageSubresourceRange {
|
||||
aspect_mask: vk::ImageAspectFlags::COLOR,
|
||||
base_mip_level: 0,
|
||||
level_count: 1,
|
||||
base_array_layer: 0,
|
||||
layer_count: 1,
|
||||
};
|
||||
|
||||
let recreate_swapchain = |swapchain: &mut Swapchain| -> Result<(), DynError> {
|
||||
device.device_wait_idle()?;
|
||||
let new = Swapchain::create(
|
||||
&device,
|
||||
&swapchain_device,
|
||||
&surface_instance,
|
||||
physical_device,
|
||||
surface,
|
||||
command_pool,
|
||||
surface_format,
|
||||
present_mode,
|
||||
client_extent(hwnd),
|
||||
swapchain.handle,
|
||||
)?;
|
||||
eprintln!(
|
||||
"vulkan-present-fixture: swapchain recreated extent={}x{} vkFormat={}",
|
||||
new.extent.width,
|
||||
new.extent.height,
|
||||
new.format.as_raw()
|
||||
);
|
||||
let old = std::mem::replace(swapchain, new);
|
||||
old.destroy(&device, &swapchain_device, command_pool);
|
||||
Ok(())
|
||||
};
|
||||
|
||||
let mut frame_index: u64 = 0;
|
||||
let mut resized = false;
|
||||
let mut msg = MSG::default();
|
||||
'render: loop {
|
||||
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
|
||||
if msg.message == WM_QUIT {
|
||||
break 'render;
|
||||
}
|
||||
let _ = TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
|
||||
if let Some(at) = options.resize_at {
|
||||
if !resized && frame_index >= at {
|
||||
if let (Some(width), Some(height)) =
|
||||
(options.resize_width, options.resize_height)
|
||||
{
|
||||
resize_window(hwnd, width, height);
|
||||
}
|
||||
recreate_swapchain(&mut swapchain)?;
|
||||
resized = true;
|
||||
}
|
||||
}
|
||||
|
||||
device.wait_for_fences(&[in_flight], true, u64::MAX)?;
|
||||
|
||||
let (image_index, suboptimal) = match swapchain_device.acquire_next_image(
|
||||
swapchain.handle,
|
||||
u64::MAX,
|
||||
image_available,
|
||||
vk::Fence::null(),
|
||||
) {
|
||||
Ok(value) => value,
|
||||
Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => {
|
||||
recreate_swapchain(&mut swapchain)?;
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
|
||||
device.reset_fences(&[in_flight])?;
|
||||
|
||||
let command_buffer = swapchain.command_buffers[image_index as usize];
|
||||
let image = swapchain.images[image_index as usize];
|
||||
device
|
||||
.reset_command_buffer(command_buffer, vk::CommandBufferResetFlags::empty())?;
|
||||
device.begin_command_buffer(
|
||||
command_buffer,
|
||||
&vk::CommandBufferBeginInfo::default()
|
||||
.flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT),
|
||||
)?;
|
||||
|
||||
let to_transfer = vk::ImageMemoryBarrier::default()
|
||||
.src_access_mask(vk::AccessFlags::empty())
|
||||
.dst_access_mask(vk::AccessFlags::TRANSFER_WRITE)
|
||||
.old_layout(vk::ImageLayout::UNDEFINED)
|
||||
.new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
|
||||
.src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
|
||||
.dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
|
||||
.image(image)
|
||||
.subresource_range(subresource_range);
|
||||
device.cmd_pipeline_barrier(
|
||||
command_buffer,
|
||||
vk::PipelineStageFlags::TOP_OF_PIPE,
|
||||
vk::PipelineStageFlags::TRANSFER,
|
||||
vk::DependencyFlags::empty(),
|
||||
&[],
|
||||
&[],
|
||||
&[to_transfer],
|
||||
);
|
||||
|
||||
let clear = pattern::clear_colour(frame_index);
|
||||
let clear_value = vk::ClearColorValue { float32: clear };
|
||||
device.cmd_clear_color_image(
|
||||
command_buffer,
|
||||
image,
|
||||
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
|
||||
&clear_value,
|
||||
&[subresource_range],
|
||||
);
|
||||
|
||||
let to_present = vk::ImageMemoryBarrier::default()
|
||||
.src_access_mask(vk::AccessFlags::TRANSFER_WRITE)
|
||||
.dst_access_mask(vk::AccessFlags::empty())
|
||||
.old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
|
||||
.new_layout(vk::ImageLayout::PRESENT_SRC_KHR)
|
||||
.src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
|
||||
.dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
|
||||
.image(image)
|
||||
.subresource_range(subresource_range);
|
||||
device.cmd_pipeline_barrier(
|
||||
command_buffer,
|
||||
vk::PipelineStageFlags::TRANSFER,
|
||||
vk::PipelineStageFlags::BOTTOM_OF_PIPE,
|
||||
vk::DependencyFlags::empty(),
|
||||
&[],
|
||||
&[],
|
||||
&[to_present],
|
||||
);
|
||||
device.end_command_buffer(command_buffer)?;
|
||||
|
||||
let wait_semaphores = [image_available];
|
||||
let wait_stages = [vk::PipelineStageFlags::TRANSFER];
|
||||
let command_buffers = [command_buffer];
|
||||
let mut submit = vk::SubmitInfo::default()
|
||||
.wait_semaphores(&wait_semaphores)
|
||||
.wait_dst_stage_mask(&wait_stages)
|
||||
.command_buffers(&command_buffers);
|
||||
let signal_semaphores = [render_finished];
|
||||
if options.present_wait == PresentWaitChoice::Semaphore {
|
||||
submit = submit.signal_semaphores(&signal_semaphores);
|
||||
}
|
||||
device.queue_submit(queue, &[submit], in_flight)?;
|
||||
|
||||
let swapchains = [swapchain.handle];
|
||||
let image_indices = [image_index];
|
||||
if options.present_wait == PresentWaitChoice::None {
|
||||
device.wait_for_fences(&[in_flight], true, u64::MAX)?;
|
||||
}
|
||||
let mut present_info = vk::PresentInfoKHR::default()
|
||||
.swapchains(&swapchains)
|
||||
.image_indices(&image_indices);
|
||||
if options.present_wait == PresentWaitChoice::Semaphore {
|
||||
present_info = present_info.wait_semaphores(&signal_semaphores);
|
||||
}
|
||||
let present_result = swapchain_device.queue_present(queue, &present_info);
|
||||
let needs_recreate = match present_result {
|
||||
Ok(present_suboptimal) => present_suboptimal || suboptimal,
|
||||
Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => true,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if needs_recreate {
|
||||
recreate_swapchain(&mut swapchain)?;
|
||||
}
|
||||
|
||||
frame_index += 1;
|
||||
if let Some(limit) = options.frames {
|
||||
if frame_index >= limit {
|
||||
break 'render;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = device.device_wait_idle();
|
||||
device.destroy_fence(in_flight, None);
|
||||
device.destroy_semaphore(render_finished, None);
|
||||
device.destroy_semaphore(image_available, None);
|
||||
swapchain.destroy(&device, &swapchain_device, command_pool);
|
||||
device.destroy_command_pool(command_pool, None);
|
||||
surface_instance.destroy_surface(surface, None);
|
||||
device.destroy_device(None);
|
||||
instance.destroy_instance(None);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#![cfg(target_os = "windows")]
|
||||
use fluxer_win_game_capture::{EncoderAttachError, EncoderAttachment};
|
||||
use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_FEATURE_LEVEL_11_0};
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
D3D11_CREATE_DEVICE_VIDEO_SUPPORT, D3D11_RESOURCE_MISC_SHARED, D3D11_SDK_VERSION,
|
||||
D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, D3D11CreateDevice, ID3D11Device,
|
||||
ID3D11DeviceContext, ID3D11Multithread, ID3D11Texture2D,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_SAMPLE_DESC};
|
||||
use windows::core::Interface;
|
||||
|
||||
fn try_create_device() -> Option<(ID3D11Device, ID3D11DeviceContext)> {
|
||||
let feature_levels = [D3D_FEATURE_LEVEL_11_0];
|
||||
let flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT | D3D11_CREATE_DEVICE_VIDEO_SUPPORT;
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
let mut context: Option<ID3D11DeviceContext> = None;
|
||||
let result = unsafe {
|
||||
D3D11CreateDevice(
|
||||
None,
|
||||
D3D_DRIVER_TYPE_HARDWARE,
|
||||
Default::default(),
|
||||
flags,
|
||||
Some(&feature_levels),
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
None,
|
||||
Some(&mut context),
|
||||
)
|
||||
};
|
||||
if result.is_err() {
|
||||
return None;
|
||||
}
|
||||
let dev = device?;
|
||||
let ctx = context?;
|
||||
if let Ok(mt) = dev.cast::<ID3D11Multithread>() {
|
||||
let _ = unsafe { mt.SetMultithreadProtected(true) };
|
||||
}
|
||||
Some((dev, ctx))
|
||||
}
|
||||
|
||||
fn create_bgra_capture_texture(
|
||||
device: &ID3D11Device,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Option<ID3D11Texture2D> {
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: width,
|
||||
Height: height,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
|
||||
CPUAccessFlags: 0,
|
||||
MiscFlags: D3D11_RESOURCE_MISC_SHARED.0 as u32,
|
||||
};
|
||||
let mut tex = None;
|
||||
unsafe { device.CreateTexture2D(&desc, None, Some(&mut tex)) }.ok()?;
|
||||
tex
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_attach_construct_and_detach() {
|
||||
let _device = match try_create_device() {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
eprintln!("skip: no D3D11 device available");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let attach = match EncoderAttachment::try_new(640, 480) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
eprintln!("skip: EncoderAttachment init failed: {e:?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
assert!(attach.is_attached(), "attached after construction");
|
||||
assert_eq!(attach.width(), 640, "width preserved");
|
||||
assert_eq!(attach.height(), 480, "height preserved");
|
||||
assert_eq!(attach.capacity(), 8, "capacity matches RING_SIZE");
|
||||
let stats = attach.stats();
|
||||
assert_eq!(stats.frames_submitted, 0, "fresh attach: no submissions");
|
||||
assert_eq!(stats.failed_blits, 0, "fresh attach: no failed blits");
|
||||
attach.detach();
|
||||
assert!(!attach.is_attached(), "post-detach not attached");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_capture_frame_with_blit_records_stats() {
|
||||
let (device, _context) = match try_create_device() {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
eprintln!("skip: no D3D11 device available");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let attach = match EncoderAttachment::try_new(640, 480) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
eprintln!("skip: attach init failed: {e:?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let capture = match create_bgra_capture_texture(&device, 640, 480) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
eprintln!("skip: capture texture create failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let result = attach.submit_capture_frame_with_blit(&capture, 640, 480);
|
||||
let stats = attach.stats();
|
||||
match result {
|
||||
Ok(()) => {
|
||||
assert!(
|
||||
stats.frames_submitted == 1,
|
||||
"expected 1 submission, got {}",
|
||||
stats.frames_submitted
|
||||
);
|
||||
assert!(
|
||||
stats.failed_blits == 0,
|
||||
"expected 0 failed blits, got {}",
|
||||
stats.failed_blits
|
||||
);
|
||||
}
|
||||
Err(EncoderAttachError::BlitFailed) => {
|
||||
assert!(
|
||||
stats.failed_blits >= 1,
|
||||
"expected >=1 failed blits, got {}",
|
||||
stats.failed_blits
|
||||
);
|
||||
}
|
||||
Err(EncoderAttachError::DeviceUnavailable) => {
|
||||
eprintln!("device unavailable from ring backend (driver constraint)");
|
||||
}
|
||||
Err(other) => {
|
||||
eprintln!("submit_capture_frame_with_blit unexpected error: {other:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_notify_back_pressure_increments_drops_after_capacity() {
|
||||
let _device = match try_create_device() {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
eprintln!("skip: no D3D11 device available");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let attach = match EncoderAttachment::try_new(64, 64) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
eprintln!("skip: attach init failed: {e:?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let total = (attach.capacity() as u64) * 2;
|
||||
for _ in 0..total {
|
||||
attach.submit_notify().expect("notify ok");
|
||||
}
|
||||
let stats = attach.stats();
|
||||
assert!(
|
||||
stats.frames_submitted >= attach.capacity() as u64,
|
||||
"many submissions accepted"
|
||||
);
|
||||
assert!(stats.frames_dropped > 0, "back pressure drops oldest");
|
||||
assert_eq!(
|
||||
stats.ring_full_events, stats.frames_dropped,
|
||||
"drops==ring_full_events"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "ash"
|
||||
version = "0.38.0+1.3.281"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
|
||||
|
||||
[[package]]
|
||||
name = "ash-layer"
|
||||
version = "0.0.2+v0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedca11308198ffa300ad1a981c10ee176e44c8ff6ee7c6883a062608b9779a5"
|
||||
dependencies = [
|
||||
"ash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
"hashbrown",
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluxer_vulkan_layer"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"ash-layer",
|
||||
"dashmap",
|
||||
"once_cell",
|
||||
"windows",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[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.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core",
|
||||
"windows-future",
|
||||
"windows-numerics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
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-numerics"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "fluxer_vulkan_layer"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
ash = {version = "0.38.0", default-features = false, features = ["std", "debug"]}
|
||||
ash-layer = "0.0.2"
|
||||
dashmap = "6.2.1"
|
||||
once_cell = "1.21.4"
|
||||
windows = {version = "0.62.2", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Direct3D",
|
||||
"Win32_Graphics_Direct3D11",
|
||||
"Win32_Graphics_Dxgi",
|
||||
"Win32_Graphics_Dxgi_Common",
|
||||
]}
|
||||
windows-sys = {version = "0.61.2", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Performance",
|
||||
"Win32_System_Threading",
|
||||
]}
|
||||
@@ -0,0 +1,138 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use ash::vk;
|
||||
use windows::Win32::Graphics::{
|
||||
Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN, D3D_DRIVER_TYPE_WARP},
|
||||
Direct3D11::{
|
||||
D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
D3D11_RESOURCE_MISC_SHARED, D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT,
|
||||
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D,
|
||||
},
|
||||
Dxgi::{
|
||||
Common::{
|
||||
DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_B8G8R8A8_UNORM_SRGB,
|
||||
DXGI_FORMAT_R8G8B8A8_UNORM, DXGI_FORMAT_R8G8B8A8_UNORM_SRGB,
|
||||
DXGI_FORMAT_R10G10B10A2_UNORM, DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_SAMPLE_DESC,
|
||||
},
|
||||
IDXGIResource,
|
||||
},
|
||||
};
|
||||
use windows::core::Interface;
|
||||
|
||||
use crate::game_capture_abi::{GAME_CAPTURE_FLAG_HDR, GAME_CAPTURE_FLAG_TEN_BIT};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct InteropFormat {
|
||||
pub vk_format: vk::Format,
|
||||
pub dxgi_format: DXGI_FORMAT,
|
||||
pub capture_flags: u32,
|
||||
}
|
||||
|
||||
pub fn interop_format(format: vk::Format) -> Option<InteropFormat> {
|
||||
let (dxgi_format, capture_flags) = match format {
|
||||
vk::Format::B8G8R8A8_UNORM => (DXGI_FORMAT_B8G8R8A8_UNORM, 0),
|
||||
vk::Format::B8G8R8A8_SRGB => (DXGI_FORMAT_B8G8R8A8_UNORM_SRGB, 0),
|
||||
vk::Format::R8G8B8A8_UNORM => (DXGI_FORMAT_R8G8B8A8_UNORM, 0),
|
||||
vk::Format::R8G8B8A8_SRGB => (DXGI_FORMAT_R8G8B8A8_UNORM_SRGB, 0),
|
||||
vk::Format::A2B10G10R10_UNORM_PACK32 => (
|
||||
DXGI_FORMAT_R10G10B10A2_UNORM,
|
||||
GAME_CAPTURE_FLAG_TEN_BIT | GAME_CAPTURE_FLAG_HDR,
|
||||
),
|
||||
vk::Format::R16G16B16A16_SFLOAT => (DXGI_FORMAT_R16G16B16A16_FLOAT, GAME_CAPTURE_FLAG_HDR),
|
||||
_ => return None,
|
||||
};
|
||||
Some(InteropFormat {
|
||||
vk_format: format,
|
||||
dxgi_format,
|
||||
capture_flags,
|
||||
})
|
||||
}
|
||||
|
||||
pub struct D3d11Device {
|
||||
device: ID3D11Device,
|
||||
_context: ID3D11DeviceContext,
|
||||
}
|
||||
|
||||
unsafe impl Send for D3d11Device {}
|
||||
unsafe impl Sync for D3d11Device {}
|
||||
|
||||
pub struct SharedTexture {
|
||||
_texture: ID3D11Texture2D,
|
||||
pub handle: u64,
|
||||
}
|
||||
|
||||
unsafe impl Send for SharedTexture {}
|
||||
unsafe impl Sync for SharedTexture {}
|
||||
|
||||
impl D3d11Device {
|
||||
pub fn create() -> Option<Self> {
|
||||
for driver in [D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_WARP] {
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
let mut context: Option<ID3D11DeviceContext> = None;
|
||||
let result = unsafe {
|
||||
D3D11CreateDevice(
|
||||
None,
|
||||
driver,
|
||||
Default::default(),
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
None,
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
None,
|
||||
Some(&mut context),
|
||||
)
|
||||
};
|
||||
if result.is_ok()
|
||||
&& let (Some(device), Some(context)) = (device, context)
|
||||
{
|
||||
return Some(Self {
|
||||
device,
|
||||
_context: context,
|
||||
});
|
||||
}
|
||||
}
|
||||
let _ = D3D_DRIVER_TYPE_UNKNOWN;
|
||||
None
|
||||
}
|
||||
|
||||
pub fn create_shared_texture(
|
||||
&self,
|
||||
width: u32,
|
||||
height: u32,
|
||||
format: InteropFormat,
|
||||
) -> Option<SharedTexture> {
|
||||
if width == 0 || height == 0 {
|
||||
return None;
|
||||
}
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: width,
|
||||
Height: height,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: format.dxgi_format,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: (D3D11_BIND_SHADER_RESOURCE.0 | D3D11_BIND_RENDER_TARGET.0) as u32,
|
||||
CPUAccessFlags: 0,
|
||||
MiscFlags: D3D11_RESOURCE_MISC_SHARED.0 as u32,
|
||||
};
|
||||
let mut texture: Option<ID3D11Texture2D> = None;
|
||||
let result = unsafe { self.device.CreateTexture2D(&desc, None, Some(&mut texture)) };
|
||||
if result.is_err() {
|
||||
return None;
|
||||
}
|
||||
let texture = texture?;
|
||||
let resource: IDXGIResource = texture.cast().ok()?;
|
||||
let handle = unsafe { resource.GetSharedHandle() }.ok()?;
|
||||
if handle.is_invalid() {
|
||||
return None;
|
||||
}
|
||||
Some(SharedTexture {
|
||||
_texture: texture,
|
||||
handle: handle.0 as usize as u64,
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user