Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61c02e695e | |||
| 203ad8069d | |||
| 5f8c6b6147 | |||
| cd3368fc71 | |||
| bd05bc8c30 | |||
| 658564353c | |||
| 6b3cbce120 | |||
| 739fa74e68 | |||
| c87ca577a3 | |||
| e68b7330ae | |||
| e5c2b4e7f5 | |||
| 7ad3a57e68 | |||
| 22bef1fd0a | |||
| bf577044f1 | |||
| 4c95ba72a3 | |||
| 011607ec10 | |||
| 803573b4ec | |||
| 00cf51d610 | |||
| 84a3b95f17 | |||
| 8cde8621ce | |||
| 0bf3984614 | |||
| 75ee53d1dd | |||
| 0255a8289c | |||
| 6bed5d9e8e | |||
| 48202a0f89 | |||
| bf57aa4000 | |||
| 0ccd0fe676 | |||
| e1ca2e4d3c | |||
| e119aa50e9 | |||
| 683c81be03 | |||
| fe61597d92 | |||
| d9b8b88a42 | |||
| 15202011c1 | |||
| 05e87e6ab0 | |||
| 38c68c33e5 | |||
| a0427cd2a3 | |||
| a4c85af155 | |||
| 9ba90d4b77 | |||
| 5358ef9fee | |||
| 0a63154293 | |||
| e5057f6cc1 | |||
| a3eefc2374 | |||
| cd591514ad | |||
| a2bd0cd77c | |||
| 48f980ebb1 | |||
| 1cd87066d7 | |||
| 789ad49bc4 | |||
| c87bfe0e7b | |||
| f98ab07dd6 |
@@ -3,7 +3,7 @@
|
||||
#
|
||||
# Stage 1 (this file): PROBE the runner's driver toolchain (WDK / EWDK / cargo-make / LLVM / the
|
||||
# inf2cat/stampinf/devgen/signtool tools) so we know what's provisioned BEFORE writing driver code,
|
||||
# and build+test the owned ABI crate (pf-vdisplay-proto) on MSVC to prove it compiles cross-OS and the
|
||||
# and build+test the owned ABI crate (pf-driver-proto) on MSVC to prove it compiles cross-OS and the
|
||||
# CI wiring works. The runner has no RTX GPU — that's fine: builds, the IddCx bindgen/link, the
|
||||
# /INTEGRITYCHECK self-sign-load, and (later) IDD-push frame flow on the basic display do not need one;
|
||||
# only live NVENC encode does, which defers to the RTX box.
|
||||
@@ -18,12 +18,12 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.gitea/workflows/windows-drivers.yml'
|
||||
- 'crates/pf-vdisplay-proto/**'
|
||||
- 'crates/pf-driver-proto/**'
|
||||
- 'packaging/windows/drivers/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.gitea/workflows/windows-drivers.yml'
|
||||
- 'crates/pf-vdisplay-proto/**'
|
||||
- 'crates/pf-driver-proto/**'
|
||||
- 'packaging/windows/drivers/**'
|
||||
|
||||
# Driver builds need the WDK on the runner (provision once via windows-drivers-provision.yml).
|
||||
@@ -93,17 +93,17 @@ jobs:
|
||||
Write-Host ("CARGO_HOME = " + ($env:CARGO_HOME ?? '<unset>'))
|
||||
Write-Host ("CARGO_TARGET_DIR (daemon) = " + ($env:CARGO_TARGET_DIR ?? '<unset>'))
|
||||
|
||||
- name: Build + test pf-vdisplay-proto (MSVC)
|
||||
- name: Build + test pf-driver-proto (MSVC)
|
||||
run: |
|
||||
# Short target dir to dodge MAX_PATH inside the deep act host workdir (see windows.yml).
|
||||
$env:CARGO_TARGET_DIR = "C:\t\drv"
|
||||
cargo build -p pf-vdisplay-proto
|
||||
cargo test -p pf-vdisplay-proto
|
||||
cargo clippy -p pf-vdisplay-proto --all-targets -- -D warnings
|
||||
cargo fmt -p pf-vdisplay-proto -- --check
|
||||
cargo build -p pf-driver-proto
|
||||
cargo test -p pf-driver-proto
|
||||
cargo clippy -p pf-driver-proto --all-targets -- -D warnings
|
||||
cargo fmt -p pf-driver-proto -- --check
|
||||
|
||||
# Build the UMDF driver workspace (wdk-probe) on windows-drivers-rs: proves wdk-sys bindgen/link works
|
||||
# on the runner's WDK + LLVM, that pf-vdisplay-proto path-deps into a driver, and exposes the produced
|
||||
# on the runner's WDK + LLVM, that pf-driver-proto path-deps into a driver, and exposes the produced
|
||||
# DLL's FORCE_INTEGRITY (/INTEGRITYCHECK) bit — the M0 self-signed-load question.
|
||||
driver-build:
|
||||
runs-on: windows-amd64
|
||||
|
||||
Generated
+92
-3
@@ -1010,6 +1010,18 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastbloom"
|
||||
version = "0.14.1"
|
||||
@@ -1111,6 +1123,12 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -1586,7 +1604,16 @@ version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1594,6 +1621,18 @@ name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5081f264ed7adee96ea4b4778b6bb9da0a7228b084587aa3bd3ff05da7c5a3b"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@@ -1966,6 +2005,17 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.38.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -2419,7 +2469,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay-proto"
|
||||
name = "pf-driver-proto"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
@@ -2655,6 +2705,7 @@ dependencies = [
|
||||
"audiopus_sys",
|
||||
"axum",
|
||||
"axum-server",
|
||||
"base64",
|
||||
"bytemuck",
|
||||
"cbc",
|
||||
"ffmpeg-next",
|
||||
@@ -2670,7 +2721,7 @@ dependencies = [
|
||||
"nvidia-video-codec-sdk",
|
||||
"openh264",
|
||||
"opus",
|
||||
"pf-vdisplay-proto",
|
||||
"pf-driver-proto",
|
||||
"pipewire",
|
||||
"punktfunk-core",
|
||||
"quinn",
|
||||
@@ -2678,6 +2729,7 @@ dependencies = [
|
||||
"rcgen",
|
||||
"reis",
|
||||
"rsa",
|
||||
"rusqlite",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rusty_enet",
|
||||
@@ -3028,6 +3080,31 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsqlite-vfs"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.40.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
"sqlite-wasm-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -3548,6 +3625,18 @@ dependencies = [
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-rs"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"js-sys",
|
||||
"rsqlite-vfs",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = [
|
||||
"crates/punktfunk-core",
|
||||
"crates/punktfunk-host",
|
||||
"crates/pf-vdisplay-proto",
|
||||
"crates/pf-driver-proto",
|
||||
"clients/probe",
|
||||
"clients/linux",
|
||||
"clients/windows",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# own OS type. Defining every wire struct ONCE here — with `const` size/offset asserts + bytemuck
|
||||
# round-trips — makes host<->driver ABI drift a COMPILE error instead of a silent frame/IOCTL corruption.
|
||||
[package]
|
||||
name = "pf-vdisplay-proto"
|
||||
name = "pf-driver-proto"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
@@ -276,7 +276,7 @@ pub mod frame {
|
||||
/// These were hand-duplicated as `OFF_*`/`SHM_*` constants in `inject/{gamepad,dualsense}_windows.rs`
|
||||
/// and (as bare literals — `*view.add(140)`) in the standalone `xusb-driver`/`dualsense-driver`
|
||||
/// workspaces, guarded only by "must match" comments — the top ABI-drift hazard the audit flagged
|
||||
/// (`docs/windows-host-rewrite-audit.md` §6.1). Owning them here with `Pod` derives + `offset_of!`
|
||||
/// (`docs/windows-host-rewrite.md` §2.7). Owning them here with `Pod` derives + `offset_of!`
|
||||
/// asserts makes a one-sided edit a compile error.
|
||||
///
|
||||
/// The host creates the section (privileged, permissive DACL so the restricted WUDFHost token can
|
||||
@@ -85,6 +85,13 @@ wayland-scanner = "0.31"
|
||||
wayland-backend = "0.3"
|
||||
# Parse `pw-dump` JSON to find gamescope's PipeWire node (gamescope backend).
|
||||
serde_json = "1"
|
||||
# Read the Lutris library DB (`pga.db`) for the Lutris store provider. `bundled` vendors + compiles
|
||||
# SQLite (cc, already needed for ffmpeg/opus) so there's no system libsqlite3 runtime dependency —
|
||||
# clean for the deb/rpm/flatpak packaging. Opened read-only/immutable (Lutris may hold it open).
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
# Inline Lutris's local cover-art JPEGs as `data:` URLs in the library (Lutris has no public CDN
|
||||
# keyed by a stable id, unlike Steam/Heroic; a `data:` URL is self-contained — no host-served endpoint).
|
||||
base64 = "0.22"
|
||||
# Builds/validates the xkb keymap uploaded to the virtual keyboard + tracks modifier state.
|
||||
xkbcommon = "0.8"
|
||||
# The safe `opus` crate is stereo-only; surround (5.1/7.1) needs the libopus *multistream*
|
||||
@@ -155,7 +162,7 @@ windows = { version = "0.62", features = [
|
||||
"Win32_System_LibraryLoader",
|
||||
# VirtualProtect — for the inline patch of the win32u GPU-preference shim (Apollo's MinHook port:
|
||||
# the hybrid-GPU output-reparenting hook that keeps Desktop Duplication stable on a 4090+iGPU box).
|
||||
# See capture/dxgi.rs `install_gpu_pref_hook`. No trampoline (we fully replace the fn) → no detour
|
||||
# See capture/windows/dxgi.rs `install_gpu_pref_hook`. No trampoline (we fully replace the fn) → no detour
|
||||
# crate / no C length-disassembler dep; a 12-byte absolute-jmp prologue patch suffices.
|
||||
"Win32_System_Memory",
|
||||
# Per-monitor-v2 DPI awareness — IDXGIOutput5::DuplicateOutput1 (the modern capture path Apollo
|
||||
@@ -175,7 +182,7 @@ openh264 = "0.9"
|
||||
# WASAPI loopback audio capture (default render endpoint -> 48 kHz stereo f32 for the Opus path).
|
||||
wasapi = "0.23"
|
||||
# Virtual Xbox 360 gamepad: the in-tree XUSB companion UMDF driver (packaging/windows/xusb-driver),
|
||||
# driven over shared memory from inject/gamepad_windows.rs — no ViGEmBus dependency.
|
||||
# driven over shared memory from inject/windows/gamepad_windows.rs — no ViGEmBus dependency.
|
||||
# NVENC hardware encoder (NVENC SDK, D3D11 input). The SDK pins `cudarc` with
|
||||
# `cuda-version-from-build-system` (a build-time CUDA-toolkit probe); its `ci-check` feature switches
|
||||
# cudarc to `dynamic-loading` (loads nvcuda.dll at runtime — nothing needed at build), which is how
|
||||
@@ -192,7 +199,7 @@ ffmpeg-next = { version = "8", optional = true }
|
||||
# (vdisplay/pf_vdisplay.rs): the control-plane IOCTL codes + `#[repr(C)] Pod` request/reply structs,
|
||||
# defined ONCE so host<->driver ABI drift is a compile error. `bytemuck` serializes those structs
|
||||
# to/from the DeviceIoControl byte buffers.
|
||||
pf-vdisplay-proto = { path = "../pf-vdisplay-proto" }
|
||||
pf-driver-proto = { path = "../pf-driver-proto" }
|
||||
bytemuck = { version = "1.19", features = ["derive"] }
|
||||
|
||||
[features]
|
||||
|
||||
@@ -88,6 +88,8 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "audio/windows/wasapi_cap.rs"]
|
||||
mod wasapi_cap;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "audio/windows/wasapi_mic.rs"]
|
||||
mod wasapi_mic;
|
||||
|
||||
@@ -44,6 +44,49 @@ impl PixelFormat {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a Windows capturer should produce, resolved **once** per session and passed **into**
|
||||
/// [`capture_virtual_output`] (Goal-1 stage 5, plan §2.3/§5). Passing the format in is what lets a
|
||||
/// capturer stop re-deriving the encode backend itself — it kills the
|
||||
/// `capture/dxgi.rs → encode::windows_resolved_backend()` back-reference (the highest-severity coupling:
|
||||
/// capture and encode could otherwise disagree on whether frames are GPU-resident). Neutral type; the
|
||||
/// Linux portal capturer ignores it (it negotiates its own format with PipeWire).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct OutputFormat {
|
||||
/// Produce GPU-resident D3D11 frames (zero-copy for a GPU encoder — NVENC/AMF/QSV) rather than CPU
|
||||
/// staging. `false` **only** for the GPU-less software encoder.
|
||||
pub gpu: bool,
|
||||
/// HDR: the capturer converts to 10-bit (IDD-push FP16 → `Rgb10a2`; the DDA secure-desktop HDR hint).
|
||||
/// `false` = 8-bit SDR.
|
||||
pub hdr: bool,
|
||||
}
|
||||
|
||||
impl OutputFormat {
|
||||
/// Resolve the output format for an entry point that doesn't build a full [`SessionPlan`]
|
||||
/// (`crate::session_plan`) — the GameStream + spike paths: `gpu` from the resolved encode backend,
|
||||
/// `hdr` as given. The native punktfunk/1 path uses `SessionPlan::output_format()` instead (it already
|
||||
/// resolved the encoder), so neither path makes a capturer re-derive it.
|
||||
pub fn resolve(hdr: bool) -> Self {
|
||||
OutputFormat {
|
||||
gpu: gpu_encode(),
|
||||
hdr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True if the resolved encode backend produces GPU frames (anything but the software encoder). The single
|
||||
/// source for [`OutputFormat::resolve`]'s `gpu`; on Linux always true (the portal/VAAPI/CUDA path is GPU).
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn gpu_encode() -> bool {
|
||||
!matches!(
|
||||
crate::encode::windows_resolved_backend(),
|
||||
crate::encode::WindowsBackend::Software
|
||||
)
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn gpu_encode() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// A captured frame. [`format`](Self::format)/dimensions describe the pixels regardless of
|
||||
/// where they live — [`payload`](Self::payload) is either a CPU buffer (the spike/fallback path)
|
||||
/// or a GPU buffer already on the device (the zero-copy path, plan §9).
|
||||
@@ -314,9 +357,12 @@ pub fn open_portal_monitor() -> Result<Box<dyn Capturer>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn capture_virtual_output(
|
||||
vout: crate::vdisplay::VirtualOutput,
|
||||
_want_hdr: bool,
|
||||
_want: OutputFormat,
|
||||
_capture: crate::session_plan::CaptureBackend,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
// The Linux host stays 8-bit (HDR is blocked upstream), so `want_hdr` is unused here.
|
||||
// The Linux host stays 8-bit (HDR is blocked upstream) and the portal negotiates its own format, so
|
||||
// the `OutputFormat` is unused here; the capture backend is always the portal (the `CaptureBackend`
|
||||
// arg is a Windows-only dispatch — ignored here).
|
||||
linux::PortalCapturer::from_virtual_output(vout).map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
|
||||
@@ -327,14 +373,16 @@ pub fn capture_virtual_output(
|
||||
/// compiled and comes back the moment the flag is unset.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn wgc_disabled() -> bool {
|
||||
std::env::var_os("PUNKTFUNK_NO_WGC").is_some()
|
||||
crate::config::config().no_wgc
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn capture_virtual_output(
|
||||
vout: crate::vdisplay::VirtualOutput,
|
||||
want_hdr: bool,
|
||||
want: OutputFormat,
|
||||
capture: crate::session_plan::CaptureBackend,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
use crate::session_plan::CaptureBackend;
|
||||
let target = vout.win_capture.clone().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"SudoVDA target not yet an active display (needs a WDDM GPU to activate it)"
|
||||
@@ -343,9 +391,10 @@ pub fn capture_virtual_output(
|
||||
let pref = vout.preferred_mode;
|
||||
let keep = vout.keepalive;
|
||||
// P2 direct frame push (kill DDA): consume frames straight from the pf-vdisplay driver's shared
|
||||
// ring — no Desktop Duplication, no win32u reparenting hook. Opt-in while it's A/B'd against DDA;
|
||||
// `idd_push` takes the keepalive (owns the virtual display) so there's no fall-through.
|
||||
if std::env::var_os("PUNKTFUNK_IDD_PUSH").is_some() {
|
||||
// ring — no Desktop Duplication, no win32u reparenting hook. Resolved once in the `SessionPlan`
|
||||
// (was re-derived from `config().idd_push` here); `IddPush` takes the keepalive (owns the virtual
|
||||
// display) so there's no fall-through.
|
||||
if capture == CaptureBackend::IddPush {
|
||||
// Recreate the monitor + ring per session (fix-teardown): a FRESH monitor reliably gets a
|
||||
// working IddCx swap-chain, whereas a REUSED monitor's swap-chain dies after ~2 sessions and
|
||||
// the host can't revive it. The driver's recreate crash (target id resolved to 0) is fixed by
|
||||
@@ -354,14 +403,14 @@ pub fn capture_virtual_output(
|
||||
// If IDD-push can't open OR the driver doesn't attach to the ring within a few seconds (e.g. a
|
||||
// hybrid-GPU render mismatch), fall back to DDA so the session is NEVER left black (audit §5.1).
|
||||
// `open()` hands the keepalive back on failure so DDA can take ownership of the virtual display.
|
||||
match idd_push::IddPushCapturer::open(target.clone(), pref, want_hdr, keep) {
|
||||
match idd_push::IddPushCapturer::open(target.clone(), pref, want.hdr, keep) {
|
||||
Ok(c) => return Ok(Box::new(c) as Box<dyn Capturer>),
|
||||
Err((e, keep)) => {
|
||||
tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"IDD-push open/attach failed — falling back to DDA"
|
||||
);
|
||||
return dxgi::DuplCapturer::open(target, pref, keep, false)
|
||||
return dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>);
|
||||
}
|
||||
}
|
||||
@@ -370,12 +419,10 @@ pub fn capture_virtual_output(
|
||||
// overlay/independent-flip planes DXGI Desktop Duplication misses (the frozen-HDR-animation bug),
|
||||
// and has no ACCESS_LOST-on-overlay churn. DDA stays available via PUNKTFUNK_CAPTURE=dda and is
|
||||
// the secure-desktop (lock/UAC) fallback (WGC can't capture those). `keep` is moved into the
|
||||
// chosen backend (it owns the SudoVDA keepalive), so there's no open-time auto-fallback.
|
||||
let backend = std::env::var("PUNKTFUNK_CAPTURE")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if backend == "dda" || backend == "dxgi" || wgc_disabled() {
|
||||
return dxgi::DuplCapturer::open(target, pref, keep, false)
|
||||
// chosen backend (it owns the SudoVDA keepalive), so there's no open-time auto-fallback. The
|
||||
// backend choice (`dda`/`dxgi`/`PUNKTFUNK_NO_WGC` → DDA, else WGC) is now resolved once in the plan.
|
||||
if capture == CaptureBackend::Dda {
|
||||
return dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>);
|
||||
}
|
||||
// WGC default, with a watchdog'd DDA fallback. WGC's Direct3D11CaptureFramePool::CreateFreeThreaded
|
||||
@@ -405,12 +452,12 @@ pub fn capture_virtual_output(
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "WGC open failed — falling back to DDA");
|
||||
dxgi::DuplCapturer::open(target, pref, keep, false)
|
||||
dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("WGC open timed out (CreateFreeThreaded hang on the virtual display) — falling back to DDA");
|
||||
dxgi::DuplCapturer::open(target, pref, keep, false)
|
||||
dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
}
|
||||
@@ -419,22 +466,31 @@ pub fn capture_virtual_output(
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
pub fn capture_virtual_output(
|
||||
_vout: crate::vdisplay::VirtualOutput,
|
||||
_want_hdr: bool,
|
||||
_want: OutputFormat,
|
||||
_capture: crate::session_plan::CaptureBackend,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
anyhow::bail!("virtual-output capture requires Linux or Windows")
|
||||
}
|
||||
|
||||
// Goal-1 stage 6: the Windows backends live under `capture/windows/`, the Linux one under `capture/linux/`
|
||||
// (`#[path]` keeps the module names flat, so every `crate::capture::*` path is unchanged).
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "capture/windows/composed_flip.rs"]
|
||||
pub mod composed_flip;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "capture/windows/desktop_watch.rs"]
|
||||
pub mod desktop_watch;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "capture/windows/dxgi.rs"]
|
||||
pub mod dxgi;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "capture/windows/idd_push.rs"]
|
||||
pub mod idd_push;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "capture/windows/wgc.rs"]
|
||||
pub mod wgc;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "capture/windows/wgc_relay.rs"]
|
||||
pub mod wgc_relay;
|
||||
|
||||
+13
-11
@@ -2046,6 +2046,9 @@ impl DuplCapturer {
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
keepalive: Box<dyn Send>,
|
||||
// Whether the (already-resolved) encode backend wants GPU-resident frames — passed IN (Goal-1
|
||||
// stage 5) so the capturer never re-derives the encode backend itself.
|
||||
gpu: bool,
|
||||
want_hdr: bool,
|
||||
) -> Result<Self> {
|
||||
unsafe {
|
||||
@@ -2183,9 +2186,9 @@ impl DuplCapturer {
|
||||
let context = context.context("null D3D11 context")?;
|
||||
// 3) duplicate the output. Attach to the current input desktop first (as SYSTEM this can
|
||||
// be the Winlogon secure desktop) so a session that starts at the lock/login screen works.
|
||||
// The SudoVDA is kept the sole desktop via the CCD isolation in sudovda::create_monitor
|
||||
// (registry-persisted), so the secure desktop has nowhere to render but the output we
|
||||
// capture — no per-open re-isolation needed.
|
||||
// The virtual display is kept the sole desktop via the CCD isolation the pf-vdisplay backend
|
||||
// applies at monitor creation (registry-persisted), so the secure desktop has nowhere to render
|
||||
// but the output we capture — no per-open re-isolation needed.
|
||||
attach_input_desktop();
|
||||
let dupl = duplicate_output(&output, &device, want_hdr)
|
||||
.context("DuplicateOutput (already duplicated by another app?)")?;
|
||||
@@ -2213,14 +2216,13 @@ impl DuplCapturer {
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or((2000 / refresh_hz.max(1)).max(100));
|
||||
// Produce GPU-resident D3D11 frames (zero-copy NVENC, or the NV12/P010 the AMF/QSV
|
||||
// backends read back / import) whenever the resolved encode backend is a GPU one — so the
|
||||
// capturer's output format matches the encoder's input. Only the software (GPU-less) path
|
||||
// takes CPU staging. Mirrors `encode::open_video`'s dispatch exactly.
|
||||
let gpu_mode = !matches!(
|
||||
crate::encode::windows_resolved_backend(),
|
||||
crate::encode::WindowsBackend::Software
|
||||
);
|
||||
// Produce GPU-resident D3D11 frames (zero-copy NVENC, or the NV12/P010 the AMF/QSV backends
|
||||
// read back / import) whenever the encode backend is a GPU one — so the capturer's output
|
||||
// format matches the encoder's input. Only the software (GPU-less) path takes CPU staging.
|
||||
// The decision is resolved ONCE per session and passed in (Goal-1 stage 5), instead of this
|
||||
// capturer re-calling `encode::windows_resolved_backend()` — the back-reference that let
|
||||
// capture and encode disagree (plan §2.3/§5).
|
||||
let gpu_mode = gpu;
|
||||
// Read the source display's HDR mastering metadata while we still hold `output` (it is
|
||||
// moved into the struct below). Only meaningful for an HDR (FP16) duplication.
|
||||
let is_hdr_init = dd.ModeDesc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT;
|
||||
+211
-192
@@ -7,18 +7,18 @@
|
||||
//! `PUNKTFUNK_IDD_PUSH`. Driver counterpart: `packaging/windows/drivers/pf-vdisplay/src/
|
||||
//! frame_transport.rs`. The shared `SharedHeader` layout, `MAGIC`/`VERSION`/`RING_LEN`, the
|
||||
//! `DRV_STATUS_*` codes, the `Global\` name scheme and the publish token all come from
|
||||
//! [`pf_vdisplay_proto::frame`] (which OWNS the contract, with `const` size asserts) — both sides
|
||||
//! [`pf_driver_proto::frame`] (which OWNS the contract, with `const` size asserts) — both sides
|
||||
//! `use` it, so drift is a compile error rather than a "must match" comment.
|
||||
|
||||
use super::dxgi::{make_device, D3d11Frame, HdrConverter, WinCaptureTarget};
|
||||
use super::{CapturedFrame, Capturer, FramePayload, PixelFormat};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use pf_vdisplay_proto::frame;
|
||||
use pf_driver_proto::frame;
|
||||
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use windows::core::{w, Interface, HSTRING};
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE, LUID};
|
||||
use windows::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE, LUID};
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
ID3D11Device, ID3D11DeviceContext, ID3D11RenderTargetView, ID3D11ShaderResourceView,
|
||||
ID3D11Texture2D, D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE,
|
||||
@@ -43,7 +43,7 @@ use windows::Win32::System::Memory::{
|
||||
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject};
|
||||
|
||||
// The frame-transport contract — `SharedHeader` layout, `MAGIC`/`VERSION`/`RING_LEN`, the
|
||||
// `DRV_STATUS_*` codes and the `Global\` name helpers — lives in `pf_vdisplay_proto::frame`; both sides
|
||||
// `DRV_STATUS_*` codes and the `Global\` name helpers — lives in `pf_driver_proto::frame`; both sides
|
||||
// `use frame::*`, so a layout/name/code drift is a compile error (the proto has `const` size asserts).
|
||||
use frame::{
|
||||
event_name, header_name, texture_name, SharedHeader, DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED,
|
||||
@@ -60,7 +60,7 @@ const DXGI_SHARED_RESOURCE_RW: u32 = 0x8000_0000 | 0x1;
|
||||
const OUT_RING: usize = 3;
|
||||
|
||||
/// Bring-up debug block (fixed name) — the host creates it; the driver writes diagnostics into it
|
||||
/// independent of the per-target header. NOT part of `pf_vdisplay_proto` (a host-side bring-up channel,
|
||||
/// independent of the per-target header. NOT part of `pf_driver_proto` (a host-side bring-up channel,
|
||||
/// not the data path); the matching `DebugBlock` lives in the OLD oracle driver's `frame_transport.rs`.
|
||||
#[repr(C)]
|
||||
struct DebugBlock {
|
||||
@@ -90,20 +90,78 @@ fn now_ns() -> u64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// RAII wrapper for a file-mapping object + its mapped view: on drop the view is `UnmapViewOfFile`'d,
|
||||
/// THEN the [`OwnedHandle`] closes the underlying mapping object (order matters — unmap before close).
|
||||
/// A `header`/`dbg_block` raw pointer borrows into the view via [`ptr`](Self::ptr); the section must
|
||||
/// outlive it (it's declared before it in [`IddPushCapturer`], and moving the section doesn't move the
|
||||
/// OS mapping, so the borrowed pointer stays valid).
|
||||
struct MappedSection {
|
||||
handle: OwnedHandle,
|
||||
view: MEMORY_MAPPED_VIEW_ADDRESS,
|
||||
}
|
||||
|
||||
impl MappedSection {
|
||||
/// The mapped view base as a `*mut T` (a borrow into the section; valid only while it lives).
|
||||
fn ptr<T>(&self) -> *mut T {
|
||||
self.view.Value as *mut T
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MappedSection {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `view` is the live view we created with `MapViewOfFile` and have not yet unmapped;
|
||||
// unmap it BEFORE `handle` (the OwnedHandle) closes the mapping object — order matters.
|
||||
unsafe {
|
||||
let _ = UnmapViewOfFile(self.view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HostSlot {
|
||||
tex: ID3D11Texture2D,
|
||||
mutex: IDXGIKeyedMutex,
|
||||
shared: HANDLE,
|
||||
/// The named shared-resource handle, held only to keep the resource alive (the driver opens it by
|
||||
/// NAME). An [`OwnedHandle`] so it closes on drop (was a manual `CloseHandle` in a `Drop` impl);
|
||||
/// never read directly — its sole purpose is the RAII close.
|
||||
#[allow(dead_code)]
|
||||
shared: OwnedHandle,
|
||||
/// SRV on the slot texture so the HDR path samples the FP16 slot DIRECTLY (no slot→scratch copy);
|
||||
/// the convert pass writes the output ring while holding the slot's keyed mutex. Unused for SDR
|
||||
/// (which CopyResource's the BGRA slot straight to the output).
|
||||
srv: ID3D11ShaderResourceView,
|
||||
}
|
||||
|
||||
impl Drop for HostSlot {
|
||||
/// RAII guard over an [`IDXGIKeyedMutex`]: [`acquire`](Self::acquire) does `AcquireSync(key, timeout)`,
|
||||
/// `Drop` does `ReleaseSync(key)`. So the lock is released even if the work between acquire and the end
|
||||
/// of the guard's scope `?`-returns or panics — the "leak the keyed-mutex lock → stall the driver on
|
||||
/// that slot" footgun the consume loop guards against by hand. Keeps the hot loop free of a raw
|
||||
/// `ReleaseSync` that a future early-return could skip.
|
||||
struct KeyedMutexGuard<'a> {
|
||||
mutex: &'a IDXGIKeyedMutex,
|
||||
key: u64,
|
||||
}
|
||||
|
||||
impl<'a> KeyedMutexGuard<'a> {
|
||||
/// Acquire `mutex` at `key`, waiting up to `timeout_ms`. `None` if the acquire times out / errors
|
||||
/// (the caller skips the frame), so the guard is only ever held when the lock is genuinely held.
|
||||
fn acquire(
|
||||
mutex: &'a IDXGIKeyedMutex,
|
||||
key: u64,
|
||||
timeout_ms: u32,
|
||||
) -> Option<KeyedMutexGuard<'a>> {
|
||||
// SAFETY: `mutex` is a live `IDXGIKeyedMutex` on this thread's immediate-context device.
|
||||
if unsafe { mutex.AcquireSync(key, timeout_ms) }.is_err() {
|
||||
return None;
|
||||
}
|
||||
Some(KeyedMutexGuard { mutex, key })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for KeyedMutexGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: we hold `mutex` at `key` (acquired in `acquire`, never released elsewhere); release it.
|
||||
unsafe {
|
||||
let _ = CloseHandle(self.shared);
|
||||
let _ = self.mutex.ReleaseSync(self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,10 +171,17 @@ pub struct IddPushCapturer {
|
||||
device: ID3D11Device,
|
||||
context: ID3D11DeviceContext,
|
||||
target_id: u32,
|
||||
map: HANDLE,
|
||||
/// Owns the shared-header file mapping + its mapped view (RAII unmap-then-close). Declared BEFORE
|
||||
/// `header`, which is a raw pointer borrowed into this view via [`MappedSection::ptr`]. Never read
|
||||
/// directly (the `header` pointer is) — held purely so the mapping outlives the capturer.
|
||||
#[allow(dead_code)]
|
||||
section: MappedSection,
|
||||
header: *mut SharedHeader,
|
||||
event: HANDLE,
|
||||
dbg_map: HANDLE,
|
||||
event: OwnedHandle,
|
||||
/// Owns the bring-up debug section (mapping + view), or `None` when the debug block wasn't created.
|
||||
/// Never read directly (the `dbg_block` pointer is) — held purely for the RAII unmap/close.
|
||||
#[allow(dead_code)]
|
||||
dbg_section: Option<MappedSection>,
|
||||
dbg_block: *mut DebugBlock,
|
||||
width: u32,
|
||||
height: u32,
|
||||
@@ -136,6 +201,10 @@ pub struct IddPushCapturer {
|
||||
/// Throttle for the `advanced_color_enabled` poll (a CCD `QueryDisplayConfig`, ~ms — too costly per
|
||||
/// frame at 240 Hz).
|
||||
last_acm_poll: Instant,
|
||||
/// Set when a display-descriptor change triggered a ring recreate (recovery, game-capture bug GB1);
|
||||
/// cleared when a fresh frame resumes. If it stays set past the recovery window, `try_consume` drops
|
||||
/// the session (recover-or-drop, no DDA).
|
||||
recovering_since: Option<Instant>,
|
||||
/// Host-owned ROTATING output ring NVENC encodes (texture + RTV per slot). Rotating it per frame is
|
||||
/// the precondition for pipelining the encode loop: while NVENC encodes frame N's texture on the
|
||||
/// ASIC, frame N+1's convert/copy writes a DIFFERENT texture on the 3D engine — the two overlap. The
|
||||
@@ -148,111 +217,11 @@ pub struct IddPushCapturer {
|
||||
last_seq: u64,
|
||||
last_present: Option<(ID3D11Texture2D, PixelFormat)>,
|
||||
status_logged: bool,
|
||||
/// The monitor generation this capturer was opened for. When the active monitor gen changes (a
|
||||
/// reconnect preempted + recreated the monitor), `next_frame` bails immediately so this session
|
||||
/// releases its NVENC encoder instead of lingering on the dead ring's 20s deadline.
|
||||
my_gen: u64,
|
||||
_keepalive: Box<dyn Send>,
|
||||
}
|
||||
// COM objects used only from the owning (encode) thread.
|
||||
unsafe impl Send for IddPushCapturer {}
|
||||
|
||||
/// The persistent IDD-push capturer, kept alive for the host lifetime and SHARED across client
|
||||
/// sessions. The driver's per-session monitor TEARDOWN→RECREATE path is unstable (on session 2 the
|
||||
/// target-id resolves to 0, `IddCxSwapChainSetDevice` fails `0x80070057`, then an access violation),
|
||||
/// while the FIRST-session path is solid. So we create the monitor + ring + swap-chain ONCE and hand
|
||||
/// every later session a thin handle delegating to this one. The persistent capturer holds a monitor
|
||||
/// lease for the host lifetime, so `VirtualDisplay::create` always JOINs the same live monitor (same
|
||||
/// target id) and the reuse match always hits — no recreate, no driver crash. Prototype scope:
|
||||
/// single-client, single-mode (a different mode would need a recreate, the unstable path).
|
||||
static IDD_PERSIST: Mutex<Option<IddPushCapturer>> = Mutex::new(None);
|
||||
|
||||
/// Open the IDD-push capturer, reusing the persistent one across sessions (see [`IDD_PERSIST`]).
|
||||
pub fn open_or_reuse(
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
keepalive: Box<dyn Send>,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
let (w, h, _) =
|
||||
preferred.context("IDD push needs the negotiated mode (WxH) to size the ring")?;
|
||||
let mut slot = IDD_PERSIST.lock().unwrap();
|
||||
let reuse = matches!(slot.as_ref(), Some(c) if c.target_id == target.target_id && c.width == w && c.height == h);
|
||||
match slot.as_mut() {
|
||||
Some(c) if reuse => {
|
||||
// Reuse: the persistent capturer already owns the monitor + ring + driver attach. Drop the
|
||||
// new per-session monitor lease (the persistent capturer's lease keeps the monitor live).
|
||||
// The ring tracks the display, not the client; only the client's 10-bit cap can differ.
|
||||
drop(keepalive);
|
||||
c.set_client_10bit(client_10bit);
|
||||
tracing::info!(
|
||||
target_id = target.target_id,
|
||||
client_10bit,
|
||||
"IDD push: reusing the persistent capturer (no monitor/ring recreate)"
|
||||
);
|
||||
}
|
||||
Some(c) => bail!(
|
||||
"IDD-push persistent capturer is {}x{} target {}, this session wants {}x{} target {} — a \
|
||||
mode/target change needs a recreate (the driver's recreate path is unstable); not \
|
||||
supported in the persistent prototype",
|
||||
c.width,
|
||||
c.height,
|
||||
c.target_id,
|
||||
w,
|
||||
h,
|
||||
target.target_id
|
||||
),
|
||||
None => {
|
||||
tracing::info!(
|
||||
target_id = target.target_id,
|
||||
client_10bit,
|
||||
"IDD push: creating the persistent capturer (first session)"
|
||||
);
|
||||
// (dead persistent path) open() now returns the keepalive on failure; this path has no
|
||||
// fallback, so discard it on error.
|
||||
*slot = Some(
|
||||
IddPushCapturer::open(target, preferred, client_10bit, keepalive)
|
||||
.map_err(|(e, _keepalive)| e)?,
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(Box::new(IddReuseHandle))
|
||||
}
|
||||
|
||||
/// Thin per-session handle: every method delegates to the single persistent [`IddPushCapturer`].
|
||||
/// Dropping it (session end) does NOT tear down the ring/monitor — that's the whole point.
|
||||
struct IddReuseHandle;
|
||||
impl Capturer for IddReuseHandle {
|
||||
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
||||
IDD_PERSIST
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_mut()
|
||||
.context("IDD-push persistent capturer missing")?
|
||||
.next_frame()
|
||||
}
|
||||
fn try_latest(&mut self) -> Result<Option<CapturedFrame>> {
|
||||
IDD_PERSIST
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_mut()
|
||||
.context("IDD-push persistent capturer missing")?
|
||||
.try_latest()
|
||||
}
|
||||
fn set_active(&self, active: bool) {
|
||||
if let Some(c) = IDD_PERSIST.lock().unwrap().as_ref() {
|
||||
c.set_active(active);
|
||||
}
|
||||
}
|
||||
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
|
||||
IDD_PERSIST
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.and_then(|c| c.hdr_meta())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a permissive (Everyone:GenericAll) `SECURITY_ATTRIBUTES` so the restricted WUDFHost driver
|
||||
/// can OPEN the host-created objects — the same `D:(A;;GA;;;WD)` SDDL the gamepad shared section uses.
|
||||
/// The returned `psd` backing must outlive `sa`; both are dropped when the process exits.
|
||||
@@ -320,6 +289,8 @@ impl IddPushCapturer {
|
||||
&HSTRING::from(texture_name(target_id, generation, k)),
|
||||
)
|
||||
.context("CreateSharedHandle(IDD-push ring slot)")?;
|
||||
// Own the shared handle so the slot's `Drop` closes it via RAII (was a manual `CloseHandle`).
|
||||
let shared = OwnedHandle::from_raw_handle(shared.0 as _);
|
||||
let mutex: IDXGIKeyedMutex = tex.cast()?;
|
||||
let mut srv: Option<ID3D11ShaderResourceView> = None;
|
||||
device
|
||||
@@ -360,8 +331,22 @@ impl IddPushCapturer {
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
) -> Result<Self> {
|
||||
let (w, h, _hz) = preferred
|
||||
let (pw, ph, _hz) = preferred
|
||||
.context("IDD push needs the negotiated mode (WxH) to size the shared ring")?;
|
||||
// Size the ring to the display's ACTUAL current resolution if it differs from the negotiated mode:
|
||||
// a fullscreen game can hold the virtual display at a different mode (esp. across a reconnect), so
|
||||
// matching the actual mode lets the first frame flow instead of being dropped (game-capture bug
|
||||
// GB1). Falls back to the negotiated mode when the CCD read is unavailable.
|
||||
let (w, h) =
|
||||
unsafe { crate::win_display::active_resolution(target.target_id) }.unwrap_or((pw, ph));
|
||||
if (w, h) != (pw, ph) {
|
||||
tracing::info!(
|
||||
target_id = target.target_id,
|
||||
negotiated = format!("{pw}x{ph}"),
|
||||
actual = format!("{w}x{h}"),
|
||||
"IDD push: sizing the ring to the display's actual mode (differs from negotiated)"
|
||||
);
|
||||
}
|
||||
// The driver composes the virtual display in FP16 (R16G16B16A16_FLOAT scRGB) when the display is
|
||||
// in advanced-color (HDR) mode, and 8-bit BGRA otherwise (per swap_chain_processor.rs + the
|
||||
// COMMIT_MODES2 colorspace/rgb_bpc log). The user can flip "Use HDR" in Windows at any time, so
|
||||
@@ -411,13 +396,21 @@ impl IddPushCapturer {
|
||||
&HSTRING::from(header_name(target.target_id)),
|
||||
)
|
||||
.context("CreateFileMapping(IDD-push header)")?;
|
||||
let view = MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, bytes);
|
||||
// Own the mapping handle so it (and its view) free via `MappedSection` RAII even on bail.
|
||||
let map = OwnedHandle::from_raw_handle(map.0 as _);
|
||||
let view = MapViewOfFile(
|
||||
HANDLE(map.as_raw_handle()),
|
||||
FILE_MAP_ALL_ACCESS,
|
||||
0,
|
||||
0,
|
||||
bytes,
|
||||
);
|
||||
if view.Value.is_null() {
|
||||
let _ = CloseHandle(map);
|
||||
bail!("MapViewOfFile failed for IDD-push header");
|
||||
bail!("MapViewOfFile failed for IDD-push header"); // `map` drops → mapping closed
|
||||
}
|
||||
let section = MappedSection { handle: map, view };
|
||||
let generation = IDD_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
let header = view.Value.cast::<SharedHeader>();
|
||||
let header = section.ptr::<SharedHeader>();
|
||||
std::ptr::write_bytes(header.cast::<u8>(), 0, bytes);
|
||||
(*header).version = VERSION;
|
||||
(*header).generation = generation;
|
||||
@@ -436,6 +429,7 @@ impl IddPushCapturer {
|
||||
&HSTRING::from(event_name(target.target_id)),
|
||||
)
|
||||
.context("CreateEvent(IDD-push)")?;
|
||||
let event = OwnedHandle::from_raw_handle(event.0 as _);
|
||||
|
||||
// Ring of shared keyed-mutex textures, format matched to the display's current mode.
|
||||
let slots =
|
||||
@@ -443,7 +437,7 @@ impl IddPushCapturer {
|
||||
|
||||
// Bring-up debug block (fixed name) — the driver writes diagnostics here. Best-effort.
|
||||
let dbg_bytes = std::mem::size_of::<DebugBlock>();
|
||||
let (dbg_map, dbg_block) = match CreateFileMappingW(
|
||||
let (dbg_section, dbg_block) = match CreateFileMappingW(
|
||||
INVALID_HANDLE_VALUE,
|
||||
Some(&sa),
|
||||
PAGE_READWRITE,
|
||||
@@ -452,18 +446,29 @@ impl IddPushCapturer {
|
||||
&HSTRING::from(DBG_NAME),
|
||||
) {
|
||||
Ok(dm) => {
|
||||
let dv = MapViewOfFile(dm, FILE_MAP_ALL_ACCESS, 0, 0, dbg_bytes);
|
||||
// Own the mapping handle so it (and its view) free via `MappedSection` RAII.
|
||||
let dm = OwnedHandle::from_raw_handle(dm.0 as _);
|
||||
let dv = MapViewOfFile(
|
||||
HANDLE(dm.as_raw_handle()),
|
||||
FILE_MAP_ALL_ACCESS,
|
||||
0,
|
||||
0,
|
||||
dbg_bytes,
|
||||
);
|
||||
if dv.Value.is_null() {
|
||||
let _ = CloseHandle(dm);
|
||||
(HANDLE::default(), std::ptr::null_mut())
|
||||
(None, std::ptr::null_mut()) // `dm` drops → mapping closed
|
||||
} else {
|
||||
let p = dv.Value.cast::<DebugBlock>();
|
||||
let section = MappedSection {
|
||||
handle: dm,
|
||||
view: dv,
|
||||
};
|
||||
let p = section.ptr::<DebugBlock>();
|
||||
std::ptr::write_bytes(p.cast::<u8>(), 0, dbg_bytes);
|
||||
(*p).magic = DBG_MAGIC;
|
||||
(dm, p)
|
||||
(Some(section), p)
|
||||
}
|
||||
}
|
||||
Err(_) => (HANDLE::default(), std::ptr::null_mut()),
|
||||
Err(_) => (None, std::ptr::null_mut()),
|
||||
};
|
||||
|
||||
// Publish: magic LAST (Release) — signals the driver the ring is ready to open.
|
||||
@@ -484,10 +489,10 @@ impl IddPushCapturer {
|
||||
device,
|
||||
context,
|
||||
target_id: target.target_id,
|
||||
map,
|
||||
section,
|
||||
header,
|
||||
event,
|
||||
dbg_map,
|
||||
dbg_section,
|
||||
dbg_block,
|
||||
width: w,
|
||||
height: h,
|
||||
@@ -496,48 +501,58 @@ impl IddPushCapturer {
|
||||
client_10bit,
|
||||
display_hdr,
|
||||
last_acm_poll: Instant::now(),
|
||||
recovering_since: None,
|
||||
out_ring: Vec::new(),
|
||||
out_idx: 0,
|
||||
hdr_conv: None,
|
||||
last_seq: 0,
|
||||
last_present: None,
|
||||
status_logged: false,
|
||||
my_gen: crate::vdisplay::sudovda::CURRENT_MON_GEN.load(Ordering::Relaxed),
|
||||
// Placeholder; `open()` attaches the real keepalive on success, so a FAILED open can hand
|
||||
// it back to the caller for the DDA fallback (audit §5.1).
|
||||
_keepalive: Box::new(()),
|
||||
};
|
||||
// Bounded wait for the driver to ATTACH to the ring (it writes DRV_STATUS_OPENED). An attach
|
||||
// failure (e.g. the OS rendered the IDD on a different GPU than our ring → DRV_STATUS_TEX_FAIL)
|
||||
// becomes an open failure the caller falls back from, instead of next_frame's 20 s deadline.
|
||||
// Bounded wait for the driver to ATTACH to the ring AND publish a first frame. An attach
|
||||
// failure (DRV_STATUS_TEX_FAIL) or an attach-but-no-frames (a game left the display in a
|
||||
// format/size the ring can't match) becomes an open failure the caller falls back from (→ DDA),
|
||||
// instead of next_frame's 20 s black-then-bail.
|
||||
me.wait_for_attach()?;
|
||||
Ok(me)
|
||||
}
|
||||
}
|
||||
|
||||
/// Block (bounded) until the driver attaches to the host ring, else fail so the caller can fall back
|
||||
/// to DDA (audit §5.1). Checks `driver_status` (NOT frame arrival — an idle desktop may present no
|
||||
/// frame yet), so it never falsely fails on the happy path: the driver writes `DRV_STATUS_OPENED` as
|
||||
/// soon as it opens the ring textures, regardless of whether DWM has composed a frame.
|
||||
/// Block (bounded) until the driver has ATTACHED to the host ring (`DRV_STATUS_OPENED`) **and published
|
||||
/// a first frame**, else fail so the caller can fall back to DDA (audit §5.1 +
|
||||
/// `docs/windows-host-rewrite.md` §2.5 — the GB1 game-capture fix).
|
||||
///
|
||||
/// Requiring the first frame — not just the attach — catches the *reconnect-into-a-broken-state* case:
|
||||
/// a fullscreen game can leave the virtual display in a format/size that the driver's `publish()` guard
|
||||
/// rejects, so the driver ATTACHES but silently drops every frame; without this the host sails past
|
||||
/// `open()` and only dies on `next_frame`'s 20 s deadline (the "reconnect = black + audio" symptom). At
|
||||
/// session open the OS activates the virtual display → DWM composites it → a frame arrives within ~1 s,
|
||||
/// so this does not false-fail a normal (even idle) open; no frame within the window = genuinely broken.
|
||||
fn wait_for_attach(&self) -> Result<()> {
|
||||
let deadline = Instant::now() + Duration::from_secs(4);
|
||||
loop {
|
||||
// Plain read: the driver writes this u32; an aligned u32 read can't tear (same access as
|
||||
// log_driver_status_once).
|
||||
let st = unsafe { (*self.header).driver_status };
|
||||
match st {
|
||||
DRV_STATUS_OPENED => return Ok(()),
|
||||
DRV_STATUS_TEX_FAIL | DRV_STATUS_NO_DEVICE1 => {
|
||||
let detail = unsafe { (*self.header).driver_status_detail };
|
||||
bail!(
|
||||
"IDD-push driver failed to attach (driver_status={st} detail=0x{detail:08x} — \
|
||||
render-adapter mismatch?)"
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
if matches!(st, DRV_STATUS_TEX_FAIL | DRV_STATUS_NO_DEVICE1) {
|
||||
let detail = unsafe { (*self.header).driver_status_detail };
|
||||
bail!(
|
||||
"IDD-push driver failed to attach (driver_status={st} detail=0x{detail:08x} — \
|
||||
render-adapter mismatch?)"
|
||||
);
|
||||
}
|
||||
// Attached AND a frame has been published — the publish token's seq advances past 0.
|
||||
if st == DRV_STATUS_OPENED && frame::FrameToken::unpack(self.latest()).seq != 0 {
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
bail!("IDD-push driver did not attach within 4s (driver_status={st})");
|
||||
bail!(
|
||||
"IDD-push: driver_status={st} but no frame published within 4s — the virtual display \
|
||||
is likely in a format/size the ring can't match (fullscreen game?); falling back"
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
@@ -633,18 +648,14 @@ impl IddPushCapturer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the client's 10-bit capability (the reuse path). Only affects whether a fresh `open`
|
||||
/// proactively enables advanced color; the per-frame conversion follows the display, not the client.
|
||||
fn set_client_10bit(&mut self, client_10bit: bool) {
|
||||
self.client_10bit = client_10bit;
|
||||
}
|
||||
|
||||
/// Recreate the ring at the format for `new_display_hdr` (the user flipped "Use HDR"). Bumps the
|
||||
/// generation so the driver re-attaches ([`is_stale`]) to the new-format textures; clears the
|
||||
/// header's `latest` so we don't consume a stale slot from the old ring; drops the conversion
|
||||
/// textures so they rebuild at the new format.
|
||||
fn recreate_ring(&mut self, new_display_hdr: bool) -> Result<()> {
|
||||
fn recreate_ring(&mut self, new_display_hdr: bool, new_w: u32, new_h: u32) -> Result<()> {
|
||||
self.display_hdr = new_display_hdr;
|
||||
self.width = new_w;
|
||||
self.height = new_h;
|
||||
let fmt = self.ring_format();
|
||||
let new_gen = IDD_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
let new_slots = unsafe {
|
||||
@@ -665,6 +676,8 @@ impl IddPushCapturer {
|
||||
(*(std::ptr::addr_of!((*self.header).latest) as *const AtomicU64))
|
||||
.store(0, Ordering::Relaxed);
|
||||
(*self.header).dxgi_format = fmt.0 as u32;
|
||||
(*self.header).width = new_w;
|
||||
(*self.header).height = new_h;
|
||||
// Publish the new generation LAST (Release): when the driver observes it (Acquire) the new
|
||||
// textures already exist and the format is already updated.
|
||||
std::sync::atomic::fence(Ordering::Release);
|
||||
@@ -689,16 +702,23 @@ impl IddPushCapturer {
|
||||
}
|
||||
self.last_acm_poll = Instant::now();
|
||||
let now_hdr = unsafe { crate::win_display::advanced_color_enabled(self.target_id) };
|
||||
if now_hdr == self.display_hdr {
|
||||
// Follow the display's ACTUAL resolution too — a fullscreen game can mode-set the virtual display
|
||||
// out from under the negotiated size (game-capture bug GB1). Unknown read → keep our current size.
|
||||
let (now_w, now_h) = unsafe { crate::win_display::active_resolution(self.target_id) }
|
||||
.unwrap_or((self.width, self.height));
|
||||
if now_hdr == self.display_hdr && now_w == self.width && now_h == self.height {
|
||||
return;
|
||||
}
|
||||
tracing::info!(
|
||||
target_id = self.target_id,
|
||||
display_hdr = now_hdr,
|
||||
client_10bit = self.client_10bit,
|
||||
"IDD push: display HDR mode flipped — recreating the ring at the new format"
|
||||
from = format!("{}x{} hdr={}", self.width, self.height, self.display_hdr),
|
||||
to = format!("{now_w}x{now_h} hdr={now_hdr}"),
|
||||
"IDD push: display descriptor changed — recreating the ring at the new mode"
|
||||
);
|
||||
if let Err(e) = self.recreate_ring(now_hdr) {
|
||||
// Start the recovery clock (if not already running): if a fresh frame doesn't resume within the
|
||||
// window, try_consume drops the session rather than freeze.
|
||||
self.recovering_since.get_or_insert_with(Instant::now);
|
||||
if let Err(e) = self.recreate_ring(now_hdr, now_w, now_h) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "IDD push: ring recreate failed");
|
||||
}
|
||||
}
|
||||
@@ -755,6 +775,17 @@ impl IddPushCapturer {
|
||||
self.log_driver_status_once();
|
||||
// Follow the display: a "Use HDR" flip recreates the ring at the matching format.
|
||||
self.poll_display_hdr();
|
||||
// Recover-or-drop (GB1): if a descriptor change triggered a recreate but no fresh frame has resumed
|
||||
// within the window, the IDD-push path can't follow the display (e.g. an exclusive-flip) — drop the
|
||||
// session cleanly (the loop's `?` ends it → the client reconnects) rather than freeze forever.
|
||||
if let Some(since) = self.recovering_since {
|
||||
if since.elapsed() > Duration::from_secs(3) {
|
||||
bail!(
|
||||
"IDD-push: display descriptor changed and the ring could not recover within 3s — \
|
||||
dropping the session so the client reconnects"
|
||||
);
|
||||
}
|
||||
}
|
||||
let latest = self.latest();
|
||||
// `latest` is the proto publish token `(generation << 40) | (seq << 8) | slot`. Reject any publish
|
||||
// whose generation isn't our CURRENT ring (a stale old-ring publish racing a recreate, or the 0
|
||||
@@ -786,24 +817,31 @@ impl IddPushCapturer {
|
||||
// ~3 ms encode — NVENC reads the host out-ring slot, not the keyed-mutex slot), so the driver gets
|
||||
// the slot back immediately and the encode of the PREVIOUS frame overlaps this convert.
|
||||
let s = &self.slots[slot];
|
||||
if unsafe { s.mutex.AcquireSync(0, 8) }.is_err() {
|
||||
return Ok(None);
|
||||
}
|
||||
unsafe {
|
||||
if self.display_hdr {
|
||||
// Sample the FP16 slot's SRV directly (no scratch copy) → BT.2020 PQ Rgb10a2.
|
||||
if let Some(conv) = self.hdr_conv.as_ref() {
|
||||
conv.convert(&self.context, &s.srv, &out_rtv, self.width, self.height);
|
||||
// Acquire the slot's keyed mutex via a RAII guard, scoped to JUST the convert/copy below so it
|
||||
// releases at the same point as the old hand-written `ReleaseSync` (the driver gets the slot back
|
||||
// immediately, NOT held across the rest of `try_consume`) — but now leak-proof on any early return.
|
||||
{
|
||||
let Some(_lock) = KeyedMutexGuard::acquire(&s.mutex, 0, 8) else {
|
||||
return Ok(None);
|
||||
};
|
||||
// SAFETY: convert/copy on the owning (encode) thread's immediate context, holding the slot lock.
|
||||
unsafe {
|
||||
if self.display_hdr {
|
||||
// Sample the FP16 slot's SRV directly (no scratch copy) → BT.2020 PQ Rgb10a2.
|
||||
if let Some(conv) = self.hdr_conv.as_ref() {
|
||||
conv.convert(&self.context, &s.srv, &out_rtv, self.width, self.height);
|
||||
}
|
||||
} else {
|
||||
// SDR: the slot is already 8-bit BGRA — one copy into the out-ring (hidden by pipelining).
|
||||
self.context.CopyResource(&out, &s.tex);
|
||||
}
|
||||
} else {
|
||||
// SDR: the slot is already 8-bit BGRA — one copy into the out-ring (hidden by pipelining).
|
||||
self.context.CopyResource(&out, &s.tex);
|
||||
}
|
||||
let _ = s.mutex.ReleaseSync(0);
|
||||
// `_lock` drops here → `ReleaseSync(0)`.
|
||||
}
|
||||
self.out_idx = (i + 1) % self.out_ring.len();
|
||||
self.last_seq = seq;
|
||||
self.last_present = Some((out.clone(), pf));
|
||||
self.recovering_since = None; // a fresh frame resumed → recovered
|
||||
Ok(Some(CapturedFrame {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
@@ -897,7 +935,7 @@ impl Capturer for IddPushCapturer {
|
||||
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
loop {
|
||||
let _ = unsafe { WaitForSingleObject(self.event, 16) };
|
||||
let _ = unsafe { WaitForSingleObject(HANDLE(self.event.as_raw_handle()), 16) };
|
||||
if let Some(f) = self.try_consume()? {
|
||||
return Ok(f);
|
||||
}
|
||||
@@ -942,34 +980,15 @@ impl Capturer for IddPushCapturer {
|
||||
// NVENC encodes N on the ASIC. We hand a rotating `OUT_RING` of output textures, so this is safe.
|
||||
// `PUNKTFUNK_IDD_DEPTH` overrides (1 disables pipelining; clamp to ≤ OUT_RING so a frame in flight
|
||||
// always has its own texture).
|
||||
std::env::var("PUNKTFUNK_IDD_DEPTH")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<usize>().ok())
|
||||
.unwrap_or(2)
|
||||
.clamp(1, OUT_RING)
|
||||
crate::config::config().idd_depth.clamp(1, OUT_RING)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for IddPushCapturer {
|
||||
fn drop(&mut self) {
|
||||
self.slots.clear();
|
||||
unsafe {
|
||||
if !self.dbg_block.is_null() {
|
||||
let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS {
|
||||
Value: self.dbg_block.cast(),
|
||||
});
|
||||
}
|
||||
if !self.dbg_map.is_invalid() {
|
||||
let _ = CloseHandle(self.dbg_map);
|
||||
}
|
||||
if !self.header.is_null() {
|
||||
let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS {
|
||||
Value: self.header.cast(),
|
||||
});
|
||||
}
|
||||
let _ = CloseHandle(self.event);
|
||||
let _ = CloseHandle(self.map);
|
||||
}
|
||||
// The shared header + debug sections (`MappedSection`) and the frame-ready `event`
|
||||
// (`OwnedHandle`) free themselves via RAII (each unmaps its view, then closes its handle).
|
||||
// _keepalive drops after, REMOVEing the virtual display.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! `HostConfig` — the host's runtime knobs parsed ONCE from the environment, instead of the ~68 scattered
|
||||
//! `env::var` reads recomputed at every call site (some up to 8×, which lets capture + encode silently
|
||||
//! disagree on the resolved backend — plan §2.4). The service / launcher loads `host.env` into the process
|
||||
//! environment before the host starts, and **for the knobs captured here the environment is constant for the
|
||||
//! process lifetime**, so a lazily-parsed global is equivalent to "parsed once at startup".
|
||||
//!
|
||||
//! **Goal-1 stages 1–2** (`docs/windows-host-rewrite.md` §2.2): stage 1 stood this up; stage 2 migrated the
|
||||
//! genuinely-constant operator/dispatch knobs onto it (the dispatch-disagreement bug class: `idd_push`,
|
||||
//! `capture_backend`, `encoder_pref`, `render_adapter`, `no_wgc`, the vdisplay backend select — plus the
|
||||
//! plan-named `secure_dda`/`idd_depth`/`zerocopy`/`ten_bit` and the multi-site `perf`/`compositor`/
|
||||
//! `video_source`/`gamepad`). `SessionPlan` (stage 3) consumes it as the single owner of the
|
||||
//! capture/topology/encoder decision.
|
||||
//!
|
||||
//! **What is deliberately NOT here (and must stay a live `env::var` read):**
|
||||
//! - **Runtime-mutated session vars.** On Linux, [`crate::vdisplay::apply_session_env`] rewrites the process
|
||||
//! env on *every connect* so one host follows a Bazzite box across Gaming↔Desktop: `WAYLAND_DISPLAY`,
|
||||
//! `XDG_CURRENT_DESKTOP`, `XDG_RUNTIME_DIR`, `DBUS_SESSION_BUS_ADDRESS`, and the *derived* `PUNKTFUNK_*`
|
||||
//! vars `INPUT_BACKEND`, `GAMESCOPE_SESSION`/`GAMESCOPE_NODE`, `KWIN_VIRTUAL_PRIMARY`,
|
||||
//! `MUTTER_VIRTUAL_PRIMARY`, `FORCE_SHM` (+ `GAMESCOPE_APP` on the launch path). Parsing these once would
|
||||
//! freeze them at startup and silently break session-following — they are NOT constant.
|
||||
//! - **Single-use local tuning** read exactly where it is used (no resolve-once benefit, and a parse with a
|
||||
//! call-site-local default/clamp): e.g. `FEC_PCT` (two *different* semantics — GameStream default-20 vs
|
||||
//! punktfunk/1 `Option`/clamp-90), `VIDEO_DROP`, `VBV_FRAMES`, `SPLIT_ENCODE`, `PACE_BURST_KB`, the
|
||||
//! `capture/dxgi.rs` timing knobs, the `*_LIVE` test gates.
|
||||
//! - **Path / genuinely-dynamic reads**: the config-dir resolution, `PATH` executable search, the
|
||||
//! env-forward-to-child loop, `PUNKTFUNK_MGMT_TOKEN`, `PUNKTFUNK_HOST_CMD`, `PUNKTFUNK_RENDER_NODE`.
|
||||
//!
|
||||
//! `PUNKTFUNK_ZEROCOPY` note: this field uses **presence** semantics (`var_os(..).is_some()`) to match the
|
||||
//! Windows `encode/ffmpeg_win.rs` reader. The Linux `zerocopy` module keeps its own *truthy* parser
|
||||
//! (`1|true|yes|on`) — the two are independent features that share a name; do NOT conflate them.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Resolved host configuration. Holds the genuinely-constant operator/dispatch knobs (see module docs for
|
||||
/// what is deliberately excluded). Fields read on only one platform are kept alive cross-platform by the
|
||||
/// derived `Debug` impl, so the parser can stay a single platform-neutral function.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HostConfig {
|
||||
/// `PUNKTFUNK_IDD_PUSH` — capture from the pf-vdisplay driver's shared ring (in-process Session-0
|
||||
/// capture; no WGC helper). **Value-aware** (`0`/`false`/`no`/`off`/empty ⇒ off, else on); unset ⇒ off.
|
||||
/// The installer's default `host.env` sets it on, so a fresh install runs the validated IDD-push path
|
||||
/// (it falls back to DDA if the driver can't attach — see [`crate::capture`]). NOT a bare presence flag
|
||||
/// (so an operator can turn it OFF in `host.env` with `=0`, which a `var_os` presence check can't).
|
||||
pub idd_push: bool,
|
||||
/// `PUNKTFUNK_ENCODER` — explicit encoder-backend override (lowercased; empty = auto-detect by GPU vendor).
|
||||
pub encoder_pref: String,
|
||||
/// `PUNKTFUNK_NO_HELPER` — never spawn the user-session WGC helper.
|
||||
pub no_helper: bool,
|
||||
/// `PUNKTFUNK_FORCE_HELPER` — force the WGC helper even when not running as SYSTEM.
|
||||
pub force_helper: bool,
|
||||
/// `PUNKTFUNK_NO_WGC` — force the pure single-process DDA path (skip WGC and the two-process relay).
|
||||
pub no_wgc: bool,
|
||||
/// `PUNKTFUNK_CAPTURE` — explicit Windows capture-backend override (lowercased; `dda`/`dxgi` vs the WGC default).
|
||||
pub capture_backend: String,
|
||||
/// `PUNKTFUNK_RENDER_ADAPTER` — discrete render-GPU pin by description substring (`Some` even when empty:
|
||||
/// the empty string still counts as "set" for the presence checks, and the value reader filters it).
|
||||
pub render_adapter: Option<String>,
|
||||
/// `PUNKTFUNK_SECURE_DDA` — enable the experimental DDA-on-secure-desktop (Winlogon/UAC) mux leg.
|
||||
pub secure_dda: bool,
|
||||
/// `PUNKTFUNK_IDD_DEPTH` — IDD-push pipeline depth override (default 2; the call site clamps to its `OUT_RING`).
|
||||
pub idd_depth: usize,
|
||||
/// `PUNKTFUNK_ZEROCOPY` — opt into the Windows D3D11 zero-copy encode path (presence semantics; see module docs).
|
||||
pub zerocopy: bool,
|
||||
/// `PUNKTFUNK_10BIT` — host policy gate for HEVC Main10 (only honored when the client also advertised 10-bit).
|
||||
pub ten_bit: bool,
|
||||
/// `PUNKTFUNK_PERF` — per-stage timing instrumentation.
|
||||
pub perf: bool,
|
||||
/// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select (`virtual` / `portal` / unset → synthetic).
|
||||
pub video_source: Option<String>,
|
||||
/// `PUNKTFUNK_COMPOSITOR` — explicit compositor override (operator/CI/test). NOT the runtime-detected
|
||||
/// session — this one is a constant operator knob; `apply_session_env` never writes it.
|
||||
pub compositor: Option<String>,
|
||||
/// `PUNKTFUNK_GAMEPAD` — client/operator virtual-pad backend preference (fed to `pick_gamepad`).
|
||||
pub gamepad: Option<String>,
|
||||
/// `PUNKTFUNK_VDISPLAY` — Windows virtual-display backend. The pf-vdisplay IddCx driver is now the only
|
||||
/// backend (the legacy SudoVDA backend was removed), so this is currently informational — kept for the
|
||||
/// shipped `host.env` and as a forward seam if a second backend is ever added.
|
||||
pub vdisplay: Option<String>,
|
||||
}
|
||||
|
||||
impl HostConfig {
|
||||
fn from_env() -> Self {
|
||||
// Presence flag: set ⇒ true. Matches the original `var_os(k).is_some()` reads (and the few
|
||||
// `var(k).is_ok()` flag reads, which coincide for every real-world value).
|
||||
let flag = |k: &str| std::env::var_os(k).is_some();
|
||||
// String value: `var(k).ok()` — `Some` (possibly empty) when set with valid UTF-8, else `None`.
|
||||
let val = |k: &str| std::env::var(k).ok();
|
||||
Self {
|
||||
// Value-aware (not a bare presence flag): the shipped default `host.env` turns it ON, and an
|
||||
// operator turns it OFF with `PUNKTFUNK_IDD_PUSH=0` (a `var_os` presence check would read `=0`
|
||||
// as "on"). Unset ⇒ off (the dev / non-pf-driver default).
|
||||
idd_push: match std::env::var("PUNKTFUNK_IDD_PUSH") {
|
||||
Ok(v) => !matches!(
|
||||
v.trim().to_ascii_lowercase().as_str(),
|
||||
"" | "0" | "false" | "no" | "off"
|
||||
),
|
||||
Err(_) => false,
|
||||
},
|
||||
encoder_pref: std::env::var("PUNKTFUNK_ENCODER")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase(),
|
||||
no_helper: flag("PUNKTFUNK_NO_HELPER"),
|
||||
force_helper: flag("PUNKTFUNK_FORCE_HELPER"),
|
||||
no_wgc: flag("PUNKTFUNK_NO_WGC"),
|
||||
capture_backend: std::env::var("PUNKTFUNK_CAPTURE")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase(),
|
||||
render_adapter: val("PUNKTFUNK_RENDER_ADAPTER"),
|
||||
secure_dda: flag("PUNKTFUNK_SECURE_DDA"),
|
||||
idd_depth: val("PUNKTFUNK_IDD_DEPTH")
|
||||
.and_then(|s| s.parse::<usize>().ok())
|
||||
.unwrap_or(2),
|
||||
zerocopy: flag("PUNKTFUNK_ZEROCOPY"),
|
||||
ten_bit: flag("PUNKTFUNK_10BIT"),
|
||||
perf: flag("PUNKTFUNK_PERF"),
|
||||
video_source: val("PUNKTFUNK_VIDEO_SOURCE"),
|
||||
compositor: val("PUNKTFUNK_COMPOSITOR"),
|
||||
gamepad: val("PUNKTFUNK_GAMEPAD"),
|
||||
vdisplay: val("PUNKTFUNK_VDISPLAY"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The process-wide host configuration, parsed once on first access.
|
||||
pub fn config() -> &'static HostConfig {
|
||||
static CFG: OnceLock<HostConfig> = OnceLock::new();
|
||||
CFG.get_or_init(HostConfig::from_env)
|
||||
}
|
||||
@@ -71,9 +71,34 @@ impl Codec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Static capabilities an [`Encoder`] declares so the session glue routes loss-recovery and HDR
|
||||
/// plumbing by *query* rather than relying on a method's no-op/`false` default. Cheap `Copy`; fixed
|
||||
/// for the session (an HDR toggle re-initialises the encoder — re-query if that matters).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct EncoderCaps {
|
||||
/// The encoder can perform real reference-frame invalidation — i.e.
|
||||
/// [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) can return `true`. When `false`
|
||||
/// the caller skips that always-`false` call and forces a keyframe directly on loss recovery.
|
||||
/// Only the Windows direct-NVENC path implements RFI; libavcodec (Linux NVENC), VAAPI and
|
||||
/// AMF/QSV always keyframe.
|
||||
pub supports_rfi: bool,
|
||||
/// The encoder emits in-band HDR mastering/CLL SEI from [`set_hdr_meta`](Encoder::set_hdr_meta).
|
||||
/// When `false`, `set_hdr_meta` is a no-op and no in-band grade reaches the client. Only the
|
||||
/// Windows direct-NVENC path attaches it today.
|
||||
pub supports_hdr_metadata: bool,
|
||||
}
|
||||
|
||||
/// A hardware encoder. One per session; runs on the encode thread.
|
||||
pub trait Encoder: Send {
|
||||
fn submit(&mut self, frame: &CapturedFrame) -> Result<()>;
|
||||
/// This encoder's static [capabilities](EncoderCaps) (RFI, HDR SEI), so the session glue can
|
||||
/// route by query rather than rely on the no-op/`false` defaults of
|
||||
/// [`invalidate_ref_frames`](Self::invalidate_ref_frames) / [`set_hdr_meta`](Self::set_hdr_meta).
|
||||
/// Default: no optional capabilities (the SDR / libavcodec backends) — only the direct-NVENC
|
||||
/// path overrides it.
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
EncoderCaps::default()
|
||||
}
|
||||
/// Force the next submitted frame to be an IDR keyframe (e.g. after a client
|
||||
/// reference-frame-invalidation request). Default: no-op.
|
||||
fn request_keyframe(&mut self) {}
|
||||
@@ -173,14 +198,12 @@ pub fn open_video(
|
||||
// AMD/Intel → VAAPI (one libavcodec backend for both). Auto-detect by default so a single
|
||||
// Linux binary serves any GPU; `PUNKTFUNK_ENCODER` forces a specific backend (and surfaces
|
||||
// its errors crisply instead of silently trying the other).
|
||||
let pref = std::env::var("PUNKTFUNK_ENCODER")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
let pref = crate::config::config().encoder_pref.as_str();
|
||||
let open_vaapi = || -> Result<Box<dyn Encoder>> {
|
||||
vaapi::VaapiEncoder::open(codec, format, width, height, fps, bitrate_bps, bit_depth)
|
||||
.map(|e| Box::new(e) as Box<dyn Encoder>)
|
||||
};
|
||||
match pref.as_str() {
|
||||
match pref {
|
||||
"nvenc" | "nvidia" | "cuda" => open_nvenc_probed(
|
||||
codec,
|
||||
format,
|
||||
@@ -379,11 +402,7 @@ fn nvidia_present() -> bool {
|
||||
/// passthrough for VAAPI vs the EGL→CUDA import for NVENC).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn linux_zero_copy_is_vaapi() -> bool {
|
||||
match std::env::var("PUNKTFUNK_ENCODER")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
match crate::config::config().encoder_pref.as_str() {
|
||||
"nvenc" | "nvidia" | "cuda" => false,
|
||||
"vaapi" | "amd" | "intel" => true,
|
||||
_ => !nvidia_present(),
|
||||
@@ -450,10 +469,8 @@ enum GpuVendor {
|
||||
/// vendor). Shared by [`open_video`] and the GameStream codec advertisement so both agree.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn windows_resolved_backend() -> WindowsBackend {
|
||||
let pref = std::env::var("PUNKTFUNK_ENCODER")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
match pref.as_str() {
|
||||
// Resolved ONCE in HostConfig (Goal-1) — was re-read from PUNKTFUNK_ENCODER on every call.
|
||||
match crate::config::config().encoder_pref.as_str() {
|
||||
"nvenc" | "hw" | "nvidia" | "cuda" => WindowsBackend::Nvenc,
|
||||
"amf" | "amd" => WindowsBackend::Amf,
|
||||
"qsv" | "intel" => WindowsBackend::Qsv,
|
||||
@@ -539,15 +556,21 @@ pub fn windows_codec_support() -> CodecSupport {
|
||||
})
|
||||
}
|
||||
|
||||
// Goal-1 stage 6: GPU/CPU encoders confined to `encode/windows/` (NVENC, AMF/QSV ffmpeg, software) and
|
||||
// `encode/linux/` (NVENC/CUDA + VAAPI); `#[path]` keeps the `crate::encode::*` module names flat.
|
||||
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
|
||||
#[path = "encode/windows/ffmpeg_win.rs"]
|
||||
mod ffmpeg_win;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
#[cfg(all(target_os = "windows", feature = "nvenc"))]
|
||||
#[path = "encode/windows/nvenc.rs"]
|
||||
mod nvenc;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "encode/windows/sw.rs"]
|
||||
mod sw;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "encode/linux/vaapi.rs"]
|
||||
mod vaapi;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@ impl WinVendor {
|
||||
/// Is the zero-copy D3D11 path enabled? Opt-in (`PUNKTFUNK_ZEROCOPY=1`) until on-glass validated;
|
||||
/// the default is the robust system-memory readback path.
|
||||
fn zerocopy_enabled() -> bool {
|
||||
std::env::var_os("PUNKTFUNK_ZEROCOPY").is_some()
|
||||
crate::config::config().zerocopy
|
||||
}
|
||||
|
||||
/// The swscale *source* pixel format for a captured packed-RGB/BGR layout (8-bit BGRA fallback only).
|
||||
+10
-1
@@ -13,7 +13,7 @@
|
||||
//! Needs a real NVIDIA GPU at runtime (session creation fails otherwise) — compiles GPU-less, but
|
||||
//! `open`/`submit` only succeed on a GPU box. The software encoder (`super::sw`) is the fallback.
|
||||
|
||||
use super::{Codec, EncodedFrame, Encoder};
|
||||
use super::{Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
@@ -732,6 +732,15 @@ impl Encoder for NvencD3d11Encoder {
|
||||
self.force_kf = true;
|
||||
}
|
||||
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
// RFI is probed once at open (`rfi_supported`); HDR SEI rides keyframes whenever the
|
||||
// session is in HDR mode. Both are the real capabilities the session glue routes on.
|
||||
EncoderCaps {
|
||||
supports_rfi: self.rfi_supported,
|
||||
supports_hdr_metadata: self.hdr,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_hdr_meta(&mut self, meta: Option<punktfunk_core::quic::HdrMeta>) {
|
||||
// Stored and emitted as in-band SEI on the next keyframe (see `submit`). Cheap to call every
|
||||
// frame; only changes when the source is regraded or HDR toggles.
|
||||
@@ -102,7 +102,7 @@ fn run(
|
||||
// request and capture it (no scaling). Self-contained — deliberately NOT pooled in
|
||||
// `video_cap`, since a reconnect at a different resolution needs a freshly-sized output; the
|
||||
// output is released when this capturer drops at stream end (RAII via its keepalive).
|
||||
if std::env::var("PUNKTFUNK_VIDEO_SOURCE").as_deref() == Ok("virtual") {
|
||||
if crate::config::config().video_source.as_deref() == Some("virtual") {
|
||||
// The launched app picks the compositor (e.g. gamescope for game entries) and the
|
||||
// nested command.
|
||||
let compositor = app
|
||||
@@ -134,8 +134,12 @@ fn run(
|
||||
// IDD-push bypasses WGC.) Acceptable for the experimental IDD-push A/B path; HDR over IDD-push
|
||||
// is wired only for punktfunk/1 (want_hdr = negotiated bit_depth >= 10). TODO: derive want_hdr
|
||||
// from a GameStream HDR flag once StreamConfig carries one.
|
||||
let mut capturer =
|
||||
capture::capture_virtual_output(vout, false).context("capture virtual output")?;
|
||||
let mut capturer = capture::capture_virtual_output(
|
||||
vout,
|
||||
capture::OutputFormat::resolve(false),
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
)
|
||||
.context("capture virtual output")?;
|
||||
capturer.set_active(true);
|
||||
return stream_body(&mut *capturer, &sock, cfg, running, force_idr, rfi_range);
|
||||
}
|
||||
@@ -147,7 +151,7 @@ fn run(
|
||||
tracing::info!("video source: reusing capturer");
|
||||
c
|
||||
}
|
||||
None if std::env::var("PUNKTFUNK_VIDEO_SOURCE").is_ok_and(|v| v == "portal") => {
|
||||
None if crate::config::config().video_source.as_deref() == Some("portal") => {
|
||||
tracing::info!("video source: portal desktop capture");
|
||||
capture::open_portal_monitor().context("open portal capturer")?
|
||||
}
|
||||
@@ -358,11 +362,15 @@ fn stream_body(
|
||||
|
||||
// Per-stage timing (PUNKTFUNK_PERF=1): max µs/stage per second + unique vs re-encoded frames,
|
||||
// to pinpoint stalls. `unique` counts genuinely-new captured frames (vs re-encoded holds).
|
||||
let perf = std::env::var_os("PUNKTFUNK_PERF").is_some();
|
||||
let perf = crate::config::config().perf;
|
||||
let (mut mx_cap, mut mx_enc, mut mx_pkt, mut mx_send, mut mx_pkts, mut uniq) =
|
||||
(0u128, 0u128, 0u128, 0u128, 0usize, 0u32);
|
||||
// Absolute next-frame deadline — the single pacing clock for the loop.
|
||||
let mut next_frame = Instant::now();
|
||||
// RFI capability is fixed for the session (probed at encoder open). Query it once so the
|
||||
// recovery path skips the always-`false` invalidate call on encoders without NVENC RFI and
|
||||
// forces a keyframe directly instead.
|
||||
let supports_rfi = enc.caps().supports_rfi;
|
||||
|
||||
while running.load(Ordering::SeqCst) {
|
||||
let tick = Instant::now();
|
||||
@@ -376,7 +384,9 @@ fn stream_body(
|
||||
// re-references an older still-valid frame — no costly IDR spike); if the encoder can't
|
||||
// invalidate (range too old, or no NVENC RFI) it returns false and we force a keyframe.
|
||||
if let Some((first, last)) = rfi_range.lock().unwrap().take() {
|
||||
if !enc.invalidate_ref_frames(first, last) {
|
||||
// Prefer reference-frame invalidation when the encoder supports it (no costly IDR
|
||||
// spike); otherwise — or if the range is too old to invalidate — force a keyframe.
|
||||
if !(supports_rfi && enc.invalidate_ref_frames(first, last)) {
|
||||
enc.request_keyframe();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,8 +112,10 @@ pub fn default_backend() -> Backend {
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
if std::env::var("PUNKTFUNK_COMPOSITOR")
|
||||
.is_ok_and(|v| v.trim().eq_ignore_ascii_case("gamescope"))
|
||||
if crate::config::config()
|
||||
.compositor
|
||||
.as_deref()
|
||||
.is_some_and(|v| v.trim().eq_ignore_ascii_case("gamescope"))
|
||||
{
|
||||
return Backend::GamescopeEi;
|
||||
}
|
||||
@@ -260,8 +262,10 @@ fn coalesce(events: Vec<InputEvent>) -> Vec<InputEvent> {
|
||||
/// (`org.gnome.Mutter.RemoteDesktop`), the same direct API the Mutter video backend uses.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn libei_ei_source() -> libei::EiSource {
|
||||
let gnome = std::env::var("PUNKTFUNK_COMPOSITOR")
|
||||
.is_ok_and(|v| v.trim().eq_ignore_ascii_case("mutter"))
|
||||
let gnome = crate::config::config()
|
||||
.compositor
|
||||
.as_deref()
|
||||
.is_some_and(|v| v.trim().eq_ignore_ascii_case("mutter"))
|
||||
|| std::env::var("XDG_CURRENT_DESKTOP")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_uppercase()
|
||||
@@ -421,30 +425,45 @@ fn gs_button_to_evdev(b: u32) -> Option<u32> {
|
||||
})
|
||||
}
|
||||
|
||||
// Goal-1 stage 6: Linux UHID/uinput/libei/wlr backends under `inject/linux/`, the Windows UMDF/SendInput
|
||||
// backends under `inject/windows/`, and the transport-independent HID codecs under `inject/proto/`;
|
||||
// `#[path]` keeps every `crate::inject::*` module name flat.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/dualsense.rs"]
|
||||
pub mod dualsense;
|
||||
/// Transport-independent DualSense HID contract, shared by the Linux UHID backend ([`dualsense`])
|
||||
/// and the Windows UMDF-driver backend ([`dualsense_windows`]).
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
#[path = "inject/proto/dualsense_proto.rs"]
|
||||
pub mod dualsense_proto;
|
||||
/// Windows: virtual DualSense via the UMDF minidriver + a shared-memory host channel.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "inject/windows/dualsense_windows.rs"]
|
||||
pub mod dualsense_windows;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/dualshock4.rs"]
|
||||
pub mod dualshock4;
|
||||
/// Transport-independent DualShock 4 HID codec used by the Windows UMDF-driver backend
|
||||
/// ([`dualshock4_windows`]). (The Linux backend still carries its own copy — see the module FIXME.)
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
#[path = "inject/proto/dualshock4_proto.rs"]
|
||||
pub mod dualshock4_proto;
|
||||
/// Windows: virtual DualShock 4 via the same UMDF minidriver + shared-memory channel (device-type 1).
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "inject/windows/dualshock4_windows.rs"]
|
||||
pub mod dualshock4_windows;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/gamepad.rs"]
|
||||
pub mod gamepad;
|
||||
/// Windows: virtual Xbox 360 pads via the in-tree XUSB companion UMDF driver (classic XInput).
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "inject/gamepad_windows.rs"]
|
||||
#[path = "inject/windows/gamepad_windows.rs"]
|
||||
pub mod gamepad;
|
||||
/// Windows: small RAII wrappers (`Shm` section+view, `SwDevice` devnode) shared by the three gamepad
|
||||
/// backends (DualSense / DualShock 4 / XUSB), so each per-pad resource closes deterministically on drop.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "inject/windows/gamepad_raii.rs"]
|
||||
mod gamepad_raii;
|
||||
/// Stub — virtual gamepads need Linux uinput or the Windows UMDF drivers; events are dropped elsewhere.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
pub mod gamepad {
|
||||
@@ -459,10 +478,13 @@ pub mod gamepad {
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/libei.rs"]
|
||||
mod libei;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "inject/windows/sendinput.rs"]
|
||||
mod sendinput;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/wlr.rs"]
|
||||
mod wlr;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+30
-96
@@ -29,42 +29,34 @@ use windows::core::{w, GUID, HRESULT, HSTRING, PCWSTR};
|
||||
use windows::Win32::Devices::Enumeration::Pnp::{
|
||||
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
|
||||
};
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE};
|
||||
use windows::Win32::Security::Authorization::{
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
|
||||
};
|
||||
use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
|
||||
use windows::Win32::System::Memory::{
|
||||
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
|
||||
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
|
||||
};
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
||||
use windows::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject};
|
||||
|
||||
/// Shared-section layout — the single source of truth is [`pf_vdisplay_proto::gamepad::PadShm`] (offset
|
||||
/// Shared-section layout — the single source of truth is [`pf_driver_proto::gamepad::PadShm`] (offset
|
||||
/// asserts pin every field; the `pf_dualsense` driver maps the same struct). Derive the size/offsets/magic
|
||||
/// from it so a layout change is a compile error, not a hand-synced literal (audit §6.1). `pub(super)` so
|
||||
/// the sibling DualShock 4 backend ([`super::dualshock4_windows`]) reuses the exact offsets.
|
||||
pub(super) const SHM_SIZE: usize = core::mem::size_of::<pf_vdisplay_proto::gamepad::PadShm>();
|
||||
pub(super) const SHM_MAGIC: u32 = pf_vdisplay_proto::gamepad::PAD_MAGIC; // "PFDS"
|
||||
pub(super) const OFF_INPUT: usize = core::mem::offset_of!(pf_vdisplay_proto::gamepad::PadShm, input);
|
||||
pub(super) const SHM_SIZE: usize = core::mem::size_of::<pf_driver_proto::gamepad::PadShm>();
|
||||
pub(super) const SHM_MAGIC: u32 = pf_driver_proto::gamepad::PAD_MAGIC; // "PFDS"
|
||||
pub(super) const OFF_INPUT: usize = core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, input);
|
||||
pub(super) const OFF_OUT_SEQ: usize =
|
||||
core::mem::offset_of!(pf_vdisplay_proto::gamepad::PadShm, out_seq);
|
||||
pub(super) const OFF_OUTPUT: usize = core::mem::offset_of!(pf_vdisplay_proto::gamepad::PadShm, output);
|
||||
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, out_seq);
|
||||
pub(super) const OFF_OUTPUT: usize = core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, output);
|
||||
/// Device-type selector the driver reads to choose which HID identity/descriptor it serves: 0 =
|
||||
/// DualSense (the default — the section is zeroed), 1 = DualShock 4.
|
||||
pub(super) const OFF_DEVTYPE: usize =
|
||||
core::mem::offset_of!(pf_vdisplay_proto::gamepad::PadShm, device_type);
|
||||
pub(super) const DEVTYPE_DUALSHOCK4: u8 = pf_vdisplay_proto::gamepad::DEVTYPE_DUALSHOCK4;
|
||||
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, device_type);
|
||||
pub(super) const DEVTYPE_DUALSHOCK4: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSHOCK4;
|
||||
|
||||
/// A single virtual DualSense: the SwDeviceCreate'd `pf_pad_<index>` software devnode (the driver
|
||||
/// loads on it and the HID DualSense appears to games) plus the shared-memory section the driver maps.
|
||||
/// Dropping it removes the devnode (`SwDeviceClose`) and unmaps + closes the section.
|
||||
struct DsWinPad {
|
||||
/// Per-session devnode from SwDeviceCreate, when it succeeds. `None` falls back to an out-of-band
|
||||
/// `pf_dualsense` devnode (installer/devgen).
|
||||
hsw: Option<HSWDEVICE>,
|
||||
map: HANDLE,
|
||||
view: *mut u8,
|
||||
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
|
||||
/// `None` falls back to an out-of-band `pf_dualsense` devnode (installer/devgen).
|
||||
_sw: Option<super::gamepad_raii::SwDevice>,
|
||||
/// The named shared section the driver maps (RAII — unmapped + closed on drop).
|
||||
shm: super::gamepad_raii::Shm,
|
||||
seq: u8,
|
||||
ts: u32,
|
||||
last_out_seq: u32,
|
||||
@@ -238,62 +230,16 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<HSWDEVICE> {
|
||||
Ok(hsw)
|
||||
}
|
||||
|
||||
/// Create + map the named section `Global\pfds-shm-<index>`, zeroed, with a permissive DACL so the
|
||||
/// WUDFHost (whatever account it runs as) can open it. Returns `(section handle, mapped base)`; the
|
||||
/// caller stamps the device-type + initial input report and finally the magic. Shared by both Windows
|
||||
/// pad backends (DualSense + DualShock 4).
|
||||
pub(super) fn create_shm_section(index: u8) -> Result<(HANDLE, *mut u8)> {
|
||||
let name = HSTRING::from(pf_vdisplay_proto::gamepad::pad_shm_name(index));
|
||||
|
||||
let mut psd = PSECURITY_DESCRIPTOR::default();
|
||||
// SAFETY: the SDDL literal is valid; psd receives an allocated descriptor (freed by the OS when
|
||||
// the process exits — acceptable for a host-lifetime object).
|
||||
unsafe {
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
w!("D:(A;;GA;;;WD)"),
|
||||
SDDL_REVISION_1,
|
||||
&mut psd,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
let sa = SECURITY_ATTRIBUTES {
|
||||
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: psd.0,
|
||||
bInheritHandle: false.into(),
|
||||
};
|
||||
|
||||
// SAFETY: anonymous (pagefile-backed) section of SHM_SIZE bytes with the SDDL above.
|
||||
let map = unsafe {
|
||||
CreateFileMappingW(
|
||||
INVALID_HANDLE_VALUE,
|
||||
Some(&sa),
|
||||
PAGE_READWRITE,
|
||||
0,
|
||||
SHM_SIZE as u32,
|
||||
PCWSTR(name.as_ptr()),
|
||||
)?
|
||||
};
|
||||
// SAFETY: map is a valid section handle; map the whole thing.
|
||||
let view = unsafe { MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, SHM_SIZE) };
|
||||
if view.Value.is_null() {
|
||||
// SAFETY: map is valid.
|
||||
unsafe {
|
||||
let _ = CloseHandle(map);
|
||||
}
|
||||
return Err(anyhow!("MapViewOfFile failed for {name}"));
|
||||
}
|
||||
let base = view.Value as *mut u8;
|
||||
// SAFETY: base points at SHM_SIZE writable bytes.
|
||||
unsafe { std::ptr::write_bytes(base, 0, SHM_SIZE) };
|
||||
Ok((map, base))
|
||||
}
|
||||
|
||||
impl DsWinPad {
|
||||
/// Create + map the section `Global\pfds-shm-<index>`, stamp the magic, then spawn the
|
||||
/// `root\pf_dualsense` devnode (the driver loads on it and maps the section). The devnode lives
|
||||
/// for the pad's lifetime — dropping the pad removes it (`SwDeviceClose`).
|
||||
fn open(index: u8) -> Result<DsWinPad> {
|
||||
let (map, base) = create_shm_section(index)?;
|
||||
let shm = super::gamepad_raii::Shm::create(
|
||||
&HSTRING::from(pf_driver_proto::gamepad::pad_shm_name(index)),
|
||||
SHM_SIZE,
|
||||
)?;
|
||||
let base = shm.base();
|
||||
// Stamp the neutral input report, then the magic LAST (the driver only accepts the section
|
||||
// once magic is set). The device-type stays 0 (DualSense — the section is already zeroed).
|
||||
// SAFETY: base points at SHM_SIZE writable bytes.
|
||||
@@ -322,10 +268,10 @@ impl DsWinPad {
|
||||
None
|
||||
}
|
||||
};
|
||||
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
||||
Ok(DsWinPad {
|
||||
hsw,
|
||||
map,
|
||||
view: base,
|
||||
_sw,
|
||||
shm,
|
||||
seq: 0,
|
||||
ts: 0,
|
||||
last_out_seq: 0,
|
||||
@@ -338,22 +284,25 @@ impl DsWinPad {
|
||||
self.ts = self.ts.wrapping_add(1);
|
||||
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, st, self.seq, self.ts);
|
||||
// SAFETY: view points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
|
||||
unsafe { std::ptr::copy_nonoverlapping(r.as_ptr(), self.view.add(OFF_INPUT), r.len()) };
|
||||
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(r.as_ptr(), self.shm.base().add(OFF_INPUT), r.len())
|
||||
};
|
||||
}
|
||||
|
||||
/// Poll the section's output slot; parse a new `0x02` report (rumble / LEDs / triggers) into a
|
||||
/// [`DsFeedback`] for pad `pad`. Returns empty feedback if the driver hasn't published anything new.
|
||||
fn service(&mut self, pad: u8) -> DsFeedback {
|
||||
let mut fb = DsFeedback::default();
|
||||
// SAFETY: view points at SHM_SIZE bytes.
|
||||
let seq = unsafe { std::ptr::read_unaligned(self.view.add(OFF_OUT_SEQ) as *const u32) };
|
||||
// SAFETY: base points at SHM_SIZE bytes.
|
||||
let seq =
|
||||
unsafe { std::ptr::read_unaligned(self.shm.base().add(OFF_OUT_SEQ) as *const u32) };
|
||||
if seq != self.last_out_seq {
|
||||
self.last_out_seq = seq;
|
||||
let mut out = [0u8; 64];
|
||||
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(self.view.add(OFF_OUTPUT), out.as_mut_ptr(), 64)
|
||||
std::ptr::copy_nonoverlapping(self.shm.base().add(OFF_OUTPUT), out.as_mut_ptr(), 64)
|
||||
};
|
||||
parse_ds_output(pad, &out, &mut fb);
|
||||
}
|
||||
@@ -361,21 +310,6 @@ impl DsWinPad {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DsWinPad {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: hsw (if any) owns the devnode; view/map from MapViewOfFile/CreateFileMappingW.
|
||||
unsafe {
|
||||
if let Some(h) = self.hsw {
|
||||
SwDeviceClose(h);
|
||||
}
|
||||
let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS {
|
||||
Value: self.view as *mut c_void,
|
||||
});
|
||||
let _ = CloseHandle(self.map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual DualSense pads of a session — the Windows analogue of
|
||||
/// [`DualSenseManager`](super::dualsense::DualSenseManager). Same method surface so the session input
|
||||
/// thread drives either backend identically.
|
||||
+23
-33
@@ -9,8 +9,8 @@
|
||||
|
||||
use super::dualsense_proto::DsState;
|
||||
use super::dualsense_windows::{
|
||||
create_shm_section, create_swdevice, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE,
|
||||
OFF_INPUT, OFF_OUTPUT, OFF_OUT_SEQ, SHM_MAGIC,
|
||||
create_swdevice, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE, OFF_INPUT, OFF_OUTPUT,
|
||||
OFF_OUT_SEQ, SHM_MAGIC, SHM_SIZE,
|
||||
};
|
||||
use super::dualshock4_proto::{
|
||||
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_TOUCH_H, DS4_TOUCH_W,
|
||||
@@ -18,18 +18,16 @@ use super::dualshock4_proto::{
|
||||
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
|
||||
use anyhow::Result;
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
use std::ffi::c_void;
|
||||
use std::time::{Duration, Instant};
|
||||
use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE};
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
||||
use windows::Win32::System::Memory::{UnmapViewOfFile, MEMORY_MAPPED_VIEW_ADDRESS};
|
||||
use windows::core::HSTRING;
|
||||
|
||||
/// A single virtual DualShock 4: the `SwDeviceCreate`'d `pf_ds4_<index>` devnode plus the mapped
|
||||
/// shared section. Dropping it removes the devnode and unmaps + closes the section.
|
||||
struct Ds4WinPad {
|
||||
hsw: Option<HSWDEVICE>,
|
||||
map: HANDLE,
|
||||
view: *mut u8,
|
||||
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
|
||||
_sw: Option<super::gamepad_raii::SwDevice>,
|
||||
/// The named shared section the driver maps (RAII — unmapped + closed on drop).
|
||||
shm: super::gamepad_raii::Shm,
|
||||
counter: u8,
|
||||
ts: u16,
|
||||
last_out_seq: u32,
|
||||
@@ -39,7 +37,11 @@ impl Ds4WinPad {
|
||||
/// Create + map the section, stamp `device_type = DualShock 4` + a neutral report + the magic,
|
||||
/// then spawn the `pf_ds4_<index>` devnode (the driver loads on it and maps the section).
|
||||
fn open(index: u8) -> Result<Ds4WinPad> {
|
||||
let (map, base) = create_shm_section(index)?;
|
||||
let shm = super::gamepad_raii::Shm::create(
|
||||
&HSTRING::from(pf_driver_proto::gamepad::pad_shm_name(index)),
|
||||
SHM_SIZE,
|
||||
)?;
|
||||
let base = shm.base();
|
||||
// device-type FIRST (so it's visible the moment magic is), neutral report, magic LAST.
|
||||
// SAFETY: base points at SHM_SIZE writable bytes; OFF_DEVTYPE/OFF_INPUT are in range.
|
||||
unsafe {
|
||||
@@ -65,10 +67,10 @@ impl Ds4WinPad {
|
||||
None
|
||||
}
|
||||
};
|
||||
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
||||
Ok(Ds4WinPad {
|
||||
hsw,
|
||||
map,
|
||||
view: base,
|
||||
_sw,
|
||||
shm,
|
||||
counter: 0,
|
||||
ts: 0,
|
||||
last_out_seq: 0,
|
||||
@@ -81,22 +83,25 @@ impl Ds4WinPad {
|
||||
self.ts = self.ts.wrapping_add(188); // ~1ms in the DS4's 5.33µs sensor-clock units
|
||||
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, st, self.counter, self.ts);
|
||||
// SAFETY: view points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
|
||||
unsafe { std::ptr::copy_nonoverlapping(r.as_ptr(), self.view.add(OFF_INPUT), r.len()) };
|
||||
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(r.as_ptr(), self.shm.base().add(OFF_INPUT), r.len())
|
||||
};
|
||||
}
|
||||
|
||||
/// Poll the section's output slot; parse a new `0x05` report (rumble / lightbar) into a
|
||||
/// [`Ds4Feedback`]. Returns empty feedback if the driver hasn't published anything new.
|
||||
fn service(&mut self) -> Ds4Feedback {
|
||||
let mut fb = Ds4Feedback::default();
|
||||
// SAFETY: view points at SHM_SIZE bytes.
|
||||
let seq = unsafe { std::ptr::read_unaligned(self.view.add(OFF_OUT_SEQ) as *const u32) };
|
||||
// SAFETY: base points at SHM_SIZE bytes.
|
||||
let seq =
|
||||
unsafe { std::ptr::read_unaligned(self.shm.base().add(OFF_OUT_SEQ) as *const u32) };
|
||||
if seq != self.last_out_seq {
|
||||
self.last_out_seq = seq;
|
||||
let mut out = [0u8; 64];
|
||||
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(self.view.add(OFF_OUTPUT), out.as_mut_ptr(), 64)
|
||||
std::ptr::copy_nonoverlapping(self.shm.base().add(OFF_OUTPUT), out.as_mut_ptr(), 64)
|
||||
};
|
||||
parse_ds4_output(&out, &mut fb);
|
||||
}
|
||||
@@ -104,21 +109,6 @@ impl Ds4WinPad {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Ds4WinPad {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: hsw (if any) owns the devnode; view/map from MapViewOfFile/CreateFileMappingW.
|
||||
unsafe {
|
||||
if let Some(h) = self.hsw {
|
||||
SwDeviceClose(h);
|
||||
}
|
||||
let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS {
|
||||
Value: self.view as *mut c_void,
|
||||
});
|
||||
let _ = CloseHandle(self.map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual DualShock 4 pads of a session — the Windows analogue of
|
||||
/// [`DualShock4Manager`](super::dualshock4::DualShock4Manager), with the same method surface as the
|
||||
/// Windows DualSense manager so the session input thread drives either backend identically.
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Per-pad Windows resource RAII for the gamepad backends (DualSense / DualShock 4 / XUSB).
|
||||
//!
|
||||
//! Each virtual pad owns two OS resources: the named shared-memory section (+ its mapped view) the
|
||||
//! `pf_dualsense`/`pf_xusb` driver reads, and the `SwDeviceCreate`'d software devnode the driver loads
|
||||
//! on. Before this module, all three backends hand-rolled the same `CreateFileMappingW` +
|
||||
//! `MapViewOfFile` and an identical `Drop` doing `SwDeviceClose` + `UnmapViewOfFile` + `CloseHandle` —
|
||||
//! easy to drift or leak on an error path. [`Shm`] and [`SwDevice`] own those resources with RAII, so a
|
||||
//! backend just holds them and the cleanup (and ordering) happens by construction.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::os::windows::io::{FromRawHandle, OwnedHandle};
|
||||
use windows::core::{w, HSTRING, PCWSTR};
|
||||
use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE};
|
||||
use windows::Win32::Foundation::INVALID_HANDLE_VALUE;
|
||||
use windows::Win32::Security::Authorization::{
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
|
||||
};
|
||||
use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
|
||||
use windows::Win32::System::Memory::{
|
||||
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
|
||||
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
|
||||
};
|
||||
|
||||
/// A named, anonymous (pagefile-backed) shared section + its mapped read/write view, created with the
|
||||
/// permissive `D:(A;;GA;;;WD)` SDDL the restricted-token driver needs to open it. RAII: drop unmaps the
|
||||
/// view, then the [`OwnedHandle`] closes the section handle (in that order). Replaces the three backends'
|
||||
/// hand-duplicated `CreateFileMappingW` + `MapViewOfFile` + manual `Drop`.
|
||||
pub(super) struct Shm {
|
||||
/// Owns the section handle (closed on drop). Held only for ownership — never read after construction.
|
||||
_handle: OwnedHandle,
|
||||
view: MEMORY_MAPPED_VIEW_ADDRESS,
|
||||
}
|
||||
|
||||
impl Shm {
|
||||
/// Create + zero a `size`-byte section named `name`, mapped read/write. The section handle is owned
|
||||
/// immediately, so any failure below (or the returned `Shm`'s drop) closes it.
|
||||
pub(super) fn create(name: &HSTRING, size: usize) -> Result<Shm> {
|
||||
let mut psd = PSECURITY_DESCRIPTOR::default();
|
||||
// SAFETY: the SDDL literal is valid; `psd` receives an OS-allocated descriptor (freed at process
|
||||
// exit — acceptable for a host-lifetime object).
|
||||
unsafe {
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
w!("D:(A;;GA;;;WD)"),
|
||||
SDDL_REVISION_1,
|
||||
&mut psd,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
let sa = SECURITY_ATTRIBUTES {
|
||||
nLength: core::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: psd.0,
|
||||
bInheritHandle: false.into(),
|
||||
};
|
||||
// SAFETY: an anonymous (pagefile-backed) section of `size` bytes with the SDDL above.
|
||||
let map = unsafe {
|
||||
CreateFileMappingW(
|
||||
INVALID_HANDLE_VALUE,
|
||||
Some(&sa),
|
||||
PAGE_READWRITE,
|
||||
0,
|
||||
size as u32,
|
||||
PCWSTR(name.as_ptr()),
|
||||
)?
|
||||
};
|
||||
// SAFETY: `map` is a fresh section handle we own; take ownership immediately so that the early
|
||||
// return below (and the eventual drop) closes it. `map` (a `Copy` `HANDLE`) stays usable for the
|
||||
// `MapViewOfFile` borrow that follows — `from_raw_handle` only copies the inner pointer.
|
||||
let handle = unsafe { OwnedHandle::from_raw_handle(map.0) };
|
||||
// SAFETY: `map` is a valid section handle; map the whole thing read/write.
|
||||
let view = unsafe { MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, size) };
|
||||
if view.Value.is_null() {
|
||||
// `handle` drops here → closes the section. No view to unmap.
|
||||
return Err(anyhow!("MapViewOfFile failed for {name}"));
|
||||
}
|
||||
// SAFETY: `view` points at `size` writable bytes (just mapped).
|
||||
unsafe { core::ptr::write_bytes(view.Value as *mut u8, 0, size) };
|
||||
Ok(Shm {
|
||||
_handle: handle,
|
||||
view,
|
||||
})
|
||||
}
|
||||
|
||||
/// The mapped section's base pointer. Stable for the `Shm`'s lifetime (moving the `Shm` does not
|
||||
/// relocate the OS mapping — the view address is fixed by `MapViewOfFile`).
|
||||
pub(super) fn base(&self) -> *mut u8 {
|
||||
self.view.Value as *mut u8
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Shm {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `view` came from `MapViewOfFile`; unmap it BEFORE the `_handle` field closes the
|
||||
// section (struct fields drop only after this `Drop::drop` returns).
|
||||
unsafe {
|
||||
let _ = UnmapViewOfFile(self.view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `SwDeviceCreate`'d software devnode; drop removes it via `SwDeviceClose`. Replaces the manual
|
||||
/// `SwDeviceClose` each backend used to call in its `Drop`.
|
||||
pub(super) struct SwDevice(HSWDEVICE);
|
||||
|
||||
impl SwDevice {
|
||||
pub(super) fn new(hsw: HSWDEVICE) -> Self {
|
||||
SwDevice(hsw)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SwDevice {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.0` is the handle `SwDeviceCreate` returned; `SwDeviceClose` removes the devnode.
|
||||
unsafe { SwDeviceClose(self.0) };
|
||||
}
|
||||
}
|
||||
+32
-85
@@ -21,23 +21,15 @@ use windows::core::{w, GUID, HRESULT, HSTRING, PCWSTR};
|
||||
use windows::Win32::Devices::Enumeration::Pnp::{
|
||||
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
|
||||
};
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE};
|
||||
use windows::Win32::Security::Authorization::{
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
|
||||
};
|
||||
use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
|
||||
use windows::Win32::System::Memory::{
|
||||
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
|
||||
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
|
||||
};
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
||||
use windows::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject};
|
||||
|
||||
// Shared-section layout — the single source of truth is `pf_vdisplay_proto::gamepad::XusbShm` (offset
|
||||
// Shared-section layout — the single source of truth is `pf_driver_proto::gamepad::XusbShm` (offset
|
||||
// asserts pin every field; the `pf_xusb` driver maps the same struct). Derive the size/offsets/magic from
|
||||
// it so a layout change is a compile error, not a hand-synced literal (audit §6.1).
|
||||
use pf_vdisplay_proto::gamepad::XusbShm;
|
||||
use pf_driver_proto::gamepad::XusbShm;
|
||||
const SHM_SIZE: usize = core::mem::size_of::<XusbShm>();
|
||||
const SHM_MAGIC: u32 = pf_vdisplay_proto::gamepad::XUSB_MAGIC; // "PFXU"
|
||||
const SHM_MAGIC: u32 = pf_driver_proto::gamepad::XUSB_MAGIC; // "PFXU"
|
||||
const OFF_PACKET: usize = core::mem::offset_of!(XusbShm, packet);
|
||||
const OFF_BUTTONS: usize = core::mem::offset_of!(XusbShm, buttons);
|
||||
const OFF_LT: usize = core::mem::offset_of!(XusbShm, left_trigger);
|
||||
@@ -150,9 +142,10 @@ fn create_swdevice(index: u8) -> Result<HSWDEVICE> {
|
||||
|
||||
/// A single virtual Xbox 360 pad: the `pf_xusb_<index>` devnode plus the mapped shared section.
|
||||
struct XusbWinPad {
|
||||
hsw: Option<HSWDEVICE>,
|
||||
map: HANDLE,
|
||||
view: *mut u8,
|
||||
/// Owns the `pf_xusb_<index>` devnode (dropped → `SwDeviceClose`). `None` if `SwDeviceCreate` failed.
|
||||
_sw: Option<super::gamepad_raii::SwDevice>,
|
||||
/// Owns `Global\pfxusb-shm-<index>` (the section + its mapped view; drop unmaps + closes).
|
||||
shm: super::gamepad_raii::Shm,
|
||||
packet: u32,
|
||||
last_rumble_seq: u32,
|
||||
}
|
||||
@@ -160,45 +153,13 @@ struct XusbWinPad {
|
||||
impl XusbWinPad {
|
||||
/// Create + map `Global\pfxusb-shm-<index>`, stamp the magic, then spawn the devnode.
|
||||
fn open(index: u8) -> Result<XusbWinPad> {
|
||||
let name = HSTRING::from(pf_vdisplay_proto::gamepad::xusb_shm_name(index));
|
||||
|
||||
// Permissive DACL so the WUDFHost (whatever account) can open the section.
|
||||
let mut psd = PSECURITY_DESCRIPTOR::default();
|
||||
// SAFETY: SDDL literal valid; psd receives an OS-freed descriptor (host-lifetime — fine).
|
||||
unsafe {
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
w!("D:(A;;GA;;;WD)"),
|
||||
SDDL_REVISION_1,
|
||||
&mut psd,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
let sa = SECURITY_ATTRIBUTES {
|
||||
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: psd.0,
|
||||
bInheritHandle: false.into(),
|
||||
};
|
||||
// SAFETY: anonymous (pagefile-backed) section of SHM_SIZE bytes with the SDDL above.
|
||||
let map = unsafe {
|
||||
CreateFileMappingW(
|
||||
INVALID_HANDLE_VALUE,
|
||||
Some(&sa),
|
||||
PAGE_READWRITE,
|
||||
0,
|
||||
SHM_SIZE as u32,
|
||||
PCWSTR(name.as_ptr()),
|
||||
)?
|
||||
};
|
||||
// SAFETY: map is a valid section handle; map the whole thing.
|
||||
let view = unsafe { MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, SHM_SIZE) };
|
||||
if view.Value.is_null() {
|
||||
// SAFETY: map is valid.
|
||||
unsafe {
|
||||
let _ = CloseHandle(map);
|
||||
}
|
||||
return Err(anyhow!("MapViewOfFile failed for {name}"));
|
||||
}
|
||||
let base = view.Value as *mut u8;
|
||||
// Permissive-DACL named section the WUDFHost (whatever account) can open; `Shm` owns the
|
||||
// section handle + its mapped view (zero-filled) and unmaps/closes on drop.
|
||||
let shm = super::gamepad_raii::Shm::create(
|
||||
&HSTRING::from(pf_driver_proto::gamepad::xusb_shm_name(index)),
|
||||
SHM_SIZE,
|
||||
)?;
|
||||
let base = shm.base();
|
||||
// Zero the section then stamp the magic LAST (the driver only accepts it once magic is set).
|
||||
// SAFETY: base points at SHM_SIZE writable bytes.
|
||||
unsafe {
|
||||
@@ -212,10 +173,10 @@ impl XusbWinPad {
|
||||
None
|
||||
}
|
||||
};
|
||||
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
||||
Ok(XusbWinPad {
|
||||
hsw,
|
||||
map,
|
||||
view: base,
|
||||
_sw,
|
||||
shm,
|
||||
packet: 0,
|
||||
last_rumble_seq: 0,
|
||||
})
|
||||
@@ -226,50 +187,36 @@ impl XusbWinPad {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn write_state(&mut self, buttons: u16, lt: u8, rt: u8, lx: i16, ly: i16, rx: i16, ry: i16) {
|
||||
self.packet = self.packet.wrapping_add(1);
|
||||
// SAFETY: view points at SHM_SIZE bytes; all offsets are in range.
|
||||
// SAFETY: base points at SHM_SIZE bytes; all offsets are in range.
|
||||
let base = self.shm.base();
|
||||
unsafe {
|
||||
std::ptr::write_unaligned(self.view.add(OFF_BUTTONS) as *mut u16, buttons);
|
||||
*self.view.add(OFF_LT) = lt;
|
||||
*self.view.add(OFF_RT) = rt;
|
||||
std::ptr::write_unaligned(self.view.add(OFF_LX) as *mut i16, lx);
|
||||
std::ptr::write_unaligned(self.view.add(OFF_LY) as *mut i16, ly);
|
||||
std::ptr::write_unaligned(self.view.add(OFF_RX) as *mut i16, rx);
|
||||
std::ptr::write_unaligned(self.view.add(OFF_RY) as *mut i16, ry);
|
||||
std::ptr::write_unaligned(self.view.add(OFF_PACKET) as *mut u32, self.packet);
|
||||
std::ptr::write_unaligned(base.add(OFF_BUTTONS) as *mut u16, buttons);
|
||||
*base.add(OFF_LT) = lt;
|
||||
*base.add(OFF_RT) = rt;
|
||||
std::ptr::write_unaligned(base.add(OFF_LX) as *mut i16, lx);
|
||||
std::ptr::write_unaligned(base.add(OFF_LY) as *mut i16, ly);
|
||||
std::ptr::write_unaligned(base.add(OFF_RX) as *mut i16, rx);
|
||||
std::ptr::write_unaligned(base.add(OFF_RY) as *mut i16, ry);
|
||||
std::ptr::write_unaligned(base.add(OFF_PACKET) as *mut u32, self.packet);
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll the section for a game's rumble (the driver bumps `rumble_seq` on each SET_STATE). Returns
|
||||
/// `(large, small)` motor levels (0..=255) when a new one arrived.
|
||||
fn service(&mut self) -> Option<(u8, u8)> {
|
||||
// SAFETY: view points at SHM_SIZE bytes.
|
||||
let seq = unsafe { std::ptr::read_unaligned(self.view.add(OFF_RUMBLE_SEQ) as *const u32) };
|
||||
let base = self.shm.base();
|
||||
// SAFETY: base points at SHM_SIZE bytes.
|
||||
let seq = unsafe { std::ptr::read_unaligned(base.add(OFF_RUMBLE_SEQ) as *const u32) };
|
||||
if seq == self.last_rumble_seq {
|
||||
return None;
|
||||
}
|
||||
self.last_rumble_seq = seq;
|
||||
// SAFETY: rumble bytes at OFF_RUMBLE / OFF_RUMBLE+1.
|
||||
let (large, small) =
|
||||
unsafe { (*self.view.add(OFF_RUMBLE), *self.view.add(OFF_RUMBLE + 1)) };
|
||||
let (large, small) = unsafe { (*base.add(OFF_RUMBLE), *base.add(OFF_RUMBLE + 1)) };
|
||||
Some((large, small))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for XusbWinPad {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: hsw (if any) owns the devnode; view/map from MapViewOfFile/CreateFileMappingW.
|
||||
unsafe {
|
||||
if let Some(h) = self.hsw {
|
||||
SwDeviceClose(h);
|
||||
}
|
||||
let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS {
|
||||
Value: self.view as *mut c_void,
|
||||
});
|
||||
let _ = CloseHandle(self.map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual Xbox 360 pads of a session — the Windows analogue of the Linux uinput-xpad manager,
|
||||
/// now backed by the XUSB companion driver. Same method surface (`new`/`handle`/`pump_rumble`) the
|
||||
/// session input thread already drives.
|
||||
+1
-1
@@ -35,7 +35,7 @@ pub struct SendInputInjector {
|
||||
desktop: Option<HDESK>,
|
||||
}
|
||||
|
||||
// Only ever used from the host's single injector thread (like SudoVdaDisplay).
|
||||
// Only ever used from the host's single injector thread.
|
||||
unsafe impl Send for SendInputInjector {}
|
||||
|
||||
impl SendInputInjector {
|
||||
@@ -256,6 +256,298 @@ fn is_steam_tool(appid: u32, name: &str) -> bool {
|
||||
|| n.contains("steamvr")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Lutris (Linux) — reads the local `pga.db` (no auth, no network). One provider covers
|
||||
// everything Lutris manages: Wine/Proton games, GOG/Epic/Battle.net installs, emulators.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/// Reads the **local** Lutris library DB (`pga.db`) — no network. Installed titles only; cover art
|
||||
/// from Lutris's on-disk cache, inlined as `data:` URLs. Linux-only (Lutris is Linux-only).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub struct LutrisProvider;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl LibraryProvider for LutrisProvider {
|
||||
fn store(&self) -> &'static str {
|
||||
"lutris"
|
||||
}
|
||||
|
||||
fn list(&self) -> Vec<GameEntry> {
|
||||
let Some(db) = lutris_db() else {
|
||||
return Vec::new();
|
||||
};
|
||||
lutris_games(&db).unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, db = %db.display(), "lutris pga.db read failed — skipping");
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The first existing Lutris `pga.db`: XDG data dir, the classic `~/.local/share`, or Flatpak.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn lutris_db() -> Option<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(d) = std::env::var_os("XDG_DATA_HOME") {
|
||||
candidates.push(PathBuf::from(d).join("lutris/pga.db"));
|
||||
}
|
||||
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
|
||||
candidates.push(home.join(".local/share/lutris/pga.db"));
|
||||
candidates.push(home.join(".var/app/net.lutris.Lutris/data/lutris/pga.db"));
|
||||
}
|
||||
candidates.into_iter().find(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// Installed games from a Lutris `pga.db`. Opened **read-only + immutable** (via a SQLite URI) so a
|
||||
/// running Lutris holding the file can't make us block or fail, and we never write to it.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
|
||||
use rusqlite::OpenFlags;
|
||||
// `immutable=1` treats the DB as read-only-and-unchanging → no locking against a live Lutris. The
|
||||
// path goes into the URI literally; a `?`/`#` in it (vanishingly rare on Linux) would mis-parse,
|
||||
// so fall back to a plain read-only open in that case.
|
||||
let path = db.to_string_lossy();
|
||||
let conn = if path.contains('?') || path.contains('#') {
|
||||
rusqlite::Connection::open_with_flags(db, OpenFlags::SQLITE_OPEN_READ_ONLY)?
|
||||
} else {
|
||||
rusqlite::Connection::open_with_flags(
|
||||
format!("file:{path}?immutable=1"),
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
|
||||
)?
|
||||
};
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, slug, name FROM games \
|
||||
WHERE installed = 1 AND name IS NOT NULL AND name <> '' \
|
||||
ORDER BY name COLLATE NOCASE",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})?;
|
||||
let mut games = Vec::new();
|
||||
for (id, slug, name) in rows.flatten() {
|
||||
games.push(GameEntry {
|
||||
id: format!("lutris:{id}"),
|
||||
store: "lutris".into(),
|
||||
title: name,
|
||||
art: slug.as_deref().map(lutris_art).unwrap_or_default(),
|
||||
launch: Some(LaunchSpec {
|
||||
kind: "lutris_id".into(),
|
||||
value: id.to_string(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
Ok(games)
|
||||
}
|
||||
|
||||
/// Lutris cover art (local files keyed by slug) inlined as `data:` URLs — Lutris has no public CDN
|
||||
/// keyed by a stable id (unlike Steam/Heroic), and `Artwork` fields are URLs the client fetches, so a
|
||||
/// self-contained `data:` URL needs no host-served endpoint. `coverart` → portrait, `banners` → header.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn lutris_art(slug: &str) -> Artwork {
|
||||
Artwork {
|
||||
portrait: lutris_image("coverart", slug),
|
||||
header: lutris_image("banners", slug),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Find `<kind>/<slug>.jpg` across the current (0.5.18+), legacy (`~/.cache`), and Flatpak Lutris
|
||||
/// dirs and inline it as `data:image/jpeg;base64,…`. Skips a missing or implausibly large file (a
|
||||
/// 1 MiB cap bounds the catalog JSON so a few big files can't bloat it).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn lutris_image(kind: &str, slug: &str) -> Option<String> {
|
||||
use base64::Engine as _;
|
||||
let home = std::env::var_os("HOME").map(PathBuf::from)?;
|
||||
let roots = [
|
||||
home.join(".local/share/lutris"),
|
||||
home.join(".cache/lutris"),
|
||||
home.join(".var/app/net.lutris.Lutris/data/lutris"),
|
||||
home.join(".var/app/net.lutris.Lutris/cache/lutris"),
|
||||
];
|
||||
for root in roots {
|
||||
let p = root.join(kind).join(format!("{slug}.jpg"));
|
||||
let Ok(meta) = std::fs::metadata(&p) else {
|
||||
continue;
|
||||
};
|
||||
if meta.len() == 0 || meta.len() > 1024 * 1024 {
|
||||
continue;
|
||||
}
|
||||
if let Ok(bytes) = std::fs::read(&p) {
|
||||
let enc = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
return Some(format!("data:image/jpeg;base64,{enc}"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Heroic (Linux) — Epic + GOG + Amazon in one provider. Reads Heroic's `store_cache` JSON
|
||||
// (no auth); cover art is already public Epic/GOG/Amazon CDN URLs the client fetches directly.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/// Reads Heroic Games Launcher's local library cache. One provider surfaces all three of Heroic's
|
||||
/// backends (legendary=Epic, gog=GOG, nile=Amazon). Linux-only for now (Heroic on Windows uses a
|
||||
/// different config path and the launch path isn't wired there yet).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub struct HeroicProvider;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl LibraryProvider for HeroicProvider {
|
||||
fn store(&self) -> &'static str {
|
||||
"heroic"
|
||||
}
|
||||
|
||||
fn list(&self) -> Vec<GameEntry> {
|
||||
let Some(root) = heroic_root() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut games = Vec::new();
|
||||
// (cache file, runner id, the electron-store data key holding the games array)
|
||||
for (file, runner, key) in [
|
||||
("legendary_library.json", "legendary", "library"),
|
||||
("gog_library.json", "gog", "games"),
|
||||
("nile_library.json", "nile", "library"),
|
||||
] {
|
||||
let path = root.join("store_cache").join(file);
|
||||
match heroic_games(&path, runner, key) {
|
||||
Ok(mut g) => games.append(&mut g),
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, file, "heroic store_cache not read (store unused?)")
|
||||
}
|
||||
}
|
||||
}
|
||||
games
|
||||
}
|
||||
}
|
||||
|
||||
/// The first existing Heroic config root: `$XDG_CONFIG_HOME/heroic`, classic `~/.config/heroic`, or
|
||||
/// the Flatpak path.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn heroic_root() -> Option<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(d) = std::env::var_os("XDG_CONFIG_HOME") {
|
||||
candidates.push(PathBuf::from(d).join("heroic"));
|
||||
}
|
||||
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
|
||||
candidates.push(home.join(".config/heroic"));
|
||||
candidates.push(home.join(".var/app/com.heroicgameslauncher.hgl/config/heroic"));
|
||||
}
|
||||
candidates.into_iter().find(|p| p.is_dir())
|
||||
}
|
||||
|
||||
/// Parse one runner's `store_cache/*_library.json` (an electron-store object whose `key` holds the
|
||||
/// games array). Keeps only installed titles whose install dir still exists (the latter works around
|
||||
/// Heroic's gog `is_installed` bug, #2691). Art comes straight from the cached public CDN URLs.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<GameEntry>> {
|
||||
let raw = std::fs::read_to_string(path)?;
|
||||
let root: serde_json::Value = serde_json::from_str(&raw)?;
|
||||
let arr = root
|
||||
.get(key)
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| anyhow::anyhow!("no '{key}' array in {}", path.display()))?;
|
||||
let mut games = Vec::new();
|
||||
for g in arr {
|
||||
if !g
|
||||
.get("is_installed")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue; // the cache also lists owned-but-not-installed titles
|
||||
}
|
||||
let install_ok = g
|
||||
.get("install")
|
||||
.and_then(|i| i.get("install_path"))
|
||||
.and_then(|p| p.as_str())
|
||||
.is_some_and(|p| Path::new(p).is_dir());
|
||||
if !install_ok {
|
||||
continue;
|
||||
}
|
||||
let Some(app_name) = g
|
||||
.get("app_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let title = g
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(app_name)
|
||||
.to_string();
|
||||
// Only emit http(s) art (sideloaded titles can carry local file:// paths the client can't fetch).
|
||||
let http = |k: &str| {
|
||||
g.get(k)
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| s.starts_with("http://") || s.starts_with("https://"))
|
||||
.map(String::from)
|
||||
};
|
||||
let art = Artwork {
|
||||
portrait: http("art_square"),
|
||||
header: http("art_cover"),
|
||||
hero: http("art_background").or_else(|| http("art_cover")),
|
||||
logo: http("art_logo"),
|
||||
};
|
||||
games.push(GameEntry {
|
||||
id: format!("heroic:{runner}:{app_name}"),
|
||||
store: "heroic".into(),
|
||||
title,
|
||||
art,
|
||||
launch: Some(LaunchSpec {
|
||||
kind: "heroic".into(),
|
||||
value: format!("{runner}:{app_name}"),
|
||||
}),
|
||||
});
|
||||
}
|
||||
Ok(games)
|
||||
}
|
||||
|
||||
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
|
||||
/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a
|
||||
/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which
|
||||
/// renders into that gamescope) and stays hidden via `--no-gui`; but if a Heroic GUI is ALREADY
|
||||
/// running on the box, the spawned process forwards the URI and exits, which would tear the session
|
||||
/// down. The validated path is the fresh-session case; needs live confirmation on a box with Heroic.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn heroic_command(value: &str) -> Option<String> {
|
||||
let (runner, app) = value.split_once(':')?;
|
||||
if !matches!(runner, "legendary" | "gog" | "nile") {
|
||||
return None;
|
||||
}
|
||||
// appName charset (Epic alnum, GOG digits, Amazon alnum) — keep the URI a single safe token.
|
||||
if app.is_empty()
|
||||
|| !app
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let prefix = heroic_launch_prefix()?;
|
||||
// No quotes: gamescope spawns the app by `split_whitespace()`, and the URI has no spaces (appName
|
||||
// is validated above) so it stays a single argv token; `&` is fine (exec'd, not shell-parsed).
|
||||
Some(format!(
|
||||
"{prefix} --no-gui heroic://launch?appName={app}&runner={runner}"
|
||||
))
|
||||
}
|
||||
|
||||
/// How to invoke Heroic: the native `heroic` binary if on `PATH`, else the Flatpak app if its data
|
||||
/// root is present. `None` ⇒ Heroic not found, so no launch command.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn heroic_launch_prefix() -> Option<String> {
|
||||
let on_path = std::env::var_os("PATH")
|
||||
.is_some_and(|paths| std::env::split_paths(&paths).any(|d| d.join("heroic").is_file()));
|
||||
if on_path {
|
||||
return Some("heroic".into());
|
||||
}
|
||||
let flatpak = std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.is_some_and(|h| h.join(".var/app/com.heroicgameslauncher.hgl").is_dir());
|
||||
flatpak.then(|| "flatpak run com.heroicgameslauncher.hgl".into())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Custom store (user-curated entries, persisted + CRUD'd via the mgmt API)
|
||||
// ---------------------------------------------------------------------------------------
|
||||
@@ -382,15 +674,27 @@ pub fn delete_custom(id: &str) -> Result<bool> {
|
||||
// Unified library
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/// A digits-only Steam appid: the sole client-influenced part of a Steam launch, validated before it
|
||||
/// is interpolated into any command / URI (so a client-sent id can never carry shell or URI syntax).
|
||||
/// Cross-platform — used by the Linux shell mapping ([`command_for`]) and the Windows spawn mapping
|
||||
/// ([`windows_launch_for`]).
|
||||
fn valid_steam_appid(value: &str) -> bool {
|
||||
!value.is_empty() && value.bytes().all(|b| b.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// Resolve a store-qualified library id (as sent by a client in `Hello::launch`) to the shell
|
||||
/// command the host should run for it — looked up in the host's OWN library so a client can only
|
||||
/// pick an existing title, never inject a command. `None` = unknown id, no launch recipe, or a
|
||||
/// malformed Steam appid.
|
||||
///
|
||||
/// - `steam_appid` → `steam steam://rungameid/<appid>` (appid validated as digits, so the only
|
||||
/// client-controlled part of the command is a number).
|
||||
/// **Linux only**: the resolved command is run nested inside the per-session gamescope. On Windows
|
||||
/// there is no gamescope to nest into; the host launches a title into the interactive user session
|
||||
/// via [`launch_title`] instead.
|
||||
///
|
||||
/// - `steam_appid` → `steam steam://rungameid/<appid>` (appid validated as digits).
|
||||
/// - `command` → the stored command verbatim. This string comes from the host's own custom store
|
||||
/// (added by the host operator via the admin UI), never from the client, so it is trusted.
|
||||
#[cfg(not(windows))]
|
||||
pub fn launch_command(id: &str) -> Option<String> {
|
||||
let spec = all_games().into_iter().find(|g| g.id == id)?.launch?;
|
||||
command_for(&spec)
|
||||
@@ -398,22 +702,109 @@ pub fn launch_command(id: &str) -> Option<String> {
|
||||
|
||||
/// Map a resolved [`LaunchSpec`] to its shell command (pure — the unit-testable core of
|
||||
/// [`launch_command`], split out so the appid-validation can be tested without a Steam install).
|
||||
#[cfg(not(windows))]
|
||||
fn command_for(spec: &LaunchSpec) -> Option<String> {
|
||||
match spec.kind.as_str() {
|
||||
"steam_appid" => {
|
||||
// Only digits — the appid is the sole client-influenced part of the command.
|
||||
(!spec.value.is_empty() && spec.value.bytes().all(|b| b.is_ascii_digit()))
|
||||
.then(|| format!("steam steam://rungameid/{}", spec.value))
|
||||
}
|
||||
"steam_appid" => valid_steam_appid(&spec.value)
|
||||
.then(|| format!("steam steam://rungameid/{}", spec.value)),
|
||||
// Lutris: a digits-only pga.db game id (same guard as steam_appid) → its run URI.
|
||||
#[cfg(target_os = "linux")]
|
||||
"lutris_id" => (!spec.value.is_empty() && spec.value.bytes().all(|b| b.is_ascii_digit()))
|
||||
.then(|| format!("lutris lutris:rungameid/{}", spec.value)),
|
||||
// Heroic: `<runner>:<appName>` → the validated heroic://launch command (see heroic_command).
|
||||
#[cfg(target_os = "linux")]
|
||||
"heroic" => heroic_command(&spec.value),
|
||||
// Trusted: the command comes from the host's own custom store, never the client.
|
||||
"command" => (!spec.value.trim().is_empty()).then(|| spec.value.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows: launch a store-qualified library id into the **interactive user session** — the Windows
|
||||
/// analogue of the Linux gamescope-nested [`launch_command`]. The id is resolved against the host's
|
||||
/// OWN library (the client never sends a command), mapped to a concrete process by
|
||||
/// [`windows_launch_for`], and spawned via [`crate::interactive::spawn_in_active_session`].
|
||||
///
|
||||
/// Wired into the data plane *after* capture is live, so the title renders onto the already-captured
|
||||
/// desktop and grabs foreground.
|
||||
#[cfg(windows)]
|
||||
pub fn launch_title(id: &str) -> Result<()> {
|
||||
let spec = all_games()
|
||||
.into_iter()
|
||||
.find(|g| g.id == id)
|
||||
.and_then(|g| g.launch)
|
||||
.ok_or_else(|| anyhow::anyhow!("no launchable library entry '{id}'"))?;
|
||||
let (cmdline, workdir) = windows_launch_for(&spec).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"library entry '{id}' has no Windows launch recipe (kind '{}')",
|
||||
spec.kind
|
||||
)
|
||||
})?;
|
||||
let pid = crate::interactive::spawn_in_active_session(&cmdline, workdir.as_deref())
|
||||
.with_context(|| format!("launch '{id}' in the interactive session"))?;
|
||||
tracing::info!(launch_id = id, %cmdline, pid, "launched library title in the interactive session");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Windows: map a resolved [`LaunchSpec`] to a `(command line, working dir)` to spawn into the
|
||||
/// interactive session. Pure + unit-testable. `None` = no Windows recipe for this kind.
|
||||
///
|
||||
/// CreateProcessAsUserW does NO shell or protocol resolution, so the URI/flags are handed to a
|
||||
/// concrete EXE as plain arguments — a (host-derived) URI string can never reach a command interpreter.
|
||||
#[cfg(windows)]
|
||||
fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::PathBuf>)> {
|
||||
match spec.kind.as_str() {
|
||||
"steam_appid" => {
|
||||
if !valid_steam_appid(&spec.value) {
|
||||
return None;
|
||||
}
|
||||
let uri = format!("steam://rungameid/{}", spec.value);
|
||||
// Prefer launching Steam.exe with the URI as an argument; fall back to explorer.exe, which
|
||||
// resolves the steam:// handler from the user hive. (The appid is digits-validated, so the
|
||||
// only variable part of the line is a number either way.)
|
||||
let cmdline = match steam_exe() {
|
||||
Some(exe) => format!("\"{}\" \"{uri}\"", exe.display()),
|
||||
None => format!("explorer.exe \"{uri}\""),
|
||||
};
|
||||
Some((cmdline, None))
|
||||
}
|
||||
// Operator-typed custom command (host-owned, never client-set): run it through the shell in the
|
||||
// interactive session. `cmd.exe /c` is acceptable here precisely because the value is operator
|
||||
// input — the same trust as the operator typing it — not a client-influenced string.
|
||||
"command" => {
|
||||
let v = spec.value.trim();
|
||||
(!v.is_empty()).then(|| (format!("cmd.exe /c {v}"), None))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows: the default Steam install's `steam.exe`, if present. A non-default Steam install dir
|
||||
/// (registry `Valve\Steam\InstallPath`) isn't covered — the explorer.exe protocol fallback handles
|
||||
/// that case. Mirrors [`steam_roots`]' "default Program Files dirs" approach.
|
||||
#[cfg(windows)]
|
||||
fn steam_exe() -> Option<std::path::PathBuf> {
|
||||
for var in ["ProgramFiles(x86)", "ProgramFiles", "ProgramW6432"] {
|
||||
if let Some(pf) = std::env::var_os(var) {
|
||||
let p = std::path::PathBuf::from(pf).join("Steam").join("steam.exe");
|
||||
if p.is_file() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The full library: every store's titles merged + the custom entries, sorted by title.
|
||||
pub fn all_games() -> Vec<GameEntry> {
|
||||
let mut games = SteamProvider.list();
|
||||
// The Lutris + Heroic providers are Linux-only (their launchers are); on other hosts the library
|
||||
// is Steam + custom. Each provider is best-effort (empty when its store isn't present).
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
games.extend(LutrisProvider.list());
|
||||
games.extend(HeroicProvider.list());
|
||||
}
|
||||
games.extend(load_custom().into_iter().map(GameEntry::from));
|
||||
games.sort_by_key(|g| g.title.to_lowercase());
|
||||
games
|
||||
@@ -478,6 +869,7 @@ mod tests {
|
||||
assert!(art.header.unwrap().ends_with("/570/header.jpg"));
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn launch_command_resolves_and_guards() {
|
||||
let steam = LaunchSpec {
|
||||
@@ -529,4 +921,143 @@ mod tests {
|
||||
assert_eq!(g.id, "custom:abc123");
|
||||
assert_eq!(g.store, "custom");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn lutris_games_reads_installed_only() {
|
||||
use rusqlite::Connection;
|
||||
let dir = std::env::temp_dir().join(format!("pf-lutris-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let db = dir.join("pga.db");
|
||||
{
|
||||
let c = Connection::open(&db).unwrap();
|
||||
c.execute_batch(
|
||||
"CREATE TABLE games (id INTEGER PRIMARY KEY, slug TEXT, name TEXT, installed INTEGER);
|
||||
INSERT INTO games (id,slug,name,installed) VALUES (42,'elden-ring','ELDEN RING',1);
|
||||
INSERT INTO games (id,slug,name,installed) VALUES (7,'owned','Owned Only',0);
|
||||
INSERT INTO games (id,slug,name,installed) VALUES (9,'noname',NULL,1);",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let games = lutris_games(&db).unwrap();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
// Only the installed, named row; the uninstalled + NULL-name rows are filtered out.
|
||||
assert_eq!(games.len(), 1);
|
||||
assert_eq!(games[0].id, "lutris:42");
|
||||
assert_eq!(games[0].store, "lutris");
|
||||
assert_eq!(games[0].title, "ELDEN RING");
|
||||
let l = games[0].launch.as_ref().unwrap();
|
||||
assert_eq!((l.kind.as_str(), l.value.as_str()), ("lutris_id", "42"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn heroic_games_parses_installed_with_cdn_art() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-heroic-test-{}", std::process::id()));
|
||||
let install = dir.join("game-install");
|
||||
std::fs::create_dir_all(&install).unwrap();
|
||||
let path = dir.join("legendary_library.json");
|
||||
let json = format!(
|
||||
r#"{{"library":[
|
||||
{{"app_name":"Quail","title":"Quail","is_installed":true,
|
||||
"install":{{"install_path":"{inst}"}},
|
||||
"art_square":"https://cdn/quail_tall.jpg","art_cover":"https://cdn/quail_wide.jpg",
|
||||
"art_logo":"file:///local/logo.png"}},
|
||||
{{"app_name":"Owned","title":"Owned Only","is_installed":false,
|
||||
"install":{{"install_path":"{inst}"}}}}
|
||||
]}}"#,
|
||||
inst = install.display()
|
||||
);
|
||||
std::fs::write(&path, json).unwrap();
|
||||
let games = heroic_games(&path, "legendary", "library").unwrap();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
assert_eq!(games.len(), 1); // the uninstalled title is filtered out
|
||||
assert_eq!(games[0].id, "heroic:legendary:Quail");
|
||||
assert_eq!(games[0].title, "Quail");
|
||||
assert_eq!(
|
||||
games[0].art.portrait.as_deref(),
|
||||
Some("https://cdn/quail_tall.jpg")
|
||||
);
|
||||
assert_eq!(
|
||||
games[0].art.header.as_deref(),
|
||||
Some("https://cdn/quail_wide.jpg")
|
||||
);
|
||||
assert!(games[0].art.logo.is_none()); // file:// art is dropped (client can't fetch it)
|
||||
let l = games[0].launch.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(l.kind.as_str(), l.value.as_str()),
|
||||
("heroic", "legendary:Quail")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn command_for_lutris_and_heroic_guards() {
|
||||
// Lutris: digits → its run URI; a non-numeric id (injection attempt) is rejected.
|
||||
assert_eq!(
|
||||
command_for(&LaunchSpec {
|
||||
kind: "lutris_id".into(),
|
||||
value: "42".into()
|
||||
})
|
||||
.as_deref(),
|
||||
Some("lutris lutris:rungameid/42")
|
||||
);
|
||||
assert_eq!(
|
||||
command_for(&LaunchSpec {
|
||||
kind: "lutris_id".into(),
|
||||
value: "42; rm -rf ~".into()
|
||||
}),
|
||||
None
|
||||
);
|
||||
// Heroic guards (independent of whether Heroic is installed): bad runner / appName → None.
|
||||
assert_eq!(heroic_command("badrunner:Quail"), None);
|
||||
assert_eq!(heroic_command("legendary:bad name"), None);
|
||||
assert_eq!(heroic_command("nile:"), None);
|
||||
// When Heroic IS resolvable (a dev box), a valid id yields the launch URI; on CI (no Heroic)
|
||||
// it's None — assert the URI shape only when a launcher prefix exists.
|
||||
if let Some(cmd) = heroic_command("legendary:Quail-1.2_x") {
|
||||
assert!(cmd.contains("heroic://launch?appName=Quail-1.2_x&runner=legendary"));
|
||||
assert!(cmd.contains("--no-gui"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_launch_for_maps_and_guards() {
|
||||
// Steam: a digits-only appid → a steam:// URI line (via Steam.exe or explorer.exe, depending
|
||||
// on the box) with no working dir.
|
||||
let steam = LaunchSpec {
|
||||
kind: "steam_appid".into(),
|
||||
value: "570".into(),
|
||||
};
|
||||
let (line, wd) = windows_launch_for(&steam).expect("steam recipe");
|
||||
assert!(line.contains("steam://rungameid/570"), "line was {line:?}");
|
||||
assert!(wd.is_none());
|
||||
// A non-numeric "appid" (a client trying to inject) is rejected, never interpolated.
|
||||
let evil = LaunchSpec {
|
||||
kind: "steam_appid".into(),
|
||||
value: "570\" & calc".into(),
|
||||
};
|
||||
assert!(windows_launch_for(&evil).is_none());
|
||||
// Operator command → cmd /c passthrough (trusted host input).
|
||||
let cmd = LaunchSpec {
|
||||
kind: "command".into(),
|
||||
value: "notepad.exe".into(),
|
||||
};
|
||||
assert_eq!(
|
||||
windows_launch_for(&cmd).unwrap().0,
|
||||
"cmd.exe /c notepad.exe"
|
||||
);
|
||||
// Empty / unknown kinds → no recipe.
|
||||
assert!(windows_launch_for(&LaunchSpec {
|
||||
kind: "command".into(),
|
||||
value: " ".into()
|
||||
})
|
||||
.is_none());
|
||||
assert!(windows_launch_for(&LaunchSpec {
|
||||
kind: "wat".into(),
|
||||
value: "x".into()
|
||||
})
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,15 +16,23 @@
|
||||
|
||||
mod audio;
|
||||
mod capture;
|
||||
mod config;
|
||||
mod discovery;
|
||||
// Goal-1 stage 6: top-level platform-only modules live under `src/linux/` and `src/windows/`; `#[path]`
|
||||
// keeps the `crate::*` module names flat (every existing path is unchanged).
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "linux/dmabuf_fence.rs"]
|
||||
mod dmabuf_fence;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "linux/drm_sync.rs"]
|
||||
mod drm_sync;
|
||||
mod encode;
|
||||
mod gamestream;
|
||||
mod hdr;
|
||||
mod inject;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "windows/interactive.rs"]
|
||||
mod interactive;
|
||||
mod library;
|
||||
mod mgmt;
|
||||
mod mgmt_token;
|
||||
@@ -33,17 +41,23 @@ mod pipeline;
|
||||
mod punktfunk1;
|
||||
mod pwinit;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "windows/service.rs"]
|
||||
mod service;
|
||||
mod session_plan;
|
||||
mod session_tuning;
|
||||
mod spike;
|
||||
mod vdisplay;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "windows/wgc_helper.rs"]
|
||||
mod wgc_helper;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "windows/win_adapter.rs"]
|
||||
mod win_adapter;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "windows/win_display.rs"]
|
||||
mod win_display;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "linux/zerocopy/mod.rs"]
|
||||
mod zerocopy;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
@@ -571,6 +571,11 @@ async fn serve_session(
|
||||
// (`what's left` §3), resolve the command into the per-session VirtualDisplay via
|
||||
// `set_launch_command` (as the GameStream path now does) so sessions can't stomp each other.
|
||||
if let Some(id) = hello.launch.as_deref() {
|
||||
// Linux: resolve the id to a gamescope-nested command and stash it in the env the
|
||||
// gamescope backend reads. Windows has no gamescope to nest into — the data plane launches
|
||||
// the title into the interactive user session via `library::launch_title` once capture is
|
||||
// live (threaded as `SessionContext.launch` below), so there is nothing to do here.
|
||||
#[cfg(not(windows))]
|
||||
match crate::library::launch_command(id) {
|
||||
Some(cmd) => {
|
||||
tracing::info!(launch_id = id, command = %cmd, "launching library title");
|
||||
@@ -581,6 +586,8 @@ async fn serve_session(
|
||||
"client requested a launch id not in this host's library — ignoring"
|
||||
),
|
||||
}
|
||||
#[cfg(windows)]
|
||||
let _ = id;
|
||||
}
|
||||
|
||||
// Resolve the client's gamepad-backend preference (pure env/cfg check — no probing
|
||||
@@ -599,7 +606,7 @@ async fn serve_session(
|
||||
// opted in (PUNKTFUNK_10BIT). A client that can't decode 10-bit (caps bit clear, or an older
|
||||
// client) always gets the 8-bit stream. PUNKTFUNK_10BIT is the host policy gate until a
|
||||
// mgmt/console toggle replaces it.
|
||||
let host_wants_10bit = std::env::var_os("PUNKTFUNK_10BIT").is_some();
|
||||
let host_wants_10bit = crate::config::config().ten_bit;
|
||||
let client_supports_10bit = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_10BIT != 0;
|
||||
let bit_depth: u8 = if host_wants_10bit && client_supports_10bit {
|
||||
10
|
||||
@@ -912,6 +919,10 @@ async fn serve_session(
|
||||
let source = opts.source;
|
||||
let (seconds, frames) = (opts.seconds, opts.frames);
|
||||
let mode = hello.mode;
|
||||
// Windows: the store-qualified launch id, threaded into the data plane so the title can be
|
||||
// launched into the interactive session once capture is live (no gamescope nesting on Windows).
|
||||
#[cfg(target_os = "windows")]
|
||||
let launch_for_dp = hello.launch.clone();
|
||||
let bitrate_kbps = welcome.bitrate_kbps; // resolved encoder bitrate (Hello clamped, or default)
|
||||
let bit_depth = welcome.bit_depth; // resolved encode bit depth (8, or 10 when negotiated)
|
||||
let stop_stream = stop.clone();
|
||||
@@ -957,21 +968,23 @@ async fn serve_session(
|
||||
Punktfunk1Source::Virtual => {
|
||||
let compositor = compositor
|
||||
.expect("the Virtual source resolves a compositor during the handshake");
|
||||
virtual_stream(
|
||||
virtual_stream(SessionContext {
|
||||
session,
|
||||
mode,
|
||||
seconds,
|
||||
stop_stream,
|
||||
&reconfig_rx,
|
||||
&keyframe_rx,
|
||||
stop: stop_stream,
|
||||
reconfig: reconfig_rx,
|
||||
keyframe: keyframe_rx,
|
||||
compositor,
|
||||
bitrate_kbps,
|
||||
bit_depth,
|
||||
probe_rx,
|
||||
probe_result_tx,
|
||||
fec_target_dp,
|
||||
conn_stream,
|
||||
)
|
||||
fec_target: fec_target_dp,
|
||||
conn: conn_stream,
|
||||
#[cfg(target_os = "windows")]
|
||||
launch: launch_for_dp,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1616,7 +1629,7 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool
|
||||
/// Resolve the client's gamepad-backend preference (the env/logging shell around
|
||||
/// [`pick_gamepad`]). Always concrete — the `Welcome` reports what the session will drive.
|
||||
fn resolve_gamepad(pref: GamepadPref) -> GamepadPref {
|
||||
let env = std::env::var("PUNKTFUNK_GAMEPAD").ok();
|
||||
let env = crate::config::config().gamepad.clone();
|
||||
let chosen = pick_gamepad(
|
||||
pref,
|
||||
env.as_deref(),
|
||||
@@ -1683,7 +1696,7 @@ fn resolve_compositor(pref: CompositorPref) -> Result<crate::vdisplay::Composito
|
||||
{
|
||||
// Explicit operator override (legacy / CI / forcing a backend for a test) wins and is assumed
|
||||
// to come with a hand-set env — don't retarget the process env in that case.
|
||||
let overridden = std::env::var_os("PUNKTFUNK_COMPOSITOR").is_some();
|
||||
let overridden = crate::config::config().compositor.is_some();
|
||||
let detected = if overridden {
|
||||
crate::vdisplay::detect().ok()
|
||||
} else {
|
||||
@@ -2141,71 +2154,81 @@ fn session_watcher_loop(tx: std::sync::mpsc::Sender<SessionSwitch>, stop: Arc<At
|
||||
}
|
||||
}
|
||||
|
||||
/// Real capture→encode→punktfunk/1: a native virtual output at the client's mode, NVENC AUs
|
||||
/// stamped with the capture wall clock (the client derives per-frame pipeline latency).
|
||||
///
|
||||
/// `reconfig` delivers accepted mid-stream mode switches: the capture/encode pipeline is
|
||||
/// rebuilt at the new mode (capturer drop tears down the PipeWire stream and, via its
|
||||
/// keepalive, the virtual output) while the data-plane `session` continues untouched —
|
||||
/// the rebuilt encoder opens with an IDR + in-band parameter sets. `probe_rx`/`probe_result_tx`
|
||||
/// carry speed-test bursts (see [`service_probes`]).
|
||||
/// The stop flag of the current in-process IDD-push session, so a NEW connection can PREEMPT it.
|
||||
/// A fresh connection means the prior client is gone (a reconnect) and a reused IddCx monitor's
|
||||
/// swap-chain is dead — so we stop the prior session (it releases its monitor cleanly while frames
|
||||
/// still flow), then build a fresh one, instead of joining a dying session or tearing its monitor out
|
||||
/// from under it (which churns the driver's ADD/REMOVE path and wedges it under rapid reconnects).
|
||||
#[cfg(target_os = "windows")]
|
||||
static IDD_SESSION_STOP: std::sync::Mutex<Option<Arc<AtomicBool>>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// Serializes IDD-push session SETUP (preempt + monitor create + first frame). Held across setup,
|
||||
/// released before the encode loop — so a reconnect FLOOD can never run concurrent monitor
|
||||
/// create/teardown (the churn that fails the ADD IOCTL and wedges the driver). Each session finishes
|
||||
/// setup before the next acquires this and preempts it, by which point the preempted session is in its
|
||||
/// encode loop and releases its monitor promptly.
|
||||
#[cfg(target_os = "windows")]
|
||||
static IDD_SETUP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn virtual_stream(
|
||||
/// All per-session inputs for [`virtual_stream`] / [`virtual_stream_relay`], bundled so the session entry
|
||||
/// is one moved value instead of a 13-positional-argument `#[allow(too_many_arguments)]` signature
|
||||
/// (Goal-1 stage 4, plan §2.4). Everything is **owned** — the receivers move in (`virtual_stream` is their
|
||||
/// only consumer) — so the whole context moves into the stream thread and the borrow plumbing disappears.
|
||||
struct SessionContext {
|
||||
/// The hardened data-plane `Session` (Leopard FEC + AES-GCM over UDP); moved into the send thread.
|
||||
session: Session,
|
||||
/// The client's requested mode — the virtual output is created at exactly this WxH@Hz (no scaling).
|
||||
mode: punktfunk_core::Mode,
|
||||
/// Stream duration cap (the persistent listener bounds back-to-back sessions).
|
||||
seconds: u32,
|
||||
/// Session stop flag (set on disconnect / reconnect-preempt).
|
||||
stop: Arc<AtomicBool>,
|
||||
reconfig: &std::sync::mpsc::Receiver<punktfunk_core::Mode>,
|
||||
keyframe: &std::sync::mpsc::Receiver<()>,
|
||||
/// Accepted mid-stream mode switches — the pipeline is rebuilt at the new mode.
|
||||
reconfig: std::sync::mpsc::Receiver<punktfunk_core::Mode>,
|
||||
/// Client decode-recovery keyframe requests.
|
||||
keyframe: std::sync::mpsc::Receiver<()>,
|
||||
/// The resolved compositor backend (moot on Windows — `vdisplay::open` ignores it there).
|
||||
compositor: crate::vdisplay::Compositor,
|
||||
/// Negotiated encoder bitrate (kbps).
|
||||
bitrate_kbps: u32,
|
||||
/// Negotiated encode bit depth (8, or 10 = HEVC Main10).
|
||||
bit_depth: u8,
|
||||
/// Speed-test burst requests (see [`service_probes`]).
|
||||
probe_rx: std::sync::mpsc::Receiver<ProbeRequest>,
|
||||
/// Speed-test results back to the control task.
|
||||
probe_result_tx: tokio::sync::mpsc::UnboundedSender<ProbeResult>,
|
||||
/// Adaptive-FEC target the control task updates from the client's loss reports.
|
||||
fec_target: Arc<AtomicU8>,
|
||||
/// The QUIC control connection (carries host→client 0xCE source-HDR metadata mid-stream).
|
||||
conn: quinn::Connection,
|
||||
) -> Result<()> {
|
||||
/// Windows: the store-qualified library id to launch into the interactive user session once
|
||||
/// capture is live (no gamescope nesting on Windows). `None` = no launch requested. Linux uses the
|
||||
/// gamescope `PUNKTFUNK_GAMESCOPE_APP` path resolved at handshake, so this field is Windows-only.
|
||||
#[cfg(target_os = "windows")]
|
||||
launch: Option<String>,
|
||||
}
|
||||
|
||||
fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
// This thread runs the capture+encode loop (single-process: Linux / synthetic / NO_WGC DDA) — or
|
||||
// tail-calls the relay below. Elevate it so a CPU-heavy game can't deschedule our GPU submission.
|
||||
boost_thread_priority(true);
|
||||
// Resolve the per-session capture / topology / encoder decision ONCE (Goal-1 stage 3): the deployed
|
||||
// path now reads this typed `SessionPlan` instead of re-deriving from config at each dispatch site
|
||||
// (the latent "capture and encode disagree on the backend" hazard, plan §2.4). `bit_depth` is the
|
||||
// only per-session input — capture/topology/encoder are otherwise pure functions of `HostConfig`.
|
||||
let plan = crate::session_plan::SessionPlan::resolve(ctx.bit_depth);
|
||||
tracing::info!(?plan, "resolved session plan");
|
||||
// Windows two-process secure-desktop path: when the host runs as SYSTEM (required for the secure
|
||||
// desktop + SendInput), WGC can't activate in-process, so we capture the normal desktop via a
|
||||
// helper spawned in the user session and relay its AUs. (Single-process WGC/DDA is used as the
|
||||
// user, and stays the path on Linux.) See docs/windows-secure-desktop.md.
|
||||
#[cfg(target_os = "windows")]
|
||||
if should_use_helper() {
|
||||
return virtual_stream_relay(
|
||||
session,
|
||||
mode,
|
||||
seconds,
|
||||
stop,
|
||||
reconfig,
|
||||
keyframe,
|
||||
compositor,
|
||||
bitrate_kbps,
|
||||
bit_depth,
|
||||
probe_rx,
|
||||
probe_result_tx,
|
||||
fec_target,
|
||||
conn,
|
||||
);
|
||||
if plan.topology == crate::session_plan::SessionTopology::TwoProcessRelay {
|
||||
return virtual_stream_relay(ctx);
|
||||
}
|
||||
// Single-process path: unpack the context into the locals the loop below uses (names unchanged, so the
|
||||
// body is byte-for-byte the same; the receivers are now owned but `try_recv()` is identical).
|
||||
let SessionContext {
|
||||
session,
|
||||
mode,
|
||||
seconds,
|
||||
stop,
|
||||
reconfig,
|
||||
keyframe,
|
||||
compositor,
|
||||
bitrate_kbps,
|
||||
bit_depth,
|
||||
probe_rx,
|
||||
probe_result_tx,
|
||||
fec_target,
|
||||
conn,
|
||||
#[cfg(target_os = "windows")]
|
||||
launch,
|
||||
} = ctx;
|
||||
tracing::info!(
|
||||
compositor = compositor.id(),
|
||||
?mode,
|
||||
@@ -2213,30 +2236,24 @@ fn virtual_stream(
|
||||
bit_depth,
|
||||
"punktfunk/1 virtual display"
|
||||
);
|
||||
// IDD-push reconnect preempt: a fresh connection means the prior client is gone. Hold IDD_SETUP_LOCK
|
||||
// across the preempt + pipeline build so a reconnect FLOOD can't run concurrent monitor
|
||||
// create/teardown. Then STOP the prior session (it ends cleanly while its monitor still composites
|
||||
// frames) and WAIT for it to release its monitor, before building a FRESH one — instead of the
|
||||
// driver-churning teardown of a monitor under a still-live session. Register THIS session's stop so
|
||||
// the next reconnect preempts it.
|
||||
#[cfg(target_os = "windows")]
|
||||
let idd_setup_guard = std::env::var_os("PUNKTFUNK_IDD_PUSH")
|
||||
.is_some()
|
||||
.then(|| IDD_SETUP_LOCK.lock().unwrap());
|
||||
#[cfg(target_os = "windows")]
|
||||
if std::env::var_os("PUNKTFUNK_IDD_PUSH").is_some() {
|
||||
let prev = IDD_SESSION_STOP.lock().unwrap().replace(stop.clone());
|
||||
if let Some(prev_stop) = prev {
|
||||
prev_stop.store(true, Ordering::SeqCst);
|
||||
crate::vdisplay::sudovda::wait_for_monitor_released(std::time::Duration::from_secs(3));
|
||||
}
|
||||
}
|
||||
// Open the backend FIRST — on Windows this constructs the vdisplay backend, which initialises the
|
||||
// host-lifetime VirtualDisplayManager (§2.5). It does NO monitor work, so it must precede the IDD-push
|
||||
// preempt below (which reaches the manager) — otherwise `vdm()` is called before init and panics.
|
||||
let mut vd = crate::vdisplay::open(compositor)?;
|
||||
// IDD-push reconnect preempt (the dance now lives in the manager, Goal-1 §2.5): serialize setup so a
|
||||
// reconnect FLOOD can't run concurrent monitor create/teardown, STOP the prior session + WAIT for it
|
||||
// to release its monitor (instead of tearing a monitor out from under a still-live session), and
|
||||
// register THIS session's stop. The returned guard holds the setup lock across the pipeline build;
|
||||
// dropping it lets the next reconnect begin (and preempt us). Held BEFORE the monitor is created
|
||||
// (build_pipeline → vd.create), so the preempt still precedes this session's monitor creation.
|
||||
#[cfg(target_os = "windows")]
|
||||
let _idd_setup_guard = (plan.capture == crate::session_plan::CaptureBackend::IddPush)
|
||||
.then(|| crate::vdisplay::manager::vdm().begin_idd_setup(stop.clone()));
|
||||
let (mut capturer, mut enc, mut frame, mut interval) =
|
||||
build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth)?;
|
||||
build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan)?;
|
||||
// Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us).
|
||||
#[cfg(target_os = "windows")]
|
||||
drop(idd_setup_guard);
|
||||
drop(_idd_setup_guard);
|
||||
|
||||
// Windows single-process DDA path (PUNKTFUNK_NO_WGC=1): the SudoVDA virtual display, isolated as the
|
||||
// SOLE active output, goes into fullscreen independent-flip (one plane on one display) which Desktop
|
||||
@@ -2251,7 +2268,18 @@ fn virtual_stream(
|
||||
#[cfg(target_os = "windows")]
|
||||
let _composed_flip = crate::capture::composed_flip::ForceComposedFlip::start();
|
||||
|
||||
let perf = std::env::var("PUNKTFUNK_PERF").is_ok();
|
||||
// Windows: capture is live (and composition forced) — launch the requested library title into the
|
||||
// interactive user session so it renders onto the captured desktop and grabs foreground. Linux
|
||||
// nests its launch in gamescope instead (the handshake `PUNKTFUNK_GAMESCOPE_APP` path). Best-effort:
|
||||
// a launch failure (no recipe for the kind, no interactive user) leaves the user on the desktop.
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(id) = launch.as_deref() {
|
||||
if let Err(e) = crate::library::launch_title(id) {
|
||||
tracing::warn!(launch_id = id, error = %e, "could not launch requested library title");
|
||||
}
|
||||
}
|
||||
|
||||
let perf = crate::config::config().perf;
|
||||
// Microburst cap (applied in send_loop/paced_submit): a frame ≤ this bursts out immediately;
|
||||
// only a bigger frame's overflow is spread. PUNKTFUNK_PACE_BURST_KB overrides the 128 KB default.
|
||||
let burst_cap = std::env::var("PUNKTFUNK_PACE_BURST_KB")
|
||||
@@ -2291,7 +2319,7 @@ fn virtual_stream(
|
||||
let mut compositor = compositor;
|
||||
let (session_tx, session_rx) = std::sync::mpsc::channel::<SessionSwitch>();
|
||||
let watch = std::env::var_os("PUNKTFUNK_SESSION_WATCH").is_some()
|
||||
&& std::env::var_os("PUNKTFUNK_COMPOSITOR").is_none();
|
||||
&& crate::config::config().compositor.is_none();
|
||||
let _watcher = if watch {
|
||||
let stop = stop.clone();
|
||||
std::thread::Builder::new()
|
||||
@@ -2362,6 +2390,7 @@ fn virtual_stream(
|
||||
cur_mode,
|
||||
bitrate_kbps,
|
||||
bit_depth,
|
||||
plan,
|
||||
)?;
|
||||
Ok((new_vd, pipe))
|
||||
})();
|
||||
@@ -2405,7 +2434,7 @@ fn virtual_stream(
|
||||
// Build the new pipeline BEFORE dropping the old one: the host already acked
|
||||
// the switch as accepted, so a rebuild failure must not kill an otherwise
|
||||
// healthy session — keep streaming the current mode and log instead.
|
||||
match build_pipeline(&mut vd, new_mode, bitrate_kbps, bit_depth) {
|
||||
match build_pipeline(&mut vd, new_mode, bitrate_kbps, bit_depth, plan) {
|
||||
Ok(next_pipe) => {
|
||||
(capturer, enc, frame, interval) = next_pipe;
|
||||
cur_mode = new_mode;
|
||||
@@ -2450,7 +2479,7 @@ fn virtual_stream(
|
||||
tracing::warn!(error = %format!("{e:#}"), rebuild = capture_rebuilds,
|
||||
"capture lost — rebuilding pipeline in place");
|
||||
let (new_cap, new_enc, new_frame, new_interval) =
|
||||
build_pipeline_with_retry(&mut vd, cur_mode, bitrate_kbps, bit_depth)
|
||||
build_pipeline_with_retry(&mut vd, cur_mode, bitrate_kbps, bit_depth, plan)
|
||||
.context("rebuild after capture loss")?;
|
||||
capturer = new_cap;
|
||||
enc = new_enc;
|
||||
@@ -2569,29 +2598,6 @@ fn virtual_stream(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Should this host take the two-process (SYSTEM host + user-session WGC helper) path? Yes when it's
|
||||
/// running as SYSTEM — the only account that can capture the secure desktop + drive SendInput on it,
|
||||
/// and the account under which in-process WGC won't activate. `PUNKTFUNK_FORCE_HELPER` forces it on
|
||||
/// (for testing the relay as a normal user); `PUNKTFUNK_NO_HELPER` forces it off. `PUNKTFUNK_NO_WGC`
|
||||
/// also forces it off — that mode runs pure single-process DDA (one capturer for the normal AND secure
|
||||
/// desktop, Apollo-style), which has no WGC helper to relay.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn should_use_helper() -> bool {
|
||||
if std::env::var_os("PUNKTFUNK_NO_HELPER").is_some() || crate::capture::wgc_disabled() {
|
||||
return false;
|
||||
}
|
||||
// IDD direct-push captures IN-PROCESS in Session 0: the pf-vdisplay driver delivers frames to the
|
||||
// SYSTEM host's session via shared memory and NVENC is headless, so no user-session WGC helper is
|
||||
// needed for VIDEO (and a Session-1 helper couldn't open the Session-0 shared textures anyway).
|
||||
// NOTE: input injection (SendInput) from Session 0 can't reach the user's Session-1 desktop yet —
|
||||
// a known follow-up; this path validates the video transport. See docs/windows-virtual-display-rust-port.md.
|
||||
if std::env::var_os("PUNKTFUNK_IDD_PUSH").is_some() {
|
||||
return false;
|
||||
}
|
||||
std::env::var_os("PUNKTFUNK_FORCE_HELPER").is_some()
|
||||
|| crate::capture::wgc_relay::running_as_system()
|
||||
}
|
||||
|
||||
/// Windows two-process video stream: the SYSTEM host creates the SudoVDA virtual output (and holds
|
||||
/// its keepalive = the sole topology/isolation owner), spawns the WGC helper in the user session to
|
||||
/// capture+encode the NORMAL desktop, and relays the helper's AUs onto the QUIC data plane via the
|
||||
@@ -2603,27 +2609,30 @@ fn should_use_helper() -> bool {
|
||||
/// helper at the new mode (and drops the stale-target DDA); keyframe requests forward to the active
|
||||
/// source.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn virtual_stream_relay(
|
||||
session: Session,
|
||||
mode: punktfunk_core::Mode,
|
||||
seconds: u32,
|
||||
stop: Arc<AtomicBool>,
|
||||
reconfig: &std::sync::mpsc::Receiver<punktfunk_core::Mode>,
|
||||
keyframe: &std::sync::mpsc::Receiver<()>,
|
||||
compositor: crate::vdisplay::Compositor,
|
||||
bitrate_kbps: u32,
|
||||
bit_depth: u8,
|
||||
probe_rx: std::sync::mpsc::Receiver<ProbeRequest>,
|
||||
probe_result_tx: tokio::sync::mpsc::UnboundedSender<ProbeResult>,
|
||||
fec_target: Arc<AtomicU8>,
|
||||
// The SYSTEM-host relay path doesn't yet send the source mastering metadata as 0xCE — the
|
||||
// helper's in-band SEI carries it (Windows follow-up). Held for that future wiring.
|
||||
_conn: quinn::Connection,
|
||||
) -> Result<()> {
|
||||
fn virtual_stream_relay(ctx: SessionContext) -> Result<()> {
|
||||
use crate::capture::dxgi::WinCaptureTarget;
|
||||
use crate::capture::wgc_relay::HelperRelay;
|
||||
use crate::capture::Capturer; // trait methods (set_active/next_frame) on the concrete DuplCapturer
|
||||
|
||||
// Unpack the context (names unchanged so the body is identical). The relay doesn't yet send the
|
||||
// source's 0xCE HDR metadata — the helper's in-band SEI carries it (a Windows follow-up) — so `conn`
|
||||
// is held unused.
|
||||
let SessionContext {
|
||||
session,
|
||||
mode,
|
||||
seconds,
|
||||
stop,
|
||||
reconfig,
|
||||
keyframe,
|
||||
compositor,
|
||||
bitrate_kbps,
|
||||
bit_depth,
|
||||
probe_rx,
|
||||
probe_result_tx,
|
||||
fec_target,
|
||||
conn: _conn,
|
||||
launch,
|
||||
} = ctx;
|
||||
tracing::info!(
|
||||
?mode,
|
||||
bitrate_kbps,
|
||||
@@ -2680,6 +2689,15 @@ fn virtual_stream_relay(
|
||||
let (mut _keepalive, mut relay, mut target, mut effective_hz) = build(&mut vd, mode)?;
|
||||
let mut cur_mode = mode;
|
||||
|
||||
// Capture is live (the WGC helper is relaying) — launch the requested library title into the
|
||||
// interactive user session so it renders onto the captured desktop and grabs foreground.
|
||||
// Best-effort: a failure (no recipe for the kind, no interactive user) leaves the user on the desktop.
|
||||
if let Some(id) = launch.as_deref() {
|
||||
if let Err(e) = crate::library::launch_title(id) {
|
||||
tracing::warn!(launch_id = id, error = %e, "could not launch requested library title");
|
||||
}
|
||||
}
|
||||
|
||||
// O3.1: optionally observe the IDD-push ring alongside WGC (WGC = the presentation trigger) to
|
||||
// confirm the 0257 driver pushes frames into a HOST-created ring. Diagnostic only; gated.
|
||||
if std::env::var_os("PUNKTFUNK_IDD_PUSH_OBSERVE").is_some() {
|
||||
@@ -2708,6 +2726,9 @@ fn virtual_stream_relay(
|
||||
target.clone(),
|
||||
Some((w, h, hz)),
|
||||
Box::new(()),
|
||||
// The relay's host encoder is GPU (NVENC/AMF/QSV unless software) — pass `gpu` in (Goal-1
|
||||
// stage 5) so the DDA capturer doesn't re-derive it.
|
||||
crate::capture::gpu_encode(),
|
||||
hdr,
|
||||
)
|
||||
.context("open DDA for secure desktop")?;
|
||||
@@ -2731,7 +2752,7 @@ fn virtual_stream_relay(
|
||||
})
|
||||
};
|
||||
|
||||
let perf = std::env::var("PUNKTFUNK_PERF").is_ok();
|
||||
let perf = crate::config::config().perf;
|
||||
let burst_cap = std::env::var("PUNKTFUNK_PACE_BURST_KB")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<usize>().ok())
|
||||
@@ -2770,7 +2791,7 @@ fn virtual_stream_relay(
|
||||
// the secure desktop's HDR independent-flip (it storms ACCESS_LOST → black), whereas the WGC helper
|
||||
// STAYS LIVE through a lock/UAC. So by default the mux keeps WGC the whole time (no DesktopWatcher
|
||||
// switch, no overlay). Enable the experimental DDA-on-secure path with PUNKTFUNK_SECURE_DDA=1.
|
||||
let dda_secure = std::env::var("PUNKTFUNK_SECURE_DDA").is_ok() || secure_test_ms.is_some();
|
||||
let dda_secure = crate::config::config().secure_dda || secure_test_ms.is_some();
|
||||
// The authoritative Default↔Winlogon signal (requires SYSTEM to read the Winlogon desktop name);
|
||||
// only needed when the DDA-on-secure path is enabled.
|
||||
let watcher = dda_secure.then(crate::capture::desktop_watch::DesktopWatcher::start);
|
||||
@@ -3041,6 +3062,7 @@ fn build_pipeline_with_retry(
|
||||
mode: punktfunk_core::Mode,
|
||||
bitrate_kbps: u32,
|
||||
bit_depth: u8,
|
||||
plan: crate::session_plan::SessionPlan,
|
||||
) -> Result<Pipeline> {
|
||||
// ~10s first-frame wait per attempt. 8 gives a ~90s budget for the SLOW case: a host-managed
|
||||
// gamescope session cold-starting Steam Big Picture (the SteamOS/Bazzite takeover) can take
|
||||
@@ -3050,7 +3072,7 @@ fn build_pipeline_with_retry(
|
||||
const MAX_ATTEMPTS: u32 = 8;
|
||||
let mut backoff = std::time::Duration::from_millis(500);
|
||||
for attempt in 1..=MAX_ATTEMPTS {
|
||||
match build_pipeline(vd, mode, bitrate_kbps, bit_depth) {
|
||||
match build_pipeline(vd, mode, bitrate_kbps, bit_depth, plan) {
|
||||
Ok(pipe) => {
|
||||
if attempt > 1 {
|
||||
tracing::info!(attempt, "pipeline up after retry");
|
||||
@@ -3109,6 +3131,7 @@ fn build_pipeline(
|
||||
mode: punktfunk_core::Mode,
|
||||
bitrate_kbps: u32,
|
||||
bit_depth: u8,
|
||||
plan: crate::session_plan::SessionPlan,
|
||||
) -> Result<Pipeline> {
|
||||
let vout = vd.create(mode).context("create virtual output")?;
|
||||
// The backend reports the refresh it actually achieved in `preferred_mode.2` (KWin may cap a
|
||||
@@ -3131,8 +3154,9 @@ fn build_pipeline(
|
||||
// VIDEO_CAP_10BIT + host opted in via PUNKTFUNK_10BIT) is our HDR path → BT.2020 PQ Rgb10a2;
|
||||
// otherwise the FP16 IDD frames are converted to 8-bit SDR. (Ignored by non-IDD-push backends,
|
||||
// which auto-detect HDR from the monitor state.)
|
||||
let mut capturer = crate::capture::capture_virtual_output(vout, bit_depth >= 10)
|
||||
.context("capture virtual output")?;
|
||||
let mut capturer =
|
||||
crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture)
|
||||
.context("capture virtual output")?;
|
||||
capturer.set_active(true);
|
||||
let frame = capturer.next_frame().context("first frame")?;
|
||||
// `bit_depth` is the handshake-negotiated value (8, or 10 = HEVC Main10 when the client
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! `SessionPlan` — the per-session capture / topology / encoder decision, resolved **once** from
|
||||
//! [`HostConfig`](crate::config) (+ the handshake-negotiated bit depth) into a typed, logged value.
|
||||
//!
|
||||
//! **Goal-1 stage 3** (`docs/windows-host-rewrite.md` §2.2): before this, the Windows session decision was
|
||||
//! re-derived at three call sites — the capture backend inside `capture::capture_virtual_output`, the
|
||||
//! process topology in `punktfunk1::should_use_helper`, and the encode backend in
|
||||
//! `encode::windows_resolved_backend` — each reading [`config`](crate::config) independently, with no
|
||||
//! single owner (the latent "capture and encode disagree on the backend" hazard, plan §2.4). `SessionPlan`
|
||||
//! resolves them together, once, so the deployed path reads one typed artifact.
|
||||
//!
|
||||
//! Stage 3 routes the **capture** and **topology** decisions through the plan (see
|
||||
//! `capture::capture_virtual_output` taking [`CaptureBackend`] in, and `virtual_stream` reading
|
||||
//! [`SessionTopology`]). The **encoder** is resolved by `encode::windows_resolved_backend` (config-backed
|
||||
//! and GPU-vendor cached since stage 2, so already a single source) and *recorded* here as
|
||||
//! [`EncoderBackend`]. Threading `encoder`/`input_format` into the encoder + capturer opens — which
|
||||
//! removes the `capture → encode::windows_resolved_backend()` back-reference recomputed in `dxgi.rs` —
|
||||
//! is **stage 5**.
|
||||
//!
|
||||
//! The type is platform-neutral so it threads through the shared `virtual_stream`/`build_pipeline`
|
||||
//! signatures; on Linux it resolves to the single portal/single-process path (the 3-way dispatch is a
|
||||
//! Windows-only concern).
|
||||
|
||||
/// Where a session's frames come from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CaptureBackend {
|
||||
/// Linux: the xdg ScreenCast portal → PipeWire (the only Linux capture path).
|
||||
Portal,
|
||||
/// Windows: IDD direct-push — frames pulled straight from the pf-vdisplay driver's shared ring
|
||||
/// (in-process, Session 0; no Desktop Duplication, no WGC helper).
|
||||
IddPush,
|
||||
/// Windows: DXGI Desktop Duplication (`PUNKTFUNK_CAPTURE=dda|dxgi` or `PUNKTFUNK_NO_WGC`).
|
||||
Dda,
|
||||
/// Windows: Windows.Graphics.Capture (the composed-desktop default), with a DDA watchdog fallback.
|
||||
Wgc,
|
||||
}
|
||||
|
||||
impl CaptureBackend {
|
||||
/// Resolve the capture backend from [`config`](crate::config). This is the single resolver shared by
|
||||
/// [`SessionPlan::resolve`] and the standalone callers (GameStream / spike), so they can't drift.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn resolve() -> Self {
|
||||
CaptureBackend::Portal
|
||||
}
|
||||
|
||||
/// Windows precedence (identical to the pre-stage-3 `capture_virtual_output` branch order):
|
||||
/// IDD-push wins; else an explicit `dda`/`dxgi` request or `PUNKTFUNK_NO_WGC` selects DDA; else WGC.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn resolve() -> Self {
|
||||
let cfg = crate::config::config();
|
||||
if cfg.idd_push {
|
||||
CaptureBackend::IddPush
|
||||
} else if matches!(cfg.capture_backend.as_str(), "dda" | "dxgi")
|
||||
|| crate::capture::wgc_disabled()
|
||||
{
|
||||
CaptureBackend::Dda
|
||||
} else {
|
||||
CaptureBackend::Wgc
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
pub fn resolve() -> Self {
|
||||
CaptureBackend::Portal
|
||||
}
|
||||
}
|
||||
|
||||
/// How a session is structured across processes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SessionTopology {
|
||||
/// One process captures + encodes (Linux; Windows non-SYSTEM / IDD-push / `NO_WGC`).
|
||||
SingleProcess,
|
||||
/// SYSTEM host + a user-session WGC helper relay (the Windows normal-desktop path under SYSTEM,
|
||||
/// where in-process WGC can't activate). See `virtual_stream_relay`.
|
||||
TwoProcessRelay,
|
||||
}
|
||||
|
||||
/// The resolved encode backend (recorded for logging / stages 4–5; the per-session encoder open still
|
||||
/// resolves via `encode::windows_resolved_backend`, which is config-backed + GPU-vendor cached).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EncoderBackend {
|
||||
/// Linux: NVENC vs VAAPI is auto-detected inside `encode::open_video` (not modeled here).
|
||||
PlatformAuto,
|
||||
Nvenc,
|
||||
Amf,
|
||||
Qsv,
|
||||
Software,
|
||||
}
|
||||
|
||||
impl EncoderBackend {
|
||||
/// True if this backend encodes on the GPU (so the capturer should produce GPU-resident frames). Only
|
||||
/// the software encoder takes CPU staging; `PlatformAuto` (Linux NVENC/VAAPI) is always GPU.
|
||||
pub fn is_gpu(self) -> bool {
|
||||
!matches!(self, EncoderBackend::Software)
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-session decision, resolved once. `Copy` so it threads through the capture/encode chain
|
||||
/// without ceremony (stage 4 folds it, with the rest of the arg soup, into a `SessionContext`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct SessionPlan {
|
||||
pub capture: CaptureBackend,
|
||||
pub topology: SessionTopology,
|
||||
pub encoder: EncoderBackend,
|
||||
/// Handshake-negotiated encode bit depth (8, or 10 = HEVC Main10).
|
||||
pub bit_depth: u8,
|
||||
/// The IDD-push HDR hint (`bit_depth >= 10`) — the want-HDR flag the capturer was passed before.
|
||||
/// Non-IDD-push Windows backends ignore it and auto-detect HDR from the monitor; Linux is 8-bit.
|
||||
pub hdr: bool,
|
||||
}
|
||||
|
||||
impl SessionPlan {
|
||||
/// Resolve the whole plan once from [`config`](crate::config) + the negotiated `bit_depth`.
|
||||
pub fn resolve(bit_depth: u8) -> Self {
|
||||
SessionPlan {
|
||||
capture: CaptureBackend::resolve(),
|
||||
topology: resolve_topology(),
|
||||
encoder: resolve_encoder(),
|
||||
bit_depth,
|
||||
hdr: bit_depth >= 10,
|
||||
}
|
||||
}
|
||||
|
||||
/// The capturer's target output format (Goal-1 stage 5): `gpu` from the already-resolved `encoder`
|
||||
/// (no second backend probe), `hdr` from the plan. Handed into `capture::capture_virtual_output` so the
|
||||
/// capturer never re-derives the encode backend.
|
||||
pub fn output_format(&self) -> crate::capture::OutputFormat {
|
||||
crate::capture::OutputFormat {
|
||||
gpu: self.encoder.is_gpu(),
|
||||
hdr: self.hdr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process topology. On Windows this is the former `punktfunk1::should_use_helper` logic verbatim; on
|
||||
/// every other platform the session is always single-process.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn resolve_topology() -> SessionTopology {
|
||||
let cfg = crate::config::config();
|
||||
// `NO_HELPER`/`NO_WGC` force single-process; IDD-push captures in-process in Session 0 (no helper);
|
||||
// otherwise the helper runs when forced or when we're SYSTEM (in-process WGC can't activate there).
|
||||
let helper = if cfg.no_helper || crate::capture::wgc_disabled() || cfg.idd_push {
|
||||
false
|
||||
} else {
|
||||
cfg.force_helper || crate::capture::wgc_relay::running_as_system()
|
||||
};
|
||||
if helper {
|
||||
SessionTopology::TwoProcessRelay
|
||||
} else {
|
||||
SessionTopology::SingleProcess
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn resolve_topology() -> SessionTopology {
|
||||
SessionTopology::SingleProcess
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn resolve_encoder() -> EncoderBackend {
|
||||
match crate::encode::windows_resolved_backend() {
|
||||
crate::encode::WindowsBackend::Nvenc => EncoderBackend::Nvenc,
|
||||
crate::encode::WindowsBackend::Amf => EncoderBackend::Amf,
|
||||
crate::encode::WindowsBackend::Qsv => EncoderBackend::Qsv,
|
||||
crate::encode::WindowsBackend::Software => EncoderBackend::Software,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn resolve_encoder() -> EncoderBackend {
|
||||
EncoderBackend::PlatformAuto
|
||||
}
|
||||
@@ -76,7 +76,12 @@ pub fn run(opts: Options) -> Result<()> {
|
||||
refresh_hz: opts.fps,
|
||||
})
|
||||
.context("create virtual output")?;
|
||||
capture::capture_virtual_output(vout, false).context("capture virtual output")?
|
||||
capture::capture_virtual_output(
|
||||
vout,
|
||||
capture::OutputFormat::resolve(false),
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
)
|
||||
.context("capture virtual output")?
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -479,7 +479,7 @@ pub fn apply_input_env(_chosen: Compositor) {}
|
||||
/// a backend for a test), else the **live session** ([`detect_active_session`] — so a Bazzite box
|
||||
/// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read.
|
||||
pub fn detect() -> Result<Compositor> {
|
||||
if let Ok(v) = std::env::var("PUNKTFUNK_COMPOSITOR") {
|
||||
if let Some(v) = crate::config::config().compositor.as_deref() {
|
||||
return match v.trim().to_ascii_lowercase().as_str() {
|
||||
"kwin" | "kde" | "plasma" => Ok(Compositor::Kwin),
|
||||
"wlroots" | "sway" | "hyprland" | "wlr" => Ok(Compositor::Wlroots),
|
||||
@@ -529,15 +529,15 @@ pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Two virtual-display backends: the new pf-vdisplay IddCx driver (pf_vdisplay_proto) and the
|
||||
// shipping SudoVDA fallback. The compositor arg is moot on Windows. PUNKTFUNK_VDISPLAY overrides;
|
||||
// default auto-detects (prefer pf-vdisplay if its driver interface is present).
|
||||
// The pf-vdisplay all-Rust IddCx driver is the sole virtual-display backend (the legacy SudoVDA
|
||||
// fallback was removed — its driver is no longer shipped). The compositor arg is moot on Windows.
|
||||
let _ = compositor;
|
||||
if windows_use_pf_vdisplay() {
|
||||
Ok(Box::new(pf_vdisplay::PfVdisplayDisplay::new()?))
|
||||
} else {
|
||||
Ok(Box::new(sudovda::SudoVdaDisplay::new()?))
|
||||
}
|
||||
anyhow::ensure!(
|
||||
pf_vdisplay::is_available(),
|
||||
"pf-vdisplay driver interface not found — the pf-vdisplay IddCx driver is not installed or \
|
||||
not loaded (the host installer bundles it; reinstall or check the driver state)"
|
||||
);
|
||||
Ok(Box::new(pf_vdisplay::PfVdisplayDisplay::new()?))
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
@@ -546,22 +546,6 @@ pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the Windows virtual-display backend. `PUNKTFUNK_VDISPLAY=pf|pf-vdisplay|pfvd` forces the new
|
||||
/// pf-vdisplay IddCx driver; `=sudovda|sudo` forces the shipping SudoVDA driver; anything else (the
|
||||
/// default) auto-detects, preferring pf-vdisplay if its device interface is enumerable.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_use_pf_vdisplay() -> bool {
|
||||
match std::env::var("PUNKTFUNK_VDISPLAY")
|
||||
.ok()
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
{
|
||||
Some("pf") | Some("pf-vdisplay") | Some("pfvd") => true,
|
||||
Some("sudovda") | Some("sudo") => false,
|
||||
_ => pf_vdisplay::is_available(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Readiness probe for `compositor`: is it up and able to create a virtual output *right
|
||||
/// now*? A session-bringup script polls this (via `punktfunk-host probe-compositor`) to gate
|
||||
/// on actual readiness instead of racing the compositor with a blind sleep.
|
||||
@@ -582,11 +566,7 @@ pub fn probe(compositor: Compositor) -> Result<()> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = compositor;
|
||||
if windows_use_pf_vdisplay() {
|
||||
pf_vdisplay::probe()
|
||||
} else {
|
||||
sudovda::probe()
|
||||
}
|
||||
pf_vdisplay::probe()
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
@@ -627,17 +607,25 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
|
||||
std::sync::Arc::new(())
|
||||
}
|
||||
|
||||
// Goal-1 stage 6: per-compositor Linux backends under `vdisplay/linux/`, the Windows IddCx/SudoVDA
|
||||
// backends under `vdisplay/windows/`; `#[path]` keeps the `crate::vdisplay::*` module names flat.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/gamescope.rs"]
|
||||
mod gamescope;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/kwin.rs"]
|
||||
mod kwin;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/mutter.rs"]
|
||||
mod mutter;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) mod pf_vdisplay;
|
||||
#[path = "vdisplay/windows/manager.rs"]
|
||||
pub(crate) mod manager;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) mod sudovda;
|
||||
#[path = "vdisplay/windows/pf_vdisplay.rs"]
|
||||
pub(crate) mod pf_vdisplay;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/wlroots.rs"]
|
||||
mod wlroots;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,725 +0,0 @@
|
||||
//! Windows virtual-display backend driving **pf-vdisplay** — punktfunk's OWN IddCx Indirect Display
|
||||
//! Driver (the clean-room replacement for SudoVDA). The Windows analogue of the Linux per-compositor
|
||||
//! backends: [`create`](VirtualDisplay::create) adds a virtual monitor at the client's exact `WxH@Hz`
|
||||
//! (the mode is baked into the ADD IOCTL — no EDID seeding), starts the mandatory watchdog ping, and
|
||||
//! the returned [`VirtualOutput`]'s keepalive `Drop` removes it (RAII).
|
||||
//!
|
||||
//! Control surface: a device-interface-GUID + `CreateFileW` + `DeviceIoControl` IOCTL protocol, with
|
||||
//! the wire contract OWNED by [`pf_vdisplay_proto::control`] (versioned + `#[repr(C)] Pod` structs,
|
||||
//! NOT the SudoVDA ABI). No DLL, no named pipe. See `docs/windows-host-rewrite.md`.
|
||||
//!
|
||||
//! This is a faithful clone of [`super::sudovda`] (the shipping fallback) repointed at the new driver:
|
||||
//! same reference-counted/lingering monitor lifecycle, same CCD isolation + active-mode forcing — those
|
||||
//! backend-NEUTRAL helpers are REUSED from `sudovda` (a pf-vdisplay monitor's `target_id` is a real OS
|
||||
//! target id, so the CCD/DXGI code works unchanged). Only the driver-specific bits (GUID, IOCTL codes,
|
||||
//! request/reply structs, the version handshake) differ, per `pf_vdisplay_proto`.
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::mem::size_of;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, Once};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use windows::core::{GUID, PCWSTR};
|
||||
use windows::Win32::Devices::DeviceAndDriverInstallation::{
|
||||
SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInterfaces, SetupDiGetClassDevsW,
|
||||
SetupDiGetDeviceInterfaceDetailW, DIGCF_DEVICEINTERFACE, DIGCF_PRESENT,
|
||||
SP_DEVICE_INTERFACE_DATA, SP_DEVICE_INTERFACE_DETAIL_DATA_W,
|
||||
};
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE, LUID};
|
||||
use windows::Win32::Storage::FileSystem::{
|
||||
CreateFileW, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
|
||||
};
|
||||
use windows::Win32::System::IO::DeviceIoControl;
|
||||
|
||||
use pf_vdisplay_proto::control;
|
||||
|
||||
use super::{Mode, VirtualDisplay, VirtualOutput};
|
||||
// Backend-NEUTRAL CCD/DXGI helpers reused from the SudoVDA backend (a pf-vdisplay monitor's target_id
|
||||
// is a real OS target id, so these operate identically). The shared MON_GEN/CURRENT_MON_GEN generation
|
||||
// counter is reused too, so the IDD-push stale-ring bail works regardless of which backend is active.
|
||||
use super::sudovda::{CURRENT_MON_GEN, MON_GEN};
|
||||
use crate::win_adapter::resolve_render_adapter_luid;
|
||||
use crate::win_display::{
|
||||
isolate_displays_ccd, resolve_gdi_name, restore_displays_ccd, set_active_mode, SavedConfig,
|
||||
};
|
||||
|
||||
// pf-vdisplay device-interface GUID (pf_vdisplay_proto::PF_VDISPLAY_INTERFACE_GUID_U128). Deliberately
|
||||
// NOT SudoVDA's `{e5bcc234-…}` — we own this driver, so a private interface GUID signals it and avoids
|
||||
// any accidental coexistence with a real SudoVDA install.
|
||||
const PF_VDISPLAY_INTERFACE: GUID =
|
||||
GUID::from_u128(pf_vdisplay_proto::PF_VDISPLAY_INTERFACE_GUID_U128);
|
||||
|
||||
/// IDD-push mode: a new client connection preempts + recreates the monitor (single-client reconnect),
|
||||
/// because a REUSED IddCx monitor's swap-chain is dead. Off → monitors are shared across sessions.
|
||||
fn idd_push_mode() -> bool {
|
||||
std::env::var_os("PUNKTFUNK_IDD_PUSH").is_some()
|
||||
}
|
||||
|
||||
/// Monotonic per-session id keying a pf-vdisplay monitor for `IOCTL_ADD`/`IOCTL_REMOVE`. Unlike
|
||||
/// SudoVDA's 16-byte GUID + pid-mangling, the proto keys monitors by a plain `u64` — the host-level
|
||||
/// refcount manager (MGR) owns collision safety (a stale session can never REMOVE a live one), so a
|
||||
/// simple monotonic counter suffices. Unique per (process, session) within this host's lifetime.
|
||||
static NEXT_SESSION_ID: AtomicU64 = AtomicU64::new(1);
|
||||
fn next_session_id() -> u64 {
|
||||
NEXT_SESSION_ID.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// One `DeviceIoControl` round trip (METHOD_BUFFERED). `input`/`output` may be empty. Identical to the
|
||||
/// SudoVDA backend's wrapper; struct<->bytes conversion happens at the call sites via `bytemuck`.
|
||||
unsafe fn ioctl(h: HANDLE, code: u32, input: &[u8], output: &mut [u8]) -> Result<u32> {
|
||||
let mut returned = 0u32;
|
||||
let inp = (!input.is_empty()).then_some(input.as_ptr() as *const c_void);
|
||||
let outp = (!output.is_empty()).then_some(output.as_mut_ptr() as *mut c_void);
|
||||
DeviceIoControl(
|
||||
h,
|
||||
code,
|
||||
inp,
|
||||
input.len() as u32,
|
||||
outp,
|
||||
output.len() as u32,
|
||||
Some(&mut returned),
|
||||
None,
|
||||
)
|
||||
.with_context(|| format!("DeviceIoControl(code={code:#x})"))?;
|
||||
Ok(returned)
|
||||
}
|
||||
|
||||
/// Pin the pf-vdisplay IddCx's RENDER GPU to `luid` (the analogue of Apollo's `SetRenderAdapter`). No
|
||||
/// output buffer. Issued on the driver handle BEFORE `IOCTL_ADD` to steer which GPU the new target
|
||||
/// renders on — on a multi-adapter box this stops DXGI from reparenting the virtual output onto a
|
||||
/// different adapter than the one we duplicate/encode on (the ACCESS_LOST storm).
|
||||
///
|
||||
/// NOTE: the pf-vdisplay driver currently returns `STATUS_NOT_IMPLEMENTED` for this IOCTL (a STEP-4
|
||||
/// stub), so this call WILL fail today. Callers tolerate the `Err` (warn + continue) — exactly as the
|
||||
/// SudoVDA backend tolerated the driver IGNORING the pin.
|
||||
unsafe fn set_render_adapter(h: HANDLE, luid: LUID) -> Result<()> {
|
||||
let req = control::SetRenderAdapterRequest {
|
||||
luid_low: luid.LowPart,
|
||||
luid_high: luid.HighPart,
|
||||
};
|
||||
let mut none: [u8; 0] = [];
|
||||
ioctl(
|
||||
h,
|
||||
control::IOCTL_SET_RENDER_ADAPTER,
|
||||
bytemuck::bytes_of(&req),
|
||||
&mut none,
|
||||
)
|
||||
.map(|_| ())
|
||||
.context("pf-vdisplay SET_RENDER_ADAPTER")
|
||||
}
|
||||
|
||||
unsafe fn open_device() -> Result<HANDLE> {
|
||||
let hdev = SetupDiGetClassDevsW(
|
||||
Some(&PF_VDISPLAY_INTERFACE),
|
||||
PCWSTR::null(),
|
||||
None,
|
||||
DIGCF_DEVICEINTERFACE | DIGCF_PRESENT,
|
||||
)
|
||||
.context("SetupDiGetClassDevsW(pf-vdisplay) — is the pf-vdisplay driver installed?")?;
|
||||
|
||||
let mut idata = SP_DEVICE_INTERFACE_DATA {
|
||||
cbSize: size_of::<SP_DEVICE_INTERFACE_DATA>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
SetupDiEnumDeviceInterfaces(hdev, None, &PF_VDISPLAY_INTERFACE, 0, &mut idata)
|
||||
.context("SetupDiEnumDeviceInterfaces(pf-vdisplay)")?;
|
||||
|
||||
let mut required = 0u32;
|
||||
let _ = SetupDiGetDeviceInterfaceDetailW(hdev, &idata, None, 0, Some(&mut required), None);
|
||||
let mut buf = vec![0u8; required as usize];
|
||||
let detail = buf.as_mut_ptr() as *mut SP_DEVICE_INTERFACE_DETAIL_DATA_W;
|
||||
(*detail).cbSize = size_of::<SP_DEVICE_INTERFACE_DETAIL_DATA_W>() as u32;
|
||||
SetupDiGetDeviceInterfaceDetailW(hdev, &idata, Some(detail), required, None, None)
|
||||
.context("SetupDiGetDeviceInterfaceDetailW(pf-vdisplay)")?;
|
||||
|
||||
let handle = CreateFileW(
|
||||
PCWSTR((*detail).DevicePath.as_ptr()),
|
||||
0xC000_0000, // GENERIC_READ | GENERIC_WRITE
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
None,
|
||||
OPEN_EXISTING,
|
||||
FILE_FLAGS_AND_ATTRIBUTES(0),
|
||||
None,
|
||||
)
|
||||
.context("CreateFileW(pf-vdisplay device)")?;
|
||||
let _ = SetupDiDestroyDeviceInfoList(hdev);
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
// ── Host-level reference-counted pf-vdisplay monitor lifecycle ───────────────────────────────────
|
||||
//
|
||||
// The virtual monitor is created on the first session and REUSED across sessions. When the last
|
||||
// session disconnects the monitor LINGERS for a grace window (PUNKTFUNK_MONITOR_LINGER_MS, default
|
||||
// 10 s): a reconnect within the window reuses it instantly (no new screen, no PnP connect/disconnect
|
||||
// chime, no teardown/recreate kernel churn); after the window a background timer REMOVEs it so a
|
||||
// physical-screen user gets their screen back. Overlapping sessions share one monitor via the
|
||||
// refcount (teardown only at refs==0 + expired grace), so a stale session can never REMOVE a live
|
||||
// session's monitor. The control-device HANDLE is opened once and kept for the host lifetime — it's a
|
||||
// handle, not a screen, so it creates no phantom display.
|
||||
|
||||
/// The resources backing one live pf-vdisplay monitor (owned by [`MGR`], not by any session).
|
||||
struct Monitor {
|
||||
/// Per-session key for `IOCTL_ADD`/`IOCTL_REMOVE` (the proto keys monitors by a plain `u64`).
|
||||
session_id: u64,
|
||||
target_id: u32,
|
||||
luid: LUID,
|
||||
gdi_name: Option<String>,
|
||||
mode: Mode,
|
||||
stop: Arc<AtomicBool>,
|
||||
pinger: Option<JoinHandle<()>>,
|
||||
ccd_saved: Option<SavedConfig>,
|
||||
/// Generation stamp (shared [`MON_GEN`]); a [`MonitorLease`] only releases if its gen still matches.
|
||||
gen: u64,
|
||||
}
|
||||
|
||||
enum MgrState {
|
||||
Idle,
|
||||
Active { mon: Monitor, refs: u32 },
|
||||
Lingering { mon: Monitor, until: Instant },
|
||||
}
|
||||
|
||||
struct Mgr {
|
||||
/// Control-device handle (raw isize; `HANDLE` isn't `Send`). Opened once, kept for the host life.
|
||||
device: Option<isize>,
|
||||
watchdog_s: u32,
|
||||
state: MgrState,
|
||||
}
|
||||
|
||||
static MGR: Mutex<Mgr> = Mutex::new(Mgr {
|
||||
device: None,
|
||||
watchdog_s: 10,
|
||||
state: MgrState::Idle,
|
||||
});
|
||||
|
||||
/// The Windows pf-vdisplay backend. A marker — the monitor lifecycle lives in the global [`MGR`].
|
||||
pub struct PfVdisplayDisplay;
|
||||
|
||||
impl PfVdisplayDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
// Open the control device once (validates the driver is present + version-matches) + log the
|
||||
// watchdog timeout.
|
||||
let mut g = MGR.lock().unwrap();
|
||||
mgr_ensure_device(&mut g)?;
|
||||
Ok(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PfVdisplayDisplay {
|
||||
fn drop(&mut self) {
|
||||
// Nothing: the control device + monitor lifecycle are host-level (owned by MGR) and
|
||||
// deliberately outlive any single session so a reconnect can reuse the monitor.
|
||||
}
|
||||
}
|
||||
|
||||
impl VirtualDisplay for PfVdisplayDisplay {
|
||||
fn name(&self) -> &'static str {
|
||||
"pf-vdisplay"
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
// Delegate to the host-level manager: create the monitor, reuse a lingering one on reconnect,
|
||||
// or join the live one — and hand back a lease whose Drop releases the refcount.
|
||||
mgr_acquire(mode)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a fresh pf-vdisplay monitor at `mode` on the (host-level) control `device`. ADD the target,
|
||||
/// start the watchdog ping, resolve the GDI name, force the client mode + (default) isolate to a sole
|
||||
/// composited display. Returns the [`Monitor`] resources; the manager tracks its lifecycle
|
||||
/// (refcount + linger).
|
||||
unsafe fn create_monitor(device: isize, mode: Mode, watchdog_s: u32) -> Result<Monitor> {
|
||||
let dev = HANDLE(device as *mut c_void);
|
||||
{
|
||||
// Fresh session id per created monitor (the manager refcount, not the id, prevents the
|
||||
// cross-session REMOVE collision).
|
||||
let session_id = next_session_id();
|
||||
let add = control::AddRequest {
|
||||
session_id,
|
||||
width: mode.width,
|
||||
height: mode.height,
|
||||
refresh_hz: mode.refresh_hz,
|
||||
_reserved: 0,
|
||||
};
|
||||
// SET_RENDER_ADAPTER is OPT-IN. By default we do NOT pin the render adapter — let the IDD use
|
||||
// its natural adapter (Apollo-parity; avoids the cross-GPU mismatch ACCESS_LOST storm). Opt in
|
||||
// with PUNKTFUNK_RENDER_ADAPTER=<name substring> or the IDD-push path (which MUST run NVENC on
|
||||
// the discrete render GPU it pins here). The pf-vdisplay driver now IMPLEMENTS this IOCTL
|
||||
// (IddCxAdapterSetRenderAdapter); a failure is still tolerated (the driver also reports its real
|
||||
// render LUID in the shared header, so the host binds to the right GPU regardless).
|
||||
let pinned = if std::env::var("PUNKTFUNK_RENDER_ADAPTER").is_ok() {
|
||||
unsafe { resolve_render_adapter_luid() }
|
||||
} else if std::env::var_os("PUNKTFUNK_IDD_PUSH").is_some() {
|
||||
// P2 direct frame push: the host opens the driver's shared textures AND runs NVENC on the
|
||||
// RENDER adapter, so on a hybrid box (dGPU + iGPU) it MUST be the discrete encoder GPU — an
|
||||
// iGPU-rendered surface is untouchable by NVENC. pf-vdisplay now IMPLEMENTS
|
||||
// SET_RENDER_ADAPTER, so pin the discrete GPU; the driver also reports the resulting render LUID
|
||||
// in the shared header, so the host binds correctly even if this is overridden.
|
||||
tracing::info!("IDD push: pinning the discrete render GPU (SET_RENDER_ADAPTER)");
|
||||
unsafe { resolve_render_adapter_luid() }
|
||||
} else {
|
||||
tracing::info!(
|
||||
"pf-vdisplay SET_RENDER_ADAPTER skipped (no render pin — avoids cross-GPU mismatch; \
|
||||
set PUNKTFUNK_RENDER_ADAPTER=<name> to force a specific render GPU)"
|
||||
);
|
||||
None
|
||||
};
|
||||
if let Some(luid) = pinned {
|
||||
match unsafe { set_render_adapter(dev, luid) } {
|
||||
Ok(()) => tracing::info!(
|
||||
luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
|
||||
"pf-vdisplay SET_RENDER_ADAPTER: pinned IDD render GPU"
|
||||
),
|
||||
// Non-fatal: warn + continue (do NOT propagate). The driver reports its real render LUID
|
||||
// in the shared header and the host binds to that, so the natural-adapter path still works.
|
||||
Err(e) => tracing::warn!(
|
||||
"pf-vdisplay SET_RENDER_ADAPTER failed (continuing on the natural adapter): {e:#}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = [0u8; size_of::<control::AddReply>()];
|
||||
unsafe { ioctl(dev, control::IOCTL_ADD, bytemuck::bytes_of(&add), &mut out) }
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"pf-vdisplay ADD {}x{}@{}",
|
||||
mode.width, mode.height, mode.refresh_hz
|
||||
)
|
||||
})?;
|
||||
// `pod_read_unaligned` (NOT `from_bytes`): `out` is a stack `[u8; N]` with no guaranteed
|
||||
// 4-byte alignment, and `from_bytes` PANICS on an alignment mismatch. This copies the bytes
|
||||
// into a properly-aligned `AddReply` value.
|
||||
let reply: control::AddReply =
|
||||
bytemuck::pod_read_unaligned(&out[..size_of::<control::AddReply>()]);
|
||||
let luid = LUID {
|
||||
LowPart: reply.adapter_luid_low,
|
||||
HighPart: reply.adapter_luid_high,
|
||||
};
|
||||
tracing::info!(
|
||||
"pf-vdisplay created {}x{}@{} (target_id={}, adapter_luid={:#x})",
|
||||
mode.width,
|
||||
mode.height,
|
||||
mode.refresh_hz,
|
||||
reply.target_id,
|
||||
luid.LowPart
|
||||
);
|
||||
if let Some(pin) = pinned {
|
||||
if luid.LowPart == pin.LowPart && luid.HighPart == pin.HighPart {
|
||||
tracing::info!("pf-vdisplay ADD render adapter matches the pinned GPU (pin took)");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
add = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
|
||||
pinned = format!("{:08x}:{:08x}", pin.HighPart, pin.LowPart),
|
||||
"pf-vdisplay ADD render adapter DIFFERS from pinned — driver ignored SET_RENDER_ADAPTER?"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Mandatory keepalive: ping inside the watchdog window or the driver tears all displays down.
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let device_raw = device;
|
||||
let interval = Duration::from_millis(watchdog_s as u64 * 1000 / 3);
|
||||
let stop_t = stop.clone();
|
||||
let pinger = thread::spawn(move || {
|
||||
let h = HANDLE(device_raw as *mut c_void);
|
||||
let mut warned = false;
|
||||
while !stop_t.load(Ordering::Relaxed) {
|
||||
let mut none: [u8; 0] = [];
|
||||
match unsafe { ioctl(h, control::IOCTL_PING, &[], &mut none) } {
|
||||
Ok(_) => warned = false,
|
||||
// A persistently failing PING means the cached control handle went invalid — the
|
||||
// driver watchdog will then tear the monitor down mid-session. Surface it once.
|
||||
Err(e) => {
|
||||
if !warned {
|
||||
tracing::warn!(
|
||||
"pf-vdisplay keepalive PING failed (control handle lost?): {e:#}"
|
||||
);
|
||||
warned = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
thread::sleep(interval);
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve the capture target. May be None on a GPU-less box (target added but not activated
|
||||
// into a WDDM path); the Windows capture backend will re-resolve once a GPU is present.
|
||||
let mut gdi_name = None;
|
||||
for _ in 0..15 {
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
if let Some(n) = unsafe { resolve_gdi_name(reply.target_id) } {
|
||||
gdi_name = Some(n);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut ccd_saved: Option<SavedConfig> = None;
|
||||
match &gdi_name {
|
||||
Some(n) => {
|
||||
tracing::info!("pf-vdisplay target {} -> {n}", reply.target_id);
|
||||
// ADD only advertises the mode; force it active so DXGI captures the requested size.
|
||||
set_active_mode(n, mode);
|
||||
// Make the pf-vdisplay the SOLE active display (default). An EXTENDED (non-primary) IDD
|
||||
// is NOT DWM-composited → Desktop Duplication gets a born-lost ACCESS_LOST; deactivating
|
||||
// the other display(s) FIRST (CCD, atomic) leaves the virtual output as the sole →
|
||||
// primary → composited desktop, so all content (incl. Winlogon) renders to it without a
|
||||
// MODE_CHANGE_IN_PROGRESS storm. Opt out with PUNKTFUNK_NO_ISOLATE=1 (a box with a real
|
||||
// second monitor to keep live).
|
||||
if std::env::var("PUNKTFUNK_NO_ISOLATE").is_err() {
|
||||
ccd_saved = unsafe { isolate_displays_ccd(reply.target_id) };
|
||||
} else {
|
||||
tracing::info!(
|
||||
"display isolation skipped (PUNKTFUNK_NO_ISOLATE) — IDD stays extended"
|
||||
);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture opens
|
||||
}
|
||||
None => tracing::warn!(
|
||||
"pf-vdisplay target {} not yet an active display path (needs a WDDM GPU to activate)",
|
||||
reply.target_id
|
||||
),
|
||||
}
|
||||
|
||||
Ok(Monitor {
|
||||
session_id,
|
||||
target_id: reply.target_id,
|
||||
luid,
|
||||
gdi_name,
|
||||
mode,
|
||||
stop,
|
||||
pinger: Some(pinger),
|
||||
ccd_saved,
|
||||
gen: MON_GEN.fetch_add(1, Ordering::Relaxed),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Monitor {
|
||||
/// The capture target handed to a session (`None` until the GDI name resolves).
|
||||
fn target(&self) -> Option<crate::capture::dxgi::WinCaptureTarget> {
|
||||
self.gdi_name
|
||||
.clone()
|
||||
.map(|n| crate::capture::dxgi::WinCaptureTarget {
|
||||
adapter_luid: crate::capture::dxgi::pack_luid(self.luid),
|
||||
gdi_name: n,
|
||||
// target_id is stable across secure-desktop topology rebuilds; the GDI name is NOT,
|
||||
// so capture re-resolves the name from this on every recovery.
|
||||
target_id: self.target_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Stop the watchdog ping, re-attach the displays we detached, then REMOVE the monitor (by session
|
||||
/// id). `device` is the host-level control handle. Consumes the monitor.
|
||||
unsafe fn teardown(mut self, device: isize) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
if let Some(j) = self.pinger.take() {
|
||||
let _ = j.join();
|
||||
}
|
||||
// Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero displays.
|
||||
if let Some(saved) = &self.ccd_saved {
|
||||
restore_displays_ccd(saved);
|
||||
}
|
||||
let req = control::RemoveRequest {
|
||||
session_id: self.session_id,
|
||||
};
|
||||
let mut none: [u8; 0] = [];
|
||||
let h = HANDLE(device as *mut c_void);
|
||||
if let Err(e) = ioctl(
|
||||
h,
|
||||
control::IOCTL_REMOVE,
|
||||
bytemuck::bytes_of(&req),
|
||||
&mut none,
|
||||
) {
|
||||
tracing::warn!("pf-vdisplay REMOVE failed: {e:#}");
|
||||
} else {
|
||||
tracing::info!("pf-vdisplay monitor removed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the control device once + version/watchdog handshake; cache the handle (raw isize) in `g`.
|
||||
fn mgr_ensure_device(g: &mut Mgr) -> Result<isize> {
|
||||
if let Some(d) = g.device {
|
||||
return Ok(d);
|
||||
}
|
||||
let device = unsafe { open_device()? };
|
||||
// Single version+watchdog handshake. The proto intends a HARD protocol-version check (unlike
|
||||
// SudoVDA's best-effort log) — a mismatched host/driver pair fails loudly here rather than
|
||||
// corrupting the IOCTL stream.
|
||||
let mut info_buf = [0u8; size_of::<control::InfoReply>()];
|
||||
unsafe { ioctl(device, control::IOCTL_GET_INFO, &[], &mut info_buf) }
|
||||
.context("pf-vdisplay IOCTL_GET_INFO (version handshake)")?;
|
||||
// `pod_read_unaligned` (see the AddReply note): copies out of the unaligned stack buffer.
|
||||
let info: control::InfoReply =
|
||||
bytemuck::pod_read_unaligned(&info_buf[..size_of::<control::InfoReply>()]);
|
||||
if info.protocol_version != pf_vdisplay_proto::PROTOCOL_VERSION {
|
||||
// Close the handle before bailing so a retry re-opens cleanly.
|
||||
unsafe {
|
||||
let _ = CloseHandle(device);
|
||||
}
|
||||
anyhow::bail!(
|
||||
"pf-vdisplay protocol mismatch: host expects {}, driver reports {} — install matching \
|
||||
host + driver",
|
||||
pf_vdisplay_proto::PROTOCOL_VERSION,
|
||||
info.protocol_version
|
||||
);
|
||||
}
|
||||
g.watchdog_s = info.watchdog_timeout_s.max(1);
|
||||
tracing::info!(
|
||||
"pf-vdisplay protocol {} (watchdog timeout {}s)",
|
||||
info.protocol_version,
|
||||
g.watchdog_s
|
||||
);
|
||||
// Reap monitors orphaned by a crashed/killed previous host instance before we create ours. This is
|
||||
// a FIRST-CLASS op on pf-vdisplay (the driver returns SUCCESS), NOT a "send-and-hope" hack: without
|
||||
// it an orphan lingers until the driver watchdog fires — but a still-pinging new session keeps
|
||||
// resetting that watchdog, so orphans could accumulate.
|
||||
{
|
||||
let mut none: [u8; 0] = [];
|
||||
if unsafe { ioctl(device, control::IOCTL_CLEAR_ALL, &[], &mut none) }.is_ok() {
|
||||
tracing::info!("cleared orphaned virtual monitors on host startup");
|
||||
} else {
|
||||
tracing::warn!("pf-vdisplay IOCTL_CLEAR_ALL failed on startup (continuing)");
|
||||
}
|
||||
}
|
||||
let raw = device.0 as isize;
|
||||
g.device = Some(raw);
|
||||
Ok(raw)
|
||||
}
|
||||
|
||||
/// Linger window before a session-less monitor is torn down. A reconnect within it reuses the
|
||||
/// monitor (no new screen / PnP chime); after it the monitor is REMOVEd so a physical screen returns.
|
||||
fn linger_ms() -> u64 {
|
||||
std::env::var("PUNKTFUNK_MONITOR_LINGER_MS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(10_000)
|
||||
}
|
||||
|
||||
/// Acquire the shared monitor for a new session: join the live one (refcount++), reuse a lingering
|
||||
/// one (reconfiguring if the client mode changed), or create one. The returned [`MonitorLease`]
|
||||
/// releases the refcount on drop.
|
||||
fn mgr_acquire(mode: Mode) -> Result<VirtualOutput> {
|
||||
ensure_linger_timer();
|
||||
let mut g = MGR.lock().unwrap();
|
||||
let device = mgr_ensure_device(&mut g)?;
|
||||
let watchdog_s = g.watchdog_s;
|
||||
|
||||
// IDD-push: a new connection while a monitor is live = a single-client RECONNECT (the prior client
|
||||
// is gone — IDD-push is one display, no concurrency). A REUSED IddCx monitor's swap-chain is DEAD,
|
||||
// so joining it would hand the new client a black screen until the old session times out. PREEMPT:
|
||||
// tear the old monitor down (its teardown restores topology + IOCTL_REMOVEs) and fall through to
|
||||
// create a FRESH one. The old session's lease is gen-stamped, so its later drop is ignored
|
||||
// (mgr_release no-op) and can't tear down the new monitor.
|
||||
if idd_push_mode()
|
||||
&& matches!(
|
||||
g.state,
|
||||
MgrState::Active { .. } | MgrState::Lingering { .. }
|
||||
)
|
||||
{
|
||||
if let MgrState::Active { mon, .. } | MgrState::Lingering { mon, .. } =
|
||||
std::mem::replace(&mut g.state, MgrState::Idle)
|
||||
{
|
||||
tracing::info!(
|
||||
old_target = mon.target_id,
|
||||
"IDD-push reconnect — preempting the prior session, recreating a fresh monitor"
|
||||
);
|
||||
// teardown() — NOT drop() — sends IOCTL_REMOVE (and restores topology). `Monitor` has NO
|
||||
// `Drop` impl, so a bare `drop(mon)` would orphan the IddCx monitor in the driver (never
|
||||
// departed → leaks a live D3D device + a stuck swap-chain processor thread per reconnect).
|
||||
unsafe { mon.teardown(device) };
|
||||
// Let the OS finish the ASYNC IddCx monitor departure before the next ADD. A back-to-back
|
||||
// REMOVE→ADD races the teardown and the ADD IOCTL is rejected under reconnect churn.
|
||||
thread::sleep(Duration::from_millis(400));
|
||||
}
|
||||
}
|
||||
|
||||
// A live monitor already exists — join it (refcount++). This covers a concurrent session AND the
|
||||
// build-then-drop overlap of a mid-stream Reconfigure / secure-return (the new lease is taken while
|
||||
// the old is still held). If the requested mode differs, reconfigure the shared monitor to it so a
|
||||
// Reconfigure actually applies (one shared monitor → sessions necessarily share a mode).
|
||||
if let MgrState::Active { mon, refs } = &mut g.state {
|
||||
*refs += 1;
|
||||
let changed = mon.mode.width != mode.width
|
||||
|| mon.mode.height != mode.height
|
||||
|| mon.mode.refresh_hz != mode.refresh_hz;
|
||||
if changed {
|
||||
unsafe { mgr_reconfigure(mon, mode) };
|
||||
}
|
||||
tracing::info!(
|
||||
refs = *refs,
|
||||
"pf-vdisplay monitor reused (concurrent / reconfigure session)"
|
||||
);
|
||||
let pm = Some((mon.mode.width, mon.mode.height, mon.mode.refresh_hz));
|
||||
let target = mon.target();
|
||||
let gen = mon.gen;
|
||||
CURRENT_MON_GEN.store(gen, Ordering::Relaxed);
|
||||
return Ok(VirtualOutput {
|
||||
node_id: 0,
|
||||
preferred_mode: pm,
|
||||
win_capture: target,
|
||||
keepalive: Box::new(MonitorLease { gen }),
|
||||
});
|
||||
}
|
||||
|
||||
// Idle or Lingering: repurpose/create a monitor → Active{refs:1}.
|
||||
let mon = match std::mem::replace(&mut g.state, MgrState::Idle) {
|
||||
MgrState::Lingering { mut mon, .. } => {
|
||||
tracing::info!("pf-vdisplay monitor reused (reconnect within the linger window)");
|
||||
let changed = mon.mode.width != mode.width
|
||||
|| mon.mode.height != mode.height
|
||||
|| mon.mode.refresh_hz != mode.refresh_hz;
|
||||
if changed {
|
||||
unsafe { mgr_reconfigure(&mut mon, mode) };
|
||||
}
|
||||
mon
|
||||
}
|
||||
MgrState::Idle => unsafe { create_monitor(device, mode, watchdog_s)? },
|
||||
MgrState::Active { .. } => unreachable!("handled above"),
|
||||
};
|
||||
let pm = Some((mon.mode.width, mon.mode.height, mon.mode.refresh_hz));
|
||||
let target = mon.target();
|
||||
let gen = mon.gen;
|
||||
CURRENT_MON_GEN.store(gen, Ordering::Relaxed);
|
||||
g.state = MgrState::Active { mon, refs: 1 };
|
||||
Ok(VirtualOutput {
|
||||
node_id: 0,
|
||||
preferred_mode: pm,
|
||||
win_capture: target,
|
||||
keepalive: Box::new(MonitorLease { gen }),
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-apply a (possibly new) mode to a reused monitor on reconnect, re-resolving its GDI name.
|
||||
unsafe fn mgr_reconfigure(mon: &mut Monitor, mode: Mode) {
|
||||
tracing::info!(
|
||||
old = format!(
|
||||
"{}x{}@{}",
|
||||
mon.mode.width, mon.mode.height, mon.mode.refresh_hz
|
||||
),
|
||||
new = format!("{}x{}@{}", mode.width, mode.height, mode.refresh_hz),
|
||||
"pf-vdisplay: reconfiguring reused monitor to the new client mode"
|
||||
);
|
||||
if let Some(n) = resolve_gdi_name(mon.target_id) {
|
||||
mon.gdi_name = Some(n);
|
||||
}
|
||||
if let Some(n) = &mon.gdi_name {
|
||||
set_active_mode(n, mode);
|
||||
}
|
||||
mon.mode = mode;
|
||||
}
|
||||
|
||||
/// Release a session's hold: refcount-- ; when the last session leaves, LINGER before teardown.
|
||||
/// `gen` is the lease's monitor generation: a STALE lease (its monitor was already torn down +
|
||||
/// recreated under it — the IDD-push reconnect-preempt path) does nothing, so it can't decrement the
|
||||
/// CURRENT (fresh) monitor's refcount and tear it down.
|
||||
fn mgr_release(gen: u64) {
|
||||
let mut g = MGR.lock().unwrap();
|
||||
let stale = match &g.state {
|
||||
MgrState::Active { mon, .. } | MgrState::Lingering { mon, .. } => mon.gen != gen,
|
||||
MgrState::Idle => true,
|
||||
};
|
||||
if stale {
|
||||
return;
|
||||
}
|
||||
g.state = match std::mem::replace(&mut g.state, MgrState::Idle) {
|
||||
MgrState::Active { mon, refs } if refs > 1 => MgrState::Active {
|
||||
mon,
|
||||
refs: refs - 1,
|
||||
},
|
||||
MgrState::Active { mon, .. } => {
|
||||
let ms = linger_ms();
|
||||
tracing::info!(
|
||||
linger_ms = ms,
|
||||
"pf-vdisplay: last session left — lingering before teardown"
|
||||
);
|
||||
MgrState::Lingering {
|
||||
mon,
|
||||
until: Instant::now() + Duration::from_millis(ms),
|
||||
}
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
}
|
||||
|
||||
// NOTE: `wait_for_monitor_released` is NOT redefined here. Its only caller (`punktfunk1.rs`, the
|
||||
// IDD-push reconnect preempt) reaches it as `crate::vdisplay::sudovda::wait_for_monitor_released`, and
|
||||
// pf_vdisplay.rs never calls it internally (the preempt is done inline in `mgr_acquire` above), so a
|
||||
// second copy here would be dead code waiting on the (separate) pf-vdisplay MGR. The two backends keep
|
||||
// independent MGRs but only one is ever active — see the cross-MGR caveat in the implementation report.
|
||||
|
||||
/// Background timer (started once): tear down a monitor that has lingered past its deadline (→ Idle),
|
||||
/// so a physical-screen user gets their screen back after they stop streaming.
|
||||
fn ensure_linger_timer() {
|
||||
static TIMER: Once = Once::new();
|
||||
TIMER.call_once(|| {
|
||||
let _ = thread::Builder::new()
|
||||
.name("pf-vdisplay-linger".into())
|
||||
.spawn(|| loop {
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
let mut g = MGR.lock().unwrap();
|
||||
let due = matches!(&g.state, MgrState::Lingering { until, .. } if Instant::now() >= *until);
|
||||
if due {
|
||||
let device = g.device.unwrap_or(0);
|
||||
if let MgrState::Lingering { mon, .. } =
|
||||
std::mem::replace(&mut g.state, MgrState::Idle)
|
||||
{
|
||||
drop(g); // release the lock before the REMOVE IOCTL + display restore
|
||||
unsafe { mon.teardown(device) };
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A session's lease on the shared monitor. Drop releases the refcount (→ linger when it hits 0),
|
||||
/// UNLESS the monitor was already torn down + recreated under it (gen mismatch — the IDD-push
|
||||
/// reconnect-preempt path), in which case the drop is a no-op so it can't tear down the new monitor.
|
||||
struct MonitorLease {
|
||||
gen: u64,
|
||||
}
|
||||
impl Drop for MonitorLease {
|
||||
fn drop(&mut self) {
|
||||
mgr_release(self.gen);
|
||||
}
|
||||
}
|
||||
|
||||
/// Readiness probe: can we open the pf-vdisplay control device?
|
||||
pub fn probe() -> Result<()> {
|
||||
let h = unsafe { open_device()? };
|
||||
unsafe {
|
||||
let _ = CloseHandle(h);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Is the pf-vdisplay driver present (device interface enumerable)?
|
||||
pub fn is_available() -> bool {
|
||||
unsafe { open_device().map(|h| CloseHandle(h)).is_ok() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Live hardware round trip — skipped unless `PUNKTFUNK_PF_VDISPLAY_LIVE=1` (needs the pf-vdisplay
|
||||
/// driver installed). Exercises the real trait path: open -> create -> hold -> drop (REMOVE).
|
||||
#[test]
|
||||
fn live_create_drop() {
|
||||
if std::env::var("PUNKTFUNK_PF_VDISPLAY_LIVE").is_err() {
|
||||
return;
|
||||
}
|
||||
let mut vd = PfVdisplayDisplay::new().expect("open pf-vdisplay");
|
||||
let vout = vd
|
||||
.create(Mode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
refresh_hz: 60,
|
||||
})
|
||||
.expect("create virtual display");
|
||||
assert_eq!(vout.preferred_mode, Some((1920, 1080, 60)));
|
||||
thread::sleep(Duration::from_secs(3));
|
||||
drop(vout); // triggers REMOVE + stops the pinger
|
||||
}
|
||||
}
|
||||
@@ -1,777 +0,0 @@
|
||||
//! Windows virtual-display backend driving **SudoVDA** (the SudoMaker Virtual Display Adapter —
|
||||
//! the Indirect Display Driver the Apollo Sunshine-fork ships). The Windows analogue of the
|
||||
//! Linux per-compositor backends: [`create`](VirtualDisplay::create) adds a virtual monitor at the
|
||||
//! client's exact `WxH@Hz` (the mode is baked into the ADD IOCTL — no EDID seeding), starts the
|
||||
//! mandatory watchdog ping, and the returned [`VirtualOutput`]'s keepalive `Drop` removes it (RAII).
|
||||
//!
|
||||
//! Control surface (verified live against SudoVDA 0.2.1): a device-interface-GUID + `CreateFileW`
|
||||
//! + `DeviceIoControl` IOCTL protocol. No DLL, no named pipe. See `docs/windows-host.md`.
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::mem::size_of;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, Once};
|
||||
|
||||
/// Monotonic monitor generation. Each [`create_monitor`] stamps the next value onto the [`Monitor`]
|
||||
/// and its [`MonitorLease`]s, so a lease whose monitor was already torn down + recreated (the IDD-push
|
||||
/// reconnect-preempt path) is ignored on drop instead of decrementing the NEW monitor's refcount.
|
||||
// pub(crate) so vdisplay::pf_vdisplay can reuse this shared generation counter (one counter across both
|
||||
// backends keeps the idd_push stale-ring bail working regardless of which backend is active).
|
||||
pub(crate) static MON_GEN: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
/// The gen of the CURRENTLY-active monitor. A session capturer captures this at open and re-checks it
|
||||
/// each frame; when it changes (a reconnect preempted + recreated the monitor), the old session bails
|
||||
/// IMMEDIATELY instead of lingering on the dead ring's 20s frame deadline — which would otherwise hold
|
||||
/// its NVENC encoder open and exhaust the GPU's encode-session limit under rapid reconnects.
|
||||
pub(crate) static CURRENT_MON_GEN: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// IDD-push mode: a new client connection preempts + recreates the monitor (single-client reconnect),
|
||||
/// because a REUSED IddCx monitor's swap-chain is dead. Off → monitors are shared across sessions.
|
||||
fn idd_push_mode() -> bool {
|
||||
std::env::var_os("PUNKTFUNK_IDD_PUSH").is_some()
|
||||
}
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use windows::core::{GUID, PCWSTR};
|
||||
use windows::Win32::Devices::DeviceAndDriverInstallation::{
|
||||
SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInterfaces, SetupDiGetClassDevsW,
|
||||
SetupDiGetDeviceInterfaceDetailW, DIGCF_DEVICEINTERFACE, DIGCF_PRESENT,
|
||||
SP_DEVICE_INTERFACE_DATA, SP_DEVICE_INTERFACE_DETAIL_DATA_W,
|
||||
};
|
||||
// (CCD `Devices::Display` + `Graphics::Gdi` imports moved with the display helpers to `win_display`.)
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE, LUID};
|
||||
use windows::Win32::Storage::FileSystem::{
|
||||
CreateFileW, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
|
||||
};
|
||||
use windows::Win32::System::IO::DeviceIoControl;
|
||||
|
||||
use super::{Mode, VirtualDisplay, VirtualOutput};
|
||||
|
||||
// SudoVDA device-interface GUID (Common/Include/sudovda-ioctl.h).
|
||||
const SUVDA_INTERFACE: GUID = GUID::from_u128(0xE5BC_C234_1E0C_418A_A0D4_EF8B_7501_414D);
|
||||
|
||||
// CTL_CODE(FILE_DEVICE_UNKNOWN=0x22, func, METHOD_BUFFERED=0, FILE_ANY_ACCESS=0).
|
||||
const fn ctl(func: u32) -> u32 {
|
||||
(0x22u32 << 16) | (func << 2)
|
||||
}
|
||||
const IOCTL_ADD: u32 = ctl(0x800);
|
||||
const IOCTL_REMOVE: u32 = ctl(0x801);
|
||||
const IOCTL_SET_RENDER_ADAPTER: u32 = ctl(0x802); // == 0x0022_2008
|
||||
const IOCTL_GET_WATCHDOG: u32 = ctl(0x803);
|
||||
/// pf-vdisplay extension (NOT in SudoVDA): tear down every virtual monitor. Sent once on host startup
|
||||
/// to reap monitors orphaned by a crashed/killed previous host. SudoVDA returns invalid (ignored).
|
||||
const IOCTL_CLEAR_ALL: u32 = ctl(0x804);
|
||||
const IOCTL_DRIVER_PING: u32 = ctl(0x888);
|
||||
const IOCTL_GET_VERSION: u32 = ctl(0x8FF);
|
||||
|
||||
/// A UNIQUE-per-session SudoVDA monitor GUID. The monitor is keyed by GUID for IOCTL_ADD/REMOVE, so a
|
||||
/// FIXED GUID makes overlapping sessions (a client reconnecting after a freeze before the old session
|
||||
/// has torn down, or genuine concurrent sessions) all map to the SAME monitor — then one session's
|
||||
/// IOCTL_REMOVE on teardown tears the monitor down OUT FROM UNDER a still-live session ("display
|
||||
/// disconnected" sound + freeze, even with no context change — observed live). Make it unique per
|
||||
/// (process, session): base GUID with the low 48-bit node = (pid << 16 | session#).
|
||||
fn next_monitor_guid() -> GUID {
|
||||
use std::sync::atomic::AtomicU32;
|
||||
static N: AtomicU32 = AtomicU32::new(0);
|
||||
let n = N.fetch_add(1, Ordering::Relaxed) as u128;
|
||||
let pid = std::process::id() as u128;
|
||||
GUID::from_u128(0x70756E6B_7466_756E_6B30_000000000000u128 | (pid << 16) | (n & 0xFFFF))
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct AddParams {
|
||||
width: u32,
|
||||
height: u32,
|
||||
refresh: u32,
|
||||
guid: GUID,
|
||||
device_name: [u8; 14],
|
||||
serial: [u8; 14],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct AddOut {
|
||||
luid: LUID,
|
||||
target_id: u32,
|
||||
}
|
||||
|
||||
// SET_RENDER_ADAPTER input — byte-identical to SudoVDA's `{ LUID AdapterLuid; }` (8 bytes). The
|
||||
// windows `LUID` is `{ LowPart: u32, HighPart: i32 }` == the C `LUID`, so `#[repr(C)]` is exact.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct SetRenderAdapterParams {
|
||||
luid: LUID,
|
||||
}
|
||||
|
||||
/// Pin the SudoVDA IDD's RENDER GPU to `luid` (Apollo's `SetRenderAdapter`). No output buffer. MUST be
|
||||
/// issued on the driver handle BEFORE `IOCTL_ADD` to steer which GPU the new target renders on — on a
|
||||
/// multi-adapter box (SudoVDA IDD + a discrete GPU) this stops DXGI from reparenting the virtual
|
||||
/// output onto a different adapter than the one we duplicate/encode on (the ACCESS_LOST storm).
|
||||
unsafe fn set_render_adapter(h: HANDLE, luid: LUID) -> Result<()> {
|
||||
let p = SetRenderAdapterParams { luid };
|
||||
let bytes = std::slice::from_raw_parts(
|
||||
&p as *const _ as *const u8,
|
||||
size_of::<SetRenderAdapterParams>(),
|
||||
);
|
||||
let mut none: [u8; 0] = [];
|
||||
ioctl(h, IOCTL_SET_RENDER_ADAPTER, bytes, &mut none)
|
||||
.map(|_| ())
|
||||
.context("SudoVDA SET_RENDER_ADAPTER")
|
||||
}
|
||||
|
||||
// `resolve_render_adapter_luid` moved to the backend-neutral `crate::win_adapter` (audit §9 / Goal 2:
|
||||
// it is display-utility, not SudoVDA-specific). Re-exported so this backend's own callers keep the short
|
||||
// name; external callers (idd_push, pf_vdisplay) use `crate::win_adapter` directly.
|
||||
pub(crate) use crate::win_adapter::resolve_render_adapter_luid;
|
||||
|
||||
#[repr(C)]
|
||||
struct RemoveParams {
|
||||
guid: GUID,
|
||||
}
|
||||
|
||||
/// One `DeviceIoControl` round trip (METHOD_BUFFERED). `input`/`output` may be empty.
|
||||
unsafe fn ioctl(h: HANDLE, code: u32, input: &[u8], output: &mut [u8]) -> Result<u32> {
|
||||
let mut returned = 0u32;
|
||||
let inp = (!input.is_empty()).then_some(input.as_ptr() as *const c_void);
|
||||
let outp = (!output.is_empty()).then_some(output.as_mut_ptr() as *mut c_void);
|
||||
DeviceIoControl(
|
||||
h,
|
||||
code,
|
||||
inp,
|
||||
input.len() as u32,
|
||||
outp,
|
||||
output.len() as u32,
|
||||
Some(&mut returned),
|
||||
None,
|
||||
)
|
||||
.with_context(|| format!("DeviceIoControl(code={code:#x})"))?;
|
||||
Ok(returned)
|
||||
}
|
||||
|
||||
// The CCD/GDI display helpers (resolve_gdi_name, set_advanced_color, advanced_color_enabled,
|
||||
// set_active_mode, isolate/restore_displays_ccd) + SavedConfig moved to the backend-neutral
|
||||
// `crate::win_display` (audit §9 / Goal 2). Re-exported so this backend's own callers keep the short
|
||||
// names; external callers use `crate::win_display` directly.
|
||||
pub(crate) use crate::win_display::{
|
||||
isolate_displays_ccd, resolve_gdi_name, restore_displays_ccd, set_active_mode, SavedConfig,
|
||||
};
|
||||
|
||||
unsafe fn open_device() -> Result<HANDLE> {
|
||||
let hdev = SetupDiGetClassDevsW(
|
||||
Some(&SUVDA_INTERFACE),
|
||||
PCWSTR::null(),
|
||||
None,
|
||||
DIGCF_DEVICEINTERFACE | DIGCF_PRESENT,
|
||||
)
|
||||
.context("SetupDiGetClassDevsW(SudoVDA) — is the SudoVDA driver installed?")?;
|
||||
|
||||
let mut idata = SP_DEVICE_INTERFACE_DATA {
|
||||
cbSize: size_of::<SP_DEVICE_INTERFACE_DATA>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
SetupDiEnumDeviceInterfaces(hdev, None, &SUVDA_INTERFACE, 0, &mut idata)
|
||||
.context("SetupDiEnumDeviceInterfaces(SudoVDA)")?;
|
||||
|
||||
let mut required = 0u32;
|
||||
let _ = SetupDiGetDeviceInterfaceDetailW(hdev, &idata, None, 0, Some(&mut required), None);
|
||||
let mut buf = vec![0u8; required as usize];
|
||||
let detail = buf.as_mut_ptr() as *mut SP_DEVICE_INTERFACE_DETAIL_DATA_W;
|
||||
(*detail).cbSize = size_of::<SP_DEVICE_INTERFACE_DETAIL_DATA_W>() as u32;
|
||||
SetupDiGetDeviceInterfaceDetailW(hdev, &idata, Some(detail), required, None, None)
|
||||
.context("SetupDiGetDeviceInterfaceDetailW(SudoVDA)")?;
|
||||
|
||||
let handle = CreateFileW(
|
||||
PCWSTR((*detail).DevicePath.as_ptr()),
|
||||
0xC000_0000, // GENERIC_READ | GENERIC_WRITE
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
None,
|
||||
OPEN_EXISTING,
|
||||
FILE_FLAGS_AND_ATTRIBUTES(0),
|
||||
None,
|
||||
)
|
||||
.context("CreateFileW(SudoVDA device)")?;
|
||||
let _ = SetupDiDestroyDeviceInfoList(hdev);
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
// ── Host-level reference-counted SudoVDA monitor lifecycle ──────────────────────────────────────
|
||||
//
|
||||
// The virtual monitor is created on the first session and REUSED across sessions. When the last
|
||||
// session disconnects the monitor LINGERS for a grace window (PUNKTFUNK_MONITOR_LINGER_MS, default
|
||||
// 10 s): a reconnect within the window reuses it instantly (no new screen, no PnP connect/disconnect
|
||||
// chime, no teardown/recreate kernel churn); after the window a background timer REMOVEs it so a
|
||||
// physical-screen user gets their screen back. Overlapping sessions share one monitor via the
|
||||
// refcount (teardown only at refs==0 + expired grace), so a stale session can never REMOVE a live
|
||||
// session's monitor (the earlier collision). The control-device HANDLE is opened once and kept for
|
||||
// the host lifetime — it's a handle, not a screen, so it creates no phantom display.
|
||||
|
||||
/// The resources backing one live SudoVDA monitor (owned by [`MGR`], not by any session).
|
||||
struct Monitor {
|
||||
guid: GUID,
|
||||
target_id: u32,
|
||||
luid: LUID,
|
||||
gdi_name: Option<String>,
|
||||
mode: Mode,
|
||||
stop: Arc<AtomicBool>,
|
||||
pinger: Option<JoinHandle<()>>,
|
||||
ccd_saved: Option<SavedConfig>,
|
||||
/// Generation stamp ([`MON_GEN`]); a [`MonitorLease`] only releases if its gen still matches.
|
||||
gen: u64,
|
||||
}
|
||||
|
||||
enum MgrState {
|
||||
Idle,
|
||||
Active { mon: Monitor, refs: u32 },
|
||||
Lingering { mon: Monitor, until: Instant },
|
||||
}
|
||||
|
||||
struct Mgr {
|
||||
/// Control-device handle (raw isize; `HANDLE` isn't `Send`). Opened once, kept for the host life.
|
||||
device: Option<isize>,
|
||||
watchdog_s: u32,
|
||||
state: MgrState,
|
||||
}
|
||||
|
||||
static MGR: Mutex<Mgr> = Mutex::new(Mgr {
|
||||
device: None,
|
||||
watchdog_s: 3,
|
||||
state: MgrState::Idle,
|
||||
});
|
||||
|
||||
/// The Windows virtual-display backend. A marker — the monitor lifecycle lives in the global [`MGR`].
|
||||
pub struct SudoVdaDisplay;
|
||||
|
||||
impl SudoVdaDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
// Open the control device once (validates the driver is present) + log version/watchdog.
|
||||
let mut g = MGR.lock().unwrap();
|
||||
mgr_ensure_device(&mut g)?;
|
||||
Ok(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SudoVdaDisplay {
|
||||
fn drop(&mut self) {
|
||||
// Nothing: the control device + monitor lifecycle are host-level (owned by MGR) and
|
||||
// deliberately outlive any single session so a reconnect can reuse the monitor.
|
||||
}
|
||||
}
|
||||
|
||||
impl VirtualDisplay for SudoVdaDisplay {
|
||||
fn name(&self) -> &'static str {
|
||||
"sudovda"
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
// Delegate to the host-level manager: create the monitor, reuse a lingering one on reconnect,
|
||||
// or join the live one — and hand back a lease whose Drop releases the refcount.
|
||||
mgr_acquire(mode)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a fresh SudoVDA monitor at `mode` on the (host-level) control `device`. The old per-session
|
||||
/// `create()` body, now owned by the manager: ADD the target, start the watchdog ping, resolve the
|
||||
/// GDI name, force the client mode + (default) isolate to a sole composited display. Returns the
|
||||
/// [`Monitor`] resources; the manager tracks its lifecycle (refcount + linger).
|
||||
unsafe fn create_monitor(device: isize, mode: Mode, watchdog_s: u32) -> Result<Monitor> {
|
||||
let dev = HANDLE(device as *mut c_void);
|
||||
{
|
||||
let mut device_name = [0u8; 14];
|
||||
let nm = b"punktfunk";
|
||||
device_name[..nm.len()].copy_from_slice(nm);
|
||||
// Fresh GUID per created monitor (the manager refcount, not the GUID, prevents the
|
||||
// cross-session REMOVE collision now).
|
||||
let session_guid = next_monitor_guid();
|
||||
let add = AddParams {
|
||||
width: mode.width,
|
||||
height: mode.height,
|
||||
refresh: mode.refresh_hz,
|
||||
guid: session_guid,
|
||||
device_name,
|
||||
serial: [0u8; 14],
|
||||
};
|
||||
// SET_RENDER_ADAPTER is OPT-IN. Apollo runs with an EMPTY config and NEVER pins the render
|
||||
// adapter, yet captures the SudoVDA cleanly at the client mode on the 4090 (verified live on
|
||||
// this exact box: no ACCESS_LOST, no MODE_CHANGE storm). On this box our pin is IGNORED by the
|
||||
// driver AND the IDD lands on a DIFFERENT adapter (0x23664) than the one its DXGI output is
|
||||
// enumerated under (the 4090, where we make the capture device) — a cross-GPU mismatch that is
|
||||
// the real source of the perpetual ACCESS_LOST + MODE_CHANGE_IN_PROGRESS storm. So default to
|
||||
// NOT pinning — let the IDD use its natural adapter like Apollo. Opt in with
|
||||
// PUNKTFUNK_RENDER_ADAPTER=<name substring> only on a box that genuinely needs steering.
|
||||
let pinned = if std::env::var("PUNKTFUNK_RENDER_ADAPTER").is_ok() {
|
||||
unsafe { resolve_render_adapter_luid() }
|
||||
} else if std::env::var_os("PUNKTFUNK_IDD_PUSH").is_some() {
|
||||
// P2 direct frame push: the host opens the driver's shared textures AND runs NVENC on the
|
||||
// RENDER adapter, so on a hybrid box (4090 + iGPU) it MUST be the discrete encoder GPU —
|
||||
// an iGPU-rendered surface is untouchable by NVENC. pf-vdisplay HONORS SET_RENDER_ADAPTER
|
||||
// (SudoVDA ignored it), so pin the discrete GPU. The driver also reports the resulting
|
||||
// render LUID in the shared header, so the host binds correctly even if this is overridden.
|
||||
tracing::info!("IDD push: pinning the discrete render GPU (SET_RENDER_ADAPTER)");
|
||||
unsafe { resolve_render_adapter_luid() }
|
||||
} else {
|
||||
tracing::info!(
|
||||
"SudoVDA SET_RENDER_ADAPTER skipped (Apollo-parity: no render pin — avoids cross-GPU \
|
||||
mismatch; set PUNKTFUNK_RENDER_ADAPTER=<name> to force a specific render GPU)"
|
||||
);
|
||||
None
|
||||
};
|
||||
if let Some(luid) = pinned {
|
||||
match unsafe { set_render_adapter(dev, luid) } {
|
||||
Ok(()) => tracing::info!(
|
||||
luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
|
||||
"SudoVDA SET_RENDER_ADAPTER: pinned IDD render GPU"
|
||||
),
|
||||
Err(e) => tracing::warn!("SudoVDA SET_RENDER_ADAPTER failed (continuing): {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
let add_bytes = unsafe {
|
||||
std::slice::from_raw_parts(&add as *const _ as *const u8, size_of::<AddParams>())
|
||||
};
|
||||
let mut out = [0u8; size_of::<AddOut>()];
|
||||
unsafe { ioctl(dev, IOCTL_ADD, add_bytes, &mut out) }.with_context(|| {
|
||||
format!(
|
||||
"SudoVDA ADD {}x{}@{}",
|
||||
mode.width, mode.height, mode.refresh_hz
|
||||
)
|
||||
})?;
|
||||
let ao = unsafe { *(out.as_ptr() as *const AddOut) };
|
||||
tracing::info!(
|
||||
"SudoVDA created {}x{}@{} (target_id={}, adapter_luid={:#x})",
|
||||
mode.width,
|
||||
mode.height,
|
||||
mode.refresh_hz,
|
||||
ao.target_id,
|
||||
ao.luid.LowPart
|
||||
);
|
||||
if let Some(luid) = pinned {
|
||||
if ao.luid.LowPart == luid.LowPart && ao.luid.HighPart == luid.HighPart {
|
||||
tracing::info!("SudoVDA ADD render adapter matches the pinned GPU (pin took)");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
add = format!("{:08x}:{:08x}", ao.luid.HighPart, ao.luid.LowPart),
|
||||
pinned = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
|
||||
"SudoVDA ADD render adapter DIFFERS from pinned — driver ignored SET_RENDER_ADAPTER?"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Mandatory keepalive: ping inside the watchdog window or the driver tears all displays down.
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let device_raw = device;
|
||||
let interval = Duration::from_millis(watchdog_s as u64 * 1000 / 3);
|
||||
let stop_t = stop.clone();
|
||||
let pinger = thread::spawn(move || {
|
||||
let h = HANDLE(device_raw as *mut c_void);
|
||||
let mut warned = false;
|
||||
while !stop_t.load(Ordering::Relaxed) {
|
||||
let mut none: [u8; 0] = [];
|
||||
match unsafe { ioctl(h, IOCTL_DRIVER_PING, &[], &mut none) } {
|
||||
Ok(_) => warned = false,
|
||||
// A persistently failing PING means the cached control handle went invalid — the
|
||||
// driver watchdog will then tear the monitor down mid-session. Surface it once
|
||||
// (the old `let _ =` swallowed it, which masked exactly this during the bad-state churn).
|
||||
Err(e) => {
|
||||
if !warned {
|
||||
tracing::warn!(
|
||||
"SudoVDA keepalive PING failed (control handle lost?): {e:#}"
|
||||
);
|
||||
warned = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
thread::sleep(interval);
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve the capture target. May be None on a GPU-less box (target added but not activated
|
||||
// into a WDDM path); the Windows capture backend will re-resolve once a GPU is present.
|
||||
let mut gdi_name = None;
|
||||
for _ in 0..15 {
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
if let Some(n) = unsafe { resolve_gdi_name(ao.target_id) } {
|
||||
gdi_name = Some(n);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut ccd_saved: Option<SavedConfig> = None;
|
||||
match &gdi_name {
|
||||
Some(n) => {
|
||||
tracing::info!("SudoVDA target {} -> {n}", ao.target_id);
|
||||
// ADD only advertises the mode; force it active so DXGI captures the requested size.
|
||||
set_active_mode(n, mode);
|
||||
// Make the SudoVDA the SOLE active display (default). On this box an EXTENDED
|
||||
// (non-primary) IDD is NOT DWM-composited → Desktop Duplication gets a born-lost
|
||||
// ACCESS_LOST (measured live: MODE_CHANGE storm fixed, but the extended IDD then
|
||||
// born-lost). Apollo reaches the same end state ("Virtual Desktop: WxH" — the IDD is the
|
||||
// whole desktop, hence primary + composited) via Windows AUTO-promoting the real WDDM
|
||||
// display over the box's leftover 1024x768 basic display; Windows does NOT auto-promote
|
||||
// for us, so we deactivate the other display(s) explicitly via the clean atomic CCD path.
|
||||
// Deactivating FIRST means set_active_mode's primary-promotion has nothing to contest →
|
||||
// no MODE_CHANGE_IN_PROGRESS storm (that storm came from promoting primary WHILE the
|
||||
// basic display stayed active). Opt out with PUNKTFUNK_NO_ISOLATE=1 (a box with a real
|
||||
// second monitor to keep live). The legacy GDI detach is skipped — it misses
|
||||
// iGPU-attached monitors on a hybrid box and churns per-device; CCD is atomic.
|
||||
if std::env::var("PUNKTFUNK_NO_ISOLATE").is_err() {
|
||||
ccd_saved = unsafe { isolate_displays_ccd(ao.target_id) };
|
||||
} else {
|
||||
tracing::info!(
|
||||
"display isolation skipped (PUNKTFUNK_NO_ISOLATE) — IDD stays extended"
|
||||
);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture opens
|
||||
}
|
||||
None => tracing::warn!(
|
||||
"SudoVDA target {} not yet an active display path (needs a WDDM GPU to activate)",
|
||||
ao.target_id
|
||||
),
|
||||
}
|
||||
|
||||
Ok(Monitor {
|
||||
guid: session_guid,
|
||||
target_id: ao.target_id,
|
||||
luid: ao.luid,
|
||||
gdi_name,
|
||||
mode,
|
||||
stop,
|
||||
pinger: Some(pinger),
|
||||
ccd_saved,
|
||||
gen: MON_GEN.fetch_add(1, Ordering::Relaxed),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Monitor {
|
||||
/// The capture target handed to a session (`None` until the GDI name resolves).
|
||||
fn target(&self) -> Option<crate::capture::dxgi::WinCaptureTarget> {
|
||||
self.gdi_name
|
||||
.clone()
|
||||
.map(|n| crate::capture::dxgi::WinCaptureTarget {
|
||||
adapter_luid: crate::capture::dxgi::pack_luid(self.luid),
|
||||
gdi_name: n,
|
||||
// target_id is stable across secure-desktop topology rebuilds; the GDI name is NOT,
|
||||
// so capture re-resolves the name from this on every recovery.
|
||||
target_id: self.target_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Stop the watchdog ping, re-attach the displays we detached, then REMOVE the monitor (by GUID).
|
||||
/// `device` is the host-level control handle. Consumes the monitor.
|
||||
unsafe fn teardown(mut self, device: isize) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
if let Some(j) = self.pinger.take() {
|
||||
let _ = j.join();
|
||||
}
|
||||
// Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero displays.
|
||||
if let Some(saved) = &self.ccd_saved {
|
||||
restore_displays_ccd(saved);
|
||||
}
|
||||
let rp = RemoveParams { guid: self.guid };
|
||||
let rp_bytes =
|
||||
std::slice::from_raw_parts(&rp as *const _ as *const u8, size_of::<RemoveParams>());
|
||||
let mut none: [u8; 0] = [];
|
||||
let h = HANDLE(device as *mut c_void);
|
||||
if let Err(e) = ioctl(h, IOCTL_REMOVE, rp_bytes, &mut none) {
|
||||
tracing::warn!("SudoVDA REMOVE failed: {e:#}");
|
||||
} else {
|
||||
tracing::info!("SudoVDA monitor removed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the control device once + read version/watchdog; cache the handle (raw isize) in `g`.
|
||||
fn mgr_ensure_device(g: &mut Mgr) -> Result<isize> {
|
||||
if let Some(d) = g.device {
|
||||
return Ok(d);
|
||||
}
|
||||
let device = unsafe { open_device()? };
|
||||
let mut ver = [0u8; 4];
|
||||
if unsafe { ioctl(device, IOCTL_GET_VERSION, &[], &mut ver) }.is_ok() {
|
||||
tracing::info!(
|
||||
"SudoVDA protocol {}.{}.{} (test={})",
|
||||
ver[0],
|
||||
ver[1],
|
||||
ver[2],
|
||||
ver[3]
|
||||
);
|
||||
}
|
||||
let mut wd = [0u8; 8];
|
||||
g.watchdog_s = if unsafe { ioctl(device, IOCTL_GET_WATCHDOG, &[], &mut wd) }.is_ok() {
|
||||
u32::from_le_bytes([wd[0], wd[1], wd[2], wd[3]]).max(1)
|
||||
} else {
|
||||
3
|
||||
};
|
||||
tracing::info!("SudoVDA watchdog timeout {}s", g.watchdog_s);
|
||||
// Reap monitors orphaned by a crashed/killed previous host instance before we create ours.
|
||||
// pf-vdisplay honors IOCTL_CLEAR_ALL; SudoVDA returns invalid (ignored). Without it an orphan
|
||||
// lingers until the driver watchdog fires — but a still-pinging new session keeps resetting that
|
||||
// watchdog, so orphans could accumulate (the "5-6 stale monitors that never tear down" failure).
|
||||
{
|
||||
let mut none: [u8; 0] = [];
|
||||
if unsafe { ioctl(device, IOCTL_CLEAR_ALL, &[], &mut none) }.is_ok() {
|
||||
tracing::info!("cleared orphaned virtual monitors on host startup");
|
||||
}
|
||||
}
|
||||
let raw = device.0 as isize;
|
||||
g.device = Some(raw);
|
||||
Ok(raw)
|
||||
}
|
||||
|
||||
/// Linger window before a session-less monitor is torn down. A reconnect within it reuses the
|
||||
/// monitor (no new screen / PnP chime); after it the monitor is REMOVEd so a physical screen returns.
|
||||
fn linger_ms() -> u64 {
|
||||
std::env::var("PUNKTFUNK_MONITOR_LINGER_MS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(10_000)
|
||||
}
|
||||
|
||||
/// Acquire the shared monitor for a new session: join the live one (refcount++), reuse a lingering
|
||||
/// one (reconfiguring if the client mode changed), or create one. The returned [`MonitorLease`]
|
||||
/// releases the refcount on drop.
|
||||
fn mgr_acquire(mode: Mode) -> Result<VirtualOutput> {
|
||||
ensure_linger_timer();
|
||||
let mut g = MGR.lock().unwrap();
|
||||
let device = mgr_ensure_device(&mut g)?;
|
||||
let watchdog_s = g.watchdog_s;
|
||||
|
||||
// IDD-push: a new connection while a monitor is live = a single-client RECONNECT (the prior client
|
||||
// is gone — IDD-push is one display, no concurrency). A REUSED IddCx monitor's swap-chain is DEAD,
|
||||
// so joining it would hand the new client a black screen until the old session times out. PREEMPT:
|
||||
// tear the old monitor down (its Drop restores topology + IOCTL_REMOVEs) and fall through to create
|
||||
// a FRESH one. The old session's lease is gen-stamped, so its later drop is ignored (mgr_release
|
||||
// no-op) and can't tear down the new monitor.
|
||||
if idd_push_mode()
|
||||
&& matches!(
|
||||
g.state,
|
||||
MgrState::Active { .. } | MgrState::Lingering { .. }
|
||||
)
|
||||
{
|
||||
if let MgrState::Active { mon, .. } | MgrState::Lingering { mon, .. } =
|
||||
std::mem::replace(&mut g.state, MgrState::Idle)
|
||||
{
|
||||
tracing::info!(
|
||||
old_target = mon.target_id,
|
||||
"IDD-push reconnect — preempting the prior session, recreating a fresh monitor"
|
||||
);
|
||||
// teardown() — NOT drop() — sends IOCTL_REMOVE (and restores topology). `Monitor` has NO
|
||||
// `Drop` impl, so a bare `drop(mon)` orphaned the IddCx monitor in the driver: it was never
|
||||
// departed, so it kept a live D3D device + a stuck swap-chain processor thread, and these
|
||||
// accumulated every reconnect (the driver-side churn leak: +1 device, ~36 nvwgf2umx threads,
|
||||
// ~50 MB VRAM per session, until it choked). teardown frees it via the driver's do_remove.
|
||||
unsafe { mon.teardown(device) };
|
||||
// Let the OS finish the ASYNC IddCx monitor departure before the next ADD. A back-to-back
|
||||
// REMOVE→ADD races the teardown and the ADD IOCTL is rejected (`DeviceIoControl failed`)
|
||||
// under reconnect churn. Held under the MGR lock, but IDD-push setup is already serialized
|
||||
// (IDD_SETUP_LOCK), so this only paces the recreate — exactly what a reconnect flood needs.
|
||||
thread::sleep(Duration::from_millis(400));
|
||||
}
|
||||
}
|
||||
|
||||
// A live monitor already exists — join it (refcount++). This covers a concurrent session AND the
|
||||
// build-then-drop overlap of a mid-stream Reconfigure / secure-return (the new lease is taken while
|
||||
// the old is still held). If the requested mode differs, reconfigure the shared monitor to it so a
|
||||
// Reconfigure actually applies (one shared monitor → sessions necessarily share a mode).
|
||||
if let MgrState::Active { mon, refs } = &mut g.state {
|
||||
*refs += 1;
|
||||
let changed = mon.mode.width != mode.width
|
||||
|| mon.mode.height != mode.height
|
||||
|| mon.mode.refresh_hz != mode.refresh_hz;
|
||||
if changed {
|
||||
unsafe { mgr_reconfigure(mon, mode) };
|
||||
}
|
||||
tracing::info!(
|
||||
refs = *refs,
|
||||
"SudoVDA monitor reused (concurrent / reconfigure session)"
|
||||
);
|
||||
let pm = Some((mon.mode.width, mon.mode.height, mon.mode.refresh_hz));
|
||||
let target = mon.target();
|
||||
let gen = mon.gen;
|
||||
CURRENT_MON_GEN.store(gen, Ordering::Relaxed);
|
||||
return Ok(VirtualOutput {
|
||||
node_id: 0,
|
||||
preferred_mode: pm,
|
||||
win_capture: target,
|
||||
keepalive: Box::new(MonitorLease { gen }),
|
||||
});
|
||||
}
|
||||
|
||||
// Idle or Lingering: repurpose/create a monitor → Active{refs:1}.
|
||||
let mon = match std::mem::replace(&mut g.state, MgrState::Idle) {
|
||||
MgrState::Lingering { mut mon, .. } => {
|
||||
tracing::info!("SudoVDA monitor reused (reconnect within the linger window)");
|
||||
let changed = mon.mode.width != mode.width
|
||||
|| mon.mode.height != mode.height
|
||||
|| mon.mode.refresh_hz != mode.refresh_hz;
|
||||
if changed {
|
||||
unsafe { mgr_reconfigure(&mut mon, mode) };
|
||||
}
|
||||
mon
|
||||
}
|
||||
MgrState::Idle => unsafe { create_monitor(device, mode, watchdog_s)? },
|
||||
MgrState::Active { .. } => unreachable!("handled above"),
|
||||
};
|
||||
let pm = Some((mon.mode.width, mon.mode.height, mon.mode.refresh_hz));
|
||||
let target = mon.target();
|
||||
let gen = mon.gen;
|
||||
CURRENT_MON_GEN.store(gen, Ordering::Relaxed);
|
||||
g.state = MgrState::Active { mon, refs: 1 };
|
||||
Ok(VirtualOutput {
|
||||
node_id: 0,
|
||||
preferred_mode: pm,
|
||||
win_capture: target,
|
||||
keepalive: Box::new(MonitorLease { gen }),
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-apply a (possibly new) mode to a reused monitor on reconnect, re-resolving its GDI name.
|
||||
unsafe fn mgr_reconfigure(mon: &mut Monitor, mode: Mode) {
|
||||
tracing::info!(
|
||||
old = format!(
|
||||
"{}x{}@{}",
|
||||
mon.mode.width, mon.mode.height, mon.mode.refresh_hz
|
||||
),
|
||||
new = format!("{}x{}@{}", mode.width, mode.height, mode.refresh_hz),
|
||||
"SudoVDA: reconfiguring reused monitor to the new client mode"
|
||||
);
|
||||
if let Some(n) = resolve_gdi_name(mon.target_id) {
|
||||
mon.gdi_name = Some(n);
|
||||
}
|
||||
if let Some(n) = &mon.gdi_name {
|
||||
set_active_mode(n, mode);
|
||||
}
|
||||
mon.mode = mode;
|
||||
}
|
||||
|
||||
/// Release a session's hold: refcount-- ; when the last session leaves, LINGER before teardown.
|
||||
/// `gen` is the lease's monitor generation: a STALE lease (its monitor was already torn down +
|
||||
/// recreated under it — the IDD-push reconnect-preempt path) does nothing, so it can't decrement the
|
||||
/// CURRENT (fresh) monitor's refcount and tear it down.
|
||||
fn mgr_release(gen: u64) {
|
||||
let mut g = MGR.lock().unwrap();
|
||||
let stale = match &g.state {
|
||||
MgrState::Active { mon, .. } | MgrState::Lingering { mon, .. } => mon.gen != gen,
|
||||
MgrState::Idle => true,
|
||||
};
|
||||
if stale {
|
||||
return;
|
||||
}
|
||||
g.state = match std::mem::replace(&mut g.state, MgrState::Idle) {
|
||||
MgrState::Active { mon, refs } if refs > 1 => MgrState::Active {
|
||||
mon,
|
||||
refs: refs - 1,
|
||||
},
|
||||
MgrState::Active { mon, .. } => {
|
||||
let ms = linger_ms();
|
||||
tracing::info!(
|
||||
linger_ms = ms,
|
||||
"SudoVDA: last session left — lingering before teardown"
|
||||
);
|
||||
MgrState::Lingering {
|
||||
mon,
|
||||
until: Instant::now() + Duration::from_millis(ms),
|
||||
}
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
}
|
||||
|
||||
/// Wait (up to `timeout`) for the active monitor to be RELEASED — i.e. the MGR is no longer `Active`
|
||||
/// (the prior session dropped its lease → `Lingering`/`Idle`). Used by the IDD-push reconnect preempt:
|
||||
/// after signalling the old session to stop, we wait here so it tears its monitor down CLEANLY (while
|
||||
/// frames still flow) before we acquire a fresh one — instead of dropping the monitor out from under a
|
||||
/// still-live session, which churns the driver's ADD/REMOVE path and wedges it under rapid reconnects.
|
||||
pub(crate) fn wait_for_monitor_released(timeout: Duration) {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if !matches!(MGR.lock().unwrap().state, MgrState::Active { .. }) {
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
tracing::warn!(
|
||||
"IDD-push preempt: prior session didn't release the monitor within {timeout:?} — \
|
||||
proceeding (mgr_acquire will preempt it)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
}
|
||||
|
||||
/// Background timer (started once): tear down a monitor that has lingered past its deadline (→ Idle),
|
||||
/// so a physical-screen user gets their screen back after they stop streaming.
|
||||
fn ensure_linger_timer() {
|
||||
static TIMER: Once = Once::new();
|
||||
TIMER.call_once(|| {
|
||||
let _ = thread::Builder::new()
|
||||
.name("sudovda-linger".into())
|
||||
.spawn(|| loop {
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
let mut g = MGR.lock().unwrap();
|
||||
let due = matches!(&g.state, MgrState::Lingering { until, .. } if Instant::now() >= *until);
|
||||
if due {
|
||||
let device = g.device.unwrap_or(0);
|
||||
if let MgrState::Lingering { mon, .. } =
|
||||
std::mem::replace(&mut g.state, MgrState::Idle)
|
||||
{
|
||||
drop(g); // release the lock before the REMOVE IOCTL + display restore
|
||||
unsafe { mon.teardown(device) };
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A session's lease on the shared monitor. Drop releases the refcount (→ linger when it hits 0),
|
||||
/// UNLESS the monitor was already torn down + recreated under it (gen mismatch — the IDD-push
|
||||
/// reconnect-preempt path), in which case the drop is a no-op so it can't tear down the new monitor.
|
||||
struct MonitorLease {
|
||||
gen: u64,
|
||||
}
|
||||
impl Drop for MonitorLease {
|
||||
fn drop(&mut self) {
|
||||
mgr_release(self.gen);
|
||||
}
|
||||
}
|
||||
|
||||
/// Readiness probe: can we open the SudoVDA control device?
|
||||
pub fn probe() -> Result<()> {
|
||||
let h = unsafe { open_device()? };
|
||||
unsafe {
|
||||
let _ = CloseHandle(h);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Is the SudoVDA driver present (device interface enumerable)?
|
||||
pub fn is_available() -> bool {
|
||||
unsafe { open_device().map(|h| CloseHandle(h)).is_ok() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Live hardware round trip — skipped unless `PUNKTFUNK_SUDOVDA_LIVE=1` (needs the SudoVDA
|
||||
/// driver installed). Exercises the real trait path: open -> create -> hold -> drop (REMOVE).
|
||||
#[test]
|
||||
fn live_create_drop() {
|
||||
if std::env::var("PUNKTFUNK_SUDOVDA_LIVE").is_err() {
|
||||
return;
|
||||
}
|
||||
let mut vd = SudoVdaDisplay::new().expect("open SudoVDA");
|
||||
let vout = vd
|
||||
.create(Mode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
refresh_hz: 60,
|
||||
})
|
||||
.expect("create virtual display");
|
||||
assert_eq!(vout.preferred_mode, Some((1920, 1080, 60)));
|
||||
thread::sleep(Duration::from_secs(3));
|
||||
drop(vout); // triggers REMOVE + stops the pinger
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
//! Host-lifetime virtual-display **ownership model** (Goal-1 §2.5). One reference-counted monitor
|
||||
//! lifecycle, shared by both Windows backends (SudoVDA + pf-vdisplay) instead of the two verbatim-
|
||||
//! duplicated `MGR: Mutex<Mgr>` globals each backend used to carry.
|
||||
//!
|
||||
//! [`VirtualDisplayManager`] owns the earned Idle/Active/Lingering refcount machine + the linger timer +
|
||||
//! a **typed** [`OwnedHandle`] control device (no more raw `isize` smuggled across the pinger/linger
|
||||
//! threads). The backend differences — the IOCTL protocol and the per-monitor REMOVE key — are the only
|
||||
//! thing behind the [`VdisplayDriver`] seam; the state machine, the render-adapter pin decision, the
|
||||
//! GDI/CCD glue (`crate::win_display`), and the generation-stamped [`MonitorLease`] are backend-neutral.
|
||||
//!
|
||||
//! It's a process-wide singleton ([`vdm`]) initialised once with the chosen backend's driver — the
|
||||
//! host runs exactly one virtual-display backend per process. The session holds a [`MonitorLease`];
|
||||
//! its `Drop` releases the refcount (a *stale* lease — its monitor was preempted + recreated under it —
|
||||
//! is a no-op, so it can never tear down the live monitor).
|
||||
|
||||
use std::os::windows::io::{AsRawHandle, OwnedHandle};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, Once, OnceLock};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
use windows::Win32::Foundation::{HANDLE, LUID};
|
||||
|
||||
use super::{Mode, VirtualOutput};
|
||||
use crate::win_display::{
|
||||
isolate_displays_ccd, resolve_gdi_name, restore_displays_ccd, set_active_mode, SavedConfig,
|
||||
};
|
||||
|
||||
/// The per-backend REMOVE key the driver stamps on ADD and consumes on REMOVE. SudoVDA keys monitors by
|
||||
/// a fresh `GUID`; pf-vdisplay keys them by a monotonic `u64` session id.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum MonitorKey {
|
||||
Guid(windows::core::GUID),
|
||||
Session(u64),
|
||||
}
|
||||
|
||||
/// What a backend's `add_monitor` returns: the REMOVE key + the OS target id + the render LUID.
|
||||
pub(crate) struct AddedMonitor {
|
||||
pub key: MonitorKey,
|
||||
pub target_id: u32,
|
||||
pub luid: LUID,
|
||||
}
|
||||
|
||||
/// The backend-specific IOCTL surface — the *only* thing that differs between SudoVDA and pf-vdisplay.
|
||||
/// Everything else (the refcount machine, the linger, the pinger, the CCD/GDI glue) is shared in
|
||||
/// [`VirtualDisplayManager`]. `Send + Sync` because the manager (and so the boxed driver) is a
|
||||
/// `&'static` singleton reached from the pinger + linger threads.
|
||||
pub(crate) trait VdisplayDriver: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
/// Find + open the control device, validate it (version handshake), read the watchdog timeout, and
|
||||
/// reap monitors orphaned by a crashed previous host (`CLEAR_ALL`). Returns the owned handle +
|
||||
/// watchdog seconds.
|
||||
///
|
||||
/// # Safety
|
||||
/// Issues setup-API + `DeviceIoControl` calls; runs in the caller's apartment.
|
||||
unsafe fn open(&self) -> Result<(OwnedHandle, u32)>;
|
||||
/// ADD a virtual monitor at `mode`, pinning the IDD render GPU to `render_luid` first if `Some`.
|
||||
/// Returns the REMOVE key + target id + the adapter LUID the driver actually used.
|
||||
///
|
||||
/// # Safety
|
||||
/// `dev` must be the live control handle from [`open`](Self::open).
|
||||
unsafe fn add_monitor(&self, dev: HANDLE, mode: Mode, render_luid: Option<LUID>)
|
||||
-> Result<AddedMonitor>;
|
||||
/// REMOVE the monitor identified by `key`.
|
||||
///
|
||||
/// # Safety
|
||||
/// `dev` must be the live control handle.
|
||||
unsafe fn remove_monitor(&self, dev: HANDLE, key: &MonitorKey) -> Result<()>;
|
||||
/// Watchdog keepalive PING (issued every `watchdog/3` from the pinger thread).
|
||||
///
|
||||
/// # Safety
|
||||
/// `dev` must be the live control handle.
|
||||
unsafe fn ping(&self, dev: HANDLE) -> Result<()>;
|
||||
}
|
||||
|
||||
/// The resources backing one live virtual monitor (owned by the [`VirtualDisplayManager`] state, not by
|
||||
/// any session). No `Drop` impl — [`teardown`](VirtualDisplayManager::teardown) must be called so the
|
||||
/// REMOVE IOCTL fires (a bare drop would orphan the driver-side monitor).
|
||||
struct Monitor {
|
||||
key: MonitorKey,
|
||||
target_id: u32,
|
||||
luid: LUID,
|
||||
gdi_name: Option<String>,
|
||||
mode: Mode,
|
||||
stop: Arc<AtomicBool>,
|
||||
pinger: Option<JoinHandle<()>>,
|
||||
ccd_saved: Option<SavedConfig>,
|
||||
/// Generation stamp; a [`MonitorLease`] only releases if its gen still matches (stale-lease no-op).
|
||||
gen: u64,
|
||||
}
|
||||
|
||||
impl Monitor {
|
||||
/// The capture target handed to a session (`None` until the GDI name resolves on a WDDM GPU).
|
||||
fn target(&self) -> Option<crate::capture::dxgi::WinCaptureTarget> {
|
||||
self.gdi_name
|
||||
.clone()
|
||||
.map(|n| crate::capture::dxgi::WinCaptureTarget {
|
||||
adapter_luid: crate::capture::dxgi::pack_luid(self.luid),
|
||||
gdi_name: n,
|
||||
target_id: self.target_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
enum MgrState {
|
||||
Idle,
|
||||
Active { mon: Monitor, refs: u32 },
|
||||
Lingering { mon: Monitor, until: Instant },
|
||||
}
|
||||
|
||||
/// The host-lifetime virtual-display manager: the single owner of the monitor lifecycle.
|
||||
pub(crate) struct VirtualDisplayManager {
|
||||
driver: Box<dyn VdisplayDriver>,
|
||||
/// Control device, opened once on first acquire. Typed + `Send+Sync`, so the pinger/linger threads
|
||||
/// share it via the `&'static` singleton with no raw-handle smuggling.
|
||||
device: OnceLock<Arc<OwnedHandle>>,
|
||||
watchdog_s: AtomicU32,
|
||||
/// Monotonic lease-generation counter (was the `MON_GEN` global).
|
||||
gen: AtomicU64,
|
||||
state: Mutex<MgrState>,
|
||||
/// Serializes IDD-push session SETUP (preempt + monitor create) so a reconnect flood can't run
|
||||
/// concurrent monitor create/teardown — held by the session across the pipeline build (was the
|
||||
/// `IDD_SETUP_LOCK` global in `punktfunk1`).
|
||||
setup_lock: Mutex<()>,
|
||||
/// The current IDD-push session's stop flag; a new connection signals the prior one to release its
|
||||
/// monitor before the fresh one is created (was the `IDD_SESSION_STOP` global in `punktfunk1`).
|
||||
idd_session_stop: Mutex<Option<Arc<AtomicBool>>>,
|
||||
}
|
||||
|
||||
static VDM: OnceLock<VirtualDisplayManager> = OnceLock::new();
|
||||
|
||||
/// Initialise the process-wide manager with `driver` (the chosen backend) and return it. Idempotent: the
|
||||
/// first backend to call wins (the host runs one backend per process), so a later call ignores its driver.
|
||||
pub(crate) fn init(driver: Box<dyn VdisplayDriver>) -> &'static VirtualDisplayManager {
|
||||
VDM.get_or_init(|| VirtualDisplayManager {
|
||||
driver,
|
||||
device: OnceLock::new(),
|
||||
watchdog_s: AtomicU32::new(3),
|
||||
gen: AtomicU64::new(1),
|
||||
state: Mutex::new(MgrState::Idle),
|
||||
setup_lock: Mutex::new(()),
|
||||
idd_session_stop: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
/// The process-wide manager. Panics if reached before a backend called [`init`] — by construction a
|
||||
/// session is only ever created after `vdisplay::open` constructed the backend (which calls `init`).
|
||||
pub(crate) fn vdm() -> &'static VirtualDisplayManager {
|
||||
VDM.get().expect("VirtualDisplayManager used before a backend initialised it")
|
||||
}
|
||||
|
||||
impl VirtualDisplayManager {
|
||||
pub(crate) fn backend_name(&self) -> &'static str {
|
||||
self.driver.name()
|
||||
}
|
||||
|
||||
/// Open + cache the control device (once). Called under the `state` lock so two racing acquires can't
|
||||
/// double-open.
|
||||
fn ensure_device(&self) -> Result<HANDLE> {
|
||||
if let Some(d) = self.device.get() {
|
||||
return Ok(HANDLE(d.as_raw_handle()));
|
||||
}
|
||||
let (handle, watchdog_s) = unsafe { self.driver.open()? };
|
||||
self.watchdog_s.store(watchdog_s, Ordering::Relaxed);
|
||||
let raw = HANDLE(handle.as_raw_handle());
|
||||
let _ = self.device.set(Arc::new(handle));
|
||||
Ok(raw)
|
||||
}
|
||||
|
||||
/// The live control handle for the pinger/linger threads (lock-free: the device never changes once
|
||||
/// opened). `None` only before the first acquire opened it.
|
||||
fn device_handle(&self) -> Option<HANDLE> {
|
||||
self.device
|
||||
.get()
|
||||
.map(|d| HANDLE(d.as_raw_handle()))
|
||||
}
|
||||
|
||||
/// Open + initialise the backend (validates the driver is present). Mirrors the old
|
||||
/// `PfVdisplayDisplay::new`.
|
||||
pub(crate) fn open_backend(&self) -> Result<()> {
|
||||
// Hold the state lock across the open so two racing backends can't double-open the device.
|
||||
let _guard = self.state.lock().unwrap();
|
||||
self.ensure_device().map(|_| ())
|
||||
}
|
||||
|
||||
/// Acquire the shared monitor for a new session: preempt-recreate under IDD-push, join a live one
|
||||
/// (refcount++), reuse a lingering one, or create one. The returned [`MonitorLease`] releases the
|
||||
/// refcount on drop.
|
||||
pub(crate) fn acquire(&'static self, mode: Mode) -> Result<VirtualOutput> {
|
||||
self.ensure_linger_timer();
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let dev = self.ensure_device()?;
|
||||
|
||||
// IDD-push: a new connection while a monitor is live is a single-client RECONNECT (the prior
|
||||
// client is gone). A REUSED IddCx swap-chain is DEAD, so joining it hands a black screen —
|
||||
// PREEMPT: tear the old monitor down (its key/topology are restored) and create a fresh one. The
|
||||
// old session's lease is gen-stamped, so its later drop is a no-op and can't tear down the new one.
|
||||
if idd_push_mode()
|
||||
&& matches!(*state, MgrState::Active { .. } | MgrState::Lingering { .. })
|
||||
{
|
||||
if let MgrState::Active { mon, .. } | MgrState::Lingering { mon, .. } =
|
||||
std::mem::replace(&mut *state, MgrState::Idle)
|
||||
{
|
||||
tracing::info!(
|
||||
old_target = mon.target_id,
|
||||
"IDD-push reconnect — preempting the prior session, recreating a fresh monitor"
|
||||
);
|
||||
unsafe { self.teardown(dev, mon) };
|
||||
// Let the OS finish the ASYNC monitor departure before the next ADD; a back-to-back
|
||||
// REMOVE→ADD races the teardown and the ADD IOCTL is rejected under reconnect churn.
|
||||
thread::sleep(Duration::from_millis(400));
|
||||
}
|
||||
}
|
||||
|
||||
// A live monitor already exists — join it (refcount++). Covers concurrent sessions AND the
|
||||
// build-then-drop overlap of a mid-stream Reconfigure (the new lease is taken while the old is
|
||||
// still held). Reconfigure the shared monitor if the requested mode differs.
|
||||
if let MgrState::Active { mon, refs } = &mut *state {
|
||||
*refs += 1;
|
||||
if mon.mode != mode {
|
||||
unsafe { self.reconfigure(mon, mode) };
|
||||
}
|
||||
tracing::info!(refs = *refs, backend = self.driver.name(), "virtual monitor reused (concurrent / reconfigure session)");
|
||||
return Ok(self.output_for(mon));
|
||||
}
|
||||
|
||||
// Idle or Lingering: repurpose a lingering monitor / create a fresh one → Active{refs:1}.
|
||||
let mon = match std::mem::replace(&mut *state, MgrState::Idle) {
|
||||
MgrState::Lingering { mut mon, .. } => {
|
||||
tracing::info!(backend = self.driver.name(), "virtual monitor reused (reconnect within the linger window)");
|
||||
if mon.mode != mode {
|
||||
unsafe { self.reconfigure(&mut mon, mode) };
|
||||
}
|
||||
mon
|
||||
}
|
||||
MgrState::Idle => unsafe { self.create_monitor(dev, mode)? },
|
||||
MgrState::Active { .. } => unreachable!("handled above"),
|
||||
};
|
||||
let out = self.output_for(&mon);
|
||||
*state = MgrState::Active { mon, refs: 1 };
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Build the [`VirtualOutput`] (preferred mode + capture target + a fresh gen-stamped lease) for `mon`.
|
||||
fn output_for(&'static self, mon: &Monitor) -> VirtualOutput {
|
||||
VirtualOutput {
|
||||
node_id: 0,
|
||||
preferred_mode: Some((mon.mode.width, mon.mode.height, mon.mode.refresh_hz)),
|
||||
win_capture: mon.target(),
|
||||
keepalive: Box::new(MonitorLease {
|
||||
mgr: self,
|
||||
gen: mon.gen,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a fresh monitor at `mode`: ADD via the driver (pinning the discrete render GPU under the
|
||||
/// usual conditions), start the watchdog pinger, resolve the GDI name, force the mode + isolate to a
|
||||
/// sole composited display.
|
||||
///
|
||||
/// # Safety
|
||||
/// `dev` must be the live control handle.
|
||||
unsafe fn create_monitor(&'static self, dev: HANDLE, mode: Mode) -> Result<Monitor> {
|
||||
let added = unsafe { self.driver.add_monitor(dev, mode, resolve_render_pin())? };
|
||||
|
||||
// Mandatory keepalive: ping inside the watchdog window or the driver tears all displays down.
|
||||
// The pinger reaches the singleton for both the device + the driver — no raw-handle smuggle.
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let interval = Duration::from_millis(self.watchdog_s.load(Ordering::Relaxed) as u64 * 1000 / 3);
|
||||
let stop_t = stop.clone();
|
||||
let pinger = thread::spawn(move || {
|
||||
let mut warned = false;
|
||||
while !stop_t.load(Ordering::Relaxed) {
|
||||
if let Some(h) = vdm().device_handle() {
|
||||
match unsafe { vdm().driver.ping(h) } {
|
||||
Ok(()) => warned = false,
|
||||
Err(e) => {
|
||||
if !warned {
|
||||
tracing::warn!("virtual-display keepalive PING failed (control handle lost?): {e:#}");
|
||||
warned = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
thread::sleep(interval);
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve the capture target. May be None on a GPU-less box (target added but not WDDM-activated);
|
||||
// the capture backend re-resolves once a GPU is present.
|
||||
let mut gdi_name = None;
|
||||
for _ in 0..15 {
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
if let Some(n) = unsafe { resolve_gdi_name(added.target_id) } {
|
||||
gdi_name = Some(n);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut ccd_saved: Option<SavedConfig> = None;
|
||||
match &gdi_name {
|
||||
Some(n) => {
|
||||
tracing::info!(backend = self.driver.name(), "target {} -> {n}", added.target_id);
|
||||
// ADD only advertises the mode; force it active so DXGI captures the requested size.
|
||||
set_active_mode(n, mode);
|
||||
// Make the virtual display the SOLE active output (default): an EXTENDED (non-primary) IDD
|
||||
// isn't DWM-composited on this box → Desktop Duplication born-losts. Deactivating the other
|
||||
// display(s) first via the atomic CCD path promotes the IDD to a composited primary with no
|
||||
// MODE_CHANGE storm. Opt out with PUNKTFUNK_NO_ISOLATE=1.
|
||||
if std::env::var("PUNKTFUNK_NO_ISOLATE").is_err() {
|
||||
ccd_saved = unsafe { isolate_displays_ccd(added.target_id) };
|
||||
} else {
|
||||
tracing::info!("display isolation skipped (PUNKTFUNK_NO_ISOLATE) — IDD stays extended");
|
||||
}
|
||||
thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture opens
|
||||
}
|
||||
None => tracing::warn!(
|
||||
"virtual-display target {} not yet an active display path (needs a WDDM GPU to activate)",
|
||||
added.target_id
|
||||
),
|
||||
}
|
||||
|
||||
Ok(Monitor {
|
||||
key: added.key,
|
||||
target_id: added.target_id,
|
||||
luid: added.luid,
|
||||
gdi_name,
|
||||
mode,
|
||||
stop,
|
||||
pinger: Some(pinger),
|
||||
ccd_saved,
|
||||
gen: self.gen.fetch_add(1, Ordering::Relaxed),
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-apply a (possibly new) mode to a reused monitor on reconnect, re-resolving its GDI name.
|
||||
///
|
||||
/// # Safety
|
||||
/// Touches the live display topology via the CCD/GDI helpers.
|
||||
unsafe fn reconfigure(&self, mon: &mut Monitor, mode: Mode) {
|
||||
tracing::info!(
|
||||
old = format!("{}x{}@{}", mon.mode.width, mon.mode.height, mon.mode.refresh_hz),
|
||||
new = format!("{}x{}@{}", mode.width, mode.height, mode.refresh_hz),
|
||||
"virtual-display: reconfiguring reused monitor to the new client mode"
|
||||
);
|
||||
if let Some(n) = unsafe { resolve_gdi_name(mon.target_id) } {
|
||||
mon.gdi_name = Some(n);
|
||||
}
|
||||
if let Some(n) = &mon.gdi_name {
|
||||
set_active_mode(n, mode);
|
||||
}
|
||||
mon.mode = mode;
|
||||
}
|
||||
|
||||
/// Stop the watchdog ping, re-attach the displays we detached, then REMOVE the monitor. Consumes it.
|
||||
///
|
||||
/// # Safety
|
||||
/// `dev` must be the live control handle.
|
||||
unsafe fn teardown(&self, dev: HANDLE, mut mon: Monitor) {
|
||||
mon.stop.store(true, Ordering::Relaxed);
|
||||
if let Some(j) = mon.pinger.take() {
|
||||
let _ = j.join();
|
||||
}
|
||||
// Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero displays.
|
||||
if let Some(saved) = &mon.ccd_saved {
|
||||
restore_displays_ccd(saved);
|
||||
}
|
||||
if let Err(e) = unsafe { self.driver.remove_monitor(dev, &mon.key) } {
|
||||
tracing::warn!("virtual-display REMOVE failed: {e:#}");
|
||||
} else {
|
||||
tracing::info!(backend = self.driver.name(), "virtual-display monitor removed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Release a session's hold (the [`MonitorLease`] `Drop`): refcount-- ; the last session leaving
|
||||
/// LINGERs before teardown. A STALE lease (its monitor was preempted + recreated under it) is a
|
||||
/// no-op, so it can't tear down the CURRENT monitor.
|
||||
fn release(&self, gen: u64) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let stale = match &*state {
|
||||
MgrState::Active { mon, .. } | MgrState::Lingering { mon, .. } => mon.gen != gen,
|
||||
MgrState::Idle => true,
|
||||
};
|
||||
if stale {
|
||||
return;
|
||||
}
|
||||
*state = match std::mem::replace(&mut *state, MgrState::Idle) {
|
||||
MgrState::Active { mon, refs } if refs > 1 => MgrState::Active { mon, refs: refs - 1 },
|
||||
MgrState::Active { mon, .. } => {
|
||||
let ms = linger_ms();
|
||||
tracing::info!(linger_ms = ms, "virtual-display: last session left — lingering before teardown");
|
||||
MgrState::Lingering {
|
||||
mon,
|
||||
until: Instant::now() + Duration::from_millis(ms),
|
||||
}
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
}
|
||||
|
||||
/// Begin an IDD-push session setup (Goal-1 §2.5 — was the `IDD_SETUP_LOCK` / `IDD_SESSION_STOP` /
|
||||
/// `wait_for_monitor_released` dance smeared across `punktfunk1`). Serializes via the setup lock,
|
||||
/// registers THIS session's stop flag while signalling the PRIOR IDD-push session to stop, and waits
|
||||
/// for it to release its monitor — so a reconnect (whose reused IddCx swap-chain is dead) preempts the
|
||||
/// stale session cleanly before a fresh monitor is created. Returns the setup guard; the caller holds
|
||||
/// it across the pipeline build, then drops it so the next reconnect can begin (and preempt this one).
|
||||
pub(crate) fn begin_idd_setup(
|
||||
&'static self,
|
||||
stop: Arc<AtomicBool>,
|
||||
) -> std::sync::MutexGuard<'static, ()> {
|
||||
let guard = self.setup_lock.lock().unwrap();
|
||||
let prev = self.idd_session_stop.lock().unwrap().replace(stop);
|
||||
if let Some(prev_stop) = prev {
|
||||
prev_stop.store(true, Ordering::SeqCst);
|
||||
self.wait_for_monitor_released(Duration::from_secs(3));
|
||||
}
|
||||
guard
|
||||
}
|
||||
|
||||
/// Wait (up to `timeout`) for the active monitor to be RELEASED (the MGR is no longer `Active`).
|
||||
/// Used by the IDD-push reconnect preempt: after signalling the old session to stop, wait here so it
|
||||
/// tears its monitor down cleanly before we acquire a fresh one.
|
||||
pub(crate) fn wait_for_monitor_released(&self, timeout: Duration) {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if !matches!(*self.state.lock().unwrap(), MgrState::Active { .. }) {
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
tracing::warn!(
|
||||
"IDD-push preempt: prior session didn't release the monitor within {timeout:?} — proceeding"
|
||||
);
|
||||
return;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
}
|
||||
|
||||
/// Background timer (started once): tear down a monitor that has lingered past its deadline (→ Idle),
|
||||
/// so a physical-screen user gets their screen back after they stop streaming.
|
||||
fn ensure_linger_timer(&'static self) {
|
||||
static TIMER: Once = Once::new();
|
||||
TIMER.call_once(|| {
|
||||
thread::Builder::new()
|
||||
.name("vdisplay-linger".into())
|
||||
.spawn(move || loop {
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
let due = {
|
||||
let g = self.state.lock().unwrap();
|
||||
matches!(&*g, MgrState::Lingering { until, .. } if Instant::now() >= *until)
|
||||
};
|
||||
if !due {
|
||||
continue;
|
||||
}
|
||||
let Some(dev) = self.device_handle() else {
|
||||
continue;
|
||||
};
|
||||
let taken = {
|
||||
let mut g = self.state.lock().unwrap();
|
||||
if matches!(&*g, MgrState::Lingering { until, .. } if Instant::now() >= *until) {
|
||||
if let MgrState::Lingering { mon, .. } =
|
||||
std::mem::replace(&mut *g, MgrState::Idle)
|
||||
{
|
||||
Some(mon)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(mon) = taken {
|
||||
unsafe { self.teardown(dev, mon) };
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The session's refcount handle. `Drop` releases the manager's refcount; a stale lease (its monitor was
|
||||
/// preempted + recreated under it) is a no-op.
|
||||
struct MonitorLease {
|
||||
mgr: &'static VirtualDisplayManager,
|
||||
gen: u64,
|
||||
}
|
||||
|
||||
impl Drop for MonitorLease {
|
||||
fn drop(&mut self) {
|
||||
self.mgr.release(self.gen);
|
||||
}
|
||||
}
|
||||
|
||||
/// IDD-push mode: a new client connection preempts + recreates the monitor (single-client reconnect),
|
||||
/// because a REUSED IddCx monitor's swap-chain is dead. Off → monitors are shared across sessions.
|
||||
fn idd_push_mode() -> bool {
|
||||
crate::config::config().idd_push
|
||||
}
|
||||
|
||||
/// The render-GPU pin decision (backend-neutral): pin the discrete render GPU when explicitly requested,
|
||||
/// or under IDD-push (the host runs NVENC on the render adapter, so it MUST be the discrete encoder GPU
|
||||
/// on a hybrid box). `None` = let the IDD use its natural adapter (Apollo parity — avoids the cross-GPU
|
||||
/// ACCESS_LOST storm SudoVDA hit when pinned).
|
||||
fn resolve_render_pin() -> Option<LUID> {
|
||||
if crate::config::config().render_adapter.is_some() {
|
||||
unsafe { crate::win_adapter::resolve_render_adapter_luid() }
|
||||
} else if crate::config::config().idd_push {
|
||||
tracing::info!("IDD push: pinning the discrete render GPU (SET_RENDER_ADAPTER)");
|
||||
unsafe { crate::win_adapter::resolve_render_adapter_luid() }
|
||||
} else {
|
||||
tracing::info!(
|
||||
"SET_RENDER_ADAPTER skipped (Apollo-parity: no render pin; set PUNKTFUNK_RENDER_ADAPTER=<name> to force one)"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Linger window before a session-less monitor is torn down (default 10 s; `PUNKTFUNK_MONITOR_LINGER_MS`).
|
||||
fn linger_ms() -> u64 {
|
||||
std::env::var("PUNKTFUNK_MONITOR_LINGER_MS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(10_000)
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
//! Windows virtual-display backend driving **pf-vdisplay** — punktfunk's OWN IddCx Indirect Display
|
||||
//! Driver (the clean-room replacement for SudoVDA). The Windows analogue of the Linux per-compositor
|
||||
//! backends: [`create`](VirtualDisplay::create) adds a virtual monitor at the client's exact `WxH@Hz`
|
||||
//! (the mode is baked into the ADD IOCTL — no EDID seeding), starts the mandatory watchdog ping, and
|
||||
//! the returned [`VirtualOutput`]'s keepalive `Drop` removes it (RAII).
|
||||
//!
|
||||
//! Control surface: a device-interface-GUID + `CreateFileW` + `DeviceIoControl` IOCTL protocol, with
|
||||
//! the wire contract OWNED by [`pf_driver_proto::control`] (versioned + `#[repr(C)] Pod` structs,
|
||||
//! NOT the SudoVDA ABI). No DLL, no named pipe. See `docs/windows-host-rewrite.md`.
|
||||
//!
|
||||
//! This is a faithful clone of [`super::sudovda`] (the shipping fallback) repointed at the new driver:
|
||||
//! same reference-counted/lingering monitor lifecycle, same CCD isolation + active-mode forcing — those
|
||||
//! backend-NEUTRAL helpers are REUSED from `sudovda` (a pf-vdisplay monitor's `target_id` is a real OS
|
||||
//! target id, so the CCD/DXGI code works unchanged). Only the driver-specific bits (GUID, IOCTL codes,
|
||||
//! request/reply structs, the version handshake) differ, per `pf_driver_proto`.
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::mem::size_of;
|
||||
use std::os::windows::io::{FromRawHandle, OwnedHandle};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use windows::core::{GUID, PCWSTR};
|
||||
use windows::Win32::Devices::DeviceAndDriverInstallation::{
|
||||
SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInterfaces, SetupDiGetClassDevsW,
|
||||
SetupDiGetDeviceInterfaceDetailW, DIGCF_DEVICEINTERFACE, DIGCF_PRESENT,
|
||||
SP_DEVICE_INTERFACE_DATA, SP_DEVICE_INTERFACE_DETAIL_DATA_W,
|
||||
};
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE, LUID};
|
||||
use windows::Win32::Storage::FileSystem::{
|
||||
CreateFileW, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
|
||||
};
|
||||
use windows::Win32::System::IO::DeviceIoControl;
|
||||
|
||||
use pf_driver_proto::control;
|
||||
|
||||
use super::manager::{AddedMonitor, MonitorKey, VdisplayDriver};
|
||||
use super::{Mode, VirtualDisplay, VirtualOutput};
|
||||
|
||||
// pf-vdisplay device-interface GUID (pf_driver_proto::PF_VDISPLAY_INTERFACE_GUID_U128). Deliberately
|
||||
// NOT SudoVDA's `{e5bcc234-…}` — we own this driver, so a private interface GUID signals it and avoids
|
||||
// any accidental coexistence with a real SudoVDA install.
|
||||
const PF_VDISPLAY_INTERFACE: GUID =
|
||||
GUID::from_u128(pf_driver_proto::PF_VDISPLAY_INTERFACE_GUID_U128);
|
||||
|
||||
/// Monotonic per-session id keying a pf-vdisplay monitor for `IOCTL_ADD`/`IOCTL_REMOVE`. Unlike
|
||||
/// SudoVDA's 16-byte GUID + pid-mangling, the proto keys monitors by a plain `u64` — the host-level
|
||||
/// refcount manager (MGR) owns collision safety (a stale session can never REMOVE a live one), so a
|
||||
/// simple monotonic counter suffices. Unique per (process, session) within this host's lifetime.
|
||||
static NEXT_SESSION_ID: AtomicU64 = AtomicU64::new(1);
|
||||
fn next_session_id() -> u64 {
|
||||
NEXT_SESSION_ID.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// One `DeviceIoControl` round trip (METHOD_BUFFERED). `input`/`output` may be empty. Identical to the
|
||||
/// SudoVDA backend's wrapper; struct<->bytes conversion happens at the call sites via `bytemuck`.
|
||||
unsafe fn ioctl(h: HANDLE, code: u32, input: &[u8], output: &mut [u8]) -> Result<u32> {
|
||||
let mut returned = 0u32;
|
||||
let inp = (!input.is_empty()).then_some(input.as_ptr() as *const c_void);
|
||||
let outp = (!output.is_empty()).then_some(output.as_mut_ptr() as *mut c_void);
|
||||
DeviceIoControl(
|
||||
h,
|
||||
code,
|
||||
inp,
|
||||
input.len() as u32,
|
||||
outp,
|
||||
output.len() as u32,
|
||||
Some(&mut returned),
|
||||
None,
|
||||
)
|
||||
.with_context(|| format!("DeviceIoControl(code={code:#x})"))?;
|
||||
Ok(returned)
|
||||
}
|
||||
|
||||
/// Pin the pf-vdisplay IddCx's RENDER GPU to `luid` (the analogue of Apollo's `SetRenderAdapter`). No
|
||||
/// output buffer. Issued on the driver handle BEFORE `IOCTL_ADD` to steer which GPU the new target
|
||||
/// renders on — on a multi-adapter box this stops DXGI from reparenting the virtual output onto a
|
||||
/// different adapter than the one we duplicate/encode on (the ACCESS_LOST storm).
|
||||
///
|
||||
/// NOTE: the pf-vdisplay driver currently returns `STATUS_NOT_IMPLEMENTED` for this IOCTL (a STEP-4
|
||||
/// stub), so this call WILL fail today. Callers tolerate the `Err` (warn + continue) — exactly as the
|
||||
/// SudoVDA backend tolerated the driver IGNORING the pin.
|
||||
unsafe fn set_render_adapter(h: HANDLE, luid: LUID) -> Result<()> {
|
||||
let req = control::SetRenderAdapterRequest {
|
||||
luid_low: luid.LowPart,
|
||||
luid_high: luid.HighPart,
|
||||
};
|
||||
let mut none: [u8; 0] = [];
|
||||
ioctl(
|
||||
h,
|
||||
control::IOCTL_SET_RENDER_ADAPTER,
|
||||
bytemuck::bytes_of(&req),
|
||||
&mut none,
|
||||
)
|
||||
.map(|_| ())
|
||||
.context("pf-vdisplay SET_RENDER_ADAPTER")
|
||||
}
|
||||
|
||||
unsafe fn open_device() -> Result<HANDLE> {
|
||||
let hdev = SetupDiGetClassDevsW(
|
||||
Some(&PF_VDISPLAY_INTERFACE),
|
||||
PCWSTR::null(),
|
||||
None,
|
||||
DIGCF_DEVICEINTERFACE | DIGCF_PRESENT,
|
||||
)
|
||||
.context("SetupDiGetClassDevsW(pf-vdisplay) — is the pf-vdisplay driver installed?")?;
|
||||
|
||||
let mut idata = SP_DEVICE_INTERFACE_DATA {
|
||||
cbSize: size_of::<SP_DEVICE_INTERFACE_DATA>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
SetupDiEnumDeviceInterfaces(hdev, None, &PF_VDISPLAY_INTERFACE, 0, &mut idata)
|
||||
.context("SetupDiEnumDeviceInterfaces(pf-vdisplay)")?;
|
||||
|
||||
let mut required = 0u32;
|
||||
let _ = SetupDiGetDeviceInterfaceDetailW(hdev, &idata, None, 0, Some(&mut required), None);
|
||||
let mut buf = vec![0u8; required as usize];
|
||||
let detail = buf.as_mut_ptr() as *mut SP_DEVICE_INTERFACE_DETAIL_DATA_W;
|
||||
(*detail).cbSize = size_of::<SP_DEVICE_INTERFACE_DETAIL_DATA_W>() as u32;
|
||||
SetupDiGetDeviceInterfaceDetailW(hdev, &idata, Some(detail), required, None, None)
|
||||
.context("SetupDiGetDeviceInterfaceDetailW(pf-vdisplay)")?;
|
||||
|
||||
let handle = CreateFileW(
|
||||
PCWSTR((*detail).DevicePath.as_ptr()),
|
||||
0xC000_0000, // GENERIC_READ | GENERIC_WRITE
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
None,
|
||||
OPEN_EXISTING,
|
||||
FILE_FLAGS_AND_ATTRIBUTES(0),
|
||||
None,
|
||||
)
|
||||
.context("CreateFileW(pf-vdisplay device)")?;
|
||||
let _ = SetupDiDestroyDeviceInfoList(hdev);
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
/// The pf-vdisplay IOCTL surface behind the shared [`VirtualDisplayManager`](super::manager::VirtualDisplayManager)
|
||||
/// (Goal-1 §2.5) — the wire contract is owned by `pf_driver_proto::control` (versioned, hard-checked).
|
||||
pub(crate) struct PfVdisplayDriver;
|
||||
|
||||
impl VdisplayDriver for PfVdisplayDriver {
|
||||
fn name(&self) -> &'static str {
|
||||
"pf-vdisplay"
|
||||
}
|
||||
|
||||
unsafe fn open(&self) -> Result<(OwnedHandle, u32)> {
|
||||
let device = unsafe { open_device()? };
|
||||
// HARD protocol-version check (unlike SudoVDA's best-effort log): a mismatched host/driver pair
|
||||
// fails loudly here rather than corrupting the IOCTL stream.
|
||||
let mut info_buf = [0u8; size_of::<control::InfoReply>()];
|
||||
unsafe { ioctl(device, control::IOCTL_GET_INFO, &[], &mut info_buf) }
|
||||
.context("pf-vdisplay IOCTL_GET_INFO (version handshake)")?;
|
||||
let info: control::InfoReply =
|
||||
bytemuck::pod_read_unaligned(&info_buf[..size_of::<control::InfoReply>()]);
|
||||
if info.protocol_version != pf_driver_proto::PROTOCOL_VERSION {
|
||||
unsafe {
|
||||
let _ = CloseHandle(device);
|
||||
}
|
||||
anyhow::bail!(
|
||||
"pf-vdisplay protocol mismatch: host expects {}, driver reports {} — install matching \
|
||||
host + driver",
|
||||
pf_driver_proto::PROTOCOL_VERSION,
|
||||
info.protocol_version
|
||||
);
|
||||
}
|
||||
let watchdog_s = info.watchdog_timeout_s.max(1);
|
||||
tracing::info!(
|
||||
"pf-vdisplay protocol {} (watchdog timeout {}s)",
|
||||
info.protocol_version,
|
||||
watchdog_s
|
||||
);
|
||||
// Reap monitors orphaned by a crashed previous host — a FIRST-CLASS op (driver returns SUCCESS).
|
||||
let mut none: [u8; 0] = [];
|
||||
if unsafe { ioctl(device, control::IOCTL_CLEAR_ALL, &[], &mut none) }.is_ok() {
|
||||
tracing::info!("cleared orphaned virtual monitors on host startup");
|
||||
} else {
|
||||
tracing::warn!("pf-vdisplay IOCTL_CLEAR_ALL failed on startup (continuing)");
|
||||
}
|
||||
Ok((
|
||||
unsafe { OwnedHandle::from_raw_handle(device.0 as _) },
|
||||
watchdog_s,
|
||||
))
|
||||
}
|
||||
|
||||
unsafe fn add_monitor(
|
||||
&self,
|
||||
dev: HANDLE,
|
||||
mode: Mode,
|
||||
render_luid: Option<LUID>,
|
||||
) -> Result<AddedMonitor> {
|
||||
let session_id = next_session_id();
|
||||
let add = control::AddRequest {
|
||||
session_id,
|
||||
width: mode.width,
|
||||
height: mode.height,
|
||||
refresh_hz: mode.refresh_hz,
|
||||
_reserved: 0,
|
||||
};
|
||||
// SET_RENDER_ADAPTER (opt-in; pf-vdisplay IMPLEMENTS it). Non-fatal on failure: the driver reports
|
||||
// its real render LUID in the shared header, so the host binds correctly even if this is ignored.
|
||||
if let Some(luid) = render_luid {
|
||||
match unsafe { set_render_adapter(dev, luid) } {
|
||||
Ok(()) => tracing::info!(
|
||||
luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
|
||||
"pf-vdisplay SET_RENDER_ADAPTER: pinned IDD render GPU"
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
"pf-vdisplay SET_RENDER_ADAPTER failed (continuing on the natural adapter): {e:#}"
|
||||
),
|
||||
}
|
||||
}
|
||||
let mut out = [0u8; size_of::<control::AddReply>()];
|
||||
unsafe { ioctl(dev, control::IOCTL_ADD, bytemuck::bytes_of(&add), &mut out) }.with_context(
|
||||
|| {
|
||||
format!(
|
||||
"pf-vdisplay ADD {}x{}@{}",
|
||||
mode.width, mode.height, mode.refresh_hz
|
||||
)
|
||||
},
|
||||
)?;
|
||||
// `pod_read_unaligned` (NOT `from_bytes`): `out` is a stack `[u8; N]` with no guaranteed 4-byte
|
||||
// alignment, and `from_bytes` PANICS on a mismatch. This copies into an aligned `AddReply`.
|
||||
let reply: control::AddReply =
|
||||
bytemuck::pod_read_unaligned(&out[..size_of::<control::AddReply>()]);
|
||||
let luid = LUID {
|
||||
LowPart: reply.adapter_luid_low,
|
||||
HighPart: reply.adapter_luid_high,
|
||||
};
|
||||
tracing::info!(
|
||||
"pf-vdisplay created {}x{}@{} (target_id={}, adapter_luid={:#x})",
|
||||
mode.width,
|
||||
mode.height,
|
||||
mode.refresh_hz,
|
||||
reply.target_id,
|
||||
luid.LowPart
|
||||
);
|
||||
if let Some(pin) = render_luid {
|
||||
if luid.LowPart == pin.LowPart && luid.HighPart == pin.HighPart {
|
||||
tracing::info!("pf-vdisplay ADD render adapter matches the pinned GPU (pin took)");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
add = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
|
||||
pinned = format!("{:08x}:{:08x}", pin.HighPart, pin.LowPart),
|
||||
"pf-vdisplay ADD render adapter DIFFERS from pinned — driver ignored SET_RENDER_ADAPTER?"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(AddedMonitor {
|
||||
key: MonitorKey::Session(session_id),
|
||||
target_id: reply.target_id,
|
||||
luid,
|
||||
})
|
||||
}
|
||||
|
||||
unsafe fn remove_monitor(&self, dev: HANDLE, key: &MonitorKey) -> Result<()> {
|
||||
let MonitorKey::Session(session_id) = key else {
|
||||
anyhow::bail!("pf-vdisplay: unexpected monitor key kind");
|
||||
};
|
||||
let req = control::RemoveRequest {
|
||||
session_id: *session_id,
|
||||
};
|
||||
let mut none: [u8; 0] = [];
|
||||
unsafe { ioctl(dev, control::IOCTL_REMOVE, bytemuck::bytes_of(&req), &mut none) }.map(|_| ())
|
||||
}
|
||||
|
||||
unsafe fn ping(&self, dev: HANDLE) -> Result<()> {
|
||||
let mut none: [u8; 0] = [];
|
||||
unsafe { ioctl(dev, control::IOCTL_PING, &[], &mut none) }.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
/// The Windows pf-vdisplay virtual-display backend. A marker — the lifecycle lives in the shared
|
||||
/// [`VirtualDisplayManager`](super::manager::VirtualDisplayManager).
|
||||
pub struct PfVdisplayDisplay;
|
||||
|
||||
impl PfVdisplayDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
super::manager::init(Box::new(PfVdisplayDriver)).open_backend()?;
|
||||
Ok(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl VirtualDisplay for PfVdisplayDisplay {
|
||||
fn name(&self) -> &'static str {
|
||||
"pf-vdisplay"
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
super::manager::vdm().acquire(mode)
|
||||
}
|
||||
}
|
||||
|
||||
/// Readiness probe: can we open the pf-vdisplay control device?
|
||||
pub fn probe() -> Result<()> {
|
||||
let h = unsafe { open_device()? };
|
||||
unsafe {
|
||||
let _ = CloseHandle(h);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Is the pf-vdisplay driver present (device interface enumerable)?
|
||||
pub fn is_available() -> bool {
|
||||
unsafe { open_device().map(|h| CloseHandle(h)).is_ok() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Live hardware round trip — skipped unless `PUNKTFUNK_PF_VDISPLAY_LIVE=1` (needs the pf-vdisplay
|
||||
/// driver installed). Exercises the real trait path: open -> create -> hold -> drop (REMOVE).
|
||||
#[test]
|
||||
fn live_create_drop() {
|
||||
if std::env::var("PUNKTFUNK_PF_VDISPLAY_LIVE").is_err() {
|
||||
return;
|
||||
}
|
||||
let mut vd = PfVdisplayDisplay::new().expect("open pf-vdisplay");
|
||||
let vout = vd
|
||||
.create(Mode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
refresh_hz: 60,
|
||||
})
|
||||
.expect("create virtual display");
|
||||
assert_eq!(vout.preferred_mode, Some((1920, 1080, 60)));
|
||||
thread::sleep(Duration::from_secs(3));
|
||||
drop(vout); // triggers REMOVE + stops the pinger
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Launch a process into the interactive user session from the SYSTEM host.
|
||||
//!
|
||||
//! The Windows host runs as a LocalSystem SCM service. To *launch* a game/launcher so it renders onto
|
||||
//! the captured desktop — and so the user's protocol handlers (`HKCU\Software\Classes`), UWP/appx
|
||||
//! activation, and each store's auth/entitlement context resolve — the process must run in the
|
||||
//! interactive session under the **logged-in user's** token, not SYSTEM and not session 0.
|
||||
//!
|
||||
//! This is the same `WTSGetActiveConsoleSessionId → WTSQueryUserToken → DuplicateTokenEx →
|
||||
//! CreateProcessAsUserW(winsta0\\default)` primitive the WGC helper relay uses
|
||||
//! ([`crate::capture::wgc_relay`]), factored out for the library launch path
|
||||
//! ([`crate::library::launch_title`]).
|
||||
//!
|
||||
//! IMPORTANT — use the **user** token (`WTSQueryUserToken`), NOT a session-retargeted SYSTEM token
|
||||
//! (the host-spawn in [`crate::service`] duplicates the SYSTEM token and only changes its session id;
|
||||
//! that is correct for launching *our own* streamer, but a store launcher needs the real user's token
|
||||
//! for activation + auth). The host process itself stays SYSTEM.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::path::Path;
|
||||
use windows::core::{PCWSTR, PWSTR};
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
||||
use windows::Win32::Security::{
|
||||
DuplicateTokenEx, SecurityImpersonation, TokenPrimary, TOKEN_ALL_ACCESS,
|
||||
};
|
||||
use windows::Win32::System::Environment::{CreateEnvironmentBlock, DestroyEnvironmentBlock};
|
||||
use windows::Win32::System::RemoteDesktop::{WTSGetActiveConsoleSessionId, WTSQueryUserToken};
|
||||
use windows::Win32::System::Threading::{
|
||||
CreateProcessAsUserW, CREATE_UNICODE_ENVIRONMENT, PROCESS_INFORMATION, STARTUPINFOW,
|
||||
};
|
||||
|
||||
/// Spawn `cmdline` in the active console session, under the logged-in user's token, on the
|
||||
/// interactive desktop (`winsta0\default`). Returns the new process id.
|
||||
///
|
||||
/// Fire-and-forget: the launched game/launcher outlives this call, so the host does not track the
|
||||
/// child — its handles are closed before returning (the process keeps running). The environment is
|
||||
/// the user's block merged with the host's `PUNKTFUNK_*`/`RUST_LOG` (same merge the WGC helper uses),
|
||||
/// so `host.env` settings propagate.
|
||||
///
|
||||
/// Requires the host to run as SYSTEM (`WTSQueryUserToken` needs `SE_TCB`). Fails when no interactive
|
||||
/// user is logged on (a pre-login / freshly-booted box can stream the login desktop but cannot
|
||||
/// auto-launch a store title until someone signs in).
|
||||
pub fn spawn_in_active_session(cmdline: &str, workdir: Option<&Path>) -> Result<u32> {
|
||||
unsafe { spawn_inner(cmdline, workdir) }
|
||||
}
|
||||
|
||||
unsafe fn spawn_inner(cmdline: &str, workdir: Option<&Path>) -> Result<u32> {
|
||||
// The user token of the active console session (requires the host to be SYSTEM).
|
||||
let session = WTSGetActiveConsoleSessionId();
|
||||
if session == 0xFFFF_FFFF {
|
||||
bail!("no active console session (no interactive user is logged on)");
|
||||
}
|
||||
let mut user_token = HANDLE::default();
|
||||
WTSQueryUserToken(session, &mut user_token)
|
||||
.context("WTSQueryUserToken (host must be SYSTEM; needs a logged-on interactive user)")?;
|
||||
|
||||
// A primary token for CreateProcessAsUserW.
|
||||
let mut primary = HANDLE::default();
|
||||
let dup = DuplicateTokenEx(
|
||||
user_token,
|
||||
TOKEN_ALL_ACCESS,
|
||||
None,
|
||||
SecurityImpersonation,
|
||||
TokenPrimary,
|
||||
&mut primary,
|
||||
);
|
||||
let _ = CloseHandle(user_token);
|
||||
dup.context("DuplicateTokenEx(TokenPrimary)")?;
|
||||
|
||||
// The user's environment block (PATH/USERPROFILE/SystemRoot for handler + DLL resolution), MERGED
|
||||
// with the host's PUNKTFUNK_*/RUST_LOG vars — same shared helper the WGC helper + service spawns use.
|
||||
let mut env_block: *mut core::ffi::c_void = std::ptr::null_mut();
|
||||
let _ = CreateEnvironmentBlock(&mut env_block, Some(primary), false);
|
||||
let merged_env = crate::capture::wgc_relay::merged_env_block(env_block as *const u16);
|
||||
if !env_block.is_null() {
|
||||
let _ = DestroyEnvironmentBlock(env_block);
|
||||
}
|
||||
|
||||
// The game/launcher must appear on the interactive desktop the host is capturing.
|
||||
let mut desktop: Vec<u16> = "winsta0\\default\0".encode_utf16().collect();
|
||||
let si = STARTUPINFOW {
|
||||
cb: std::mem::size_of::<STARTUPINFOW>() as u32,
|
||||
lpDesktop: PWSTR(desktop.as_mut_ptr()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut cmd: Vec<u16> = cmdline.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let workdir_w: Option<Vec<u16>> = workdir.map(|d| {
|
||||
d.as_os_str()
|
||||
.to_string_lossy()
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect()
|
||||
});
|
||||
let cwd = match &workdir_w {
|
||||
Some(w) => PCWSTR(w.as_ptr()),
|
||||
None => PCWSTR::null(),
|
||||
};
|
||||
|
||||
let mut pi = PROCESS_INFORMATION::default();
|
||||
let created = CreateProcessAsUserW(
|
||||
Some(primary),
|
||||
None,
|
||||
Some(PWSTR(cmd.as_mut_ptr())),
|
||||
None,
|
||||
None,
|
||||
false, // no handle inheritance — fire-and-forget GUI launch, no stdio relay
|
||||
CREATE_UNICODE_ENVIRONMENT,
|
||||
Some(merged_env.as_ptr() as *const core::ffi::c_void),
|
||||
cwd,
|
||||
&si,
|
||||
&mut pi,
|
||||
);
|
||||
let _ = CloseHandle(primary);
|
||||
created.context("CreateProcessAsUserW (interactive-session launch)")?;
|
||||
|
||||
let pid = pi.dwProcessId;
|
||||
// We don't supervise the child (it owns its own window/lifetime) — close the handles the API gave us.
|
||||
let _ = CloseHandle(pi.hProcess);
|
||||
let _ = CloseHandle(pi.hThread);
|
||||
Ok(pid)
|
||||
}
|
||||
+88
-50
@@ -23,8 +23,9 @@
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::ffi::{c_void, OsString};
|
||||
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicIsize, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use windows::core::{PCWSTR, PWSTR};
|
||||
@@ -64,14 +65,19 @@ const SERVICE_DESCRIPTION: &str =
|
||||
/// legacy GCM nonce reuse — security-review #5/#9; native clients only).
|
||||
const DEFAULT_HOST_CMD: &str = "serve --gamestream";
|
||||
|
||||
/// Event handles shared between the SCM control handler (which signals them) and the supervision loop
|
||||
/// (which waits on them). Stored as raw `isize` so the `'static + Send` handler can reach them without
|
||||
/// a non-`Send` `HANDLE` capture. Set once in `run_service`.
|
||||
static STOP_EVENT: AtomicIsize = AtomicIsize::new(0);
|
||||
static SESSION_EVENT: AtomicIsize = AtomicIsize::new(0);
|
||||
/// The STOP and SESSION manual-reset events, shared between the SCM control handler (a capture-free
|
||||
/// `'static` closure that SIGNALS them) and the supervision loop (which WAITS on them). They live in
|
||||
/// `OnceLock`s — a static the handler can reach without capturing a non-`Send` `HANDLE` — and each owns
|
||||
/// its handle (`OwnedHandle`) for the process lifetime: the service process exits right after
|
||||
/// `run_service` returns, so the OS reaps them at exit, and owning them past the handler's last possible
|
||||
/// call avoids the close-then-signal window the old raw-`isize` statics had. Set once, in `run_service`.
|
||||
static STOP_EVENT: OnceLock<OwnedHandle> = OnceLock::new();
|
||||
static SESSION_EVENT: OnceLock<OwnedHandle> = OnceLock::new();
|
||||
|
||||
fn load_event(a: &AtomicIsize) -> HANDLE {
|
||||
HANDLE(a.load(Ordering::Relaxed) as *mut c_void)
|
||||
/// Borrow an event's handle for the control handler's `SetEvent`. `None` until `run_service` creates the
|
||||
/// events — but the handler is registered only AFTER they're set, so in practice this is always `Some`.
|
||||
fn event_handle(ev: &OnceLock<OwnedHandle>) -> Option<HANDLE> {
|
||||
ev.get().map(|h| HANDLE(h.as_raw_handle()))
|
||||
}
|
||||
|
||||
/// Dispatch `service <sub>`.
|
||||
@@ -199,12 +205,19 @@ fn run_service() -> Result<()> {
|
||||
|
||||
// Two manual-reset events: STOP (set once, never reset) and SESSION (set on a console
|
||||
// connect/disconnect, reset by the supervisor after it reacts).
|
||||
let stop =
|
||||
let stop_raw =
|
||||
unsafe { CreateEventW(None, true, false, PCWSTR::null()) }.context("CreateEvent stop")?;
|
||||
let session = unsafe { CreateEventW(None, true, false, PCWSTR::null()) }
|
||||
let session_raw = unsafe { CreateEventW(None, true, false, PCWSTR::null()) }
|
||||
.context("CreateEvent session")?;
|
||||
STOP_EVENT.store(stop.0 as isize, Ordering::Relaxed);
|
||||
SESSION_EVENT.store(session.0 as isize, Ordering::Relaxed);
|
||||
// Own each event handle (the OS reaps them at process exit); the handler reaches them through the
|
||||
// OnceLocks, while `supervise` waits on the borrowed `HANDLE`s. SAFETY: each is a fresh CreateEventW
|
||||
// handle we own — take ownership exactly once.
|
||||
let stop_owned = unsafe { OwnedHandle::from_raw_handle(stop_raw.0) };
|
||||
let session_owned = unsafe { OwnedHandle::from_raw_handle(session_raw.0) };
|
||||
let stop = HANDLE(stop_owned.as_raw_handle());
|
||||
let session = HANDLE(session_owned.as_raw_handle());
|
||||
let _ = STOP_EVENT.set(stop_owned); // set once per process
|
||||
let _ = SESSION_EVENT.set(session_owned);
|
||||
|
||||
// The control handler captures nothing — it reaches the events through the statics, so it stays
|
||||
// `Fn + Send + 'static`. Session lock/unlock are handled inside the host (DesktopWatcher), so we
|
||||
@@ -212,7 +225,9 @@ fn run_service() -> Result<()> {
|
||||
let handler = move |control| -> ServiceControlHandlerResult {
|
||||
match control {
|
||||
ServiceControl::Stop | ServiceControl::Preshutdown | ServiceControl::Shutdown => {
|
||||
unsafe { SetEvent(load_event(&STOP_EVENT)) }.ok();
|
||||
if let Some(h) = event_handle(&STOP_EVENT) {
|
||||
unsafe { SetEvent(h) }.ok();
|
||||
}
|
||||
ServiceControlHandlerResult::NoError
|
||||
}
|
||||
ServiceControl::SessionChange(param) => {
|
||||
@@ -221,7 +236,9 @@ fn run_service() -> Result<()> {
|
||||
param.reason,
|
||||
ConsoleConnect | ConsoleDisconnect | SessionLogon
|
||||
) {
|
||||
unsafe { SetEvent(load_event(&SESSION_EVENT)) }.ok();
|
||||
if let Some(h) = event_handle(&SESSION_EVENT) {
|
||||
unsafe { SetEvent(h) }.ok();
|
||||
}
|
||||
}
|
||||
ServiceControlHandlerResult::NoError
|
||||
}
|
||||
@@ -258,10 +275,8 @@ fn run_service() -> Result<()> {
|
||||
controls_accepted: ServiceControlAccept::empty(),
|
||||
..running
|
||||
});
|
||||
unsafe {
|
||||
let _ = CloseHandle(stop);
|
||||
let _ = CloseHandle(session);
|
||||
}
|
||||
// The STOP/SESSION events stay owned by the OnceLocks for the process lifetime (the OS reaps them at
|
||||
// exit); NOT closing them while the SCM handler could still fire avoids a use-after-close.
|
||||
result
|
||||
}
|
||||
|
||||
@@ -280,7 +295,8 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
|
||||
.collect();
|
||||
|
||||
// Kill-on-close job so a service crash never orphans the SYSTEM host; BREAKAWAY_OK lets the host
|
||||
// still spawn the WGC helper.
|
||||
// still spawn the WGC helper. Owned: dropping it at function exit (KILL_ON_JOB_CLOSE) reaps any
|
||||
// straggler still inside it — no manual CloseHandle(job).
|
||||
let job = unsafe { make_job() }.context("create job object")?;
|
||||
|
||||
let mut restarts: u32 = 0;
|
||||
@@ -299,8 +315,10 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
|
||||
continue;
|
||||
}
|
||||
|
||||
let pi = match unsafe { spawn_host(session, &cmdline, &workdir, job) } {
|
||||
Ok(pi) => pi,
|
||||
// BORROW the owned job handle for AssignProcessToJobObject inside spawn_host.
|
||||
let job_h = HANDLE(job.as_raw_handle());
|
||||
let child = match unsafe { spawn_host(session, &cmdline, &workdir, job_h) } {
|
||||
Ok(child) => child,
|
||||
Err(e) => {
|
||||
tracing::error!("failed to launch host into session {session}: {e:#}");
|
||||
if wait_one(stop, 3000) {
|
||||
@@ -309,17 +327,21 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
tracing::info!(pid = pi.dwProcessId, session, cmd = %host_cmd, "host launched");
|
||||
tracing::info!(pid = child.pid, session, cmd = %host_cmd, "host launched");
|
||||
|
||||
// A BORROW of the owned process handle for the waits + TerminateProcess (HANDLE is Copy, so
|
||||
// `proc_h` is a plain copy that does NOT close it). `child` owns the process + thread handles
|
||||
// and auto-closes BOTH when it drops — at the end of this iteration, on `continue`, or on
|
||||
// `break` — so every match arm below only stops/terminates and lets the drop do the closing.
|
||||
let proc_h = HANDLE(child.process.as_raw_handle());
|
||||
|
||||
// Wait on stop / session-change / child-exit.
|
||||
let reason = wait_any(&[stop, session_ev, pi.hProcess], INFINITE);
|
||||
let reason = wait_any(&[stop, session_ev, proc_h], INFINITE);
|
||||
match reason {
|
||||
Some(0) => {
|
||||
// Stop: terminate the child and exit.
|
||||
// Stop: terminate the child and exit (the `child` drop closes its handles).
|
||||
unsafe {
|
||||
let _ = TerminateProcess(pi.hProcess, 0);
|
||||
let _ = CloseHandle(pi.hProcess);
|
||||
let _ = CloseHandle(pi.hThread);
|
||||
let _ = TerminateProcess(proc_h, 0);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -334,19 +356,15 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
|
||||
"console session changed — relaunching host"
|
||||
);
|
||||
unsafe {
|
||||
let _ = TerminateProcess(pi.hProcess, 0);
|
||||
let _ = CloseHandle(pi.hProcess);
|
||||
let _ = CloseHandle(pi.hThread);
|
||||
let _ = TerminateProcess(proc_h, 0);
|
||||
}
|
||||
restarts = 0;
|
||||
continue;
|
||||
}
|
||||
// Same session (e.g. a stray notification) — keep waiting on the same child.
|
||||
let r = wait_any(&[stop, pi.hProcess], INFINITE);
|
||||
let r = wait_any(&[stop, proc_h], INFINITE);
|
||||
unsafe {
|
||||
let _ = TerminateProcess(pi.hProcess, 0);
|
||||
let _ = CloseHandle(pi.hProcess);
|
||||
let _ = CloseHandle(pi.hThread);
|
||||
let _ = TerminateProcess(proc_h, 0);
|
||||
}
|
||||
if r == Some(0) {
|
||||
break;
|
||||
@@ -354,12 +372,9 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
|
||||
// child exited → fall through to relaunch
|
||||
}
|
||||
_ => {
|
||||
// Child exited on its own — relaunch (with a small crash-loop backoff).
|
||||
// Child exited on its own — relaunch (with a small crash-loop backoff). The `child`
|
||||
// drop closes its (already-exited) handles.
|
||||
tracing::warn!("host process exited — relaunching");
|
||||
unsafe {
|
||||
let _ = CloseHandle(pi.hProcess);
|
||||
let _ = CloseHandle(pi.hThread);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,12 +383,11 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
|
||||
if wait_one(stop, backoff) {
|
||||
break;
|
||||
}
|
||||
// `child` drops here (end of iteration) → its process + thread handles close before relaunch.
|
||||
}
|
||||
|
||||
unsafe {
|
||||
// Dropping the job (KILL_ON_JOB_CLOSE) reaps any straggler in it.
|
||||
let _ = CloseHandle(job);
|
||||
}
|
||||
// `job` (OwnedHandle) drops at function exit, closing the job object → KILL_ON_JOB_CLOSE reaps
|
||||
// any straggler still inside it.
|
||||
tracing::info!("supervision loop ended");
|
||||
Ok(())
|
||||
}
|
||||
@@ -390,14 +404,16 @@ fn wait_any(handles: &[HANDLE], ms: u32) -> Option<usize> {
|
||||
(idx < handles.len() as u32).then_some(idx as usize)
|
||||
}
|
||||
|
||||
/// A kill-on-close + breakaway-ok job object.
|
||||
unsafe fn make_job() -> Result<HANDLE> {
|
||||
let job = CreateJobObjectW(None, PCWSTR::null()).context("CreateJobObjectW")?;
|
||||
/// A kill-on-close + breakaway-ok job object, returned as an `OwnedHandle` (auto-`CloseHandle` on drop).
|
||||
unsafe fn make_job() -> Result<OwnedHandle> {
|
||||
let job_raw = CreateJobObjectW(None, PCWSTR::null()).context("CreateJobObjectW")?;
|
||||
// Own it immediately so any early return (e.g. a failed SetInformationJobObject) still closes it.
|
||||
let job = OwnedHandle::from_raw_handle(job_raw.0);
|
||||
let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
|
||||
info.BasicLimitInformation.LimitFlags =
|
||||
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK;
|
||||
SetInformationJobObject(
|
||||
job,
|
||||
HANDLE(job.as_raw_handle()),
|
||||
JobObjectExtendedLimitInformation,
|
||||
&info as *const _ as *const c_void,
|
||||
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||
@@ -406,13 +422,24 @@ unsafe fn make_job() -> Result<HANDLE> {
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
/// Launch the host as SYSTEM into `session_id`'s interactive desktop. Returns the child handles.
|
||||
/// The owned handles to a spawned host child. The `process`/`thread` `OwnedHandle`s auto-`CloseHandle`
|
||||
/// when the `Child` drops (or is replaced each loop iteration) — replacing the manual
|
||||
/// `CloseHandle(pi.hProcess/hThread)` the supervise loop used to scatter across its match arms.
|
||||
struct Child {
|
||||
process: OwnedHandle,
|
||||
/// Held only for its RAII `CloseHandle` (the thread handle is never used after spawn) — `_`-prefixed
|
||||
/// so the `dead_code` lint (CI's `-D warnings`) doesn't flag the never-read field.
|
||||
_thread: OwnedHandle,
|
||||
pid: u32,
|
||||
}
|
||||
|
||||
/// Launch the host as SYSTEM into `session_id`'s interactive desktop. Returns the owned child handles.
|
||||
unsafe fn spawn_host(
|
||||
session_id: u32,
|
||||
cmdline: &str,
|
||||
workdir: &[u16],
|
||||
job: HANDLE,
|
||||
) -> Result<PROCESS_INFORMATION> {
|
||||
) -> Result<Child> {
|
||||
// 1) A primary SYSTEM token retargeted to the active console session: duplicate THIS process's
|
||||
// (LocalSystem) token, then set its session id. SYSTEM holds SE_TCB so SetTokenInformation
|
||||
// (TokenSessionId) is permitted.
|
||||
@@ -494,7 +521,14 @@ unsafe fn spawn_host(
|
||||
|
||||
// Best-effort: keep the host inside the kill-on-close job.
|
||||
let _ = AssignProcessToJobObject(job, pi.hProcess);
|
||||
Ok(pi)
|
||||
|
||||
// Take ownership of the process + thread handles the API filled into `pi`; the returned `Child`
|
||||
// closes BOTH on drop, so the supervise loop no longer hand-closes them in its match arms.
|
||||
Ok(Child {
|
||||
process: OwnedHandle::from_raw_handle(pi.hProcess.0),
|
||||
_thread: OwnedHandle::from_raw_handle(pi.hThread.0),
|
||||
pid: pi.dwProcessId,
|
||||
})
|
||||
}
|
||||
|
||||
/// Open `path` for appending, as an INHERITABLE handle (so the child can use it as stdout/stderr).
|
||||
@@ -621,6 +655,10 @@ fn ensure_default_host_env() -> Result<()> {
|
||||
# Force one with nvenc | amf | qsv | sw (software H.264). amf/qsv need an FFmpeg-built host.\n\
|
||||
PUNKTFUNK_ENCODER=auto\n\
|
||||
PUNKTFUNK_VIDEO_SOURCE=virtual\n\
|
||||
# Virtual display = the bundled pf-vdisplay driver; capture from its shared ring (the validated\n\
|
||||
# zero-copy IDD-push path; falls back to DDA if it can't attach). Set PUNKTFUNK_IDD_PUSH=0 to force WGC/DDA.\n\
|
||||
PUNKTFUNK_VDISPLAY=pf\n\
|
||||
PUNKTFUNK_IDD_PUSH=1\n\
|
||||
PUNKTFUNK_SECURE_DDA=1\n\
|
||||
RUST_LOG=info\n\
|
||||
\n\
|
||||
+1
-1
@@ -135,7 +135,7 @@ pub fn run(opts: HelperOptions) -> Result<()> {
|
||||
// the GPU scheduling priority the SYSTEM host stamps on us, not pipeline depth.
|
||||
let interval = std::time::Duration::from_secs_f64(1.0 / opts.fps.max(1) as f64);
|
||||
|
||||
let perf = std::env::var_os("PUNKTFUNK_PERF").is_some();
|
||||
let perf = crate::config::config().perf;
|
||||
let mut frames = 0u64;
|
||||
let mut repeats = 0u64; // frames where no newer capture had arrived (duplicate re-encode)
|
||||
let mut cap_ns = 0u64; // time in try_latest (capture + video-processor convert)
|
||||
+5
-4
@@ -3,8 +3,8 @@
|
||||
//! The discrete render-GPU LUID picker used to live in the SudoVDA backend (`vdisplay::sudovda`) — a
|
||||
//! historical accident, since it is display-utility, not SudoVDA-specific. It lives here so the capturers
|
||||
//! (IDD-push) and the pf-vdisplay backend depend on it as a *peer* instead of reaching into the SudoVDA
|
||||
//! module — breaking that circular reach-in so SudoVDA can eventually be dropped without losing this
|
||||
//! helper (audit §9 / Goal 2). This is the plan's `windows/adapter.rs`.
|
||||
//! module — breaking that circular reach-in, which let the SudoVDA backend be dropped without losing this
|
||||
//! helper (audit §9 / Goal 2 — done). This is the plan's `windows/adapter.rs`.
|
||||
|
||||
use windows::Win32::Foundation::LUID;
|
||||
|
||||
@@ -18,8 +18,9 @@ use windows::Win32::Foundation::LUID;
|
||||
/// already satisfy this).
|
||||
pub(crate) unsafe fn resolve_render_adapter_luid() -> Option<LUID> {
|
||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIFactory1};
|
||||
let want = std::env::var("PUNKTFUNK_RENDER_ADAPTER")
|
||||
.ok()
|
||||
let want = crate::config::config()
|
||||
.render_adapter
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty());
|
||||
let factory: IDXGIFactory1 = CreateDXGIFactory1().ok()?;
|
||||
let mut best: Option<(LUID, u64, String)> = None;
|
||||
+24
-3
@@ -5,8 +5,8 @@
|
||||
//! These are display-utility, NOT SudoVDA-specific (a pf-vdisplay monitor's target_id is a real OS target
|
||||
//! id, so they operate identically), so they live here rather than in the SudoVDA backend — breaking the
|
||||
//! circular reach-in where the capturers + the pf-vdisplay backend reached into `vdisplay::sudovda` for
|
||||
//! them, so SudoVDA can eventually be dropped without losing them (audit §9 / Goal 2). The plan's
|
||||
//! `windows/display_ccd.rs`. Moved verbatim from `vdisplay::sudovda`.
|
||||
//! them, which let the SudoVDA backend be dropped without losing them (audit §9 / Goal 2 — done). The
|
||||
//! plan's `windows/display_ccd.rs`. Extracted verbatim from the former SudoVDA backend before its removal.
|
||||
|
||||
use std::mem::size_of;
|
||||
|
||||
@@ -23,7 +23,7 @@ use windows::Win32::Devices::Display::{
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
ChangeDisplaySettingsExW, EnumDisplaySettingsW, CDS_TEST, CDS_UPDATEREGISTRY, DEVMODEW,
|
||||
DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY, DM_PELSHEIGHT, DM_PELSWIDTH,
|
||||
ENUM_DISPLAY_SETTINGS_MODE,
|
||||
ENUM_CURRENT_SETTINGS, ENUM_DISPLAY_SETTINGS_MODE,
|
||||
};
|
||||
|
||||
use crate::vdisplay::Mode;
|
||||
@@ -67,6 +67,27 @@ pub(crate) unsafe fn resolve_gdi_name(target_id: u32) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The virtual display's CURRENT active resolution `(width, height)` via the GDI/CCD API, or `None` if the
|
||||
/// target isn't an active display yet / the query fails. The IDD-push capturer sizes its ring to this
|
||||
/// ACTUAL mode and polls it to recreate the ring when it changes — a fullscreen game can change the
|
||||
/// virtual display's mode out from under the session-negotiated one (game-capture bug GB1).
|
||||
///
|
||||
/// # Safety
|
||||
/// Calls the GDI/CCD APIs; safe to call from any thread.
|
||||
pub(crate) unsafe fn active_resolution(target_id: u32) -> Option<(u32, u32)> {
|
||||
let gdi = resolve_gdi_name(target_id)?;
|
||||
let wname: Vec<u16> = gdi.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let mut dm = DEVMODEW {
|
||||
dmSize: size_of::<DEVMODEW>() as u16,
|
||||
..Default::default()
|
||||
};
|
||||
let ok = EnumDisplaySettingsW(PCWSTR(wname.as_ptr()), ENUM_CURRENT_SETTINGS, &mut dm).as_bool();
|
||||
if !ok || dm.dmPelsWidth == 0 || dm.dmPelsHeight == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((dm.dmPelsWidth, dm.dmPelsHeight))
|
||||
}
|
||||
|
||||
/// Toggle the SudoVDA target's advanced-color (HDR) state via the CCD API. Disabling HDR while on the
|
||||
/// secure (Winlogon) desktop makes it render SDR/composed so DXGI Desktop Duplication can capture it
|
||||
/// (the HDR fullscreen independent-flip otherwise storms `ACCESS_LOST` → black); re-enable on return so
|
||||
+14
-84
@@ -11,8 +11,8 @@
|
||||
"@tanstack/react-start": "^1.121.0",
|
||||
"@unom/style": "^0.4.4",
|
||||
"@unom/ui": "^0.8.16",
|
||||
"fumadocs-core": "^16.10.1",
|
||||
"fumadocs-ui": "^16.10.1",
|
||||
"fumadocs-core": "^16.10.5",
|
||||
"fumadocs-ui": "^16.10.5",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
},
|
||||
@@ -481,7 +481,7 @@
|
||||
|
||||
"@radix-ui/react-accessible-icon": ["@radix-ui/react-accessible-icon@1.1.10", "", { "dependencies": { "@radix-ui/react-visually-hidden": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TraSwZUqTcVbiDV2/RXzAXC7aeVVXchq0daPFZE7zAxYFaMzjOUggLOfQH9KFLgRizuwVKZO/crveV1eeO3/ZQ=="],
|
||||
|
||||
"@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collapsible": "1.1.13", "@radix-ui/react-collection": "1.1.9", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-xITxBB2p5m5tAe7M0F95kb4uAh7jSIKGlExMEm93HlW+XxZHV2eXFbPWLktd4JhRiwcnXNbO7iekcrbZy6ZCvA=="],
|
||||
"@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collapsible": "1.1.14", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-iE8YB9nmTBH8zd73ofBISZ8JCzgMoMkATJr7qDwa6u5F1+7mTM81V6fa71jgZ65rpjVpecDf1vSnwIFP9Ly1zw=="],
|
||||
|
||||
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dialog": "1.1.17", "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-563ygGeyWPrxyVCNp7OV4rE2aIXhFPknpFyo4wbDlcyMMPZ6ySh+zC5WTvY0ZFLgPTg/QB6tA8PyDQyJ2b4cPg=="],
|
||||
|
||||
@@ -493,7 +493,7 @@
|
||||
|
||||
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pREzrmNnVwGvYaBoM64huTRK7B3lrTRuwj8A9nwhPiEtMb+yudiWh6zWAqEtP0Dzd5+iBa1Ki7V1pCxV8ExMdA=="],
|
||||
|
||||
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F0s8+p2XNpfc3k02zBfB0jPWbkHVG162+p7BdUMyJ2308QMqZ+oaclX+FAzKFovgL5OqRU+Rvy6f/vbdlJVaqA=="],
|
||||
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9bT+FvifX1FK2Mj6UEsTdyu0cN3JaA3KdfhaBao+ONrYFy/pyOy3TU1TNw7iOk1o+0hOEq67RojlUUmoFGwxyA=="],
|
||||
|
||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.10", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g=="],
|
||||
|
||||
@@ -503,7 +503,7 @@
|
||||
|
||||
"@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.3.1", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-menu": "2.1.18", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XbrxS68W5dyiE4fAb96yvJwSVU5x66B20A99sD5Mk3xSWK/LqeOnx6TZnim1KieMjXS/CTFq8reOAjWxas2G8Q=="],
|
||||
|
||||
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.12", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.9", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.11", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-slot": "1.2.5", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-l9ok83YBclEZhbjgzt76Hw733e6cvRKPNgO6GJ/IETlufXG9p+fRu2wlvpImQvR6xdJ8h7J8J2DBvsPEiEsKMw=="],
|
||||
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw=="],
|
||||
|
||||
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA=="],
|
||||
|
||||
@@ -527,13 +527,13 @@
|
||||
|
||||
"@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-menu": "2.1.18", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-roving-focus": "1.1.13", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-hX7EGx/oFq6DPY27GQuP/2wP48GHf5LG6r06VgNJlG+znmDS8OfopZcRcGly3L4lsB9FqpmLx6JQSE9P3BUpyw=="],
|
||||
|
||||
"@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.9", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.12", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/fS8hKCcRt4DwCGa5QIB3juRXmfYSOk4a2AEe/BDIyy7Hm+eje2Y13oUx5zejl+wFt1owrM7E8NWlbaEl5EGpg=="],
|
||||
"@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-nJ0SkrSQgudyYhMiYeHA1ayLVuduEJCFLan1RZZN7c9kqzzCFLaU9kuy81uNtqzweM9YaQPgWzxi9MwQ9jZ04g=="],
|
||||
|
||||
"@radix-ui/react-one-time-password-field": ["@radix-ui/react-one-time-password-field@0.1.10", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-roving-focus": "1.1.13", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-is-hydrated": "0.1.1", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-GHkcJ+WVj91At+OvUVTD4R3W0/wxw9t/sG5xFUBYXaCbtWiooZX5Md376QjJqgH4VsVyXrbVNHO2O4NYcmjfVg=="],
|
||||
|
||||
"@radix-ui/react-password-toggle-field": ["@radix-ui/react-password-toggle-field@0.1.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-is-hydrated": "0.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-fVuA82u0b/fClpbEJv8yp1nU9eSvoSEOERsU/hhf3FXGPIvkmE7oEaHEu8poowoXO39/Va7zq2E0TUcYr1dBRg=="],
|
||||
|
||||
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.12", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.9", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.0", "@radix-ui/react-portal": "1.1.11", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-slot": "1.2.5", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8brVpAU5Uq7Bh0c8EFc4ZTf2JJTYn0o+1L+CUJB3UYIOkTjKGMgoHvduylrahdmNlr3DfH0rFq2DrbNZXgaspw=="],
|
||||
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.1", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g=="],
|
||||
|
||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.1", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-rect": "1.1.2", "@radix-ui/react-use-size": "1.1.2", "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw=="],
|
||||
|
||||
@@ -549,7 +549,7 @@
|
||||
|
||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw=="],
|
||||
|
||||
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.11", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-DS39ziOgea75U/TrXKU2/oKp0be2jrDHnzFLvahg/0iNAT1Zq16e4Uw0WXwyXvsK+mG3BRyMb7A3NRZMDuEXtQ=="],
|
||||
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.12", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-xuafVzQiTCLsyEjakowTdG3OgTXsmO7IdCiO77otIa+z44xoLNs9Do5eg7POFumIOCjtG6djfm6RKUKpUa/csA=="],
|
||||
|
||||
"@radix-ui/react-select": ["@radix-ui/react-select@2.3.1", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.1", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.6", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-w6eDvY78LE9ZUiNnXCA1QVK8RYN7k9galFv09kjVydJqBAgHd7Y9A6h0UJ/6DCZNGZMZrB2ohcSW1Bo9d8+wWA=="],
|
||||
|
||||
@@ -557,11 +557,11 @@
|
||||
|
||||
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.4.1", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-r91WSpQucNGFKAIxT8FT0H0zyjd5tJlqObLp7LOMV4z49KoDCwjy01w3vDOU4e1wxhF9IgjYco7SB6byOW7Buw=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg=="],
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
|
||||
|
||||
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.3.1", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-55bQtCnOB0BohomSHi6qvQXpJEEqUGDm6hRrM0Bph5OXwhSegqkd8IqgBAQkM1IlgUlWZIxpxRcpOEfRIgimyw=="],
|
||||
|
||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-roving-focus": "1.1.12", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-D5jwp9JNuwDeCw3CYD2Fz+sSHo0droQjC8u75dJHe4aWr5q6yBiXZU+hurXnKudRgEpUkD5TsI6bjHPo5ThUxA=="],
|
||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-roving-focus": "1.1.13", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg=="],
|
||||
|
||||
"@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-uL4kyyWy000pPL43fGGCV5qT6ZchCWEQZOSlkYiPwPt8Hy1iW38RjeptIvz1/SZesrW6Vn58Ct3sV7tfEfiAbw=="],
|
||||
|
||||
@@ -1297,11 +1297,11 @@
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"fumadocs-core": ["fumadocs-core@16.10.1", "", { "dependencies": { "@fuma-translate/react": "^1.0.1", "@orama/orama": "^3.1.18", "estree-util-value-to-estree": "^3.5.0", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "js-yaml": "^4.2.0", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^4.2.0", "tinyglobby": "^0.2.17", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" }, "peerDependencies": { "@mdx-js/mdx": "*", "@mixedbread/sdk": "0.x.x", "@orama/core": "1.x.x", "@oramacloud/client": "2.x.x", "@tanstack/react-router": "1.x.x", "@types/estree-jsx": "*", "@types/hast": "*", "@types/mdast": "*", "@types/react": "*", "algoliasearch": "5.x.x", "flexsearch": "*", "lucide-react": "*", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "7.x.x", "waku": "*", "zod": "4.x.x" }, "optionalPeers": ["@mdx-js/mdx", "@mixedbread/sdk", "@orama/core", "@oramacloud/client", "@tanstack/react-router", "@types/estree-jsx", "@types/hast", "@types/mdast", "@types/react", "algoliasearch", "flexsearch", "lucide-react", "next", "react", "react-dom", "react-router", "waku", "zod"] }, "sha512-iGnB03/VyMSTWIaZ8zaDG/b/4q1e4gSzWDSvP3AR5Yxg9UJMsA0acaN/IFcURBSgRgJq6PELyYA6WfHBvHAgSg=="],
|
||||
"fumadocs-core": ["fumadocs-core@16.10.5", "", { "dependencies": { "@orama/orama": "^3.1.18", "estree-util-value-to-estree": "^3.5.0", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "js-yaml": "^4.2.0", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^4.2.0", "tinyglobby": "^0.2.17", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" }, "peerDependencies": { "@mdx-js/mdx": "*", "@mixedbread/sdk": "0.x.x", "@orama/core": "1.x.x", "@oramacloud/client": "2.x.x", "@tanstack/react-router": "1.x.x", "@types/estree-jsx": "*", "@types/hast": "*", "@types/mdast": "*", "@types/react": "*", "algoliasearch": "5.x.x", "flexsearch": "*", "lucide-react": "*", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "7.x.x || 8.x.x", "waku": "*", "zod": "4.x.x" }, "optionalPeers": ["@mdx-js/mdx", "@mixedbread/sdk", "@orama/core", "@oramacloud/client", "@tanstack/react-router", "@types/estree-jsx", "@types/hast", "@types/mdast", "@types/react", "algoliasearch", "flexsearch", "lucide-react", "next", "react", "react-dom", "react-router", "waku", "zod"] }, "sha512-e/xrZnKvQo8bF/WYMwPuym8PR3OtjZzHy0S/EIOvGwjKRgVq9z6J58zaBpi4LvYtPVZxNGsxdZVlmZXCVWq4FQ=="],
|
||||
|
||||
"fumadocs-mdx": ["fumadocs-mdx@15.0.12", "", { "dependencies": { "@mdx-js/mdx": "^3.1.1", "@standard-schema/spec": "^1.1.0", "chokidar": "^5.0.0", "esbuild": "^0.28.0", "estree-util-value-to-estree": "^3.5.0", "js-yaml": "^4.2.0", "mdast-util-mdx": "^3.0.0", "picocolors": "^1.1.1", "picomatch": "^4.0.4", "tinyexec": "^1.2.4", "tinyglobby": "^0.2.17", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3", "zod": "^4.4.3" }, "peerDependencies": { "@types/mdast": "*", "@types/mdx": "*", "@types/react": "*", "fumadocs-core": "^16.7.0", "mdast-util-directive": "*", "next": "^15.3.0 || ^16.0.0", "react": "^19.2.0", "rolldown": "*", "vite": "7.x.x || 8.x.x" }, "optionalPeers": ["@types/mdast", "@types/mdx", "@types/react", "mdast-util-directive", "next", "react", "rolldown", "vite"], "bin": { "fumadocs-mdx": "./bin.js" } }, "sha512-R4WenrNQxSKi+QU46Q1cscVWi+S90dj3As4jdN+vgChO2o0TVOj+FFIe3onWM7mglhPj53NxZp/upP+t/ryekQ=="],
|
||||
|
||||
"fumadocs-ui": ["fumadocs-ui@16.10.1", "", { "dependencies": { "@fuma-translate/react": "^1.0.1", "@fumadocs/tailwind": "0.0.5", "@radix-ui/react-accordion": "^1.2.13", "@radix-ui/react-collapsible": "^1.1.13", "@radix-ui/react-dialog": "^1.1.16", "@radix-ui/react-direction": "^1.1.2", "@radix-ui/react-navigation-menu": "^1.2.15", "@radix-ui/react-popover": "^1.1.16", "@radix-ui/react-presence": "^1.1.6", "@radix-ui/react-scroll-area": "^1.2.11", "@radix-ui/react-slot": "^1.2.5", "@radix-ui/react-tabs": "^1.1.14", "class-variance-authority": "^0.7.1", "lucide-react": "^1.17.0", "motion": "^12.40.0", "next-themes": "^0.4.6", "react-remove-scroll": "^2.7.2", "rehype-raw": "^7.0.0", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^4.2.0", "tailwind-merge": "^3.6.0", "unist-util-visit": "^5.1.0" }, "peerDependencies": { "@takumi-rs/image-response": "*", "@types/mdx": "*", "@types/react": "*", "fumadocs-core": "16.10.1", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0" }, "optionalPeers": ["@takumi-rs/image-response", "@types/mdx", "@types/react", "next"] }, "sha512-ytEwbMFFadfuul9x4Pz4pg9FMRI1MkqW5P7bHrWsLF+d1C4whzNtcUKPn0QP6KCQqIKoVhIa3C7qlI9v06Ik1A=="],
|
||||
"fumadocs-ui": ["fumadocs-ui@16.10.5", "", { "dependencies": { "@fuma-translate/react": "^1.0.2", "@fumadocs/tailwind": "0.0.5", "@radix-ui/react-accordion": "^1.2.14", "@radix-ui/react-collapsible": "^1.1.14", "@radix-ui/react-dialog": "^1.1.17", "@radix-ui/react-direction": "^1.1.2", "@radix-ui/react-navigation-menu": "^1.2.16", "@radix-ui/react-popover": "^1.1.17", "@radix-ui/react-presence": "^1.1.6", "@radix-ui/react-scroll-area": "^1.2.12", "@radix-ui/react-slot": "^1.3.0", "@radix-ui/react-tabs": "^1.1.15", "class-variance-authority": "^0.7.1", "lucide-react": "^1.20.0", "motion": "^12.40.0", "next-themes": "^0.4.6", "react-remove-scroll": "^2.7.2", "rehype-raw": "^7.0.0", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^4.2.0", "tailwind-merge": "^3.6.0", "unist-util-visit": "^5.1.0" }, "peerDependencies": { "@takumi-rs/image-response": "*", "@types/mdx": "*", "@types/react": "*", "fumadocs-core": "16.10.5", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0" }, "optionalPeers": ["@takumi-rs/image-response", "@types/mdx", "@types/react", "next"] }, "sha512-vd69ckYx/4a1aoJTCUJ5LBkqNeOFxm3r+8SK9bVYaeHJrY/n8+4W6b0soqxVqgj1UwNmgovoAg0vlsYmSxZBgQ=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
@@ -2355,56 +2355,6 @@
|
||||
|
||||
"@quansync/fs/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],
|
||||
|
||||
"@radix-ui/react-accordion/@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.9", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zuSVi7ziP7uQRqc+yGxsKJfNkdyHv3ZKDaHe0gzg4dRgws96TPKWIiz84tVHP4GEcEl8bC0mdt17NkcxaJHmaQ=="],
|
||||
|
||||
"@radix-ui/react-accordion/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.5", "", { "dependencies": { "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA=="],
|
||||
|
||||
"@radix-ui/react-alert-dialog/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw=="],
|
||||
|
||||
"@radix-ui/react-collapsible/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.5", "", { "dependencies": { "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA=="],
|
||||
|
||||
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-escape-keydown": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.9", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9Se8t+Zry+1rEOL7Y6l/4ANYU/TOtAtf8O2fKdwLltcaMcm6kOqYGbzO4tMFQ0bvzO920pRAoHpFZ4W85S3keQ=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UEytdjgEh2tJGgD/gZK4FUx6t1rNIlM3U0DENhSrG7I75FGm1DnaDuVUWF1pWAWUwGmn1sCJ1VGHn8LhN1aTOw=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.5", "", { "dependencies": { "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA=="],
|
||||
|
||||
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
|
||||
|
||||
"@radix-ui/react-navigation-menu/@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.9", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zuSVi7ziP7uQRqc+yGxsKJfNkdyHv3ZKDaHe0gzg4dRgws96TPKWIiz84tVHP4GEcEl8bC0mdt17NkcxaJHmaQ=="],
|
||||
|
||||
"@radix-ui/react-navigation-menu/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-escape-keydown": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg=="],
|
||||
|
||||
"@radix-ui/react-navigation-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.5", "", { "dependencies": { "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA=="],
|
||||
|
||||
"@radix-ui/react-navigation-menu/@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.5", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tPcHNI3FajdDBFpl/Ez1m2WL0ufJqBKyHxMDBvKitopamK36WwBGOMicuMEZKkM5Wce41QxUyv6BsiqfrWBiGg=="],
|
||||
|
||||
"@radix-ui/react-popover/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-escape-keydown": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg=="],
|
||||
|
||||
"@radix-ui/react-popover/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.9", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9Se8t+Zry+1rEOL7Y6l/4ANYU/TOtAtf8O2fKdwLltcaMcm6kOqYGbzO4tMFQ0bvzO920pRAoHpFZ4W85S3keQ=="],
|
||||
|
||||
"@radix-ui/react-popover/@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.0", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.9", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-rect": "1.1.2", "@radix-ui/react-use-size": "1.1.2", "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9PB589e1aWZbrlFUHdz6WiPCL+xLZHQFX7oibqG/6Q0SwOkxDyQX9W/cyPa+sAPPKuC8cpLCpRczE5a/1DiwVQ=="],
|
||||
|
||||
"@radix-ui/react-popover/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UEytdjgEh2tJGgD/gZK4FUx6t1rNIlM3U0DENhSrG7I75FGm1DnaDuVUWF1pWAWUwGmn1sCJ1VGHn8LhN1aTOw=="],
|
||||
|
||||
"@radix-ui/react-popover/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.5", "", { "dependencies": { "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA=="],
|
||||
|
||||
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
|
||||
|
||||
"@radix-ui/react-scroll-area/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.5", "", { "dependencies": { "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA=="],
|
||||
|
||||
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
|
||||
|
||||
"@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.5", "", { "dependencies": { "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA=="],
|
||||
|
||||
"@radix-ui/react-tabs/@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.9", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FvgPt1bRmg8Xt2QpF7NUZW3dE0ZQHGm41dAdgT2J2GJPoIXz+9Em3NobAxf4fupcxhgHu03E5CRiU2MWvObXyg=="],
|
||||
|
||||
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
|
||||
|
||||
"@rollup/plugin-commonjs/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"@rollup/plugin-inject/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
@@ -2465,6 +2415,8 @@
|
||||
|
||||
"ast-kit/@babel/parser": ["@babel/parser@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" }, "bin": "./bin/babel-parser.js" }, "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ=="],
|
||||
|
||||
"fumadocs-ui/lucide-react": ["lucide-react@1.21.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ=="],
|
||||
|
||||
"h3/cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="],
|
||||
|
||||
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
@@ -2487,22 +2439,6 @@
|
||||
|
||||
"payload/@next/env": ["@next/env@15.5.19", "", {}, "sha512-sWWluFvcv5v3Fxznmf2ZfjyoVQt/64oCnYqS90inQWGzMPK1VjvekPiz3OPHKmFT30EnHrjlbyaHLt3M0vWabw=="],
|
||||
|
||||
"radix-ui/@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collapsible": "1.1.14", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-iE8YB9nmTBH8zd73ofBISZ8JCzgMoMkATJr7qDwa6u5F1+7mTM81V6fa71jgZ65rpjVpecDf1vSnwIFP9Ly1zw=="],
|
||||
|
||||
"radix-ui/@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9bT+FvifX1FK2Mj6UEsTdyu0cN3JaA3KdfhaBao+ONrYFy/pyOy3TU1TNw7iOk1o+0hOEq67RojlUUmoFGwxyA=="],
|
||||
|
||||
"radix-ui/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw=="],
|
||||
|
||||
"radix-ui/@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-nJ0SkrSQgudyYhMiYeHA1ayLVuduEJCFLan1RZZN7c9kqzzCFLaU9kuy81uNtqzweM9YaQPgWzxi9MwQ9jZ04g=="],
|
||||
|
||||
"radix-ui/@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.1", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g=="],
|
||||
|
||||
"radix-ui/@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.12", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-xuafVzQiTCLsyEjakowTdG3OgTXsmO7IdCiO77otIa+z44xoLNs9Do5eg7POFumIOCjtG6djfm6RKUKpUa/csA=="],
|
||||
|
||||
"radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
|
||||
|
||||
"radix-ui/@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-roving-focus": "1.1.13", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg=="],
|
||||
|
||||
"radix-vue/@vueuse/core": ["@vueuse/core@10.11.1", "", { "dependencies": { "@types/web-bluetooth": "^0.0.20", "@vueuse/metadata": "10.11.1", "@vueuse/shared": "10.11.1", "vue-demi": ">=0.14.8" } }, "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww=="],
|
||||
|
||||
"radix-vue/@vueuse/shared": ["@vueuse/shared@10.11.1", "", { "dependencies": { "vue-demi": ">=0.14.8" } }, "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA=="],
|
||||
@@ -2565,12 +2501,6 @@
|
||||
|
||||
"@payloadcms/richtext-lexical/mdast-util-mdx-jsx/mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
|
||||
|
||||
"@radix-ui/react-alert-dialog/@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
|
||||
|
||||
"@radix-ui/react-popover/@radix-ui/react-popper/@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-yqHW5WQ/cTpU/un7dqqIKNy2iRU8BC0JB78PEzTfCCYvZu1U6W9KwObAniMk9nhSfyotKPQTYaUD/HB0f5muig=="],
|
||||
|
||||
"@radix-ui/react-tabs/@radix-ui/react-roving-focus/@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.9", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zuSVi7ziP7uQRqc+yGxsKJfNkdyHv3ZKDaHe0gzg4dRgws96TPKWIiz84tVHP4GEcEl8bC0mdt17NkcxaJHmaQ=="],
|
||||
|
||||
"@scalar/icons/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"archiver-utils/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
"@tanstack/react-start": "^1.121.0",
|
||||
"@unom/style": "^0.4.4",
|
||||
"@unom/ui": "^0.8.16",
|
||||
"fumadocs-core": "^16.10.1",
|
||||
"fumadocs-ui": "^16.10.1",
|
||||
"fumadocs-core": "^16.10.5",
|
||||
"fumadocs-ui": "^16.10.5",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { ApiReferenceReact } from '@scalar/api-reference-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
// @scalar/api-reference-react@0.9.47's entry does NOT import its own stylesheet
|
||||
// (and doesn't inject it at runtime), so we must ship it ourselves or the
|
||||
// reference renders unstyled. Load it as a route-scoped <link> (same pattern as
|
||||
@@ -148,15 +147,24 @@ body.light-mode {
|
||||
`
|
||||
|
||||
function ApiReference() {
|
||||
// Follow the docs' own light/dark switch (Fumadocs drives next-themes). Scalar
|
||||
// has no way to auto-detect the host theme, so we feed it the resolved theme
|
||||
// and hide its own toggle — the Fumadocs toggle stays the single source of
|
||||
// truth. `mounted` avoids a hydration flash (resolvedTheme is undefined on the
|
||||
// server); default to dark to match the docs' default.
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
const isDark = !mounted || resolvedTheme !== 'light'
|
||||
// Follow the docs' own light/dark switch and hide Scalar's own toggle, so the
|
||||
// Fumadocs toggle stays the single source of truth. Fumadocs drives next-themes
|
||||
// with `attribute: "class"`, which writes the resolved theme as a class on
|
||||
// <html> — we read THAT class directly rather than next-themes' useTheme().
|
||||
// The class is the authoritative, already-resolved signal (system → light/dark
|
||||
// included) and, unlike the React context, can't be desynced when bridging into
|
||||
// Scalar's separate Vue app. Default to dark (the docs default) so SSR and the
|
||||
// first client render agree — no hydration flash; the observer then syncs to the
|
||||
// live class, tracking the docs toggle AND OS changes while in system mode.
|
||||
const [isDark, setIsDark] = useState(true)
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
const sync = () => setIsDark(root.classList.contains('dark'))
|
||||
sync()
|
||||
const observer = new MutationObserver(sync)
|
||||
observer.observe(root, { attributes: true, attributeFilter: ['class'] })
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// Scalar pollutes global scope and never cleans up: it appends a persistent
|
||||
// <style id="scalar-style"> to <head> that includes a *global*
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
# Game library: more game stores
|
||||
|
||||
Status: **design / not started** · Author research: web-backed, adversarially verified (2026-06-26).
|
||||
|
||||
Goal: extend the unified game library so it enumerates and launches titles from more stores —
|
||||
on **Windows** Xbox / Game Pass, Epic, EA app (and GOG / Ubisoft / Battle.net / Amazon);
|
||||
on **Linux** Heroic (Epic+GOG+Amazon), Lutris, and a `.desktop`/Flatpak catch-all.
|
||||
|
||||
---
|
||||
|
||||
## 1. Where the extension point already is
|
||||
|
||||
The library lives in [`crates/punktfunk-host/src/library.rs`](../crates/punktfunk-host/src/library.rs)
|
||||
and is already a plug-in system — its own doc comment names these exact targets. Adding a store is
|
||||
a new `LibraryProvider`, not a rewrite.
|
||||
|
||||
```rust
|
||||
pub trait LibraryProvider {
|
||||
fn store(&self) -> &'static str; // "steam", ...
|
||||
fn list(&self) -> Vec<GameEntry>; // best-effort: empty (not Err) if the store is absent
|
||||
}
|
||||
pub struct GameEntry { id: String /* "<store>:<localid>" */, store, title, art: Artwork, launch: Option<LaunchSpec> }
|
||||
pub struct Artwork { portrait, hero, logo, header: Option<String> } // URLs the CLIENT fetches
|
||||
pub struct LaunchSpec{ kind: String, value: String } // today: "steam_appid" | "command"
|
||||
```
|
||||
|
||||
Today: `SteamProvider` (reads local `.acf` / `.vdf` files — **no API key, no network**) plus a
|
||||
user-curated `custom` store. `all_games()` merges them; `launch_command(id)` resolves a
|
||||
store-qualified id **against the host's own library** and maps the `LaunchSpec` to a shell command,
|
||||
with injection guards (`steam_appid` is validated digits-only; the client never sends a raw command).
|
||||
|
||||
**The "read the launcher's own on-disk files, no auth" approach is the gold standard we replicate per store.**
|
||||
|
||||
Surfaces touched by adding stores:
|
||||
- `library.rs` — new providers (the bulk of the work is small per store).
|
||||
- [`mgmt.rs`](../crates/punktfunk-host/src/mgmt.rs) `:1138` — serves `/library`; OpenAPI-generated TS client picks up new stores as data.
|
||||
- [`web/src/sections/Library/view.tsx`](../web/src/sections/Library/view.tsx) — the grid; **store badge is hard-coded** steam-vs-custom, needs generalizing per `game.store`.
|
||||
- Launch wiring: [`punktfunk1.rs`](../crates/punktfunk-host/src/punktfunk1.rs) `:573` (native) and [`gamestream/stream.rs`](../crates/punktfunk-host/src/gamestream/stream.rs) `:122` (Moonlight).
|
||||
|
||||
> The legacy GameStream `apps.json` ([`gamestream/apps.rs`](../crates/punktfunk-host/src/gamestream/apps.rs))
|
||||
> is a **separate** Moonlight surface (session recipes: compositor + nested command) and stays as-is.
|
||||
|
||||
---
|
||||
|
||||
## 2. The two cross-cutting pieces (this is the real work)
|
||||
|
||||
Per-store enumeration is mostly easy. Two shared problems gate everything — especially Windows.
|
||||
|
||||
### 2a. Launch abstraction + the Windows launch gap
|
||||
|
||||
- **Linux** runs the chosen title as a shell command **nested in the per-session gamescope**
|
||||
(`set_launch_command` / `PUNKTFUNK_GAMESCOPE_APP`). Works today.
|
||||
- **Windows** captures the whole desktop (DXGI/WGC); there is no nesting, and
|
||||
`VirtualDisplay::set_launch_command` is a **no-op** ([`vdisplay.rs:57`](../crates/punktfunk-host/src/vdisplay.rs)).
|
||||
So on Windows **nothing is auto-started** — the user just sees the desktop.
|
||||
|
||||
**Plan.** Stop returning a single Linux shell string from `command_for`; introduce an internal enum and
|
||||
an OS-aware resolver:
|
||||
|
||||
```rust
|
||||
enum LaunchAction { Shell(String), Spawn { exe: PathBuf, args: Vec<String>, workdir: Option<PathBuf> } }
|
||||
fn resolve_launch(&LaunchSpec) -> Option<LaunchAction> // cfg-aware
|
||||
fn launch_command(id) -> Option<String> // Linux: thin Shell wrapper (back-compat)
|
||||
#[cfg(windows)] fn launch_title(id) -> Result<()> // resolve Spawn + run in interactive session
|
||||
```
|
||||
|
||||
**The Windows launcher already exists in the codebase — reuse it.**
|
||||
[`capture/windows/wgc_relay.rs:196-204`](../crates/punktfunk-host/src/capture/windows/wgc_relay.rs)
|
||||
does exactly the needed sequence:
|
||||
`WTSGetActiveConsoleSessionId → WTSQueryUserToken → DuplicateTokenEx(TokenPrimary) →
|
||||
CreateEnvironmentBlock → CreateProcessAsUserW(lpDesktop="winsta0\\default")`.
|
||||
|
||||
- Factor that into `windows/interactive.rs::spawn_in_active_session(exe, args, workdir) -> u32`.
|
||||
- **Critical:** use the **logged-in user token** (`WTSQueryUserToken`, as `wgc_relay` does) — **not**
|
||||
`windows/service.rs:449-510`'s variant, which duplicates the **SYSTEM** token and only retargets its
|
||||
session id. UWP/appx activation, the user-hive protocol handlers (`HKCU\Software\Classes`), and each
|
||||
launcher's auth/entitlement context all require the *real user's* token. The host process stays SYSTEM.
|
||||
- For URI-handoff kinds (Epic/Steam/EA/Amazon/GOG-Galaxy) build a **concrete EXE + the URI as a separate
|
||||
argv element**. `CreateProcessAsUserW` does **no** shell/protocol resolution — never `cmd /c`, never a
|
||||
bare URI. For schemes with no exe-argv form (`amazon-games://`, `origin2://`), add an impersonate-token
|
||||
`ShellExecuteEx` fallback (`ImpersonateLoggedOnUser` on a worker thread + `CoInitialize`).
|
||||
- **Order:** launch the title **after** the interactive capture pipeline is live, so the game renders onto
|
||||
the already-captured desktop and grabs foreground.
|
||||
- **Caveats:** `WTSQueryUserToken` fails when no interactive user is logged on (a pre-login box can stream
|
||||
the login/secure desktop but can't auto-launch a title); on the lock/secure desktop a launch may queue
|
||||
until unlock. **Needs on-glass validation** (RTX box) that each launcher EXE accepts its URI on argv and
|
||||
that post-capture launch grabs foreground.
|
||||
|
||||
### 2b. Artwork: a layered, no-auth-first `ArtResolver`
|
||||
|
||||
Steam gets free CDN art keyed by appid. Most stores don't. Layered ladder, degrade to a title-only card:
|
||||
|
||||
1. **Steam** → public Steam CDN by appid (unchanged, client fetches directly).
|
||||
2. **Stores that already hold public CDN URLs** → emit verbatim, **no host endpoint**: Heroic
|
||||
`store_cache` `art_*` (Epic/GOG/Amazon CDN), itch `cover_url`, GOG via public `api.gog.com/products/<id>?expand=images`
|
||||
(one cached lookup), Epic via local `catcache.bin` keyImages.
|
||||
3. **Xbox** → one **unofficial** no-auth `displaycatalog.mp.microsoft.com` lookup by StoreId, cached,
|
||||
degrade to no-art offline. (Not a stable contract — tolerate drift.)
|
||||
4. **Genuinely-local art** (Lutris `coverart`/`banners` JPEGs, Flatpak/.desktop icons, Bottles) → a
|
||||
**new host-served endpoint is required**, because `Artwork` carries URLs the client fetches and a file
|
||||
on the host has no public URL.
|
||||
5. **Opt-in SteamGridDB** enrichment (v2 API `https://www.steamgriddb.com/api/v2`, `Authorization: Bearer
|
||||
<operator key>`, **off by default**) to fill gaps. Not no-auth; never blocks listing.
|
||||
6. **None** → existing title-only card.
|
||||
|
||||
**New endpoint:** `GET /library/art/<entryId>/<slot>` (slot ∈ `portrait|hero|logo|header`) on `mgmt.rs`.
|
||||
It resolves `entryId` in the host library to a **known on-disk absolute path** (never interpolates raw
|
||||
client input into a filesystem path), sanitizes the slot, rejects `..`, streams the bytes with the right
|
||||
content-type. Reserve `data:` URLs for tiny logos only (don't bloat the catalog JSON that crosses the
|
||||
control plane). See open question on whether this GET bypasses the mgmt bearer (images are non-sensitive
|
||||
and the streaming client connects over punktfunk/1, not the bearer-gated REST).
|
||||
|
||||
---
|
||||
|
||||
## 3. Security model (preserved and extended)
|
||||
|
||||
The invariant is unchanged: **the client sends only a store-qualified `GameEntry.id`** (e.g. `lutris:42`,
|
||||
`xbox:9NBLGGH4R315`, `epic:fn:4fe…:Fortnite`) in `Hello.launch`. The host looks it up in its **own**
|
||||
enumerated library, reads the **host-derived** `LaunchSpec`, and resolves it. The client never sends a
|
||||
`LaunchSpec`, command, URI, or path.
|
||||
|
||||
Per-kind charset validators are belt-and-suspenders before any interpolation (values are already
|
||||
host-derived from local files the host owns):
|
||||
|
||||
| kind | guard |
|
||||
|---|---|
|
||||
| `steam_appid`, `lutris_id`, `uplay` | digits only |
|
||||
| `battlenet` | `^[A-Za-z0-9]+$` (case-sensitive) |
|
||||
| `amazon` | `^[A-Za-z0-9-]+$` |
|
||||
| `aumid` | `^[A-Za-z0-9._-]+![A-Za-z0-9._-]+$` (the `!` separator) |
|
||||
| `epic` | ≤3 `:`-split parts, each `^[A-Za-z0-9._-]+$`, then URL-encode colons |
|
||||
| `heroic` | runner ∈ {legendary,gog,nile} + appName `^[A-Za-z0-9._-]+$` |
|
||||
| `ea_offer_ids` | `^[A-Za-z0-9._,-]+$` (allow comma) |
|
||||
|
||||
On **Windows never route a client-influenced string through `cmd /c start`.** `resolve_launch` yields
|
||||
`Spawn{exe,args,workdir}`; `CreateProcessAsUserW` launches a concrete EXE with the URI/flags as separate
|
||||
argv elements. The operator-only `command` kind (custom store + provider-generated Linux shell lines for
|
||||
`desktop`/`itch`) is host-derived/operator-typed, never client-set.
|
||||
|
||||
The one net-new surface is `GET /library/art` — covered in §2b (id-resolved path, no traversal).
|
||||
|
||||
---
|
||||
|
||||
## 4. New `LaunchSpec` kinds
|
||||
|
||||
| kind | value holds | maps to |
|
||||
|---|---|---|
|
||||
| `lutris_id` | `pga.db` `games.id` (digits) | Linux Shell `lutris lutris:rungameid/<id>` (nests in gamescope) |
|
||||
| `heroic` | `<runner>:<appName>` | Linux argv `heroic --no-gui "heroic://launch?appName=<app>&runner=<runner>"` |
|
||||
| `aumid` | `<PFN>!<AppId>` | Windows Spawn `explorer.exe "shell:AppsFolder\<aumid>"` (interactive session) |
|
||||
| `epic` | `<namespace>:<catalogItemId>:<appName>` | Windows Spawn `EpicGamesLauncher.exe` + `com.epicgames.launcher://apps/<ns>%3A<cat>%3A<app>?action=launch&silent=true` |
|
||||
| `gog` | host-resolved `exe \t args \t workdir` | Windows Spawn `CreateProcessAsUserW(exe,args,workdir)` (direct exe, no Galaxy) |
|
||||
| `uplay` | Ubisoft gameId (digits) | Windows `uplay://launch/<gameId>/0` |
|
||||
| `battlenet` | product code (e.g. `WTCG`, `Fen`, `OSI`) | Windows Spawn `Battle.net.exe --exec="launch <code>"` |
|
||||
| `amazon` | Amazon Games `DbSet.Id` | Windows `amazon-games://play/<Id>` (impersonate ShellExecute) |
|
||||
| `ea_offer_ids` | comma-joined contentID list | Windows `origin2://game/launch/?offerIds=<list>&autoDownload=1` |
|
||||
| `command` (existing) | host-derived shell line | Linux gamescope-nested (desktop/flatpak/itch reuse this) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Per-store provider catalog
|
||||
|
||||
Confidence is **after** adversarial web-verification (research → verify). All enumeration is no-auth,
|
||||
local, launcher-need-not-be-running unless noted.
|
||||
|
||||
### Linux
|
||||
|
||||
#### Lutris — P0, effort M, confidence **high**
|
||||
- **Enumerate:** read-only `rusqlite` open of `pga.db`
|
||||
(`$XDG_DATA_HOME/lutris` | `~/.local/share/lutris` | `~/.var/app/net.lutris.Lutris/data/lutris`).
|
||||
`SELECT id, slug, name, runner FROM games WHERE installed=1`. Optionally LEFT JOIN
|
||||
`games_categories`/`categories` to drop the `.hidden` category. Open `mode=ro`/`immutable=1` (Lutris
|
||||
holds it open). `installed=1` matters — the DB also lists owned-but-not-installed rows.
|
||||
- **Launch:** `lutris_id` → `lutris lutris:rungameid/<id>` (execs the game; most nesting-friendly).
|
||||
One-time on-box check that `games.id` == the `rungameid` int.
|
||||
- **Artwork:** **local** JPEGs keyed by slug — `coverart/<slug>.jpg` (→ portrait), `banners/<slug>.jpg`
|
||||
(→ header) under `~/.local/share/lutris` (0.5.18+), with `~/.cache/lutris` (≤0.5.17) and the Flatpak
|
||||
cache as fallbacks. Needs the `/library/art` endpoint. hero/logo stay None.
|
||||
- **Notes:** highest-confidence new store. A `runner=='steam'` row can duplicate `SteamProvider` — dedup
|
||||
is a nicety. Verify bundled-SQLite is fine for deb/rpm/flatpak.
|
||||
|
||||
#### Heroic — P0, effort M, confidence **high** (one provider = Epic + GOG + Amazon, art free)
|
||||
- **Enumerate:** parse `~/.config/heroic/store_cache/{legendary,gog,nile}_library.json` (Flatpak:
|
||||
`~/.var/app/com.heroicgameslauncher.hgl/config/heroic/...`). Data key is `"library"` (legendary/nile)
|
||||
or `"games"` (gog); ignore `__timestamp.*` siblings. Filter `is_installed==true` **and** cross-check
|
||||
`install.install_path` exists (works around the gog `is_installed` bug, Heroic #2691). Fall back to
|
||||
`legendaryConfig/legendary/installed.json` etc. when a cache file is absent.
|
||||
*(Heroic uses `legendaryConfig/legendary`, **not** the standalone `~/.config/legendary`.)*
|
||||
- **Launch:** `heroic` → `heroic --no-gui "heroic://launch?appName=<app>&runner=<runner>"` (argv, no shell).
|
||||
`--no-gui` does the suppression; the `gui=false` query param is **inert/fabricated** — drop it.
|
||||
**Ship enumeration+art first, gate launch:** Heroic is single-instance Electron — if already running it
|
||||
forwards the URI and **exits**, which (as gamescope's foreground child) would tear the session down while
|
||||
the game runs **outside** gamescope, uncaptured. Also Electron needs a display — fine nested in gamescope,
|
||||
not in a bare headless context.
|
||||
- **Artwork:** **free** — `art_square` → portrait, `art_cover` → header, `art_background`||`art_cover` →
|
||||
hero, `art_logo` → logo are already public Epic/GOG/Amazon CDN URLs. Skip non-`http(s)` values
|
||||
(sideloaded `file://` art). No host endpoint.
|
||||
- **Notes:** do **not** also build separate Linux GOG/Amazon providers — native Linux GOG Galaxy doesn't
|
||||
exist; Heroic is the canonical Linux path for those.
|
||||
|
||||
#### Desktop (`.desktop` + Flatpak) — P1, effort M, confidence medium (universal catch-all)
|
||||
- **Enumerate:** scan `{/var/lib/flatpak/exports/share/applications,
|
||||
~/.local/share/flatpak/.../applications, /usr/share/applications, /usr/local/share/applications,
|
||||
~/.local/share/applications}/*.desktop`. Require `Type=Application` + `Categories` contains `Game`; skip
|
||||
`NoDisplay`/`Hidden`/`Terminal=true` and known launcher app-ids (Steam/Heroic/Lutris/Bottles/RetroArch)
|
||||
to avoid recursion/dupes.
|
||||
- **Launch:** reuse `command` (host-derived shell line, nested in gamescope): cleaned `Exec` (strip
|
||||
`%U/%F/%f/%u/%i/%c/%k`) else `flatpak run <app-id>`.
|
||||
- **Artwork:** local — resolve `Icon=` via the hicolor theme / flatpak exported icons → `/library/art`.
|
||||
App icons are low-res, not box art (acceptable header fallback).
|
||||
- **Notes:** run **last** and dedup by install path / drop ids already surfaced by Steam/Heroic/Lutris.
|
||||
|
||||
#### itch.io — P3, effort S, confidence medium (Linux + Windows)
|
||||
- **Enumerate:** read-only `rusqlite` of `butler.db` (`~/.config/itch/db/butler.db`; Flatpak
|
||||
`io.itch.itch`; Windows `%AppData%\itch\db`, per-user). JOIN `caves`→`games`. **Key on `cave.ID`** (a
|
||||
game can have multiple caves; install location + verdict are per-cave). Read game title / `cover_url`;
|
||||
resolve install dir from `InstallLocationID`+`InstallFolderName`||`CustomInstallFolder` + the Verdict
|
||||
candidate. Confirm exact column names on-box.
|
||||
- **Launch:** `command` → direct binary `basePath`+`candidate.path`, **only** for Verdict candidates with
|
||||
`flavor==native` (html/jar/love need itch's runtime — fall back to custom).
|
||||
- **Artwork:** **free** — `games.cover_url` is a public itch CDN URL.
|
||||
|
||||
### Windows
|
||||
|
||||
#### Epic Games Store — P1, effort M, confidence medium (cleanest Windows store to validate the launch wiring)
|
||||
- **Enumerate:** read `C:\ProgramData\Epic\EpicGamesLauncher\Data\Manifests\*.item` (JSON; machine-wide,
|
||||
SYSTEM-readable, launcher need not run). Read `DisplayName`, `AppName`, `CatalogNamespace`,
|
||||
`CatalogItemId`, `InstallLocation`, `LaunchExecutable`, `MainGameAppName`, `AppCategories`. Iterate the
|
||||
dir (filename is a random GUID).
|
||||
**Use Playnite's EXCLUSION filter, not a positive `games` filter:** skip `AppName` starting `UE_`; skip
|
||||
DLC only when `AppCategories` has `addons` && **not** `addons/launchable`; require `InstallLocation`
|
||||
exists. (The first-pass positive filter `games + MainGameAppName==AppName` can drop legit games.)
|
||||
- **Launch:** `epic` → Spawn `EpicGamesLauncher.exe` + `com.epicgames.launcher://apps/<ns>%3A<cat>%3A<app>?action=launch&silent=true`.
|
||||
Build the **triple** only when both namespace and CatalogItemId are present; otherwise **fall back to the
|
||||
bare `appName` URI (don't set launch=None)** — bare still works in Playnite today, it's just less robust.
|
||||
CatalogItemId is **not** present in every `.item` — verify on a real box.
|
||||
- **Artwork:** **free** — base64-decode + parse `Data\Catalog\catcache.bin`, index by catalogItemId, map
|
||||
keyImages `DieselGameBoxTall`→portrait, `DieselGameBox`→hero, `DieselGameBoxLogo`→logo. None on miss.
|
||||
- **Notes:** `.item` + `catcache.bin` are community-RE'd; `silent=true` may not suppress a cold-start
|
||||
launcher window.
|
||||
|
||||
#### GOG — P1, effort M, confidence medium
|
||||
- **Enumerate:** registry `HKLM\SOFTWARE\WOW6432Node\GOG.com\Games\<id>` (PATH/GAMENAME/gameID/EXE) or
|
||||
Uninstall `<id>_is1` keys with `Publisher=='GOG.com'` (exclude `GOGPACK*`). Parse
|
||||
`<PATH>\goggame-<id>.info` for `playTasks[isPrimary && type=='FileTask']` → exe/args/workingDir.
|
||||
- **Launch:** `gog` → **direct-exe** Spawn (no Galaxy dependency, dodges cold-start/anti-cheat). Optional
|
||||
fallback: `GalaxyClient.exe /launchViaAutostart /gameId=<id> /command=runGame /path="<dir>"` (note the
|
||||
`/launchViaAutostart` token; `goggalaxy://openGameView/<id>` only **opens the page**, doesn't launch).
|
||||
- **Artwork:** **free** — public no-auth `GET https://api.gog.com/products/<id>?expand=images` →
|
||||
`images.logo2x`/`verticalCover`/`background`; cache resolved URLs. (`goggame-.info` carries no art; the
|
||||
Galaxy `galaxy-2.0.db` is undocumented/locked — avoid.)
|
||||
|
||||
#### Xbox / Microsoft Store / Game Pass — P1, effort **L**, confidence medium (big Game Pass value, most plumbing)
|
||||
- **Enumerate:** probe each fixed drive for an `XboxGames` dir (default `C:\XboxGames`; the `.GamingRoot`
|
||||
binary layout is **undocumented** — just scan, don't depend on parsing it). For each
|
||||
`<Title>\Content\MicrosoftGame.config` (**presence = it's a GDK game**, the game-vs-app signal) read
|
||||
`ShellVisuals.DefaultDisplayName` (title), `<StoreId>` (12-char BigId, the art key), `Identity Name`,
|
||||
`<Executable Id="Game">` (the AppId). **Read the PackageFamilyName from the
|
||||
`C:\ProgramData\Microsoft\Windows\AppRepository\Packages\<PackageFullName>` directory name** (strip
|
||||
`_Version_Arch_~_PublisherHash`) — **never compute the PFN by hashing the publisher**. AUMID = `PFN!AppId`.
|
||||
- **Launch:** `aumid` → `explorer.exe shell:AppsFolder\<AUMID>` into the interactive session. **UWP
|
||||
activation fails from SYSTEM/session-0 — the interactive user token is load-bearing.**
|
||||
- **Artwork:** one **unofficial** no-auth lookup
|
||||
`displaycatalog.mp.microsoft.com/v7.0/products/<StoreId>?market=US&languages=en-us&fieldsTemplate=Details`,
|
||||
map `Images[]` ImagePurpose Poster→portrait / SuperHeroArt→hero / Logo→logo / BoxArt→header; cache to
|
||||
the config dir, degrade to no-art offline. Not a stable contract.
|
||||
- **Notes:** misses pure-UWP (non-GDK) Store games under the ACL-locked `WindowsApps` — accept for v1.
|
||||
|
||||
#### Ubisoft Connect — P2, effort S, confidence medium
|
||||
- **Enumerate:** registry `HKLM\SOFTWARE\WOW6432Node\Ubisoft\Launcher\Installs\<gameId>` (both reg views),
|
||||
read `InstallDir`; title = install-dir leaf folder (primary) else the `Uplay Install <gameId>` Uninstall
|
||||
`DisplayName`.
|
||||
- **Launch:** `uplay` → `uplay://launch/<gameId>/0`. **Artwork:** none → title-only.
|
||||
- **Notes:** smallest effort once the Windows URI-launch wiring exists; hive+scheme unchanged across the
|
||||
Origin→EA migration.
|
||||
|
||||
#### Amazon Games — P2, effort S, confidence medium
|
||||
- **Enumerate:** read-only `rusqlite` of
|
||||
`%LocalAppData%\Amazon Games\Data\Games\Sql\GameInstallInfo.sqlite`:
|
||||
`SELECT Id,ProductTitle,InstallDirectory FROM DbSet WHERE Installed=1`. **Per-user path** — the SYSTEM
|
||||
service must resolve the **active session user's** profile (not the SYSTEM profile).
|
||||
- **Launch:** `amazon` → `amazon-games://play/<Id>` (impersonate-token ShellExecute; no clean exe-argv form).
|
||||
- **Artwork:** `ProductIconUrl`/`ProductLogoUrl` columns when present, else none.
|
||||
|
||||
#### Battle.net — P2, effort **L**, confidence medium (high catalog value: WoW/Diablo IV/Overwatch 2/CoD)
|
||||
- **Enumerate:** hand-roll a ~4-field protobuf decode of `C:\ProgramData\Battle.net\Agent\product.db`
|
||||
(`product_install{ uid, product_code, settings.install_path, cached_product_state.base_product_state.installed }`).
|
||||
Registry fallback: Uninstall keys whose `UninstallString` matches `Battle.net.exe --uid=<uid>`.
|
||||
`product.db` has **no titles** → maintain a ~30-entry `product_code`→name map (source from
|
||||
bnetlauncher/Lutris/Heroic; codes are **case-sensitive**).
|
||||
- **Launch:** `battlenet` → `Battle.net.exe --exec="launch <code>"` (more reliable than the
|
||||
`battlenet://<code>` URI, which only hands off). **Artwork:** none → title-only.
|
||||
- **Notes:** the protobuf + name map + no-art make it L; pin the `.proto` and decode defensively.
|
||||
|
||||
#### EA app — P2, effort M, confidence medium (most closed/fragile — ship last)
|
||||
- **Enumerate:** registry `HKLM\SOFTWARE\WOW6432Node\{EA Games,Origin Games}\<id>` (Install Dir /
|
||||
DisplayName), parse `<dir>\__Installer\installerdata.xml` for the **full** `<contentIDs>` list +
|
||||
`<gameTitle locale='en_US'>`. Registry under-reports for EA-app (vs legacy Origin) installs — known
|
||||
completeness gap. Keep the AES-256 encrypted `IS`-file decrypt **out** of the default path (optional
|
||||
feature flag for completeness).
|
||||
- **Launch:** `ea_offer_ids` → `origin2://game/launch/?offerIds=<full,comma,list>&autoDownload=1`. **Emit
|
||||
the full contentID list** — a single offerId generally no longer launches under the EA app.
|
||||
- **Artwork:** none no-auth → title-only.
|
||||
|
||||
#### Rockstar — P3, fold into custom
|
||||
- Registry `HKLM\SOFTWARE\WOW6432Node\Rockstar Games\<Title>\InstallFolder`; direct-exe Spawn; no art.
|
||||
Tiny catalog, most titles now bought on Steam/Epic.
|
||||
|
||||
---
|
||||
|
||||
## 6. Suggested structure & phasing
|
||||
|
||||
**Structure.** Split `library.rs` → a `library/` dir before it balloons:
|
||||
`mod.rs` (trait, wire types, `LaunchAction`, custom CRUD, `all_games`, `resolve_launch`,
|
||||
`launch_command`/`launch_title`), `steam.rs`, one file per provider, `art.rs` (ArtResolver +
|
||||
displaycatalog/gog-api/steamgriddb helpers), `win_util.rs` (HKLM subkey enumerator, read-only SQLite
|
||||
opener, tiny read-only XML reader). New deps: `rusqlite` (bundled, read-only) for lutris/itch/amazon DBs;
|
||||
`roxmltree`/`quick-xml` for the Windows manifests; registry via the `windows` crate's
|
||||
`Win32_System_Registry` feature (no new crate). Avoid `prost` — hand-roll the ~4 Battle.net fields.
|
||||
|
||||
| Phase | Deliverable | Files |
|
||||
|---|---|---|
|
||||
| **1 — Foundation** (no new stores) | Split `library.rs` → `library/`; add `LaunchAction` + `resolve_launch`; factor `windows/interactive.rs::spawn_in_active_session` out of `wgc_relay.rs`; make `set_launch_command` real on Windows; wire `launch_title` at session-start post-capture; add `win_util.rs` + deps | `library/{mod,steam,launch,art,win_util}.rs`; `windows/interactive.rs` (new); `capture/windows/wgc_relay.rs`; `punktfunk1.rs:573`; `gamestream/stream.rs:122`; `vdisplay.rs:57`; `main.rs`; `Cargo.toml` |
|
||||
| **2 — Linux Lutris + Heroic + art endpoint** (P0) | `LutrisProvider`, `HeroicProvider` (art free); `GET /library/art/<id>/<slot>` for Lutris local JPEGs; wire into `all_games()`; unit tests for new `resolve_launch` arms + guards | `library/{lutris,heroic,art}.rs`; `library/mod.rs`; `mgmt.rs:1138` + new route |
|
||||
| **3 — Windows Epic + GOG** (P1) | `EpicProvider` (.item + catcache art), `GogProvider` (registry + .info + api.gog.com art); validate `windows/interactive.rs` end-to-end on the RTX box | `library/{epic,gog,win_util,art,launch}.rs` |
|
||||
| **4 — Xbox / Game Pass** (P1) | `XboxProvider` (XboxGames scan + MicrosoftGame.config + AppRepository PFN + aumid launch) + displaycatalog art with caching/offline degrade | `library/{xbox,art,launch}.rs` |
|
||||
| **5 — Linux Desktop catch-all + easy Windows URI stores** (P1/P2) | `DesktopProvider` (last + dedup, icons via `/library/art`), `UplayProvider`, `AmazonProvider` (+ per-user-profile-under-SYSTEM helper) | `library/{desktop,uplay,amazon,win_util,art}.rs` |
|
||||
| **6 — Remaining + opt-in enrichment** (P2/P3) | `BattleNetProvider` (hand-rolled protobuf + code→name map), `EaAppProvider`, `ItchProvider`; Rockstar/Bottles → custom; optional SteamGridDB v2 behind an operator key | `library/{battlenet,eaapp,itch,art,mod}.rs` |
|
||||
|
||||
Also generalize the web console store badge (`web/src/sections/Library/view.tsx`) to render per `game.store`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions
|
||||
|
||||
- **Art delivery auth:** the streaming client connects over punktfunk/1 (QUIC), not the bearer-gated mgmt
|
||||
REST, yet already fetches Steam CDN URLs over plain HTTP. Should `GET /library/art/*` be an
|
||||
unauthenticated read-only image GET on the mgmt listener (bearer bypass for that path only), a separate
|
||||
tiny image server, or should local-art bytes ride the punktfunk/1 control plane?
|
||||
- **Windows launch ordering** needs on-glass RTX-box validation: confirm launching *after* capture is live
|
||||
grabs foreground+capture, and that `CreateProcessAsUserW(EpicGamesLauncher.exe/steam.exe, URI-as-argv)`
|
||||
actually starts the game per launcher (vs needing the impersonate-ShellExecute fallback).
|
||||
- **Per-user-profile resolution under SYSTEM** for Amazon (`%LocalAppData%`) and itch (`%AppData%`): add
|
||||
`WTSQueryUserToken` + `GetUserProfileDirectoryW` (or read `USERPROFILE` from `CreateEnvironmentBlock`)?
|
||||
- **`rusqlite` bundled SQLite** — acceptable for deb/rpm/flatpak and no link conflict? Otherwise fall back
|
||||
to `lutris -l -j` (fragile: single-instance D-Bus forwarding).
|
||||
- **Battle.net** product-code→name map source/maintenance, and `product.db` `.proto` drift across Agent versions.
|
||||
- **Unofficial art sources** (Xbox displaycatalog): best-effort with aggressive caching + no-art degrade,
|
||||
or Xbox-art local-tile-only for v1?
|
||||
- **Heroic launch:** ship enumeration+art only at first, or invest in direct legendary/gogdl/nile CLI
|
||||
launch (needs the user's on-disk auth tokens) to dodge the single-instance-Electron / gamescope-escape problem?
|
||||
- **`config_dir()` consistency:** `library.rs` uses an XDG/HOME-based dir; confirm the Windows SYSTEM host
|
||||
lands its art cache + custom store under `%ProgramData%\punktfunk` (there's a separate
|
||||
`gamestream::config_dir()` that already does this).
|
||||
- Should provider-generated Linux shell lines (`desktop`/`itch`) reuse the `command` kind (documented
|
||||
"operator-only") or get a distinct internal kind to keep the mgmt-UI `command` semantics clean?
|
||||
|
||||
---
|
||||
|
||||
## 8. Verification notes (what the adversarial pass corrected)
|
||||
|
||||
First-pass research was web-re-checked; corrections folded into §5 above:
|
||||
- **Epic:** bare-`AppName` URI is **not** universally removed (Playnite still uses it) — build the triple
|
||||
when ids exist, fall back to bare; use Playnite's **exclusion** filter, not a positive `games` filter.
|
||||
- **EA:** a single offerId no longer launches — emit the **full** comma-joined contentID list; registry
|
||||
under-reports for EA-app installs.
|
||||
- **Battle.net:** `battlenet://<code>` only hands off — use `Battle.net.exe --exec="launch <code>"`.
|
||||
- **Xbox:** **read** the PFN from the AppRepository dir name, don't hash the publisher; `.GamingRoot`
|
||||
layout is undocumented — just scan `XboxGames`.
|
||||
- **Heroic:** `gui=false` is inert (`--no-gui` does it); single-instance Electron forwards-and-exits →
|
||||
gate launch.
|
||||
- **Lutris:** open the DB read-only; `lutris -l -j` fallback is fragile (single-instance D-Bus forwarding).
|
||||
- **SteamGridDB:** v1 is deprecated — use v2 (`/api/v2`, Bearer key).
|
||||
|
||||
**Not web-confirmable / needs on-box validation:** every Windows launch path (each launcher's argv
|
||||
handling, foreground grab, secure-desktop behavior), all registry keys / DB schemas against a live box,
|
||||
and `rusqlite` packaging.
|
||||
@@ -0,0 +1,430 @@
|
||||
# GPU-contention performance investigation — why a saturating game starves the stream (2026-06-25)
|
||||
|
||||
> The headache, stated precisely:
|
||||
> a game renders ~140 fps on the host GPU; the client requests 120/240; in a GPU-light scene the
|
||||
> stream tracks; the moment the game pins the GPU the **stream collapses to 40–50 fps** while the
|
||||
> game keeps rendering 140. Capping the game's fps raises the stream back up (clearest in light
|
||||
> titles like CS2). **Capping is not an acceptable fix** — demanding titles exhaust the GPU even
|
||||
> when capped.
|
||||
|
||||
This is the second, deeper pass on the problem. The first pass is
|
||||
[`host-latency-plan.md`](host-latency-plan.md) (a 25-agent investigation, 2026-06-18). **This doc
|
||||
supersedes several of that doc's conclusions** — the codebase moved a lot in the week since
|
||||
(the Windows-host rewrite landed IDD-push as the default capture path, split-encode shipped, the
|
||||
GPU-priority knob got configurable), and a fresh, adversarially-verified research pass overturned
|
||||
two of the old plan's premises. Read §1 (corrections) before acting on the old doc.
|
||||
|
||||
Method: five parallel investigations — three deep reads of the *current* code (encode, capture,
|
||||
mitigations) and two web-research passes (encoder-side and GPU-scheduling-side), the latter run with
|
||||
their own adversarial verifiers. Every external claim below carries a source URL; every code claim
|
||||
carries a current `file:line`.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR — the corrected mental model and the action list
|
||||
|
||||
**The governing fact:** NVENC is a **dedicated ASIC on its own GPU runlist**, physically separate
|
||||
from the SM/CUDA/graphics cores a 3D game saturates. The game does **not** steal the encode block.
|
||||
It steals everything that *feeds* the block — capture-acquire, the **RGB→YUV colour-convert**, the
|
||||
copy into the encoder's input surface, the readback — **and the GPU-scheduler time** to run that
|
||||
feed work, which is queued behind the game's graphics context.
|
||||
([NVENC app-note](https://docs.nvidia.com/video-technologies/video-codec-sdk/13.0/nvenc-application-note/index.html),
|
||||
[engine-table proof, UNC RTAS'24](https://www.cs.unc.edu/~jbakita/rtas24.pdf))
|
||||
|
||||
**Therefore there are two different bottlenecks with opposite fixes, and you must tell them apart
|
||||
before writing code:**
|
||||
|
||||
| Bottleneck | Symptom | Fix family |
|
||||
|---|---|---|
|
||||
| **(a) feed-scheduling contention** | `uniq`≈`fps`, both ~50; `encode_ms` 13–17 | shrink the host's contended-engine footprint; raise GPU scheduling priority; pipeline correctly; in the limit, a second GPU |
|
||||
| **(b) frame-source ceiling** | `fps`≈240 (held re-encodes) but `uniq`→40–50 | capture the game's real frames (swapchain hook); compose-flip for the DLSS-FG case |
|
||||
|
||||
**The single hardest truth:** on one saturated GPU there is **no free lunch**. Any host GPU work
|
||||
either *preempts* the game (and steals its frames) or *waits* behind it. Capping the game works
|
||||
only because it cuts the game's **total** GPU demand and opens idle gaps. The non-capping
|
||||
equivalents are exactly three: **need less GPU** (footprint shrink), **take more** (priority — which
|
||||
costs the game fps), or **use a different GPU** (real isolation). Anything pitched as "make the game
|
||||
politely yield without losing anything" — Reflex, render-queue tricks — is a **placebo** here (§7).
|
||||
|
||||
**Action list, highest leverage first** (detail in §5–§6):
|
||||
|
||||
1. **Diagnose first** (§3). Read `uniq`-vs-`fps` under the real workload + PresentMon presentation
|
||||
mode. Half a day; decides whether you're fighting (a) or (b). The repo already prints the counter.
|
||||
2. **Stop feeding NVENC RGB on the default path.** IDD-push (the install default) hands NVENC
|
||||
BGRA → NVENC runs its RGB→YUV CSC on the SM, the exact contended engine. Convert to NV12/P010 on
|
||||
the **video engine** like the WGC/DDA paths already do. Biggest in-our-control win. (§5.A)
|
||||
3. **Build a *correct* async encode pipeline** — submit on one thread, blocking-retrieve on another,
|
||||
deep surface pool, Windows completion events. Our past "pipelining didn't help" was a *same-thread*
|
||||
implementation that can't overlap; the two-thread pattern the NVENC guide mandates was never
|
||||
tried. Recovers the depth-1 serialization that produces ~50 fps, up to the priority ceiling. (§5.B)
|
||||
4. **Auto-gated REALTIME GPU priority.** Our `LocalSystem` service *can* grant it (most apps can't).
|
||||
Gate on HAGS-state + VRAM headroom to dodge the documented NVENC freeze. (§5.C)
|
||||
5. **Lock clocks / pin P-state** for jitter (cheap; fixes the light-scene "200-not-240", not the
|
||||
collapse). (§5.E)
|
||||
6. **If source-bound: swapchain-hook capture** (OBS-style) — the real escape from the compose
|
||||
ceiling. Big lift, anti-cheat tradeoffs. (§5.F)
|
||||
7. **The honest endgame for demanding titles: encode on a second GPU / the iGPU.** The only approach
|
||||
that *removes* contention instead of re-prioritizing it. We already have AMF/QSV paths. (§5.G)
|
||||
|
||||
---
|
||||
|
||||
## 1. Corrections to `host-latency-plan.md` (read before reusing it)
|
||||
|
||||
The old doc was right about the shape but several specifics are now wrong or stale:
|
||||
|
||||
- **"Windows already feeds NVENC YUV on the video engine, so it does the right thing."** True for the
|
||||
DDA and WGC paths — **false for IDD-push, which is now the install default** and feeds NVENC
|
||||
**RGB**, paying the SM-side CSC the old doc said Windows had eliminated. The default path
|
||||
*regressed* on the exact axis the doc celebrated. (§5.A, `capture/windows/idd_push.rs:545-551,743`)
|
||||
- **"`PUNKTFUNK_ENCODE_DEPTH` (default 4, ≤6) deep-pipelines."** **There is no such knob.** It exists
|
||||
only in two stale comments (`encode/windows/nvenc.rs:30`, `capture/windows/wgc.rs:57`) and is never
|
||||
parsed. The real depth knob is `PUNKTFUNK_IDD_DEPTH` (default 2), used only by IDD-push on the
|
||||
native path; GameStream and the WGC helper are hardcoded depth-1.
|
||||
- **"Async NVENC is measure-gated and probably stacks latency (Tier 3D)."** The measurement that
|
||||
produced that verdict (`capture/windows/wgc_helper.rs:131-135`) pipelined **on a single thread** —
|
||||
it queued more frames but still blocked `lock_bitstream` inline, so it added queue latency with
|
||||
**zero overlap**. That is not the pattern the NVENC guide prescribes (submit/retrieve on
|
||||
*separate* threads). The correct async pipeline is **untried**, not disproven. (§5.B)
|
||||
- **"More GPU priority is maxed and hits a hard preemption wall with no recourse."** Half right.
|
||||
Priority *is* near-maxed (HIGH), but the "no recourse" intuition is wrong: a **higher-priority GPU
|
||||
context does preempt a saturating graphics context at pixel granularity** — that is precisely how
|
||||
NVIDIA VR Async-TimeWarp injects a frame into a busy game
|
||||
([VRWorks Context Priority](https://developer.nvidia.com/vrworks/headset/contextpriority)). And we
|
||||
default to HIGH, leaving **REALTIME unused** even though our SYSTEM service can grant it. (§5.C)
|
||||
- **"Force Composed Flip / double-refresh recovers the 'capture sees half the frames' loss."** The
|
||||
"half the frames" effect is **specifically a DLSS-Frame-Generation flip-metering artifact**
|
||||
(FG v310.x+ / RTX 50-series), *not* a general property of independent-flip games — normal
|
||||
fullscreen flip games are captured at full rate by DDA. So composed-flip is a **narrow** fix, not a
|
||||
general lever. ([Apollo #676 — DDA captured a flip game at full 120 fps](https://github.com/ClassicOldSong/Apollo/issues/676),
|
||||
[Sunshine #3621 — version-pinned to FG 310.x](https://github.com/LizardByte/Sunshine/issues/3621))
|
||||
- **"NvFBC is a possible low-overhead capture path."** **Dead on Windows** — deprecated, frozen at
|
||||
Capture SDK 7.1 / Win10-1803
|
||||
([NVIDIA deprecation bulletin](https://developer.download.nvidia.com/designworks/capture-sdk/docs/NVFBC_Win10_Deprecation_Tech_Bulletin.pdf)).
|
||||
Linux-only, and there only via the consumer `keylase` patch.
|
||||
|
||||
What the old doc got right and still holds: feeding NVENC RGB is backwards; the source/compose ceiling
|
||||
is real and upstream of encode; split-encode is a pixel-rate lever not a contention lever; the
|
||||
honest residual ceiling at 100% GPU. Those carry forward.
|
||||
|
||||
---
|
||||
|
||||
## 2. How the pipeline actually serializes today (verified against current code)
|
||||
|
||||
The capture→encode loop is a **fixed-cadence pacer** (`gamestream/stream.rs:375-480`,
|
||||
`punktfunk1.rs:2430-2540`): every `1/target_fps` tick it grabs the freshest frame with a
|
||||
**non-blocking** `try_latest()`, and **if nothing new arrived it re-encodes the held frame** (a
|
||||
near-empty P-frame). So the **outbound fps is pinned at `target_fps` no matter what the source did** —
|
||||
which is *why the raw fps counter lies* under contention. The only honest signal is the `uniq` /
|
||||
`diag_new` counter (`stream.rs:380`, `punktfunk1.rs:2433-2436`), and the code itself states the
|
||||
diagnostic: *"low new_fps at high send rate ⇒ the source isn't producing frames, not an encode
|
||||
stall"* (`punktfunk1.rs:2466-2468`).
|
||||
|
||||
The encode round-trip (NVENC, the dominant path):
|
||||
|
||||
- `submit` → `encode_picture` (`encode/windows/nvenc.rs:722`) is a **non-blocking** ASIC launch; it
|
||||
pushes onto a `pending` FIFO.
|
||||
- `poll` → `lock_bitstream` (`nvenc.rs:801`) **blocks the same thread** until that frame's encode
|
||||
completes. The session is **synchronous** — no `enableEncodeAsync`, no completion event.
|
||||
- The only thread split is **encode-vs-network-send**, never submit-vs-retrieve.
|
||||
|
||||
So at depth-1 the loop is strictly serial: `capture (+convert) → submit → block in lock_bitstream →
|
||||
hand AU to the send thread`. The arithmetic matches the symptom — `1000/17 ≈ 59` and `1000/13 ≈ 77`
|
||||
fps bracket the observed ~50, the signature of **one frame in flight per round-trip**, not an ASIC
|
||||
throughput wall.
|
||||
([independent NVENC latency study: ~7 frames across all presets](https://arxiv.org/html/2511.18688v2))
|
||||
|
||||
Where the per-frame GPU work lands, by path (this is the crux of contention):
|
||||
|
||||
| Path | Colour-convert | Extra copy | NVENC input | Contended-engine load/frame |
|
||||
|---|---|---|---|---|
|
||||
| **IDD-push** (install default) | **none → NVENC internal RGB→YUV on the SM** | `CopyResource` BGRA→out-ring (3D), `idd_push.rs:743` | **BGRA/Rgb10a2** | **highest** (SM CSC + 3D copy) |
|
||||
| **WGC** (fallback default) | `VideoProcessorBlt` → NV12 on the **video engine**, `wgc.rs:631` | none (encodes pool texture in place) | NV12/P010 | low |
|
||||
| **DDA** | `VideoProcessorBlt` → NV12 on the **video engine**, `dxgi.rs:1657-1762` | one `CopyResource` (3D) to release the dup fast, `dxgi.rs:3099` | NV12/P010 | medium |
|
||||
| **Linux NVENC** | **none → NVENC internal RGB→YUV on the SM** (default) | CUDA dev→dev copy + `cuStreamSynchronize` | RGBZ/BGRZ (NV12 only if `PUNKTFUNK_NV12` *and* `PUNKTFUNK_ZEROCOPY`) | high |
|
||||
|
||||
Measured magnitude of "RGB vs NV12 to the encoder":
|
||||
[**RGB input ≈ video-engine 40% + 3D/CUDA 15%; NV12 input ≈ video 26% + 3D 2%**](https://hardforum.com/threads/can-someone-explain-to-me-how-nvenc-obs-work-with-nvidia-gpus-and-the-gpu-load-they-cause.2025896/).
|
||||
NVENC's guide confirms the mechanism: *"Encoding of RGB contents"* is on the explicit list of
|
||||
features that **internally use CUDA**
|
||||
([NVENC prog-guide §Encoder Features using CUDA](https://docs.nvidia.com/video-technologies/video-codec-sdk/13.0/nvenc-video-encoder-api-prog-guide/index.html)).
|
||||
|
||||
---
|
||||
|
||||
## 3. Diagnose first — cheap, decisive, do before any code
|
||||
|
||||
Everything in §5 is gated on knowing whether you're fighting bottleneck (a) or (b). The dev VM
|
||||
cannot reproduce this — run on the **RTX 4090 Windows box** (and a real NVIDIA Linux box) with an
|
||||
actual saturating game.
|
||||
|
||||
1. **Run with `PUNKTFUNK_PERF=1` and read `uniq` vs `fps`** under CS2 at GPU-100%:
|
||||
- `fps`≈target but `uniq`→40–50 ⇒ **(b) source ceiling** — the compositor/IDD only produced
|
||||
40–50 unique frames. No encode/priority fix exceeds that number. Go to §5.F.
|
||||
- both `fps` and `uniq`→40–50, with `encode_ms` 13–17 ⇒ **(a) feed contention** — the round-trip
|
||||
is starving. Go to §5.A/B/C.
|
||||
2. **Classify the game's presentation with [PresentMon](https://github.com/GameTechDev/PresentMon)** —
|
||||
"Presented FPS" vs "Displayed FPS" and **Presentation Mode** (Hardware: Independent Flip vs
|
||||
Composed: Flip). Independent-Flip + `uniq` ≪ Presented ⇒ source/flip problem; **Presented FPS
|
||||
itself** collapsed ⇒ the game is genuinely GPU-bound and no capture trick invents the missing
|
||||
frames.
|
||||
3. Log `cap_us` / `enc_us` / `pace_us` p50/p99 alongside to localise the stall.
|
||||
|
||||
> **Necessary-but-not-sufficient caveat:** if the game only *rendered* 50 frames because it's
|
||||
> GPU-bound, **nothing downstream creates the other 90**. Source fixes address (b) only; the
|
||||
> throughput of a saturated single GPU is split between game and host no matter what.
|
||||
|
||||
---
|
||||
|
||||
## 4. Current-state audit (what's shipped / regressed / missing)
|
||||
|
||||
| Area | State | Where |
|
||||
|---|---|---|
|
||||
| Thread priority (Win) | HIGH class + MMCSS "Games" + 1 ms timer | `session_tuning.rs` ✅ |
|
||||
| Thread priority (Linux) | `setpriority` −10/−5 — **native path only; GameStream Linux threads get none** | `punktfunk1.rs:1977` ⚠ |
|
||||
| GPU sched priority | `D3DKMTSetProcessSchedulingPriorityClass` **HIGH(4)** default; `realtime` opt-in, no auto-gate; cross-process onto WGC helper | `capture/windows/dxgi.rs:208-330` ⚠ |
|
||||
| GPU thread/latency | `SetGPUThreadPriority(0x4000001E)`, `SetMaximumFrameLatency(1)` | `dxgi.rs:193-200` ✅ |
|
||||
| CSC off-SM (Win SDR) | WGC/DDA video-engine NV12 ✅ — **IDD-push (default) RGB→SM ✗** | `wgc.rs:631` / `idd_push.rs:545` |
|
||||
| CSC off-SM (Win HDR) | on-SM unless `PUNKTFUNK_HDR_SHADER_P010` (default **off**) | `wgc.rs:603` ⚠ |
|
||||
| CSC off-SM (Linux) | RGB→SM by default; NV12 is **double-opt-in** (`PUNKTFUNK_NV12`+`PUNKTFUNK_ZEROCOPY`) | `encode/linux/mod.rs:104` ⚠ |
|
||||
| Encode pipeline | depth-1 synchronous, inline `lock_bitstream`; IDD-push native = depth-2 same-thread | `nvenc.rs:801` ⚠ |
|
||||
| Split-encode | 2-way >1 Gpix/s (HEVC/AV1); disabled 10-bit (correct); proper enum | `nvenc.rs:424-447` ✅ |
|
||||
| Zero-copy register-in-place | yes (no encoder-owned pool copy) — IDD-push adds its own out-ring copy | `nvenc.rs:623` ✅/⚠ |
|
||||
| AMF tuning | `usage=ultralowlatency`, `preanalysis=false` | `ffmpeg_win.rs:215-219` ✅ |
|
||||
| QSV tuning | `async_depth=1`, `low_power=1` (VDEnc) | `ffmpeg_win.rs:226-227` ✅ |
|
||||
| Intra-refresh / infinite GOP | yes (killed the periodic-IDR freeze) | ✅ |
|
||||
| encode\|send split + paced send + sendmmsg + 32 MB sockbuf | yes | `stream.rs`, `transport/qos.rs` ✅ |
|
||||
| **Clock / P-state pin** | **none** (zero hits repo-wide) | ✗ |
|
||||
| **Async NVENC (2-thread)** | **none** | ✗ |
|
||||
| **Frame-source escape (hook/NvFBC-Linux)** | **none** | ✗ |
|
||||
| **Second-GPU / iGPU encode offload** | **none** | ✗ |
|
||||
| DSCP/QoS | implemented, `PUNKTFUNK_DSCP` opt-in (default off) | `transport/qos.rs` ⚠ |
|
||||
|
||||
---
|
||||
|
||||
## 5. The levers, ranked, with honest verdicts
|
||||
|
||||
### A. Stop feeding NVENC RGB on the default path — **highest in-our-control win**
|
||||
|
||||
The default Windows capture path (IDD-push) and the default Linux path both hand NVENC packed RGB,
|
||||
forcing NVENC's internal RGB→YUV CSC onto the SM the game saturates. The WGC and DDA paths already
|
||||
solved this by doing the CSC with `ID3D11VideoProcessor::VideoProcessorBlt` (video engine) and
|
||||
feeding NV12/P010. **Make IDD-push and Linux do the same.**
|
||||
|
||||
- **Windows IDD-push:** add a `VideoProcessorBlt` BGRA→NV12 (SDR) / FP16→P010 (HDR) step into the
|
||||
out-ring, exactly like `wgc.rs:631` / `dxgi.rs:1657-1762`, and feed `NV_ENC_BUFFER_FORMAT_NV12` /
|
||||
`..._YUV420_10BIT`. This *also* lets you drop the separate `CopyResource` (the convert writes the
|
||||
out-ring), removing **both** contended-engine ops per frame. Plug it into `SessionPlan`
|
||||
(`session_plan.rs`, the single owner of the capture/encode decision) so capture and encode can't
|
||||
disagree on the format.
|
||||
- **Linux:** make NV12 the **default** for the tiled zero-copy path (it's gated behind
|
||||
`PUNKTFUNK_NV12` *and* `PUNKTFUNK_ZEROCOPY` today — `encode/linux/mod.rs:104`,
|
||||
`linux/zerocopy/egl.rs:272`), and feed NVENC `NV_ENC_BUFFER_FORMAT_NV12`. The GL detile already
|
||||
runs; emitting NV12 from it replaces the swizzle at ~equal cost and deletes NVENC's CSC.
|
||||
- **Windows HDR:** flip `PUNKTFUNK_HDR_SHADER_P010` on by default (or, better, use a video-engine
|
||||
P010 convert where the VP supports it).
|
||||
|
||||
**Verdict: REAL, but honestly *conditional*.** Feeding NV12 provably removes NVENC's internal CUDA
|
||||
CSC — but the convert has to land **off** the SM to fully pay off. `VideoProcessorBlt` is *designed*
|
||||
to use fixed-function video hardware and the hardforum numbers back the 15%→2% drop, **but no NVIDIA
|
||||
doc explicitly confirms `VideoProcessorBlt` runs off-SM on GeForce** — treat the "video engine" claim
|
||||
as well-founded-but-unverified and confirm on-box with `nvidia-smi dmon` (watch the `enc`/`sm`
|
||||
columns) before and after. Do **not** convert with a CUDA/3D shader and call it done — that just
|
||||
relocates the CSC to the same SM (Sunshine's RGB→NV12 CUDA kernel still contends).
|
||||
|
||||
### B. A *correct* async encode pipeline (the untried encoder lever)
|
||||
|
||||
The NVENC Programming Guide is explicit: *"The main encoder thread should be used only to submit
|
||||
work… (non-blocking `NvEncEncodePicture`). Output buffer processing — waiting on the completion
|
||||
event in asynchronous mode, or calling `NvEncLockBitstream` in synchronous mode — should be done in
|
||||
the **secondary thread**."*
|
||||
([NVENC prog-guide, threading model](https://docs.nvidia.com/video-technologies/video-codec-sdk/13.0/nvenc-video-encoder-api-prog-guide/index.html))
|
||||
We do the opposite — submit and blocking-retrieve on **one** thread. Queuing more `pending` entries
|
||||
(IDD-push depth-2, or the abandoned wgc_helper experiment) adds queue latency with **no overlap**,
|
||||
which is exactly the "deeper pipeline only stacks latency" result we recorded. It was the wrong
|
||||
implementation, not a disproof.
|
||||
|
||||
The fix: **submit on the capture/encode thread; do `lock_bitstream` on a dedicated retrieve thread;
|
||||
hold a deep input+output surface pool (≈4–8); on Windows register a `completionEvent` per output
|
||||
buffer (`enableEncodeAsync=1`) — on Linux async events are unsupported, so use the same two-thread
|
||||
split with a blocking retrieve.**
|
||||
([async is Windows/WDDM-only](https://docs.nvidia.com/video-technologies/video-codec-sdk/13.0/nvenc-video-encoder-api-prog-guide/index.html);
|
||||
FFmpeg models the same knob as `delay`/`async_depth`,
|
||||
[libavcodec/nvenc.c](https://github.com/FFmpeg/FFmpeg/blob/master/libavcodec/nvenc.c)).
|
||||
|
||||
This lets the WDDM scheduler find a **backlog** when it finally grants the encoder context a slice,
|
||||
and drain several frames back-to-back, while the ASIC encodes frame N as the contended engines do
|
||||
frame N+1's convert.
|
||||
|
||||
**Verdict: REAL throughput recovery for the depth-1 collapse, latency cost +1–2 frames, ceiling-bounded.**
|
||||
The honest bound (and why this is *second* to §A/§C): pipelining cannot manufacture GPU time — if the
|
||||
scheduler grants the encode context only X% under load, depth only guarantees work is *ready* for
|
||||
each grant; it can't raise X. That is why Sunshine's documented lever for "GPU heavily loaded" is
|
||||
**priority**, not depth. So §B recovers the serialization loss; §A/§C raise the share it's bounded by.
|
||||
Watch out: this **forecloses sub-frame slice output** (mutually exclusive with `enableEncodeAsync`),
|
||||
and HAGS can spike the *submit* call itself
|
||||
([100–200 ms `nvEncEncodePicture` stalls under HAGS](https://forums.developer.nvidia.com/t/windows-11-hardware-accelerated-gpu-scheduling-issue/286128)).
|
||||
|
||||
### C. Auto-gated REALTIME GPU scheduling priority
|
||||
|
||||
Raising the host process's WDDM GPU priority is **the** proven single-PC production lever — OBS and
|
||||
Sunshine both set `D3DKMT_SCHEDULINGPRIORITYCLASS_REALTIME` to stop being descheduled behind
|
||||
fullscreen games
|
||||
([OBS commit](https://github.com/obsproject/obs-studio/commit/ec769ef008b748f7dfba211daec9eb203ea4bea0),
|
||||
[Sunshine `display_base.cpp`](https://raw.githubusercontent.com/LizardByte/Sunshine/master/src/platform/windows/display_base.cpp)).
|
||||
It works **independently of HAGS** (HAGS does *not* reassign cross-process priority — Microsoft:
|
||||
*"Windows continues to control prioritization"*
|
||||
[DirectX devblog](https://devblogs.microsoft.com/directx/hardware-accelerated-gpu-scheduling/)).
|
||||
|
||||
We ship only **HIGH(4)** by default with a static `realtime` opt-in and **no auto-gate**. Two things
|
||||
to change:
|
||||
|
||||
- **We can actually grant REALTIME.** It needs `SeIncreaseBasePriorityPrivilege`, which an unelevated
|
||||
app lacks (OBS logs the failure) — **but our host runs as a `LocalSystem` service, which holds it.**
|
||||
The lever is available to us specifically.
|
||||
- **Gate it to dodge the freeze.** REALTIME + NVIDIA + HAGS-on + near-full-VRAM is a **documented
|
||||
NVENC hang** (Sunshine ships `nvenc_realtime_hags` to downgrade to HIGH for exactly this;
|
||||
[Sunshine config](https://docs.lizardbyte.dev/projects/sunshine/latest/md_docs_2configuration.html),
|
||||
[NVIDIA repro](https://forums.developer.nvidia.com/t/bug-report-nvenc-encoder-hangs-on-windows-when-using-d3d11-in-real-time-mode/357466)).
|
||||
Implement the old plan's "Tier 3B": probe HAGS via `D3DKMTQueryAdapterInfo` and VRAM headroom via
|
||||
`IDXGIAdapter3::QueryVideoMemoryInfo` (continuously); use REALTIME only when HAGS-off, or HAGS-on
|
||||
with comfortable VRAM headroom; downgrade to HIGH the instant VRAM tightens.
|
||||
|
||||
**Verdict: REAL — the genuine ceiling-raiser — but it is the no-free-lunch lever.** Priority is how
|
||||
the host *takes* GPU time from the game; it measurably **costs the game fps**
|
||||
([Doom Eternal 121→60 with Sunshine running](https://github.com/LizardByte/Sunshine/issues/3703)).
|
||||
That's acceptable for a streaming host (the remote view is the product), but say so plainly and make
|
||||
the class operator-configurable (we already expose `PUNKTFUNK_GPU_PRIORITY_CLASS`).
|
||||
|
||||
### D. Multi-vendor encoder hygiene (AMF/QSV) — mostly done, one caveat
|
||||
|
||||
Our `*_amf`/`*_qsv` libavcodec config already follows the research's advice: AMF
|
||||
`usage=ultralowlatency` + `preanalysis=false` (`ffmpeg_win.rs:215`), QSV `async_depth=1` +
|
||||
`low_power=1` VDEnc path (`:226`). Keep them. Two notes:
|
||||
|
||||
- **AMF/QSV suffer contention *worse* than NVENC.** OBS: *"For Intel and AMD GPUs, the hardware
|
||||
encoder requires significant resources of the same type a 3D app/game requires… different from
|
||||
NVIDIA's NVENC, which has dedicated encoding circuits"*
|
||||
([OBS KB](https://obsproject.com/forum/threads/how-to-debug-encoding-overloaded.168625/)). So on an
|
||||
AMD/Intel host the collapse is *expected to be harder* — and §G (iGPU offload) is even more
|
||||
attractive there.
|
||||
- **The AMF busy-poll floor** (a fixed-sleep `QueryOutput` poll imposes ~15 ms via timer
|
||||
granularity) is fixed in FFmpeg's amf wrapper (Cameron Gutman's `QUERY_TIMEOUT` patch); since we
|
||||
go through libavcodec we inherit it — just **confirm the pinned FFmpeg build includes it**.
|
||||
([ffmpeg-devel](https://www.mail-archive.com/ffmpeg-devel@ffmpeg.org/msg170489.html))
|
||||
|
||||
**Verdict: REAL but largely already captured.** No big win left here except via §G.
|
||||
|
||||
### E. Lock clocks / pin P-state — cheap jitter fix, not a collapse fix
|
||||
|
||||
NVIDIA's adaptive clocking downclocks between our small bursty frames and pays a ramp tax every
|
||||
frame — most visible in the *light* scene (the "200-not-240"). Pin it:
|
||||
|
||||
- **Windows:** NvAPI per-application DRS `PREFERRED_PSTATE = PREFER_MAX` scoped to our exe (this is
|
||||
exactly Sunshine's `nvenc_latency_over_power`,
|
||||
[Sunshine nvprefs](https://github.com/LizardByte/Sunshine/blob/master/src/platform/windows/nvprefs/driver_settings.cpp)).
|
||||
**Crash-safe undo is mandatory** — persist an undo record to `%ProgramData%\punktfunk\` *before*
|
||||
applying, revert a stale profile on next start, so a crash never leaves the user's control panel
|
||||
modified.
|
||||
- **Linux:** `nvidia-smi -lgc`/NVML `nvmlDeviceSetGpuLockedClocks` (needs root/`CAP_SYS_ADMIN`; query
|
||||
`nvmlDeviceGetMaxClockInfo`, lock to that, restore on teardown *and* SIGTERM). Plus the newly-added
|
||||
`CudaNoStablePerfLimit` driver profile — *new in R580/595, so usable on the 595 box* — to defeat
|
||||
the CUDA "Force P2" memory-clock clamp.
|
||||
- Gate behind `PUNKTFUNK_PIN_CLOCKS`; **default off on battery / Steam Deck** (pinning is harmful
|
||||
there).
|
||||
|
||||
**Verdict: REAL for latency *stability*, marginal for the saturated collapse** (at 100% util the game
|
||||
already pins P0). Cheap, low risk, do it for the light-scene win.
|
||||
|
||||
### F. Escape the frame-source ceiling — only if §3 says (b)
|
||||
|
||||
If `uniq` is the wall, no encoder/priority work helps — you need a better frame source.
|
||||
|
||||
- **Swapchain-hook capture (the real fix).** Inject a hook on `IDXGISwapChain::Present`/`Present1`,
|
||||
`vkQueuePresentKHR`, `wglSwapBuffers` and copy the backbuffer to a shared texture *before* the
|
||||
compositor — OBS Game Capture's mechanism. Sees **every presented frame**, no compose/refresh
|
||||
gating.
|
||||
([OBS dxgi-capture](https://github.com/obsproject/obs-studio/blob/master/plugins/win-capture/graphics-hook/dxgi-capture.cpp))
|
||||
**Tradeoffs are serious:** anti-cheat (EAC/BattlEye/Vanguard) flags injection — needs
|
||||
whitelisting/compat handling; per-graphics-API hooks; fragility across game updates. Scope it as an
|
||||
opt-in "game capture" mode, not the default.
|
||||
- **NvFBC:** **not an option on Windows** (dead, §1). On **Linux** it's viable via the consumer
|
||||
keylase patch and captures below composition — worth a flag for the Linux NVIDIA host.
|
||||
- **Compose-flip (narrow):** the topmost 1×1 layered-window trick (we already have
|
||||
`composed_flip.rs`) forces DWM composition and fixes specifically the **DLSS-Frame-Gen** half-rate
|
||||
case. Adds host-display latency; don't enable globally.
|
||||
- **WGC "deliver 2× rate":** Apollo sets `MinUpdateInterval = 1e7/(fps*2)` so the pacer always has a
|
||||
fresh frame to pick ([Apollo](https://github.com/ClassicOldSong/Apollo/pull/785)); we set it to 1×
|
||||
refresh (`wgc.rs:310`). Cheap tweak to try on the WGC path.
|
||||
|
||||
**Verdict: swapchain-hook is REAL and the only general escape; the rest are narrow.** None invents
|
||||
frames the game didn't render.
|
||||
|
||||
### G. The honest endgame — encode on a second GPU / the iGPU
|
||||
|
||||
For *demanding* titles that saturate the GPU even when capped, the only thing that **removes**
|
||||
contention rather than re-prioritizing it is to run the capture→convert→encode pipeline on a
|
||||
**different** GPU — a second dGPU or, more realistically, the **iGPU** (Intel QuickSync / AMD VCN),
|
||||
which most desktops already have. Render on the gaming GPU, copy the frame across the adapter once,
|
||||
encode on the iGPU's independent media engine. This is the textbook "stream on a separate encoder"
|
||||
play, and the OBS "second GPU is harmful" verdict does **not** apply — that verdict is about moving
|
||||
*only the NVENC block*; moving capture + CSC + copies off the gaming GPU genuinely frees it.
|
||||
([OBS forum](https://obsproject.com/forum/threads/can-you-use-a-2nd-gpu-to-eliminate-encoder-overload.149644/))
|
||||
|
||||
We're unusually well-placed for this: we already have working AMF and QSV backends
|
||||
(`encode/windows/ffmpeg_win.rs`) and the Linux VAAPI backend. The missing piece is a capture/topology
|
||||
mode that pins capture to the gaming adapter and the encoder to the iGPU adapter, with one
|
||||
cross-adapter shared-texture copy. Cost: that copy still shares VRAM bandwidth, so it's not free, but
|
||||
it's the only path that lets a demanding game and a clean stream coexist on one machine.
|
||||
|
||||
**Verdict: REAL — the cleanest isolation, and the right answer to "even capped it collapses."**
|
||||
Datacenter stacks (GeForce NOW, Stadia) "solve" this by one dedicated GPU + encoder per session;
|
||||
the consumer analogue is the iGPU.
|
||||
|
||||
---
|
||||
|
||||
## 6. Recommended order of attack
|
||||
|
||||
1. **§3 Diagnose** on the RTX box + a real game. Settles (a) vs (b). *(half a day, decisive)*
|
||||
2. **§5.A NV12/P010 on the default paths** (IDD-push video-engine convert; Linux NV12 default-on;
|
||||
Windows HDR P010 default). Biggest in-our-control floor-raise; confirm off-SM with `nvidia-smi dmon`.
|
||||
3. **§5.C Auto-gated REALTIME** priority (HAGS + VRAM gate). Cheap, big, we can uniquely grant it.
|
||||
4. **§5.E Clock pin** both OSes (crash-safe undo). Cheap light-scene win.
|
||||
5. **§5.B Correct two-thread async pipeline.** Structural; recovers the depth-1 serialization.
|
||||
6. **§3-gated §5.F** source escape (swapchain hook) — only if `uniq` is the wall.
|
||||
7. **§5.G iGPU encode offload** — the strategic answer for demanding titles; larger build.
|
||||
|
||||
After 2–5 the light-scene gap closes and the saturated floor rises materially. But report the
|
||||
honest ceiling: **on one saturated GPU the game and the host split a fixed pie** — coarse WDDM
|
||||
graphics preemption caps how much priority can claw back, and a genuinely GPU-bound game that only
|
||||
*rendered* 50 frames cannot also yield 140 unique frames to capture. The only escapes from that pie
|
||||
are reducing the game's demand (cap — rejected), taking a bigger slice (priority — costs game fps),
|
||||
or a second slice of silicon (§G). Don't chase the rest with encoder micro-optimisation.
|
||||
|
||||
---
|
||||
|
||||
## 7. Placebos & dead ends (so we don't re-propose them)
|
||||
|
||||
| Candidate | Verdict | Why |
|
||||
|---|---|---|
|
||||
| **NVIDIA Reflex / Ultra-Low-Latency / max-pre-rendered-frames** as a "non-capping yield" | ✗ placebo | Shrinks the *game's* render queue but the game still demands ~99% GPU → frees ≈0 SM headroom. Reflex needs in-game SDK (host can't force it); ULLM is host-forceable only on DX11/DX9 (DX12 since driver 551.23) and is NVIDIA's weaker mechanism. Only honest effect: µs of tail-jitter smoothing. ([Battle(non)sense LDAT data](https://forums.guru3d.com/threads/battle-non-sense-youtuber-claims-low-latency-mode-only-helps-when-gpu-load-is-99.429074/)) |
|
||||
| **HAGS on, as a contention fix** | ✗ neutral→harmful | Doesn't reassign cross-process priority (Microsoft); OBS reports it *causes* NVENC latency spikes; it's the freeze-hazard variable. Needed only to enable the VK/D3D12 realtime *queue*. ([OBS KB](https://obsproject.com/kb/hags)) |
|
||||
| **Split-frame encode (2/3/4-way) to fix contention** | ✗ (pixel-rate only) | Parallelizes the ASIC, not the contended copy/CSC; measured **zero** latency change at 4K. Correct use = raise the single-session pixel ceiling (5K@240). `splitEncodeMode=15` is the legit *disable* sentinel, not a bug. ([SDK header](https://raw.githubusercontent.com/FFmpeg/nv-codec-headers/master/include/ffnvcodec/nvEncodeAPI.h)) |
|
||||
| **Move the encoded-bitstream readback to a copy engine** | ✗ placebo | Output is KB-scale; the cost of `lock_bitstream` is the completion *wait*, not copy bandwidth. (The *input* full-frame copy is the real one — but D3D11 can't target the copy engine; zero-copy already avoids it.) |
|
||||
| **CUDA stream priority / `CUDA_DEVICE_MAX_CONNECTIONS` / `CU_CTX_SCHED_*`** | ✗ placebo cross-process | Intra-context only; the game is a *separate* context. Stream priority "will not preempt already executing work". ([CUDA docs](https://docs.nvidia.com/cuda/cuda-programming-guide/02-basics/asynchronous-execution.html)) |
|
||||
| **VK/EGL global-priority REALTIME on Linux NVIDIA** | ✗ | Not reliably granted on the proprietary driver, and moot anyway — our Linux NVENC is driven via CUDA/NVENC-SDK, not a Vulkan queue. |
|
||||
| **Windows "High performance" GPU preference** | ✗ single-GPU placebo | Only selects an adapter; real only to split work across adapters (→ that's §G). |
|
||||
| **MIG / MPS / vGPU** | ✗ N/A | MIG/vGPU are datacenter/pro + hypervisor/license; MPS is Linux-CUDA-only with no graphics notion. None apply to a consumer GPU. |
|
||||
| **NvFBC on Windows** | ✗ dead | Deprecated, frozen at Capture SDK 7.1 / Win10-1803. |
|
||||
| **Frame Generation / Smooth Motion** to "make more frames" | ✗ red herring | We stream *rendered* frames; FG adds optical-flow/tensor + present load to the same GPU → amplifies contention. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Open evidence gaps (flagged honestly)
|
||||
|
||||
- Whether `ID3D11VideoProcessor::VideoProcessorBlt` (BGRA→NV12) runs **off the SM on GeForce** is not
|
||||
confirmed by any NVIDIA document — it's the linchpin of §5.A's full payoff. **Verify on-box** with
|
||||
`nvidia-smi dmon` (sm% vs enc%) on the WGC path before assuming IDD-push will match it.
|
||||
- The exact share of the 13–17 ms `encode_ms` that is *convert-on-SM* vs *scheduling-wait* is
|
||||
unmeasured. §3 + an A/B of IDD-push-RGB vs IDD-push-NV12 on the same scene settles it and tells you
|
||||
whether §5.A alone is enough or whether §5.C is doing the heavy lifting.
|
||||
- AMD VCN "degrades worse under contention" is practitioner-consensus + architecture, not an AMD
|
||||
whitepaper; treat the *direction* as solid, the magnitude as TBD.
|
||||
@@ -1,5 +1,14 @@
|
||||
# Host latency & the GPU-contention collapse — analysis + prioritized plan
|
||||
|
||||
> **⚠ Partially superseded (2026-06-25) by [`gpu-contention-investigation.md`](gpu-contention-investigation.md).**
|
||||
> That follow-up re-verified this plan against the current code and overturned several specifics:
|
||||
> the default Windows path (IDD-push) now feeds NVENC **RGB** (regressing the §0A "Windows does it
|
||||
> right" claim); `PUNKTFUNK_ENCODE_DEPTH` never existed (phantom knob); the "async NVENC stacks
|
||||
> latency" result was a *same-thread* implementation, not a disproof of a correct two-thread pipeline;
|
||||
> "capture sees half the frames" is DLSS-Frame-Gen-specific, not general; and NvFBC is dead on
|
||||
> Windows. Use the new doc's ranked action list. The tiers/dropped-placebo analysis below remain a
|
||||
> useful record.
|
||||
|
||||
Scope: Windows + Linux GameStream/punktfunk1 hosts. Priority: **latency**, and specifically the
|
||||
"saturating game starves the stream" headache:
|
||||
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
# Windows Host Rewrite — Audit
|
||||
|
||||
Status: **audit** (2026-06-25). Reviews the state of the Windows host rewrite against its plan
|
||||
([`docs/windows-host-rewrite.md`](windows-host-rewrite.md)). Read-only assessment — no code changed.
|
||||
Scope: the new IddCx driver workspace (`packaging/windows/drivers/`), the owned ABI crate
|
||||
(`crates/pf-vdisplay-proto`), the host-side IDD-push path (`capture/idd_push.rs`,
|
||||
`vdisplay/pf_vdisplay.rs`), and the deployment/packaging seam. Evidence is cited as `file:line`.
|
||||
|
||||
> **Remediation in progress (2026-06-25).** The findings below were the state at audit time; several are
|
||||
> already being worked through. Resolved since: the **cutover (§3)** — STEP 8 gave the new driver its own
|
||||
> `.inx` and re-vendored the installer to the new wdk-sys build (`pf_vdisplay.dll` 613 KB → 251 KB), so the
|
||||
> new driver is now the shipped one; and the **proto ABI hardening (§6.1/§6.2)** — offset asserts + the
|
||||
> owned gamepad SHM layouts have landed. **Live progress + the hand-off task list are tracked in
|
||||
> [`docs/windows-host-rewrite-remediation.md`](windows-host-rewrite-remediation.md).**
|
||||
|
||||
---
|
||||
|
||||
## 0. Bottom line
|
||||
|
||||
The framing "the Windows host has been rewritten with IDD-push as the main path" **overstates what is
|
||||
on disk.** What actually landed is the **driver rewrite** (plan M0 + M1, STEPs 0–7): a clean, new,
|
||||
all-Rust IddCx driver (`packaging/windows/drivers/pf-vdisplay`, ~2,000 LOC) on the unified
|
||||
`windows-drivers-rs` stack, speaking an owned ABI crate (`pf-vdisplay-proto`), validated on-glass through
|
||||
HDR. That is the hardest, highest-risk part of the plan (the `/INTEGRITYCHECK` answer, the `iddcx` binding
|
||||
on `wdk-sys`, on-glass IDD-push + HDR) and it is genuinely well executed.
|
||||
|
||||
Three facts the framing hides:
|
||||
|
||||
1. **The new path is not the shipped path — it is not shipped at all.** The installer still vendors and
|
||||
installs the **old** `vdisplay-driver/` (wdf-umdf) build
|
||||
(`packaging/windows/pf-vdisplay/pf_vdisplay.dll`, dated 2026-06-24). The new driver has **no INF
|
||||
in-tree**, is not vendored, and therefore cannot be packaged. IDD-push capture is gated behind
|
||||
`PUNKTFUNK_IDD_PUSH`, which is **not set** in `scripts/windows/host.env.example`, so the default
|
||||
capture path is **WGC→DDA** and the default display backend falls back to **SudoVDA** whenever the new
|
||||
driver interface isn't enumerable. The new path runs only on a hand-built bench box with the env var
|
||||
set.
|
||||
2. **The host-side rewrite — Goal 1 — has not started.** No `src/windows/` tree, no `config.rs`/
|
||||
`HostConfig`, no `SessionFactory`/`SessionPlan`, no `session/`. The old god-files are intact. SudoVDA
|
||||
was not removed (135 refs; `sudovda.rs` is a *hard dependency* of the new path). Unsafe went **up**,
|
||||
not down.
|
||||
3. **The new driver itself diverges from its own spec in load-bearing ways** — the watchdog is dead code,
|
||||
`SET_RENDER_ADAPTER` is a stub, the §2.5 ownership-model refactor wasn't done, and world-writable
|
||||
logging was re-introduced.
|
||||
|
||||
So the riskiest **proof** is done (real progress). The **rewrite** (clean architecture, cutover,
|
||||
hardening) is still ahead.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal / milestone scorecard
|
||||
|
||||
| Goal / milestone | Status | Evidence |
|
||||
|---|---|---|
|
||||
| **M0** proto ABI + driver toolchain + `/INTEGRITYCHECK` + iddcx binding | ✅ Done | `pf-vdisplay-proto`, vendored `windows-drivers-rs`, `clear-force-integrity.ps1` |
|
||||
| **M1** new IddCx driver, first light + HDR | ✅ Done (on-glass) | STEPs 0–7; `swap_chain_processor.rs`, `frame_transport.rs`, `callbacks.rs` |
|
||||
| **Goal 1** clean, layered host architecture | ❌ Not started | no `src/windows/`, `config.rs`, `session/`, `SessionFactory`/`SessionPlan` |
|
||||
| **Goal 2** drop every trace of SudoVDA | ❌ Not done | 135 `sudovda` refs; `sudovda.rs` (1,193 LOC) is a hard dep of `pf_vdisplay.rs` + `idd_push.rs` |
|
||||
| **Goal 3** minimize unsafe + P0 lints | ❌ Regressed | host unsafe ~476 (↑); driver ~160 vs ~60 target; **no** P0 lints anywhere; `OwnedHandle` in **0** host files |
|
||||
| **§2.5** delete driver global statics / DeviceContext-owned state / `EvtCleanupCallback` | ❌ Not done | `MONITOR_MODES`/`NEXT_ID`/`ADAPTER`/`DEVICE_POOL` still process-globals; `DeviceContext{_device}` empty; no monitor cleanup callback |
|
||||
| **M4** unify gamepad drivers onto new stack | ❌ Not started | workspace members = `wdk-probe/wdk-iddcx/pf-vdisplay` only; gamepad drivers still standalone wdf-umdf |
|
||||
| **M6** cutover + delete old monoliths | ❌ Not reached | old driver trees + `dxgi/wgc/wgc_relay/sudovda/punktfunk1` all present (partly by-design as "reference until parity") |
|
||||
|
||||
---
|
||||
|
||||
## 2. What landed well (preserve, do not regress)
|
||||
|
||||
- **The §1 driver "jewels" survived the port.** The two real swap-chain leak fixes are verbatim with
|
||||
their rationale: borrow `IDXGIDevice` once across `SetDevice` retries
|
||||
(`swap_chain_processor.rs:174`), and check `terminate` at the loop top during a frame burst (`:238`).
|
||||
`DEVICE_POOL` keyed by render LUID (the NVIDIA UMD-thread/VRAM leak fix) is intact
|
||||
(`direct_3d_device.rs:115`). Monitor lock discipline (drop the worker **outside** `MONITOR_MODES`) is
|
||||
correct (`monitor.rs:343-390`).
|
||||
- **The frame transport is clean and correct** — the standout module. `FramePublisher` uses
|
||||
`pf_vdisplay_proto::frame` for header/token/names (no hand-rolled offsets), straight-line
|
||||
acquire→copy→release with no `?` between lock/unlock (`frame_transport.rs:266-275`), format guard
|
||||
before `CopyResource`, stale-ring generation detection, correct drop order.
|
||||
- **The proto control plane is properly owned**: fresh GUID (not SudoVDA's `e5bcc234`), centralized
|
||||
`FrameToken::pack/unpack` used by both sides, and a **real version handshake the host actually
|
||||
asserts** and bails on mismatch (`pf_vdisplay.rs:455-466`). Typed IOCTL dispatch collapsed the
|
||||
per-call unsafe (`control.rs`).
|
||||
- **Per-block `// SAFETY:` discipline** is already present throughout the new driver — most of the value
|
||||
of `clippy::undocumented_unsafe_blocks` without the lint being on yet.
|
||||
|
||||
---
|
||||
|
||||
## 3. Deployment gap (the headline)
|
||||
|
||||
The new path is built and validated but not reachable by an installed product.
|
||||
|
||||
- **Installer ships the old driver.** `packaging/windows/stage-pf-vdisplay.ps1:7-8` vendors the signed
|
||||
output of `packaging/windows/vdisplay-driver/` (the wdf-umdf tree); `punktfunk-host.iss` installs that
|
||||
via `install-pf-vdisplay.ps1`. The vendored binary is `packaging/windows/pf-vdisplay/pf_vdisplay.dll`
|
||||
(613,760 bytes — the old build).
|
||||
- **New driver is not packageable.** `find packaging/windows/drivers -name '*.inf'` → none. The new
|
||||
workspace is built + FORCE_INTEGRITY-cleared in CI (`windows-drivers.yml`) as a **compile/link gate
|
||||
only**; nothing signs or vendors its output.
|
||||
- **GUID split keeps them apart.** The old driver exposes the old SudoVDA interface GUID; the host's
|
||||
`sudovda.rs` backend opens it. The new driver exposes the fresh `70667664-…` GUID; only
|
||||
`pf_vdisplay.rs` opens it. With the old driver installed, `pf_vdisplay::is_available()` → false → the
|
||||
host silently uses the SudoVDA backend.
|
||||
- **IDD-push is off by default.** `scripts/windows/host.env.example` sets only
|
||||
`PUNKTFUNK_ENCODER=auto`, `PUNKTFUNK_VIDEO_SOURCE=virtual`, `PUNKTFUNK_SECURE_DDA=1`, `RUST_LOG=info`.
|
||||
`PUNKTFUNK_IDD_PUSH` is checked via `var_os(...).is_some()` (`capture.rs:348`, `punktfunk1.rs:2223+`,
|
||||
`pf_vdisplay.rs:57`) but never set in deployment.
|
||||
|
||||
Net: a freshly installed Windows host runs **old driver + SudoVDA backend + WGC/DDA capture** — the
|
||||
pre-rewrite path. The rewrite is a manually-validated parallel track, not a delivered feature.
|
||||
|
||||
---
|
||||
|
||||
## 4. Driver code audit — stability / correctness
|
||||
|
||||
### 4.1 P0 — the watchdog is dead code; host-crash leaks an orphan monitor
|
||||
|
||||
`WATCHDOG_PINGS` is incremented on `IOCTL_PING` (`control.rs:35`) but **nothing reads it** — the only
|
||||
`thread::spawn` in the driver is the swap-chain worker (`swap_chain_processor.rs:104`). The comments are
|
||||
misleading: "STEP 4's watchdog thread samples it" (`control.rs:17`) and "the watchdog reaps all monitors"
|
||||
(`control.rs:14`) describe a thread that does not exist; `adapter_init_finished`
|
||||
(`callbacks.rs:30-37`) does not start one despite its doc claiming so.
|
||||
|
||||
Consequence: if `serve` dies or the service is stopped with `TerminateProcess` (skipping `Drop` → no
|
||||
`IOCTL_REMOVE`), the virtual monitor + its worker thread + pooled D3D device persist in WUDFHost until the
|
||||
**next** host start issues `IOCTL_CLEAR_ALL`. If the host is not restarted, the orphan monitor stays
|
||||
plugged into the desktop topology indefinitely.
|
||||
|
||||
The plan called for host-gone detection by **`EvtCleanupCallback` RAII**, a **polling watchdog**, or
|
||||
**`EvtFileClose`** (§3.4) — none is implemented. Fix: implement the watchdog thread, or (preferred) wire
|
||||
`EvtFileClose` so "host holds the control handle open" = liveness; and remove the false comments.
|
||||
|
||||
### 4.2 P1 — `SET_RENDER_ADAPTER` is a stub → hybrid-GPU is a hard failure
|
||||
|
||||
`control.rs:47` returns `STATUS_NOT_IMPLEMENTED`, contradicting plan §3.2 (which made it unconditional).
|
||||
The driver renders the virtual monitor on whatever adapter the OS picks (`callbacks.rs:275`,
|
||||
`pooled_device(luid)`) and reports that LUID to the host. On a hybrid **iGPU+dGPU** box, if the OS picks
|
||||
the iGPU, the host's ring textures (created on the NVENC dGPU) fail `OpenSharedResourceByName` →
|
||||
`DRV_STATUS_TEX_FAIL` (`frame_transport.rs:195-208`) → the host's 20 s hard bail (§5.1). This is a silent
|
||||
hard failure on common Optimus/hybrid configs. The single-dGPU RTX bench box never reproduced it.
|
||||
|
||||
### 4.3 P1 — the §2.5 ownership refactor wasn't done
|
||||
|
||||
State is still process-global: `MONITOR_MODES`/`NEXT_ID` (`monitor.rs:63,65`), `ADAPTER`
|
||||
(`adapter.rs:41`), `DEVICE_POOL` (`direct_3d_device.rs:115`); `DeviceContext` is an empty `{ _device }`
|
||||
(`entry.rs:20`). No `EvtCleanupCallback` on the monitor object (`monitor.rs:292-296` sets only Size +
|
||||
scope). Monitor identity is still 3-keyed (`id`/`object`/`session_id`), not the collapsed single
|
||||
`Monitor`.
|
||||
|
||||
This is why the plan's central payoff — *stable monitor reuse → drop the preempt dance → unblock
|
||||
`max_concurrent>1` on Windows* — was not achieved. The host still does fresh-monitor-per-session with the
|
||||
`IDD_SETUP_LOCK` preempt + `wait_for_monitor_released` dance (`punktfunk1.rs:2216-2237`), so Windows
|
||||
IDD-push is effectively single-client even though `DEFAULT_MAX_CONCURRENT = 4`.
|
||||
|
||||
### 4.4 P2 — world-writable logging re-introduced
|
||||
|
||||
Plan §6 said delete the `C:\Users\Public\*.log` driver logging; the new driver re-added it
|
||||
(`pf-vdisplay/src/log.rs:18` → `C:\Users\Public\pfvd-driver.log`). Info-leak / DoS surface; should move to
|
||||
ETW or be gated off release builds.
|
||||
|
||||
### 4.5 P2 — no control-plane input validation
|
||||
|
||||
`create_monitor` receives `width/height/refresh` from the IOCTL with no bounds check (`control.rs:62-63`
|
||||
→ `monitor.rs:243`). The host is a trusted LocalSystem process so the trust boundary holds, but a buggy
|
||||
host could request an absurd mode. `read_input` uses `T: Copy`, not `bytemuck::Pod` (`control.rs:96`);
|
||||
Pod would be a stronger guarantee.
|
||||
|
||||
---
|
||||
|
||||
## 5. Host code audit
|
||||
|
||||
### 5.1 P1 — when IDD-push is engaged there is no fallback
|
||||
|
||||
The plan kept WGC/DDA as a safety net; the code commits hard. `capture.rs:345` consumes the keepalive and
|
||||
returns the IDD-push capturer with "no fall-through"; attach failure surfaces as a **20 s deadline
|
||||
`bail!`** (`idd_push.rs:820-846`) that tears the session down black rather than degrading to DDA. Combined
|
||||
with §4.2, hybrid-GPU = a guaranteed 20 s black-then-drop.
|
||||
|
||||
### 5.2 P1 — SudoVDA is a hard dependency of the "new" path
|
||||
|
||||
`pf_vdisplay.rs` and `idd_push.rs` import `isolate_displays_ccd`/`resolve_render_adapter_luid`/
|
||||
`set_advanced_color`/`CURRENT_MON_GEN` directly from `super::sudovda` (`pf_vdisplay.rs:43-46`,
|
||||
`idd_push.rs:351-356,809`). `punktfunk1.rs:2231` calls `crate::vdisplay::sudovda::wait_for_monitor_released`
|
||||
even when pf-vdisplay is the live backend — benign **today** only because pf-vdisplay preempts inline and
|
||||
the SudoVDA `MGR` is empty (`pf_vdisplay.rs:645-647`), but it is a fragile cross-static landmine. Plan §9
|
||||
(move CCD/adapter helpers into neutral `windows/display_ccd.rs` + `adapter.rs`) is the right fix and is
|
||||
unstarted.
|
||||
|
||||
### 5.3 P2 — texture-ownership contract is convention, not types
|
||||
|
||||
The §4 in-place-encode hazard is *mitigated* by a host-owned 3-slot `OUT_RING` +
|
||||
`pipeline_depth().clamp(1, OUT_RING)` (`idd_push.rs:60,867-872`) — sound for the live synchronous loop —
|
||||
but nothing type-enforces it. `nvenc.rs:7-10` still carries the "safe because the loop is synchronous"
|
||||
comment, and `repeat_last()` (`idd_push.rs:755-766`) can re-hand an out-ring slot that may still be
|
||||
encoding under depth>1. Narrow, but it is the residual corruption edge the plan wanted closed type-level.
|
||||
|
||||
### 5.4 P2 — HDR toggle recreates the whole ring mid-session
|
||||
|
||||
`recreate_ring` (`idd_push.rs:582-617`) drops + recreates all 6 keyed-mutex textures on an HDR mode flip,
|
||||
polled on a 250 ms throttle (`idd_push.rs:622-626`) → up to a 250 ms format-mismatch freeze window where
|
||||
the driver drops every frame (`frame_transport.rs:256-260`). Works, but heavy and visibly janky.
|
||||
|
||||
---
|
||||
|
||||
## 6. ABI / proto
|
||||
|
||||
### 6.1 P1 — gamepad SHM was not migrated into proto (the one real drift hazard)
|
||||
|
||||
Plan §3.1 wanted `XusbShm` (64 B) and `PadShm` (256 B incl. `device_type`) in `pf-vdisplay-proto`. They
|
||||
are hand-duplicated across four sides on two build graphs, with `device_type` as a bare literal `140`:
|
||||
host `inject/dualsense_windows.rs:45-52` (`OFF_DEVTYPE=140`) vs driver `dualsense-driver/src/lib.rs:753`
|
||||
(`*view.add(140)`); XUSB host `inject/gamepad_windows.rs:36-47` vs driver `xusb-driver/src/lib.rs`. A
|
||||
one-sided edit compiles clean on both and silently mis-routes. The `pf-vdisplay` frame/control contract
|
||||
got compile-error-on-drift; the gamepad contract did not. (The gamepad drivers being standalone cargo
|
||||
workspaces is the structural blocker — folding them into the unified workspace, M4, fixes both.)
|
||||
|
||||
### 6.2 P2 — proto advertises offset asserts but only has size asserts
|
||||
|
||||
`SharedHeader` (14 mixed-width fields + a `_pad`) is guarded by `size_of == 64` + bytemuck-Pod
|
||||
(`pf-vdisplay-proto/src/lib.rs:232`), which catches most regressions but not a same-size field reorder.
|
||||
Add `offset_of!` asserts for `magic/latest/generation/dxgi_format/driver_status` and the `AddReply` LUID
|
||||
split.
|
||||
|
||||
---
|
||||
|
||||
## 7. Performance opportunities
|
||||
|
||||
- **Hybrid-GPU cross-adapter copy** (once §4.2 `SET_RENDER_ADAPTER` works): pinning the driver render to
|
||||
the NVENC GPU removes a cross-adapter staging path entirely — correctness *and* latency.
|
||||
- **HDR ring recreate** (§5.4) is the heaviest per-session-event op; if the display HDR state is known at
|
||||
`open()` from the negotiated mode, size the ring right the first time and skip the recreate + 250 ms
|
||||
window in the common case.
|
||||
- **Keyed-mutex acquire timeout is 8 ms** on the host consume side (`idd_push.rs:725`) — at 240 Hz
|
||||
(4.2 ms/frame) one stall already drops ≥2 frames. Reasonable as a safety bound; worth measuring under
|
||||
load against a tighter value plus an explicit drop counter.
|
||||
- The encode|send split, microburst pacing, and `pipeline_depth=2` convert/copy-vs-NVENC overlap are
|
||||
preserved — no regression on the hot path.
|
||||
|
||||
---
|
||||
|
||||
## 8. Hygiene (Goal 3)
|
||||
|
||||
- **No P0 lints anywhere.** Neither the host crate nor the new driver crates carry
|
||||
`deny(unsafe_op_in_unsafe_fn)` / `warn(clippy::undocumented_unsafe_blocks)` /
|
||||
`warn(clippy::multiple_unsafe_ops_per_block)`. The plan claimed the driver workspace "already has it";
|
||||
it does not (`pf-vdisplay/src/lib.rs:11` is only `allow(...)`). A few-line, high-leverage first step
|
||||
before any further unsafe work.
|
||||
- **`OwnedHandle`/`from_raw_handle` used in zero host files** — the plan's "single biggest cheap win."
|
||||
`pf_vdisplay.rs` holds a raw `isize` device handle in the pinger thread; `idd_push.rs` holds raw
|
||||
event/map handles. Obvious first conversions.
|
||||
- **Unsafe counts moved the wrong way.** Host ~476 (target ~35); new driver ~160 (target for all three
|
||||
drivers ~60), and the old gamepad drivers are untouched on top of that.
|
||||
|
||||
---
|
||||
|
||||
## 9. Recommended priority order
|
||||
|
||||
**P0 — correctness/stability, before relying on the path**
|
||||
1. Make host-gone detection real: implement the watchdog thread **or** `EvtFileClose`, and delete the
|
||||
false "watchdog" comments. Verify service stop is cooperative (named stop event → `Drop` →
|
||||
`IOCTL_REMOVE`), not `TerminateProcess`. (§4.1)
|
||||
2. Implement `SET_RENDER_ADAPTER` (pin driver render to the NVENC adapter) **and** add a real capture
|
||||
fallback (IDD-push attach failure → DDA) instead of the 20 s black bail. (§4.2, §5.1)
|
||||
|
||||
**P1 — ship-ability + the actual rewrite**
|
||||
3. Cutover plan: give the new driver an in-tree INF, vendor *its* signed output, flip
|
||||
`stage-pf-vdisplay.ps1`, and make IDD-push the code default (WGC/DDA fallback) or set
|
||||
`PUNKTFUNK_IDD_PUSH=1` in `host.env`. Until then the rewrite does not reach users. (§3)
|
||||
4. Migrate the gamepad SHM into `pf-vdisplay-proto` (kills the `140`-literal drift hazard). (§6.1)
|
||||
5. Add the P0 lints; convert raw handles to `OwnedHandle`. (§8)
|
||||
|
||||
**P2 — the host-side architecture (Goal 1, the bulk of "rewrite the host")**
|
||||
6. §2.5 driver ownership refactor (DeviceContext state + `EvtCleanupCallback` + single monitor identity)
|
||||
— the prerequisite to `max_concurrent>1` on Windows. (§4.3)
|
||||
7. §9 SudoVDA decoupling (split CCD/adapter helpers into neutral modules), then the §2.2/§2.4 host tree
|
||||
(`config.rs`/`SessionFactory`) — the clean architecture that was Goal 1. (§5.2)
|
||||
8. Offset asserts in proto; remove world-writable driver logging; M4 gamepad-driver unification; then M6
|
||||
deletion of the old monoliths. (§6.2, §4.4)
|
||||
|
||||
---
|
||||
|
||||
## Appendix — methodology
|
||||
|
||||
Full read of the new driver (`packaging/windows/drivers/pf-vdisplay/src/*.rs`, `wdk-iddcx/src/lib.rs`)
|
||||
and `pf-vdisplay-proto`; targeted read of the host IDD-push path (`capture/idd_push.rs`,
|
||||
`vdisplay/pf_vdisplay.rs`, `capture.rs`, `vdisplay.rs`, `encode.rs`, `encode/nvenc.rs`); structural
|
||||
grep/diff of plan §2.2/§6/§8/§9/§10 against the on-disk tree; packaging/CI inspection
|
||||
(`punktfunk-host.iss`, `stage-pf-vdisplay.ps1`, `windows-drivers.yml`, `scripts/windows/host.env.example`).
|
||||
Unsafe counts are raw `grep -c unsafe` over the relevant subtrees (occurrences, not blocks). Not validated
|
||||
on hardware — this audit reads code and packaging only; on-glass behavior is per the commit log and
|
||||
[`docs/windows-host-rewrite.md`](windows-host-rewrite.md) §13–14.
|
||||
@@ -1,158 +0,0 @@
|
||||
# Windows Host Rewrite — Audit Remediation Tracker
|
||||
|
||||
Status: **in progress** (2026-06-25). Living hand-off doc for working through the findings in
|
||||
[`docs/windows-host-rewrite-audit.md`](windows-host-rewrite-audit.md) (the audit of the IDD-push rewrite
|
||||
vs [`docs/windows-host-rewrite.md`](windows-host-rewrite.md)). Keep this updated as items land so the work
|
||||
can be handed off without losing tasks.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **9 commits on `main`, NOT pushed** (`+9` ahead of `origin/main`, tip `e60cda3`). Each is compile-verified
|
||||
on the RTX box (see [Verification](#verification)).
|
||||
- **Done:** the entire audit **P0 + P1 + P2** payload, the driver `unsafe` lint, and **F1** (SudoVDA helper
|
||||
decoupling) complete.
|
||||
- **Remaining:** **D2** (OwnedHandle), **D1-host** (unsafe-lint sweep), **E1** (driver ownership refactor),
|
||||
**G** (gamepad-driver unification + old-tree deletion + host `src/windows/` tree).
|
||||
- **Two cross-cutting follow-ups:** (1) **on-glass behavioral validation** of the committed driver/host
|
||||
fixes (the box is single-GPU + headless-ish, so hybrid-GPU / HDR-toggle / fallback paths weren't
|
||||
exercised at runtime); (2) **push** to run the full CI matrix (the local checks skip the `amf-qsv` path).
|
||||
|
||||
## Done — committed on `main` (unpushed)
|
||||
|
||||
| Commit | Audit § | What | Compile-verified |
|
||||
|---|---|---|---|
|
||||
| `0badc17` | — | The audit doc itself | — |
|
||||
| `95dcef3` | §6.1/6.2 | **A** proto: `offset_of!` asserts on `SharedHeader`/`AddReply`/control structs; owned `XusbShm`/`PadShm` gamepad layouts (+ `min_const_generics`) | local `cargo test` + MSVC (box) |
|
||||
| `0a7ae5e` | §4.1/4.2/4.4/4.5 | **B** driver: real host-gone **watchdog** (was dead code), **`SET_RENDER_ADAPTER`** impl, world-writable-log gate, mode bounds + `display_info` u64-saturate | driver `cargo build` (box) |
|
||||
| `e5c9ee8` | §4.2h/6.1 | **C2/C5** host: render-pin comment/activation (driver now honors it); gamepad SHM consumers derive from `pf_vdisplay_proto::gamepad` | host clippy (box) |
|
||||
| `ed58365` | §5.1 | **C1** host: IDD-push **attach fallback to DDA** (open() hands keepalive back; bounded `wait_for_attach` on `DRV_STATUS_OPENED`) instead of the 20s black bail | host clippy (box) |
|
||||
| `b0d2838` | §5.3/5.4 | **C3/C4** host: `repeat_last` rotates+copies into a fresh out-ring slot; HDR ring sized FP16 at open when advanced-color is enabled | host clippy (box) |
|
||||
| `a755d6e` | §8 | **D1-driver** `#![deny(unsafe_op_in_unsafe_fn)]` on `pf-vdisplay` + `wdk-iddcx` | driver `cargo build` (box) |
|
||||
| `d638a93` | §9 | **F1 pt1**: `resolve_render_adapter_luid` → neutral `crate::win_adapter` | host clippy (box) |
|
||||
| `e60cda3` | §9 | **F1 rest**: 6 CCD/HDR helpers + `SavedConfig` → neutral `crate::win_display`; SudoVDA reach-in fully broken | host clippy (box) + Linux `cargo check` |
|
||||
|
||||
## Remaining — to do
|
||||
|
||||
Ordered by suggested sequence. **On-glass = cannot be *finished* without a real session on the RTX box,
|
||||
driven by a human** (driver install + client connect).
|
||||
|
||||
### D2 — `OwnedHandle` on the new path · audit §8 · compile-verifiable · moderate
|
||||
- **Goal:** replace raw `HANDLE`/`isize` handles held across their lifetime with
|
||||
`std::os::windows::io::OwnedHandle` (RAII close, fixes leak-on-error, deletes manual `CloseHandle`).
|
||||
- **Targets:** `vdisplay/pf_vdisplay.rs` — the pinger thread's raw `isize` device handle (`pf_vdisplay.rs`
|
||||
~324-344); `capture/idd_push.rs` — `IddPushCapturer { map, event, dbg_map: HANDLE }` (manually closed in
|
||||
`Drop`). The plan also lists events/jobs/tokens/sections in `windows/process.rs`/`service.rs` (broader).
|
||||
- **Risk:** handle ownership (double-close / premature close). Compile catches type errors; lifecycle
|
||||
needs care. Touches the live IDD-push path → ideally smoke-tested on glass after.
|
||||
- **Verify:** host clippy on the box (the new path is `--features nvenc`).
|
||||
|
||||
### D1-host — host-wide `unsafe` lint sweep · audit §8 · large/mechanical
|
||||
- **Goal:** add `#![deny(unsafe_op_in_unsafe_fn)]` + `#![warn(clippy::undocumented_unsafe_blocks)]`
|
||||
(+ optionally `multiple_unsafe_ops_per_block`) to the **host crate** (`crates/punktfunk-host/src/main.rs`),
|
||||
and fix the fallout.
|
||||
- **Scope:** large — hundreds of `unsafe` blocks across **both** Linux and Windows code need explicit
|
||||
`unsafe {}` wrapping inside `unsafe fn`s and `// SAFETY:` comments. The driver already has the `deny`
|
||||
(`a755d6e`); the host has none.
|
||||
- **Verify:** Linux `cargo clippy -p punktfunk-host --all-targets -- -D warnings` (Linux/cross paths) **and**
|
||||
host clippy on the box (Windows paths). Do it incrementally per-subsystem to keep the diff reviewable.
|
||||
|
||||
### E1 — driver ownership refactor · audit §4.3 / plan §2.5 + §14 step 5 · **on-glass-gated** · large
|
||||
- **Goal:** move the driver's process-global statics (`MONITOR_MODES`, `NEXT_ID`, `ADAPTER`, `DEVICE_POOL`)
|
||||
into a WDF `DeviceContext`; **wire `EvtCleanupCallback` on the `IDDCX_MONITOR` object** so the
|
||||
`SwapChainProcessor` + D3D drop via RAII; collapse the 3-key monitor identity (`id`/`object`/`session_id`)
|
||||
to one. Unblocks `max_concurrent>1` on Windows + removes the host-side preempt dance.
|
||||
- **Why on-glass:** the plan's critique is explicit — *instrument that `MonitorContext::Drop` actually
|
||||
RAN*; if the cleanup callback does not fire on this UMDF/IddCx stack, **keep the current explicit
|
||||
REMOVE/teardown path as the fallback**. Cannot be signed off compile-only.
|
||||
- **Files:** `packaging/windows/drivers/pf-vdisplay/src/{entry,adapter,monitor,callbacks,swap_chain_processor}.rs`.
|
||||
- **Verify:** driver `cargo build` (compile) on the box; then on-glass reconnect-storm + leak check
|
||||
(`LIVE_DEVICES` counter in `direct_3d_device.rs`, the world-readable log when `PFVD_DEBUG_LOG` is set).
|
||||
|
||||
### G — gamepad-driver unification (M4) + deletion (M6) + host tree · audit §6/§10 + plan §2.2 · **on-glass-gated** · largest
|
||||
- **M4:** fold `pf_dualsense` + `pf_xusb` (today standalone `packaging/windows/{dualsense,xusb}-driver/` on
|
||||
the old `wdf` stack) into the unified `packaging/windows/drivers/` workspace on `windows-drivers-rs`. This
|
||||
also enables the **driver-side** gamepad-SHM→proto switch (host side already done in C5 — the driver still
|
||||
hand-reads `view.add(140)`; point it at `pf_vdisplay_proto::gamepad::PadShm`/`XusbShm`).
|
||||
- **M6:** delete the old `packaging/windows/vdisplay-driver/` tree + the old gamepad driver trees + the
|
||||
bring-up scaffolding (`DebugBlock`/`spawn_observer`/`IDD_PERSIST`/`open_or_reuse` in `idd_push.rs`) — **only
|
||||
after on-glass parity** of the new path.
|
||||
- **Host architecture (Goal 1, plan §2.2/2.4):** the `src/windows/` subtree + `config.rs` (`HostConfig`) +
|
||||
`SessionFactory`/`SessionPlan` — **not started**. The biggest clarity lever; large.
|
||||
|
||||
### Cross-cutting follow-ups (not a single task)
|
||||
- **On-glass validation of the committed fixes** — needs the RTX box + a client. Specifically: the
|
||||
**watchdog** actually reaps on host-kill (B1); **`SET_RENDER_ADAPTER`** pins correctly on a *hybrid* box
|
||||
(B2/C2 — the lab box is single-dGPU, so this path is unexercised); the **IDD-push→DDA fallback** triggers
|
||||
+ the happy path still attaches within 4s (C1); **HDR ring sizing** + **out-ring repeat** under real HDR /
|
||||
static-desktop pipelining (C3/C4).
|
||||
- **Push** to run the full CI matrix — the local host checks use `--features nvenc` only (no FFmpeg), so the
|
||||
`amf-qsv` encode path is unexercised locally; CI (`windows-host.yml`) covers it.
|
||||
|
||||
## Related workstream — fullscreen-game IDD-push capture bug (separate doc)
|
||||
|
||||
A **separate, newly-found bug** (NOT an audit finding) in the same IDD-push subsystem, with its own staged
|
||||
fix plan: [`docs/windows-host-rewrite-game-capture-bug.md`](windows-host-rewrite-game-capture-bug.md).
|
||||
**Symptom:** launching a fullscreen game (Doom the Dark Ages) on an HDR IDD-push stream flashes the desktop,
|
||||
the game never shows, and reconnect = black screen + working audio. **Root cause:** the IDD-push ring is
|
||||
fixed format+size at session start; the driver silently drops every frame whose surface descriptor no longer
|
||||
matches (a game forces a mode-set); the host has no channel to learn the descriptor changed; and there is no
|
||||
mid-session fallback → 20 s `bail!`.
|
||||
|
||||
**Intersections with this remediation — read before implementing:**
|
||||
- **Stage 1 builds on our C1 (`ed58365`); do not duplicate it.** C1 added an IDD-push→DDA fallback, but
|
||||
**open-time only** (driver never attaches). The game bug is **mid-session** (attached, then a game changes
|
||||
format/size). The bug doc's Stage 1 (a composing capturer that fails over mid-session) is the
|
||||
generalization — build it on C1's `open()`-returns-keepalive + bounded-attach infrastructure.
|
||||
- **The bug doc was written against pre-remediation `main` (`a11b0dd`).** Its line numbers and its claim
|
||||
"`capture.rs:348-356` … no fall-through" are **stale after our 9 commits** (C1 changed exactly that).
|
||||
Rebase on current `main` first.
|
||||
- **Stage 2 (new `SharedHeader` fields + `PROTOCOL_VERSION` bump)** must update the **`offset_of!`/size
|
||||
asserts added in A (`95dcef3`)** — they catch drift at compile time (the intended safety net). Note: those
|
||||
asserts live in the `frame` module of `crates/pf-vdisplay-proto/src/lib.rs` (the doc says `frame.rs`).
|
||||
- **Stage 0 / S3 diagnostics rely on the driver log**, which **B3 (`0a7ae5e`) gated off in release builds**
|
||||
(`debug_assertions || PFVD_DEBUG_LOG`). Enable it (`PFVD_DEBUG_LOG=1` or a debug build) for the repro.
|
||||
- **S1/S2 (driver swap-chain resilience)** is adjacent to **E1** (same `swap_chain_processor.rs`/
|
||||
`callbacks.rs`); coordinate so they don't conflict.
|
||||
- The bug doc's "doc-lag" note (`stage-pf-vdisplay.ps1` still names the old `vdisplay-driver/` tree) is part
|
||||
of our **G / M6** packaging cleanup.
|
||||
|
||||
**Stages (detail in the bug doc):** Stage 0 diagnostics (S3) → Stage 1 mid-session fallback (P3, host-only,
|
||||
the user-visible fix) → Stage 2 adaptive ring (P1/P2; proto bump + driver re-vendor) → Stage 3 trim
|
||||
advertised modes → Stage S driver resilience (S1/S2). Tracked as GB0–GB3 in the task list.
|
||||
|
||||
## Verification
|
||||
|
||||
The persistent validator is the **RTX box** `ssh "Enrico Bühler"@192.168.1.158` (ENRICOS-DESKTOP, RTX 4090,
|
||||
PS shell). **EPHEMERAL — boots to Proxmox on reboot; never reboot it, never depend on it surviving.** It has
|
||||
WDK 26100 + LLVM 21.1.2 + the Rust toolchain. Build clone: `C:\Users\Public\pf-rewrite`.
|
||||
|
||||
```sh
|
||||
# 0. (local, cross-platform) the proto crate + the Linux host build
|
||||
cargo test -p pf-vdisplay-proto
|
||||
cargo check -p punktfunk-host # Linux paths; the win_* mods are #[cfg(windows)]
|
||||
|
||||
# 1. reset the box clone to a clean base, then overlay your changed files
|
||||
# ssh ... "cd C:\Users\Public\pf-rewrite; git fetch -q origin; git reset -q --hard origin/main; git clean -qfd; git checkout -q <rev>"
|
||||
# scp <changed files> "Enrico Bühler@192.168.1.158:C:/Users/Public/pf-rewrite/<same rel path>"
|
||||
|
||||
# 2. host clippy (warm target ~4s). NVENC import lib at C:\t\nvenc; no FFmpeg needed (amf-qsv off).
|
||||
ssh ... "cd C:\Users\Public\pf-rewrite; $env:PUNKTFUNK_NVENC_LIB_DIR='C:\t\nvenc'; \
|
||||
cargo clippy -p punktfunk-host --features nvenc --target x86_64-pc-windows-msvc -- -D warnings"
|
||||
|
||||
# 3. driver workspace build (fires deny(unsafe_op_in_unsafe_fn)); ~5s
|
||||
ssh ... "cd C:\Users\Public\pf-rewrite\packaging\windows\drivers; \
|
||||
$env:Version_Number='10.0.26100.0'; $env:LIBCLANG_PATH='C:\Program Files\LLVM\bin'; cargo build"
|
||||
```
|
||||
|
||||
Gotchas: the box username has a `ü` → quote it; PS shell, filter output with `Select-Object -Last N`. After
|
||||
a `git reset --hard` on the box clone, re-`scp` your working files (reset discards them). Do **not** build in
|
||||
`C:\Users\Public\punktfunk-native` (the deployed host).
|
||||
|
||||
## New modules introduced by this work
|
||||
|
||||
- `crates/pf-vdisplay-proto/src/lib.rs` → added `mod gamepad` (`XusbShm`/`PadShm`/magics/name helpers) +
|
||||
`offset_of!` asserts.
|
||||
- `crates/punktfunk-host/src/win_adapter.rs` → `resolve_render_adapter_luid` (plan's `windows/adapter.rs`).
|
||||
- `crates/punktfunk-host/src/win_display.rs` → CCD/HDR display helpers (plan's `windows/display_ccd.rs`).
|
||||
- Driver: `start_watchdog`/`reap_orphaned` (control.rs/monitor.rs), `set_render_adapter` (adapter.rs),
|
||||
`file_log_enabled` gate (log.rs).
|
||||
+387
-731
File diff suppressed because it is too large
Load Diff
@@ -71,21 +71,47 @@ read it from `%ProgramData%\punktfunk\web-password`.
|
||||
| `install-pf-vdisplay.ps1` | Runs at install time (elevated): trust cert → gated device-node create (nefconc) → `pnputil` install. |
|
||||
| `../../scripts/windows/web-run.cmd` | The `PunktfunkWeb` task action: loads the mgmt token + login password env, runs the bundled `bun` on the Nitro server (`:3000`). |
|
||||
| `../../scripts/windows/web-setup.ps1` | Install-time (elevated): write the ACL'd console password, register the `PunktfunkWeb` task + firewall rule, start it. |
|
||||
| `pf-vdisplay/` | **Vendored** signed pf-vdisplay driver: `pf_vdisplay.inf` / `pf_vdisplay.cat` / `pf_vdisplay.dll` / `punktfunk-driver.cer`. Built from `vdisplay-driver/`. |
|
||||
| `vdisplay-driver/` | The all-Rust IddCx **driver source** (`pf-vdisplay` crate + vendored `wdf-umdf*` bindings) + `deploy-dev.ps1` (build/sign/install for dev). |
|
||||
| `pf-vdisplay/` | **Vendored** signed pf-vdisplay driver: `pf_vdisplay.inf` / `pf_vdisplay.cat` / `pf_vdisplay.dll` / `punktfunk-driver.cer`. Built from `drivers/`. |
|
||||
| `drivers/` | The all-Rust IddCx **driver source** workspace: the `pf-vdisplay` crate on `wdk-sys` / windows-drivers-rs + the owned `pf-driver-proto` ABI + `wdk-iddcx` / `wdk-probe`, plus `deploy-dev.ps1` (build/sign/install for dev). |
|
||||
| `reset-pf-vdisplay.ps1` | **Dev:** recover a wedged driver — stop host → reap ghost monitor nodes → reload the adapter → start host (no reboot). See *Dev iteration* below. |
|
||||
| `redeploy-pf-vdisplay.ps1` | **Dev:** one-shot redeploy — (optional) build → stop host → `deploy-dev.ps1 -Install` → reload adapter → start host. |
|
||||
| `nvenc/nvenc.def`, `nvenc/gen-nvenc-importlib.ps1` | Synthesise `nvencodeapi.lib` for the `--features nvenc` link (llvm-dlltool / lib.exe). |
|
||||
|
||||
> **Vendored driver:** pf-vdisplay is our **all-Rust IddCx** virtual display (UMDF2), built from
|
||||
> `packaging/windows/vdisplay-driver/`. It replaced the vendored SudoVDA C++ driver — full story in
|
||||
> `packaging/windows/drivers/`. It replaced the vendored SudoVDA C++ driver — full story in
|
||||
> [`docs/windows-virtual-display-rust-port.md`](../../docs/windows-virtual-display-rust-port.md). The
|
||||
> **signed** output (`pf_vdisplay.dll`/`.inf`/`.cat` + `punktfunk-driver.cer`; signer
|
||||
> `punktfunk-ds-test` — the same cert the gamepad drivers ship, Class=Display, HWID `root\pf_vdisplay`)
|
||||
> is checked in under `pf-vdisplay/`. To refresh it after a driver-source change, rebuild + re-sign with
|
||||
> `vdisplay-driver/deploy-dev.ps1` and copy the staged `pf_vdisplay.{dll,inf,cat}` over the vendored
|
||||
> `drivers/deploy-dev.ps1` and copy the staged `pf_vdisplay.{dll,inf,cat}` over the vendored
|
||||
> copies. nefcon (the device-node tool — the install creates the node with it, **never** `devgen`, which
|
||||
> leaves persistent phantom devices) **is** fetched + SHA-256-verified from its pinned release in
|
||||
> `stage-pf-vdisplay.ps1`.
|
||||
|
||||
## Dev iteration on the test box (driver)
|
||||
|
||||
Two helpers wrap the painful manual steps of iterating on the pf-vdisplay driver against a live host
|
||||
service. Run **elevated**; both default to the `PunktfunkHost` service.
|
||||
|
||||
```powershell
|
||||
# Recover a WEDGED driver. Symptom: every session fails with
|
||||
# create virtual output: pf-vdisplay ADD ...: DeviceIoControl(0x222400): Element nicht gefunden (0x80070490)
|
||||
# i.e. ERROR_NOT_FOUND — sustained ADD/REMOVE churn exhausted the IddCx monitor slots (ghost
|
||||
# "Generic Monitor (punktfunk)" nodes pile up, target_ids climb). A host restart's CLEAR_ALL does NOT
|
||||
# fix it; the driver instance must be reloaded. This clears the ghosts + cycles the adapter (no reboot —
|
||||
# this box boots to Proxmox).
|
||||
powershell -ExecutionPolicy Bypass -File reset-pf-vdisplay.ps1 -Verify -Probe C:\t-goal1\debug\punktfunk-probe.exe
|
||||
|
||||
# Redeploy a driver build cleanly (stop host → install with a strictly-increasing DriverVer → reload
|
||||
# adapter → start host). -Build runs `cargo build` first, but ONLY from an MSVC dev shell
|
||||
# (LIBCLANG_PATH + Version_Number=10.0.26100.0); otherwise build separately and omit -Build.
|
||||
powershell -ExecutionPolicy Bypass -File redeploy-pf-vdisplay.ps1 -Build -Verify -Probe C:\t-goal1\debug\punktfunk-probe.exe
|
||||
```
|
||||
|
||||
The driver should reclaim monitor slots on REMOVE so churn can't wedge it; until it does, `reset` is
|
||||
the recovery. From a Linux box drive either over SSH, e.g.
|
||||
`ssh user@box 'powershell -ExecutionPolicy Bypass -File C:\...\reset-pf-vdisplay.ps1'`.
|
||||
|
||||
## Build locally (Windows, MSVC + Windows SDK + Inno Setup)
|
||||
|
||||
```powershell
|
||||
|
||||
Generated
+3
-3
@@ -398,7 +398,7 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
name = "pf-vdisplay"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"pf-vdisplay-proto",
|
||||
"pf-driver-proto",
|
||||
"thiserror",
|
||||
"wdk",
|
||||
"wdk-build",
|
||||
@@ -408,7 +408,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay-proto"
|
||||
name = "pf-driver-proto"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
@@ -776,7 +776,7 @@ dependencies = [
|
||||
name = "wdk-probe"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"pf-vdisplay-proto",
|
||||
"pf-driver-proto",
|
||||
"wdk",
|
||||
"wdk-build",
|
||||
"wdk-sys",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
#
|
||||
# Separate from the main cargo workspace (own [workspace] root) because driver crates are cdylibs built
|
||||
# with the WDK toolchain (cargo-wdk / wdk-build) on Windows only. Path-deps the shared ABI crate
|
||||
# crates/pf-vdisplay-proto from the main tree.
|
||||
# crates/pf-driver-proto from the main tree.
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["wdk-probe", "wdk-iddcx", "pf-vdisplay"]
|
||||
@@ -20,7 +20,7 @@ wdk = "0.4.1"
|
||||
wdk-sys = "0.5.1"
|
||||
wdk-build = "0.5.1"
|
||||
wdk-iddcx = { path = "wdk-iddcx" }
|
||||
pf-vdisplay-proto = { path = "../../../crates/pf-vdisplay-proto" }
|
||||
pf-driver-proto = { path = "../../../crates/pf-driver-proto" }
|
||||
|
||||
# Vendored windows-drivers-rs 0.5.1 (the published, self-contained crates) + an added `iddcx`
|
||||
# ApiSubset (M1 — bindgens iddcx/1.10/IddCx.h reusing wdk_default for WDF type-identity). Redirect ALL
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Build-stage-sign-install the NEW-tree pf-vdisplay UMDF IddCx driver (packaging/windows/drivers/) for
|
||||
local dev/test on the RTX box. The wdk-sys / windows-drivers-rs analogue of the superseded
|
||||
vdisplay-driver/deploy-dev.ps1.
|
||||
|
||||
.DESCRIPTION
|
||||
Stages the freshly built pf_vdisplay.dll, CLEARS its FORCE_INTEGRITY PE bit (this tree's wdk-build links
|
||||
/INTEGRITYCHECK, which a self-signed cert can't satisfy — the old wdf-umdf tree didn't), signs it with
|
||||
the self-signed test cert, stamps a STRICTLY-INCREASING DriverVer into the INF, generates + signs the
|
||||
catalog, and (with -Install) pnputil-installs it.
|
||||
|
||||
Build first: from packaging/windows/drivers/, in an MSVC dev shell with LIBCLANG_PATH +
|
||||
Version_Number=10.0.26100.0, run `cargo build`.
|
||||
|
||||
Re-deploying needs a HIGHER DriverVer than the installed one or pnputil silently keeps the old binary —
|
||||
hence the 9.9.MMdd.HHmm scheme (the vendored build is 9.5.*). If the host service is running it holds the
|
||||
driver: `punktfunk-host service stop`, deploy, then start it, for a clean test.
|
||||
.PARAMETER Install
|
||||
Also add the driver package to the store + (if absent) create the Root\pf_vdisplay devnode via nefconc.
|
||||
Needs an ELEVATED shell.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Stage = 'C:\Users\Public\pfvd-stage-deploy',
|
||||
[string]$Thumbprint = '6A52984E54376C45A1C236B1A2C8A746C5AB6131',
|
||||
[string]$Nefconc = 'C:\Users\Public\virtual-display-rs\installer\files\nefconc.exe',
|
||||
[switch]$Install
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$dll = Join-Path $root 'target\x86_64-pc-windows-msvc\debug\pf_vdisplay.dll'
|
||||
$inx = Join-Path $root 'pf-vdisplay\pf_vdisplay.inx'
|
||||
$clear = Join-Path $root '..\clear-force-integrity.ps1'
|
||||
if (-not (Test-Path $dll)) { throw "driver not built: $dll (cargo build in packaging/windows/drivers first)" }
|
||||
|
||||
$kits = 'C:\Program Files (x86)\Windows Kits\10\bin'
|
||||
function Find-Tool([string]$name, [string]$arch) {
|
||||
(Get-ChildItem "$kits\*\$arch\$name" -EA SilentlyContinue | Sort-Object FullName | Select-Object -Last 1).FullName
|
||||
}
|
||||
$signtool = Find-Tool 'signtool.exe' 'x64'
|
||||
$stampinf = Find-Tool 'stampinf.exe' 'x64'
|
||||
$inf2cat = Find-Tool 'Inf2Cat.exe' 'x86'
|
||||
foreach ($t in @($signtool, $stampinf, $inf2cat)) { if (-not $t) { throw 'a WDK tool (signtool/stampinf/Inf2Cat) was not found' } }
|
||||
|
||||
if (Test-Path $Stage) { Remove-Item $Stage -Recurse -Force }
|
||||
New-Item -ItemType Directory -Force -Path $Stage | Out-Null
|
||||
$stagedDll = Join-Path $Stage 'pf_vdisplay.dll'
|
||||
$stagedInf = Join-Path $Stage 'pf_vdisplay.inf'
|
||||
$stagedCat = Join-Path $Stage 'pf_vdisplay.cat'
|
||||
Copy-Item $dll $stagedDll -Force
|
||||
Copy-Item $inx $stagedInf -Force # stampinf rewrites this copy in place
|
||||
|
||||
# Clear FORCE_INTEGRITY BEFORE signing (the clear edits the PE, which invalidates any signature).
|
||||
& $clear -Path $stagedDll | Out-Null
|
||||
|
||||
# DriverVer must strictly increase. Installed is 9.5.* — 9.9.MMdd.HHmm always wins on the same day.
|
||||
$now = Get-Date
|
||||
$ver = '9.9.{0}.{1}' -f $now.ToString('MMdd'), $now.ToString('HHmm')
|
||||
|
||||
& $signtool sign /fd SHA256 /sha1 $Thumbprint $stagedDll | Out-Null
|
||||
& $stampinf -f $stagedInf -d '*' -a 'amd64' -u '2.15.0' -v $ver | Out-Null
|
||||
& $inf2cat /driver:$Stage /os:10_X64 /uselocaltime | Out-Null
|
||||
& $signtool sign /fd SHA256 /sha1 $Thumbprint $stagedCat | Out-Null
|
||||
Write-Host "staged + signed pf-vdisplay (new tree) DriverVer=$ver -> $Stage"
|
||||
|
||||
if ($Install) {
|
||||
& pnputil /add-driver $stagedInf /install
|
||||
$present = Get-PnpDevice -EA SilentlyContinue |
|
||||
Where-Object { $_.InstanceId -match 'PF_VDISPLAY' -or $_.FriendlyName -match 'punktfunk Virtual Display' }
|
||||
if (-not $present) {
|
||||
if (-not (Test-Path $Nefconc)) { throw "nefconc not found: $Nefconc" }
|
||||
& $Nefconc --create-device-node --hardware-id 'root\pf_vdisplay' --class-name Display --class-guid '{4d36e968-e325-11ce-bfc1-08002be10318}' | Out-Null
|
||||
Start-Sleep 2
|
||||
& pnputil /add-driver $stagedInf /install
|
||||
}
|
||||
Write-Host "installed pf-vdisplay DriverVer=$ver"
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
# pf-vdisplay — the all-Rust UMDF IddCx virtual-display driver (M1 step-2 rewrite onto wdk-sys + the
|
||||
# owned pf-vdisplay-proto ABI). Replaces the vendored-binding oracle at packaging/windows/vdisplay-driver/
|
||||
# owned pf-driver-proto ABI). Replaces the vendored-binding oracle at packaging/windows/vdisplay-driver/
|
||||
# (deleted once on-glass parity is reached, per docs/windows-host-rewrite.md §14 STEP 8).
|
||||
[package]
|
||||
name = "pf-vdisplay"
|
||||
@@ -23,7 +23,7 @@ wdk-build.workspace = true
|
||||
wdk.workspace = true
|
||||
wdk-sys = { workspace = true, features = ["iddcx"] }
|
||||
wdk-iddcx.workspace = true
|
||||
pf-vdisplay-proto.workspace = true
|
||||
pf-driver-proto.workspace = true
|
||||
# STEP 5: the swap-chain processor's render-side D3D11 device + worker. 0.58.0 matches the wdk-build
|
||||
# transitive `windows` already in the workspace lock (one resolved version) AND the proven oracle's
|
||||
# version, so the ported D3D/DXGI/threading calls compile verbatim.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
; pf-vdisplay - punktfunk virtual display, UMDF2 IddCx driver INF (template; stampinf -> .inf).
|
||||
;
|
||||
; For the all-Rust wdk-sys / windows-drivers-rs driver in THIS tree
|
||||
; (packaging/windows/drivers/pf-vdisplay/). The driver registers the OWNED pf_vdisplay_proto
|
||||
; (packaging/windows/drivers/pf-vdisplay/). The driver registers the OWNED pf_driver_proto
|
||||
; control-interface GUID in CODE (WdfDeviceCreateDeviceInterface), so this INF is GUID-agnostic and
|
||||
; is byte-identical to the superseded oracle's (packaging/windows/vdisplay-driver/.../pf_vdisplay.inx,
|
||||
; itself adapted from MolotovCherry/virtual-display-rs (MIT) + SudoVDA's control-device security DACL).
|
||||
|
||||
@@ -66,9 +66,7 @@ pub fn init_adapter(device: WDFDEVICE) -> NTSTATUS {
|
||||
// Firmware/hardware version (telemetry). The oracle points BOTH at one IDDCX_ENDPOINT_VERSION.
|
||||
// `version` is a stack local read synchronously by IddCxAdapterInitAsync (same as the oracle). `.Size`
|
||||
// is `size_of` throughout — these are the IddCx 1.10 structs and the framework here is 1.10 (= upstream).
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized IDDCX_ENDPOINT_VERSION;
|
||||
// the required `.Size` (+ version fields) are set immediately below before the struct is used.
|
||||
let mut version: iddcx::IDDCX_ENDPOINT_VERSION = unsafe { core::mem::zeroed() };
|
||||
let mut version = pod_init!(iddcx::IDDCX_ENDPOINT_VERSION);
|
||||
version.Size = core::mem::size_of::<iddcx::IDDCX_ENDPOINT_VERSION>() as u32;
|
||||
version.MajorVer = env!("CARGO_PKG_VERSION_MAJOR").parse().unwrap_or(0);
|
||||
version.MinorVer = env!("CARGO_PKG_VERSION_MINOR").parse().unwrap_or(0);
|
||||
@@ -78,9 +76,7 @@ pub fn init_adapter(device: WDFDEVICE) -> NTSTATUS {
|
||||
// zeroed value is IDDCX_FEATURE_IMPLEMENTATION_UNINITIALIZED (0), which the framework's adapter Validate
|
||||
// rejects with INVALID_PARAMETER (ddivalidation.cpp:797) — set it to NONE (1) like upstream. THIS was
|
||||
// the on-glass adapter-init blocker.
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized
|
||||
// IDDCX_ENDPOINT_DIAGNOSTIC_INFO; the required `.Size` (+ the fields read by Validate) are set below.
|
||||
let mut diag: iddcx::IDDCX_ENDPOINT_DIAGNOSTIC_INFO = unsafe { core::mem::zeroed() };
|
||||
let mut diag = pod_init!(iddcx::IDDCX_ENDPOINT_DIAGNOSTIC_INFO);
|
||||
diag.Size = core::mem::size_of::<iddcx::IDDCX_ENDPOINT_DIAGNOSTIC_INFO>() as u32;
|
||||
diag.GammaSupport = iddcx::IDDCX_FEATURE_IMPLEMENTATION::IDDCX_FEATURE_IMPLEMENTATION_NONE;
|
||||
diag.TransmissionType = iddcx::IDDCX_TRANSMISSION_TYPE::IDDCX_TRANSMISSION_TYPE_WIRED_OTHER;
|
||||
@@ -92,9 +88,7 @@ pub fn init_adapter(device: WDFDEVICE) -> NTSTATUS {
|
||||
diag.pFirmwareVersion = (&raw mut version).cast();
|
||||
diag.pHardwareVersion = (&raw mut version).cast();
|
||||
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized IDDCX_ADAPTER_CAPS;
|
||||
// the required `.Size` (+ flags/limits/diag) are set immediately below.
|
||||
let mut caps: iddcx::IDDCX_ADAPTER_CAPS = unsafe { core::mem::zeroed() };
|
||||
let mut caps = pod_init!(iddcx::IDDCX_ADAPTER_CAPS);
|
||||
caps.Size = core::mem::size_of::<iddcx::IDDCX_ADAPTER_CAPS>() as u32;
|
||||
// STEP 7 (HDR): declare we can process FP16 (scRGB) desktop surfaces — this is what marks the virtual
|
||||
// monitor advanced-color-capable (→ the host sees display_hdr=true → the "Use HDR" toggle appears). The
|
||||
@@ -109,9 +103,7 @@ pub fn init_adapter(device: WDFDEVICE) -> NTSTATUS {
|
||||
|
||||
// The adapter WDF object's attributes: Size + Synchronization/Execution = InheritFromParent (NOT zeroed,
|
||||
// since zero = *Invalid*) + the adapter context type (STEP 4 stores adapter state here).
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized WDF_OBJECT_ATTRIBUTES;
|
||||
// the required `.Size` (+ execution/sync scope + context type) are set immediately below.
|
||||
let mut attr: wdk_sys::WDF_OBJECT_ATTRIBUTES = unsafe { core::mem::zeroed() };
|
||||
let mut attr = pod_init!(wdk_sys::WDF_OBJECT_ATTRIBUTES);
|
||||
attr.Size = core::mem::size_of::<wdk_sys::WDF_OBJECT_ATTRIBUTES>() as u32;
|
||||
attr.ExecutionLevel = wdk_sys::_WDF_EXECUTION_LEVEL::WdfExecutionLevelInheritFromParent;
|
||||
attr.SynchronizationScope =
|
||||
@@ -122,9 +114,7 @@ pub fn init_adapter(device: WDFDEVICE) -> NTSTATUS {
|
||||
pCaps: &raw mut caps,
|
||||
ObjectAttributes: &raw mut attr,
|
||||
};
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized IDARG_OUT_ADAPTER_INIT
|
||||
// (an out-param the framework fills).
|
||||
let mut out: iddcx::IDARG_OUT_ADAPTER_INIT = unsafe { core::mem::zeroed() };
|
||||
let mut out = pod_init!(iddcx::IDARG_OUT_ADAPTER_INIT);
|
||||
// SAFETY: `init`/`out` are valid local storage; IddCxAdapterInitAsync reads the caps synchronously
|
||||
// (the adapter object itself is delivered later via adapter_init_finished). Called once per device.
|
||||
let st = unsafe { wdk_iddcx::IddCxAdapterInitAsync(&init, &mut out) };
|
||||
@@ -147,15 +137,13 @@ pub(crate) fn adapter() -> Option<iddcx::IDDCX_ADAPTER> {
|
||||
/// iGPU+dGPU box the OS may otherwise pick the iGPU to render the virtual monitor, so the host's shared
|
||||
/// ring textures (created on the NVENC dGPU) can't be opened → `DRV_STATUS_TEX_FAIL` → the host's 20 s
|
||||
/// black bail. Pinning the render adapter to the encode GPU fixes that. Unconditional — NOT the
|
||||
/// SudoVDA-parity default-off branch (`docs/windows-host-rewrite-audit.md` §4.2). Returns
|
||||
/// SudoVDA-parity default-off branch (`docs/windows-host-rewrite.md` §2.8). Returns
|
||||
/// `STATUS_NOT_FOUND` if called before the adapter exists.
|
||||
pub fn set_render_adapter(luid_low: u32, luid_high: i32) -> NTSTATUS {
|
||||
let Some(adapter) = adapter() else {
|
||||
return crate::STATUS_NOT_FOUND;
|
||||
};
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid IDARG_IN_ADAPTERSETRENDERADAPTER;
|
||||
// the one meaningful field is assigned below.
|
||||
let mut in_args: iddcx::IDARG_IN_ADAPTERSETRENDERADAPTER = unsafe { core::mem::zeroed() };
|
||||
let mut in_args = pod_init!(iddcx::IDARG_IN_ADAPTERSETRENDERADAPTER);
|
||||
in_args.PreferredRenderAdapter = wdk_sys::LUID {
|
||||
LowPart: luid_low,
|
||||
HighPart: luid_high,
|
||||
|
||||
@@ -37,6 +37,15 @@ pub unsafe extern "C" fn adapter_init_finished(
|
||||
STATUS_SUCCESS
|
||||
}
|
||||
|
||||
/// `EvtCleanupCallback` on the WDFDEVICE (E1): the device is being removed (PnP / driver unload) — drop
|
||||
/// every monitor's swap-chain worker so the worker threads don't linger into teardown. IddCx-free (the
|
||||
/// framework tears the monitors down with the departing device); see
|
||||
/// [`crate::monitor::cleanup_for_device_removal`].
|
||||
pub unsafe extern "C" fn device_cleanup(_object: WDFOBJECT) {
|
||||
dbglog!("[pf-vd] device cleanup — releasing monitors");
|
||||
crate::monitor::cleanup_for_device_removal();
|
||||
}
|
||||
|
||||
/// SDR mode list for an EDID monitor: EDID-serial lookup → count-then-fill `IDDCX_MONITOR_MODE`.
|
||||
pub unsafe extern "C" fn parse_monitor_description(
|
||||
p_in: *const iddcx::IDARG_IN_PARSEMONITORDESCRIPTION,
|
||||
@@ -71,9 +80,7 @@ pub unsafe extern "C" fn parse_monitor_description(
|
||||
// SAFETY: `pMonitorModes` points to >= `count` IDDCX_MONITOR_MODE entries (validated above).
|
||||
let out = unsafe { core::slice::from_raw_parts_mut(in_args.pMonitorModes, count as usize) };
|
||||
for (item, slot) in crate::monitor::flatten(&modes).zip(out.iter_mut()) {
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized IDDCX_MONITOR_MODE;
|
||||
// the required `.Size` (+ origin / signal info) are set immediately below.
|
||||
let mut mode: iddcx::IDDCX_MONITOR_MODE = unsafe { core::mem::zeroed() };
|
||||
let mut mode = pod_init!(iddcx::IDDCX_MONITOR_MODE);
|
||||
mode.Size = core::mem::size_of::<iddcx::IDDCX_MONITOR_MODE>() as u32;
|
||||
mode.Origin = iddcx::IDDCX_MONITOR_MODE_ORIGIN::IDDCX_MONITOR_MODE_ORIGIN_MONITORDESCRIPTOR;
|
||||
mode.MonitorVideoSignalInfo =
|
||||
@@ -122,9 +129,7 @@ pub unsafe extern "C" fn parse_monitor_description2(
|
||||
// SAFETY: `pMonitorModes` points to >= `count` IDDCX_MONITOR_MODE2 entries (validated above).
|
||||
let out = unsafe { core::slice::from_raw_parts_mut(in_args.pMonitorModes, count as usize) };
|
||||
for (item, slot) in crate::monitor::flatten(&modes).zip(out.iter_mut()) {
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized IDDCX_MONITOR_MODE2;
|
||||
// the required `.Size` (+ origin / signal info / bit depth) are set immediately below.
|
||||
let mut mode: iddcx::IDDCX_MONITOR_MODE2 = unsafe { core::mem::zeroed() };
|
||||
let mut mode = pod_init!(iddcx::IDDCX_MONITOR_MODE2);
|
||||
mode.Size = core::mem::size_of::<iddcx::IDDCX_MONITOR_MODE2>() as u32;
|
||||
mode.Origin = iddcx::IDDCX_MONITOR_MODE_ORIGIN::IDDCX_MONITOR_MODE_ORIGIN_MONITORDESCRIPTOR;
|
||||
mode.MonitorVideoSignalInfo =
|
||||
@@ -220,7 +225,7 @@ pub unsafe extern "C" fn query_target_info(
|
||||
) -> NTSTATUS {
|
||||
// SAFETY: p_out is the framework's (uninitialised) out buffer; zero then set the one field we report.
|
||||
unsafe {
|
||||
core::ptr::write(p_out, core::mem::zeroed());
|
||||
core::ptr::write(p_out, pod_init!(iddcx::IDARG_OUT_QUERYTARGET_INFO));
|
||||
(*p_out).TargetCaps = iddcx::IDDCX_TARGET_CAPS::IDDCX_TARGET_CAPS_HIGH_COLOR_SPACE;
|
||||
}
|
||||
STATUS_SUCCESS
|
||||
@@ -318,7 +323,7 @@ pub unsafe extern "C" fn unassign_swap_chain(monitor: iddcx::IDDCX_MONITOR) -> N
|
||||
STATUS_SUCCESS
|
||||
}
|
||||
|
||||
/// The pf-vdisplay-proto control plane. Returns `()` and completes the request itself (matches the C
|
||||
/// The pf-driver-proto control plane. Returns `()` and completes the request itself (matches the C
|
||||
/// `EVT_IDD_CX_DEVICE_IO_CONTROL` shape). STEP 4: dispatch the proto IOCTLs; for now just complete.
|
||||
pub unsafe extern "C" fn device_io_control(
|
||||
_device: WDFDEVICE,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The `pf-vdisplay-proto` control plane (`EvtIddCxDeviceIoControl`). The host opens the device interface
|
||||
//! The `pf-driver-proto` control plane (`EvtIddCxDeviceIoControl`). The host opens the device interface
|
||||
//! (`PF_VDISPLAY_INTERFACE_GUID`) and drives the low-frequency IOCTLs: GET_INFO (version handshake), PING
|
||||
//! (watchdog keepalive), ADD/REMOVE/CLEAR_ALL (virtual monitors), and SET_RENDER_ADAPTER (next). Every
|
||||
//! path completes the `WDFREQUEST` exactly once (the `EVT_IDD_CX_DEVICE_IO_CONTROL` shape returns `()`).
|
||||
@@ -6,7 +6,7 @@
|
||||
use core::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use pf_vdisplay_proto::control;
|
||||
use pf_driver_proto::control;
|
||||
use wdk_iddcx::nt_success;
|
||||
use wdk_sys::{NTSTATUS, WDFREQUEST, call_unsafe_wdf_function_binding};
|
||||
|
||||
@@ -27,7 +27,7 @@ static WATCHDOG_STARTED: AtomicBool = AtomicBool::new(false);
|
||||
/// without a cooperative REMOVE (crash / `TerminateProcess`) left its virtual monitor + swap-chain
|
||||
/// worker + pooled D3D device wedged in WUDFHost until the next host start's CLEAR_ALL, and a
|
||||
/// not-restarted host left the orphan monitor in the desktop topology indefinitely
|
||||
/// (`docs/windows-host-rewrite-audit.md` §4.1). This thread closes that: if no IOCTL arrives for
|
||||
/// (`docs/windows-host-rewrite.md` §2.8). This thread closes that: if no IOCTL arrives for
|
||||
/// `WATCHDOG_TIMEOUT_S` while monitors exist, it departs them all.
|
||||
///
|
||||
/// (A WDF `EvtFileClose` on the control handle would be more immediate — the plan's preferred §3.4
|
||||
@@ -76,7 +76,7 @@ pub unsafe fn dispatch(request: WDFREQUEST, ioctl_code: u32) {
|
||||
match ioctl_code {
|
||||
control::IOCTL_GET_INFO => {
|
||||
let reply = control::InfoReply {
|
||||
protocol_version: pf_vdisplay_proto::PROTOCOL_VERSION,
|
||||
protocol_version: pf_driver_proto::PROTOCOL_VERSION,
|
||||
watchdog_timeout_s: WATCHDOG_TIMEOUT_S,
|
||||
};
|
||||
// SAFETY: `request` is the framework WDFREQUEST.
|
||||
|
||||
@@ -38,8 +38,7 @@ pub unsafe extern "system" fn driver_entry(
|
||||
registry_path: PCUNICODE_STRING,
|
||||
) -> NTSTATUS {
|
||||
dbglog!("[pf-vd] DriverEntry");
|
||||
// SAFETY: zeroed then Size + the device-add callback set, per the WDF_DRIVER_CONFIG contract.
|
||||
let mut config: WDF_DRIVER_CONFIG = unsafe { core::mem::zeroed() };
|
||||
let mut config = pod_init!(WDF_DRIVER_CONFIG);
|
||||
config.Size = core::mem::size_of::<WDF_DRIVER_CONFIG>() as ULONG;
|
||||
config.EvtDriverDeviceAdd = Some(driver_add);
|
||||
// SAFETY: driver + registry_path are loader-provided; config is valid for the call.
|
||||
@@ -60,9 +59,7 @@ pub unsafe extern "system" fn driver_entry(
|
||||
extern "C" fn driver_add(_driver: WDFDRIVER, mut init: PWDFDEVICE_INIT) -> NTSTATUS {
|
||||
dbglog!("[pf-vd] driver_add");
|
||||
// Defer adapter creation to the first D0 entry.
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized
|
||||
// WDF_PNPPOWER_EVENT_CALLBACKS; the required `.Size` (+ the D0-entry callback) are set immediately below.
|
||||
let mut pnp: WDF_PNPPOWER_EVENT_CALLBACKS = unsafe { core::mem::zeroed() };
|
||||
let mut pnp = pod_init!(WDF_PNPPOWER_EVENT_CALLBACKS);
|
||||
pnp.Size = core::mem::size_of::<WDF_PNPPOWER_EVENT_CALLBACKS>() as ULONG;
|
||||
pnp.EvtDeviceD0Entry = Some(callbacks::device_d0_entry);
|
||||
// SAFETY: init is the framework-provided device-init; pnp is valid for the call.
|
||||
@@ -71,9 +68,7 @@ extern "C" fn driver_add(_driver: WDFDRIVER, mut init: PWDFDEVICE_INIT) -> NTSTA
|
||||
}
|
||||
|
||||
// Build the IddCx client config and wire the SDR callbacks. `.Size` = size_of (1.10 structs, 1.10 fw).
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized IDD_CX_CLIENT_CONFIG;
|
||||
// the required `.Size` (+ the IddCx client callbacks) are set immediately below.
|
||||
let mut cfg: iddcx::IDD_CX_CLIENT_CONFIG = unsafe { core::mem::zeroed() };
|
||||
let mut cfg = pod_init!(iddcx::IDD_CX_CLIENT_CONFIG);
|
||||
cfg.Size = core::mem::size_of::<iddcx::IDD_CX_CLIENT_CONFIG>() as u32;
|
||||
cfg.EvtIddCxAdapterInitFinished = Some(callbacks::adapter_init_finished);
|
||||
cfg.EvtIddCxParseMonitorDescription = Some(callbacks::parse_monitor_description);
|
||||
@@ -105,14 +100,15 @@ extern "C" fn driver_add(_driver: WDFDRIVER, mut init: PWDFDEVICE_INIT) -> NTSTA
|
||||
|
||||
let mut device: WDFDEVICE = core::ptr::null_mut();
|
||||
// Attach a device context type (like the working virtual-display-rs/oracle), not WDF_NO_OBJECT_ATTRIBUTES.
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized WDF_OBJECT_ATTRIBUTES;
|
||||
// the required `.Size` (+ execution/sync scope + context type) are set immediately below.
|
||||
let mut dev_attr: wdk_sys::WDF_OBJECT_ATTRIBUTES = unsafe { core::mem::zeroed() };
|
||||
let mut dev_attr = pod_init!(wdk_sys::WDF_OBJECT_ATTRIBUTES);
|
||||
dev_attr.Size = core::mem::size_of::<wdk_sys::WDF_OBJECT_ATTRIBUTES>() as u32;
|
||||
dev_attr.ExecutionLevel = wdk_sys::_WDF_EXECUTION_LEVEL::WdfExecutionLevelInheritFromParent;
|
||||
dev_attr.SynchronizationScope =
|
||||
wdk_sys::_WDF_SYNCHRONIZATION_SCOPE::WdfSynchronizationScopeInheritFromParent;
|
||||
dev_attr.ContextTypeInfo = &DEVICE_CTX.0;
|
||||
// Drop every monitor's swap-chain worker when the device is removed (PnP / unload), so the worker
|
||||
// threads don't linger into teardown (E1 device cleanup). IddCx-free; see callbacks::device_cleanup.
|
||||
dev_attr.EvtCleanupCallback = Some(callbacks::device_cleanup);
|
||||
// SAFETY: init configured above; dev_attr is a valid context-typed attributes block.
|
||||
let status = unsafe {
|
||||
call_unsafe_wdf_function_binding!(WdfDeviceCreate, &mut init, &mut dev_attr, &mut device)
|
||||
@@ -132,7 +128,7 @@ extern "C" fn driver_add(_driver: WDFDRIVER, mut init: PWDFDEVICE_INIT) -> NTSTA
|
||||
// Expose the owned pf-vdisplay control interface: the host opens this GUID and drives the proto control
|
||||
// plane (IOCTL_ADD/REMOVE/PING/…) which arrives at EvtIddCxDeviceIoControl. NOT SudoVDA's GUID. (The
|
||||
// upstream uses a socket instead, so it has no interface; ours is IOCTL-based.)
|
||||
let (d1, d2, d3, d4) = pf_vdisplay_proto::interface_guid_fields();
|
||||
let (d1, d2, d3, d4) = pf_driver_proto::interface_guid_fields();
|
||||
let guid = GUID {
|
||||
Data1: d1,
|
||||
Data2: d2,
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
//!
|
||||
//! Host counterpart: `crates/punktfunk-host/src/capture/idd_push.rs`. The shared `SharedHeader` layout,
|
||||
//! the [`FrameToken`] packing, the `Global\` object-name scheme, the `MAGIC`/`RING_LEN` and the
|
||||
//! `DRV_STATUS_*` codes are NOT hand-duplicated here: both sides `use pf_vdisplay_proto::frame::*`, which
|
||||
//! `DRV_STATUS_*` codes are NOT hand-duplicated here: both sides `use pf_driver_proto::frame::*`, which
|
||||
//! OWNS the contract (with `const` size asserts so any drift is a compile error).
|
||||
//!
|
||||
//! Ported from the proven oracle (`packaging/windows/vdisplay-driver/pf-vdisplay/src/frame_transport.rs`).
|
||||
//! Differences from the oracle:
|
||||
//! * the layout/consts/names/token come from `pf_vdisplay_proto::frame` instead of being re-declared;
|
||||
//! * the layout/consts/names/token come from `pf_driver_proto::frame` instead of being re-declared;
|
||||
//! * `dbglog!` replaces `log::info!`;
|
||||
//! * the optional fixed-name `Global\pfvd-dbg` `DebugBlock` bring-up channel is SKIPPED (not on the data
|
||||
//! path). FOLLOW-UP: if the host bring-up diagnostics are needed again, port the oracle's `DebugBlock`
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
|
||||
use pf_vdisplay_proto::frame::{
|
||||
use pf_driver_proto::frame::{
|
||||
DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED, DRV_STATUS_TEX_FAIL, FrameToken, MAGIC, RING_LEN,
|
||||
SharedHeader, event_name, header_name, texture_name,
|
||||
};
|
||||
@@ -72,6 +72,9 @@ pub struct FramePublisher {
|
||||
/// recreates the ring at a new format mid-session (the display's HDR mode flipped) — [`Self::is_stale`]
|
||||
/// detects that so `run_core` re-attaches to the new-format textures instead of dropping every frame.
|
||||
generation: u32,
|
||||
/// Set when a surface is dropped for a descriptor mismatch (a game mode-set the display), cleared on a
|
||||
/// matched publish — throttles the drop log to once per mismatch episode (game-capture bug GB1).
|
||||
mismatch_logged: bool,
|
||||
}
|
||||
|
||||
// SAFETY: created and used only on the swap-chain processor thread.
|
||||
@@ -246,6 +249,7 @@ impl FramePublisher {
|
||||
// SAFETY: `header` is the mapped host header; `dxgi_format` lives within it.
|
||||
ring_format: unsafe { (*header).dxgi_format },
|
||||
generation,
|
||||
mismatch_logged: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -281,9 +285,28 @@ impl FramePublisher {
|
||||
let mut desc = D3D11_TEXTURE2D_DESC::default();
|
||||
// SAFETY: `surface` is a live ID3D11Texture2D (borrowed from IddCx); `desc` is a valid local out-param.
|
||||
unsafe { surface.GetDesc(&mut desc) };
|
||||
if desc.Format.0 as u32 != self.ring_format {
|
||||
// Descriptor guard: CopyResource needs the surface + ring textures to share format AND dimensions.
|
||||
// A fullscreen game can mode-set the display, changing the surface's format/size before the host
|
||||
// recreates the ring to match (game-capture bug GB1) — drop a mismatched frame (else garbage) and
|
||||
// report the ACTUAL descriptor once per episode so a repro shows exactly what changed.
|
||||
// SAFETY: `self.header` stays mapped for the publisher's lifetime; width/height are plain u32 fields.
|
||||
let (rw, rh) = unsafe { ((*self.header).width, (*self.header).height) };
|
||||
if desc.Format.0 as u32 != self.ring_format || desc.Width != rw || desc.Height != rh {
|
||||
if !self.mismatch_logged {
|
||||
self.mismatch_logged = true;
|
||||
dbglog!(
|
||||
"[pf-vd] frame-push DROP: surface {}x{} fmt={} != ring {}x{} fmt={} — display mode-set? (host should recreate the ring)",
|
||||
desc.Width,
|
||||
desc.Height,
|
||||
desc.Format.0 as u32,
|
||||
rw,
|
||||
rh,
|
||||
self.ring_format
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.mismatch_logged = false;
|
||||
let start = self.next;
|
||||
for attempt in 0..ring_len {
|
||||
let slot = (start + attempt) % ring_len;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! pf-vdisplay — the all-Rust UMDF IddCx virtual-display driver (M1 step-2 rewrite, on wdk-sys + the
|
||||
//! owned pf-vdisplay-proto ABI). See docs/windows-host-rewrite.md §14 for the full port plan.
|
||||
//! owned pf-driver-proto ABI). See docs/windows-host-rewrite.md §14 for the full port plan.
|
||||
//!
|
||||
//! STEP 2: the IddCx driver SKELETON — DriverEntry → driver_add builds the full `IDD_CX_CLIENT_CONFIG`
|
||||
//! (14 IddCx callbacks + the PnP `EvtDeviceD0Entry`, all stubs) sized via the versioned
|
||||
|
||||
@@ -16,24 +16,60 @@ fn file_log_enabled() -> bool {
|
||||
*ON.get_or_init(|| cfg!(debug_assertions) || std::env::var_os("PFVD_DEBUG_LOG").is_some())
|
||||
}
|
||||
|
||||
/// Process-lifetime append handle to the bring-up log, opened ONCE (by whichever thread logs first) and
|
||||
/// shared via a `Mutex` — so the swap-chain WORKER thread's writes land too. Per-call open/append raced
|
||||
/// the control thread and/or could fail under the worker's restricted token, hiding exactly the
|
||||
/// swap-chain-processor lines a game-break repro needs (game-capture bug S3). `flush` after each line so a
|
||||
/// crash/stall doesn't lose the tail.
|
||||
fn file_appender() -> Option<&'static std::sync::Mutex<std::fs::File>> {
|
||||
use std::sync::OnceLock;
|
||||
static APPENDER: OnceLock<Option<std::sync::Mutex<std::fs::File>>> = OnceLock::new();
|
||||
APPENDER
|
||||
.get_or_init(|| {
|
||||
if !file_log_enabled() {
|
||||
return None;
|
||||
}
|
||||
std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open("C:\\Users\\Public\\pfvd-driver.log")
|
||||
.ok()
|
||||
.map(std::sync::Mutex::new)
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
pub fn log(s: &str) {
|
||||
if let Ok(c) = std::ffi::CString::new(s) {
|
||||
// SAFETY: `c` is a valid NUL-terminated string for the duration of the call.
|
||||
unsafe { OutputDebugStringA(c.as_ptr().cast()) };
|
||||
}
|
||||
if !file_log_enabled() {
|
||||
return;
|
||||
}
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open("C:\\Users\\Public\\pfvd-driver.log")
|
||||
{
|
||||
let _ = writeln!(f, "{s}");
|
||||
if let Some(m) = file_appender() {
|
||||
if let Ok(mut f) = m.lock() {
|
||||
let _ = writeln!(f, "{s}");
|
||||
let _ = f.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! dbglog {
|
||||
($($a:tt)*) => { $crate::log::log(&::std::format!($($a)*)) };
|
||||
}
|
||||
|
||||
/// Zero-initialise a C POD struct (windows-rs / WDK / IddCx). These are `#[repr(C)]` framework structs
|
||||
/// whose all-zero bit pattern is a valid zero-initialised value; the caller stamps the required
|
||||
/// `.Size`/etc fields immediately after. Centralises the `unsafe { core::mem::zeroed() }` the IddCx/WDF
|
||||
/// bring-up needs — pass the type EXPLICITLY (`pod_init!(T)`) so it works without a binding annotation.
|
||||
/// Made crate-visible by the same `#[macro_use] mod log;` in `lib.rs` that exports `dbglog!`.
|
||||
macro_rules! pod_init {
|
||||
($t:ty) => {{
|
||||
// SAFETY: $t is a C POD (windows-rs/WDK/IddCx struct); its all-zero bit pattern is a valid
|
||||
// zero-initialised value and the caller sets the required .Size/etc fields immediately after.
|
||||
// `unused_unsafe`: pod_init! is also expanded at call sites already inside an `unsafe` block
|
||||
// (where this `unsafe` is redundant), but it IS required at the non-unsafe sites — so allow it.
|
||||
#[allow(unused_unsafe)]
|
||||
let zeroed = unsafe { ::core::mem::zeroed::<$t>() };
|
||||
zeroed
|
||||
}};
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
//! ([`crate::control`], `IOCTL_ADD`): each carries the requested mode (advertised as preferred) plus the
|
||||
//! `session_id` the host keys it by and the OS target id + render-adapter LUID captured at arrival. Ported
|
||||
//! from the working upstream virtual-display-rs (`monitor.rs` + `context.rs::create_monitor`), with
|
||||
//! `guid: u128` → `session_id: u64` for the owned `pf_vdisplay_proto` control plane.
|
||||
//! `guid: u128` → `session_id: u64` for the owned `pf_driver_proto` control plane.
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use wdk_sys::iddcx;
|
||||
@@ -60,9 +59,15 @@ pub struct MonitorObject {
|
||||
// SAFETY: the raw IddCx monitor handle is framework-managed; access is serialized by MONITOR_MODES.
|
||||
unsafe impl Send for MonitorObject {}
|
||||
|
||||
/// All live monitors. A process-`static` (not a WDFDEVICE-context-owned allocation) BY NECESSITY: the IddCx
|
||||
/// monitor/mode DDIs receive only an IddCx handle — never the WDFDEVICE or its context — so this state must
|
||||
/// be reachable without one (the upstream virtual-display-rs is a process-`static` for the same reason).
|
||||
/// With a single `pf_vdisplay` devnode + `UmdfHostProcessSharing=ProcessSharingDisabled` the host process
|
||||
/// (and this state) die WITH the device, so it is effectively device-scoped already; a `Box` + `AtomicPtr`
|
||||
/// "device-owned" variant (audit §2.5) would only add a use-after-free window — the host-gone watchdog
|
||||
/// thread ([`crate::control::start_watchdog`]) races device cleanup — for no real gain. Cleanup of the
|
||||
/// heavy per-monitor resources on device removal is instead done explicitly ([`cleanup_for_device_removal`]).
|
||||
pub static MONITOR_MODES: Mutex<Vec<MonitorObject>> = Mutex::new(Vec::new());
|
||||
/// Monitor id / EDID-serial counter (unique per created monitor).
|
||||
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
|
||||
|
||||
/// True if any virtual monitor currently exists — the host-gone watchdog only reaps when there's
|
||||
/// something to reap (see [`crate::control::start_watchdog`]).
|
||||
@@ -135,9 +140,7 @@ pub fn display_info(
|
||||
// Identical for every real mode; only an absurd (also now bounds-rejected) mode saturates.
|
||||
let clock_rate: u64 = u64::from(refresh_rate) * u64::from(height + 4) * u64::from(height + 4) + 1000;
|
||||
let clock_rate_u32 = u32::try_from(clock_rate).unwrap_or(u32::MAX);
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized
|
||||
// DISPLAYCONFIG_VIDEO_SIGNAL_INFO; every meaningful field is assigned below.
|
||||
let mut si: wdk_sys::DISPLAYCONFIG_VIDEO_SIGNAL_INFO = unsafe { core::mem::zeroed() };
|
||||
let mut si = pod_init!(wdk_sys::DISPLAYCONFIG_VIDEO_SIGNAL_INFO);
|
||||
si.pixelRate = clock_rate;
|
||||
si.hSyncFreq = wdk_sys::DISPLAYCONFIG_RATIONAL {
|
||||
Numerator: clock_rate_u32,
|
||||
@@ -168,9 +171,7 @@ pub fn target_mode(width: u32, height: u32, refresh_rate: u32) -> iddcx::IDDCX_T
|
||||
cx: width,
|
||||
cy: height,
|
||||
};
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized
|
||||
// DISPLAYCONFIG_VIDEO_SIGNAL_INFO; every meaningful field is assigned below.
|
||||
let mut si: wdk_sys::DISPLAYCONFIG_VIDEO_SIGNAL_INFO = unsafe { core::mem::zeroed() };
|
||||
let mut si = pod_init!(wdk_sys::DISPLAYCONFIG_VIDEO_SIGNAL_INFO);
|
||||
si.pixelRate = u64::from(refresh_rate) * u64::from(width) * u64::from(height);
|
||||
si.hSyncFreq = wdk_sys::DISPLAYCONFIG_RATIONAL {
|
||||
Numerator: refresh_rate * height,
|
||||
@@ -186,9 +187,7 @@ pub fn target_mode(width: u32, height: u32, refresh_rate: u32) -> iddcx::IDDCX_T
|
||||
wdk_sys::DISPLAYCONFIG_SCANLINE_ORDERING::DISPLAYCONFIG_SCANLINE_ORDERING_PROGRESSIVE;
|
||||
// videoStandard=255, vSyncFreqDivider=1 (bits 16..21) => 255 | (1<<16).
|
||||
si.__bindgen_anon_1.videoStandard = 255 | (1 << 16);
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized IDDCX_TARGET_MODE;
|
||||
// the required `.Size` (+ signal info) are set immediately below.
|
||||
let mut tm: iddcx::IDDCX_TARGET_MODE = unsafe { core::mem::zeroed() };
|
||||
let mut tm = pod_init!(iddcx::IDDCX_TARGET_MODE);
|
||||
tm.Size = core::mem::size_of::<iddcx::IDDCX_TARGET_MODE>() as u32;
|
||||
tm.TargetVideoSignalInfo = wdk_sys::DISPLAYCONFIG_TARGET_MODE {
|
||||
targetVideoSignalInfo: si,
|
||||
@@ -205,9 +204,7 @@ pub fn target_mode(width: u32, height: u32, refresh_rate: u32) -> iddcx::IDDCX_T
|
||||
pub fn wire_bits() -> iddcx::IDDCX_WIRE_BITS_PER_COMPONENT {
|
||||
let rgb = iddcx::IDDCX_BITS_PER_COMPONENT::IDDCX_BITS_PER_COMPONENT_8
|
||||
| iddcx::IDDCX_BITS_PER_COMPONENT::IDDCX_BITS_PER_COMPONENT_10;
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized
|
||||
// IDDCX_WIRE_BITS_PER_COMPONENT; every field is assigned below.
|
||||
let mut w: iddcx::IDDCX_WIRE_BITS_PER_COMPONENT = unsafe { core::mem::zeroed() };
|
||||
let mut w = pod_init!(iddcx::IDDCX_WIRE_BITS_PER_COMPONENT);
|
||||
w.Rgb = rgb;
|
||||
w.YCbCr444 = iddcx::IDDCX_BITS_PER_COMPONENT::IDDCX_BITS_PER_COMPONENT_NONE;
|
||||
w.YCbCr422 = iddcx::IDDCX_BITS_PER_COMPONENT::IDDCX_BITS_PER_COMPONENT_NONE;
|
||||
@@ -220,9 +217,7 @@ pub fn wire_bits() -> iddcx::IDDCX_WIRE_BITS_PER_COMPONENT {
|
||||
/// zeroed.
|
||||
pub fn target_mode2(width: u32, height: u32, refresh_rate: u32) -> iddcx::IDDCX_TARGET_MODE2 {
|
||||
let m1 = target_mode(width, height, refresh_rate);
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized IDDCX_TARGET_MODE2;
|
||||
// the required `.Size` (+ signal info + bit depth) are set immediately below.
|
||||
let mut tm: iddcx::IDDCX_TARGET_MODE2 = unsafe { core::mem::zeroed() };
|
||||
let mut tm = pod_init!(iddcx::IDDCX_TARGET_MODE2);
|
||||
tm.Size = core::mem::size_of::<iddcx::IDDCX_TARGET_MODE2>() as u32;
|
||||
tm.TargetVideoSignalInfo = m1.TargetVideoSignalInfo;
|
||||
tm.BitsPerComponent = wire_bits();
|
||||
@@ -296,7 +291,7 @@ pub fn take_swap_chain_processor(
|
||||
}
|
||||
|
||||
/// `IOCTL_ADD`: create + arrive a virtual monitor at `width`x`height`@`refresh`. Returns the OS
|
||||
/// `(target_id, adapter_luid_low, adapter_luid_high)` for the [`AddReply`](pf_vdisplay_proto::control::AddReply),
|
||||
/// `(target_id, adapter_luid_low, adapter_luid_high)` for the [`AddReply`](pf_driver_proto::control::AddReply),
|
||||
/// or `None` on failure (no adapter yet / IddCx error).
|
||||
pub fn create_monitor(
|
||||
session_id: u64,
|
||||
@@ -305,8 +300,16 @@ pub fn create_monitor(
|
||||
refresh: u32,
|
||||
) -> Option<(u32, u32, i32)> {
|
||||
let adapter = crate::adapter::adapter()?;
|
||||
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
// Single identity per session (E1): if the host re-ADDs a still-live `session_id` (it shouldn't), depart
|
||||
// the stale monitor first, so one session maps to exactly one monitor (no duplicate EDID/target lingers).
|
||||
if MONITOR_MODES
|
||||
.lock()
|
||||
.map(|l| l.iter().any(|m| m.session_id == session_id))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
dbglog!("[pf-vd] create_monitor: session {session_id} already live — departing the stale monitor");
|
||||
remove_monitor(session_id);
|
||||
}
|
||||
let mut modes = vec![Mode {
|
||||
width,
|
||||
height,
|
||||
@@ -314,8 +317,17 @@ pub fn create_monitor(
|
||||
}];
|
||||
modes.extend(default_modes());
|
||||
|
||||
// Register the (pending) monitor so the mode DDIs can find it by EDID-serial id before arrival.
|
||||
if let Ok(mut lock) = MONITOR_MODES.lock() {
|
||||
// Register the (pending) monitor so the mode DDIs can find it by EDID-serial id before arrival, under a
|
||||
// REUSED id (the lowest not currently live). Reclaiming the id on REMOVE — instead of a monotonic
|
||||
// counter — keeps the connector index / EDID serial / container GUID bounded, so IddCx reuses the same
|
||||
// OS target slot on a fresh ADD rather than leaving a ghost monitor node behind (the slot-exhaustion
|
||||
// wedge: sustained ADD/REMOVE churn eventually makes ADD fail 0x80070490 ERROR_NOT_FOUND). Allocated
|
||||
// under the lock with the push so two concurrent ADDs can't pick the same id.
|
||||
let id = {
|
||||
let Ok(mut lock) = MONITOR_MODES.lock() else {
|
||||
return None;
|
||||
};
|
||||
let id = alloc_monitor_id(&lock);
|
||||
lock.push(MonitorObject {
|
||||
object: None,
|
||||
id,
|
||||
@@ -327,15 +339,12 @@ pub fn create_monitor(
|
||||
swap_chain_processor: None,
|
||||
created_at: Instant::now(),
|
||||
});
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
id
|
||||
};
|
||||
|
||||
// EDID (serial = id) describes the monitor; the OS calls back into parse_monitor_description.
|
||||
let mut edid = crate::edid::Edid::generate_with(id);
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized
|
||||
// IDDCX_MONITOR_DESCRIPTION; the required `.Size`/Type/DataSize/pData are set immediately below.
|
||||
let mut desc: iddcx::IDDCX_MONITOR_DESCRIPTION = unsafe { core::mem::zeroed() };
|
||||
let mut desc = pod_init!(iddcx::IDDCX_MONITOR_DESCRIPTION);
|
||||
desc.Size = core::mem::size_of::<iddcx::IDDCX_MONITOR_DESCRIPTION>() as u32;
|
||||
desc.Type = iddcx::IDDCX_MONITOR_DESCRIPTION_TYPE::IDDCX_MONITOR_DESCRIPTION_TYPE_EDID;
|
||||
desc.DataSize = edid.len() as u32;
|
||||
@@ -343,9 +352,7 @@ pub fn create_monitor(
|
||||
// reads through `pData` SYNCHRONOUSLY, before `edid` drops — the pointer never escapes the call.
|
||||
desc.pData = edid.as_mut_ptr().cast();
|
||||
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized IDDCX_MONITOR_INFO;
|
||||
// the required `.Size` (+ container id / type / connector / description) are set immediately below.
|
||||
let mut info: iddcx::IDDCX_MONITOR_INFO = unsafe { core::mem::zeroed() };
|
||||
let mut info = pod_init!(iddcx::IDDCX_MONITOR_INFO);
|
||||
info.Size = core::mem::size_of::<iddcx::IDDCX_MONITOR_INFO>() as u32;
|
||||
info.MonitorContainerId = container_guid(id);
|
||||
info.MonitorType =
|
||||
@@ -353,9 +360,7 @@ pub fn create_monitor(
|
||||
info.ConnectorIndex = id;
|
||||
info.MonitorDescription = desc;
|
||||
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized WDF_OBJECT_ATTRIBUTES;
|
||||
// the required `.Size` (+ execution/sync scope) are set immediately below.
|
||||
let mut attr: wdk_sys::WDF_OBJECT_ATTRIBUTES = unsafe { core::mem::zeroed() };
|
||||
let mut attr = pod_init!(wdk_sys::WDF_OBJECT_ATTRIBUTES);
|
||||
attr.Size = core::mem::size_of::<wdk_sys::WDF_OBJECT_ATTRIBUTES>() as u32;
|
||||
attr.ExecutionLevel = wdk_sys::_WDF_EXECUTION_LEVEL::WdfExecutionLevelInheritFromParent;
|
||||
attr.SynchronizationScope =
|
||||
@@ -365,9 +370,7 @@ pub fn create_monitor(
|
||||
ObjectAttributes: &raw mut attr,
|
||||
pMonitorInfo: &raw mut info,
|
||||
};
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized IDARG_OUT_MONITORCREATE
|
||||
// (an out-param the framework fills).
|
||||
let mut create_out: iddcx::IDARG_OUT_MONITORCREATE = unsafe { core::mem::zeroed() };
|
||||
let mut create_out = pod_init!(iddcx::IDARG_OUT_MONITORCREATE);
|
||||
// SAFETY: adapter is a valid IddCx adapter; create_in points to valid local storage read synchronously.
|
||||
let st = unsafe { wdk_iddcx::IddCxMonitorCreate(adapter, &create_in, &mut create_out) };
|
||||
dbglog!("[pf-vd] IddCxMonitorCreate(id={id}) -> {st:#x}");
|
||||
@@ -383,9 +386,7 @@ pub fn create_monitor(
|
||||
}
|
||||
|
||||
// Tell the OS the monitor is plugged in.
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized IDARG_OUT_MONITORARRIVAL
|
||||
// (an out-param the framework fills).
|
||||
let mut arrival_out: iddcx::IDARG_OUT_MONITORARRIVAL = unsafe { core::mem::zeroed() };
|
||||
let mut arrival_out = pod_init!(iddcx::IDARG_OUT_MONITORARRIVAL);
|
||||
// SAFETY: `monitor` is the just-created IddCx monitor handle.
|
||||
let st = unsafe { wdk_iddcx::IddCxMonitorArrival(monitor, &mut arrival_out) };
|
||||
dbglog!("[pf-vd] IddCxMonitorArrival(id={id}) -> {st:#x}");
|
||||
@@ -459,6 +460,27 @@ pub fn clear_all() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `EvtCleanupCallback` (device removal, [`crate::callbacks::device_cleanup`]): drop every monitor's heavy
|
||||
/// resources — the swap-chain processor workers (each RAII-joins its thread + deletes its swap-chain) — and
|
||||
/// clear the list, WITHOUT `IddCxMonitorDeparture` (the framework tears the IddCx monitors down together
|
||||
/// with the departing device; departing here would double-tear). Frees our worker threads promptly even
|
||||
/// though the per-devnode WUDFHost (`ProcessSharingDisabled`) would also reap them when it exits.
|
||||
pub fn cleanup_for_device_removal() {
|
||||
let mut drained: Vec<Option<crate::swap_chain_processor::SwapChainProcessor>> = {
|
||||
let Ok(mut lock) = MONITOR_MODES.lock() else {
|
||||
return;
|
||||
};
|
||||
lock.drain(..)
|
||||
.map(|mut m| m.swap_chain_processor.take())
|
||||
.collect()
|
||||
};
|
||||
// Drop the workers (join their threads) AFTER releasing the lock — joining under MONITOR_MODES would
|
||||
// head-block the control plane (same discipline as remove_monitor / clear_all).
|
||||
for processor in &mut drained {
|
||||
drop(processor.take());
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a pending entry by id (create failed before arrival).
|
||||
fn remove_by_id(id: u32) {
|
||||
if let Ok(mut lock) = MONITOR_MODES.lock() {
|
||||
@@ -466,6 +488,17 @@ fn remove_by_id(id: u32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The lowest monitor id (≥1) not currently live. Reusing freed ids (instead of a monotonic counter) keeps
|
||||
/// the connector index / EDID serial / container GUID bounded to the number of concurrent monitors, so a
|
||||
/// fresh ADD reuses a departed monitor's OS target slot rather than allocating a new one and orphaning the
|
||||
/// old (the ghost-monitor accumulation that wedges ADD at 0x80070490 ERROR_NOT_FOUND). Caller holds
|
||||
/// `MONITOR_MODES`. With ≤ N live ids, a free one always exists in `1..=N+1` (pigeonhole).
|
||||
fn alloc_monitor_id(modes: &[MonitorObject]) -> u32 {
|
||||
(1u32..=modes.len() as u32 + 1)
|
||||
.find(|id| !modes.iter().any(|m| m.id == *id))
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
/// A deterministic, monitor-unique container GUID (groups targets into a physical device). Derived from
|
||||
/// `id` so it is stable + collision-free without a random source.
|
||||
fn container_guid(id: u32) -> wdk_sys::GUID {
|
||||
|
||||
@@ -183,9 +183,7 @@ impl SwapChainProcessor {
|
||||
}
|
||||
};
|
||||
// Built zeroed + field-assigned (driver style) — robust against a bindgen field-set difference.
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized
|
||||
// IDARG_IN_SWAPCHAINSETDEVICE; the `pDevice` field is set immediately below.
|
||||
let mut set_device: IDARG_IN_SWAPCHAINSETDEVICE = unsafe { core::mem::zeroed() };
|
||||
let mut set_device = pod_init!(IDARG_IN_SWAPCHAINSETDEVICE);
|
||||
set_device.pDevice = dxgi_device.as_raw().cast();
|
||||
let mut set_ok = false;
|
||||
let mut terminated = false;
|
||||
@@ -280,20 +278,16 @@ impl SwapChainProcessor {
|
||||
// the GPU surface (out.MetaData.pSurface) — STEP 6 publishes it into the shared ring in the
|
||||
// success branch below. Built zeroed + field-assigned (driver style) so a bindgen field-set
|
||||
// difference can't break a positional struct literal.
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized
|
||||
// IDARG_IN_RELEASEANDACQUIREBUFFER2; the required `.Size`/AcquireSystemMemoryBuffer are set below.
|
||||
let mut in_args: IDARG_IN_RELEASEANDACQUIREBUFFER2 = unsafe { core::mem::zeroed() };
|
||||
let mut in_args = pod_init!(IDARG_IN_RELEASEANDACQUIREBUFFER2);
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
{
|
||||
in_args.Size = size_of::<IDARG_IN_RELEASEANDACQUIREBUFFER2>() as u32;
|
||||
}
|
||||
in_args.AcquireSystemMemoryBuffer = 0;
|
||||
// `core::mem::zeroed()` (not `::default()`) — consistent with every other IddCx out-struct
|
||||
// `pod_init!` (zeroed, not `::default()`) — consistent with every other IddCx out-struct
|
||||
// in this driver, and robust whether or not bindgen derives `Default` for this type (its
|
||||
// `MetaData` field carries a raw `pSurface` pointer + union which can suppress the derive).
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized
|
||||
// IDARG_OUT_RELEASEANDACQUIREBUFFER2 (an out-param the framework fills).
|
||||
let mut buffer: IDARG_OUT_RELEASEANDACQUIREBUFFER2 = unsafe { core::mem::zeroed() };
|
||||
let mut buffer = pod_init!(IDARG_OUT_RELEASEANDACQUIREBUFFER2);
|
||||
// SAFETY: driver is loaded; `swap_chain` is valid; in/out point to valid local storage.
|
||||
let hr: NTSTATUS = unsafe {
|
||||
wdk_iddcx::IddCxSwapChainReleaseAndAcquireBuffer2(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# M0/M1 toolchain probe: the smallest possible UMDF2 driver on windows-drivers-rs (crates.io wdk 0.5).
|
||||
# Purpose: prove on the windows-amd64 runner that (1) wdk-sys bindgen + WDF stub link works against the
|
||||
# runner's WDK + LLVM, (2) the shared no_std pf-vdisplay-proto ABI crate path-deps cleanly into a driver
|
||||
# runner's WDK + LLVM, (2) the shared no_std pf-driver-proto ABI crate path-deps cleanly into a driver
|
||||
# build graph, and (3) what the produced DLL's PE FORCE_INTEGRITY (/INTEGRITYCHECK) bit is. NOT shipped.
|
||||
[package]
|
||||
name = "wdk-probe"
|
||||
@@ -26,4 +26,4 @@ wdk.workspace = true
|
||||
# This is the M1 make-or-break: does IddCx.h bindgen in wdk-sys's config without a header conflict, and
|
||||
# do its WDF/DXGI types resolve to wdk-sys's (so the generated module compiles)?
|
||||
wdk-sys = { workspace = true, features = ["iddcx"] }
|
||||
pf-vdisplay-proto.workspace = true
|
||||
pf-driver-proto.workspace = true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! crate's Cargo.toml). DriverEntry → WdfDriverCreate → (EvtDeviceAdd) IddCxDeviceInitConfig →
|
||||
//! WdfDeviceCreate → IddCxDeviceInitialize → IddCxAdapterInitAsync: enough to exercise the wdk-sys WDF
|
||||
//! stub link AND prove the `iddcx` subset is callable + links against `IddCxStub`. Also force-links the
|
||||
//! shared `pf-vdisplay-proto` ABI crate (no_std + bytemuck) across the workspace boundary.
|
||||
//! shared `pf-driver-proto` ABI crate (no_std + bytemuck) across the workspace boundary.
|
||||
|
||||
#![allow(non_snake_case, clippy::missing_safety_doc)]
|
||||
|
||||
@@ -18,10 +18,10 @@ use wdk_sys::{
|
||||
|
||||
const STATUS_SUCCESS: NTSTATUS = 0;
|
||||
|
||||
/// Force `pf-vdisplay-proto` to actually link into the driver build graph (validates the cross-workspace
|
||||
/// Force `pf-driver-proto` to actually link into the driver build graph (validates the cross-workspace
|
||||
/// path-dep + that the no_std bytemuck ABI crate compiles for a UMDF cdylib). `#[used]` keeps it.
|
||||
#[used]
|
||||
static PROTO_GUID_LO: u64 = pf_vdisplay_proto::PF_VDISPLAY_INTERFACE_GUID_U128 as u64;
|
||||
static PROTO_GUID_LO: u64 = pf_driver_proto::PF_VDISPLAY_INTERFACE_GUID_U128 as u64;
|
||||
|
||||
/// IddCx (stub mode) requires the driver to export the minimum IddCx framework version it needs — the
|
||||
/// `#ifndef IDD_STUB` branch of `IddCxFuncEnum.h` (which normally emits it) is compiled out under
|
||||
|
||||
@@ -141,7 +141,7 @@ $defines = @(
|
||||
)
|
||||
|
||||
# --- stage the pf-vdisplay virtual-display driver bundle --------------------------------------
|
||||
# pf-vdisplay is our all-Rust IddCx driver (packaging/windows/vdisplay-driver/), vendored signed under
|
||||
# pf-vdisplay is our all-Rust IddCx driver (packaging/windows/drivers/), vendored signed under
|
||||
# packaging/windows/pf-vdisplay/. It replaced the vendored SudoVDA C++ driver.
|
||||
if (-not $NoDriver) {
|
||||
$stage = Join-Path $OutDir 'stage'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
; pf-vdisplay - punktfunk virtual display, UMDF2 IddCx driver INF (template; stampinf -> .inf).
|
||||
;
|
||||
; For the all-Rust wdk-sys / windows-drivers-rs driver in THIS tree
|
||||
; (packaging/windows/drivers/pf-vdisplay/). The driver registers the OWNED pf_vdisplay_proto
|
||||
; (packaging/windows/drivers/pf-vdisplay/). The driver registers the OWNED pf_driver_proto
|
||||
; control-interface GUID in CODE (WdfDeviceCreateDeviceInterface), so this INF is GUID-agnostic and
|
||||
; is byte-identical to the superseded oracle's (packaging/windows/vdisplay-driver/.../pf_vdisplay.inx,
|
||||
; itself adapted from MolotovCherry/virtual-display-rs (MIT) + SudoVDA's control-device security DACL).
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
One-shot DEV redeploy of the pf-vdisplay (punktfunk) virtual-display driver on the test box:
|
||||
(optional) build -> stop host -> stage+sign+install -> reload the adapter -> start host.
|
||||
|
||||
.DESCRIPTION
|
||||
Wraps drivers/deploy-dev.ps1 (which stages the freshly built pf_vdisplay.dll, clears its
|
||||
FORCE_INTEGRITY PE bit, signs it, stamps a STRICTLY-INCREASING DriverVer, builds+signs the catalog,
|
||||
and pnputil-installs it) with the two things the dev loop always needs around it:
|
||||
|
||||
* The running host service HOLDS the driver's control device, and pnputil can't replace a busy
|
||||
DLL - so the host must be stopped across the install. This stops it first and starts it after.
|
||||
* pnputil /add-driver /install updates the driver STORE, but the OS keeps the LIVE adapter on the
|
||||
old binary until the device is reloaded - so this cycles the adapter (reset-pf-vdisplay.ps1)
|
||||
after install, which also clears the ghost monitor nodes for a clean slate.
|
||||
|
||||
Run ELEVATED. Use -Build only from an MSVC dev shell (the driver's cargo build needs LIBCLANG_PATH
|
||||
+ Version_Number=10.0.26100.0, per drivers/deploy-dev.ps1); otherwise build separately and omit it.
|
||||
|
||||
.PARAMETER Build Run `cargo build` in packaging/windows/drivers first (needs the MSVC env).
|
||||
.PARAMETER Service Host service name. Default PunktfunkHost.
|
||||
.PARAMETER Thumbprint Passthrough to deploy-dev.ps1 (test-cert SHA-1). Omit to use its default.
|
||||
.PARAMETER Nefconc Passthrough to deploy-dev.ps1 (nefconc.exe path). Omit to use its default.
|
||||
.PARAMETER Verify After redeploy, probe to confirm ADD works (passes through to the reset's
|
||||
-Verify; needs -Probe or punktfunk-probe.exe on PATH).
|
||||
.PARAMETER Probe Path to punktfunk-probe.exe for -Verify.
|
||||
|
||||
.EXAMPLE
|
||||
# already built the driver in an MSVC shell -> deploy it cleanly:
|
||||
powershell -ExecutionPolicy Bypass -File redeploy-pf-vdisplay.ps1
|
||||
.EXAMPLE
|
||||
# build + deploy + verify, from an MSVC dev shell:
|
||||
powershell -ExecutionPolicy Bypass -File redeploy-pf-vdisplay.ps1 -Build -Verify -Probe C:\t-goal1\debug\punktfunk-probe.exe
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Build,
|
||||
[string]$Service = 'PunktfunkHost',
|
||||
[string]$Thumbprint,
|
||||
[string]$Nefconc,
|
||||
[switch]$Verify,
|
||||
[string]$Probe
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$driversDir = Join-Path $here 'drivers'
|
||||
$deploy = Join-Path $driversDir 'deploy-dev.ps1'
|
||||
$reset = Join-Path $here 'reset-pf-vdisplay.ps1'
|
||||
foreach ($f in @($deploy, $reset)) { if (-not (Test-Path $f)) { throw "missing helper: $f" } }
|
||||
|
||||
# 1) Optional rebuild (MSVC dev shell only).
|
||||
if ($Build) {
|
||||
Write-Host "==> cargo build (pf-vdisplay driver, $driversDir)"
|
||||
Push-Location $driversDir
|
||||
try {
|
||||
cargo build
|
||||
if ($LASTEXITCODE -ne 0) { throw "cargo build failed ($LASTEXITCODE) - is this an MSVC dev shell with LIBCLANG_PATH + Version_Number set?" }
|
||||
}
|
||||
finally { Pop-Location }
|
||||
}
|
||||
|
||||
# 2) Stop the host (it holds the driver DLL; pnputil can't replace a busy binary).
|
||||
$svc = Get-Service $Service -ErrorAction SilentlyContinue
|
||||
if ($svc -and $svc.Status -eq 'Running') {
|
||||
Write-Host "==> stopping $Service"
|
||||
Stop-Service $Service -Force
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
# 3) Stage + sign + install (strictly-increasing DriverVer so pnputil takes the new binary).
|
||||
$deployArgs = @{ Install = $true }
|
||||
if ($Thumbprint) { $deployArgs.Thumbprint = $Thumbprint }
|
||||
if ($Nefconc) { $deployArgs.Nefconc = $Nefconc }
|
||||
Write-Host "==> deploy-dev.ps1 -Install"
|
||||
& $deploy @deployArgs
|
||||
|
||||
# 4) Reload the adapter so the OS loads the freshly-installed binary (+ clear ghost nodes). The reset
|
||||
# leaves the host alone (-NoHost) - we own the service lifecycle here.
|
||||
Write-Host "==> reloading the pf-vdisplay adapter (clean slate)"
|
||||
& $reset -NoHost
|
||||
|
||||
# 5) Start the host.
|
||||
if ($svc) {
|
||||
Write-Host "==> starting $Service"
|
||||
Start-Service $Service
|
||||
Start-Sleep -Seconds 3
|
||||
Write-Host " $Service status: $((Get-Service $Service -ErrorAction SilentlyContinue).Status)"
|
||||
}
|
||||
|
||||
# 6) Optional verification probe.
|
||||
if ($Verify) {
|
||||
$vArgs = @{ NoHost = $true; KeepGhosts = $true; Verify = $true }
|
||||
if ($Probe) { $vArgs.Probe = $Probe }
|
||||
& $reset @vArgs
|
||||
}
|
||||
|
||||
Write-Host "pf-vdisplay redeploy done."
|
||||
@@ -0,0 +1,130 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Recover the pf-vdisplay (punktfunk) virtual-display driver after it WEDGES under rapid ADD/REMOVE
|
||||
churn - no reboot. The dev-iteration counterpart to redeploy-pf-vdisplay.ps1.
|
||||
|
||||
.DESCRIPTION
|
||||
Sustained connect/disconnect churn (e.g. a client reconnect loop x the host's 8 pipeline-build
|
||||
retries - ~100 ADD/REMOVE cycles) exhausts the driver's IddCx monitor slots: the per-monitor
|
||||
target_ids climb, ghost "Generic Monitor (punktfunk)" device nodes pile up, and eventually
|
||||
IOCTL_ADD returns 0x80070490 ERROR_NOT_FOUND ("Element nicht gefunden"). Every session then fails
|
||||
to create a virtual output -> the client gets a hard blackscreen. A host-service restart's
|
||||
IOCTL_CLEAR_ALL does NOT recover it; the driver instance itself must be reloaded.
|
||||
|
||||
Steps (run ELEVATED):
|
||||
1. Stop the host service (it holds the driver's control device).
|
||||
2. pnputil /remove-device the GHOST (Status != OK = not-present) punktfunk virtual-monitor nodes
|
||||
that accumulated - the root of the slot exhaustion.
|
||||
3. Disable + Enable the pf-vdisplay adapter (ROOT\DISPLAY\*, "punktfunk Virtual Display") to
|
||||
reload the IddCx driver instance and reset its monitor list. (Restart-PnpDevice does NOT exist
|
||||
on this box's PowerShell, so we disable+enable explicitly.)
|
||||
4. Restart the host service.
|
||||
Avoids a reboot on purpose (this box boots to Proxmox).
|
||||
|
||||
.PARAMETER Service Host service name. Default PunktfunkHost.
|
||||
.PARAMETER AdapterName FriendlyName substring of the IddCx adapter to cycle. Default "punktfunk
|
||||
Virtual Display" (NOT SudoVDA's "SudoMaker Virtual Display Adapter").
|
||||
.PARAMETER GhostMatch FriendlyName substring of the virtual monitors to reap. Default "punktfunk".
|
||||
.PARAMETER KeepGhosts Skip the ghost-node cleanup; only cycle the adapter.
|
||||
.PARAMETER NoHost Don't stop/start the host service (just reset the driver) - used by
|
||||
redeploy-pf-vdisplay.ps1, which manages the service itself.
|
||||
.PARAMETER Verify After recovery, run a punktfunk-probe loopback and report whether ADD works
|
||||
again (best-effort; needs punktfunk-probe.exe on PATH or via -Probe).
|
||||
.PARAMETER Probe Path to punktfunk-probe.exe for -Verify.
|
||||
|
||||
.EXAMPLE
|
||||
powershell -ExecutionPolicy Bypass -File reset-pf-vdisplay.ps1
|
||||
.EXAMPLE
|
||||
powershell -ExecutionPolicy Bypass -File reset-pf-vdisplay.ps1 -Verify -Probe C:\t-goal1\debug\punktfunk-probe.exe
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Service = 'PunktfunkHost',
|
||||
[string]$AdapterName = 'punktfunk Virtual Display',
|
||||
[string]$GhostMatch = 'punktfunk',
|
||||
[switch]$KeepGhosts,
|
||||
[switch]$NoHost,
|
||||
[switch]$Verify,
|
||||
[string]$Probe
|
||||
)
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
function Get-PfAdapter {
|
||||
Get-PnpDevice -Class Display -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.FriendlyName -match $AdapterName } | Select-Object -First 1
|
||||
}
|
||||
|
||||
# 1) Stop the host so it isn't mid-IOCTL during the reset (it holds the control device).
|
||||
$svc = Get-Service $Service -ErrorAction SilentlyContinue
|
||||
$hostWasRunning = $svc -and $svc.Status -eq 'Running'
|
||||
if (-not $NoHost -and $hostWasRunning) {
|
||||
Write-Host "==> stopping $Service"
|
||||
Stop-Service $Service -Force
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
# 2) Reap the ghost (not-present) punktfunk virtual-monitor device nodes.
|
||||
if (-not $KeepGhosts) {
|
||||
$ghosts = Get-PnpDevice -Class Monitor -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Status -ne 'OK' -and $_.FriendlyName -match $GhostMatch }
|
||||
Write-Host "==> removing $($ghosts.Count) ghost virtual-monitor node(s)"
|
||||
$removed = 0
|
||||
foreach ($g in $ghosts) {
|
||||
pnputil /remove-device $g.InstanceId *> $null
|
||||
if ($LASTEXITCODE -eq 0) { $removed++ }
|
||||
}
|
||||
Write-Host " removed $removed"
|
||||
}
|
||||
|
||||
# 3) Reload the IddCx adapter instance (disable + enable) to clear its monitor list.
|
||||
$ad = Get-PfAdapter
|
||||
if (-not $ad) {
|
||||
Write-Warning "pf-vdisplay adapter '$AdapterName' not found (Class Display) - is the driver installed?"
|
||||
}
|
||||
else {
|
||||
Write-Host "==> cycling adapter $($ad.InstanceId)"
|
||||
Disable-PnpDevice -InstanceId $ad.InstanceId -Confirm:$false -ErrorAction Continue
|
||||
Start-Sleep -Seconds 3
|
||||
Enable-PnpDevice -InstanceId $ad.InstanceId -Confirm:$false -ErrorAction Continue
|
||||
Start-Sleep -Seconds 3
|
||||
$st = (Get-PnpDevice -InstanceId $ad.InstanceId -ErrorAction SilentlyContinue).Status
|
||||
if ($st -ne 'OK') {
|
||||
# One retry - a disabled root device occasionally needs a second enable to come back OK.
|
||||
Enable-PnpDevice -InstanceId $ad.InstanceId -Confirm:$false -ErrorAction Continue
|
||||
Start-Sleep -Seconds 2
|
||||
$st = (Get-PnpDevice -InstanceId $ad.InstanceId -ErrorAction SilentlyContinue).Status
|
||||
}
|
||||
Write-Host " adapter status: $st"
|
||||
}
|
||||
|
||||
# 4) Restart the host.
|
||||
if (-not $NoHost -and $svc) {
|
||||
Write-Host "==> starting $Service"
|
||||
Start-Service $Service
|
||||
Start-Sleep -Seconds 3
|
||||
Write-Host " $Service status: $((Get-Service $Service -ErrorAction SilentlyContinue).Status)"
|
||||
}
|
||||
|
||||
# 5) Optional: probe to confirm ADD recovers.
|
||||
if ($Verify) {
|
||||
if (-not $Probe) {
|
||||
$Probe = (Get-Command punktfunk-probe.exe -ErrorAction SilentlyContinue).Source
|
||||
}
|
||||
if (-not $Probe -or -not (Test-Path $Probe)) {
|
||||
Write-Warning "-Verify: punktfunk-probe.exe not found (pass -Probe <path>); skipping verification."
|
||||
}
|
||||
else {
|
||||
$log = Join-Path $env:ProgramData 'punktfunk\logs\host.log'
|
||||
Write-Host "==> verifying with $Probe"
|
||||
& $Probe *> $null
|
||||
Start-Sleep -Seconds 2
|
||||
$last = Get-Content $log -Tail 80 -ErrorAction SilentlyContinue |
|
||||
Select-String -Pattern 'pf-vdisplay created|Element nicht|0x80070490' | Select-Object -Last 1
|
||||
if ($last -match 'created') { Write-Host " OK: ADD succeeded after reset." }
|
||||
elseif ($last) { Write-Warning " ADD still failing after reset: $($last.Line.Trim())" }
|
||||
else { Write-Warning " no ADD outcome found in the log; check $log." }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "pf-vdisplay reset done."
|
||||
@@ -4,11 +4,11 @@
|
||||
driver + the fetched nefcon device tool.
|
||||
|
||||
.DESCRIPTION
|
||||
pf-vdisplay (our all-Rust IddCx virtual display) is built from packaging/windows/vdisplay-driver/, and
|
||||
pf-vdisplay (our all-Rust IddCx virtual display) is built from packaging/windows/drivers/, and
|
||||
the SIGNED output (pf_vdisplay.dll/.inf/.cat + punktfunk-driver.cer) is VENDORED under
|
||||
packaging/windows/pf-vdisplay/ (signer punktfunk-ds-test — shared with the gamepad drivers — Class=
|
||||
Display, HWID root\pf_vdisplay). Rebuild + re-vendor with
|
||||
packaging/windows/vdisplay-driver/deploy-dev.ps1 when the driver source changes, then copy the staged
|
||||
packaging/windows/drivers/deploy-dev.ps1 when the driver source changes, then copy the staged
|
||||
pf_vdisplay.{dll,inf,cat} over the vendored copies. nefcon publishes a pinned release, so we fetch +
|
||||
SHA-256-verify it (it provides nefconc.exe, used to create the root-enumerated device node — pnputil
|
||||
can't).
|
||||
@@ -36,7 +36,7 @@ New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
||||
|
||||
# --- vendored pf-vdisplay driver --------------------------------------------------------------
|
||||
$inf = Get-ChildItem -Path $VendorDir -Filter pf_vdisplay.inf -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if (-not $inf) { throw "no vendored pf_vdisplay.inf under $VendorDir — re-vendor via vdisplay-driver/deploy-dev.ps1" }
|
||||
if (-not $inf) { throw "no vendored pf_vdisplay.inf under $VendorDir — re-vendor via drivers/deploy-dev.ps1" }
|
||||
Copy-Item (Join-Path $VendorDir '*') $OutDir -Force
|
||||
Write-Host "==> vendored pf-vdisplay staged from $VendorDir"
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[build]
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
@@ -1,3 +0,0 @@
|
||||
/target
|
||||
*.cer
|
||||
*.pfx
|
||||
-510
@@ -1,510 +0,0 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.70.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools",
|
||||
"log",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
"shlex",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||
dependencies = [
|
||||
"bytemuck_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck_derive"
|
||||
version = "1.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cexpr"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "clang-sys"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
|
||||
dependencies = [
|
||||
"glob",
|
||||
"libc",
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytemuck",
|
||||
"log",
|
||||
"thiserror",
|
||||
"wdf-umdf",
|
||||
"wdf-umdf-sys",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "wdf-umdf"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"paste",
|
||||
"thiserror",
|
||||
"wdf-umdf-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wdf-umdf-sys"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"bytemuck",
|
||||
"paste",
|
||||
"thiserror",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
|
||||
dependencies = [
|
||||
"windows-result",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys",
|
||||
]
|
||||
@@ -1,26 +0,0 @@
|
||||
# pf-vdisplay — punktfunk Windows virtual display (IddCx), in Rust.
|
||||
#
|
||||
# A self-contained driver workspace (NOT built on windows-drivers-rs like the gamepad drivers — IddCx
|
||||
# functions are direct IddCxStub exports the WDF function-table macro can't reach, so a unified bindgen
|
||||
# is the cleaner base). The wdf-umdf-sys / wdf-umdf binding crates are vendored from MolotovCherry's
|
||||
# MIT-licensed virtual-display-rs (see LICENSE.virtual-display-rs); pf-vdisplay is our driver, swapping
|
||||
# its named-pipe IPC for the SudoVDA-compatible IOCTL control plane our host already speaks.
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["wdf-umdf-sys", "wdf-umdf", "pf-vdisplay"]
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_op_in_unsafe_fn = "deny"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
multiple_unsafe_ops_per_block = "deny"
|
||||
ignored_unit_patterns = "allow"
|
||||
missing_errors_doc = "allow"
|
||||
module_inception = "allow"
|
||||
module_name_repetitions = "allow"
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Cherry
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user