72 Commits

Author SHA1 Message Date
enricobuehler 3e7c9bd059 fix(host): remove unsound unsafe impl Sync for HelperRelay
apple / screenshots (push) Has been skipped
windows-drivers / probe-and-proto (push) Successful in 29s
audit / cargo-audit (push) Failing after 1m20s
windows-drivers / driver-build (push) Successful in 1m14s
ci / web (push) Successful in 46s
ci / docs-site (push) Successful in 1m3s
windows-host / package (push) Successful in 6m46s
apple / swift (push) Failing after 0s
release / apple (push) Failing after 0s
android / android (push) Failing after 2m5s
ci / bench (push) Successful in 4m34s
decky / build-publish (push) Successful in 22s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m25s
ci / rust (push) Successful in 8m36s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m11s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 59s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m37s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m3s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
deb / build-publish (push) Successful in 7m50s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m52s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m33s
flatpak / build-publish (push) Successful in 3m56s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m46s
docker / deploy-docs (push) Successful in 22s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m26s
The one genuine soundness defect the unsafe-proof program surfaced (flagged
SUSPECT in program 3/N). `HelperRelay` holds an `rx: Receiver<RelayAu>`, which is
`!Sync` (std mpsc is single-consumer), so asserting `Sync` claimed more than the
fields support — an `Arc<HelperRelay>` recv'd from two threads would compile and
be UB.

It was never live-exploited, and it turns out `Sync` is also unnecessary: the
relay is a single-owner `mut relay` local in the punktfunk1 two-process mux loop
(recv_timeout/try_recv/request_keyframe all called on the owning thread; no `Arc`,
no `thread::spawn` capturing it). So the fix is simply to delete the impl — the
struct keeps its sound `unsafe impl Send` (needed for the raw `HANDLE` fields),
which is all the code uses.

Box-verified: cargo clippy -p punktfunk-host --features nvenc --target
x86_64-pc-windows-msvc -- -D warnings stays green without the Sync impl.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:00:40 +00:00
enricobuehler 7aa787a789 docs(host): prove the last 3 files + crate-root deny (unsafe-proof program 4/N, final)
Completes the unsafe-proof program now that the parallel WIP has landed:

- idd_push.rs (25 sites), nvenc.rs (7), punktfunk1.rs (21): a SAFETY proof on
  every unsafe block — D3D11/DXGI COM (same-device textures, immediate-context
  single-thread, keyed-mutex-held convert), the NVENC SDK table (versioned POD,
  register/map/lock-bitstream pairing), cross-process shm reads (atomic
  magic/generation handshake), and the C-ABI harness (each call cross-checked
  against its abi.rs `# Safety` doc). No SUSPECT (UB) blocks.
- capture.rs / encode.rs: the parent-module deny is restored (their WIP children
  are now proven), and main.rs gains a crate-root
  #![deny(clippy::undocumented_unsafe_blocks)] — the permanent catch-all gate so
  no future unsafe block anywhere in the crate can land without a proof.
- Fixed 4 blocks the agents missed: unsafe blocks nested inside `assert_eq!(...)`
  macro args (the comment-above-statement didn't associate) — hoisted to a `let`.
- rustfmt-canonicalized the Windows files (the agents' SAFETY comments + some
  pre-existing 1.9.0 drift) so `cargo fmt --all --check` is clean.

Verified: cargo clippy -p punktfunk-host --all-targets -- -D warnings AND
cargo fmt -p punktfunk-host --check both green with the crate-root deny active.
Windows cfg(windows) re-verified on the box next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:57:00 +00:00
enricobuehler 3514702d8c feat(windows-host): IDD-push encodes native NV12/P010 (skip NVENC's SM-side CSC)
GPU-contention work (host-latency plan §5.A): the IDD-push output ring now hands
NVENC native YUV instead of RGB, so NVENC skips its internal RGB→YUV colour
conversion on the SM/3D engine the running game saturates.

- idd_push.rs: out_ring is now NV12 (SDR, BT.709 limited) via a D3D11 VIDEO-engine
  BGRA→NV12 VideoConverter (keeps the CSC off the contended 3D/compute engine), or
  P010 (HDR, BT.2020 PQ limited) via the FP16→P010 shader (NVIDIA's VideoProcessor
  can't do RGB→P010). The ring drops its per-slot RTV (textures only), matching the
  WGC YUV ring; converters rebuild on a size/HDR flip.
- nvenc.rs: NV12 input forces bit_depth=8 so an HDR→SDR toggle (or a 10-bit-
  negotiated client on an SDR display) re-inits the session at the matching depth —
  NV12 can't feed a 10-bit session (register_resource rejects it).
- punktfunk1.rs: per-stage latency instrumentation under PUNKTFUNK_PERF
  (cap=try_latest, submit=encode_picture, wait=lock_bitstream µs p50/p99/max) to
  pinpoint where capture→encoded latency goes under GPU saturation.
2026-06-26 09:35:23 +00:00
enricobuehler 327a5fa828 docs(host): prove unsafe blocks in the Windows + cross-platform files + gate them (unsafe-proof program 3/N)
Continues the unsafe-proof program across the Windows/cross-platform host files
(~75 blocks, 21 files), each with a SAFETY proof of the real invariant and a
per-file #![deny(clippy::undocumented_unsafe_blocks)] gate:

  capture/windows: dxgi.rs, wgc_relay.rs, wgc.rs, desktop_watch.rs, composed_flip.rs
                   (windows-rs COM: interface validity, same-D3D11-device textures,
                    immediate-context single-thread, borrowed args outlive the call)
  windows: service.rs (SCM/token/CreateProcessAsUserW/event handles — OwnedHandle
           liveness, no double-close/signal race), win_display, wgc_helper, interactive
  vdisplay/windows: manager.rs, pf_vdisplay.rs (SwDeviceCreate/IddCx/ioctl handle
                    liveness via the OnceLock VDM singleton + OwnedHandle)
  encode/windows: ffmpeg_win.rs (full AVBufferRef refcount audit — balanced, NO leaks,
                  unlike the vaapi sibling), sw.rs
  cross-platform: gamestream/audio.rs (libopus), gamestream/stream.rs (sendmmsg),
                  inject/windows/sendinput.rs, audio/windows/wasapi_mic.rs,
                  session_tuning.rs, vdisplay.rs

Two findings (handled separately):
- wgc_relay.rs `unsafe impl Sync for HelperRelay` is UNSOUND (its mpsc Receiver is
  !Sync) though not live-exploited — marked SUSPECT inline; fix pending box check
  (it touches the in-flight punktfunk1.rs).
- capture.rs / encode.rs (PARENT modules of the WIP idd_push.rs / nvenc.rs) do NOT
  get the file deny yet — it would propagate the lint into the undocumented WIP
  children. The deny lands there once those are documented (after the WIP commits).

Linux-visible parts verified green (cargo clippy -p punktfunk-host --all-targets
-- -D warnings). The cfg(windows) deny gates are box-verified next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:23:25 +00:00
enricobuehler 9777ed7fb3 fix(host/vaapi): plug two AVBufferRef leaks in DmabufInner::open
Surfaced while writing the unsafe-soundness proofs (2/N): both are refcount
leaks (sound — never dangling/double-free — so the SAFETY proofs held, but real
bugs on the persistent punktfunk1-host listener that opens a fresh encoder per
session).

1. Per-session leak: `par->hw_frames_ctx = av_buffer_ref(drm_frames)` created a
   second owned ref. `av_buffersrc_parameters_set` takes its OWN ref of
   `par->hw_frames_ctx`, and `av_free(par)` frees only the struct, not the ref —
   so the extra ref leaked every session, pinning the DRM frames ctx + device.
   Fix: assign `drm_frames` borrowed (the standard ffmpeg pattern); our single
   owned ref lives in DmabufInner and is unref'd in Drop.

2. Error-path leak: the final `open_vaapi_encoder(...)?` returned without the
   unref ladder every other error path runs, leaking graph/drm_frames/
   vaapi_device/drm_device on encoder-open failure. Fix: match + clean up before
   returning (nv12_ctx is borrowed from the sink → freed by graph teardown).

cargo clippy -p punktfunk-host --all-targets -- -D warnings clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:02:54 +00:00
enricobuehler ba68a98873 docs(host): prove every unsafe block in the Linux FFI files + gate them (unsafe-proof program 2/N)
Continues the structural unsafe-proof program (every unsafe carries a documented
proof of soundness; the file gains #![deny(clippy::undocumented_unsafe_blocks)]
so it stays proven). This batch covers all 10 remaining pure-Linux files
(104 blocks), each proof stating the REAL invariant — not boilerplate:

  zerocopy/cuda.rs (26)   leaked process-lifetime libcuda fn-ptr table; opaque
                          CUcontext never dereferenced; free-exactly-once via the
                          Arc<Mutex<PoolInner>> ownership graph; dmabuf fd take/close split
  zerocopy/egl.rs (18)    eglGetProcAddress'd procs with the GL context current;
                          EGLImage liveness; the two-call modifier-query bounds
  zerocopy/vulkan.rs (4)  copy-bounds arithmetic (src_size>=span); Send = thread
                          confinement to the punktfunk-pipewire thread
  dmabuf_fence.rs (4)     poll/ioctl/close fd liveness + ownership
  capture/linux/mod.rs (16)  spa_data repr(transparent) cast; null-checked spa
                          derefs; single-loop-thread buffer ownership until requeue
  inject/linux/gamepad.rs (10)  uinput ioctl request-number ↔ struct-size match
                          (static-asserted); InputEventRaw no-padding for the byte cast
  encode/linux/vaapi.rs (15) + encode/linux/mod.rs (9)  ffmpeg object ownership/
                          free ladders; VAAPI/DRM graph; Send = single-thread transfer
  inject/linux/wlr.rs (2), vdisplay/linux/kwin.rs (1)

No memory-unsafety SUSPECT blocks were found — the unsafe is sound. The vaapi
agent did flag two real AVBufferRef *leaks* (not UB) in DmabufInner::open; marked
inline with NOTE(leak) and addressed in a follow-up.

Verified: cargo clippy -p punktfunk-host --all-targets -- -D warnings is clean
(each file's deny gate hard-errors on any undocumented block).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:00:30 +00:00
enricobuehler 22359f5dc8 docs(host): prove every unsafe block in drm_sync.rs + gate it (unsafe-proof program 1/N)
Start of the structural unsafe-proof program (per the "every unsafe needs a
documented proof of soundness" goal): each `unsafe` block gets an accurate
`// SAFETY:` proof of WHY it is sound, and the file gains
`#![deny(clippy::undocumented_unsafe_blocks)]` so the proof requirement is
permanently enforced (a future undocumented unsafe in this file fails CI).

drm_sync.rs (10 blocks: libc open/ioctl/clock_gettime/close + 3 in tests): each
proof states the real invariant — fd liveness/ownership, the ioctl request number
encoding the matching struct size, the `&mut req` being a live correctly-sized
`#[repr(C)]` struct, and (for the timeline ioctls) the `handles`/`points` arrays
outliving the synchronous call with `count_handles` matching their length.

The gate grows file-by-file (CI stays green; undone files don't carry the lint
yet); it promotes to a crate-root deny once every file is done. ~122 Linux blocks
+ the Windows files remain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 08:35:32 +00:00
enricobuehler 7e9023faad feat(gamestream): launch apps on Windows + Linux non-gamescope hosts
GameStream's apps.json `cmd` is delivered via set_launch_command, which ONLY the Linux
gamescope backend nests. On Windows (no gamescope) and Linux kwin/mutter/wlroots (which
stream the existing desktop) the command was silently dropped. Now, after capture is live,
stream.rs spawns it via library::launch_gamestream_command for those backends — Windows:
into the interactive USER session (spawn_in_active_session, since the host is SYSTEM);
Linux: a plain `sh -c` spawn into the host's own graphical session so the app lands on the
streamed (primary) output. Linux gamescope keeps nesting via set_launch_command and is
skipped here to avoid a double launch. The command is operator-typed apps.json (trusted),
never client-set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 08:12:53 +00:00
enricobuehler 5acc12d9e9 feat(library): shared cover-art warmer + cache (GOG + Xbox art)
A disk-backed art cache (library-art-cache.json in the canonical host config dir) is the
source of truth read by all_games(), so the library list + launch-resolve never block on
the network. A host-lifetime background warmer (start_art_warmer, started in serve())
fetches uncached art OFF the hot path: GOG via the public no-auth api.gog.com product API,
Xbox via the unofficial no-auth displaycatalog (keyed by StoreId). Both best-effort
(protocol-relative URLs normalized to https; results cached even when empty so they aren't
re-fetched). The GOG + Xbox providers now read cached_art() (title-only until warmed).

Cross-platform (ureq blocking HTTP — no tokio on this path) so the fetch/parse code is
compiled + checked everywhere; a host whose stores all self-provide art (Steam CDN /
Heroic CDN / Lutris data: URLs) does no fetching. Dep: ureq (webpki roots, no system certs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 08:00:31 +00:00
enricobuehler aed0bf0c2a feat(library): Windows Xbox / Game Pass store provider
XboxProvider scans each fixed drive's <drive>:\XboxGames for GDK games (presence of
Content\MicrosoftGame.config marks a game vs. an ordinary UWP app), parsing title /
Identity name / Executable Id / StoreId via roxmltree. The PackageFamilyName is READ
from the AppRepository\Packages\<PackageFullName> dir name (reduced to Name_Hash) —
never computed from the publisher. Launch via the AUMID (shell:AppsFolder\<PFN>!<AppId>)
through explorer in the interactive user session (UWP activation needs the user token,
which spawn_in_active_session already provides). Cover art (displaycatalog) is deferred
→ title-only. Known v1 gaps: custom .GamingRoot install folders + non-GDK pure-UWP Store
games (under the ACL-locked WindowsApps) aren't enumerated.

New windows_launch_for `aumid` arm; XboxProvider wired into all_games() under cfg(windows).
Dep: roxmltree (Windows). Windows unit tests cover MicrosoftGame.config parsing (incl. the
ms-resource title fallback), the PackageFullName→PFN reduction, and the aumid launch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:49:03 +00:00
enricobuehler b65745284e feat(library): Windows Epic + GOG store providers
EpicProvider reads the launcher's local .item manifests under %ProgramData% (no auth,
launcher need not run) with Playnite's exclusion filter (skip UE_* components +
non-launchable addons + dead install dirs); cover art from the base64 catcache.bin
(public Epic CDN, best-effort). Launch via the com.epicgames.launcher:// URI opened
through explorer.exe — the namespace:catalogItemId:appName triple, with a bare-appName
fallback so a launch is never dropped.

GogProvider enumerates HKLM\SOFTWARE\WOW6432Node\GOG.com\Games (winreg) + each
goggame-<id>.info primary FileTask into a direct-exe spawn (no Galaxy, dodges its
cold-start/anti-cheat). GOG cover art (public api.gog.com) is deferred — it needs an
HTTP fetch + cache off the hot all_games() path — so GOG is title-only for now.

windows_launch_for gains epic/gog arms; both providers wired into all_games() under
cfg(windows). Deps: base64 moved to the cross-platform table (Epic catcache decode +
Lutris art encode both need it); winreg added on the Windows target. Windows unit tests
cover the Epic exclusion filter + URI builder and the GOG spawn + play-task parsing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:37:30 +00:00
enricobuehler 8ca695eb4c docs(windows-host): SCM event redesign done + runtime-validated (D2 complete)
The service.rs STOP/SESSION events are now OnceLock<OwnedHandle> (61c02e6) — the
last host-side raw-handle smuggle retired. Runtime-validated on the RTX box: swap
in, sc start -> RUNNING, sc stop -> clean STOPPED in ~1s, original restored. D2
(OwnedHandle/RAII rollout) is complete; only the deferred host P0 lints remain in
Goal 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:28:29 +00:00
enricobuehler 61c02e695e refactor(windows-host): OwnedHandle for the SCM STOP/SESSION events (Goal-3, last unsafe reduction)
The service's STOP/SESSION manual-reset events were smuggled across the C SCM
control-handler boundary as raw `isize` in `AtomicIsize` statics (the handler is a
capture-free `'static` closure, so it can't hold a non-`Send` `HANDLE` — it has to
reach the events through statics), reconstructed via `load_event`, and explicitly
`CloseHandle`d at `run_service` end.

Replace the raw-`isize` statics with `OnceLock<OwnedHandle>`:
- `run_service` creates each event, wraps it in an `OwnedHandle`, derives a borrowed
  `HANDLE` for `supervise` (unchanged signature), and `set`s the OnceLock (once per
  process) — all BEFORE the handler is registered, so the handler always sees `Some`.
- The handler reads `event_handle(&STOP_EVENT)` (a borrow) and `SetEvent`s it, with a
  defensive `None` guard (matches the old `SetEvent(HANDLE(0))` no-op if it ever fired
  pre-init).
- The events are owned by the OnceLocks for the process lifetime (the service process
  exits right after `run_service` returns, so the OS reaps them at exit). Dropping the
  explicit `CloseHandle` also removes the latent close-then-signal window the old
  statics had (the raw isize lingered after the close).

Deletes the `AtomicIsize`/`Ordering` import + `load_event` + the raw-isize smuggle —
the last host-side raw-handle reduction. Behaviour-preserving (same events, same
signal/wait/reset, same once-per-process init order). Linux check + fmt clean; the
file is #[cfg(windows)] → to be box-validated (compile + a service stop/restart).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:22:46 +00:00
enricobuehler 203ad8069d fix(web): library badge shows the actual store, not always "Steam"
The GameCard badge hard-coded steam-vs-custom, so any non-Steam non-custom store
rendered with the "Steam" label. Add storeLabel(store): steam/custom keep their
localized strings, every other store is shown as a capitalized proper noun — so the
new Lutris/Heroic providers (and future ones) surface correctly with no per-store
translation. tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:22:28 +00:00
enricobuehler 5f8c6b6147 feat(library): Lutris + Heroic store providers (Linux)
LutrisProvider reads the local pga.db (rusqlite, read-only/immutable so a running
Lutris can't block us) → installed games, launch via `lutris lutris:rungameid/<id>`,
cover art from Lutris's on-disk cache inlined as data: URLs (no public CDN keyed by a
stable id, unlike Steam/Heroic). HeroicProvider parses Heroic's store_cache JSON —
legendary/gog/nile = Epic+GOG+Amazon in one provider — installed-only with an
install-dir existence cross-check (works around Heroic's gog is_installed bug #2691),
free public CDN cover art, launch via `heroic --no-gui heroic://launch?...` (the
single-instance-Electron gamescope-escape caveat is documented; needs live confirm).

New command_for arms (lutris_id digits-guard, heroic runner+appName-guard) + both
providers wired into all_games(); everything Linux-gated (the launchers are
Linux-only), so the Windows/macOS host build is unaffected. Deps rusqlite (bundled
SQLite, no system dep) + base64 added to the Linux target only. Unit tests with
sqlite/json fixtures (installed-only filtering, CDN-art mapping, launch guards); live
`library` enumeration returns [] gracefully on a box without the launchers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:20:58 +00:00
enricobuehler cd3368fc71 docs(windows-host): KeyedMutexGuard done + record the on-glass build validation
Goal 3: the IDD-push hot-loop KeyedMutexGuard (6585643) landed, and the whole
session's Windows + driver work is now ON-GLASS BUILD-VALIDATED on the RTX box —
host clippy -D warnings clean + driver build clean (the gate that surfaced + got
11 lints fixed in bd05bc8). Only the deferred host P0 lints + the deliberately-
left service.rs SCM-handler event smuggling remain, plus an optional latency A/B.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:16:23 +00:00
enricobuehler bd05bc8c30 fix(windows): clippy/build cleanups the on-glass build surfaced (-D warnings)
Built the host crate (`cargo clippy --features nvenc -D warnings`) and the driver
workspace (`cargo build`) on the RTX box — the project's intended Windows gate,
which `cargo check` (what the goal1/§2.5 work used) never runs. It surfaced lint
issues accumulated across the goal1 / §2.5 / this-session Windows work:

- 9× redundant `as *mut c_void` after `.as_raw_handle()` (already `*mut c_void`):
  idd_push.rs (3, this session), service.rs (3, this session), manager.rs (3,
  pre-existing §2.5 — my OwnedHandle work copied the idiom). Removed the casts +
  the now-unused `use std::ffi::c_void` in idd_push.rs / manager.rs (service still
  uses it).
- `if_same_then_else` in session_plan.rs::resolve_topology (pre-existing goal1
  stage 3): collapsed the two `false` arms into one condition (behavior identical).
- `unused_unsafe` in the driver `pod_init!` macro: it expands at call sites already
  inside an `unsafe` block, where its own `unsafe` is redundant — `#[allow(
  unused_unsafe)]` (needed at the non-unsafe sites, redundant at the nested ones).

After these, BOTH builds are clean on the box — validating the whole session's
blind Windows + driver work compiles + passes clippy on real hardware.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:15:00 +00:00
enricobuehler 658564353c refactor(windows-host): KeyedMutexGuard RAII for the IDD-push consume hot loop (Goal-3, hw-validated)
The IDD-push consume loop acquired the slot's keyed mutex by hand
(`AcquireSync(0,8)` … work … `ReleaseSync(0)`), with a comment warning that a
`?`-return between acquire and release would leak the lock and stall the driver
on that slot — the reason the HDR converter is built *before* the acquire.

Replace with a `KeyedMutexGuard` RAII (acquire → `ReleaseSync` on drop), scoped
to JUST the convert/copy block so the lock releases at the EXACT same point as
before (the driver gets the slot back immediately; not held across the rest of
`try_consume`). Now the release can't be skipped on any early return/panic — the
leak footgun is gone by construction, and the hot loop has no raw `ReleaseSync`.

Behavior/latency-equivalent (same acquire params, same release point). Windows-
only (CI + on-glass gated); to be validated on the RTX box (host clippy build +
a PERF=1 latency A/B vs the shipping binary — the change should show no delta).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:02:05 +00:00
enricobuehler 6b3cbce120 wip: host latency/GPU-contention notes + Windows packaging tweaks
Pre-existing working-tree changes committed to the branch on request: the
gpu-contention investigation doc, host-latency-plan additions, and small
pack-host-installer / stage-pf-vdisplay packaging-script edits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:53:09 +00:00
enricobuehler 739fa74e68 docs(library): game-store provider design (Xbox/Epic/EA, Heroic/Lutris, …)
Web-researched + adversarially-verified design for extending library.rs with more
store providers: the LibraryProvider extension point, the two cross-cutting pieces
(Windows interactive-session launch wiring + a layered artwork strategy), new
LaunchSpec kinds, per-store enumeration/launch/art recipes with priority/effort/
confidence, a phased plan, and the verification corrections.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:53:09 +00:00
enricobuehler c87ca577a3 feat(windows-host): launch the chosen library title into the interactive session
Make the no-op Windows `set_launch_command` real. New `windows/interactive.rs`
`spawn_in_active_session` (WTSGetActiveConsoleSessionId → WTSQueryUserToken →
CreateProcessAsUserW(winsta0\default) under the LOGGED-IN USER token, factored from
the wgc_relay primitive) + `library::launch_title` resolving a store-qualified id to
a concrete process via `windows_launch_for` (steam_appid → Steam.exe/explorer.exe
steam:// URI; command → cmd.exe /c). Threaded as `SessionContext.launch` into both
native data-plane paths (`virtual_stream`, `virtual_stream_relay`) and fired after
capture is live so the title renders onto the captured desktop and grabs foreground.

Security invariant intact: the client sends only the store-qualified id; the host
resolves the recipe from its own library and the URI/flags are handed to a concrete
EXE as plain args (never cmd /c of a client string). Linux unchanged (gamescope
nesting via the handshake PUNKTFUNK_GAMESCOPE_APP path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:51:10 +00:00
enricobuehler e68b7330ae docs(windows-host): record the shared gamepad RAII reduction (e5c2b4e)
Goal 3 scorecard + §4 P2: the OwnedHandle/RAII rollout now covers the three
gamepad backends via the shared inject/windows/gamepad_raii.rs (Shm + SwDevice).
Scratched the IOCTL-dispatcher item (control.rs's read_input/write_output_complete
are already generic — would be churn, not reduction). The only remaining unsafe
reductions are the deliberately-left service.rs SCM-handler event smuggling and
the on-glass-gated KeyedMutexGuard hot-loop RAII.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:38:19 +00:00
enricobuehler e5c2b4e7f5 refactor(windows-host): shared Shm/SwDevice RAII for the 3 gamepad backends (Goal-3 unsafe reduction)
The DualSense, DualShock 4, and XUSB Windows pad backends each hand-rolled the
SAME per-pad resource handling: a `CreateFileMappingW` + `MapViewOfFile` shared
section (with the permissive D:(A;;GA;;;WD) SDDL the restricted-token driver
needs) and an identical `Drop` doing `SwDeviceClose` + `UnmapViewOfFile` +
`CloseHandle` — three copies, each a chance to drift or leak on an error path.

New `inject/windows/gamepad_raii.rs` owns both resources with RAII:
- `Shm` — the section handle (`OwnedHandle`) + its view; `Shm::create(name, size)`
  does the SDDL + map + zero-fill leak-safely, `base()` gives the mapped pointer,
  `Drop` unmaps then closes (in that order).
- `SwDevice` — the `SwDeviceCreate`'d devnode; `Drop` calls `SwDeviceClose`.

All three backends now hold `_sw: Option<SwDevice>` + `shm: Shm` instead of raw
`hsw`/`map`/`view`, access the section via `self.shm.base()`, and have NO manual
`Drop`. Deletes the duplicated `create_shm_section` (DualSense/DS4 now use
`Shm::create`) and the three hand-written Drops; the DS4 device-type byte is still
written before the magic, the SwDeviceCreate `None` fallback still works, and the
field drop order (devnode removed, then section unmapped+closed) matches the old
manual order.

Net: 3 manual `Drop`s + a duplicated section-creation path → one shared RAII
module; fewer unsafe ops, leak-on-error fixed by construction. Linux `cargo check`
clean (the inject mod wiring); the backends are #[cfg(windows)] → CI-gated.
Drafted + adversarially verified (no double-free, imports correct under
-D warnings, behavior preserved); my own spot-checks confirm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:36:57 +00:00
enricobuehler 7ad3a57e68 fix theme 2026-06-26 06:20:21 +00:00
enricobuehler 22bef1fd0a docs(windows-host): record the Goal-3 unsafe reductions (OwnedHandle rollout + pod_init!)
Scorecard Goal 3 + §4 P2: the OwnedHandle RAII rollout (idd_push 011607e — also a
view-leak fix; service child/job 4c95ba7) and the driver pod_init! macro (bf57704,
27→1) landed. Recorded the remaining items (service SCM-handler event smuggling,
driver IOCTL-dispatch / KeyedMutexGuard levers, the deferred D1-host lint sweep)
and that ThreadBound was skipped as not-a-clean-win.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:02:06 +00:00
enricobuehler bf577044f1 refactor(windows-drivers): pod_init! macro — 27 unsafe { mem::zeroed() } POD inits -> 1 (Goal-3 #3)
The driver zero-initialised C POD structs (IddCx/WDF descriptors) with 27
scattered `let mut x: T = unsafe { core::mem::zeroed() };`, each carrying its own
`// SAFETY` about the all-zero bit pattern being valid + the caller setting `.Size`
etc. right after.

Replace with one `pod_init!(T)` macro (in log.rs, reachable everywhere via the
existing `#[macro_use] mod log;` — same mechanism as `dbglog!`) that owns the
single `unsafe { zeroed::<T>() }` + the SAFETY rationale. All 27 sites
(adapter 6, callbacks 3, entry 4, monitor 10, swap_chain_processor 4) now read
`let mut x = pod_init!(T)`. Zero behavior change (mem::zeroed semantics identical);
the type is passed explicitly so no inference depends on the removed annotation.

27 `unsafe` blocks → 1. Driver still `deny(unsafe_op_in_unsafe_fn)`-clean (the
macro expands to an explicit `unsafe {}`; the one nested-in-user-unsafe site is
fine — no `unused_unsafe` for macro-generated blocks). Driver-only (CI-gated);
adversarially reviewed (macro scoping, all sites, no leftover raw zeroed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:01:02 +00:00
enricobuehler 4c95ba72a3 refactor(windows-host): OwnedHandle for the service child + job handles (Goal-3 unsafe reduction #2)
The SCM supervisor scattered manual `CloseHandle(pi.hProcess)`/`(pi.hThread)`
across ~5 supervise-loop match arms and hand-closed the job object — easy to miss
an arm (leak) or double-close.

- `spawn_host` returns an owned `Child { process: OwnedHandle, _thread: OwnedHandle,
  pid }` instead of raw `PROCESS_INFORMATION`; the supervise loop borrows
  `child.process` (`HANDLE(as_raw_handle() as *mut c_void)`) for wait/Terminate and
  the `Child` auto-closes both handles when it drops / is replaced each iteration.
- The job object → `OwnedHandle` (borrowed for AssignProcessToJobObject), auto-closed.
- Deletes ~9 manual `CloseHandle` calls. The `_thread` handle is RAII-only (`_`-prefixed
  so `dead_code`/`-D warnings` doesn't flag it).

Deliberately LEFT the `STOP_EVENT`/`SESSION_EVENT` `AtomicIsize` statics as-is — they
are smuggled into the C SCM control handler, so `OwnedHandle`-ifying them is a separate,
riskier supervisor redesign out of scope here (noted in a comment).

Behavior preserved (the supervise state machine / wait semantics / restart-on-
session-change / kill-on-close are unchanged). Windows-only (CI-gated); adversarially
reviewed (no double-close, handles outlive their borrows, idiom matches manager.rs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:01:02 +00:00
enricobuehler 011607ec10 refactor(windows-host): RAII for IDD-push handles/views — fix a leak (Goal-3 unsafe reduction #1)
The IDD-push capturer held raw `HANDLE`s for the shared header mapping, the
frame-ready event, the debug section, and each ring slot's shared texture, with
manual `CloseHandle` scattered across two `Drop` impls — and the MapViewOfFile
VIEWS (header/dbg_block) were never UnmapViewOfFile'd (a real view leak).

- New `MappedSection { handle: OwnedHandle, view }` RAII: `Drop` UnmapViewOfFile's
  the view THEN the `OwnedHandle` closes the mapping (unmap-before-close).
- `map`+`header` → `section: MappedSection` (+ a cached `header` ptr borrowing into
  it, declared after `section` for drop order); same for `dbg_map`+`dbg_block`.
- `event: HANDLE` → `OwnedHandle` (borrowed as `HANDLE(as_raw_handle() as *mut
  c_void)` for WaitForSingleObject); `HostSlot.shared` → `OwnedHandle` (its manual
  `Drop` deleted). Removed the manual `CloseHandle`s + the `CloseHandle` import.

Net: deletes two `Drop` impls' worth of manual handle/view teardown and fixes the
view leak — fewer unsafe ops, RAII-correct. Behavior preserved (recreate_ring
writes the header in place; the keepalive still drops last so REMOVE is last).
Windows-only (CI-gated); adversarially reviewed (no double-free / UAF / dangling
header; handle interop matches manager.rs). Linux check unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:01:02 +00:00
enricobuehler 803573b4ec improve web ui 2026-06-26 05:43:34 +00:00
enricobuehler 00cf51d610 refactor: rename pf-vdisplay-proto -> pf-driver-proto (it spans all drivers)
The shared host<->driver ABI crate already contains more than the virtual
display: the IDD-push frame ring + control plane AND the gamepad shared-memory
layouts (XusbShm / PadShm). "pf-vdisplay-proto" was a misnomer — the name now
represents all the drivers it serves.

Mechanical rename, no behavior change:
- git mv crates/pf-vdisplay-proto -> crates/pf-driver-proto (package name +
  path-deps in the host crate and the driver workspace).
- pf_vdisplay_proto -> pf_driver_proto across host + driver Rust, both Cargo.lock
  files, the workspace members, the CI path triggers (windows-drivers.yml), and
  the docs/INF comments. The runtime Global\pfvd-* shared-object names are a
  SEPARATE contract and are deliberately untouched (host<->driver name matching).
- The pf-vdisplay DRIVER crate + its INF service name (Root\pf_vdisplay,
  UmdfService=pf_vdisplay, pf_vdisplay.dll) are unchanged — only the full
  `pf_vdisplay_proto` token was replaced, never the `pf_vdisplay` driver name.

Linux-verified: cargo test -p pf-driver-proto (const size-asserts compile) +
cargo clippy -p punktfunk-host -D warnings clean; Cargo.lock regenerated. The
driver-workspace side (path-dep + imports + its Cargo.lock) is Windows-CI-gated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:38:21 +00:00
enricobuehler 84a3b95f17 refactor(windows-host): delete the SudoVDA backend — pf-vdisplay is the sole vdisplay (Goal 2)
Goal 2 ("drop every trace of SudoVDA") is done. The SudoVDA driver is no longer
shipped (only pf-vdisplay; the old vdisplay-driver tree was deleted in a2bd0cd),
and F1 (d638a93/e60cda3) already moved the display-utility helpers out of the
backend into neutral modules (win_adapter/win_display), breaking the reach-in.
So the backend is now cleanly removable:

- Deleted crates/punktfunk-host/src/vdisplay/windows/sudovda.rs (350 lines: the
  SudoVdaDisplay VirtualDisplay impl + its VdisplayDriver/probe).
- vdisplay::open()/probe() are now unconditional pf-vdisplay; deleted the
  windows_use_pf_vdisplay() backend selector. open() now ensure!s
  pf_vdisplay::is_available() with a clear "driver not installed" error instead
  of the old silent SudoVDA fallback (no fallback driver exists anymore).
- Scrubbed the dangling references to the deleted symbols (manager/sendinput/dxgi
  comments, the config + host.env PUNKTFUNK_VDISPLAY docs); the var stays as an
  informational forward-seam. Updated the F1 module docs (Goal 2 now done).

All changes are #[cfg(windows)] except the config doc; Linux clippy
-p punktfunk-host -D warnings clean; zero `sudovda::`/`SudoVdaDisplay` code refs
remain (comments only). Windows build is CI-gated.

Scorecard Goal 2 -> DONE; recorded the E1 "do NOT do it" stability decision in
windows-host-rewrite.md §4 (the process-global driver design is sound given
ProcessSharingDisabled; a device-owned variant adds a use-after-free window for
no gain).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:36:10 +00:00
enricobuehler 8cde8621ce fix(windows-drivers): reclaim pf-vdisplay monitor ids on REMOVE (P1, slot-reclaim)
The driver assigned each virtual monitor a monotonically-increasing NEXT_ID used
as the EDID serial / IddCx ConnectorIndex / container GUID, and never reclaimed
it on REMOVE. Under sustained ADD/REMOVE churn the connector index kept climbing,
so IddCx/PnP allocated a NEW OS target slot every cycle and orphaned the old one
(ghost "Generic Monitor (punktfunk)" nodes) until the adapter's target capacity
was exhausted and ADD failed 0x80070490 ERROR_NOT_FOUND.

Fix: `create_monitor` now allocates the LOWEST free id (`alloc_monitor_id`,
computed under the MONITOR_MODES lock with the push) instead of a counter, so a
departed monitor's id is reclaimed and a fresh ADD reuses its target slot rather
than orphaning it. With <= N live monitors the id stays bounded to 1..=N+1.
Deleted the now-unused NEXT_ID + AtomicU32/Ordering import.

CI-compile-gated only — the wedge reproduces solely under sustained churn on the
RTX box, so this needs an on-glass reconnect-storm A/B to confirm (box is
ephemeral/down). Marked on-glass-pending in windows-host-rewrite.md §4; keep
reset-pf-vdisplay.ps1 as the recovery until validated. NOT to be relied on (or
merged to main) until that A/B passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:11:36 +00:00
enricobuehler 0bf3984614 feat(windows-host): IDD-push is the default capture path for fresh installs (P1)
Make the validated IDD-push zero-copy path the default for a fresh install,
without penalising dev / non-pf-driver runs:

- The shipped default config now enables it. Both seed sites set
  `PUNKTFUNK_VDISPLAY=pf` + `PUNKTFUNK_IDD_PUSH=1`: the hardcoded default the
  service writes on `service install` (`ensure_default_host_env`) AND the
  `host.env.example` template the installer bundles. A fresh install therefore
  runs the validated path (the installer also bundles the pf-vdisplay driver);
  it falls back to DDA if the driver can't attach.
- `idd_push` is now **value-aware** instead of a bare presence flag, so an
  operator can turn it OFF with `PUNKTFUNK_IDD_PUSH=0` in host.env — a `var_os`
  presence check read `=0` as "on". Unset still ⇒ off (the code default is
  unchanged, so existing host.env files and dev/CI runs are unaffected; only the
  shipped default config opts in).

Also scrubbed the stale "SudoVDA" wording in host.env.example. Linux cargo
clippy -p punktfunk-host -D warnings clean; the service.rs default string is
Windows-only (CI-gated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:08:45 +00:00
enricobuehler 75ee53d1dd feat(web): Storybook for offline UI design + light theme + brand spinner
Stand up Storybook so the management console can be designed without a running
host, plus the design-system work that surfaced along the way.

Storybook (@storybook/react-vite):
- Slim Start/Nitro-free vite config; the preview imports the app's real
  src/styles.css directly so the design tokens stay single-sourced (no mirror).
- Stories for the @unom/ui primitives (Button/Card/Inputs/Badge), brand marks,
  the AppShell (throwaway in-memory TanStack router), and every data-driven page
  (Dashboard/Host/Clients/Library/Settings) rendered offline via a window.fetch
  stub + typed fixtures. The route page components are exported so stories can
  render them.

Light theme:
- styles.css now carries a light :root (lavender, from the docs palette) with the
  existing violet chrome moved to .dark; the live console still pins html.dark by
  default, so this only adds the option (Storybook's toolbar toggles it).
- Fixes a stray `*/` inside a comment that prematurely closed it and silently
  broke Tailwind's @theme processing.

Spinner:
- The punktfunk lens recreated with motion/react: two circles surge through one
  another in depth (JS perspective scale + z-index — robust where mix-blend-mode
  flattens CSS preserve-3d) with a screen-blend lens highlight. Replaces the
  skeleton loading state in QueryState; removes ui/skeleton.tsx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:58:36 +00:00
enricobuehler 0255a8289c docs(windows-host): consolidate 5 scattered docs into one current source of truth
The Windows-host docs were scattered across a design plan, a staged-refactor
plan, an audit, an audit-remediation tracker, and a game-capture-bug analysis —
several badly stale (the audit/remediation predate the Goal-1 branch landing and
call DONE items "not started"). Verified the true state of every audit finding /
goal / milestone against current code+git (4-agent workflow), then rewrote
windows-host-rewrite.md as ONE consolidated, accurate doc:

- §1 Status scorecard (Goals 1-3, M0-M6, GB1, audit P0/P1/P2) with DONE/PARTIAL/
  OPEN + commit evidence.
- §2 Architecture as-built (layering, HostConfig→SessionPlan→SessionContext, the
  VirtualDisplayManager ownership model, IDD-push-primary capture incl. secure
  desktop + GB1 recovery, encode/EncoderCaps, pf-vdisplay-proto, the driver,
  service/packaging).
- §3 Validated invariants (the jewels).
- §4 Prioritized open tasks (the genuine remaining work).
- §5 Operations (RTX-box recipe, CI, env, build).
- §6 Deep reference (/INTEGRITYCHECK answer, the 6 iddcx bindgen knobs, the driver
  port checklist, resolved decisions).

Deleted the four now-redundant docs (content folded in; history in git):
windows-host-goal1-plan.md, windows-host-rewrite-audit.md,
windows-host-rewrite-remediation.md, windows-host-rewrite-game-capture-bug.md.
Repointed the 6 code/proto/driver doc-comment refs that targeted them at the
consolidated windows-host-rewrite.md sections. Linux cargo check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:57:23 +00:00
enricobuehler 6bed5d9e8e docs(windows-rewrite): secure desktop validated on glass — mark M3 done, retire the biggest risk
Owner-confirmed on glass (2026-06-25, "works great"): the IDD-push primary path
captures the lock/UAC secure desktop AND input reaches the streamed console
session. This was the single biggest open risk — the whole capture strategy
(Decision B: IDD-push primary for everything incl. secure desktop, WGC/DDA
demoted) rested on it. Now proven, not asserted.

- §15: M3 row → DONE (secure desktop); removed the secure-desktop gate from
  "What genuinely remains" (renumbered); added it to "Resolved since §11".
- §11 "IDD-push input + secure desktop" open item → RESOLVED.
- §14 critique "SINGLE BIGGEST RISK: the secure-desktop claim" → RESOLVED.

The WGC-relay / secure-DDA path is no longer load-bearing — kept only as a
non-IddCx-hardware fallback. Remaining rewrite work is migration/cleanup (M4
gamepad drivers, M5/M6, slot-reclaim), none blocking the validated path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:42:25 +00:00
enricobuehler 48202a0f89 docs(windows-rewrite): mark game-capture bug FIXED + bring rewrite status current (§15)
The fullscreen-game-breaks-IDD-push bug is FIXED by the resolution-listening
recovery (c87bfe0: the 250ms poll now follows the display's actual resolution
and recreates the ring on any descriptor change, recover-or-drop), backed by
open-time first-frame DDA failover (f98ab07) and the driver publish() width/
height guard + flushed logging (789ad49). No protocol bump was needed — the host
reads the real resolution straight from Windows (CCD/GDI), so the bug doc's
Stage-1 composing capturer + Stage-2 protocol bump were unnecessary. Bug doc
marked FIXED with a Resolution section; the staged plan kept as superseded record.

windows-host-rewrite.md: the progress log was stale (ended at "M1 cont."). Added
§15 Current status — the driver STEP 0-8 port landed on main on-glass HDR-
validated; the host was refactored *in place* via windows-host-goal1 (not the §10
greenfield rebuild); §2.5 ownership model resolved the swap-chain-reuse / monitor-
leak open item; iddcx + /INTEGRITYCHECK CI-green. Remaining: the secure-desktop
on-glass gate (the single biggest unproven claim), M4 gamepad-driver migration,
M5/M6 cleanup, and the pf-vdisplay slot-reclaim driver fix. Top Status flipped
proposed → largely implemented.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:35:55 +00:00
enricobuehler bf57aa4000 docs(windows-host-goal1): Stage 5 tightening 3 (EncoderCaps) DONE; refresh Remaining
The Goal-1 host refactor is now functionally complete — all 6 stages, §2.5, and
all three Stage-5 seam-trait tightenings have landed (EncoderCaps = 0ccd0fe).
Remaining is non-blocking: the optional namespace collapse (decision: skip —
pure churn), the merge to main (confirm with the user — outward-facing), and the
pf-vdisplay slot-reclaim driver fix (reassigned to windows-host-rewrite.md, the
greenfield driver rewrite, alongside the fullscreen-game capture bug).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:28:30 +00:00
enricobuehler 0ccd0fe676 feat(windows-host): EncoderCaps — query RFI/HDR-SEI caps (Goal-1 stage 5, tightening 3)
The last §2.3 seam-trait tightening: give `Encoder` a `caps() -> EncoderCaps`
so the session glue routes by *query* instead of relying on the no-op/`false`
defaults of `invalidate_ref_frames`/`set_hdr_meta`.

`EncoderCaps { supports_rfi, supports_hdr_metadata }` is a cheap `Copy` struct.
The trait gains a default `caps()` returning `EncoderCaps::default()` (all
false) — correct for every SDR/libavcodec backend (Linux NVENC, VAAPI, AMF/QSV,
software openh264), so they need no change. Only the Windows direct-NVENC path
(`NvencD3d11Encoder`) overrides it, reporting the real `rfi_supported` (probed
once at open via `nvEncGetEncodeCaps`) and `hdr` (HDR-SEI on keyframes).

Consumer: the GameStream encode loop (`gamestream/stream.rs`) hoists
`supports_rfi` once before the loop and gates the loss-recovery path on it —
`!(supports_rfi && enc.invalidate_ref_frames(..))` forces a keyframe directly
on non-RFI encoders instead of making an always-`false` call every loss event.
Behaviour-preserving (same keyframe/RFI outcome), one fewer no-op call, intent
explicit. The native host (punktfunk1) uses FEC+keyframes, no RFI consumer.

Linux `cargo clippy -p punktfunk-host --all-targets -D warnings` clean; the
three edited files are rustfmt-clean. The NVENC override is Windows-only
(1:1 with the existing impl style) → CI/on-glass gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:27:20 +00:00
enricobuehler e1ca2e4d3c docs(windows-host-goal1): record §2.5 done + on-glass results + Remaining list
The plan tracker referenced "§2.5 — see below" but had no §2.5 section and no "what's left". Add:
  * a Status banner (all 6 stages + §2.5 done; branch not merged),
  * the §2.5 section — the 3-step ownership-model rewrite (VirtualDisplayManager/MonitorLease,
    the deleted globals), the CURRENT_MON_GEN-write-only finding, and the on-glass reconnect-leak
    result (the vdm-init-order panic found+fixed, 0 leaks, IDD-push zero-copy verified),
  * a "Remaining (next session)" list: EncoderCaps, optional namespace collapse, merge to main, and
    the pf-vdisplay driver slot-reclaim fix (driver WIP, not the host refactor) with the dev scripts.
Mark §2.5 IMPLEMENTED in the design doc (windows-host-rewrite.md) with the write-only-gen deviation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:04:48 +00:00
enricobuehler e119aa50e9 feat(windows-packaging): dev-iteration scripts — reset + redeploy pf-vdisplay driver
Today's manual driver recovery (wedged under ADD/REMOVE churn → ERROR_NOT_FOUND) and the manual
host-stop/install/host-start dance around drivers/deploy-dev.ps1 are now two scripts:

  * reset-pf-vdisplay.ps1   — recover a wedged driver: stop host → pnputil /remove-device the ghost
                              "Generic Monitor (punktfunk)" nodes → Disable+Enable the adapter
                              (Restart-PnpDevice doesn't exist on the box PS) → start host. No reboot
                              (the box boots to Proxmox). -Verify probes to confirm ADD recovered.
  * redeploy-pf-vdisplay.ps1 — one-shot dev redeploy wrapping deploy-dev.ps1 with the host stop/start
                              (the running host holds the driver DLL) + a post-install adapter reload
                              (pnputil updates the store but the live device keeps the old binary).

Both standalone (don't touch deploy-dev.ps1). README gains a "Dev iteration on the test box" section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:48:32 +00:00
enricobuehler 683c81be03 fix(windows-host): §2.5 — open the backend before the IDD-push preempt (vdm() init order)
On-glass caught a runtime panic the box compile couldn't: `VirtualDisplayManager used before a
backend initialised it`. Step 3 put the preempt (`vdm().begin_idd_setup`) BEFORE
`vdisplay::open` in virtual_stream, but vdisplay::open is what constructs the backend that calls
manager::init() — so vdm() was reached before init and panicked on the first IDD-push session.
(The old IDD_SETUP_LOCK/IDD_SESSION_STOP globals needed no init, so the prior ordering was fine.)

Fix: open the backend first (it does no monitor work — just constructs the marker + opens the
control device, initialising the manager), THEN run the preempt, THEN build the pipeline (which
creates the monitor). The preempt still precedes this session's monitor creation, so the
semantics are unchanged. Validates why §2.5 needs the on-glass gate, not just the compile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:06:41 +00:00
enricobuehler fe61597d92 refactor(windows-host): §2.5 step 3 — isolate the IDD-push preempt into the manager
The last two virtual-display globals lived in punktfunk1: IDD_SETUP_LOCK (serialize IDD-push
setup against a reconnect flood) + IDD_SESSION_STOP (the prior session's stop flag, signalled +
waited-on so a reconnect preempts the stale session cleanly). Both move onto VirtualDisplayManager
as fields, behind one `vdm().begin_idd_setup(stop)` method that locks the setup gate, registers
this session's stop while signalling the prior one, waits for the monitor to release, and hands
back the setup guard the session holds across the pipeline build. punktfunk1 no longer reaches
into vdisplay internals for the preempt — it just calls the manager and holds the guard.

Behaviour-identical (same lock/signal/wait order, same guard lifetime). Completes §2.5's
"delete the smeared globals": CURRENT_MON_GEN/MON_GEN/MGR x2/IDD_PERSIST/IDD_SETUP_LOCK/
IDD_SESSION_STOP are all gone, replaced by the one OnceLock VirtualDisplayManager with a typed
OwnedHandle device. Box build to follow; on-glass reconnect-leak test pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:58:02 +00:00
enricobuehler d9b8b88a42 refactor(windows-host): §2.5 step 2 — unify both backends behind VirtualDisplayManager (OnceLock)
The two Windows virtual-display backends (sudovda + pf_vdisplay) carried VERBATIM-DUPLICATED
~250-line Idle/Active/Lingering refcount state machines in two `MGR: Mutex<Mgr>` globals, each
smuggling the control HANDLE across the pinger/linger threads as a raw `isize` (HANDLE is !Send).

New `vdisplay/windows/manager.rs`: one host-lifetime `VirtualDisplayManager` (OnceLock singleton,
user-approved) owns the earned state machine + the linger timer + a TYPED `Arc<OwnedHandle>`
control device (the raw-isize smuggle is gone — OwnedHandle is Send+Sync and also CloseHandle's
the device on drop, fixing a latent leak). The only backend-specific code left is the IOCTL
surface behind a small `VdisplayDriver` trait (open/add_monitor/remove_monitor/ping) + the
per-monitor REMOVE key (`MonitorKey::Guid` for sudovda, `::Session(u64)` for pf-vdisplay). The
render-adapter pin decision, the GDI/CCD glue (crate::win_display), and the gen-stamped
MonitorLease are backend-neutral and live once in the manager.

  * sudovda.rs / pf_vdisplay.rs: shrink to a `VdisplayDriver` impl + a thin `VirtualDisplay`
    wrapper (new() -> manager::init(driver); create() -> manager::vdm().acquire(mode)). Their
    IOCTL ops + structs + open_device stay in place (no transcription).
  * MON_GEN -> a manager field; the preempt's wait_for_monitor_released moves onto the manager
    (punktfunk1 calls vdm().wait_for_monitor_released). MonitorLease.drop -> vdm().release(gen),
    with the stale-lease no-op preserved verbatim.

Behaviour-preserving: the state machine (acquire/release/reconfigure/teardown/linger/preempt) is
the canonical sudovda copy with the IOCTLs routed through the driver seam. Box build to follow
(Windows-only; Linux check is a no-op for these files).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:52:22 +00:00
enricobuehler 15202011c1 refactor(windows-host): §2.5 step 1 — delete the dead/write-only monitor-lifecycle code
Removes the cruft the §2.5 ownership-model rewrite would otherwise carry forward, and corrects a
false invariant the docs described:

  * CURRENT_MON_GEN (sudovda) — the "current monitor generation" global was WRITE-ONLY. It was
    stored on every mgr_acquire (both backends) but its only reader, idd_push's `my_gen`, was set
    and NEVER read. The "session capturer re-checks the monitor gen each frame and bails on a
    reconnect" behaviour the doc describes was never wired — per-frame staleness is the SEPARATE
    ring FrameToken.generation / IDD_GENERATION mechanism (which works and is untouched). So the
    monitor-gen-via-WinCaptureTarget carry the design proposed is unnecessary. Deleted the static,
    its stores in both backends, the pf_vdisplay import, and idd_push's dead `my_gen` field/read.
    (MON_GEN — the lease-generation counter behind the stale-lease no-op — is REAL and kept.)

  * IDD_PERSIST + open_or_reuse + IddReuseHandle (idd_push) — a persistent-capturer reuse path
    from an early prototype, defined but with ZERO callers across the crate. Deleted, plus the now
    -orphaned `use std::sync::Mutex` and the now-dead `set_client_10bit` setter.

Windows-only; grep confirms no remaining references to any deleted symbol. Box build to follow.
First of the incremental §2.5 steps (user-approved OnceLock VirtualDisplayManager design).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:26:17 +00:00
enricobuehler 05e87e6ab0 chore(windows-host): fix two stale file-path comments after the stage-6 move
capture/dxgi.rs -> capture/windows/dxgi.rs, inject/gamepad_windows.rs -> inject/windows/gamepad_windows.rs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:55:46 +00:00
enricobuehler 38c68c33e5 refactor(windows-host): confine platform code under windows/ + linux/ folders (Goal-1 stage 6)
Move 36 platform-specific files into per-module `windows/` and `linux/` subfolders (and the
shared HID codecs into `inject/proto/`):
  capture/{windows,linux}/  encode/{windows,linux}/  inject/{windows,linux,proto}/
  audio/{windows,linux}/  vdisplay/{windows,linux}/
  src/windows/ (service, wgc_helper, win_adapter, win_display)
  src/linux/  (dmabuf_fence, drm_sync, zerocopy/)

Done with `#[path]`, NOT a module rename: every file moves into its folder while the
`crate::*::*` module names stay FLAT, so all caller paths and every internal `super::`/`crate::`
reference are unchanged — only the parent `mod` decls gained `#[path = "..."]`. This is the
codebase's existing pattern (inject's gamepad_windows) and makes the move byte-identical in
behaviour with ZERO reference churn, far lower risk than collapsing to a single
`crate::capture::windows::` namespace (that deeper rename is an optional follow-on; this delivers
the cfg-sprawl folder confinement the stage is about). Done LAST, after the semantic stages, so
the path churn didn't fight them.

Verified: Linux cargo check + clippy (-D warnings) clean; my mod-decl changes fmt-clean (the 3
remaining fmt diffs are pre-existing local-rustfmt-version skew that moved with their files); all
36 `#[path]` targets exist; no internal `#[path]`/`include!`/file-child-mod in any moved file
(the inline `mod X {` blocks are self-contained). Box build to follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:53:45 +00:00
enricobuehler a0427cd2a3 feat(windows-host): OutputFormat into the capturer — kill the dxgi back-reference (Goal-1 stage 5, tightening 1)
The headline §2.3 seam tightening (the explicit Stage-3 deferral; §5's "highest-severity
coupling"): the capturer is now TOLD its output format instead of re-deriving the encode backend.

New `capture::OutputFormat { gpu, hdr }`, resolved once per session and passed INTO
capture_virtual_output:
  * native punktfunk/1 path: `SessionPlan::output_format()` (gpu = encoder.is_gpu(), from the
    already-resolved plan.encoder — no second probe; hdr = plan.hdr).
  * GameStream + spike paths: `OutputFormat::resolve(hdr)` (gpu from the single `gpu_encode()`
    source, which maps windows_resolved_backend()).

`capture/dxgi.rs DuplCapturer::open` takes `gpu` in and its internal
`!matches!(windows_resolved_backend(), Software)` recompute is DELETED — the capture layer no
longer re-calls the encode layer (the back-reference that could let capture and encode disagree
on whether frames are GPU-resident, plan §2.3/§5). The relay's secure-desktop DDA passes
`gpu_encode()` likewise.

Behavior-preserving: the `gpu` passed in equals the value the capturer used to compute (same
encode-backend resolution). The DDA opens keep `want_hdr=false` (the SDR fallback, unchanged).

Tightenings 2 (HDR/release -> VirtualLease) and 3 (EncoderCaps) split off: (2) needs the
monitor-generation carried on the lease + the keepalive becoming Box<dyn VirtualLease> — that's
the §2.5 ownership-model change (CURRENT_MON_GEN / sudovda::wait_for_monitor_released), so it
moves there; (3) is a small additive follow-on. Documented in the plan.

Verified: Linux cargo check + clippy (-D warnings) + fmt clean. Box build to follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:37:48 +00:00
enricobuehler a4c85af155 feat(windows-host): SessionContext — bundle the 13-arg session entry (Goal-1 stage 4)
Bundle the 13-positional-argument `#[allow(too_many_arguments)]` session entry (virtual_stream
AND virtual_stream_relay) into one owned SessionContext struct, moved into the stream thread.
The reconfig/keyframe receivers move IN (virtual_stream is their only consumer), retiring the
&Receiver borrow plumbing. Behavior-identical by construction: each function destructures the
context into the same local names at the top, so the ~400-line loop bodies are byte-for-byte
unchanged. Both `#[allow(too_many_arguments)]` attrs removed.

Scoped deliberately: the plan's SessionFactory.build() owning a `vdm.lease -> open_capturer ->
open_encoder -> spawn` RAII chain with Session::drop as the ONLY teardown is coupled to §2.5's
ownership-model rewrite — it needs a host-side VirtualDisplayManager/MonitorLease that doesn't
exist yet (the lifecycle still lives in CURRENT_MON_GEN/IDD_SETUP_LOCK globals + the
per-compositor vdisplay backends). The current teardown is ALREADY drop-based (the capturer owns
the keepalive whose Drop releases the monitor — "restore displays before REMOVE" lives there;
only send_thread.join() is explicit) and is the validated shipping path, so wrapping the deployed
reconfig/switch/rebuild loop in a Session::drop for a behavior-preserving change would add real
regression risk for marginal gain. The SessionFactory/Session::drop/vdm.lease work folds into
§2.5; this stage delivers the concrete, safe arg-bundling.

Verified: Linux cargo check + clippy (-D warnings) + fmt clean. Box build to follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:23:57 +00:00
enricobuehler 9ba90d4b77 docs(windows-host-goal1): Stage 3 DONE — on-glass validated (SessionPlan resolves correctly; A/B vs shipping proves the env-only no-frame is not a regression)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:10:49 +00:00
enricobuehler 5358ef9fee docs(windows-host-goal1): record Stage 3 box build green (cargo check --features nvenc clean on the RTX box)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:55:42 +00:00
enricobuehler 0a63154293 feat(windows-host): SessionPlan — resolve capture/topology/encoder once per session (Goal-1 stage 3)
New src/session_plan.rs: a Copy `SessionPlan { capture, topology, encoder, bit_depth, hdr }`
resolved ONCE from HostConfig (+ the negotiated bit_depth) at the top of `virtual_stream`,
logged, and threaded through build_pipeline_with_retry/build_pipeline. The three scattered
Windows dispatch points now read this one typed artifact instead of re-deriving from config
(plan §2.4, the "capture and encode disagree on the backend" hazard):

  * capture: capture::capture_virtual_output takes a CaptureBackend IN (was re-reading
    config().idd_push / capture_backend / no_wgc internally). CaptureBackend::resolve() is the
    single resolver, shared with the GameStream + spike call sites.
  * topology: virtual_stream reads plan.topology; should_use_helper is deleted (its body is
    session_plan::resolve_topology, verbatim). The IDD-push reconnect-preempt guard reads
    plan.capture too.
  * encoder: recorded as EncoderBackend from encode::windows_resolved_backend (config-backed +
    GPU-vendor cached since stage 2 -> already a single source). Threading encoder/input_format
    into the encoder+capturer opens (which removes the capture->windows_resolved_backend()
    back-reference recomputed in dxgi.rs) is stage 5.

Behavior-preserving by construction: each resolved decision is provably equivalent to the
pre-stage-3 reads (same config() + the same cached running_as_system()/GPU-vendor probes), so
old==new. SessionPlan is platform-neutral so it threads the shared virtual_stream/build_pipeline
signatures; on Linux it resolves to the single portal/single-process path.

Also fixes a pre-existing mod-ordering fmt drift in main.rs (mod config; / mod capture;).

Verified: Linux cargo check + clippy (-D warnings) + fmt clean on the touched files. Box build
(Windows compile) + on-glass (NVENC + IDD-push + mode switch) pending on the RTX box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:47:48 +00:00
enricobuehler e5057f6cc1 feat(windows-host): finish HostConfig migration — resolve operator/dispatch knobs once (Goal-1 stage 2)
Migrate 31 genuinely-constant operator/dispatch env::var sites onto HostConfig, so the
capture/topology/encoder decision reads ONE owner instead of being recomputed at each call
site (the latent bug where capture and encode could disagree on the resolved backend, plan §2.4):
idd_push x7, no_wgc, capture_backend, render_adapter, encoder_pref (Linux open_video +
linux_zero_copy_is_vaapi), the Windows vdisplay-backend select, plus the plan-named
secure_dda/idd_depth/zerocopy/ten_bit and the multi-site perf x4 / compositor x5 /
video_source x3 / gamepad. Each HostConfig field's parser is byte-identical to the read it
replaced, so old==new by construction (the plan's "a flipped bool is a silent regression" guard).

Scope correction — the plan's "~64 sites / Linux XDG+compositor included / grep env::var -> 0"
was unsafe as written. Two classes are deliberately KEPT as live reads and documented in config.rs:

  * Runtime-mutated session vars. vdisplay::apply_session_env REWRITES the process env on every
    connect (the Bazzite Gaming<->Desktop follow): WAYLAND_DISPLAY, XDG_CURRENT_DESKTOP,
    XDG_RUNTIME_DIR, DBUS_SESSION_BUS_ADDRESS, and the derived PUNKTFUNK_INPUT_BACKEND,
    GAMESCOPE_SESSION/NODE, KWIN/MUTTER_VIRTUAL_PRIMARY, FORCE_SHM. Parsing these once would
    freeze them at startup and silently break session-following — they are NOT constant.
  * Single-use local tuning with no resolve-once benefit (and FEC_PCT even has two different
    semantics): FEC_PCT, VIDEO_DROP, VBV_FRAMES, SPLIT_ENCODE, PACE_BURST_KB, the dxgi timing
    knobs, the *_LIVE/test gates, plus path/dynamic reads (config-dir, PATH search,
    env-forward-to-child). PUNKTFUNK_ZEROCOPY is split on purpose: Windows presence-semantics
    moved to the field; Linux keeps its own truthy (1|true|yes|on) parser.

Verified: Linux cargo check + clippy (-D warnings) + fmt clean on the touched files. The
Windows-only edits are 1:1 substitutions; they get a real Windows compile on the box with Stage 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:24:00 +00:00
enricobuehler a3eefc2374 feat(windows-host): HostConfig foundation + staged Goal-1 roadmap (Goal-1 stage 1)
config.rs: typed HostConfig parsed ONCE from env (idd_push/encoder_pref/no_helper/force_helper), replacing per-call env::var re-reads (PUNKTFUNK_ENCODER was re-read on EVERY windows_resolved_backend() call; PUNKTFUNK_IDD_PUSH is read 8x across the host — the recompute that lets capture + encode disagree on the backend, plan §2.4). Migrated the two highest-churn dispatch reads onto it (encode::windows_resolved_backend, punktfunk1::should_use_helper). Behavior-identical: the env is constant for the process lifetime (the service loads host.env before launch), so a lazily-parsed global == parsed-once-at-startup.

docs/windows-host-goal1-plan.md: the ORDERED, independently-shippable execution plan for Goal-1 (the plan's biggest unstarted goal — a from-scratch layered host architecture). Six behavior-preserving, box-verified stages (HostConfig -> SessionPlan -> SessionContext/SessionFactory -> seam-trait tightenings -> src/windows tree), because the host is live-validated and a monolithic rewrite would strand it broken. Stage 1 done here; stages 3-5 rewire the deployed path and require on-glass re-test.

Verified: Linux + box (--features nvenc) cargo check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:02:16 +00:00
enricobuehler cd591514ad feat(windows-drivers): EvtCleanupCallback + single-identity dedup; document state ownership (E1)
EvtCleanupCallback on the WDFDEVICE (entry.rs + callbacks::device_cleanup): on device removal (PnP/unload) drop every monitor's swap-chain worker via monitor::cleanup_for_device_removal (joins threads, IddCx-free — the framework tears the monitors down with the device). Worker threads no longer linger into teardown.

Single identity per session (create_monitor): a re-ADD of a still-live session_id departs the stale monitor first, so one session maps to exactly one monitor (no duplicate EDID/target).

DeviceContext-owned state (audit §2.5): documented decision NOT to migrate the globals to a Box/AtomicPtr device-owned allocation. The IddCx monitor/mode DDIs receive only an IddCx handle (never the WDFDEVICE/context), so the state MUST be globally reachable (upstream virtual-display-rs is a process-static for the same reason); the globals are already module-encapsulated; and with one devnode + UmdfHostProcessSharing=ProcessSharingDisabled they die with the host process on removal anyway. A pointer variant would only add a host-gone-watchdog-race use-after-free for zero benefit.

Verified: driver workspace builds clean on the RTX box (.173).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:48:23 +00:00
enricobuehler a2bd0cd77c refactor(windows-packaging): delete the superseded vdisplay-driver/ tree (M6)
The old all-Rust IddCx driver tree (packaging/windows/vdisplay-driver/ — the wdf-umdf-sys 'oracle', 7896 lines) is fully superseded by packaging/windows/drivers/ (wdk-sys / windows-drivers-rs + the owned pf-vdisplay-proto ABI), which is the source of the vendored + installed driver. It was in NO cargo workspace (never built) and NO CI workflow; only stale doc/script refs pointed at it (the confusion the audit + game-capture-bug doc both flagged).

Delete it + repoint the build-relevant refs (packaging/windows/README.md, stage-pf-vdisplay.ps1, pack-host-installer.ps1) at drivers/ + drivers/deploy-dev.ps1. The vendored driver (packaging/windows/pf-vdisplay/) is unaffected; docs/windows-virtual-display-rust-port.md keeps its historical mentions as narrative.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:37:00 +00:00
enricobuehler 48f980ebb1 feat(packaging): deploy-dev.ps1 for the new-tree pf-vdisplay driver
Build/sign/install script for the wdk-sys/windows-drivers-rs driver in packaging/windows/drivers/ (the new tree lacked one). Like the old vdisplay-driver/deploy-dev.ps1 but adds the FORCE_INTEGRITY clear (this tree links /INTEGRITYCHECK) and a 9.9.MMdd.HHmm DriverVer (the vendored build is 9.5.*). Verified: deployed the rebuilt driver to the RTX box (.173).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:09:27 +00:00
enricobuehler 1cd87066d7 docs(windows-rewrite): track GB1/GB3 progress + box IP floats (DHCP)
Record GB1 (host-side recover-or-drop) + GB3 groundwork (driver descriptor guard/logging) in the tracker; note the RTX validation box IP floats (DHCP/ephemeral, recently .173/.158) instead of hardcoding .158.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:35:27 +00:00
enricobuehler 789ad49bc4 feat(windows-drivers): publish() descriptor guard + log appender (game-capture GB3 groundwork)
publish() now guards width/height alongside format (CopyResource needs matching DIMS too, else garbage): drops a surface whose descriptor no longer matches the host ring (a fullscreen game mode-set the display) AND logs the actual descriptor once per mismatch episode, so a repro shows exactly what changed (GB1/Stage-0 diagnostic + the Stage-2 width/height guard).

log.rs: a process-lifetime, flushed, Mutex-shared append handle (opened ONCE) replaces the per-call open/append — so the swap-chain WORKER thread's lines land. They were hidden (per-call open raced the control thread / could fail under the worker's restricted token), which is exactly why a game-break repro showed no swap-chain-processor lines (bug doc S3). This is the observability foundation the bug doc gates Stage S (S1/S2 driver resilience) on.

Needs a driver rebuild + re-vendor to deploy (separate from the GB1 host-only fix). Stage 3 (trim default_modes) deprioritized: GB1 recovers from mode-sets, and trimming risks the live display-activation path.

Verified: driver workspace builds clean on the RTX box (.173).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:33:11 +00:00
enricobuehler c87bfe0e7b feat(windows-host): IDD-push recovers from a game mode-set, else drops (game-capture bug GB1)
The bug: a fullscreen game mode-sets the virtual display (format/size); the driver's publish() guard then drops every frame; the host's ring — fixed at the session-negotiated mode — never adapts -> frozen picture, then black on reconnect.

RECOVER (no DDA, per the chosen design): the ring now TRACKS the display's actual mode. At open it is sized to the display's actual resolution (new win_display::active_resolution, CCD/GDI) — so reconnecting while a game holds a different mode just works. Mid-session, the 250ms poll (was HDR-only) now also follows the active resolution; on any descriptor change (size or HDR) it recreates the ring at the new mode (recreate_ring generalized to a new size) -> the driver re-attaches -> frames resume at the game's mode. No freeze, no reconnect needed.

DROP if unrecoverable: a descriptor change starts a recovery clock (recovering_since); if no fresh frame resumes within 3s (e.g. an exclusive-flip the host can't follow), try_consume bails -> the session ends cleanly -> the client reconnects, instead of freezing forever. A pure idle desktop (no mode change) never triggers this.

Verified: host clippy (nvenc) clean on the RTX box. NEEDS ON-GLASS (Doom repro on .158): confirm the poll sees the mode-set, the ring recreates + recovers, the encoder+client adapt to the size change; tune the 3s window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:12:48 +00:00
enricobuehler f98ab07dd6 feat(windows-host): IDD-push first-frame failover to DDA (game-capture bug GB1 pt1)
wait_for_attach now requires the driver to publish a FIRST frame, not just attach (DRV_STATUS_OPENED). A fullscreen game can leave the virtual display in a format/size the driver's publish() guard rejects -> the driver ATTACHES but silently drops every frame; previously the host sailed past open() and only died on next_frame's 20s deadline (the 'reconnect = black + working audio' symptom). Now open() fails -> capture.rs falls back to DDA (reusing the C1 fallback) -> the game is captured + visible after a reconnect.

Safe at open: the OS composites the freshly-activated virtual display, so a frame arrives within ~1s — a normal/idle open isn't false-failed; only a genuinely-broken display (no frame in 4s) falls back (and DDA is a working path, so even a false-positive degrades gracefully).

GB1 Stage 1a (docs/windows-host-rewrite-game-capture-bug.md P3). The mid-session-without-reconnect live failover (composing capturer) is the next piece.

Verified: host clippy (nvenc) clean on the RTX box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:50:12 +00:00
enricobuehler dbab1f98ba docs(windows-rewrite): track the fullscreen-game capture bug as a related workstream
Cross-reference docs/windows-host-rewrite-game-capture-bug.md from the remediation tracker, with the intersections that matter for whoever implements it: Stage 1 builds on (doesn't duplicate) our C1 mid-/open-time fallback; the bug doc is written against pre-remediation main (a11b0dd) so its line refs are stale; Stage 2's new SharedHeader fields must update A's offset asserts (in lib.rs frame mod); Stage 0/S3 diagnostics need the driver log B3 gated off in release; S1/S2 is adjacent to E1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:40:16 +00:00
enricobuehler 5d279f8886 docs(windows-rewrite): audit-remediation hand-off tracker
Living progress/hand-off doc (docs/windows-host-rewrite-remediation.md): the 9 committed remediation commits with audit refs + how each was verified, the remaining tasks (D2, D1-host, E1, G) with scope / on-glass-gating / verification notes, the box verification recipe, and the new modules introduced. Cross-linked from the audit doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:30:43 +00:00
enricobuehler e60cda3939 refactor(windows-host): move CCD/HDR display helpers to a neutral module — F1 complete (audit §9)
Moved the remaining 6 SudoVDA reach-in helpers + SavedConfig (resolve_gdi_name, set_advanced_color, advanced_color_enabled, set_active_mode, isolate/restore_displays_ccd) verbatim from vdisplay::sudovda into a backend-neutral crate::win_display module (the plan's windows/display_ccd.rs). The capturers (idd_push/dxgi/wgc), pf_vdisplay, and punktfunk1 now depend on these as PEERS via crate::win_display instead of reaching into the SudoVDA backend.

With win_adapter (F1 pt1), all 7 reach-in helpers are now neutral — the circular reach-in is broken, so SudoVDA can eventually be deleted (Goal 2) without losing the display utilities. sudovda re-exports the ones it still uses internally; its now-unused CCD/GDI imports were removed.

Verified: host clippy (nvenc) clean on the RTX box; Linux check clean (the new modules are #[cfg(windows)]).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:26:25 +00:00
enricobuehler d638a93e04 refactor(windows-host): move resolve_render_adapter_luid to a neutral module (audit §9 / F1 pt 1)
The discrete-render-GPU LUID picker was display-utility living in the SudoVDA backend; moved it verbatim to a backend-neutral crate::win_adapter module (the plan's windows/adapter.rs). The IDD-push capturer + pf-vdisplay backend now depend on it as a PEER instead of reaching into vdisplay::sudovda — the first step in breaking the circular reach-in so SudoVDA can eventually be dropped (Goal 2). sudovda re-exports it for its own callers.

Remaining F1 increments: the CCD/HDR helpers (resolve_gdi_name, set_advanced_color, advanced_color_enabled, set_active_mode, isolate/restore_displays_ccd) → a neutral win_display module.

Verified: host clippy (nvenc) clean on the RTX box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:33:23 +00:00
enricobuehler a755d6eab7 chore(windows-drivers): deny(unsafe_op_in_unsafe_fn) on the driver crates (audit §8 P0)
Lock in the explicit-unsafe-block discipline so a fn-level 'unsafe' never silently blesses its whole body (the per-site // SAFETY: comments already landed in STEP 8). Builds clean on the RTX box — no fallout. The host-wide unsafe-lint sweep + clippy::undocumented_unsafe_blocks (hundreds of blocks across Linux+Windows) are a larger dedicated follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:19:38 +00:00
enricobuehler b0d28380b5 feat(windows-host): rotate out-ring on repeat + size HDR ring at open (audit §5.3/§5.4)
§5.3 (C3): repeat_last() now copies the last frame into a FRESH rotated out-ring slot instead of re-handing last_present's slot, so a repeat (static desktop) never re-hands a slot still encoding under pipeline_depth>1. OUT_RING(3) > max depth(2) keeps the rotated slot free — the out-ring rotation contract now holds for repeats too, not just the synchronous-loop assumption.

§5.4 (C4): when enabling advanced color for a 10-bit client, trust set_advanced_color success and size the ring FP16 directly, instead of racing the advanced_color_enabled poll (which could size SDR while the driver composes FP16 -> format mismatch -> an immediate ring recreate + dropped first frames).

Verified: host clippy (nvenc) clean on the RTX box. On-glass to confirm: HDR-client first-frame + static-desktop pipelining.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:18:05 +00:00
enricobuehler ed583650a6 feat(windows-host): IDD-push attach fallback to DDA, not the 20s black bail (audit §5.1)
open() now hands the keepalive BACK on failure (the WGC attach_keepalive pattern) so the caller can fall back instead of tearing the virtual display down. Added a bounded wait_for_attach() that polls the driver's DRV_STATUS_OPENED — it checks ATTACH status, not frame arrival, so it never false-fails on an idle desktop that has composed no frame yet.

An attach failure (e.g. a hybrid-GPU render mismatch -> DRV_STATUS_TEX_FAIL, or the driver never opening the ring within 4s) now fails open() -> capture.rs falls back to DDA, instead of next_frame's 20s deadline leaving the session black. Pairs with the driver SET_RENDER_ADAPTER fix (0a7ae5e).

Verified: host clippy (nvenc) clean on the RTX box. Behavioral validation (fallback trigger + happy-path attach timing) needs an on-glass session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:09:28 +00:00
enricobuehler e5c9ee8327 feat(windows-host): activate render-adapter pin; gamepad SHM from proto (audit §4.2h/§6.1)
§4.2h (C2): the host already pins the discrete GPU via IOCTL_SET_RENDER_ADAPTER on the IDD-push path; now that the pf-vdisplay driver implements it (0a7ae5e), correct the stale 'driver returns STATUS_NOT_IMPLEMENTED / STEP-4 stub' comments. Hybrid iGPU+dGPU boxes now actually pin the NVENC GPU.

§6.1 (C5): switch the host gamepad SHM consumers (inject/{dualsense,gamepad}_windows.rs) to derive size/offsets/magic/name from pf_vdisplay_proto::gamepad::{PadShm,XusbShm} via offset_of!/size_of!/helpers, instead of hand-literal OFF_*/140 — proto is now the single source of truth (driver-side switch follows with the gamepad-driver unification). The DualShock4 backend reuses the same pub(super) consts unchanged.

Verified: host clippy (nvenc) clean on the RTX box (x86_64-pc-windows-msvc).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:02:22 +00:00
enricobuehler 0a7ae5ef09 feat(windows-drivers): host-gone watchdog, SET_RENDER_ADAPTER, log gate, mode bounds
Audit §4.1: implement the host-gone watchdog — it was dead code (WATCHDOG_PINGS bumped but never sampled, no thread). Every IOCTL now bumps a liveness counter; a watchdog thread reap_orphaned()s monitors (created_at grace) if no IOCTL arrives within WATCHDOG_TIMEOUT_S, so a crashed/TerminateProcess'd host no longer leaves its virtual monitor + swap-chain worker + pooled D3D device wedged until the next CLEAR_ALL. Removes the false 'watchdog thread' comments.

Audit §4.2: implement SET_RENDER_ADAPTER (was STATUS_NOT_IMPLEMENTED) via IddCxAdapterSetRenderAdapter, so the host can pin the IDD render to the NVENC GPU on a hybrid iGPU+dGPU box (else the OS-picked iGPU makes the host ring textures un-openable -> DRV_STATUS_TEX_FAIL).

Audit §4.4: gate the world-writable C:\Users\Public\pfvd-driver.log behind debug builds / PFVD_DEBUG_LOG (a release build never writes it).

Audit §4.5: bounds-check the requested mode in IOCTL_ADD; compute display_info clock_rate in u64 + saturate (the old u32 refresh*(h+4)^2 overflowed/aborted the mode DDI for large modes).

Verified: driver workspace builds clean on the RTX box (WDK 26100 + LLVM 21.1.2, MSVC). On-glass functional validation of the watchdog/render-pin is a follow-up (needs a driver reinstall + session).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:49:49 +00:00
enricobuehler 95dcef3515 fix(pf-vdisplay-proto): offset asserts + own the gamepad SHM layouts (audit §6.1/§6.2)
§6.2: add offset_of! asserts to SharedHeader/AddReply/control structs so a same-size field reorder is a compile error, not silent corruption (size+Pod alone miss it).

§6.1: add XusbShm (64B) + PadShm (256B, incl device_type@140) layouts + Global\ name helpers + magics to the proto crate as the single source of truth, with offset asserts pinned to the shipped wire layout — kills the hand-duplicated literal-140 host/driver drift hazard. Enables bytemuck min_const_generics for the >32-byte reserved tails. Host + driver consumers switch in a follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:39:42 +00:00
enricobuehler 0badc17d87 docs(windows-rewrite): audit the IDD-push rewrite against its plan
Driver track (M0+M1, STEPs 0-7) landed and is on-glass-validated, but the host-side goals (clean architecture, SudoVDA removal, unsafe reduction) and several driver-spec items (host-gone watchdog, SET_RENDER_ADAPTER, ownership model) are not yet done. Full findings + a prioritized P0-P2 fix list in the doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:39:42 +00:00
206 changed files with 12154 additions and 13990 deletions
+9 -9
View File
@@ -3,7 +3,7 @@
# #
# Stage 1 (this file): PROBE the runner's driver toolchain (WDK / EWDK / cargo-make / LLVM / the # 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, # 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 # 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; # /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. # only live NVENC encode does, which defers to the RTX box.
@@ -18,12 +18,12 @@ on:
branches: [main] branches: [main]
paths: paths:
- '.gitea/workflows/windows-drivers.yml' - '.gitea/workflows/windows-drivers.yml'
- 'crates/pf-vdisplay-proto/**' - 'crates/pf-driver-proto/**'
- 'packaging/windows/drivers/**' - 'packaging/windows/drivers/**'
pull_request: pull_request:
paths: paths:
- '.gitea/workflows/windows-drivers.yml' - '.gitea/workflows/windows-drivers.yml'
- 'crates/pf-vdisplay-proto/**' - 'crates/pf-driver-proto/**'
- 'packaging/windows/drivers/**' - 'packaging/windows/drivers/**'
# Driver builds need the WDK on the runner (provision once via windows-drivers-provision.yml). # 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_HOME = " + ($env:CARGO_HOME ?? '<unset>'))
Write-Host ("CARGO_TARGET_DIR (daemon) = " + ($env:CARGO_TARGET_DIR ?? '<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: | run: |
# Short target dir to dodge MAX_PATH inside the deep act host workdir (see windows.yml). # Short target dir to dodge MAX_PATH inside the deep act host workdir (see windows.yml).
$env:CARGO_TARGET_DIR = "C:\t\drv" $env:CARGO_TARGET_DIR = "C:\t\drv"
cargo build -p pf-vdisplay-proto cargo build -p pf-driver-proto
cargo test -p pf-vdisplay-proto cargo test -p pf-driver-proto
cargo clippy -p pf-vdisplay-proto --all-targets -- -D warnings cargo clippy -p pf-driver-proto --all-targets -- -D warnings
cargo fmt -p pf-vdisplay-proto -- --check cargo fmt -p pf-driver-proto -- --check
# Build the UMDF driver workspace (wdk-probe) on windows-drivers-rs: proves wdk-sys bindgen/link works # 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. # DLL's FORCE_INTEGRITY (/INTEGRITYCHECK) bit — the M0 self-signed-load question.
driver-build: driver-build:
runs-on: windows-amd64 runs-on: windows-amd64
Generated
+424 -3
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3 version = 3
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "aead" name = "aead"
version = "0.5.2" version = "0.5.2"
@@ -735,6 +741,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "criterion" name = "criterion"
version = "0.5.1" version = "0.5.1"
@@ -1010,6 +1025,18 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "fastbloom" name = "fastbloom"
version = "0.14.1" version = "0.14.1"
@@ -1088,6 +1115,16 @@ version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]] [[package]]
name = "flume" name = "flume"
version = "0.11.1" version = "0.11.1"
@@ -1111,6 +1148,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@@ -1586,7 +1629,16 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [ 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]] [[package]]
@@ -1594,6 +1646,18 @@ name = "hashbrown"
version = "0.17.1" version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" 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]] [[package]]
name = "heck" name = "heck"
@@ -1712,12 +1776,115 @@ dependencies = [
"tower-service", "tower-service",
] ]
[[package]]
name = "icu_collections"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
[[package]]
name = "icu_properties"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
[[package]]
name = "icu_provider"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]] [[package]]
name = "id-arena" name = "id-arena"
version = "2.3.0" version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]] [[package]]
name = "if-addrs" name = "if-addrs"
version = "0.15.0" version = "0.15.0"
@@ -1966,12 +2133,29 @@ dependencies = [
"system-deps", "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]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.12.1" version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -2066,6 +2250,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.1" version = "1.2.1"
@@ -2419,7 +2613,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "pf-vdisplay-proto" name = "pf-driver-proto"
version = "0.0.1" version = "0.0.1"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
@@ -2498,6 +2692,15 @@ dependencies = [
"universal-hash", "universal-hash",
] ]
[[package]]
name = "potential_utf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
dependencies = [
"zerovec",
]
[[package]] [[package]]
name = "powerfmt" name = "powerfmt"
version = "0.2.0" version = "0.2.0"
@@ -2655,6 +2858,7 @@ dependencies = [
"audiopus_sys", "audiopus_sys",
"axum", "axum",
"axum-server", "axum-server",
"base64",
"bytemuck", "bytemuck",
"cbc", "cbc",
"ffmpeg-next", "ffmpeg-next",
@@ -2670,14 +2874,16 @@ dependencies = [
"nvidia-video-codec-sdk", "nvidia-video-codec-sdk",
"openh264", "openh264",
"opus", "opus",
"pf-vdisplay-proto", "pf-driver-proto",
"pipewire", "pipewire",
"punktfunk-core", "punktfunk-core",
"quinn", "quinn",
"rand 0.8.6", "rand 0.8.6",
"rcgen", "rcgen",
"reis", "reis",
"roxmltree",
"rsa", "rsa",
"rusqlite",
"rustls", "rustls",
"rustls-pemfile", "rustls-pemfile",
"rusty_enet", "rusty_enet",
@@ -2689,6 +2895,7 @@ dependencies = [
"tower", "tower",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"ureq",
"utoipa", "utoipa",
"utoipa-axum", "utoipa-axum",
"utoipa-scalar", "utoipa-scalar",
@@ -2700,6 +2907,7 @@ dependencies = [
"wayland-scanner", "wayland-scanner",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", "windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
"windows-service", "windows-service",
"winreg",
"x509-parser", "x509-parser",
"xkbcommon", "xkbcommon",
] ]
@@ -3002,6 +3210,15 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "roxmltree"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "rpkg-config" name = "rpkg-config"
version = "0.1.2" version = "0.1.2"
@@ -3028,6 +3245,31 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.2" version = "2.1.2"
@@ -3478,6 +3720,12 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.3" version = "1.0.3"
@@ -3548,6 +3796,24 @@ dependencies = [
"der", "der",
] ]
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
dependencies = [
"cc",
"js-sys",
"rsqlite-vfs",
"wasm-bindgen",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.11.1" version = "0.11.1"
@@ -3700,6 +3966,16 @@ dependencies = [
"time-core", "time-core",
] ]
[[package]]
name = "tinystr"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
"displaydoc",
"zerovec",
]
[[package]] [[package]]
name = "tinytemplate" name = "tinytemplate"
version = "1.2.1" version = "1.2.1"
@@ -4005,6 +4281,40 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64",
"flate2",
"log",
"once_cell",
"rustls",
"rustls-pki-types",
"url",
"webpki-roots 0.26.11",
]
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]] [[package]]
name = "utf8parse" name = "utf8parse"
version = "0.2.2" version = "0.2.2"
@@ -4332,6 +4642,24 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.8",
]
[[package]]
name = "webpki-roots"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "wide" name = "wide"
version = "0.7.33" version = "0.7.33"
@@ -4857,6 +5185,16 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "winreg"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.51.0" version = "0.51.0"
@@ -4951,6 +5289,12 @@ dependencies = [
"wasmparser", "wasmparser",
] ]
[[package]]
name = "writeable"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]] [[package]]
name = "x509-parser" name = "x509-parser"
version = "0.16.0" version = "0.16.0"
@@ -4994,6 +5338,29 @@ dependencies = [
"time", "time",
] ]
[[package]]
name = "yoke"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]] [[package]]
name = "zbus" name = "zbus"
version = "5.16.0" version = "5.16.0"
@@ -5070,12 +5437,66 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]] [[package]]
name = "zeroize" name = "zeroize"
version = "1.8.2" version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = [ members = [
"crates/punktfunk-core", "crates/punktfunk-core",
"crates/punktfunk-host", "crates/punktfunk-host",
"crates/pf-vdisplay-proto", "crates/pf-driver-proto",
"clients/probe", "clients/probe",
"clients/linux", "clients/linux",
"clients/windows", "clients/windows",
@@ -7,7 +7,7 @@
# own OS type. Defining every wire struct ONCE here — with `const` size/offset asserts + bytemuck # 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. # round-trips — makes host<->driver ABI drift a COMPILE error instead of a silent frame/IOCTL corruption.
[package] [package]
name = "pf-vdisplay-proto" name = "pf-driver-proto"
version = "0.0.1" version = "0.0.1"
edition = "2021" edition = "2021"
rust-version = "1.82" rust-version = "1.82"
@@ -16,4 +16,5 @@ description = "Shared host<->driver binary contract for the punktfunk pf-vdispla
publish = false publish = false
[dependencies] [dependencies]
bytemuck = { version = "1.19", features = ["derive"] } # `min_const_generics`: Pod/Zeroable for `[u8; N]` of any N (the gamepad SHM reserved tails are >32).
bytemuck = { version = "1.19", features = ["derive", "min_const_generics"] }
@@ -119,13 +119,32 @@ pub mod control {
} }
// Layout is load-bearing across the process boundary — pin it. (bytemuck's Pod derive already // Layout is load-bearing across the process boundary — pin it. (bytemuck's Pod derive already
// rejects any internal padding; these assert the externally-visible sizes too.) // rejects any internal padding; these assert the externally-visible sizes too.) The `offset_of!`
// asserts additionally catch a SAME-SIZE field reorder, which the size+Pod checks alone miss.
const _: () = { const _: () = {
assert!(core::mem::size_of::<AddRequest>() == 24); use core::mem::{offset_of, size_of};
assert!(core::mem::size_of::<AddReply>() == 16);
assert!(core::mem::size_of::<RemoveRequest>() == 8); assert!(size_of::<AddRequest>() == 24);
assert!(core::mem::size_of::<SetRenderAdapterRequest>() == 8); assert!(offset_of!(AddRequest, session_id) == 0);
assert!(core::mem::size_of::<InfoReply>() == 8); assert!(offset_of!(AddRequest, width) == 8);
assert!(offset_of!(AddRequest, height) == 12);
assert!(offset_of!(AddRequest, refresh_hz) == 16);
assert!(size_of::<AddReply>() == 16);
assert!(offset_of!(AddReply, adapter_luid_low) == 0);
assert!(offset_of!(AddReply, adapter_luid_high) == 4);
assert!(offset_of!(AddReply, target_id) == 8);
assert!(size_of::<RemoveRequest>() == 8);
assert!(offset_of!(RemoveRequest, session_id) == 0);
assert!(size_of::<SetRenderAdapterRequest>() == 8);
assert!(offset_of!(SetRenderAdapterRequest, luid_low) == 0);
assert!(offset_of!(SetRenderAdapterRequest, luid_high) == 4);
assert!(size_of::<InfoReply>() == 8);
assert!(offset_of!(InfoReply, protocol_version) == 0);
assert!(offset_of!(InfoReply, watchdog_timeout_s) == 4);
}; };
} }
@@ -228,8 +247,138 @@ pub mod frame {
alloc::format!("Global\\pfvd-tex-{target_id}-{generation}-{slot}") alloc::format!("Global\\pfvd-tex-{target_id}-{generation}-{slot}")
} }
// Size + per-field offsets are load-bearing: both sides access these via raw atomic views over the
// mapping, so a same-size field reorder would silently corrupt. Pin every offset. The `_pad` after
// `dxgi_format` is what 8-aligns the `u64 latest` at offset 32 — assert that too.
const _: () = { const _: () = {
assert!(core::mem::size_of::<SharedHeader>() == 64); use core::mem::{offset_of, size_of};
assert!(size_of::<SharedHeader>() == 64);
assert!(offset_of!(SharedHeader, magic) == 0);
assert!(offset_of!(SharedHeader, version) == 4);
assert!(offset_of!(SharedHeader, generation) == 8);
assert!(offset_of!(SharedHeader, ring_len) == 12);
assert!(offset_of!(SharedHeader, width) == 16);
assert!(offset_of!(SharedHeader, height) == 20);
assert!(offset_of!(SharedHeader, dxgi_format) == 24);
assert!(offset_of!(SharedHeader, _pad) == 28);
assert!(offset_of!(SharedHeader, latest) == 32);
assert!(offset_of!(SharedHeader, qpc_pts) == 40);
assert!(offset_of!(SharedHeader, driver_render_luid_low) == 48);
assert!(offset_of!(SharedHeader, driver_render_luid_high) == 52);
assert!(offset_of!(SharedHeader, driver_status) == 56);
assert!(offset_of!(SharedHeader, driver_status_detail) == 60);
};
}
/// Gamepad shared-memory layouts (host ↔ the UMDF gamepad drivers `pf_xusb` / `pf_dualsense`).
///
/// These were hand-duplicated as `OFF_*`/`SHM_*` constants in `inject/{gamepad,dualsense}_windows.rs`
/// and (as bare literals — `*view.add(140)`) in the standalone `xusb-driver`/`dualsense-driver`
/// workspaces, guarded only by "must match" comments — the top ABI-drift hazard the audit flagged
/// (`docs/windows-host-rewrite.md` §2.7). Owning them here with `Pod` derives + `offset_of!`
/// asserts makes a one-sided edit a compile error.
///
/// The host creates the section (privileged, permissive DACL so the restricted WUDFHost token can
/// open it) and the driver maps it. Layout only; the section itself is host-created shared memory.
pub mod gamepad {
use alloc::string::String;
use bytemuck::{Pod, Zeroable};
/// XUSB section magic — the exact u32 the shipped host + `pf_xusb` driver compare (loosely "PFXU").
pub const XUSB_MAGIC: u32 = 0x5558_4650;
/// Pad section magic — the exact u32 the shipped host + `pf_dualsense` driver compare (loosely
/// "PFDS"). (Note: the two magics happen to use opposite byte-order mnemonics in the legacy code;
/// only the u32 value is the contract.)
pub const PAD_MAGIC: u32 = 0x5046_4453;
/// `device_type` selector the `pf_dualsense` driver reads to pick its HID identity. The section is
/// zeroed, so `0` = DualSense is the default; one driver serves either identity.
pub const DEVTYPE_DUALSENSE: u8 = 0;
/// `device_type` = DualShock 4 (`VID_054C&PID_09CC` HID identity).
pub const DEVTYPE_DUALSHOCK4: u8 = 1;
/// `Global\pfxusb-shm-<index>` — the virtual Xbox 360 (XInput) shared section.
pub fn xusb_shm_name(index: u8) -> String {
alloc::format!("Global\\pfxusb-shm-{index}")
}
/// `Global\pfds-shm-<index>` — the virtual DualSense / DualShock 4 shared section.
pub fn pad_shm_name(index: u8) -> String {
alloc::format!("Global\\pfds-shm-{index}")
}
/// Virtual Xbox 360 (XInput) shared section (64 B). The host writes the XInput state (a bumped
/// `packet` number + buttons/triggers/sticks in XInput conventions); the driver answers
/// `XInputGetState`. The driver writes force-feedback (`XInputSetState`) into `rumble_*`, bumping
/// `rumble_seq`, which the host relays to the client.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug)]
pub struct XusbShm {
pub magic: u32,
/// XInput `dwPacketNumber` — bumped by the host on every state change.
pub packet: u32,
pub buttons: u16,
pub left_trigger: u8,
pub right_trigger: u8,
pub thumb_lx: i16,
pub thumb_ly: i16,
pub thumb_rx: i16,
pub thumb_ry: i16,
pub _reserved0: u32,
/// Bumped by the driver on a new force-feedback packet.
pub rumble_seq: u32,
pub rumble_large: u8,
pub rumble_small: u8,
pub _reserved1: [u8; 34],
}
/// Virtual DualSense / DualShock 4 shared section (256 B). The host writes the `0x01`-style HID
/// input report into `input`; the driver feeds it to game `READ_REPORT`s and publishes a game's
/// `0x02` output (rumble / lightbar / player-LEDs / adaptive triggers) into `output`, bumping
/// `out_seq`. `device_type` selects the HID identity ([`DEVTYPE_DUALSENSE`] / [`DEVTYPE_DUALSHOCK4`]).
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug)]
pub struct PadShm {
pub magic: u32,
pub _reserved0: u32,
/// Input report region (host-written; the codec's report is <= 64 B — see
/// `inject::dualsense_proto::DS_INPUT_REPORT_LEN`). The region spans `magic`+pad .. `out_seq`.
pub input: [u8; 64],
/// Bumped by the driver when it publishes a new `output` report.
pub out_seq: u32,
/// Output report region (driver-written): rumble / lightbar / player-LEDs / adaptive triggers.
pub output: [u8; 64],
/// HID identity selector — see [`DEVTYPE_DUALSENSE`] / [`DEVTYPE_DUALSHOCK4`].
pub device_type: u8,
pub _reserved1: [u8; 115],
}
// Offsets are the wire contract the shipped drivers already read by hand — pin every one. A failing
// assert here means the struct no longer matches the historical `OFF_*` layout (host) / `view.add(N)`
// literal (driver) and must be fixed before either side switches to the type.
const _: () = {
use core::mem::{offset_of, size_of};
assert!(size_of::<XusbShm>() == 64);
assert!(offset_of!(XusbShm, magic) == 0);
assert!(offset_of!(XusbShm, packet) == 4);
assert!(offset_of!(XusbShm, buttons) == 8);
assert!(offset_of!(XusbShm, left_trigger) == 10);
assert!(offset_of!(XusbShm, right_trigger) == 11);
assert!(offset_of!(XusbShm, thumb_lx) == 12);
assert!(offset_of!(XusbShm, thumb_ly) == 14);
assert!(offset_of!(XusbShm, thumb_rx) == 16);
assert!(offset_of!(XusbShm, thumb_ry) == 18);
assert!(offset_of!(XusbShm, rumble_seq) == 24);
assert!(offset_of!(XusbShm, rumble_large) == 28);
assert!(offset_of!(XusbShm, rumble_small) == 29);
assert!(size_of::<PadShm>() == 256);
assert!(offset_of!(PadShm, magic) == 0);
assert!(offset_of!(PadShm, input) == 8);
assert!(offset_of!(PadShm, out_seq) == 72);
assert!(offset_of!(PadShm, output) == 76);
assert!(offset_of!(PadShm, device_type) == 140);
}; };
} }
@@ -301,6 +450,15 @@ mod tests {
assert_eq!(frame::texture_name(10, 3, 5), "Global\\pfvd-tex-10-3-5"); assert_eq!(frame::texture_name(10, 3, 5), "Global\\pfvd-tex-10-3-5");
} }
#[test]
fn gamepad_names_and_magics_are_stable() {
assert_eq!(gamepad::xusb_shm_name(0), "Global\\pfxusb-shm-0");
assert_eq!(gamepad::pad_shm_name(2), "Global\\pfds-shm-2");
// Lock the exact u32 magics the shipped host/drivers use (inject/{gamepad,dualsense}_windows.rs).
assert_eq!(gamepad::XUSB_MAGIC, 0x5558_4650);
assert_eq!(gamepad::PAD_MAGIC, 0x5046_4453);
}
#[test] #[test]
fn ctl_codes_are_contiguous_and_distinct() { fn ctl_codes_are_contiguous_and_distinct() {
assert_eq!(control::IOCTL_ADD, ctl_code(0x900)); assert_eq!(control::IOCTL_ADD, ctl_code(0x900));
+21 -3
View File
@@ -25,6 +25,14 @@ aes-gcm = "0.10"
cbc = { version = "0.1", features = ["alloc"] } cbc = { version = "0.1", features = ["alloc"] }
rand = "0.8" rand = "0.8"
hex = "0.4" hex = "0.4"
# Cover-art delivery in the game library: encode Lutris's local JPEGs into `data:` URLs and decode
# the Epic launcher's base64 `catcache.bin`. Cross-platform (Linux Lutris art + Windows Epic art).
base64 = "0.22"
# Blocking HTTP for the library cover-art warmer (no-auth GOG api.gog.com + Xbox displaycatalog),
# run on a background thread off the hot path. `ureq` is small + sync (no tokio here) and bundles
# webpki roots (no system cert dependency). Cross-platform so the fetch/parse code is compiled +
# checked everywhere even though only the Windows GOG/Xbox providers need it today.
ureq = "2"
rcgen = { version = "0.13", default-features = false, features = ["aws_lc_rs", "pem"] } rcgen = { version = "0.13", default-features = false, features = ["aws_lc_rs", "pem"] }
x509-parser = "0.16" x509-parser = "0.16"
axum-server = { version = "0.7", features = ["tls-rustls"] } axum-server = { version = "0.7", features = ["tls-rustls"] }
@@ -85,6 +93,10 @@ wayland-scanner = "0.31"
wayland-backend = "0.3" wayland-backend = "0.3"
# Parse `pw-dump` JSON to find gamescope's PipeWire node (gamescope backend). # Parse `pw-dump` JSON to find gamescope's PipeWire node (gamescope backend).
serde_json = "1" serde_json = "1"
# Read the Lutris library DB (`pga.db`) for the Lutris store provider. `bundled` vendors + compiles
# SQLite (cc, already needed for ffmpeg/opus) so there's no system libsqlite3 runtime dependency —
# clean for the deb/rpm/flatpak packaging. Opened read-only/immutable (Lutris may hold it open).
rusqlite = { version = "0.40", features = ["bundled"] }
# Builds/validates the xkb keymap uploaded to the virtual keyboard + tracks modifier state. # Builds/validates the xkb keymap uploaded to the virtual keyboard + tracks modifier state.
xkbcommon = "0.8" xkbcommon = "0.8"
# The safe `opus` crate is stereo-only; surround (5.1/7.1) needs the libopus *multistream* # The safe `opus` crate is stereo-only; surround (5.1/7.1) needs the libopus *multistream*
@@ -155,7 +167,7 @@ windows = { version = "0.62", features = [
"Win32_System_LibraryLoader", "Win32_System_LibraryLoader",
# VirtualProtect — for the inline patch of the win32u GPU-preference shim (Apollo's MinHook port: # 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). # 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. # crate / no C length-disassembler dep; a 12-byte absolute-jmp prologue patch suffices.
"Win32_System_Memory", "Win32_System_Memory",
# Per-monitor-v2 DPI awareness — IDXGIOutput5::DuplicateOutput1 (the modern capture path Apollo # Per-monitor-v2 DPI awareness — IDXGIOutput5::DuplicateOutput1 (the modern capture path Apollo
@@ -169,13 +181,19 @@ windows = { version = "0.62", features = [
# handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses # handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses
# the `windows` crate above. # the `windows` crate above.
windows-service = "0.7" windows-service = "0.7"
# Read the GOG.com install registry (HKLM\SOFTWARE\WOW6432Node\GOG.com\Games) for the GOG store
# provider — ergonomic + correct-by-construction vs. hand-rolled Reg* FFI for subkey enumeration.
winreg = "0.56"
# Parse each Xbox/Game-Pass game's MicrosoftGame.config (GDK manifest XML) for the Xbox store
# provider — a small read-only DOM is all we need (Identity/Executable/ShellVisuals/StoreId).
roxmltree = "0.21"
# Software H.264 encoder (GPU-less path + NVENC fallback). The default `source` feature statically # Software H.264 encoder (GPU-less path + NVENC fallback). The default `source` feature statically
# compiles OpenH264 (BSD-2) — no system lib, builds on MSVC; nasm on PATH adds the SIMD fast path. # compiles OpenH264 (BSD-2) — no system lib, builds on MSVC; nasm on PATH adds the SIMD fast path.
openh264 = "0.9" openh264 = "0.9"
# WASAPI loopback audio capture (default render endpoint -> 48 kHz stereo f32 for the Opus path). # WASAPI loopback audio capture (default render endpoint -> 48 kHz stereo f32 for the Opus path).
wasapi = "0.23" wasapi = "0.23"
# Virtual Xbox 360 gamepad: the in-tree XUSB companion UMDF driver (packaging/windows/xusb-driver), # 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 # 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 # `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 # cudarc to `dynamic-loading` (loads nvcuda.dll at runtime — nothing needed at build), which is how
@@ -192,7 +210,7 @@ ffmpeg-next = { version = "8", optional = true }
# (vdisplay/pf_vdisplay.rs): the control-plane IOCTL codes + `#[repr(C)] Pod` request/reply structs, # (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 # defined ONCE so host<->driver ABI drift is a compile error. `bytemuck` serializes those structs
# to/from the DeviceIoControl byte buffers. # 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"] } bytemuck = { version = "1.19", features = ["derive"] }
[features] [features]
+2
View File
@@ -88,6 +88,8 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
mod linux; mod linux;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "audio/windows/wasapi_cap.rs"]
mod wasapi_cap; mod wasapi_cap;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "audio/windows/wasapi_mic.rs"]
mod wasapi_mic; mod wasapi_mic;
@@ -13,6 +13,9 @@
//! when the client isn't talking. WASAPI objects are `!Send`, so they live entirely on that thread //! when the client isn't talking. WASAPI objects are `!Send`, so they live entirely on that thread
//! (mirrors `WasapiLoopbackCapturer`). //! (mirrors `WasapiLoopbackCapturer`).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{VirtualMic, SAMPLE_RATE}; use super::{VirtualMic, SAMPLE_RATE};
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use std::collections::VecDeque; use std::collections::VecDeque;
@@ -154,6 +157,13 @@ fn find_or_install_device() -> Result<wasapi::Device> {
Ok(d) => Ok(d), Ok(d) => Ok(d),
Err(e) => { Err(e) => {
tracing::info!("no virtual mic device present — attempting auto-install"); tracing::info!("no virtual mic device present — attempting auto-install");
// SAFETY: `try_install_virtual_mic` is `unsafe` only because it `LoadLibraryExW`s
// `newdev.dll` and calls `DiInstallDriverW` through a `transmute`d function pointer;
// calling it imposes no extra precondition here (it takes no args and aliases nothing).
// Its internal contract holds: the `DiInstall` type matches the documented
// `BOOL DiInstallDriverW(HWND, PCWSTR, DWORD, PBOOL)` ABI, and it passes a
// NUL-terminated UTF-16 INF path with null/zero optional args. Invoked once on the
// dedicated mic thread.
if unsafe { try_install_virtual_mic() } { if unsafe { try_install_virtual_mic() } {
find_device() find_device()
} else { } else {
+94 -17
View File
@@ -2,6 +2,10 @@
//! CPU-copy fallback (the portal delivers a CPU buffer; the encoder uploads it to the GPU //! CPU-copy fallback (the portal delivers a CPU buffer; the encoder uploads it to the GPU
//! internally). Zero-copy dmabuf→NVENC import is deferred (plan §9 risk). //! internally). Zero-copy dmabuf→NVENC import is deferred (plan §9 risk).
// Every unsafe block in this module tree carries a `// SAFETY:` proof; enforce it (unsafe-proof
// program). As a parent module this also covers the child modules (capture::windows/linux::*).
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::Result; use anyhow::Result;
/// Packed pixel layout of a [`CapturedFrame`]. The ScreenCast portal negotiates the /// Packed pixel layout of a [`CapturedFrame`]. The ScreenCast portal negotiates the
@@ -44,6 +48,49 @@ impl PixelFormat {
} }
} }
/// What a Windows capturer should produce, resolved **once** per session and passed **into**
/// [`capture_virtual_output`] (Goal-1 stage 5, plan §2.3/§5). Passing the format in is what lets a
/// capturer stop re-deriving the encode backend itself — it kills the
/// `capture/dxgi.rs → encode::windows_resolved_backend()` back-reference (the highest-severity coupling:
/// capture and encode could otherwise disagree on whether frames are GPU-resident). Neutral type; the
/// Linux portal capturer ignores it (it negotiates its own format with PipeWire).
#[derive(Clone, Copy, Debug)]
pub struct OutputFormat {
/// Produce GPU-resident D3D11 frames (zero-copy for a GPU encoder — NVENC/AMF/QSV) rather than CPU
/// staging. `false` **only** for the GPU-less software encoder.
pub gpu: bool,
/// HDR: the capturer converts to 10-bit (IDD-push FP16 → `Rgb10a2`; the DDA secure-desktop HDR hint).
/// `false` = 8-bit SDR.
pub hdr: bool,
}
impl OutputFormat {
/// Resolve the output format for an entry point that doesn't build a full [`SessionPlan`]
/// (`crate::session_plan`) — the GameStream + spike paths: `gpu` from the resolved encode backend,
/// `hdr` as given. The native punktfunk/1 path uses `SessionPlan::output_format()` instead (it already
/// resolved the encoder), so neither path makes a capturer re-derive it.
pub fn resolve(hdr: bool) -> Self {
OutputFormat {
gpu: gpu_encode(),
hdr,
}
}
}
/// True if the resolved encode backend produces GPU frames (anything but the software encoder). The single
/// source for [`OutputFormat::resolve`]'s `gpu`; on Linux always true (the portal/VAAPI/CUDA path is GPU).
#[cfg(target_os = "windows")]
pub(crate) fn gpu_encode() -> bool {
!matches!(
crate::encode::windows_resolved_backend(),
crate::encode::WindowsBackend::Software
)
}
#[cfg(not(target_os = "windows"))]
pub(crate) fn gpu_encode() -> bool {
true
}
/// A captured frame. [`format`](Self::format)/dimensions describe the pixels regardless of /// 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) /// 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). /// or a GPU buffer already on the device (the zero-copy path, plan §9).
@@ -314,9 +361,12 @@ pub fn open_portal_monitor() -> Result<Box<dyn Capturer>> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub fn capture_virtual_output( pub fn capture_virtual_output(
vout: crate::vdisplay::VirtualOutput, vout: crate::vdisplay::VirtualOutput,
_want_hdr: bool, _want: OutputFormat,
_capture: crate::session_plan::CaptureBackend,
) -> Result<Box<dyn Capturer>> { ) -> 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>) linux::PortalCapturer::from_virtual_output(vout).map(|c| Box::new(c) as Box<dyn Capturer>)
} }
@@ -327,14 +377,16 @@ pub fn capture_virtual_output(
/// compiled and comes back the moment the flag is unset. /// compiled and comes back the moment the flag is unset.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub(crate) fn wgc_disabled() -> bool { pub(crate) fn wgc_disabled() -> bool {
std::env::var_os("PUNKTFUNK_NO_WGC").is_some() crate::config::config().no_wgc
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub fn capture_virtual_output( pub fn capture_virtual_output(
vout: crate::vdisplay::VirtualOutput, vout: crate::vdisplay::VirtualOutput,
want_hdr: bool, want: OutputFormat,
capture: crate::session_plan::CaptureBackend,
) -> Result<Box<dyn Capturer>> { ) -> Result<Box<dyn Capturer>> {
use crate::session_plan::CaptureBackend;
let target = vout.win_capture.clone().ok_or_else(|| { let target = vout.win_capture.clone().ok_or_else(|| {
anyhow::anyhow!( anyhow::anyhow!(
"SudoVDA target not yet an active display (needs a WDDM GPU to activate it)" "SudoVDA target not yet an active display (needs a WDDM GPU to activate it)"
@@ -343,27 +395,38 @@ pub fn capture_virtual_output(
let pref = vout.preferred_mode; let pref = vout.preferred_mode;
let keep = vout.keepalive; let keep = vout.keepalive;
// P2 direct frame push (kill DDA): consume frames straight from the pf-vdisplay driver's shared // 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; // ring — no Desktop Duplication, no win32u reparenting hook. Resolved once in the `SessionPlan`
// `idd_push` takes the keepalive (owns the virtual display) so there's no fall-through. // (was re-derived from `config().idd_push` here); `IddPush` takes the keepalive (owns the virtual
if std::env::var_os("PUNKTFUNK_IDD_PUSH").is_some() { // 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 // 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 // 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 // the host can't revive it. The driver's recreate crash (target id resolved to 0) is fixed by
// stamping target_id onto the monitor context. The ring is always FP16 (the driver composes // stamping target_id onto the monitor context. The ring is always FP16 (the driver composes
// the IDD in FP16); `want_hdr` selects the per-frame conversion (FP16 → Rgb10a2 vs Bgra). // the IDD in FP16); `want_hdr` selects the per-frame conversion (FP16 → Rgb10a2 vs Bgra).
return idd_push::IddPushCapturer::open(target, pref, want_hdr, keep) // If IDD-push can't open OR the driver doesn't attach to the ring within a few seconds (e.g. a
// hybrid-GPU render mismatch), fall back to DDA so the session is NEVER left black (audit §5.1).
// `open()` hands the keepalive back on failure so DDA can take ownership of the virtual display.
match idd_push::IddPushCapturer::open(target.clone(), pref, want.hdr, keep) {
Ok(c) => return Ok(Box::new(c) as Box<dyn Capturer>),
Err((e, keep)) => {
tracing::warn!(
error = %format!("{e:#}"),
"IDD-push open/attach failed — falling back to DDA"
);
return dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false)
.map(|c| Box::new(c) as Box<dyn Capturer>); .map(|c| Box::new(c) as Box<dyn Capturer>);
} }
}
}
// WGC (Windows.Graphics.Capture) is the default: it captures the COMPOSED desktop including the // WGC (Windows.Graphics.Capture) is the default: it captures the COMPOSED desktop including the
// overlay/independent-flip planes DXGI Desktop Duplication misses (the frozen-HDR-animation bug), // 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 // 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 // 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. // chosen backend (it owns the SudoVDA keepalive), so there's no open-time auto-fallback. The
let backend = std::env::var("PUNKTFUNK_CAPTURE") // backend choice (`dda`/`dxgi`/`PUNKTFUNK_NO_WGC` → DDA, else WGC) is now resolved once in the plan.
.unwrap_or_default() if capture == CaptureBackend::Dda {
.to_ascii_lowercase(); return dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false)
if backend == "dda" || backend == "dxgi" || wgc_disabled() {
return dxgi::DuplCapturer::open(target, pref, keep, false)
.map(|c| Box::new(c) as Box<dyn Capturer>); .map(|c| Box::new(c) as Box<dyn Capturer>);
} }
// WGC default, with a watchdog'd DDA fallback. WGC's Direct3D11CaptureFramePool::CreateFreeThreaded // WGC default, with a watchdog'd DDA fallback. WGC's Direct3D11CaptureFramePool::CreateFreeThreaded
@@ -374,6 +437,11 @@ pub fn capture_virtual_output(
// DDA is the safety net (+ the secure-desktop path). The encode thread is set MTA so the WGC // DDA is the safety net (+ the secure-desktop path). The encode thread is set MTA so the WGC
// objects built on the watchdog thread (also MTA) are usable here; the keepalive is handed to WGC // objects built on the watchdog thread (also MTA) are usable here; the keepalive is handed to WGC
// only on success, else to DDA. A hung watchdog thread is abandoned (holds no keepalive). // only on success, else to DDA. A hung watchdog thread is abandoned (holds no keepalive).
// SAFETY: `RoInitialize` is a combase FFI call that initializes the WinRT apartment for the calling
// thread. It takes the `RO_INIT_MULTITHREADED` enum by value and borrows no memory, so there is no
// pointer/lifetime/aliasing obligation; it is safe on any thread and idempotent — a second call on a
// thread already in a compatible apartment returns S_FALSE / RPC_E_CHANGED_MODE, which we discard.
// Runs on the encode thread that goes on to use the WGC (WinRT) objects built by the watchdog thread.
unsafe { unsafe {
let _ = windows::Win32::System::WinRT::RoInitialize( let _ = windows::Win32::System::WinRT::RoInitialize(
windows::Win32::System::WinRT::RO_INIT_MULTITHREADED, windows::Win32::System::WinRT::RO_INIT_MULTITHREADED,
@@ -393,12 +461,12 @@ pub fn capture_virtual_output(
} }
Ok(Err(e)) => { Ok(Err(e)) => {
tracing::warn!(error = %format!("{e:#}"), "WGC open failed — falling back to DDA"); 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>) .map(|c| Box::new(c) as Box<dyn Capturer>)
} }
Err(_) => { Err(_) => {
tracing::warn!("WGC open timed out (CreateFreeThreaded hang on the virtual display) — falling back to DDA"); 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>) .map(|c| Box::new(c) as Box<dyn Capturer>)
} }
} }
@@ -407,22 +475,31 @@ pub fn capture_virtual_output(
#[cfg(not(any(target_os = "linux", target_os = "windows")))] #[cfg(not(any(target_os = "linux", target_os = "windows")))]
pub fn capture_virtual_output( pub fn capture_virtual_output(
_vout: crate::vdisplay::VirtualOutput, _vout: crate::vdisplay::VirtualOutput,
_want_hdr: bool, _want: OutputFormat,
_capture: crate::session_plan::CaptureBackend,
) -> Result<Box<dyn Capturer>> { ) -> Result<Box<dyn Capturer>> {
anyhow::bail!("virtual-output capture requires Linux or Windows") 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")] #[cfg(target_os = "windows")]
#[path = "capture/windows/composed_flip.rs"]
pub mod composed_flip; pub mod composed_flip;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "capture/windows/desktop_watch.rs"]
pub mod desktop_watch; pub mod desktop_watch;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "capture/windows/dxgi.rs"]
pub mod dxgi; pub mod dxgi;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "capture/windows/idd_push.rs"]
pub mod idd_push; pub mod idd_push;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
mod linux; mod linux;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "capture/windows/wgc.rs"]
pub mod wgc; pub mod wgc;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "capture/windows/wgc_relay.rs"]
pub mod wgc_relay; pub mod wgc_relay;
@@ -17,6 +17,9 @@
//! instead of leaking it to process exit. The portal thread (when used) still parks on its zbus //! instead of leaking it to process exit. The portal thread (when used) still parks on its zbus
//! connection until process exit. //! connection until process exit.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat}; use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat};
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use std::os::fd::OwnedFd; use std::os::fd::OwnedFd;
@@ -498,6 +501,12 @@ mod pipewire {
impl DmabufMap { impl DmabufMap {
fn new(fd: i32, len: usize) -> Option<DmabufMap> { fn new(fd: i32, len: usize) -> Option<DmabufMap> {
// SAFETY: a null `addr` lets the kernel choose the mapping address; `fd` is a caller-owned
// dmabuf/MemFd fd, valid for the duration of this call, and `len` is the requested map length.
// `mmap` reads no Rust memory — it installs a fresh PROT_READ/MAP_SHARED page mapping and
// returns its base (or MAP_FAILED, checked below before `DmabufMap` adopts it). The returned
// region is a brand-new VMA, so it aliases no live Rust object, and it keeps the underlying
// object mapped independently of `fd` (which may be closed after this returns).
let ptr = unsafe { let ptr = unsafe {
libc::mmap( libc::mmap(
std::ptr::null_mut(), std::ptr::null_mut(),
@@ -514,6 +523,11 @@ mod pipewire {
impl Drop for DmabufMap { impl Drop for DmabufMap {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `self.ptr`/`self.len` are exactly the base+length of a successful `mmap` in
// `DmabufMap::new` (constructed only when `ptr != MAP_FAILED`). This `DmabufMap` uniquely owns
// that mapping and `drop` runs once, so `munmap` releases a live mapping exactly once — no
// double-unmap. Every `&[u8]` derived from the mapping is bounded by this `DmabufMap`'s
// lifetime, so no borrow outlives the unmap.
unsafe { unsafe {
libc::munmap(self.ptr, self.len); libc::munmap(self.ptr, self.len);
} }
@@ -719,6 +733,14 @@ mod pipewire {
if !ud.active.load(Ordering::Relaxed) { if !ud.active.load(Ordering::Relaxed) {
return; return;
} }
// SAFETY: `spa_buf` is the `*mut spa_buffer` of the PipeWire buffer we dequeued and still hold for
// this `.process` callback (not requeued until after `consume_frame` returns), so it is live. The
// block null-checks `spa_buf`, requires `n_datas != 0`, and null-checks the `datas` array pointer
// before forming any slice. `(*spa_buf).datas` points to `n_datas` libspa `spa_data` structs, and
// `pw::spa::buffer::Data` is `#[repr(transparent)]` over `spa_data` (the same cast
// `Buffer::datas_mut` performs — see the function doc), so the pointer cast + length describe
// exactly that array, in bounds. The PipeWire loop is single-threaded and owns the buffer here, so
// this `&mut` slice is the only reference to it (no aliasing/data race).
let datas: &mut [pw::spa::buffer::Data] = unsafe { let datas: &mut [pw::spa::buffer::Data] = unsafe {
if spa_buf.is_null() || (*spa_buf).n_datas == 0 || (*spa_buf).datas.is_null() { if spa_buf.is_null() || (*spa_buf).n_datas == 0 || (*spa_buf).datas.is_null() {
&mut [] &mut []
@@ -783,6 +805,10 @@ mod pipewire {
// dup the fd so it survives the SPA buffer recycle — the encode thread // dup the fd so it survives the SPA buffer recycle — the encode thread
// imports it. (Content stability across the brief map+CSC window relies on // imports it. (Content stability across the brief map+CSC window relies on
// the compositor's buffer-pool depth, like any zero-copy capture.) // the compositor's buffer-pool depth, like any zero-copy capture.)
// SAFETY: `datas[0].fd()` is the dmabuf fd owned by the live PipeWire buffer (valid
// for this callback). `fcntl(fd, F_DUPFD_CLOEXEC, 0)` reads only the integer fd,
// touches no Rust memory, and returns a fresh independent CLOEXEC duplicate (or -1).
// The original stays owned by PipeWire; the dup is a new fd we own (checked >= 0).
let dup = let dup =
unsafe { libc::fcntl(datas[0].fd() as i32, libc::F_DUPFD_CLOEXEC, 0) }; unsafe { libc::fcntl(datas[0].fd() as i32, libc::F_DUPFD_CLOEXEC, 0) };
if dup >= 0 { if dup >= 0 {
@@ -796,6 +822,10 @@ mod pipewire {
pts_ns, pts_ns,
format: fmt, format: fmt,
payload: FramePayload::Dmabuf(DmabufFrame { payload: FramePayload::Dmabuf(DmabufFrame {
// SAFETY: `dup` is the fresh fd `fcntl(F_DUPFD_CLOEXEC)` just returned
// (checked `dup >= 0`); nothing else owns it, so `OwnedFd` takes sole
// ownership and closes it exactly once on drop — no alias, no
// double-close.
fd: unsafe { OwnedFd::from_raw_fd(dup) }, fd: unsafe { OwnedFd::from_raw_fd(dup) },
fourcc, fourcc,
modifier: ud.modifier, modifier: ud.modifier,
@@ -930,6 +960,11 @@ mod pipewire {
// cleanly if the real buffer is genuinely too small. MemPtr buffers (no fd) are same-process — // cleanly if the real buffer is genuinely too small. MemPtr buffers (no fd) are same-process —
// trust `d.data()`. // trust `d.data()`.
let fd_len = if raw_fd > 0 { let fd_len = if raw_fd > 0 {
// SAFETY: `libc::stat` is a C plain-old-data struct for which all-zero is a valid value, so
// `mem::zeroed()` is a sound initializer. `raw_fd` is the buffer's fd (`> 0` checked here) and
// valid for this callback; `fstat` writes metadata into `&mut st`, a live, aligned,
// correctly-sized stack `stat` that outlives the synchronous call. `st.st_size` is read only
// after the return value is confirmed `== 0`. `st` is a fresh local, so nothing aliases it.
unsafe { unsafe {
let mut st: libc::stat = std::mem::zeroed(); let mut st: libc::stat = std::mem::zeroed();
(libc::fstat(raw_fd as i32, &mut st) == 0 && st.st_size > 0) (libc::fstat(raw_fd as i32, &mut st) == 0 && st.st_size > 0)
@@ -946,6 +981,14 @@ mod pipewire {
match DmabufMap::new(raw_fd as i32, map_len) { match DmabufMap::new(raw_fd as i32, map_len) {
Some(m) => { Some(m) => {
_mapping = m; _mapping = m;
// SAFETY: `_mapping` is the `DmabufMap` just stored; its `ptr`/`len` come from a
// successful `mmap` of `map_len` PROT_READ bytes, so `ptr` is non-null, page-aligned,
// and the VMA is one allocated object of `len` bytes valid for reads. In the common
// path `map_len == fd_len` (the fd's real size from `fstat`), so the mapping spans the
// whole object; the de-pad copy below is further bounded by the `offset <= buf.len()`
// and `needed > avail` guards. The `&[u8]` borrows `_mapping`, which lives to the end
// of `consume_frame`, so the slice never outlives the mapping, and the memory is only
// read here, so there is no aliasing/mutation.
Some(unsafe { Some(unsafe {
std::slice::from_raw_parts(_mapping.ptr as *const u8, _mapping.len) std::slice::from_raw_parts(_mapping.ptr as *const u8, _mapping.len)
}) })
@@ -1177,24 +1220,43 @@ mod pipewire {
// Latest-frame-only (OBS pattern): Mutter delivers buffers in bursts and // Latest-frame-only (OBS pattern): Mutter delivers buffers in bursts and
// recycles its pool; an older queued buffer carries a STALE frame. Drain all // recycles its pool; an older queued buffer carries a STALE frame. Drain all
// queued buffers, requeue the older ones, keep only the newest. // queued buffers, requeue the older ones, keep only the newest.
// SAFETY: `stream` is the live stream PipeWire passes into this `.process` callback on
// the loop thread, where `pw_stream_dequeue_buffer` is the documented call. It returns
// a `*mut pw_buffer` owned by the stream (or null when the queue is drained),
// null-checked before any use. The loop is single-threaded, so no concurrent access.
let mut newest = unsafe { stream.dequeue_raw_buffer() }; let mut newest = unsafe { stream.dequeue_raw_buffer() };
if newest.is_null() { if newest.is_null() {
return; return;
} }
let mut drained = 1u32; let mut drained = 1u32;
loop { loop {
// SAFETY: same stream/loop-thread contract as the dequeue above; each call returns
// the next stream-owned `*mut pw_buffer` or null (null-checked before use).
let next = unsafe { stream.dequeue_raw_buffer() }; let next = unsafe { stream.dequeue_raw_buffer() };
if next.is_null() { if next.is_null() {
break; break;
} }
// SAFETY: `newest` is a non-null `*mut pw_buffer` previously dequeued from this same
// stream and not yet requeued; `pw_stream_queue_buffer` hands ownership back to the
// stream. We immediately overwrite `newest = next`, so the requeued pointer is never
// touched again (no use-after-requeue). Loop thread, single-threaded.
unsafe { stream.queue_raw_buffer(newest) }; unsafe { stream.queue_raw_buffer(newest) };
newest = next; newest = next;
drained += 1; drained += 1;
} }
// SAFETY: `newest` is the non-null buffer we still own (dequeued, not requeued);
// `.buffer` is a `*mut spa_buffer` field libpipewire populated. This is a single field
// load through a valid pointer — no mutation or aliasing.
let spa_buf = unsafe { (*newest).buffer }; let spa_buf = unsafe { (*newest).buffer };
// Inspect the newest buffer's header + first chunk for the diagnostic and the // Inspect the newest buffer's header + first chunk for the diagnostic and the
// CORRUPTED skip. SPA_META_Header is optional — `hdr` may be null. // CORRUPTED skip. SPA_META_Header is optional — `hdr` may be null.
// SAFETY: `spa_buf` is the `*mut spa_buffer` of the buffer we still hold.
// `spa_buffer_find_meta_data` scans that buffer's metadata array for a `SPA_META_Header`
// of at least `size_of::<spa_meta_header>()` bytes and returns a pointer into the held
// buffer's metadata (or null). The size argument matches the struct the result is cast
// to, and the pointer stays valid as long as the buffer is held (until requeue). Null is
// handled below.
let hdr = unsafe { let hdr = unsafe {
spa::sys::spa_buffer_find_meta_data( spa::sys::spa_buffer_find_meta_data(
spa_buf, spa_buf,
@@ -1205,11 +1267,20 @@ mod pipewire {
let hdr_flags = if hdr.is_null() { let hdr_flags = if hdr.is_null() {
0u32 0u32
} else { } else {
// SAFETY: reached only when `hdr` is non-null; it points to a `spa_meta_header`
// inside the live buffer's metadata (returned for a size >=
// `size_of::<spa_meta_header>()`, so `.flags` is in bounds). A single field read
// while the buffer is still held.
unsafe { (*hdr).flags } unsafe { (*hdr).flags }
}; };
// First data chunk's size + flags (used for the diagnostic + CORRUPTED check) // First data chunk's size + flags (used for the diagnostic + CORRUPTED check)
// and its data type (a dmabuf legitimately reports chunk size 0, so the size-0 // and its data type (a dmabuf legitimately reports chunk size 0, so the size-0
// stale skip only applies to mappable SHM buffers). // stale skip only applies to mappable SHM buffers).
// SAFETY: every dereference is guarded in order before any field read — `spa_buf`
// non-null, `n_datas > 0`, the `datas` (`*mut spa_data`) array non-null, and the first
// element's `chunk` (`*mut spa_chunk`) non-null. `d0` is that first `spa_data` and `c`
// its chunk; reading `(*d0).type_`, `(*c).size`, `(*c).flags` are in-bounds field loads
// of libspa structs inside the buffer we still hold. Single-threaded loop, no mutation.
let (chunk_size, chunk_flags, is_dmabuf) = unsafe { let (chunk_size, chunk_flags, is_dmabuf) = unsafe {
if !spa_buf.is_null() if !spa_buf.is_null()
&& (*spa_buf).n_datas > 0 && (*spa_buf).n_datas > 0
@@ -1246,11 +1317,17 @@ mod pipewire {
"capture: skipped a stale CORRUPTED/cursor buffer (GNOME)" "capture: skipped a stale CORRUPTED/cursor buffer (GNOME)"
); );
} }
// SAFETY: `newest` is the non-null buffer we own (dequeued, never requeued on this
// skip path); hand it back to the stream exactly once and return without touching it
// again. Loop thread inside `.process`.
unsafe { stream.queue_raw_buffer(newest) }; unsafe { stream.queue_raw_buffer(newest) };
return; return;
} }
consume_frame(ud, spa_buf); consume_frame(ud, spa_buf);
// SAFETY: `consume_frame` has finished reading `spa_buf` (and the `datas` borrows derived
// from `newest`), so requeuing the owned `newest` exactly once here is sound — no
// use-after-requeue. Loop thread inside `.process`.
unsafe { stream.queue_raw_buffer(newest) }; unsafe { stream.queue_raw_buffer(newest) };
})); }));
if outcome.is_err() { if outcome.is_err() {
@@ -15,6 +15,9 @@
//! composed while a session is live). Effectiveness can be build/driver-dependent; gated by //! composed while a session is live). Effectiveness can be build/driver-dependent; gated by
//! `PUNKTFUNK_FORCE_COMPOSED` (default ON; set =0 to disable). //! `PUNKTFUNK_FORCE_COMPOSED` (default ON; set =0 to disable).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use windows::core::w; use windows::core::w;
@@ -48,6 +51,10 @@ impl ForceComposedFlip {
let st = stop.clone(); let st = stop.clone();
std::thread::Builder::new() std::thread::Builder::new()
.name("composed-flip".into()) .name("composed-flip".into())
// SAFETY: `run` is this module's `unsafe fn` (it owns a desktop+window lifecycle via Win32
// FFI); it takes ownership of `st` (the stop `Arc<AtomicBool>`) and has no caller-side memory
// precondition. It is designed to own its thread for its whole duration — exactly the
// dedicated `composed-flip` thread spawned here.
.spawn(move || unsafe { run(st) }) .spawn(move || unsafe { run(st) })
.ok()?; .ok()?;
tracing::info!("force-composed-flip overlay started (Winlogon-aware)"); tracing::info!("force-composed-flip overlay started (Winlogon-aware)");
@@ -62,6 +69,9 @@ impl Drop for ForceComposedFlip {
} }
extern "system" fn wndproc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> LRESULT { extern "system" fn wndproc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> LRESULT {
// SAFETY: this is the window procedure the OS invokes with the window's own `hwnd` and a real
// message `(msg, wp, lp)`. `DefWindowProcW` performs default processing for exactly those
// parameters (all passed straight through by value); it borrows no Rust memory and is synchronous.
unsafe { DefWindowProcW(hwnd, msg, wp, lp) } unsafe { DefWindowProcW(hwnd, msg, wp, lp) }
} }
@@ -7,6 +7,9 @@
//! desktop's NAME (WTS session notifications miss UAC entirely, so the name is the reliable signal) //! desktop's NAME (WTS session notifications miss UAC entirely, so the name is the reliable signal)
//! and publishes it as an atomic the capture mux + input path read. //! and publishes it as an atomic the capture mux + input path read.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -33,6 +36,10 @@ impl DesktopWatcher {
// mux) sees the real state immediately. Otherwise a session that begins already on the secure // mux) sees the real state immediately. Otherwise a session that begins already on the secure
// desktop (e.g. a reconnect to a locked box) would read DESKTOP_NORMAL for the first poll // desktop (e.g. a reconnect to a locked box) would read DESKTOP_NORMAL for the first poll
// interval and relay one stale normal-desktop frame — the "flash of the login screen" bug. // interval and relay one stale normal-desktop frame — the "flash of the login screen" bug.
// SAFETY: `is_secure_desktop` is this module's `unsafe fn` — unsafe only because it calls Win32
// desktop FFI (`OpenInputDesktop`/`GetUserObjectInformationW`/`CloseDesktop`), with no caller
// precondition; it opens, names, and closes the input-desktop handle internally and is safe to
// call from any thread (here, on the thread running `DesktopWatcher::start`).
let initial = if unsafe { is_secure_desktop() } { let initial = if unsafe { is_secure_desktop() } {
DESKTOP_SECURE DESKTOP_SECURE
} else { } else {
@@ -53,6 +60,9 @@ impl DesktopWatcher {
let mut candidate = initial; let mut candidate = initial;
let mut stable = 0u32; let mut stable = 0u32;
while !st.load(Ordering::Relaxed) { while !st.load(Ordering::Relaxed) {
// SAFETY: same as in `start` — `is_secure_desktop` is self-contained Win32 desktop
// FFI with no caller precondition, called here on the dedicated `desktop-watch`
// polling thread.
let v = if unsafe { is_secure_desktop() } { let v = if unsafe { is_secure_desktop() } {
DESKTOP_SECURE DESKTOP_SECURE
} else { } else {
@@ -7,6 +7,9 @@
//! Validates only with a real GPU + an *activated* SudoVDA monitor (`DuplicateOutput` needs a live //! Validates only with a real GPU + an *activated* SudoVDA monitor (`DuplicateOutput` needs a live
//! WDDM output). Compiles on the GPU-less VM; the pure helpers are unit-tested there. //! WDDM output). Compiles on the GPU-less VM; the pure helpers are unit-tested there.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{CapturedFrame, Capturer, FramePayload, PixelFormat}; use super::{CapturedFrame, Capturer, FramePayload, PixelFormat};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use std::ffi::c_void; use std::ffi::c_void;
@@ -69,7 +72,12 @@ pub struct D3d11Frame {
pub texture: ID3D11Texture2D, pub texture: ID3D11Texture2D,
pub device: ID3D11Device, pub device: ID3D11Device,
} }
// COM pointers, used only from the single owning thread. // SAFETY: `D3d11Frame` owns an `ID3D11Texture2D` + `ID3D11Device`, which are COM interface pointers.
// D3D11 devices/resources use thread-safe (interlocked) COM reference counting, and the device is
// created free-threaded (`make_device` passes no `D3D11_CREATE_DEVICE_SINGLETHREADED`), so handing
// ownership of the frame to another thread — the capture→encode handoff — and releasing it there is
// sound. The value is moved, never aliased (no `Sync`), so there is no concurrent use of the
// single-threaded immediate context.
unsafe impl Send for D3d11Frame {} unsafe impl Send for D3d11Frame {}
pub fn pack_luid(luid: LUID) -> i64 { pub fn pack_luid(luid: LUID) -> i64 {
@@ -295,6 +303,12 @@ unsafe fn d3dkmt_set_scheduling_priority_class(
fn elevate_process_gpu_priority() { fn elevate_process_gpu_priority() {
use std::sync::Once; use std::sync::Once;
static ONCE: Once = Once::new(); static ONCE: Once = Once::new();
// SAFETY: the closure calls two of this module's `unsafe fn`s — `enable_inc_base_priority`
// (adjusts the current-process token; it has no caller precondition and builds all its FFI args
// locally) and `d3dkmt_set_scheduling_priority_class` (loads gdi32 by name and calls the export).
// The latter requires `process` to be a valid process handle; `GetCurrentProcess()` returns the
// current-process pseudo-handle, which is always valid and needs no close. Runs once via
// `Once::call_once`; no raw pointers are dereferenced here.
ONCE.call_once(|| unsafe { ONCE.call_once(|| unsafe {
use windows::Win32::System::Threading::GetCurrentProcess; use windows::Win32::System::Threading::GetCurrentProcess;
let Some(prio) = configured_gpu_priority_class() else { let Some(prio) = configured_gpu_priority_class() else {
@@ -538,6 +552,17 @@ unsafe extern "system" fn hybrid_query_hook(gpu_preference: *mut u32) -> i32 {
pub(crate) fn install_gpu_pref_hook() { pub(crate) fn install_gpu_pref_hook() {
use std::sync::Once; use std::sync::Once;
static HOOK: Once = Once::new(); static HOOK: Once = Once::new();
// SAFETY: this one-time hook install only touches a region it has just validated.
// `LoadLibraryA("win32u.dll")` + `GetProcAddress("NtGdiDdDDIGetCachedHybridQueryValue")` yield the
// live base of the real exported function, so `target` is a valid executable code pointer to at
// least the 12 bytes the patch overwrites (an x64 prologue, per Apollo's verified hook). The two
// `ptr::copy_nonoverlapping`s each move exactly 12 bytes between the 12-byte stack arrays
// (`patch`/`readback`) and `target`, which `VirtualProtect(target, 12, PAGE_EXECUTE_READWRITE, …)`
// has just made writable (and is restored to `old` after) — source and dest never overlap (stack
// vs. loaded module image), so every access stays in mapped, in-bounds memory.
// `FlushInstructionCache` gets the current-process pseudo-handle + that same range. The DPI calls
// take by-value context handles / fill the live local `&mut old`/`&mut restore` for the duration of
// each synchronous call. Runs once via `Once::call_once`, before any DXGI use.
HOOK.call_once(|| unsafe { HOOK.call_once(|| unsafe {
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA}; use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA};
use windows::Win32::System::Memory::{ use windows::Win32::System::Memory::{
@@ -1389,6 +1414,14 @@ pub fn hdr_p010_selftest() -> Result<()> {
} }
} }
// SAFETY: this self-test creates its own D3D11 device + immediate context (`D3D11CreateDevice`,
// both checked non-null) and uses ONLY that device for the rest of the block: every
// `CreateTexture2D`/`CreateShaderResourceView`/`HdrP010Converter::{new,convert}`/`CopyResource`/
// `Map` is invoked on that device or its context, so all resources share one device and run on this
// single thread. The source texture's `D3D11_SUBRESOURCE_DATA` points at `fp16`, a live
// `Vec<u16>` of `W*H*4` samples with `SysMemPitch = W*8`, matching the W×H R16G16B16A16 texture;
// `fp16` outlives the synchronous `CreateTexture2D` that reads it. The mapped-pointer reads are
// proven individually at the `read_u16` closure below.
unsafe { unsafe {
// Hardware D3D11 device (no adapter pin — the default GPU is fine for the self-test). // Hardware D3D11 device (no adapter pin — the default GPU is fine for the self-test).
let mut device: Option<ID3D11Device> = None; let mut device: Option<ID3D11Device> = None;
@@ -2038,7 +2071,11 @@ pub struct DuplCapturer {
dbg_cursor: u64, dbg_cursor: u64,
_keepalive: Box<dyn Send>, _keepalive: Box<dyn Send>,
} }
// COM objects used only from the one thread that owns the capturer (the encode thread). // SAFETY: `DuplCapturer` holds D3D11 device/context/duplication COM pointers plus plain data. The
// device is created free-threaded (`make_device` sets no `D3D11_CREATE_DEVICE_SINGLETHREADED`) and
// COM reference counting is interlocked, so moving ownership of the whole capturer to another thread
// is sound. It is used by exactly one thread (the encode thread) at a time — moved to it once, never
// shared (no `Sync`) — so the single-threaded immediate context is never touched concurrently.
unsafe impl Send for DuplCapturer {} unsafe impl Send for DuplCapturer {}
impl DuplCapturer { impl DuplCapturer {
@@ -2046,8 +2083,18 @@ impl DuplCapturer {
target: WinCaptureTarget, target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>, preferred: Option<(u32, u32, u32)>,
keepalive: Box<dyn Send>, 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, want_hdr: bool,
) -> Result<Self> { ) -> Result<Self> {
// SAFETY: runs on the capture thread that will own this `DuplCapturer`. `install_gpu_pref_hook()`
// and the DPI-context calls take by-value handles / no args and touch only thread/process state;
// `SetThreadExecutionState` takes a flags bitmask by value. `CreateDXGIFactory1` yields a live
// `IDXGIFactory1`, and every subsequent COM method (`EnumAdapters1`/`EnumOutputs`/`GetDesc1`/
// `GetDesc`/`cast`) is called on that factory or on an adapter/output it returned — each obtained
// through a checked `while let Ok(..)`/`?` — all from this one thread. No raw pointers are
// dereferenced; the borrowed strings/locals outlive each synchronous call.
unsafe { unsafe {
// Stop DXGI hybrid-GPU output reparenting BEFORE we create the factory / enumerate outputs // Stop DXGI hybrid-GPU output reparenting BEFORE we create the factory / enumerate outputs
// (the cause of the 0x887A0026 ACCESS_LOST churn on this hybrid box: RTX 4090 + AMD iGPU). // (the cause of the 0x887A0026 ACCESS_LOST churn on this hybrid box: RTX 4090 + AMD iGPU).
@@ -2183,9 +2230,9 @@ impl DuplCapturer {
let context = context.context("null D3D11 context")?; let context = context.context("null D3D11 context")?;
// 3) duplicate the output. Attach to the current input desktop first (as SYSTEM this can // 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. // 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 // The virtual display is kept the sole desktop via the CCD isolation the pf-vdisplay backend
// (registry-persisted), so the secure desktop has nowhere to render but the output we // applies at monitor creation (registry-persisted), so the secure desktop has nowhere to render
// capture — no per-open re-isolation needed. // but the output we capture — no per-open re-isolation needed.
attach_input_desktop(); attach_input_desktop();
let dupl = duplicate_output(&output, &device, want_hdr) let dupl = duplicate_output(&output, &device, want_hdr)
.context("DuplicateOutput (already duplicated by another app?)")?; .context("DuplicateOutput (already duplicated by another app?)")?;
@@ -2213,14 +2260,13 @@ impl DuplCapturer {
.ok() .ok()
.and_then(|s| s.parse().ok()) .and_then(|s| s.parse().ok())
.unwrap_or((2000 / refresh_hz.max(1)).max(100)); .unwrap_or((2000 / refresh_hz.max(1)).max(100));
// Produce GPU-resident D3D11 frames (zero-copy NVENC, or the NV12/P010 the AMF/QSV // Produce GPU-resident D3D11 frames (zero-copy NVENC, or the NV12/P010 the AMF/QSV backends
// backends read back / import) whenever the resolved encode backend is a GPU one — so the // read back / import) whenever the encode backend is a GPU one — so the capturer's output
// capturer's output format matches the encoder's input. Only the software (GPU-less) path // format matches the encoder's input. Only the software (GPU-less) path takes CPU staging.
// takes CPU staging. Mirrors `encode::open_video`'s dispatch exactly. // The decision is resolved ONCE per session and passed in (Goal-1 stage 5), instead of this
let gpu_mode = !matches!( // capturer re-calling `encode::windows_resolved_backend()` — the back-reference that let
crate::encode::windows_resolved_backend(), // capture and encode disagree (plan §2.3/§5).
crate::encode::WindowsBackend::Software let gpu_mode = gpu;
);
// Read the source display's HDR mastering metadata while we still hold `output` (it is // 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. // moved into the struct below). Only meaningful for an HDR (FP16) duplication.
let is_hdr_init = dd.ModeDesc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT; let is_hdr_init = dd.ModeDesc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT;
@@ -2712,7 +2758,7 @@ impl DuplCapturer {
} }
// The SudoVDA output's GDI name can CHANGE across a secure-desktop topology rebuild — // The SudoVDA output's GDI name can CHANGE across a secure-desktop topology rebuild —
// re-resolve from the STABLE target id so we find it under its current name. // re-resolve from the STABLE target id so we find it under its current name.
if let Some(n) = crate::vdisplay::sudovda::resolve_gdi_name(self.target_id) { if let Some(n) = crate::win_display::resolve_gdi_name(self.target_id) {
self.gdi_name = n; self.gdi_name = n;
} }
// Re-sync the capture thread to the CURRENT input desktop on EVERY rebuild — symmetric for // Re-sync the capture thread to the CURRENT input desktop on EVERY rebuild — symmetric for
@@ -3205,6 +3251,11 @@ impl Capturer for DuplCapturer {
// the duplication up to 12 s). Better a few seconds of frozen-last-frame than dropping the stream. // the duplication up to 12 s). Better a few seconds of frozen-last-frame than dropping the stream.
let mut deadline = Instant::now() + Duration::from_secs(20); let mut deadline = Instant::now() + Duration::from_secs(20);
loop { loop {
// SAFETY: `acquire` is an `unsafe fn` because it drives the D3D11 immediate context + the
// output duplication, which must be touched only from the capturer's owning thread.
// `next_frame` runs on that one thread — `DuplCapturer` is `Send` but not `Sync`, so it is
// owned by a single (encode) thread for its whole life — and `&mut self` gives exclusive
// access for the call, satisfying that contract.
if let Some(f) = unsafe { self.acquire() }? { if let Some(f) = unsafe { self.acquire() }? {
self.ever_got_frame = true; self.ever_got_frame = true;
return Ok(f); return Ok(f);
@@ -3251,6 +3302,8 @@ impl Capturer for DuplCapturer {
} }
fn try_latest(&mut self) -> Result<Option<CapturedFrame>> { fn try_latest(&mut self) -> Result<Option<CapturedFrame>> {
// SAFETY: as in `next_frame` — `acquire` must run on the capturer's single owning thread, and
// `try_latest` is called on it (`DuplCapturer` is `Send`, not `Sync`); `&mut self` is exclusive.
unsafe { self.acquire() } unsafe { self.acquire() }
} }
@@ -3262,11 +3315,19 @@ impl Capturer for DuplCapturer {
impl Drop for DuplCapturer { impl Drop for DuplCapturer {
fn drop(&mut self) { fn drop(&mut self) {
if self.holding_frame { if self.holding_frame {
// SAFETY: `self.dupl` is the live `IDXGIOutputDuplication` this capturer created and owns;
// `ReleaseFrame` is a valid COM method on it, called only when `holding_frame` records that a
// frame was acquired and not yet released (so it is not an unbalanced release). Drop runs on
// whichever thread owns the capturer — its sole owner, since it is `!Sync` — and the `&`
// borrow of the duplication outlives this synchronous call.
unsafe { unsafe {
let _ = self.dupl.as_ref().map(|d| d.ReleaseFrame()); let _ = self.dupl.as_ref().map(|d| d.ReleaseFrame());
} }
} }
// Release the display/system-required execution state we took at open(). // Release the display/system-required execution state we took at open().
// SAFETY: `SetThreadExecutionState` is a Win32 FFI call taking an execution-state flag bitmask
// by value (`ES_CONTINUOUS` clears the display/system-required state taken at open); it borrows
// no Rust memory and is safe to call from any thread.
unsafe { unsafe {
SetThreadExecutionState(ES_CONTINUOUS); SetThreadExecutionState(ES_CONTINUOUS);
} }
@@ -7,26 +7,28 @@
//! `PUNKTFUNK_IDD_PUSH`. Driver counterpart: `packaging/windows/drivers/pf-vdisplay/src/ //! `PUNKTFUNK_IDD_PUSH`. Driver counterpart: `packaging/windows/drivers/pf-vdisplay/src/
//! frame_transport.rs`. The shared `SharedHeader` layout, `MAGIC`/`VERSION`/`RING_LEN`, the //! 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 //! `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` it, so drift is a compile error rather than a "must match" comment.
use super::dxgi::{make_device, D3d11Frame, HdrConverter, WinCaptureTarget}; // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::dxgi::{make_device, D3d11Frame, HdrP010Converter, VideoConverter, WinCaptureTarget};
use super::{CapturedFrame, Capturer, FramePayload, PixelFormat}; use super::{CapturedFrame, Capturer, FramePayload, PixelFormat};
use anyhow::{bail, Context, Result}; 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::atomic::{AtomicU32, AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use windows::core::{w, Interface, HSTRING}; 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::{ use windows::Win32::Graphics::Direct3D11::{
ID3D11Device, ID3D11DeviceContext, ID3D11RenderTargetView, ID3D11ShaderResourceView, ID3D11Device, ID3D11DeviceContext, ID3D11ShaderResourceView, ID3D11Texture2D,
ID3D11Texture2D, D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX,
D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX, D3D11_RESOURCE_MISC_SHARED_NTHANDLE, D3D11_RESOURCE_MISC_SHARED_NTHANDLE, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT,
D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT,
}; };
use windows::Win32::Graphics::Dxgi::Common::{ use windows::Win32::Graphics::Dxgi::Common::{
DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_R10G10B10A2_UNORM, DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_P010,
DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_SAMPLE_DESC, DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_SAMPLE_DESC,
}; };
use windows::Win32::Graphics::Dxgi::{ use windows::Win32::Graphics::Dxgi::{
@@ -43,7 +45,7 @@ use windows::Win32::System::Memory::{
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject}; use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject};
// The frame-transport contract — `SharedHeader` layout, `MAGIC`/`VERSION`/`RING_LEN`, the // 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::*`, so a layout/name/code drift is a compile error (the proto has `const` size asserts).
use frame::{ use frame::{
event_name, header_name, texture_name, SharedHeader, DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED, event_name, header_name, texture_name, SharedHeader, DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED,
@@ -60,7 +62,7 @@ const DXGI_SHARED_RESOURCE_RW: u32 = 0x8000_0000 | 0x1;
const OUT_RING: usize = 3; const OUT_RING: usize = 3;
/// Bring-up debug block (fixed name) — the host creates it; the driver writes diagnostics into it /// 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`. /// not the data path); the matching `DebugBlock` lives in the OLD oracle driver's `frame_transport.rs`.
#[repr(C)] #[repr(C)]
struct DebugBlock { struct DebugBlock {
@@ -90,20 +92,78 @@ fn now_ns() -> u64 {
.unwrap_or(0) .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 { struct HostSlot {
tex: ID3D11Texture2D, tex: ID3D11Texture2D,
mutex: IDXGIKeyedMutex, 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); /// 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 /// 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). /// (which CopyResource's the BGRA slot straight to the output).
srv: ID3D11ShaderResourceView, 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) { fn drop(&mut self) {
// SAFETY: we hold `mutex` at `key` (acquired in `acquire`, never released elsewhere); release it.
unsafe { unsafe {
let _ = CloseHandle(self.shared); let _ = self.mutex.ReleaseSync(self.key);
} }
} }
} }
@@ -113,10 +173,17 @@ pub struct IddPushCapturer {
device: ID3D11Device, device: ID3D11Device,
context: ID3D11DeviceContext, context: ID3D11DeviceContext,
target_id: u32, 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, header: *mut SharedHeader,
event: HANDLE, event: OwnedHandle,
dbg_map: HANDLE, /// 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, dbg_block: *mut DebugBlock,
width: u32, width: u32,
height: u32, height: u32,
@@ -136,118 +203,39 @@ pub struct IddPushCapturer {
/// Throttle for the `advanced_color_enabled` poll (a CCD `QueryDisplayConfig`, ~ms — too costly per /// Throttle for the `advanced_color_enabled` poll (a CCD `QueryDisplayConfig`, ~ms — too costly per
/// frame at 240 Hz). /// frame at 240 Hz).
last_acm_poll: Instant, last_acm_poll: Instant,
/// Host-owned ROTATING output ring NVENC encodes (texture + RTV per slot). Rotating it per frame is /// Set when a display-descriptor change triggered a ring recreate (recovery, game-capture bug GB1);
/// the precondition for pipelining the encode loop: while NVENC encodes frame N's texture on the /// cleared when a fresh frame resumes. If it stays set past the recovery window, `try_consume` drops
/// ASIC, frame N+1's convert/copy writes a DIFFERENT texture on the 3D engine — the two overlap. The /// the session (recover-or-drop, no DDA).
/// HDR convert and the SDR copy both write into the current slot. Format = `out_format()` (Rgb10a2 in recovering_since: Option<Instant>,
/// HDR, Bgra in SDR); rebuilt on a display-mode flip. Built lazily. /// Host-owned ROTATING output ring NVENC encodes (one YUV texture per slot). Rotating it per frame
out_ring: Vec<(ID3D11Texture2D, ID3D11RenderTargetView)>, /// is the precondition for pipelining the encode loop: while NVENC encodes frame N's texture on the
/// ASIC, frame N+1's convert writes a DIFFERENT texture — the two overlap. Format = `out_format()`:
/// NV12 (SDR, BT.709 limited) or P010 (HDR, BT.2020 PQ limited), so NVENC takes native YUV and skips
/// its internal RGB→YUV CSC on the SM/3D engine the game saturates (plan §5.A). Rebuilt on a
/// display-mode flip. Built lazily.
out_ring: Vec<ID3D11Texture2D>,
out_idx: usize, out_idx: usize,
/// FP16 scRGB → `Rgb10a2` BT.2020 PQ converter, used while the display is HDR. Built lazily. /// BGRA slot → NV12 (BT.709 limited) on the dedicated D3D11 VIDEO engine, used while the display is
hdr_conv: Option<HdrConverter>, /// SDR — keeps the colour-convert OFF the contended 3D/compute engine. Built lazily; rebuilt on a
/// size/HDR flip.
video_conv: Option<VideoConverter>,
/// FP16 scRGB slot → P010 (BT.2020 PQ limited) via two shader passes, used while the display is HDR
/// (NVIDIA's VideoProcessor can't do RGB→P010). The passes run on the 3D engine, but it still skips
/// NVENC's internal SM-side CSC. Built lazily.
hdr_p010_conv: Option<HdrP010Converter>,
last_seq: u64, last_seq: u64,
last_present: Option<(ID3D11Texture2D, PixelFormat)>, last_present: Option<(ID3D11Texture2D, PixelFormat)>,
status_logged: bool, 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>, _keepalive: Box<dyn Send>,
} }
// COM objects used only from the owning (encode) thread. // SAFETY: `IddPushCapturer` is `!Send` only because of its `*mut SharedHeader`/`*mut DebugBlock` raw
// pointers (and the COM interfaces). It is created, used, and dropped by a SINGLE thread — the owning
// capture/encode thread — never shared: the `ID3D11DeviceContext` is the device's IMMEDIATE context
// (single-threaded by D3D11 contract) and is only ever touched from that thread, and the header/
// dbg_block pointers (into mappings this struct owns) are only dereferenced there. `Send` transfers
// ownership to one thread at a time with NO concurrent access; we do not (and must not) claim `Sync`.
unsafe impl Send for IddPushCapturer {} unsafe impl Send for IddPushCapturer {}
/// The persistent IDD-push capturer, kept alive for the host lifetime and SHARED across client
/// sessions. The driver's per-session monitor TEARDOWN→RECREATE path is unstable (on session 2 the
/// target-id resolves to 0, `IddCxSwapChainSetDevice` fails `0x80070057`, then an access violation),
/// while the FIRST-session path is solid. So we create the monitor + ring + swap-chain ONCE and hand
/// every later session a thin handle delegating to this one. The persistent capturer holds a monitor
/// lease for the host lifetime, so `VirtualDisplay::create` always JOINs the same live monitor (same
/// target id) and the reuse match always hits — no recreate, no driver crash. Prototype scope:
/// single-client, single-mode (a different mode would need a recreate, the unstable path).
static IDD_PERSIST: Mutex<Option<IddPushCapturer>> = Mutex::new(None);
/// Open the IDD-push capturer, reusing the persistent one across sessions (see [`IDD_PERSIST`]).
pub fn open_or_reuse(
target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>,
client_10bit: bool,
keepalive: Box<dyn Send>,
) -> Result<Box<dyn Capturer>> {
let (w, h, _) =
preferred.context("IDD push needs the negotiated mode (WxH) to size the ring")?;
let mut slot = IDD_PERSIST.lock().unwrap();
let reuse = matches!(slot.as_ref(), Some(c) if c.target_id == target.target_id && c.width == w && c.height == h);
match slot.as_mut() {
Some(c) if reuse => {
// Reuse: the persistent capturer already owns the monitor + ring + driver attach. Drop the
// new per-session monitor lease (the persistent capturer's lease keeps the monitor live).
// The ring tracks the display, not the client; only the client's 10-bit cap can differ.
drop(keepalive);
c.set_client_10bit(client_10bit);
tracing::info!(
target_id = target.target_id,
client_10bit,
"IDD push: reusing the persistent capturer (no monitor/ring recreate)"
);
}
Some(c) => bail!(
"IDD-push persistent capturer is {}x{} target {}, this session wants {}x{} target {} — a \
mode/target change needs a recreate (the driver's recreate path is unstable); not \
supported in the persistent prototype",
c.width,
c.height,
c.target_id,
w,
h,
target.target_id
),
None => {
tracing::info!(
target_id = target.target_id,
client_10bit,
"IDD push: creating the persistent capturer (first session)"
);
*slot = Some(IddPushCapturer::open(target, preferred, client_10bit, keepalive)?);
}
}
Ok(Box::new(IddReuseHandle))
}
/// Thin per-session handle: every method delegates to the single persistent [`IddPushCapturer`].
/// Dropping it (session end) does NOT tear down the ring/monitor — that's the whole point.
struct IddReuseHandle;
impl Capturer for IddReuseHandle {
fn next_frame(&mut self) -> Result<CapturedFrame> {
IDD_PERSIST
.lock()
.unwrap()
.as_mut()
.context("IDD-push persistent capturer missing")?
.next_frame()
}
fn try_latest(&mut self) -> Result<Option<CapturedFrame>> {
IDD_PERSIST
.lock()
.unwrap()
.as_mut()
.context("IDD-push persistent capturer missing")?
.try_latest()
}
fn set_active(&self, active: bool) {
if let Some(c) = IDD_PERSIST.lock().unwrap().as_ref() {
c.set_active(active);
}
}
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
IDD_PERSIST
.lock()
.unwrap()
.as_ref()
.and_then(|c| c.hdr_meta())
}
}
/// Build a permissive (Everyone:GenericAll) `SECURITY_ATTRIBUTES` so the restricted WUDFHost driver /// 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. /// 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. /// The returned `psd` backing must outlive `sa`; both are dropped when the process exits.
@@ -315,6 +303,8 @@ impl IddPushCapturer {
&HSTRING::from(texture_name(target_id, generation, k)), &HSTRING::from(texture_name(target_id, generation, k)),
) )
.context("CreateSharedHandle(IDD-push ring slot)")?; .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 mutex: IDXGIKeyedMutex = tex.cast()?;
let mut srv: Option<ID3D11ShaderResourceView> = None; let mut srv: Option<ID3D11ShaderResourceView> = None;
device device
@@ -331,14 +321,49 @@ impl IddPushCapturer {
Ok(slots) Ok(slots)
} }
/// Open the IDD-push capturer. On success the caller's `keepalive` is attached (the capturer owns the
/// virtual display); on FAILURE the keepalive is handed BACK so the caller can fall back to DDA
/// instead of tearing the display down (audit §5.1 — no more 20 s black bail). "Failure" includes the
/// driver not attaching to the ring within a few seconds (e.g. a hybrid-GPU render mismatch).
pub fn open( pub fn open(
target: WinCaptureTarget, target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>, preferred: Option<(u32, u32, u32)>,
client_10bit: bool, client_10bit: bool,
keepalive: Box<dyn Send>, keepalive: Box<dyn Send>,
) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> {
match Self::open_inner(target, preferred, client_10bit) {
Ok(mut me) => {
me._keepalive = keepalive;
Ok(me)
}
Err(e) => Err((e, keepalive)),
}
}
fn open_inner(
target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>,
client_10bit: bool,
) -> Result<Self> { ) -> 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")?; .context("IDD push needs the negotiated mode (WxH) to size the shared ring")?;
// Size the ring to the display's ACTUAL current resolution if it differs from the negotiated mode:
// a fullscreen game can hold the virtual display at a different mode (esp. across a reconnect), so
// matching the actual mode lets the first frame flow instead of being dropped (game-capture bug
// GB1). Falls back to the negotiated mode when the CCD read is unavailable.
// SAFETY: `active_resolution` is an `unsafe fn` (Win32 CCD `QueryDisplayConfig`) that takes only a
// copy of the plain `u32` CCD target id and returns owned `(w, h)` values; it forms no borrows from
// us and validates the id internally, returning `None` on any failure (handled by `unwrap_or`).
let (w, h) =
unsafe { crate::win_display::active_resolution(target.target_id) }.unwrap_or((pw, ph));
if (w, h) != (pw, ph) {
tracing::info!(
target_id = target.target_id,
negotiated = format!("{pw}x{ph}"),
actual = format!("{w}x{h}"),
"IDD push: sizing the ring to the display's actual mode (differs from negotiated)"
);
}
// The driver composes the virtual display in FP16 (R16G16B16A16_FLOAT scRGB) when the display is // 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 // 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 // COMMIT_MODES2 colorspace/rgb_bpc log). The user can flip "Use HDR" in Windows at any time, so
@@ -347,13 +372,40 @@ impl IddPushCapturer {
// PROACTIVELY enable advanced color so HDR streams without the user toggling anything; an // PROACTIVELY enable advanced color so HDR streams without the user toggling anything; an
// SDR-only client leaves the display alone (and still gets a tone-mapped picture, never a freeze, // SDR-only client leaves the display alone (and still gets a tone-mapped picture, never a freeze,
// if the user does enable HDR). // if the user does enable HDR).
// SAFETY: one block over the whole ring setup; every operation in it is sound:
// - `set_advanced_color`/`advanced_color_enabled` are `unsafe fn`s taking only a copy of the plain
// `u32` target id; they read/flip CCD display config and return owned values, borrowing nothing.
// - `CreateDXGIFactory1`, `EnumAdapterByLuid`, `make_device`, `permissive_sa`, `CreateFileMappingW`,
// `MapViewOfFile`, `CreateEventW`, and `create_ring_slots` are all `?`-checked, so every returned
// interface/handle/view is non-error before use; `&sa`/`&adapter`/`&device`/the `&HSTRING` names
// are live borrows that outlive each synchronous call, and `sa.lpSecurityDescriptor` stays valid
// because its backing `_psd` is held in scope for the whole block.
// - The header mapping is created AND viewed at `bytes == size_of::<SharedHeader>().max(64)`; the
// view's null is checked (`bail!` on failure, after which the owned `map` closes the mapping). The
// OS view base is page-aligned, so `section.ptr::<SharedHeader>()` is suitably aligned for a
// `SharedHeader`, and `write_bytes(.., 0, bytes)` plus the `(*header).field = ..` writes all stay
// within those `bytes` and write THROUGH the raw pointer without forming any `&mut`. The debug
// section is the same pattern at `dbg_bytes == size_of::<DebugBlock>()`, only entered when its
// own view is non-null.
// - The `magic` publish stores through `addr_of!((*header).magic) as *const AtomicU32`: `addr_of!`
// takes the field address without a reference; the field is a 4-aligned `u32` (valid for
// `AtomicU32`), and the `Release` store after the `Release` fence is the cross-process handshake
// that orders all preceding writes before the driver may observe `MAGIC`.
// - `header`/`dbg_block` point into the OS mappings, NOT into the `MappedSection` structs, so moving
// `section`/`dbg_section` into `me` leaves them valid (see the `MappedSection` doc comment).
unsafe { unsafe {
if client_10bit && crate::vdisplay::sudovda::set_advanced_color(target.target_id, true) // If we ENABLE advanced color for a 10-bit client, trust it (the driver will compose FP16) and
{ // size the ring FP16 directly — don't race the advanced_color_enabled poll, which may not have
// settled within 250 ms and would size the ring SDR while the driver composes FP16 → a format
// mismatch → an immediate ring recreate + dropped first frames (audit §5.4).
let enabled_hdr =
client_10bit && crate::win_display::set_advanced_color(target.target_id, true);
if enabled_hdr {
// Let the colorspace change settle before the driver composes + we size the ring. // Let the colorspace change settle before the driver composes + we size the ring.
std::thread::sleep(Duration::from_millis(250)); std::thread::sleep(Duration::from_millis(250));
} }
let display_hdr = crate::vdisplay::sudovda::advanced_color_enabled(target.target_id); let display_hdr =
enabled_hdr || crate::win_display::advanced_color_enabled(target.target_id);
let ring_fmt = if display_hdr { let ring_fmt = if display_hdr {
DXGI_FORMAT_R16G16B16A16_FLOAT DXGI_FORMAT_R16G16B16A16_FLOAT
} else { } else {
@@ -382,13 +434,21 @@ impl IddPushCapturer {
&HSTRING::from(header_name(target.target_id)), &HSTRING::from(header_name(target.target_id)),
) )
.context("CreateFileMapping(IDD-push header)")?; .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() { if view.Value.is_null() {
let _ = CloseHandle(map); bail!("MapViewOfFile failed for IDD-push header"); // `map` drops → mapping closed
bail!("MapViewOfFile failed for IDD-push header");
} }
let section = MappedSection { handle: map, view };
let generation = IDD_GENERATION.fetch_add(1, Ordering::Relaxed); 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); std::ptr::write_bytes(header.cast::<u8>(), 0, bytes);
(*header).version = VERSION; (*header).version = VERSION;
(*header).generation = generation; (*header).generation = generation;
@@ -407,6 +467,7 @@ impl IddPushCapturer {
&HSTRING::from(event_name(target.target_id)), &HSTRING::from(event_name(target.target_id)),
) )
.context("CreateEvent(IDD-push)")?; .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. // Ring of shared keyed-mutex textures, format matched to the display's current mode.
let slots = let slots =
@@ -414,7 +475,7 @@ impl IddPushCapturer {
// Bring-up debug block (fixed name) — the driver writes diagnostics here. Best-effort. // Bring-up debug block (fixed name) — the driver writes diagnostics here. Best-effort.
let dbg_bytes = std::mem::size_of::<DebugBlock>(); 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, INVALID_HANDLE_VALUE,
Some(&sa), Some(&sa),
PAGE_READWRITE, PAGE_READWRITE,
@@ -423,18 +484,29 @@ impl IddPushCapturer {
&HSTRING::from(DBG_NAME), &HSTRING::from(DBG_NAME),
) { ) {
Ok(dm) => { 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() { if dv.Value.is_null() {
let _ = CloseHandle(dm); (None, std::ptr::null_mut()) // `dm` drops → mapping closed
(HANDLE::default(), std::ptr::null_mut())
} else { } 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); std::ptr::write_bytes(p.cast::<u8>(), 0, dbg_bytes);
(*p).magic = DBG_MAGIC; (*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. // Publish: magic LAST (Release) — signals the driver the ring is ready to open.
@@ -451,14 +523,14 @@ impl IddPushCapturer {
ring_fp16 = display_hdr, ring_fp16 = display_hdr,
"IDD push(host): created shared ring; waiting for the driver to attach + publish" "IDD push(host): created shared ring; waiting for the driver to attach + publish"
); );
Ok(Self { let me = Self {
device, device,
context, context,
target_id: target.target_id, target_id: target.target_id,
map, section,
header, header,
event, event,
dbg_map, dbg_section,
dbg_block, dbg_block,
width: w, width: w,
height: h, height: h,
@@ -467,20 +539,76 @@ impl IddPushCapturer {
client_10bit, client_10bit,
display_hdr, display_hdr,
last_acm_poll: Instant::now(), last_acm_poll: Instant::now(),
recovering_since: None,
out_ring: Vec::new(), out_ring: Vec::new(),
out_idx: 0, out_idx: 0,
hdr_conv: None, video_conv: None,
hdr_p010_conv: None,
last_seq: 0, last_seq: 0,
last_present: None, last_present: None,
status_logged: false, 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
_keepalive: keepalive, // it back to the caller for the DDA fallback (audit §5.1).
}) _keepalive: Box::new(()),
};
// Bounded wait for the driver to ATTACH to the ring AND publish a first frame. An attach
// failure (DRV_STATUS_TEX_FAIL) or an attach-but-no-frames (a game left the display in a
// format/size the ring can't match) becomes an open failure the caller falls back from (→ DDA),
// instead of next_frame's 20 s black-then-bail.
me.wait_for_attach()?;
Ok(me)
}
}
/// Block (bounded) until the driver has ATTACHED to the host ring (`DRV_STATUS_OPENED`) **and published
/// a first frame**, else fail so the caller can fall back to DDA (audit §5.1 +
/// `docs/windows-host-rewrite.md` §2.5 — the GB1 game-capture fix).
///
/// Requiring the first frame — not just the attach — catches the *reconnect-into-a-broken-state* case:
/// a fullscreen game can leave the virtual display in a format/size that the driver's `publish()` guard
/// rejects, so the driver ATTACHES but silently drops every frame; without this the host sails past
/// `open()` and only dies on `next_frame`'s 20 s deadline (the "reconnect = black + audio" symptom). At
/// session open the OS activates the virtual display → DWM composites it → a frame arrives within ~1 s,
/// so this does not false-fail a normal (even idle) open; no frame within the window = genuinely broken.
fn wait_for_attach(&self) -> Result<()> {
let deadline = Instant::now() + Duration::from_secs(4);
loop {
// SAFETY: `self.header` points into the live shared-header mapping this capturer owns (sized
// `>= size_of::<SharedHeader>()`, page-aligned), so the field read is in-bounds + aligned, and
// no reference into the shared region is formed. Plain read: the driver writes this `u32`
// cross-process, but an aligned `u32` read can't tear and `driver_status` is best-effort
// diagnostics — the real handshake is the atomic `magic`/`latest` (same access as
// log_driver_status_once).
let st = unsafe { (*self.header).driver_status };
if matches!(st, DRV_STATUS_TEX_FAIL | DRV_STATUS_NO_DEVICE1) {
// SAFETY: as above — an in-bounds, aligned `u32` read of a best-effort diagnostic field
// through the owned, live header mapping; no reference into the shared region is formed.
let detail = unsafe { (*self.header).driver_status_detail };
bail!(
"IDD-push driver failed to attach (driver_status={st} detail=0x{detail:08x} — \
render-adapter mismatch?)"
);
}
// Attached AND a frame has been published — the publish token's seq advances past 0.
if st == DRV_STATUS_OPENED && frame::FrameToken::unpack(self.latest()).seq != 0 {
return Ok(());
}
if Instant::now() > deadline {
bail!(
"IDD-push: driver_status={st} but no frame published within 4s — the virtual display \
is likely in a format/size the ring can't match (fullscreen game?); falling back"
);
}
std::thread::sleep(Duration::from_millis(20));
} }
} }
#[inline] #[inline]
fn latest(&self) -> u64 { fn latest(&self) -> u64 {
// SAFETY: `self.header` is the live, owned shared-header mapping (page-aligned, sized for a
// `SharedHeader`). `addr_of!((*self.header).latest)` forms the address of the `latest` field
// WITHOUT a reference; it is an 8-aligned `u64` (so valid for `AtomicU64`), and the `Acquire` load
// is the consumer half of the cross-process publish handshake (pairs with the driver's `Release`).
unsafe { unsafe {
(*(std::ptr::addr_of!((*self.header).latest) as *const AtomicU64)) (*(std::ptr::addr_of!((*self.header).latest) as *const AtomicU64))
.load(Ordering::Acquire) .load(Ordering::Acquire)
@@ -492,6 +620,10 @@ impl IddPushCapturer {
if self.status_logged { if self.status_logged {
return; return;
} }
// SAFETY: four in-bounds, aligned reads of the live, owned shared-header mapping. The driver writes
// these `u32`/`i32` diagnostic fields cross-process, but aligned word reads can't tear and these are
// best-effort status (the real handshake is the atomic `magic`/`latest`); no `&`/`&mut` reference
// into the shared region is formed.
let (status, detail, lo, hi) = unsafe { let (status, detail, lo, hi) = unsafe {
( (
(*self.header).driver_status, (*self.header).driver_status,
@@ -531,6 +663,11 @@ impl IddPushCapturer {
tracing::warn!("IDD push DEBUG: no debug block"); tracing::warn!("IDD push DEBUG: no debug block");
return; return;
} }
// SAFETY: `self.dbg_block` was just checked non-null (the early return above); it points into the
// owned `dbg_section` mapping sized exactly `size_of::<DebugBlock>()` and page-aligned, so it is
// valid + aligned for `DebugBlock`. `d` is a short-lived SHARED reference used only to read the
// fields below; we never form `&mut` into this region, and the driver's cross-process writes are
// aligned `u32`s that don't tear (best-effort bring-up diagnostics).
let d = unsafe { &*self.dbg_block }; let d = unsafe { &*self.dbg_block };
tracing::error!( tracing::error!(
run_core_entries = d.run_core_entries, run_core_entries = d.run_core_entries,
@@ -546,16 +683,17 @@ impl IddPushCapturer {
); );
} }
/// The output texture format + the [`PixelFormat`] it presents as, driven SOLELY by the DISPLAY's /// The output texture format + the [`PixelFormat`] NVENC encodes, driven SOLELY by the DISPLAY's HDR
/// HDR state (like the WGC path): HDR → `Rgb10a2` BT.2020 PQ → NVENC Main10, and the client /// state (like the WGC path): HDR → `P010` (BT.2020 PQ 10-bit limited) → NVENC Main10, and the client
/// auto-detects PQ from the HEVC VUI; SDR → 8-bit `Bgra`. We do NOT gate HDR on the client's /// auto-detects PQ from the HEVC VUI; SDR → `Nv12` (BT.709 8-bit limited). Both are native YUV so
/// advertised `VIDEO_CAP_10BIT` — clients under-report it (e.g. the Mac advertises 10-bit only when /// NVENC skips its internal RGB→YUV CSC on the contended SM (plan §5.A). We do NOT gate HDR on the
/// its OWN display is HDR), yet all decode Main10 + auto-switch, exactly as on the WGC path. /// client's advertised `VIDEO_CAP_10BIT` — clients under-report it (e.g. the Mac advertises 10-bit
/// only when its OWN display is HDR), yet all decode Main10 + auto-switch, exactly as on the WGC path.
fn out_format(&self) -> (DXGI_FORMAT, PixelFormat) { fn out_format(&self) -> (DXGI_FORMAT, PixelFormat) {
if self.display_hdr { if self.display_hdr {
(DXGI_FORMAT_R10G10B10A2_UNORM, PixelFormat::Rgb10a2) (DXGI_FORMAT_P010, PixelFormat::P010)
} else { } else {
(DXGI_FORMAT_B8G8R8A8_UNORM, PixelFormat::Bgra) (DXGI_FORMAT_NV12, PixelFormat::Nv12)
} }
} }
@@ -569,20 +707,20 @@ impl IddPushCapturer {
} }
} }
/// Update the client's 10-bit capability (the reuse path). Only affects whether a fresh `open`
/// proactively enables advanced color; the per-frame conversion follows the display, not the client.
fn set_client_10bit(&mut self, client_10bit: bool) {
self.client_10bit = client_10bit;
}
/// Recreate the ring at the format for `new_display_hdr` (the user flipped "Use HDR"). Bumps the /// 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 /// 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 /// 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. /// 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.display_hdr = new_display_hdr;
self.width = new_w;
self.height = new_h;
let fmt = self.ring_format(); let fmt = self.ring_format();
let new_gen = IDD_GENERATION.fetch_add(1, Ordering::Relaxed); let new_gen = IDD_GENERATION.fetch_add(1, Ordering::Relaxed);
// SAFETY: `create_ring_slots` is an `unsafe fn` (it makes D3D11/DXGI COM calls); we pass a live
// borrow of `self.device` (the capturer's own device, on which the slots are created) plus plain
// `u32`/`DXGI_FORMAT` values, and `?` propagates any failure before the slots are used. Every
// returned slot's texture + keyed mutex belongs to that same `self.device`.
let new_slots = unsafe { let new_slots = unsafe {
Self::create_ring_slots( Self::create_ring_slots(
&self.device, &self.device,
@@ -593,6 +731,12 @@ impl IddPushCapturer {
fmt, fmt,
)? )?
}; };
// SAFETY: `self.header` is the live, owned shared-header mapping (page-aligned, sized for a
// `SharedHeader`). The `latest`/`generation` stores go through `addr_of!`-formed field pointers (no
// references) of correctly-aligned `u64`/`u32` fields, valid for `AtomicU64`/`AtomicU32`; the
// `dxgi_format`/`width`/`height` writes are in-bounds raw writes through the pointer (no `&mut`).
// The `Release` fence + the `Release` `generation` store publish all preceding writes so the driver
// only re-attaches (`Acquire`) once the new textures + format are in place.
unsafe { unsafe {
// Clear `latest` to the 0 sentinel (generation 0, which try_consume rejects). The real guard // Clear `latest` to the 0 sentinel (generation 0, which try_consume rejects). The real guard
// against consuming an unwritten new-ring slot is the generation tag in `latest`: a stale // against consuming an unwritten new-ring slot is the generation tag in `latest`: a stale
@@ -601,6 +745,8 @@ impl IddPushCapturer {
(*(std::ptr::addr_of!((*self.header).latest) as *const AtomicU64)) (*(std::ptr::addr_of!((*self.header).latest) as *const AtomicU64))
.store(0, Ordering::Relaxed); .store(0, Ordering::Relaxed);
(*self.header).dxgi_format = fmt.0 as u32; (*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 // Publish the new generation LAST (Release): when the driver observes it (Acquire) the new
// textures already exist and the format is already updated. // textures already exist and the format is already updated.
std::sync::atomic::fence(Ordering::Release); std::sync::atomic::fence(Ordering::Release);
@@ -611,6 +757,8 @@ impl IddPushCapturer {
self.generation = new_gen; self.generation = new_gen;
self.last_seq = 0; self.last_seq = 0;
self.out_ring.clear(); // the output format changed → rebuild lazily at the new format self.out_ring.clear(); // the output format changed → rebuild lazily at the new format
self.video_conv = None; // converters are sized + HDR-specific → rebuild at the new mode
self.hdr_p010_conv = None;
self.out_idx = 0; self.out_idx = 0;
self.last_present = None; self.last_present = None;
Ok(()) Ok(())
@@ -624,17 +772,28 @@ impl IddPushCapturer {
return; return;
} }
self.last_acm_poll = Instant::now(); self.last_acm_poll = Instant::now();
let now_hdr = unsafe { crate::vdisplay::sudovda::advanced_color_enabled(self.target_id) }; // SAFETY: `advanced_color_enabled` is an `unsafe fn` taking only a copy of the plain `u32` target
if now_hdr == self.display_hdr { // id; it performs a read-only CCD query and returns an owned `bool`, borrowing nothing from us.
let now_hdr = unsafe { crate::win_display::advanced_color_enabled(self.target_id) };
// Follow the display's ACTUAL resolution too — a fullscreen game can mode-set the virtual display
// out from under the negotiated size (game-capture bug GB1). Unknown read → keep our current size.
// SAFETY: `active_resolution` is an `unsafe fn` taking only a copy of the plain `u32` target id; it
// performs a read-only CCD query and returns owned `(w, h)` values, borrowing nothing from us.
let (now_w, now_h) = unsafe { crate::win_display::active_resolution(self.target_id) }
.unwrap_or((self.width, self.height));
if now_hdr == self.display_hdr && now_w == self.width && now_h == self.height {
return; return;
} }
tracing::info!( tracing::info!(
target_id = self.target_id, target_id = self.target_id,
display_hdr = now_hdr, from = format!("{}x{} hdr={}", self.width, self.height, self.display_hdr),
client_10bit = self.client_10bit, to = format!("{now_w}x{now_h} hdr={now_hdr}"),
"IDD push: display HDR mode flipped — recreating the ring at the new format" "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"); tracing::warn!(error = %format!("{e:#}"), "IDD push: ring recreate failed");
} }
} }
@@ -658,31 +817,46 @@ impl IddPushCapturer {
Quality: 0, Quality: 0,
}, },
Usage: D3D11_USAGE_DEFAULT, Usage: D3D11_USAGE_DEFAULT,
BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32, // RENDER_TARGET: the VIDEO processor (NV12) and the P010 shader passes both write here, and
// NVENC registers it as encode input — matching the WGC YUV ring.
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
CPUAccessFlags: 0, CPUAccessFlags: 0,
MiscFlags: 0, MiscFlags: 0,
}; };
for _ in 0..OUT_RING { for _ in 0..OUT_RING {
let mut t: Option<ID3D11Texture2D> = None; let mut t: Option<ID3D11Texture2D> = None;
let mut rtv: Option<ID3D11RenderTargetView> = None; // SAFETY: `CreateTexture2D` is called on `self.device` (the capturer's live D3D11 device);
// `&desc` is a fully-initialized stack `D3D11_TEXTURE2D_DESC`, the data arg is `None` (no
// initial data), and `Some(&mut t)` is a live out-parameter the call fills. `?` rejects a failed
// HRESULT before `t` is unwrapped, and the created texture belongs to `self.device`.
unsafe { unsafe {
self.device self.device
.CreateTexture2D(&desc, None, Some(&mut t)) .CreateTexture2D(&desc, None, Some(&mut t))
.context("CreateTexture2D(IDD out ring)")?; .context("CreateTexture2D(IDD out ring)")?;
let t = t.context("null out-ring texture")?; self.out_ring.push(t.context("null out-ring texture")?);
self.device
.CreateRenderTargetView(&t, None, Some(&mut rtv))
.context("CreateRenderTargetView(IDD out ring)")?;
self.out_ring.push((t, rtv.context("null out-ring rtv")?));
} }
} }
Ok(()) Ok(())
} }
/// Build the HDR converter if not already built (HDR-display path only — an SDR display is a copy). /// Build the per-mode YUV converter if not already built: a VIDEO-engine BGRA→NV12 processor on an
/// SDR display, or the FP16→P010 shader on an HDR display. Both keep NVENC's RGB→YUV CSC off the SM.
fn ensure_converter(&mut self) -> Result<()> { fn ensure_converter(&mut self) -> Result<()> {
if self.hdr_conv.is_none() { if self.display_hdr {
self.hdr_conv = Some(unsafe { HdrConverter::new(&self.device)? }); if self.hdr_p010_conv.is_none() {
// SAFETY: `HdrP010Converter::new` is `unsafe` (it compiles D3D11 shaders + creates
// resources); we pass a live borrow of `self.device`, the device the converter's resources
// belong to, and `?` propagates any failure before the converter is stored.
self.hdr_p010_conv = Some(unsafe { HdrP010Converter::new(&self.device)? });
}
} else if self.video_conv.is_none() {
// SAFETY: `VideoConverter::new` is `unsafe` (it sets up the D3D11 VIDEO processor); we pass live
// borrows of `self.device` + its immediate `self.context` (single-threaded, this thread) plus
// plain `u32` dimensions, and `?` propagates any failure before it is stored. The converter's
// resources belong to that same device/context.
self.video_conv = Some(unsafe {
VideoConverter::new(&self.device, &self.context, self.width, self.height, false)?
});
} }
Ok(()) Ok(())
} }
@@ -691,6 +865,17 @@ impl IddPushCapturer {
self.log_driver_status_once(); self.log_driver_status_once();
// Follow the display: a "Use HDR" flip recreates the ring at the matching format. // Follow the display: a "Use HDR" flip recreates the ring at the matching format.
self.poll_display_hdr(); 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(); let latest = self.latest();
// `latest` is the proto publish token `(generation << 40) | (seq << 8) | slot`. Reject any publish // `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 // whose generation isn't our CURRENT ring (a stale old-ring publish racing a recreate, or the 0
@@ -706,40 +891,53 @@ impl IddPushCapturer {
return Ok(None); return Ok(None);
} }
self.ensure_out_ring()?; self.ensure_out_ring()?;
// Build the HDR converter BEFORE acquiring the slot so nothing between Acquire and Release can // Build the converter BEFORE acquiring the slot so nothing between Acquire and Release can
// `?`-return and leak the keyed-mutex lock (which would stall the driver on that slot). // `?`-return and leak the keyed-mutex lock (which would stall the driver on that slot).
if self.display_hdr {
self.ensure_converter()?; self.ensure_converter()?;
}
let i = self.out_idx; let i = self.out_idx;
let (out, out_rtv) = { let out = self.out_ring[i].clone();
let (t, rtv) = &self.out_ring[i];
(t.clone(), rtv.clone())
};
let (_, pf) = self.out_format(); let (_, pf) = self.out_format();
// Hold the slot's keyed mutex only across the convert/copy into the host out-ring (NOT across the // Hold the slot's keyed mutex only across the convert/copy into the host out-ring (NOT across the
// ~3 ms encode — NVENC reads the host out-ring slot, not the keyed-mutex slot), so the driver gets // ~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. // the slot back immediately and the encode of the PREVIOUS frame overlaps this convert.
let s = &self.slots[slot]; let s = &self.slots[slot];
if unsafe { s.mutex.AcquireSync(0, 8) }.is_err() { // 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); return Ok(None);
} };
// SAFETY: convert on the owning (encode) thread's immediate context, holding the slot lock.
// A `?` here is leak-safe: `_lock` (the KeyedMutexGuard) drops on the early return, releasing
// the slot back to the driver.
unsafe { unsafe {
if self.display_hdr { if self.display_hdr {
// Sample the FP16 slot's SRV directly (no scratch copy) → BT.2020 PQ Rgb10a2. // HDR: FP16 slot SRV → P010 (BT.2020 PQ) via the shader; NVENC takes native P010.
if let Some(conv) = self.hdr_conv.as_ref() { if let Some(conv) = self.hdr_p010_conv.as_ref() {
conv.convert(&self.context, &s.srv, &out_rtv, self.width, self.height); conv.convert(
&self.device,
&self.context,
&s.srv,
&out,
self.width,
self.height,
)?;
} }
} else { } else {
// SDR: the slot is already 8-bit BGRA — one copy into the out-ring (hidden by pipelining). // SDR: BGRA slot → NV12 on the VIDEO engine; NVENC takes native NV12, no SM-side CSC.
self.context.CopyResource(&out, &s.tex); if let Some(conv) = self.video_conv.as_ref() {
conv.convert(&s.tex, &out)?;
} }
let _ = s.mutex.ReleaseSync(0); }
}
// `_lock` drops here → `ReleaseSync(0)`.
} }
self.out_idx = (i + 1) % self.out_ring.len(); self.out_idx = (i + 1) % self.out_ring.len();
self.last_seq = seq; self.last_seq = seq;
self.last_present = Some((out.clone(), pf)); self.last_present = Some((out.clone(), pf));
self.recovering_since = None; // a fresh frame resumed → recovered
Ok(Some(CapturedFrame { Ok(Some(CapturedFrame {
width: self.width, width: self.width,
height: self.height, height: self.height,
@@ -752,14 +950,28 @@ impl IddPushCapturer {
})) }))
} }
fn repeat_last(&self) -> Option<CapturedFrame> { fn repeat_last(&mut self) -> Option<CapturedFrame> {
self.last_present.as_ref().map(|(tex, pf)| CapturedFrame { // Copy the last presented frame into a FRESH rotated out-ring slot so a repeat (static desktop, no
// new driver frame) never re-hands a slot that may still be encoding under pipeline_depth>1 — the
// out-ring rotation IS the texture-ownership contract, and repeats must honor it too (audit §5.3).
// OUT_RING(3) > the max pipeline_depth(2) guarantees the rotated slot is not in flight.
let (src, pf) = self.last_present.clone()?;
let i = self.out_idx;
let dst = self.out_ring.get(i)?.clone();
// SAFETY: GPU copy on the owning thread's immediate context; src/dst are our out-ring textures of
// identical format/size (src is a previous out-ring slot; dst the next).
unsafe {
self.context.CopyResource(&dst, &src);
}
self.out_idx = (i + 1) % self.out_ring.len();
self.last_present = Some((dst.clone(), pf));
Some(CapturedFrame {
width: self.width, width: self.width,
height: self.height, height: self.height,
pts_ns: now_ns(), pts_ns: now_ns(),
format: *pf, format: pf,
payload: FramePayload::D3d11(D3d11Frame { payload: FramePayload::D3d11(D3d11Frame {
texture: tex.clone(), texture: dst,
device: self.device.clone(), device: self.device.clone(),
}), }),
}) })
@@ -796,7 +1008,7 @@ pub fn spawn_observer(target: WinCaptureTarget, preferred: Option<(u32, u32, u32
); );
cap.log_debug_block(); cap.log_debug_block();
} }
Err(e) => tracing::warn!( Err((e, _keep)) => tracing::warn!(
target_id = tid, target_id = tid,
"IDD push OBSERVER: ring open failed: {e:#}" "IDD push OBSERVER: ring open failed: {e:#}"
), ),
@@ -806,7 +1018,9 @@ pub fn spawn_observer(target: WinCaptureTarget, preferred: Option<(u32, u32, u32
/// The discrete render GPU LUID (where NVENC runs), falling back to the monitor's `OsAdapterLuid`. /// The discrete render GPU LUID (where NVENC runs), falling back to the monitor's `OsAdapterLuid`.
fn resolve_render_adapter_luid_or(fallback_packed: i64) -> LUID { fn resolve_render_adapter_luid_or(fallback_packed: i64) -> LUID {
if let Some(l) = unsafe { crate::vdisplay::sudovda::resolve_render_adapter_luid() } { // SAFETY: `resolve_render_adapter_luid` is an `unsafe fn` (it enumerates DXGI adapters) that takes no
// arguments and returns an owned `Option<LUID>`, borrowing nothing.
if let Some(l) = unsafe { crate::win_adapter::resolve_render_adapter_luid() } {
return l; return l;
} }
LUID { LUID {
@@ -819,7 +1033,10 @@ impl Capturer for IddPushCapturer {
fn next_frame(&mut self) -> Result<CapturedFrame> { fn next_frame(&mut self) -> Result<CapturedFrame> {
let deadline = Instant::now() + Duration::from_secs(20); let deadline = Instant::now() + Duration::from_secs(20);
loop { loop {
let _ = unsafe { WaitForSingleObject(self.event, 16) }; // SAFETY: `self.event` is the live frame-ready `OwnedHandle` this capturer owns; its raw value
// (borrowed for the call, so it outlives this synchronous wait) is a valid auto-reset event
// handle. `WaitForSingleObject` only reads the handle; the 16 ms timeout bounds the wait.
let _ = unsafe { WaitForSingleObject(HANDLE(self.event.as_raw_handle()), 16) };
if let Some(f) = self.try_consume()? { if let Some(f) = self.try_consume()? {
return Ok(f); return Ok(f);
} }
@@ -828,6 +1045,9 @@ impl Capturer for IddPushCapturer {
} }
if Instant::now() > deadline { if Instant::now() > deadline {
self.log_debug_block(); self.log_debug_block();
// SAFETY: four in-bounds, aligned reads of the live, owned shared-header mapping — the same
// best-effort diagnostic fields as `log_driver_status_once` (aligned word reads can't tear;
// no reference into the shared region is formed).
let (st, detail, lo, hi) = unsafe { let (st, detail, lo, hi) = unsafe {
( (
(*self.header).driver_status, (*self.header).driver_status,
@@ -864,34 +1084,15 @@ impl Capturer for IddPushCapturer {
// NVENC encodes N on the ASIC. We hand a rotating `OUT_RING` of output textures, so this is safe. // 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 // `PUNKTFUNK_IDD_DEPTH` overrides (1 disables pipelining; clamp to ≤ OUT_RING so a frame in flight
// always has its own texture). // always has its own texture).
std::env::var("PUNKTFUNK_IDD_DEPTH") crate::config::config().idd_depth.clamp(1, OUT_RING)
.ok()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(2)
.clamp(1, OUT_RING)
} }
} }
impl Drop for IddPushCapturer { impl Drop for IddPushCapturer {
fn drop(&mut self) { fn drop(&mut self) {
self.slots.clear(); self.slots.clear();
unsafe { // The shared header + debug sections (`MappedSection`) and the frame-ready `event`
if !self.dbg_block.is_null() { // (`OwnedHandle`) free themselves via RAII (each unmaps its view, then closes its handle).
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);
}
// _keepalive drops after, REMOVEing the virtual display. // _keepalive drops after, REMOVEing the virtual display.
} }
} }
@@ -16,6 +16,9 @@
//! Limitation: WGC cannot capture the secure desktop (lock / UAC / login) — the caller falls back to //! Limitation: WGC cannot capture the secure desktop (lock / UAC / login) — the caller falls back to
//! the DDA backend ([`super::dxgi::DuplCapturer`]) for those (see capture.rs). //! the DDA backend ([`super::dxgi::DuplCapturer`]) for those (see capture.rs).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::dxgi::{ use super::dxgi::{
find_output, hdr_shader_p010_enabled, make_device, nudge_cursor_onto, D3d11Frame, HdrConverter, find_output, hdr_shader_p010_enabled, make_device, nudge_cursor_onto, D3d11Frame, HdrConverter,
HdrP010Converter, VideoConverter, WinCaptureTarget, HdrP010Converter, VideoConverter, WinCaptureTarget,
@@ -92,6 +95,10 @@ struct Deimpersonate(Option<HANDLE>);
impl Drop for Deimpersonate { impl Drop for Deimpersonate {
fn drop(&mut self) { fn drop(&mut self) {
if let Some(tok) = self.0.take() { if let Some(tok) = self.0.take() {
// SAFETY: `RevertToSelf` takes no arguments and undoes the thread impersonation set during
// WGC activation; `tok` is the impersonation token `HANDLE` from `impersonate_active_user`,
// owned by this `Deimpersonate` and closed exactly once here (taken out of the `Option`, so
// no double-close). Both are FFI calls borrowing no Rust memory.
unsafe { unsafe {
let _ = RevertToSelf(); let _ = RevertToSelf();
let _ = CloseHandle(tok); let _ = CloseHandle(tok);
@@ -174,7 +181,12 @@ pub struct WgcCapturer {
_keepalive: Option<Box<dyn Send>>, _keepalive: Option<Box<dyn Send>>,
} }
// COM + WinRT pointers; confined to the single owning (encode) thread, like DuplCapturer. // SAFETY: like `DuplCapturer`. `WgcCapturer` holds D3D11 (free-threaded device/context) plus WGC WinRT
// objects (`Direct3D11CaptureFramePool` etc., created free-threaded via `CreateFreeThreaded`). COM/WinRT
// reference counting is interlocked, and the capturer is owned + used by exactly one encode thread,
// moved to it once and never shared (no `Sync`), so transferring ownership across threads is sound. The
// free-threaded `FrameArrived` callback touches only the `Arc<WgcSignal>` (itself `Send + Sync`), not
// the capturer's COM fields.
unsafe impl Send for WgcCapturer {} unsafe impl Send for WgcCapturer {}
impl WgcCapturer { impl WgcCapturer {
@@ -182,6 +194,15 @@ impl WgcCapturer {
/// [`attach_keepalive`](Self::attach_keepalive) only after open succeeds, so a failure leaves the /// [`attach_keepalive`](Self::attach_keepalive) only after open succeeds, so a failure leaves the
/// keepalive with the caller to hand to the DDA fallback. /// keepalive with the caller to hand to the DDA fallback.
pub fn open(target: WinCaptureTarget, preferred: Option<(u32, u32, u32)>) -> Result<Self> { pub fn open(target: WinCaptureTarget, preferred: Option<(u32, u32, u32)>) -> Result<Self> {
// SAFETY: runs on the thread opening the WGC session. `RoInitialize` inits this thread's WinRT
// apartment (idempotent; result ignored). `impersonate_active_user()` and `find_output()` are
// this module's `unsafe fn`s whose contracts (call on the activating thread; pass a GDI name)
// are met, and the impersonation is reverted by `_deimp`'s Drop on every return path. Every
// COM/WinRT call thereafter operates on an object obtained + `?`-checked earlier in this same
// block on this single thread — the `IDXGIOutput1` from `find_output`, the device/context from
// `make_device`, the factory/interop/item/pool/session — and the `TypedEventHandler` closure
// captures an `Arc<WgcSignal>` (Send+Sync) by move. No raw pointers are dereferenced; borrowed
// locals outlive their synchronous calls.
unsafe { unsafe {
// WGC is WinRT — the calling thread needs a COM/WinRT apartment for the GraphicsCaptureItem // WGC is WinRT — the calling thread needs a COM/WinRT apartment for the GraphicsCaptureItem
// activation factory (RoGetActivationFactory). Initialize MTA; ignore "already initialized" // activation factory (RoGetActivationFactory). Initialize MTA; ignore "already initialized"
@@ -196,7 +217,7 @@ impl WgcCapturer {
// The SudoVDA output appears a beat after the display is created — settle-retry like DDA. // The SudoVDA output appears a beat after the display is created — settle-retry like DDA.
let deadline = Instant::now() + Duration::from_millis(2000); let deadline = Instant::now() + Duration::from_millis(2000);
let (adapter, output) = loop { let (adapter, output) = loop {
if let Some(n) = crate::vdisplay::sudovda::resolve_gdi_name(target.target_id) { if let Some(n) = crate::win_display::resolve_gdi_name(target.target_id) {
if let Ok(found) = find_output(&n) { if let Ok(found) = find_output(&n) {
break found; break found;
} }
@@ -585,6 +606,15 @@ impl WgcCapturer {
} }
fn process_frame(&mut self, frame: Direct3D11CaptureFrame) -> Result<CapturedFrame> { fn process_frame(&mut self, frame: Direct3D11CaptureFrame) -> Result<CapturedFrame> {
// SAFETY: runs on the capturer's single owning thread. `frame` is a live
// `Direct3D11CaptureFrame` from `self.pool`; `frame.Surface().cast::<IDirect3DDxgiInterfaceAccess
// >().GetInterface()` yields the frame's backing `ID3D11Texture2D`, which belongs to
// `self.device` (the pool was created on it via `CreateDirect3D11DeviceFromDXGIDevice`). Every
// helper called here — `hdr_to_p010`, `convert_to_yuv`, `ensure_fp16_src`, `ensure_out_ring`,
// `HdrConverter::convert`, `CopyResource`, `CreateRenderTargetView` — operates on
// `self.device`/`self.context` and that same-device texture, so all resources share one device.
// The frame is held in `self.held` until its async GPU read completes for the zero-copy paths.
// Single-threaded immediate-context use; borrowed textures/SRVs/RTVs outlive each synchronous call.
unsafe { unsafe {
let surface = frame.Surface().context("frame Surface")?; let surface = frame.Surface().context("frame Surface")?;
let access: IDirect3DDxgiInterfaceAccess = surface let access: IDirect3DDxgiInterfaceAccess = surface
@@ -13,6 +13,9 @@
//! Wire framing (must match `wgc_helper::write_au`): per AU //! Wire framing (must match `wgc_helper::write_au`): per AU
//! `[u32 magic "PFAU" LE][u32 len LE][u64 pts_ns LE][u8 keyframe][len bytes data]`. //! `[u32 magic "PFAU" LE][u32 len LE][u64 pts_ns LE][u8 keyframe][len bytes data]`.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use crate::capture::dxgi::WinCaptureTarget; use crate::capture::dxgi::WinCaptureTarget;
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use std::io::{BufRead, BufReader, Read}; use std::io::{BufRead, BufReader, Read};
@@ -56,9 +59,15 @@ pub struct HelperRelay {
rx: Receiver<RelayAu>, rx: Receiver<RelayAu>,
} }
// HANDLEs are just kernel handle values; we own them for the relay's lifetime and close them on Drop. // SAFETY: every field is itself `Send`: the `proc`/`thread` `HANDLE`s are process-global kernel
// handle values (plain integers valid from any thread, owned for the relay's lifetime and closed once
// on Drop), `stdin_w` is a `Mutex<HANDLE>`, and `rx` is an mpsc `Receiver<RelayAu>` (which is `Send`).
// The relay is moved to one thread and owned there, so transferring it across threads is sound.
unsafe impl Send for HelperRelay {} unsafe impl Send for HelperRelay {}
unsafe impl Sync for HelperRelay {} // NOTE: `HelperRelay` is deliberately NOT `Sync`. Its `rx: Receiver<RelayAu>` is `!Sync` (std mpsc
// is single-consumer), and the relay is only ever a single-owner local in the punktfunk1 two-process
// mux loop — never shared by `&` across threads — so `Sync` is neither sound nor needed. (A prior
// `unsafe impl Sync` here asserted more than the fields support; removed.)
/// Control byte on the helper's stdin: force the next encoded frame to be an IDR (client decode /// Control byte on the helper's stdin: force the next encoded frame to be an IDR (client decode
/// recovery). Mirrors `enc.request_keyframe()` in the single-process path. /// recovery). Mirrors `enc.request_keyframe()` in the single-process path.
@@ -84,6 +93,10 @@ impl HelperRelay {
); );
tracing::info!(cmd = %cmdline, "spawning WGC helper in user session"); tracing::info!(cmd = %cmdline, "spawning WGC helper in user session");
// SAFETY: `spawn_inner` is an `unsafe fn` only because it drives raw Win32 token/pipe/process
// FFI; it imposes no caller-side memory precondition beyond valid arguments. `cmdline` is a live
// `&str` borrowed for the synchronous call and `(w, h, hz)` are plain `u32`s. It validates its
// own runtime requirements (active console session, SYSTEM token) and returns `Err` otherwise.
unsafe { spawn_inner(&cmdline, w, h, hz) } unsafe { spawn_inner(&cmdline, w, h, hz) }
} }
@@ -108,6 +121,11 @@ impl HelperRelay {
pub fn request_keyframe(&self) { pub fn request_keyframe(&self) {
let h = self.stdin_w.lock().unwrap(); let h = self.stdin_w.lock().unwrap();
let mut written = 0u32; let mut written = 0u32;
// SAFETY: `*h` is the host's write end of the helper's stdin pipe — a live `HANDLE` owned by
// this `HelperRelay` (held under the `stdin_w` Mutex, locked here), closed only in Drop.
// `WriteFile` reads the 1-byte `&[CTL_KEYFRAME]` buffer and writes the byte count into
// `written`; both are live locals that outlive the synchronous call. A failure (helper gone) is
// discarded as documented.
unsafe { unsafe {
let _ = windows::Win32::Storage::FileSystem::WriteFile( let _ = windows::Win32::Storage::FileSystem::WriteFile(
*h, *h,
@@ -121,6 +139,10 @@ impl HelperRelay {
impl Drop for HelperRelay { impl Drop for HelperRelay {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `self.proc`/`self.thread` are the child process/thread `HANDLE`s from
// `CreateProcessAsUserW`, and `stdin_w` is the host's pipe write end — all owned by this
// `HelperRelay` and closed exactly once here in Drop (no double-close). `TerminateProcess` and
// the three `CloseHandle`s are FFI calls taking those handles by value, borrowing no Rust memory.
unsafe { unsafe {
// Terminate the child first so its WGC capture + NVENC session tear down, then close our // Terminate the child first so its WGC capture + NVENC session tear down, then close our
// handles (the reader threads end on the resulting broken pipe). // handles (the reader threads end on the resulting broken pipe).
@@ -364,10 +386,17 @@ fn au_reader(mut r: HandleReader, tx: SyncSender<RelayAu>) {
/// Minimal `Read` over a Win32 pipe HANDLE (the windows crate doesn't impl `Read` on HANDLE). /// Minimal `Read` over a Win32 pipe HANDLE (the windows crate doesn't impl `Read` on HANDLE).
struct HandleReader(HANDLE); struct HandleReader(HANDLE);
// SAFETY: `HandleReader` owns a single pipe `HANDLE` (a process-global kernel handle value, valid from
// any thread). It is moved into the dedicated reader thread and used only there (and closed once on
// Drop), never shared — so transferring ownership across threads is sound.
unsafe impl Send for HandleReader {} unsafe impl Send for HandleReader {}
impl Read for HandleReader { impl Read for HandleReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let mut read = 0u32; let mut read = 0u32;
// SAFETY: `self.0` is the live read end of an anonymous pipe owned by this `HandleReader`
// (closed only in Drop). `ReadFile` fills the caller-provided `buf` (writing at most `buf.len()`
// bytes) and stores the count in `read`; both outlive the synchronous call. A broken pipe
// surfaces as `Err` and is mapped to EOF below.
let ok = unsafe { let ok = unsafe {
windows::Win32::Storage::FileSystem::ReadFile(self.0, Some(buf), Some(&mut read), None) windows::Win32::Storage::FileSystem::ReadFile(self.0, Some(buf), Some(&mut read), None)
}; };
@@ -380,6 +409,8 @@ impl Read for HandleReader {
} }
impl Drop for HandleReader { impl Drop for HandleReader {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `self.0` is the pipe `HANDLE` this `HandleReader` owns; `CloseHandle` (an FFI call
// taking the handle by value) is invoked exactly once here in Drop, so there is no double-close.
unsafe { unsafe {
let _ = CloseHandle(self.0); let _ = CloseHandle(self.0);
} }
@@ -391,6 +422,13 @@ impl Drop for HandleReader {
pub fn running_as_system() -> bool { pub fn running_as_system() -> bool {
use windows::Win32::Security::{GetTokenInformation, TokenUser, TOKEN_QUERY, TOKEN_USER}; use windows::Win32::Security::{GetTokenInformation, TokenUser, TOKEN_QUERY, TOKEN_USER};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
// SAFETY: `OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token)` opens the current-process
// token (the pseudo-handle is always valid) into `token`, which is closed once before each return.
// The first `GetTokenInformation` (null buffer) queries the required `len`; `buf` is then a
// `Vec<u8>` of exactly `len` bytes and the second call fills it, so `&*(buf.as_ptr() as *const
// TOKEN_USER)` reads a `TOKEN_USER` the kernel just wrote into a sufficiently-sized buffer (the
// variable-length SID it points at also lies within `buf`, which outlives the borrow).
// `is_local_system_sid` is this module's `unsafe fn`, given that in-buffer `PSID`. Safe on any thread.
unsafe { unsafe {
let mut token = HANDLE::default(); let mut token = HANDLE::default();
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token).is_err() { if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token).is_err() {
+128
View File
@@ -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 12** (`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)
}
+47 -13
View File
@@ -3,6 +3,9 @@
//! RGB→YUV on the GPU, so no host-side CSC) and VAAPI on AMD/Intel (`*_vaapi`; the CPU-input //! RGB→YUV on the GPU, so no host-side CSC) and VAAPI on AMD/Intel (`*_vaapi`; the CPU-input
//! fallback swscales RGB→NV12, the zero-copy path imports the capture dmabuf straight into a //! fallback swscales RGB→NV12, the zero-copy path imports the capture dmabuf straight into a
//! VA surface). One [`Encoder`] trait, selected in [`open_video`]. //! VA surface). One [`Encoder`] trait, selected in [`open_video`].
// Every unsafe block in this module tree carries a `// SAFETY:` proof; enforce it (unsafe-proof
// program). As a parent module this also covers the child modules (encode::windows/linux::*).
#![deny(clippy::undocumented_unsafe_blocks)]
use crate::capture::{CapturedFrame, PixelFormat}; use crate::capture::{CapturedFrame, PixelFormat};
use anyhow::Result; use anyhow::Result;
@@ -71,9 +74,34 @@ impl Codec {
} }
} }
/// Static capabilities an [`Encoder`] declares so the session glue routes loss-recovery and HDR
/// plumbing by *query* rather than relying on a method's no-op/`false` default. Cheap `Copy`; fixed
/// for the session (an HDR toggle re-initialises the encoder — re-query if that matters).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct EncoderCaps {
/// The encoder can perform real reference-frame invalidation — i.e.
/// [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) can return `true`. When `false`
/// the caller skips that always-`false` call and forces a keyframe directly on loss recovery.
/// Only the Windows direct-NVENC path implements RFI; libavcodec (Linux NVENC), VAAPI and
/// AMF/QSV always keyframe.
pub supports_rfi: bool,
/// The encoder emits in-band HDR mastering/CLL SEI from [`set_hdr_meta`](Encoder::set_hdr_meta).
/// When `false`, `set_hdr_meta` is a no-op and no in-band grade reaches the client. Only the
/// Windows direct-NVENC path attaches it today.
pub supports_hdr_metadata: bool,
}
/// A hardware encoder. One per session; runs on the encode thread. /// A hardware encoder. One per session; runs on the encode thread.
pub trait Encoder: Send { pub trait Encoder: Send {
fn submit(&mut self, frame: &CapturedFrame) -> Result<()>; 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 /// Force the next submitted frame to be an IDR keyframe (e.g. after a client
/// reference-frame-invalidation request). Default: no-op. /// reference-frame-invalidation request). Default: no-op.
fn request_keyframe(&mut self) {} fn request_keyframe(&mut self) {}
@@ -173,14 +201,12 @@ pub fn open_video(
// AMD/Intel → VAAPI (one libavcodec backend for both). Auto-detect by default so a single // 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 // Linux binary serves any GPU; `PUNKTFUNK_ENCODER` forces a specific backend (and surfaces
// its errors crisply instead of silently trying the other). // its errors crisply instead of silently trying the other).
let pref = std::env::var("PUNKTFUNK_ENCODER") let pref = crate::config::config().encoder_pref.as_str();
.unwrap_or_default()
.to_ascii_lowercase();
let open_vaapi = || -> Result<Box<dyn Encoder>> { let open_vaapi = || -> Result<Box<dyn Encoder>> {
vaapi::VaapiEncoder::open(codec, format, width, height, fps, bitrate_bps, bit_depth) vaapi::VaapiEncoder::open(codec, format, width, height, fps, bitrate_bps, bit_depth)
.map(|e| Box::new(e) as Box<dyn Encoder>) .map(|e| Box::new(e) as Box<dyn Encoder>)
}; };
match pref.as_str() { match pref {
"nvenc" | "nvidia" | "cuda" => open_nvenc_probed( "nvenc" | "nvidia" | "cuda" => open_nvenc_probed(
codec, codec,
format, format,
@@ -379,11 +405,7 @@ fn nvidia_present() -> bool {
/// passthrough for VAAPI vs the EGL→CUDA import for NVENC). /// passthrough for VAAPI vs the EGL→CUDA import for NVENC).
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub fn linux_zero_copy_is_vaapi() -> bool { pub fn linux_zero_copy_is_vaapi() -> bool {
match std::env::var("PUNKTFUNK_ENCODER") match crate::config::config().encoder_pref.as_str() {
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"nvenc" | "nvidia" | "cuda" => false, "nvenc" | "nvidia" | "cuda" => false,
"vaapi" | "amd" | "intel" => true, "vaapi" | "amd" | "intel" => true,
_ => !nvidia_present(), _ => !nvidia_present(),
@@ -450,10 +472,8 @@ enum GpuVendor {
/// vendor). Shared by [`open_video`] and the GameStream codec advertisement so both agree. /// vendor). Shared by [`open_video`] and the GameStream codec advertisement so both agree.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub(crate) fn windows_resolved_backend() -> WindowsBackend { pub(crate) fn windows_resolved_backend() -> WindowsBackend {
let pref = std::env::var("PUNKTFUNK_ENCODER") // Resolved ONCE in HostConfig (Goal-1) — was re-read from PUNKTFUNK_ENCODER on every call.
.unwrap_or_default() match crate::config::config().encoder_pref.as_str() {
.to_ascii_lowercase();
match pref.as_str() {
"nvenc" | "hw" | "nvidia" | "cuda" => WindowsBackend::Nvenc, "nvenc" | "hw" | "nvidia" | "cuda" => WindowsBackend::Nvenc,
"amf" | "amd" => WindowsBackend::Amf, "amf" | "amd" => WindowsBackend::Amf,
"qsv" | "intel" => WindowsBackend::Qsv, "qsv" | "intel" => WindowsBackend::Qsv,
@@ -488,6 +508,14 @@ fn windows_gpu_vendor() -> Option<GpuVendor> {
CreateDXGIFactory1, IDXGIFactory1, DXGI_ADAPTER_FLAG_SOFTWARE, CreateDXGIFactory1, IDXGIFactory1, DXGI_ADAPTER_FLAG_SOFTWARE,
}; };
static CACHE: OnceLock<Option<GpuVendor>> = OnceLock::new(); static CACHE: OnceLock<Option<GpuVendor>> = OnceLock::new();
// SAFETY: `CreateDXGIFactory1` returns a fresh owned `IDXGIFactory1` COM object (refcounted by the
// windows-rs wrapper, Released when the local drops); `.ok()?` bails on failure so `factory` is a
// valid interface before any use. `EnumAdapters1(i)` hands back the i-th adapter as an owned
// `IDXGIAdapter1` (or an error past the last adapter, which ends the loop). `GetDesc1()` returns the
// `DXGI_ADAPTER_DESC1` by value (no out-pointer), so reading `desc.Flags`/`desc.VendorId` is plain
// field access. Every call only touches COM objects this closure owns; the `OnceLock` runs the
// closure once (no data race) and all interfaces are Released as the locals drop. No raw pointer is
// dereferenced and nothing is aliased.
*CACHE.get_or_init(|| unsafe { *CACHE.get_or_init(|| unsafe {
let factory: IDXGIFactory1 = CreateDXGIFactory1().ok()?; let factory: IDXGIFactory1 = CreateDXGIFactory1().ok()?;
let mut i = 0u32; let mut i = 0u32;
@@ -539,15 +567,21 @@ pub fn windows_codec_support() -> CodecSupport {
}) })
} }
// Goal-1 stage 6: GPU/CPU encoders confined to `encode/windows/` (NVENC, AMF/QSV ffmpeg, software) and
// `encode/linux/` (NVENC/CUDA + VAAPI); `#[path]` keeps the `crate::encode::*` module names flat.
#[cfg(all(target_os = "windows", feature = "amf-qsv"))] #[cfg(all(target_os = "windows", feature = "amf-qsv"))]
#[path = "encode/windows/ffmpeg_win.rs"]
mod ffmpeg_win; mod ffmpeg_win;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
mod linux; mod linux;
#[cfg(all(target_os = "windows", feature = "nvenc"))] #[cfg(all(target_os = "windows", feature = "nvenc"))]
#[path = "encode/windows/nvenc.rs"]
mod nvenc; mod nvenc;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "encode/windows/sw.rs"]
mod sw; mod sw;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "encode/linux/vaapi.rs"]
mod vaapi; mod vaapi;
#[cfg(test)] #[cfg(test)]
@@ -8,6 +8,8 @@
//! does *not* accept — we expand it to `rgb0` (one padding byte/pixel, no colour math). //! does *not* accept — we expand it to `rgb0` (one padding byte/pixel, no colour math).
//! The encoder is opened *without* a global header so VPS/SPS/PPS are emitted in-band on //! The encoder is opened *without* a global header so VPS/SPS/PPS are emitted in-band on
//! every IDR — the output is both a playable raw Annex-B stream and self-contained AUs. //! every IDR — the output is both a playable raw Annex-B stream and self-contained AUs.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{Codec, EncodedFrame, Encoder}; use super::{Codec, EncodedFrame, Encoder};
use crate::capture::{CapturedFrame, FramePayload, PixelFormat}; use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
@@ -79,6 +81,12 @@ impl CudaHw {
impl Drop for CudaHw { impl Drop for CudaHw {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `frames_ref`/`device_ref` are the two non-null `AVBufferRef`s `CudaHw::new` created
// (it bails before returning `Self` if either alloc fails, so a live `CudaHw` always holds
// both). `av_buffer_unref` drops one reference and nulls the pointer through the `&mut`. This
// `Drop` runs exactly once and `CudaHw` owns these refs exclusively → no double-free /
// use-after-free. Frames are unref'd before the device (the frames ctx internally refs the
// device; refcounted, so the order is sound regardless).
unsafe { unsafe {
ffi::av_buffer_unref(&mut self.frames_ref); ffi::av_buffer_unref(&mut self.frames_ref);
ffi::av_buffer_unref(&mut self.device_ref); ffi::av_buffer_unref(&mut self.device_ref);
@@ -136,6 +144,13 @@ pub struct NvencEncoder {
// `CudaHw` holds raw `AVBufferRef`s; the encoder lives on a single thread. The CPU encoder is // `CudaHw` holds raw `AVBufferRef`s; the encoder lives on a single thread. The CPU encoder is
// already `Send` via ffmpeg-next; assert it for the CUDA fields too. // already `Send` via ffmpeg-next; assert it for the CUDA fields too.
// SAFETY: `NvencEncoder` owns an ffmpeg-next `Encoder`/`VideoFrame` (already `Send`) plus a `CudaHw`
// holding raw `AVBufferRef`s, which are not `Send` by default. The encoder is owned and driven by
// exactly ONE thread — the per-session encode thread it is moved to — and is only touched through
// `&mut self` methods, so it is never aliased or accessed concurrently. The wrapped libav contexts
// (and the shared `CUcontext` the `CudaHw` references) have no thread affinity, so transferring
// ownership across threads is sound. This asserts `Send` (transfer) only, extending ffmpeg-next's
// existing `Send` to the raw CUDA fields; `Sync` (shared `&`) is deliberately NOT implemented.
unsafe impl Send for NvencEncoder {} unsafe impl Send for NvencEncoder {}
impl NvencEncoder { impl NvencEncoder {
@@ -162,6 +177,9 @@ impl NvencEncoder {
} }
ffmpeg::init().context("ffmpeg init")?; ffmpeg::init().context("ffmpeg init")?;
if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() { if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() {
// SAFETY: `av_log_set_level` sets libav's global integer log level; `48` (= AV_LOG_DEBUG)
// is a valid level with no pointer args, and libav was just initialized by `ffmpeg::init()`
// above — always sound.
unsafe { ffi::av_log_set_level(48) }; // AV_LOG_DEBUG — surface NVENC hw-frame rejects unsafe { ffi::av_log_set_level(48) }; // AV_LOG_DEBUG — surface NVENC hw-frame rejects
} }
let name = codec.nvenc_name(); let name = codec.nvenc_name();
@@ -195,6 +213,11 @@ impl NvencEncoder {
.unwrap_or(1.0); .unwrap_or(1.0);
let vbv_bits = ((bitrate_bps as f64 / fps.max(1) as f64) * vbv_frames as f64) let vbv_bits = ((bitrate_bps as f64 / fps.max(1) as f64) * vbv_frames as f64)
.clamp(1.0, i32::MAX as f64); .clamp(1.0, i32::MAX as f64);
// SAFETY: `video` is the ffmpeg-next encoder builder wrapping a freshly-allocated
// `AVCodecContext` that we hold by value and have not opened yet; `video.as_mut_ptr()` returns
// that non-null, properly-aligned, exclusively-owned context. Writing the plain `rc_buffer_size`
// int field before `open_with` is the supported way to set a field ffmpeg-next exposes no
// setter for. Sole owner → no aliasing; synchronous in-bounds scalar write.
unsafe { unsafe {
(*video.as_mut_ptr()).rc_buffer_size = vbv_bits as i32; (*video.as_mut_ptr()).rc_buffer_size = vbv_bits as i32;
} }
@@ -204,6 +227,9 @@ impl NvencEncoder {
// "freeze". NVENC emits one IDR at stream start, then P-frames only; `forced-idr` (below) // "freeze". NVENC emits one IDR at stream start, then P-frames only; `forced-idr` (below)
// turns a client recovery request (RFI, via `request_keyframe`) into an IDR on demand. // turns a client recovery request (RFI, via `request_keyframe`) into an IDR on demand.
// This is the Moonlight/Sunshine low-latency model. // This is the Moonlight/Sunshine low-latency model.
// SAFETY: same `video` builder as above — a non-null, properly-aligned, sole-owned, not-yet-
// opened `AVCodecContext`. We write the plain `gop_size` int field (= -1, infinite GOP) before
// `open_with`, which ffmpeg-next has no setter for. No aliasing; synchronous scalar write.
unsafe { unsafe {
(*video.as_mut_ptr()).gop_size = -1; (*video.as_mut_ptr()).gop_size = -1;
} }
@@ -214,6 +240,10 @@ impl NvencEncoder {
// RGB-input paths leave these unset (NVENC's internal CSC writes its own VUI). Matches the // RGB-input paths leave these unset (NVENC's internal CSC writes its own VUI). Matches the
// Windows NV12 path's BT.709 limited-range signalling. // Windows NV12 path's BT.709 limited-range signalling.
if matches!(format, PixelFormat::Nv12) { if matches!(format, PixelFormat::Nv12) {
// SAFETY: same `video` builder — `raw = video.as_mut_ptr()` is the non-null, properly-
// aligned, sole-owned, not-yet-opened `AVCodecContext`. We set its four VUI colour enum
// fields to valid `AVColorSpace`/`AVColorRange`/`AVColorPrimaries`/`AVColorTransfer-
// Characteristic` variants before `open_with`. Sole owner → no aliasing; synchronous writes.
unsafe { unsafe {
let raw = video.as_mut_ptr(); let raw = video.as_mut_ptr();
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709; (*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
@@ -228,7 +258,17 @@ impl NvencEncoder {
// *before* open (NVENC derives the device from `hw_frames_ctx`). // *before* open (NVENC derives the device from `hw_frames_ctx`).
let cuda_hw = if cuda { let cuda_hw = if cuda {
let cu_ctx = crate::zerocopy::cuda::context().context("shared CUDA context")?; let cu_ctx = crate::zerocopy::cuda::context().context("shared CUDA context")?;
// SAFETY: `CudaHw::new` (an `unsafe fn`) requires libav initialized (the `ffmpeg::init()`
// above ran) and a valid `CUcontext`; `cu_ctx` is the shared importer context from
// `zerocopy::cuda::context()?`, non-null on the `Ok` path. `nvenc_pixel` is a valid `Pixel`
// and `width`/`height` are the validated positive dims. It returns a RAII `CudaHw` wrapping
// (not owning) `cu_ctx` and owning two `AVBufferRef`s freed on drop.
let hw = unsafe { CudaHw::new(cu_ctx, nvenc_pixel, width, height)? }; let hw = unsafe { CudaHw::new(cu_ctx, nvenc_pixel, width, height)? };
// SAFETY: `raw = video.as_mut_ptr()` is the non-null, sole-owned, not-yet-opened
// `AVCodecContext`. We set `pix_fmt = CUDA` and attach NEW refs (`av_buffer_ref`) of
// `hw.device_ref`/`hw.frames_ref` — both non-null (`CudaHw::new` guarantees) and from the
// live `hw`, which is moved into `NvencEncoder.cuda` next to `enc` and so outlives the
// encoder. The context owns its own refs (freed when the context closes). No aliasing.
unsafe { unsafe {
let raw = video.as_mut_ptr(); let raw = video.as_mut_ptr();
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_CUDA; (*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_CUDA;
@@ -428,6 +468,19 @@ impl NvencEncoder {
// The device→device copy below uses our shared context directly; make it current on the // The device→device copy below uses our shared context directly; make it current on the
// encode thread (ffmpeg pushes its own around the pool alloc, so order is fine). // encode thread (ffmpeg pushes its own around the pool alloc, so order is fine).
crate::zerocopy::cuda::make_current().context("CUDA context current (encode thread)")?; crate::zerocopy::cuda::make_current().context("CUDA context current (encode thread)")?;
// SAFETY: `frames_ref` is the non-null CUDA frames ctx from `self.cuda` (unwrapped via
// `.context(..)?` above), and the shared CUDA context was just made current on THIS thread
// (`make_current()?`), the precondition for the device-pointer copies below.
// * `av_frame_alloc` → `f` (null-checked). `av_hwframe_get_buffer(frames_ref, f, 0)` fills `f`
// with a pooled CUDA surface (sets `data[]`/`linesize[]`/`buf[0]`/`hw_frames_ctx`); on
// failure we free `f` and bail.
// * For NV12 we read `(*f).data[0..2]` / `linesize[0..2]` (Y + interleaved UV), else
// `data[0]`/`linesize[0]` — in-struct fields of the non-null `f`, valid for the surface dims
// ffmpeg allocated — and pass them to the cuda copy helpers, which device→device copy `buf`
// (the imported `DeviceBuffer`, owned by the caller and live for this call) into the surface.
// * On copy error we free `f` and return. Otherwise we write `pts`/`pict_type` through `f` and
// `avcodec_send_frame` it into the live owned `self.enc` context (which takes its own ref of
// the pooled surface), then free our `f` ref exactly once. Single-threaded encoder → no race.
unsafe { unsafe {
let mut f = ffi::av_frame_alloc(); let mut f = ffi::av_frame_alloc();
if f.is_null() { if f.is_null() {
@@ -19,6 +19,8 @@
//! hwdevice/hwframes/buffersrc/buffersink calls go through `ffmpeg::ffi` (= `ffmpeg_sys_next`), //! hwdevice/hwframes/buffersrc/buffersink calls go through `ffmpeg::ffi` (= `ffmpeg_sys_next`),
//! as the CUDA encode path and the clients' decode paths already do. The encoder is opened //! as the CUDA encode path and the clients' decode paths already do. The encoder is opened
//! *without* a global header, so VPS/SPS/PPS are in-band on every IDR. //! *without* a global header, so VPS/SPS/PPS are in-band on every IDR.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{Codec, EncodedFrame, Encoder}; use super::{Codec, EncodedFrame, Encoder};
use crate::capture::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat}; use crate::capture::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat};
@@ -133,6 +135,14 @@ pub fn probe_can_encode(codec: Codec) -> bool {
if ffmpeg::init().is_err() { if ffmpeg::init().is_err() {
return false; return false;
} }
// SAFETY: `ffmpeg::init()` returned Ok above, so libav is initialized. `av_log_get_level`/
// `av_log_set_level` only read/write libav's global integer log level (no pointer args) and are
// always sound to call post-init. `VaapiHw::new` (an `unsafe fn`) builds a VAAPI device + NV12
// frames pool from the literal NV12/640x480/pool=2 args and hands back a RAII handle that unrefs
// both `AVBufferRef`s on drop. `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/
// `hw.frames_ref` — the two non-null refs `VaapiHw::new` just created — and `av_buffer_ref`s them
// into the encoder; `hw` is a live local for the whole match arm, so the borrows outlive the
// synchronous call, and both `hw` and the probe encoder are dropped (RAII) when the arm ends.
unsafe { unsafe {
// A missing VA device (non-VAAPI host, GPU-less CI) is an expected probe outcome — quiet // A missing VA device (non-VAAPI host, GPU-less CI) is an expected probe outcome — quiet
// ffmpeg's "No VA display found" error for the probe, then restore the level. // ffmpeg's "No VA display found" error for the probe, then restore the level.
@@ -224,6 +234,12 @@ impl VaapiHw {
impl Drop for VaapiHw { impl Drop for VaapiHw {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `frames_ref`/`device_ref` are the two non-null `AVBufferRef`s `VaapiHw::new`
// created (it bails before constructing `Self` if either alloc fails, so a live `VaapiHw`
// always holds both). `av_buffer_unref` drops one reference and nulls the pointer through the
// `&mut`. This `Drop` runs exactly once and `VaapiHw` owns these refs exclusively, so there
// is no double-free / use-after-free. Frames are unref'd before the device because the frames
// ctx internally holds a ref on the device (refcounted, so the order is sound either way).
unsafe { unsafe {
ffi::av_buffer_unref(&mut self.frames_ref); ffi::av_buffer_unref(&mut self.frames_ref);
ffi::av_buffer_unref(&mut self.device_ref); ffi::av_buffer_unref(&mut self.device_ref);
@@ -252,7 +268,16 @@ impl CpuInner {
) -> Result<Self> { ) -> Result<Self> {
let src_pixel = vaapi_sws_src(format)?; let src_pixel = vaapi_sws_src(format)?;
const POOL: c_int = 16; const POOL: c_int = 16;
// SAFETY: `VaapiHw::new` (an `unsafe fn`) requires libav initialized — guaranteed because the
// only path here is `VaapiEncoder::open` → `ensure_inner` → `CpuInner::open`, and `open` ran
// `ffmpeg::init()`. The args are valid: NV12 sw_format, the validated positive `width`/`height`,
// pool=16. It returns a RAII `VaapiHw` that unrefs its two `AVBufferRef`s on drop.
let hw = unsafe { VaapiHw::new(ffi::AVPixelFormat::AV_PIX_FMT_NV12, width, height, POOL)? }; let hw = unsafe { VaapiHw::new(ffi::AVPixelFormat::AV_PIX_FMT_NV12, width, height, POOL)? };
// SAFETY: `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/`hw.frames_ref` — both
// non-null (`VaapiHw::new` guarantees it) and from the `hw` just built above, which is a live
// local that outlives this synchronous call. The fn `av_buffer_ref`s them into the encoder, so
// the encoder holds its own references; `hw` is also moved into the returned `CpuInner` next to
// `enc`, keeping the device/frames alive for the encoder's whole lifetime.
let enc = unsafe { let enc = unsafe {
open_vaapi_encoder( open_vaapi_encoder(
codec, codec,
@@ -266,6 +291,12 @@ impl CpuInner {
}; };
// swscale RGB→NV12, BT.709 limited (matches the VUI), no rescale. // swscale RGB→NV12, BT.709 limited (matches the VUI), no rescale.
let src_av = pixel_to_av(src_pixel); let src_av = pixel_to_av(src_pixel);
// SAFETY: `sws_getContext` allocates a swscale context for the given src/dst dimensions and
// pixel formats. All four dims are the encoder's positive `width`/`height` cast to `c_int`;
// `src_av` is a valid `AVPixelFormat` (from `pixel_to_av` of the `vaapi_sws_src`-validated
// `src_pixel`), the dst is NV12. The three trailing pointers (srcFilter, dstFilter, param) are
// explicitly null = "use defaults", which the API documents as accepted. No Rust memory is
// borrowed — only by-value ints/enums — and the returned pointer is null-checked just below.
let sws = unsafe { let sws = unsafe {
ffi::sws_getContext( ffi::sws_getContext(
width as c_int, width as c_int,
@@ -283,10 +314,23 @@ impl CpuInner {
if sws.is_null() { if sws.is_null() {
bail!("sws_getContext(RGB→NV12) failed"); bail!("sws_getContext(RGB→NV12) failed");
} }
// SAFETY: `sws` is the non-null `SwsContext` from `sws_getContext` above (the `is_null()`
// check immediately preceding returned false). `sws_getCoefficients(SWS_CS_ITU709)` returns a
// pointer into a libswscale static const coefficient table valid for the whole process, reused
// here for both the inverse (src) and forward (dst) matrices. `sws_setColorspaceDetails` only
// reads those tables and writes scalar CSC settings into `sws`; the table pointer outlives the
// synchronous call and no Rust memory is passed.
unsafe { unsafe {
let cs709 = ffi::sws_getCoefficients(SWS_CS_ITU709); let cs709 = ffi::sws_getCoefficients(SWS_CS_ITU709);
ffi::sws_setColorspaceDetails(sws, cs709, 1, cs709, 0, 0, 1 << 16, 1 << 16); ffi::sws_setColorspaceDetails(sws, cs709, 1, cs709, 0, 0, 1 << 16, 1 << 16);
} }
// SAFETY: `av_frame_alloc` returns a fresh, uniquely-owned heap `AVFrame` (null-checked — on
// null we free the already-built `sws` and bail). We then write the plain `format`/`width`/
// `height` fields through the non-null, properly-aligned `f` (sole owner, not yet shared).
// `av_frame_get_buffer(f, 0)` allocates backing storage for those dims/format; on failure we
// free `f` and `sws` (unwinding the half-built state) and bail. On success `f` is a fully-owned
// NV12 frame stored in `CpuInner.nv12` and freed once in `CpuInner::drop`. `f` is a unique
// fresh pointer, so none of these writes alias anything.
let nv12 = unsafe { let nv12 = unsafe {
let f = ffi::av_frame_alloc(); let f = ffi::av_frame_alloc();
if f.is_null() { if f.is_null() {
@@ -329,6 +373,18 @@ impl CpuInner {
let h = self.height as usize; let h = self.height as usize;
let src_row = w * self.src_format.bytes_per_pixel(); let src_row = w * self.src_format.bytes_per_pixel();
anyhow::ensure!(bytes.len() >= src_row * h, "captured buffer too small"); anyhow::ensure!(bytes.len() >= src_row * h, "captured buffer too small");
// SAFETY: The `ensure!`s above guarantee `format == self.src_format` and
// `bytes.len() >= src_row * h`. `sws_scale` reads `h` rows of `src_row` bytes from
// `src_data[0] = bytes.as_ptr()` (the other planes null/0 — packed RGB is single-plane), all
// in bounds; `bytes`, `src_data`, `src_stride` are live locals for this synchronous call.
// `self.sws` is the non-null context built in `open`; it writes into `self.nv12` (a non-null
// owned frame whose `data`/`linesize` in-struct arrays were sized by `av_frame_get_buffer`).
// `av_frame_alloc` (null-checked) yields a fresh `hwf`; `av_hwframe_get_buffer` pulls a pooled
// VAAPI surface from the live non-null `self.hw.frames_ref`; `av_hwframe_transfer_data` uploads
// the staged NV12 into it — both frames live, failures free `hwf` and bail. We then write
// `pts`/`pict_type` through the non-null `hwf` and `avcodec_send_frame` it into the live
// owned `self.enc` context (which takes its own ref), then free our `hwf` ref exactly once.
// The encoder runs only on this thread (see `unsafe impl Send`), so no aliasing/data race.
unsafe { unsafe {
let src_data: [*const u8; 4] = [bytes.as_ptr(), ptr::null(), ptr::null(), ptr::null()]; let src_data: [*const u8; 4] = [bytes.as_ptr(), ptr::null(), ptr::null(), ptr::null()];
let src_stride: [c_int; 4] = [src_row as c_int, 0, 0, 0]; let src_stride: [c_int; 4] = [src_row as c_int, 0, 0, 0];
@@ -374,6 +430,12 @@ impl CpuInner {
impl Drop for CpuInner { impl Drop for CpuInner {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `self.nv12` (an owned `AVFrame`) and `self.sws` (an owned `SwsContext`) are each
// freed exactly once here, guarded by `is_null()` so a never-set pointer is skipped (no double
// free). `CpuInner` owns both exclusively and `Drop` runs once. `av_frame_free` takes `&mut`
// and nulls the pointer. `self.enc`/`self.hw` are freed afterward by their own `Drop` impls;
// the encoder holds its own `av_buffer_ref`'d device/frames copies, so field-drop order is
// irrelevant to soundness.
unsafe { unsafe {
if !self.nv12.is_null() { if !self.nv12.is_null() {
ffi::av_frame_free(&mut self.nv12); ffi::av_frame_free(&mut self.nv12);
@@ -417,6 +479,31 @@ impl DmabufInner {
let drm_fourcc = crate::zerocopy::drm_fourcc(format) let drm_fourcc = crate::zerocopy::drm_fourcc(format)
.ok_or_else(|| anyhow!("no DRM fourcc for {format:?} (VAAPI zero-copy)"))?; .ok_or_else(|| anyhow!("no DRM fourcc for {format:?} (VAAPI zero-copy)"))?;
let node = render_node(); let node = render_node();
// SAFETY: libav is initialized (`VaapiEncoder::open` ran `ffmpeg::init()` before
// `ensure_inner` → `DmabufInner::open`). Every raw pointer dereferenced below is either freshly
// allocated by the immediately-preceding ffmpeg call and null-checked, or an in-struct field of
// such an object:
// * `node` is a `CString` (from `render_node`) live for the whole block; its `.as_ptr()` is a
// NUL-terminated path read only during `av_hwdevice_ctx_create`.
// * `av_hwdevice_ctx_create(&mut drm_device, DRM, …)` / `…_create_derived(&mut vaapi_device,
// VAAPI, drm_device, …)`: on `r < 0` the out-param stays null and we bail (the derive path
// unrefs `drm_device` first); on success each is a non-null owned `AVBufferRef`.
// * `av_hwframe_ctx_alloc(drm_device)` → `drm_frames` (null-checked); `(*drm_frames).data` is
// its `AVHWFramesContext` payload, written before `av_hwframe_ctx_init`.
// * `avfilter_graph_alloc` → `graph` (null-checked); `avfilter_get_by_name` returns a static
// const `AVFilter` (process-lifetime) or null; `avfilter_graph_alloc_filter` allocates each
// filter ctx inside `graph`; the four are null-checked together. `inst`/arg strings are
// 'static C literals.
// * `(*hwmap/scale).hw_device_ctx = av_buffer_ref(vaapi_device)` attaches a NEW ref owned by
// the filter (freed by `avfilter_graph_free`); our `vaapi_device` ref is untouched.
// * `av_buffersink_get_hw_frames_ctx(sink)` → `nv12_ctx` is a borrowed ref owned by the sink,
// valid while `graph` lives (and `graph` is moved into the returned `DmabufInner`).
// * `open_vaapi_encoder` borrows `vaapi_device` (our live owned ref) and `nv12_ctx` (sink's
// live ref) and `av_buffer_ref`s both into the encoder.
// Every early-error path unref's the allocated buffers and frees the graph in the right order
// before bailing; on success the four `AVBufferRef`s + `graph` + `src`/`sink` are moved into
// `DmabufInner` and freed in its `Drop`. (Two non-UB leaks noted below: `av_buffersrc_*` and
// the final `?`.)
unsafe { unsafe {
// DRM device (source dmabuf frames) + a VAAPI device derived from it (same GPU) for // DRM device (source dmabuf frames) + a VAAPI device derived from it (same GPU) for
// hwmap/scale_vaapi/the encoder. // hwmap/scale_vaapi/the encoder.
@@ -509,7 +596,12 @@ impl DmabufInner {
num: 1, num: 1,
den: fps as c_int, den: fps as c_int,
}; };
(*par).hw_frames_ctx = ffi::av_buffer_ref(drm_frames); // Assign `drm_frames` BORROWED (no extra ref): `av_buffersrc_parameters_set` takes its
// own ref of `par->hw_frames_ctx` (via av_buffer_replace), and `av_free(par)` frees only
// the struct, not the ref. Our single owned `drm_frames` ref is retained, lives in
// `DmabufInner`, and is unref'd in `Drop`. Wrapping it in `av_buffer_ref` here would leak
// that extra ref every session (the persistent listener would accumulate them).
(*par).hw_frames_ctx = drm_frames;
let r = ffi::av_buffersrc_parameters_set(src, par); let r = ffi::av_buffersrc_parameters_set(src, par);
ffi::av_free(par as *mut _); ffi::av_free(par as *mut _);
if r < 0 { if r < 0 {
@@ -564,7 +656,12 @@ impl DmabufInner {
ffi::av_buffer_unref(&mut drm_device); ffi::av_buffer_unref(&mut drm_device);
bail!("filter sink has no VAAPI frames context"); bail!("filter sink has no VAAPI frames context");
} }
let enc = open_vaapi_encoder( // On encoder-open failure, free the graph + our owned buffer refs before bailing (matching
// every error path above) so a failed session doesn't leak them. `nv12_ctx` is borrowed
// from the sink (owned by `graph`), so `avfilter_graph_free` reclaims it — don't unref it
// separately. On success the encoder takes its own ref of `vaapi_device`, and `drm_frames`/
// `vaapi_device`/`drm_device`/`graph` move into `DmabufInner` (freed in `Drop`).
let enc = match open_vaapi_encoder(
codec, codec,
width, width,
height, height,
@@ -572,7 +669,16 @@ impl DmabufInner {
bitrate_bps, bitrate_bps,
vaapi_device, vaapi_device,
nv12_ctx, nv12_ctx,
)?; ) {
Ok(enc) => enc,
Err(e) => {
ffi::avfilter_graph_free(&mut graph);
ffi::av_buffer_unref(&mut drm_frames);
ffi::av_buffer_unref(&mut vaapi_device);
ffi::av_buffer_unref(&mut drm_device);
return Err(e);
}
};
tracing::info!( tracing::info!(
encoder = codec.vaapi_name(), encoder = codec.vaapi_name(),
@@ -600,6 +706,23 @@ impl DmabufInner {
dmabuf.fourcc, dmabuf.fourcc,
self.fourcc self.fourcc
); );
// SAFETY: The `ensure!` above checked `dmabuf.fourcc == self.fourcc`.
// * `std::mem::zeroed::<AVDRMFrameDescriptor>()` is sound: it is a `#[repr(C)]` POD of ints and
// nested int-struct arrays (no `NonNull`/refs), for which all-zero is a valid bit pattern;
// `Box` puts it on the heap with a unique owner.
// * `dmabuf.fd.as_raw_fd()` is the fd of the caller's `&DmabufFrame`, which owns it for the
// whole synchronous `submit`; we describe one object/layer/plane from its
// fourcc/modifier/offset/stride and pass `object.size = 0` (ffmpeg queries the real size).
// * `av_frame_alloc` → `drm` (null-checked); we set its scalar fields and
// `hw_frames_ctx = av_buffer_ref(self.drm_frames)` (new ref of the live owned ctx).
// * `data[0] = Box::into_raw(desc)` transfers the box into the frame; `buf[0] =
// av_buffer_create(.., free_desc, ..)` registers a destructor that reclaims it exactly once
// when the buffer's refcount hits zero — matched alloc/free, no leak/double-free.
// * `av_buffersrc_add_frame_flags(self.src, drm, KEEP_REF)` pushes a ref into the live
// buffersrc; KEEP_REF keeps our own `drm` ref, which we then `av_frame_free`. We pull the
// converted surface with `av_buffersink_get_frame(self.sink, nv12)` BEFORE returning, so the
// dmabuf (owned by the caller) is read while still valid. `nv12` is sent into the live owned
// `self.enc` (takes its own ref) and our ref freed once. Single-threaded encoder → no race.
unsafe { unsafe {
// Build a DRM-PRIME AVFrame describing the dmabuf (one object/fd, one layer/plane). // Build a DRM-PRIME AVFrame describing the dmabuf (one object/fd, one layer/plane).
let mut desc: Box<ffi::AVDRMFrameDescriptor> = Box::new(std::mem::zeroed()); let mut desc: Box<ffi::AVDRMFrameDescriptor> = Box::new(std::mem::zeroed());
@@ -626,6 +749,11 @@ impl DmabufInner {
// Own the descriptor so it frees with the frame (the fd is owned by the DmabufFrame, // Own the descriptor so it frees with the frame (the fd is owned by the DmabufFrame,
// which outlives this call — the graph reads the surface before submit returns). // which outlives this call — the graph reads the surface before submit returns).
extern "C" fn free_desc(_opaque: *mut std::ffi::c_void, data: *mut u8) { extern "C" fn free_desc(_opaque: *mut std::ffi::c_void, data: *mut u8) {
// SAFETY: `data` is exactly the pointer produced by `Box::into_raw(desc)` and passed as
// `av_buffer_create`'s first arg, which libav hands back verbatim to this callback. It
// is a valid, uniquely-owned `Box<AVDRMFrameDescriptor>` raw pointer; libav invokes the
// callback exactly once (when the last buffer ref drops), so `from_raw` + `drop`
// reclaims it exactly once — no double-free. `_opaque` is unused (we passed null).
unsafe { drop(Box::from_raw(data as *mut ffi::AVDRMFrameDescriptor)) }; unsafe { drop(Box::from_raw(data as *mut ffi::AVDRMFrameDescriptor)) };
} }
(*drm).buf[0] = ffi::av_buffer_create( (*drm).buf[0] = ffi::av_buffer_create(
@@ -673,6 +801,13 @@ impl DmabufInner {
impl Drop for DmabufInner { impl Drop for DmabufInner {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `graph`/`drm_frames`/`vaapi_device`/`drm_device` are the non-null objects
// `DmabufInner::open` built and moved into `self` (open bails before constructing `Self` if any
// alloc fails). `avfilter_graph_free` frees the graph (and the per-filter device refs it owns);
// each `av_buffer_unref` drops one ref and nulls the pointer via `&mut`. `DmabufInner` owns all
// four exclusively and `Drop` runs once → no double-free/use-after-free. The graph is freed
// first (it holds refs on the devices), then frames, then the derived VAAPI device, then DRM.
// (`self.enc` drops via ffmpeg-next afterward, holding its own refs.)
unsafe { unsafe {
ffi::avfilter_graph_free(&mut self.graph); ffi::avfilter_graph_free(&mut self.graph);
ffi::av_buffer_unref(&mut self.drm_frames); ffi::av_buffer_unref(&mut self.drm_frames);
@@ -703,6 +838,13 @@ pub struct VaapiEncoder {
} }
// Raw FFI pointers; the encoder lives on a single thread (same contract as `NvencEncoder`). // Raw FFI pointers; the encoder lives on a single thread (same contract as `NvencEncoder`).
// SAFETY: `VaapiEncoder`'s `Inner` holds raw FFI pointers (`SwsContext`, `AVFrame`, `AVBufferRef`,
// `AVFilterContext`, `AVCodecContext`) that are not `Send` by default. The encoder is owned and
// driven by exactly ONE thread — the host's per-session encode thread it is moved (transferred) to —
// and is only ever touched through `&mut self` methods, so it is never aliased or accessed
// concurrently from two threads. None of the underlying libav/libswscale objects have thread
// affinity (they are not thread-local), so transferring ownership across threads is sound. This
// asserts `Send` (transfer) only; `Sync` (shared `&`) is deliberately NOT implemented.
unsafe impl Send for VaapiEncoder {} unsafe impl Send for VaapiEncoder {}
impl VaapiEncoder { impl VaapiEncoder {
@@ -720,6 +862,9 @@ impl VaapiEncoder {
} }
ffmpeg::init().context("ffmpeg init")?; ffmpeg::init().context("ffmpeg init")?;
if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() { if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() {
// SAFETY: `av_log_set_level` sets libav's global integer log level; `48` (= AV_LOG_DEBUG)
// is a valid level and there are no pointer args. libav was just initialized by the
// `ffmpeg::init()` above, so the call is always sound.
unsafe { ffi::av_log_set_level(48) }; unsafe { ffi::av_log_set_level(48) };
} }
// Validate the codec/format up front so a bad request fails at open, not on the first frame. // Validate the codec/format up front so a bad request fails at open, not on the first frame.
@@ -28,6 +28,8 @@
//! through `ffmpeg::ffi` (= `ffmpeg_sys_next`), exactly as the Linux CUDA/VAAPI paths do. The //! through `ffmpeg::ffi` (= `ffmpeg_sys_next`), exactly as the Linux CUDA/VAAPI paths do. The
//! `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext` layouts are mirrored (the bindings don't //! `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext` layouts are mirrored (the bindings don't
//! allowlist `hwcontext_d3d11va.h`), as [`super::linux`] mirrors `AVCUDADeviceContext`. //! allowlist `hwcontext_d3d11va.h`), as [`super::linux`] mirrors `AVCUDADeviceContext`.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{Codec, EncodedFrame, Encoder}; use super::{Codec, EncodedFrame, Encoder};
use crate::capture::{dxgi::D3d11Frame, CapturedFrame, FramePayload, PixelFormat}; use crate::capture::{dxgi::D3d11Frame, CapturedFrame, FramePayload, PixelFormat};
@@ -109,7 +111,7 @@ impl WinVendor {
/// Is the zero-copy D3D11 path enabled? Opt-in (`PUNKTFUNK_ZEROCOPY=1`) until on-glass validated; /// 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. /// the default is the robust system-memory readback path.
fn zerocopy_enabled() -> bool { 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). /// The swscale *source* pixel format for a captured packed-RGB/BGR layout (8-bit BGRA fallback only).
@@ -243,6 +245,12 @@ pub fn probe_can_encode(vendor: WinVendor, codec: Codec) -> bool {
if ffmpeg::init().is_err() { if ffmpeg::init().is_err() {
return false; return false;
} }
// SAFETY: `ffmpeg::init()` succeeded above, so libav's global state is initialised.
// `av_log_get_level`/`av_log_set_level` are global scalar getters/setters with no pointer args.
// `open_win_encoder` (the `unsafe fn`) is called with null `device_ref`/`frames_ref` (the system
// path), so it touches no D3D11/hwcontext — it only allocates and opens a self-contained
// libavcodec encoder that is dropped at the end of `.is_ok()`. We restore the prior log level and
// no raw pointer escapes the block.
unsafe { unsafe {
// A missing AMF/QSV runtime (wrong-vendor host, GPU-less CI) is an expected probe outcome — // A missing AMF/QSV runtime (wrong-vendor host, GPU-less CI) is an expected probe outcome —
// quiet ffmpeg's open error for the probe, then restore the level. // quiet ffmpeg's open error for the probe, then restore the level.
@@ -337,6 +345,10 @@ impl SystemInner {
} else { } else {
ffi::AVPixelFormat::AV_PIX_FMT_NV12 ffi::AVPixelFormat::AV_PIX_FMT_NV12
}; };
// SAFETY: calls the `unsafe fn open_win_encoder` with null `device_ref`/`frames_ref`, so the
// system path is taken (no hw device/frames context is touched); all other args are scalars.
// The returned `encoder::video::Encoder` owns its `AVCodecContext` and frees it on drop; no raw
// pointer is aliased.
let enc = unsafe { let enc = unsafe {
open_win_encoder( open_win_encoder(
vendor, vendor,
@@ -352,6 +364,11 @@ impl SystemInner {
ptr::null_mut(), ptr::null_mut(),
)? )?
}; };
// SAFETY: `av_frame_alloc` returns a freshly-allocated, uniquely-owned `AVFrame` (null-checked
// before any deref); writing `format`/`width`/`height` through `*f` stays inside that
// allocation. `av_frame_get_buffer(f, 0)` allocates the backing planes — on failure we
// `av_frame_free` the sole owner (no double-free) and bail; on success the raw `f` is moved into
// `self.sw_frame` and freed exactly once in `Drop`.
let sw_frame = unsafe { let sw_frame = unsafe {
let f = ffi::av_frame_alloc(); let f = ffi::av_frame_alloc();
if f.is_null() { if f.is_null() {
@@ -467,6 +484,18 @@ impl SystemInner {
} else { } else {
DXGI_FORMAT_NV12 DXGI_FORMAT_NV12
}; };
// SAFETY: `ensure_staging` builds a STAGING texture (CPU_ACCESS_READ) matching `dxgi_fmt` on
// `frame.device` — the same `ID3D11Device` that owns `frame.texture` — and caches that device's
// immediate context in `self.ctx`. `src`/`dst` are that device's textures of identical NV12/P010
// format and dimensions, so `CopyResource` on the single-threaded immediate context is valid.
// `Map(.., D3D11_MAP_READ)` succeeds on a staging texture and yields `map.pData` valid for the
// whole resource; for NV12/P010 the luma plane is `H` rows at `RowPitch` and the chroma plane
// follows at byte offset `RowPitch*H` (`H/2` rows), so `total = pitch*(H+⌈H/2⌉)` is exactly the
// mapped extent and `from_raw_parts(base, total)` stays in-bounds. Each `copy_nonoverlapping`
// reads a bounds-checked `mapped[..]` sub-slice (`row_bytes ≤ pitch`) and writes `row_bytes ≤
// linesize` into the `av_frame_get_buffer`-allocated plane at row `y < H`, so every destination
// offset is inside the frame's plane allocation; src and dst never alias. `Unmap` pairs `Map`,
// then `send` (the `unsafe fn`) hands `sw_frame` to the encoder.
unsafe { unsafe {
self.ensure_staging(&frame.device, dxgi_fmt)?; self.ensure_staging(&frame.device, dxgi_fmt)?;
let staging = self.staging.clone().context("staging texture")?; let staging = self.staging.clone().context("staging texture")?;
@@ -510,6 +539,14 @@ impl SystemInner {
if self.ten_bit { if self.ten_bit {
bail!("ffmpeg_win: BGRA readback is 8-bit only (HDR needs the P010 capture path)"); bail!("ffmpeg_win: BGRA readback is 8-bit only (HDR needs the P010 capture path)");
} }
// SAFETY: `ensure_staging` builds a B8G8R8A8 STAGING texture on `frame.device` and caches that
// device's immediate context; `src`/`dst` are that device's textures of matching BGRA format,
// so `CopyResource` on the single-threaded context is valid. `Map(READ)` on the staging texture
// yields `base` valid for `pitch` × `h` rows. `ensure_sws` lazily builds the BGRA→NV12 context;
// `sws_scale` reads `h` rows of `pitch` bytes from `base` (in-bounds — the staging surface is
// `≥ pitch*h`) into the `sw_frame` planes addressed by its `data`/`linesize` (allocated for
// `width`×`height` NV12). `Unmap` pairs `Map`; the cached `sws` is freed once in `Drop`. The
// mapped read region never aliases the owned encoder frame.
unsafe { unsafe {
self.ensure_staging(&frame.device, DXGI_FORMAT_B8G8R8A8_UNORM)?; self.ensure_staging(&frame.device, DXGI_FORMAT_B8G8R8A8_UNORM)?;
let staging = self.staging.clone().context("staging texture")?; let staging = self.staging.clone().context("staging texture")?;
@@ -552,6 +589,13 @@ impl SystemInner {
/// R10 shader output instead of P010. DXGI `R10G10B10A2_UNORM` (R in the low 10 bits, X2 alpha in /// R10 shader output instead of P010. DXGI `R10G10B10A2_UNORM` (R in the low 10 bits, X2 alpha in
/// the top 2) == FFmpeg `AV_PIX_FMT_X2BGR10LE`. UNTESTED on glass (no AMD/Intel Windows box). /// the top 2) == FFmpeg `AV_PIX_FMT_X2BGR10LE`. UNTESTED on glass (no AMD/Intel Windows box).
fn readback_rgb10(&mut self, frame: &D3d11Frame, pts: i64, idr: bool) -> Result<()> { fn readback_rgb10(&mut self, frame: &D3d11Frame, pts: i64, idr: bool) -> Result<()> {
// SAFETY: same shape as `readback_yuv`/`readback_bgra` — `ensure_staging` builds an
// R10G10B10A2 STAGING texture on `frame.device` and caches its immediate context; `src`/`dst`
// are that device's matching-format textures, so `CopyResource` on the single-threaded context
// is valid. `Map(READ)` yields `base` valid for `pitch` × `h` rows. `ensure_sws` builds the
// X2BGR10LE→P010 (BT.2020) context; `sws_scale` reads `h` rows of `pitch` bytes from `base`
// (in-bounds) into the `sw_frame` P010 planes (`data`/`linesize`, allocated `width`×`height`).
// `Unmap` pairs `Map`; `sws` is freed once in `Drop`. No aliasing between read and write.
unsafe { unsafe {
self.ensure_staging(&frame.device, DXGI_FORMAT_R10G10B10A2_UNORM)?; self.ensure_staging(&frame.device, DXGI_FORMAT_R10G10B10A2_UNORM)?;
let staging = self.staging.clone().context("staging texture")?; let staging = self.staging.clone().context("staging texture")?;
@@ -605,6 +649,12 @@ impl SystemInner {
let h = self.height as usize; let h = self.height as usize;
let src_row = w * format.bytes_per_pixel(); let src_row = w * format.bytes_per_pixel();
anyhow::ensure!(bytes.len() >= src_row * h, "captured buffer too small"); anyhow::ensure!(bytes.len() >= src_row * h, "captured buffer too small");
// SAFETY: `ensure_sws` lazily builds the (packed RGB/BGR)→NV12 context for this fixed src/dst
// format pair. `src_data[0] = bytes.as_ptr()` with `src_stride[0] = src_row`; the `ensure!`
// above guarantees `bytes` holds at least `src_row*h` bytes, so `sws_scale` reads `h` rows of
// `src_row` bytes in-bounds and writes the `sw_frame` NV12 planes (`data`/`linesize`, allocated
// `width`×`height`). `bytes` is borrowed for the call only and never aliases the owned
// `sw_frame`. `send` then hands `sw_frame` to the encoder.
unsafe { unsafe {
self.ensure_sws( self.ensure_sws(
pixel_to_av(sws_src(format)?), pixel_to_av(sws_src(format)?),
@@ -667,6 +717,10 @@ impl SystemInner {
impl Drop for SystemInner { impl Drop for SystemInner {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `sw_frame` is the `AVFrame` allocated in `open` (or null) — `av_frame_free` drops it
// once and nulls the pointer through the `&mut`; `sws` is the cached `SwsContext` (or null) —
// `sws_freeContext` frees it once. This `Drop` runs exactly once and `SystemInner` owns both
// exclusively, so there is no double-free or use-after-free.
unsafe { unsafe {
if !self.sw_frame.is_null() { if !self.sw_frame.is_null() {
ffi::av_frame_free(&mut self.sw_frame); ffi::av_frame_free(&mut self.sw_frame);
@@ -745,6 +799,12 @@ impl D3d11Hw {
impl Drop for D3d11Hw { impl Drop for D3d11Hw {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `frames_ref`/`device_ref` are the two non-null `AVBufferRef`s `D3d11Hw::new` created
// (it bails before constructing `Self` if either alloc/init fails, so a live `D3d11Hw` always
// holds both). `av_buffer_unref` drops one reference and nulls the pointer through the `&mut`.
// This `Drop` runs exactly once and `D3d11Hw` owns these refs exclusively → no double-free /
// use-after-free. Frames are unref'd before the device because the frames ctx internally holds
// a ref on the device (refcounted, so the order is sound either way).
unsafe { unsafe {
ffi::av_buffer_unref(&mut self.frames_ref); ffi::av_buffer_unref(&mut self.frames_ref);
ffi::av_buffer_unref(&mut self.device_ref); ffi::av_buffer_unref(&mut self.device_ref);
@@ -800,6 +860,18 @@ impl ZeroCopyInner {
WinVendor::Qsv => (D3D11_BIND_DECODER.0 | D3D11_BIND_VIDEO_ENCODER.0) as u32, WinVendor::Qsv => (D3D11_BIND_DECODER.0 | D3D11_BIND_VIDEO_ENCODER.0) as u32,
}; };
const POOL: c_int = 8; const POOL: c_int = 8;
// SAFETY: `D3d11Hw::new` wraps the capturer's `device` as a D3D11VA hwdevice (handing FFmpeg an
// owned AddRef of it, balanced by FFmpeg's teardown Release) and builds an owned
// device_ref/frames_ref pair freed by `D3d11Hw::Drop`; `hw` is a local, so it is dropped (and
// both refs freed) on every early `return Err`. For QSV, `av_hwdevice_ctx_create_derived` and
// `av_hwframe_ctx_create_derived` fill the null-initialised `qsv_device`/`qsv_frames` out-params
// only on success (`r >= 0` checked); on the frames-derive failure we unref the already-created
// `qsv_device` before bailing. `open_win_encoder` internally `av_buffer_ref`s the dev/frames
// refs it is given (so ownership of `hw`'s and the derived refs stays here), and on its failure
// we unref the still-owned derived `qsv_frames`/`qsv_device` (null for AMF → skipped) and return
// — `hw` then drops its D3D11 refs. On success the derived refs are moved into `ZeroCopyInner`
// (freed in its `Drop`) and the encoder holds its own AddRef'd copies. Every `AVBufferRef` is
// unref'd exactly once across all paths — no leak, no double-free.
unsafe { unsafe {
let hw = D3d11Hw::new(device, sw_av, bind_flags, width, height, POOL)?; let hw = D3d11Hw::new(device, sw_av, bind_flags, width, height, POOL)?;
let (pix_fmt, dev_ref, frames_ref, mut qsv_device, mut qsv_frames) = match vendor { let (pix_fmt, dev_ref, frames_ref, mut qsv_device, mut qsv_frames) = match vendor {
@@ -887,6 +959,19 @@ impl ZeroCopyInner {
} }
fn submit(&mut self, frame: &D3d11Frame, pts: i64, idr: bool) -> Result<()> { fn submit(&mut self, frame: &D3d11Frame, pts: i64, idr: bool) -> Result<()> {
// SAFETY: `d3d = av_frame_alloc()` is a fresh owned frame (null-checked) and is `av_frame_free`d
// exactly once on every path below. `av_hwframe_get_buffer` fills it from the pool — on failure
// we free it and bail. `(*d3d).data[0]` is the pool's texture-array and `data[1]` the array
// index; `from_raw_borrowed` borrows that `ID3D11Texture2D` WITHOUT taking ownership (no Release
// — the frame owns it) and is null-checked. `src` (the captured texture) and `dst` (the pooled
// slice) live on the SAME D3D11 device wrapped by `self.hw`, and the caller guarantees
// `captured.format == pool_format` before calling, so `CopySubresourceRegion(dst, dst_index, ..,
// src, 0, ..)` on the single-threaded immediate context `self.ctx` is a valid same-format GPU
// copy. For QSV the mapped `qsv` frame is a fresh owned frame whose `hw_frames_ctx` takes an
// `av_buffer_ref` of `self.qsv_frames`; it is `av_frame_free`d (releasing that ref) on both the
// map-failure and success paths. `avcodec_send_frame` only internally refs the input frame, so
// the `av_frame_free(d3d)`/`av_frame_free(qsv)` afterwards are the sole owning frees — no leak,
// no double-free, no use-after-free.
unsafe { unsafe {
// Pull a pooled D3D11 surface; its data[0] is the pool's texture-ARRAY, data[1] the slice. // Pull a pooled D3D11 surface; its data[0] is the pool's texture-ARRAY, data[1] the slice.
let mut d3d = ffi::av_frame_alloc(); let mut d3d = ffi::av_frame_alloc();
@@ -959,6 +1044,11 @@ impl ZeroCopyInner {
impl Drop for ZeroCopyInner { impl Drop for ZeroCopyInner {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `qsv_frames`/`qsv_device` are the derived QSV `AVBufferRef`s (or null for AMF); each
// is `av_buffer_unref`'d once here (nulling the pointer through the `&mut`) — `ZeroCopyInner`
// owns these handles exclusively and this `Drop` runs once, so no double-free. The `enc` and
// `hw` fields free the encoder's AddRef'd copies and the D3D11 device/frames refs through their
// own `Drop`, so all references stay balanced.
unsafe { unsafe {
if !self.qsv_frames.is_null() { if !self.qsv_frames.is_null() {
ffi::av_buffer_unref(&mut self.qsv_frames); ffi::av_buffer_unref(&mut self.qsv_frames);
@@ -996,6 +1086,13 @@ pub struct FfmpegWinEncoder {
} }
// Raw FFI pointers + COM objects; the encoder lives on a single thread (same contract as NVENC/VAAPI). // Raw FFI pointers + COM objects; the encoder lives on a single thread (same contract as NVENC/VAAPI).
// SAFETY: `FfmpegWinEncoder` owns raw libav pointers (`AVFrame`/`SwsContext`/`AVBufferRef`) and
// windows-rs COM handles (`ID3D11Device`/`ID3D11DeviceContext`/textures) that are not auto-`Send`. The
// session creates the encoder, drives `submit`/`poll`/`flush`, and drops it all on one dedicated encode
// thread; it is never shared by reference across threads, and the D3D11 immediate context is only ever
// touched from that thread. The only cross-thread action is the initial move to the encode thread,
// after which every interior pointer/COM ref is used single-threaded — the same contract the
// NVENC/VAAPI encoders rely on. No interior state is accessed concurrently.
unsafe impl Send for FfmpegWinEncoder {} unsafe impl Send for FfmpegWinEncoder {}
impl FfmpegWinEncoder { impl FfmpegWinEncoder {
@@ -1012,6 +1109,8 @@ impl FfmpegWinEncoder {
) -> Result<Self> { ) -> Result<Self> {
ffmpeg::init().context("ffmpeg init")?; ffmpeg::init().context("ffmpeg init")?;
if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() { if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() {
// SAFETY: `ffmpeg::init()` ran on the line above, so libav is initialised; `av_log_set_level`
// is a global scalar setter with no pointer arguments.
unsafe { ffi::av_log_set_level(48) }; unsafe { ffi::av_log_set_level(48) };
} }
// Make sure the encoder name exists in this libavcodec build up front (clear error vs a // Make sure the encoder name exists in this libavcodec build up front (clear error vs a
@@ -13,7 +13,10 @@
//! Needs a real NVIDIA GPU at runtime (session creation fails otherwise) — compiles GPU-less, but //! 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. //! `open`/`submit` only succeed on a GPU box. The software encoder (`super::sw`) is the fallback.
use super::{Codec, EncodedFrame, Encoder}; // Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{Codec, EncodedFrame, Encoder, EncoderCaps};
use crate::capture::{CapturedFrame, FramePayload, PixelFormat}; use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
@@ -88,7 +91,15 @@ pub struct NvencD3d11Encoder {
init_device: *mut c_void, init_device: *mut c_void,
} }
// Raw NVENC handle + COM ptrs; confined to the single encode thread (like the Linux encoder). // SAFETY: the `!Send` fields are the raw NVENC session/device handles (`encoder`, `init_device`),
// the raw NVENC bitstream/registered/mapped pointers carried in `bitstreams`/`regs`/`pending`, and
// the `ID3D11Texture2D` COM refs — none of which may be touched concurrently from two threads. This
// encoder is owned by exactly one thread: it is moved onto the host encode thread once at
// construction, and every NVENC call and D3D11 access happens only from that thread thereafter
// (`submit`/`poll`/`invalidate_ref_frames`/`Drop` all run there, like the Linux encoder). Moving the
// handles across that single ownership-transfer boundary is sound because no NVENC/D3D11 call is in
// flight during the move and the session and its D3D11 immediate context are never shared (`&`) or
// used concurrently — so `Send` introduces no data race on the non-`Send` fields.
unsafe impl Send for NvencD3d11Encoder {} unsafe impl Send for NvencD3d11Encoder {}
impl NvencD3d11Encoder { impl NvencD3d11Encoder {
@@ -403,6 +414,17 @@ impl NvencD3d11Encoder {
/// Lazily create the session on the first frame's D3D11 device (so capture + encode share it). /// Lazily create the session on the first frame's D3D11 device (so capture + encode share it).
fn init_session(&mut self, device: &ID3D11Device) -> Result<()> { fn init_session(&mut self, device: &ID3D11Device) -> Result<()> {
// SAFETY: every call below goes through a function pointer resolved once from the loaded
// `nvidia_video_codec_sdk::ENCODE_API` (`nvEncodeAPI`) table, or through this type's own
// `unsafe fn`s whose contract is met here. `query_caps`/`try_open_session` receive `device`,
// the live `ID3D11Device` the caller pulled off the first frame; each returns either a valid
// open NVENC session handle or an `Err`. `destroy_encoder` is only ever called on a handle a
// `try_open_session` just returned (and `best` only when `!best.is_null()`), so it never frees
// a dangling or null session. `create_bitstream_buffer` is passed `enc` — the one chosen live
// session — and `&mut cb`, a `#[repr(C)] NV_ENC_CREATE_BITSTREAM_BUFFER` whose `version` is set
// to `NV_ENC_CREATE_BITSTREAM_BUFFER_VER`; `cb` lives across the synchronous call and its
// returned `bitstreamBuffer` is copied into `self.bitstreams` before `cb` drops. No handle
// escapes the encode thread.
unsafe { unsafe {
// Probe real GPU caps first (max dims / 10-bit / custom-VBV / RFI) so the config below is // Probe real GPU caps first (max dims / 10-bit / custom-VBV / RFI) so the config below is
// gated on what this card supports and an out-of-range mode fails with a clear error // gated on what this card supports and an out-of-range mode fails with a clear error
@@ -589,6 +611,11 @@ impl Encoder for NvencD3d11Encoder {
new = format!("{}x{}", captured.width, captured.height), new = format!("{}x{}", captured.width, captured.height),
"NVENC: capture device/size/HDR changed — re-initializing session" "NVENC: capture device/size/HDR changed — re-initializing session"
); );
// SAFETY: `teardown` (an `unsafe fn`) requires the encode thread with no NVENC call in
// flight and a session whose cached regs/bitstreams/pending all belong to `self.encoder`.
// All hold: this is the synchronous encode thread, `self.inited` so `self.encoder` is the
// live session every cached resource was created against, and the previous frame's encode
// has already been polled (synchronous submit→poll), so nothing is mid-encode.
unsafe { self.teardown() }; unsafe { self.teardown() };
} }
if !self.inited { if !self.inited {
@@ -609,7 +636,14 @@ impl Encoder for NvencD3d11Encoder {
self.bit_depth = 10; self.bit_depth = 10;
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10 nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
} }
PixelFormat::Nv12 => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12, PixelFormat::Nv12 => {
// NV12 is 8-bit 4:2:0. Force 8-bit so a transition from a prior P010 (10-bit) session
// — or a 10-bit-negotiated client on an SDR display — re-inits at the matching depth.
// Unlike ARGB (which NVENC upconverts to Main10), NV12 cannot feed a 10-bit session:
// `register_resource` rejects it as InvalidParam (the HDR→SDR-toggle stream drop).
self.bit_depth = 8;
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12
}
_ => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB, _ => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB,
}; };
let device = frame.device.clone(); let device = frame.device.clone();
@@ -618,6 +652,21 @@ impl Encoder for NvencD3d11Encoder {
} }
let slot = self.next % POOL; let slot = self.next % POOL;
self.next += 1; self.next += 1;
// SAFETY: every NVENC call goes through a function pointer from the loaded `ENCODE_API` table
// and takes `self.encoder`, the live session `init_session` just established (non-null on the
// path that reaches here). `NV_ENC_REGISTER_RESOURCE rr` has `version =
// NV_ENC_REGISTER_RESOURCE_VER` and registers `frame.texture` — a D3D11 texture from
// `frame.device`, which is the SAME device the session was opened against (any device change
// tears down and re-inits above, so `init_device == frame.device.as_raw()` here); the cloned
// `ID3D11Texture2D` is kept alive in `regs` so NVENC's registration never outlives the texture.
// `mp` (`NV_ENC_MAP_INPUT_RESOURCE`, version set) maps that registration and the map is recorded
// in `pending` to be unmapped exactly once in `poll`/`teardown`. `pic` (`NV_ENC_PIC_PARAMS`,
// version set) points `inputBuffer` at `mp.mappedResource` and `outputBitstream` at the live
// pool bitstream `bitstreams[slot]`; the optional SEI scratch (`mastering_sei`/`cll_sei` and the
// `sei` Vec whose `as_mut_ptr()` is written into the codec union) are stack locals that outlive
// the synchronous `encode_picture`. Every `#[repr(C)]` param is a live local borrowed `&mut`
// for the duration of its one synchronous call. (In-place encode without `CopyResource` is
// sound because the encode loop is synchronous, as the module docs state.)
unsafe { unsafe {
// Register the capturer's texture with NVENC once (cached by raw pointer), then encode it // Register the capturer's texture with NVENC once (cached by raw pointer), then encode it
// IN PLACE — no `CopyResource` into an encoder-owned pool. This is the zero-copy win: the // IN PLACE — no `CopyResource` into an encoder-owned pool. This is the zero-copy win: the
@@ -732,6 +781,15 @@ impl Encoder for NvencD3d11Encoder {
self.force_kf = true; 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>) { 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 // 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. // frame; only changes when the source is regraded or HDR toggles.
@@ -765,6 +823,12 @@ impl Encoder for NvencD3d11Encoder {
// We tag each input with `inputTimeStamp = frame_idx` (0,1,2,…), which is also the client's // We tag each input with `inputTimeStamp = frame_idx` (0,1,2,…), which is also the client's
// frame number (the packetizer numbers frames in submit order), so the client's lost-frame // frame number (the packetizer numbers frames in submit order), so the client's lost-frame
// range maps 1:1 onto the timestamps NVENC invalidates here. // range maps 1:1 onto the timestamps NVENC invalidates here.
// SAFETY: `invalidate_ref_frames` is a function pointer from the loaded `ENCODE_API` table.
// `self.encoder` was checked non-null at the top of this fn and is the live session; this runs
// on the encode thread (like submit/poll), so there is no concurrent NVENC use. Each `ts` was
// clamped to `[oldest_in_dpb, frame_idx - 1]` above, so it names a frame still in the session's
// DPB; the call passes only that `u64` timestamp (no struct), so there is no struct-size or
// lifetime concern.
unsafe { unsafe {
for ts in first..=last { for ts in first..=last {
if (API.invalidate_ref_frames)(self.encoder, ts as u64) if (API.invalidate_ref_frames)(self.encoder, ts as u64)
@@ -783,6 +847,16 @@ impl Encoder for NvencD3d11Encoder {
let Some((bs, map, pts_ns)) = self.pending.pop_front() else { let Some((bs, map, pts_ns)) = self.pending.pop_front() else {
return Ok(None); return Ok(None);
}; };
// SAFETY: a non-empty `pending` implies `submit` ran, so `self.encoder` is the live session
// (`teardown` clears `pending` whenever it nulls the handle); all calls below use function
// pointers from the loaded `ENCODE_API` table on the encode thread. `NV_ENC_LOCK_BITSTREAM lock`
// (version = `NV_ENC_LOCK_BITSTREAM_VER`) locks `bs`, a pool bitstream a prior `encode_picture`
// targeted; `lock_bitstream` blocks until that encode finishes, so on success
// `lock.bitstreamBufferPtr` is non-null and points at `lock.bitstreamSizeInBytes` bytes of
// NVENC-owned, CPU-readable output valid until `unlock_bitstream`. The `from_raw_parts` slice is
// only read (copied via `to_vec()`) BEFORE `unlock_bitstream(bs)` — lock and unlock pair on the
// same buffer — so it never outlives the lock. `map` (the input resource paired with `bs` in
// `pending`) is unmapped here, after the encode completed, exactly once.
unsafe { unsafe {
let mut lock = nv::NV_ENC_LOCK_BITSTREAM { let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
version: nv::NV_ENC_LOCK_BITSTREAM_VER, version: nv::NV_ENC_LOCK_BITSTREAM_VER,
@@ -822,6 +896,11 @@ impl Encoder for NvencD3d11Encoder {
impl Drop for NvencD3d11Encoder { impl Drop for NvencD3d11Encoder {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `teardown` (an `unsafe fn`) needs the owning thread with no NVENC call in flight and
// a session whose cached resources all belong to `self.encoder`. At Drop this encoder is owned
// exclusively (no other reference can exist), runs on the encode thread it was confined to, and
// `teardown` early-returns when `self.encoder` is null; otherwise every cached reg/bitstream/
// pending was created against that live session. It runs exactly once (here).
unsafe { self.teardown() }; unsafe { self.teardown() };
} }
} }
@@ -2,6 +2,8 @@
//! fallback when NVENC is unavailable). Low-latency screen-content config: single-reference, //! fallback when NVENC is unavailable). Low-latency screen-content config: single-reference,
//! no B-frames (Baseline), bitrate rate-control, in-band SPS/PPS each IDR, BT.709 limited range. //! no B-frames (Baseline), bitrate rate-control, in-band SPS/PPS each IDR, BT.709 limited range.
//! Synchronous: `submit` encodes immediately and stashes the AU for `poll` (no internal queue). //! Synchronous: `submit` encodes immediately and stashes the AU for `poll` (no internal queue).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{EncodedFrame, Encoder}; use super::{EncodedFrame, Encoder};
use crate::capture::{CapturedFrame, FramePayload, PixelFormat}; use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
@@ -30,6 +32,12 @@ pub struct OpenH264Encoder {
} }
// openh264's Encoder holds a raw C handle (not auto-Send); it lives on the single encode thread. // openh264's Encoder holds a raw C handle (not auto-Send); it lives on the single encode thread.
// SAFETY: `OpenH264Encoder` wraps `Oh264` (openh264's `Encoder`), which holds a raw C handle to the
// openh264 `ISVCEncoder` and is not auto-`Send`; the other fields (`YUVBuffer`, `Vec`, scalars,
// `Option<EncodedFrame>`) are plain owned data. The session creates the encoder, calls
// `submit`/`poll`/`flush`, and drops it all on one dedicated encode thread, never sharing it by
// reference across threads, so the C handle is only ever touched from a single thread. Moving the
// whole value to that thread is therefore sound — there is no concurrent access to the handle.
unsafe impl Send for OpenH264Encoder {} unsafe impl Send for OpenH264Encoder {}
impl OpenH264Encoder { impl OpenH264Encoder {
+48 -1
View File
@@ -17,6 +17,9 @@
//! data packets are consumed immediately and missing parity only costs loss recovery — so //! data packets are consumed immediately and missing parity only costs loss recovery — so
//! the validated stereo path stays byte-identical (data packets only, exactly as before). //! the validated stereo path stays byte-identical (data packets only, exactly as before).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
#[cfg(any(target_os = "linux", target_os = "windows", test))] #[cfg(any(target_os = "linux", target_os = "windows", test))]
use crate::audio::SAMPLE_RATE; use crate::audio::SAMPLE_RATE;
#[cfg(any(target_os = "linux", target_os = "windows"))] #[cfg(any(target_os = "linux", target_os = "windows"))]
@@ -409,7 +412,10 @@ struct MsEncoder {
st: std::ptr::NonNull<audiopus_sys::OpusMSEncoder>, st: std::ptr::NonNull<audiopus_sys::OpusMSEncoder>,
} }
// The raw encoder state has no thread affinity; the session owns it on one thread at a time. // SAFETY: `MsEncoder` owns a unique `OpusMSEncoder` via `NonNull` (it is neither `Clone` nor
// `Sync`, so the pointer is never aliased). libopus's multistream encoder state is a self-contained
// heap allocation with no thread-local or thread-affine state, so moving ownership to another thread
// is sound; every method takes `&mut self`, keeping access single-threaded at any instant.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
unsafe impl Send for MsEncoder {} unsafe impl Send for MsEncoder {}
@@ -418,6 +424,13 @@ impl MsEncoder {
fn new(layout: &OpusLayout) -> Result<MsEncoder> { fn new(layout: &OpusLayout) -> Result<MsEncoder> {
use std::os::raw::c_int; use std::os::raw::c_int;
let mut err: c_int = 0; let mut err: c_int = 0;
// SAFETY: every scalar arg is a valid libopus input (sample rate, channel/stream/coupled
// counts, the RESTRICTED_LOWDELAY application constant). `layout.mapping.as_ptr()` addresses
// a 'static slice of exactly `layout.channels` bytes (every `OpusLayout` constant upholds
// that), which is the element count `opus_multistream_encoder_create` reads through it, and
// `&mut err` is a live local the call writes its status into. libopus copies the mapping into
// its own allocation, so the pointer need only be valid for the call; the returned pointer is
// null/`OPUS_OK`-checked below before any use.
let st = unsafe { let st = unsafe {
audiopus_sys::opus_multistream_encoder_create( audiopus_sys::opus_multistream_encoder_create(
SAMPLE_RATE as i32, SAMPLE_RATE as i32,
@@ -432,6 +445,11 @@ impl MsEncoder {
let st = std::ptr::NonNull::new(st) let st = std::ptr::NonNull::new(st)
.filter(|_| err == audiopus_sys::OPUS_OK) .filter(|_| err == audiopus_sys::OPUS_OK)
.ok_or_else(|| anyhow::anyhow!("opus_multistream_encoder_create failed ({err})"))?; .ok_or_else(|| anyhow::anyhow!("opus_multistream_encoder_create failed ({err})"))?;
// SAFETY: `st` is the non-null encoder `opus_multistream_encoder_create` just returned, owned
// exclusively here. Each `opus_multistream_encoder_ctl` call passes a valid request constant
// with the single by-value `c_int` argument that request's variadic ABI expects
// (`OPUS_SET_BITRATE_REQUEST` → bitrate, `OPUS_SET_VBR_REQUEST` → 0). No pointer escapes the
// call and the encoder outlives it.
unsafe { unsafe {
audiopus_sys::opus_multistream_encoder_ctl( audiopus_sys::opus_multistream_encoder_ctl(
st.as_ptr(), st.as_ptr(),
@@ -453,6 +471,13 @@ impl MsEncoder {
samples_per_channel: usize, samples_per_channel: usize,
out: &mut [u8], out: &mut [u8],
) -> Result<usize> { ) -> Result<usize> {
// SAFETY: `self.st` is the live encoder from `new`. libopus reads `samples_per_channel *
// channels` f32s through `frame.as_ptr()`; every caller passes a `frame` of exactly that
// length together with the matching `samples_per_channel` (`audio_body`'s `frame_len =
// samples_per_channel * layout.channels`; the round-trip tests size identically), so the read
// stays in bounds. `out.as_mut_ptr()` is written for at most `out.len()` bytes, which is
// passed as the capacity bound. Both buffers are live locals outliving this synchronous call;
// the return value is range-checked before being used as a length.
let n = unsafe { let n = unsafe {
audiopus_sys::opus_multistream_encode_float( audiopus_sys::opus_multistream_encode_float(
self.st.as_ptr(), self.st.as_ptr(),
@@ -470,6 +495,9 @@ impl MsEncoder {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
impl Drop for MsEncoder { impl Drop for MsEncoder {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `self.st` is the encoder `opus_multistream_encoder_create` returned; this
// `MsEncoder` owns it uniquely and `drop` runs exactly once, so the destroy frees it once
// with no subsequent use.
unsafe { audiopus_sys::opus_multistream_encoder_destroy(self.st.as_ptr()) } unsafe { audiopus_sys::opus_multistream_encoder_destroy(self.st.as_ptr()) }
} }
} }
@@ -761,6 +789,10 @@ mod tests {
let client_mapping = client_swap(&digits[3..]); let client_mapping = client_swap(&digits[3..]);
let mut err = 0i32; let mut err = 0i32;
// SAFETY: scalar args are valid libopus inputs. `client_mapping.as_ptr()` addresses a
// `Vec<u8>` of exactly `ch` entries (derived from the advertised surround-params), which is
// the element count the decoder reads through it, and `&mut err` is a live local the call
// writes. The returned pointer is `OPUS_OK`/non-null-checked immediately below before use.
let dec = unsafe { let dec = unsafe {
audiopus_sys::opus_multistream_decoder_create( audiopus_sys::opus_multistream_decoder_create(
SAMPLE_RATE as i32, SAMPLE_RATE as i32,
@@ -789,6 +821,11 @@ mod tests {
} }
let n = enc.encode_float(&frame, samples, &mut out).unwrap(); let n = enc.encode_float(&frame, samples, &mut out).unwrap();
assert!(n > 0); assert!(n > 0);
// SAFETY: `dec` is the non-null decoder asserted above. `out.as_ptr()` is read for
// the `n` encoded bytes just produced by `encode_float`; `decoded.as_mut_ptr()` is
// written for up to `samples * ch` f32s and `decoded` is exactly that long; `samples`
// is the per-channel frame size. All buffers are live locals outliving the call; the
// return is checked to equal `samples`.
let got = unsafe { let got = unsafe {
audiopus_sys::opus_multistream_decode_float( audiopus_sys::opus_multistream_decode_float(
dec, dec,
@@ -817,6 +854,8 @@ mod tests {
(energies: {energy:?})" (energies: {energy:?})"
); );
} }
// SAFETY: `dec` is the decoder `opus_multistream_decoder_create` returned; the test owns it
// and destroys it exactly once here, after the final decode — no later use, no double free.
unsafe { audiopus_sys::opus_multistream_decoder_destroy(dec) }; unsafe { audiopus_sys::opus_multistream_decoder_destroy(dec) };
} }
@@ -853,6 +892,9 @@ mod tests {
let digits: Vec<u8> = s.bytes().map(|b| b - b'0').collect(); let digits: Vec<u8> = s.bytes().map(|b| b - b'0').collect();
let client_mapping = client_swap(&digits[3..]); let client_mapping = client_swap(&digits[3..]);
let mut err = 0i32; let mut err = 0i32;
// SAFETY: scalar args are valid; `client_mapping.as_ptr()` addresses a 6-entry `Vec<u8>`
// (matches the 6-channel layout the decoder reads through it), alive past the call, and
// `&mut err` is a live local. The pointer is `OPUS_OK`-checked before use.
let dec = unsafe { let dec = unsafe {
audiopus_sys::opus_multistream_decoder_create( audiopus_sys::opus_multistream_decoder_create(
48000, 48000,
@@ -865,6 +907,10 @@ mod tests {
}; };
assert_eq!(err, audiopus_sys::OPUS_OK); assert_eq!(err, audiopus_sys::OPUS_OK);
let mut pcm = vec![0f32; 240 * 6]; let mut pcm = vec![0f32; 240 * 6];
// SAFETY: `dec` is the non-null decoder from create. `out.as_ptr()` is read for the CBR
// packet length passed in (`*sizes.first()`, a real encoded packet size in `out`);
// `pcm.as_mut_ptr()` is written for up to `240 * 6` f32s and `pcm` is exactly that long;
// `240` is the per-channel frame size. All buffers are live locals outliving the call.
let got = unsafe { let got = unsafe {
audiopus_sys::opus_multistream_decode_float( audiopus_sys::opus_multistream_decode_float(
dec, dec,
@@ -875,6 +921,7 @@ mod tests {
0, 0,
) )
}; };
// SAFETY: `dec` is owned by the test; destroyed exactly once here after the final decode.
unsafe { audiopus_sys::opus_multistream_decoder_destroy(dec) }; unsafe { audiopus_sys::opus_multistream_decoder_destroy(dec) };
assert_eq!(got, 240); assert_eq!(got, 240);
} }
+49 -6
View File
@@ -3,6 +3,9 @@
//! either real portal desktop capture (`PUNKTFUNK_VIDEO_SOURCE=portal`, the portal PipeWire path) or //! either real portal desktop capture (`PUNKTFUNK_VIDEO_SOURCE=portal`, the portal PipeWire path) or
//! a synthetic test pattern (default). Runs on its own native thread. //! a synthetic test pattern (default). Runs on its own native thread.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::video::{FrameType, VideoPacketizer}; use super::video::{FrameType, VideoPacketizer};
use super::VIDEO_PORT; use super::VIDEO_PORT;
use crate::capture::{self, Capturer, FastSyntheticCapturer}; use crate::capture::{self, Capturer, FastSyntheticCapturer};
@@ -102,7 +105,7 @@ fn run(
// request and capture it (no scaling). Self-contained — deliberately NOT pooled in // 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 // `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). // 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 // The launched app picks the compositor (e.g. gamescope for game entries) and the
// nested command. // nested command.
let compositor = app let compositor = app
@@ -134,9 +137,32 @@ fn run(
// IDD-push bypasses WGC.) Acceptable for the experimental IDD-push A/B path; HDR over IDD-push // 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 // 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. // from a GameStream HDR flag once StreamConfig carries one.
let mut capturer = let mut capturer = capture::capture_virtual_output(
capture::capture_virtual_output(vout, false).context("capture virtual output")?; vout,
capture::OutputFormat::resolve(false),
crate::session_plan::CaptureBackend::resolve(),
)
.context("capture virtual output")?;
capturer.set_active(true); capturer.set_active(true);
// Launch the app's command now that capture is live, for the backends that DON'T nest it via
// set_launch_command above: Windows (no gamescope) and Linux kwin/mutter/wlroots (which stream
// the existing desktop, so the app must be spawned into the session to land on the streamed
// output). Linux gamescope already nested it via set_launch_command, so skip it there.
#[cfg(windows)]
let launch_here = true;
#[cfg(target_os = "linux")]
let launch_here = compositor != crate::vdisplay::Compositor::Gamescope;
#[cfg(any(windows, target_os = "linux"))]
if launch_here {
if let Some(cmd) = app
.and_then(|a| a.cmd.as_deref())
.filter(|c| !c.trim().is_empty())
{
if let Err(e) = crate::library::launch_gamestream_command(cmd) {
tracing::warn!(command = %cmd, error = %e, "gamestream: could not launch app");
}
}
}
return stream_body(&mut *capturer, &sock, cfg, running, force_idr, rfi_range); return stream_body(&mut *capturer, &sock, cfg, running, force_idr, rfi_range);
} }
@@ -147,7 +173,7 @@ fn run(
tracing::info!("video source: reusing capturer"); tracing::info!("video source: reusing capturer");
c 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"); tracing::info!("video source: portal desktop capture");
capture::open_portal_monitor().context("open portal capturer")? capture::open_portal_monitor().context("open portal capturer")?
} }
@@ -184,6 +210,10 @@ fn sendmmsg_all(sock: &UdpSocket, pkts: &[Vec<u8>]) -> std::io::Result<()> {
let mut hdrs: Vec<libc::mmsghdr> = iovs let mut hdrs: Vec<libc::mmsghdr> = iovs
.iter_mut() .iter_mut()
.map(|iov| { .map(|iov| {
// SAFETY: `libc::mmsghdr` is a plain `#[repr(C)]` struct of integers and raw
// pointers, for which an all-zero bit pattern is valid (null pointers / zero
// lengths); the fields we rely on (`msg_iov`, `msg_iovlen`) are overwritten on the
// next two lines before the struct is handed to the kernel.
let mut h: libc::mmsghdr = unsafe { std::mem::zeroed() }; let mut h: libc::mmsghdr = unsafe { std::mem::zeroed() };
h.msg_hdr.msg_iov = iov; h.msg_hdr.msg_iov = iov;
h.msg_hdr.msg_iovlen = 1; h.msg_hdr.msg_iovlen = 1;
@@ -192,6 +222,13 @@ fn sendmmsg_all(sock: &UdpSocket, pkts: &[Vec<u8>]) -> std::io::Result<()> {
.collect(); .collect();
let mut off = 0usize; let mut off = 0usize;
while off < hdrs.len() { while off < hdrs.len() {
// SAFETY: `fd` is `sock`'s live raw fd (`sock` outlives the call). `hdrs[off..]
// .as_mut_ptr()` is a live slice of `(hdrs.len() - off)` `mmsghdr`s — exactly the count
// passed — into which the kernel writes each `msg_len`. Each header's `msg_iov` points
// into `iovs` (a local that outlives this call, with `msg_iovlen == 1` matching its one
// entry) and each `iovec.iov_base` points into the `chunk` packet buffers (the caller's
// `pkts`, alive for the call); the kernel only reads those payloads. Flags 0; the return
// is error-/progress-checked before advancing `off`.
let n = unsafe { let n = unsafe {
libc::sendmmsg(fd, hdrs[off..].as_mut_ptr(), (hdrs.len() - off) as u32, 0) libc::sendmmsg(fd, hdrs[off..].as_mut_ptr(), (hdrs.len() - off) as u32, 0)
}; };
@@ -358,11 +395,15 @@ fn stream_body(
// Per-stage timing (PUNKTFUNK_PERF=1): max µs/stage per second + unique vs re-encoded frames, // 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). // 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) = let (mut mx_cap, mut mx_enc, mut mx_pkt, mut mx_send, mut mx_pkts, mut uniq) =
(0u128, 0u128, 0u128, 0u128, 0usize, 0u32); (0u128, 0u128, 0u128, 0u128, 0usize, 0u32);
// Absolute next-frame deadline — the single pacing clock for the loop. // Absolute next-frame deadline — the single pacing clock for the loop.
let mut next_frame = Instant::now(); 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) { while running.load(Ordering::SeqCst) {
let tick = Instant::now(); let tick = Instant::now();
@@ -376,7 +417,9 @@ fn stream_body(
// re-references an older still-valid frame — no costly IDR spike); if the encoder can't // 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. // 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 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(); enc.request_keyframe();
} }
} }
+27 -5
View File
@@ -112,8 +112,10 @@ pub fn default_backend() -> Backend {
} }
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
{ {
if std::env::var("PUNKTFUNK_COMPOSITOR") if crate::config::config()
.is_ok_and(|v| v.trim().eq_ignore_ascii_case("gamescope")) .compositor
.as_deref()
.is_some_and(|v| v.trim().eq_ignore_ascii_case("gamescope"))
{ {
return Backend::GamescopeEi; 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. /// (`org.gnome.Mutter.RemoteDesktop`), the same direct API the Mutter video backend uses.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn libei_ei_source() -> libei::EiSource { fn libei_ei_source() -> libei::EiSource {
let gnome = std::env::var("PUNKTFUNK_COMPOSITOR") let gnome = crate::config::config()
.is_ok_and(|v| v.trim().eq_ignore_ascii_case("mutter")) .compositor
.as_deref()
.is_some_and(|v| v.trim().eq_ignore_ascii_case("mutter"))
|| std::env::var("XDG_CURRENT_DESKTOP") || std::env::var("XDG_CURRENT_DESKTOP")
.unwrap_or_default() .unwrap_or_default()
.to_ascii_uppercase() .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")] #[cfg(target_os = "linux")]
#[path = "inject/linux/dualsense.rs"]
pub mod dualsense; pub mod dualsense;
/// Transport-independent DualSense HID contract, shared by the Linux UHID backend ([`dualsense`]) /// Transport-independent DualSense HID contract, shared by the Linux UHID backend ([`dualsense`])
/// and the Windows UMDF-driver backend ([`dualsense_windows`]). /// and the Windows UMDF-driver backend ([`dualsense_windows`]).
#[cfg(any(target_os = "linux", target_os = "windows"))] #[cfg(any(target_os = "linux", target_os = "windows"))]
#[path = "inject/proto/dualsense_proto.rs"]
pub mod dualsense_proto; pub mod dualsense_proto;
/// Windows: virtual DualSense via the UMDF minidriver + a shared-memory host channel. /// Windows: virtual DualSense via the UMDF minidriver + a shared-memory host channel.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "inject/windows/dualsense_windows.rs"]
pub mod dualsense_windows; pub mod dualsense_windows;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "inject/linux/dualshock4.rs"]
pub mod dualshock4; pub mod dualshock4;
/// Transport-independent DualShock 4 HID codec used by the Windows UMDF-driver backend /// 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.) /// ([`dualshock4_windows`]). (The Linux backend still carries its own copy — see the module FIXME.)
#[cfg(any(target_os = "linux", target_os = "windows"))] #[cfg(any(target_os = "linux", target_os = "windows"))]
#[path = "inject/proto/dualshock4_proto.rs"]
pub mod dualshock4_proto; pub mod dualshock4_proto;
/// Windows: virtual DualShock 4 via the same UMDF minidriver + shared-memory channel (device-type 1). /// Windows: virtual DualShock 4 via the same UMDF minidriver + shared-memory channel (device-type 1).
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "inject/windows/dualshock4_windows.rs"]
pub mod dualshock4_windows; pub mod dualshock4_windows;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "inject/linux/gamepad.rs"]
pub mod gamepad; pub mod gamepad;
/// Windows: virtual Xbox 360 pads via the in-tree XUSB companion UMDF driver (classic XInput). /// Windows: virtual Xbox 360 pads via the in-tree XUSB companion UMDF driver (classic XInput).
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "inject/gamepad_windows.rs"] #[path = "inject/windows/gamepad_windows.rs"]
pub mod gamepad; 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. /// Stub — virtual gamepads need Linux uinput or the Windows UMDF drivers; events are dropped elsewhere.
#[cfg(not(any(target_os = "linux", target_os = "windows")))] #[cfg(not(any(target_os = "linux", target_os = "windows")))]
pub mod gamepad { pub mod gamepad {
@@ -459,10 +478,13 @@ pub mod gamepad {
} }
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "inject/linux/libei.rs"]
mod libei; mod libei;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "inject/windows/sendinput.rs"]
mod sendinput; mod sendinput;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "inject/linux/wlr.rs"]
mod wlr; mod wlr;
#[cfg(test)] #[cfg(test)]
@@ -15,6 +15,9 @@
//! `<linux/uinput.h>` on x86_64. `/dev/uinput` needs a udev rule + `input` group membership //! `<linux/uinput.h>` on x86_64. `/dev/uinput` needs a udev rule + `input` group membership
//! (see `scripts/60-punktfunk.rules`); creation fails with a clear error otherwise. //! (see `scripts/60-punktfunk.rules`); creation fails with a clear error otherwise.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use crate::gamestream::gamepad::{self, GamepadFrame, MAX_PADS}; use crate::gamestream::gamepad::{self, GamepadFrame, MAX_PADS};
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use std::collections::HashMap; use std::collections::HashMap;
@@ -215,6 +218,11 @@ const _: () = {
}; };
fn ioctl_int(fd: i32, req: libc::c_ulong, arg: libc::c_int, what: &str) -> Result<()> { fn ioctl_int(fd: i32, req: libc::c_ulong, arg: libc::c_int, what: &str) -> Result<()> {
// SAFETY: every caller passes one of UI_SET_EVBIT/KEYBIT/FFBIT/UI_DEV_CREATE/UI_DEV_DESTROY as
// `req` — all integer-argument ioctls whose third arg the kernel takes BY VALUE, so nothing is
// dereferenced through `arg` and no memory must outlive the call. The only precondition is `fd`
// being a valid open descriptor; callers pass the live `/dev/uinput` fd, and even a stale fd
// would merely return -1/EBADF (reported below), never UB.
if unsafe { libc::ioctl(fd, req, arg) } < 0 { if unsafe { libc::ioctl(fd, req, arg) } < 0 {
bail!("{what}: {}", std::io::Error::last_os_error()); bail!("{what}: {}", std::io::Error::last_os_error());
} }
@@ -222,6 +230,12 @@ fn ioctl_int(fd: i32, req: libc::c_ulong, arg: libc::c_int, what: &str) -> Resul
} }
fn ioctl_ptr<T>(fd: i32, req: libc::c_ulong, arg: *mut T, what: &str) -> Result<()> { fn ioctl_ptr<T>(fd: i32, req: libc::c_ulong, arg: *mut T, what: &str) -> Result<()> {
// SAFETY: `fd` is the caller's live `/dev/uinput` fd. Every call site passes `&mut x` for a live,
// uniquely-borrowed `#[repr(C)]` `x: T` whose size matches the struct the request number encodes
// (UI_DEV_SETUP=0x405c_5503 → 0x5c=92=size_of::<UinputSetup>(); UI_ABS_SETUP → 0x1c=28; the FF
// upload/erase ioctls → 0x68/0x0c — all pinned by the `size_of` asserts above). The kernel copies
// exactly that many bytes in/out through `arg`; the `&mut` keeps the pointee alive and unaliased
// for the whole synchronous call.
if unsafe { libc::ioctl(fd, req, arg) } < 0 { if unsafe { libc::ioctl(fd, req, arg) } < 0 {
bail!("{what}: {}", std::io::Error::last_os_error()); bail!("{what}: {}", std::io::Error::last_os_error());
} }
@@ -251,6 +265,9 @@ pub struct VirtualPad {
impl VirtualPad { impl VirtualPad {
pub fn create(index: usize, identity: PadIdentity) -> Result<VirtualPad> { pub fn create(index: usize, identity: PadIdentity) -> Result<VirtualPad> {
use std::os::fd::FromRawFd; use std::os::fd::FromRawFd;
// SAFETY: `c"/dev/uinput"` is a 'static NUL-terminated C string literal; `as_ptr()` yields a
// valid pointer the kernel only reads as a filesystem path. `open` returns a fresh fd (or -1)
// and retains nothing; no Rust memory is aliased or handed to the kernel beyond that 'static path.
let raw = unsafe { let raw = unsafe {
libc::open( libc::open(
c"/dev/uinput".as_ptr(), c"/dev/uinput".as_ptr(),
@@ -264,6 +281,9 @@ impl VirtualPad {
std::io::Error::last_os_error() std::io::Error::last_os_error()
); );
} }
// SAFETY: `raw >= 0` here (the `< 0` branch above already bailed), so it is a freshly-opened fd
// from `libc::open` that is not stored or owned anywhere else. Transferring it to `OwnedFd` makes
// this the unique owner, which will `close` it exactly once on drop (no double-close, no leak).
let fd = unsafe { OwnedFd::from_raw_fd(raw) }; let fd = unsafe { OwnedFd::from_raw_fd(raw) };
ioctl_int(raw, UI_SET_EVBIT, EV_KEY as i32, "UI_SET_EVBIT(EV_KEY)")?; ioctl_int(raw, UI_SET_EVBIT, EV_KEY as i32, "UI_SET_EVBIT(EV_KEY)")?;
@@ -356,6 +376,11 @@ impl VirtualPad {
code, code,
value, value,
}; };
// SAFETY: `ev` is a live local `#[repr(C)]` struct of all-integer fields with no padding bytes
// (timeval=16 + u16 + u16 + i32 = 24, the size asserted above), so every byte is initialized and
// valid to read as `u8`. The pointer is non-null and `u8`-aligned (align 1), the length is exactly
// `size_of::<InputEventRaw>()` so the slice spans precisely `ev`'s bytes (in bounds), and `ev`
// outlives `bytes` (used immediately below) with no concurrent mutation (single-threaded local).
let bytes = unsafe { let bytes = unsafe {
std::slice::from_raw_parts( std::slice::from_raw_parts(
&ev as *const _ as *const u8, &ev as *const _ as *const u8,
@@ -363,6 +388,10 @@ impl VirtualPad {
) )
}; };
// Best-effort: a full kernel queue drops the event; the next frame re-syncs state. // Best-effort: a full kernel queue drops the event; the next frame re-syncs state.
// SAFETY: `self.fd` is the live uinput `OwnedFd` (borrowed via `as_raw_fd`, so it stays open for
// the call); `bytes` is the slice above backed by the still-live local `ev`. `write` only READS
// exactly `bytes.len()` bytes from `bytes.as_ptr()` (in bounds) and retains nothing past return,
// so the buffer outlives the synchronous call and the read-only access cannot race or alias.
let _ = unsafe { let _ = unsafe {
libc::write( libc::write(
self.fd.as_raw_fd(), self.fd.as_raw_fd(),
@@ -404,6 +433,10 @@ impl VirtualPad {
let raw = self.fd.as_raw_fd(); let raw = self.fd.as_raw_fd();
let mut buf = [0u8; std::mem::size_of::<InputEventRaw>()]; let mut buf = [0u8; std::mem::size_of::<InputEventRaw>()];
loop { loop {
// SAFETY: `raw` is the live raw fd of `self.fd` (the non-blocking uinput device). `buf` is a
// live local `[u8; size_of::<InputEventRaw>()]`; `buf.as_mut_ptr()` is a valid writable pointer
// to its `buf.len()` bytes. `read` writes AT MOST `buf.len()` bytes (in bounds), the buffer
// outlives this synchronous call, and `buf` is borrowed uniquely here (no alias/race).
let n = unsafe { libc::read(raw, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) }; let n = unsafe { libc::read(raw, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
if n != buf.len() as isize { if n != buf.len() as isize {
break; // EAGAIN / short read — queue drained break; // EAGAIN / short read — queue drained
@@ -415,6 +448,10 @@ impl VirtualPad {
unsafe { std::ptr::read_unaligned(buf.as_ptr() as *const InputEventRaw) }; unsafe { std::ptr::read_unaligned(buf.as_ptr() as *const InputEventRaw) };
match (ev.type_, ev.code) { match (ev.type_, ev.code) {
(EV_UINPUT, UI_FF_UPLOAD) => { (EV_UINPUT, UI_FF_UPLOAD) => {
// SAFETY: `UinputFfUpload` is `#[repr(C)]` over integers (`u32`, `i32`) and two
// `FfEffect`s (integers + `[u8; 32]`); all-zero is a valid bit pattern for every field
// (no bool/NonZero/enum/reference niche), so `zeroed` yields a fully-initialized valid
// value — `request_id` is then set below and the rest filled by UI_BEGIN_FF_UPLOAD.
let mut up: UinputFfUpload = unsafe { std::mem::zeroed() }; let mut up: UinputFfUpload = unsafe { std::mem::zeroed() };
up.request_id = ev.value as u32; up.request_id = ev.value as u32;
if ioctl_ptr(raw, UI_BEGIN_FF_UPLOAD, &mut up, "UI_BEGIN_FF_UPLOAD").is_ok() { if ioctl_ptr(raw, UI_BEGIN_FF_UPLOAD, &mut up, "UI_BEGIN_FF_UPLOAD").is_ok() {
@@ -442,6 +479,9 @@ impl VirtualPad {
} }
} }
(EV_UINPUT, UI_FF_ERASE) => { (EV_UINPUT, UI_FF_ERASE) => {
// SAFETY: `UinputFfErase` is `#[repr(C)]` over three integer fields (`u32`, `i32`,
// `u32`); all-zero is a valid bit pattern for each, so `zeroed` produces a fully-valid
// initialized value — `request_id` is set below and `effect_id` filled by the ioctl.
let mut er: UinputFfErase = unsafe { std::mem::zeroed() }; let mut er: UinputFfErase = unsafe { std::mem::zeroed() };
er.request_id = ev.value as u32; er.request_id = ev.value as u32;
if ioctl_ptr(raw, UI_BEGIN_FF_ERASE, &mut er, "UI_BEGIN_FF_ERASE").is_ok() { if ioctl_ptr(raw, UI_BEGIN_FF_ERASE, &mut er, "UI_BEGIN_FF_ERASE").is_ok() {
@@ -492,6 +532,9 @@ impl VirtualPad {
impl Drop for VirtualPad { impl Drop for VirtualPad {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `self.fd` is still the live owned uinput fd here (the `OwnedFd` field is closed only
// AFTER this `drop` body returns), borrowed by `as_raw_fd`. UI_DEV_DESTROY takes its argument
// (0) BY VALUE, so nothing is dereferenced or aliased; the ioctl just tears down the device.
let _ = unsafe { libc::ioctl(self.fd.as_raw_fd(), UI_DEV_DESTROY, 0) }; let _ = unsafe { libc::ioctl(self.fd.as_raw_fd(), UI_DEV_DESTROY, 0) };
} }
} }
@@ -5,6 +5,9 @@
//! keymap, and translate events into virtual pointer/keyboard requests, tracking modifier state //! keymap, and translate events into virtual pointer/keyboard requests, tracking modifier state
//! so the compositor resolves shifted keysyms correctly. //! so the compositor resolves shifted keysyms correctly.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector}; use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector};
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use punktfunk_core::input::InputKind; use punktfunk_core::input::InputKind;
@@ -264,10 +267,17 @@ impl InputInjector for WlrootsInjector {
/// Create an anonymous in-memory file holding `s` + a trailing NUL (for the keymap fd). /// Create an anonymous in-memory file holding `s` + a trailing NUL (for the keymap fd).
fn memfd_with(s: &str) -> Result<std::fs::File> { fn memfd_with(s: &str) -> Result<std::fs::File> {
let name = b"punktfunk-keymap\0"; let name = b"punktfunk-keymap\0";
// SAFETY: `name` is a byte-string literal with an explicit trailing NUL, so `name.as_ptr()` is a
// valid NUL-terminated C string; `memfd_create` only reads that name (copying it) and creates an
// anonymous file, returning a fresh fd (or -1). `MFD_CLOEXEC` is a valid flag. The 'static literal
// outlives the synchronous call and nothing aliases it. The result is checked `< 0` below.
let fd = unsafe { libc::memfd_create(name.as_ptr() as *const libc::c_char, libc::MFD_CLOEXEC) }; let fd = unsafe { libc::memfd_create(name.as_ptr() as *const libc::c_char, libc::MFD_CLOEXEC) };
if fd < 0 { if fd < 0 {
bail!("memfd_create failed: {}", std::io::Error::last_os_error()); bail!("memfd_create failed: {}", std::io::Error::last_os_error());
} }
// SAFETY: `fd` is the fresh memfd `memfd_create` just returned and checked `>= 0`; it is a unique
// open fd nothing else owns, so `File` takes sole ownership and closes it exactly once on drop —
// no alias, no double-close.
let mut f = unsafe { std::fs::File::from_raw_fd(fd) }; let mut f = unsafe { std::fs::File::from_raw_fd(fd) };
f.write_all(s.as_bytes()).context("write keymap")?; f.write_all(s.as_bytes()).context("write keymap")?;
f.write_all(&[0]).context("write keymap NUL")?; f.write_all(&[0]).context("write keymap NUL")?;
@@ -29,38 +29,35 @@ use windows::core::{w, GUID, HRESULT, HSTRING, PCWSTR};
use windows::Win32::Devices::Enumeration::Pnp::{ use windows::Win32::Devices::Enumeration::Pnp::{
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO, SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
}; };
use windows::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}; use windows::Win32::Foundation::{CloseHandle, HANDLE};
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::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject}; use windows::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject};
/// Shared-section layout — must match `packaging/windows/dualsense-driver/src/lib.rs`. `pub(super)` /// Shared-section layout — the single source of truth is [`pf_driver_proto::gamepad::PadShm`] (offset
/// so the sibling DualShock 4 backend ([`super::dualshock4_windows`]) reuses the exact offsets. /// asserts pin every field; the `pf_dualsense` driver maps the same struct). Derive the size/offsets/magic
pub(super) const SHM_SIZE: usize = 256; /// from it so a layout change is a compile error, not a hand-synced literal (audit §6.1). `pub(super)` so
pub(super) const SHM_MAGIC: u32 = 0x5046_4453; // "PFDS" /// the sibling DualShock 4 backend ([`super::dualshock4_windows`]) reuses the exact offsets.
pub(super) const OFF_INPUT: usize = 8; pub(super) const SHM_SIZE: usize = core::mem::size_of::<pf_driver_proto::gamepad::PadShm>();
pub(super) const OFF_OUT_SEQ: usize = 72; pub(super) const SHM_MAGIC: u32 = pf_driver_proto::gamepad::PAD_MAGIC; // "PFDS"
pub(super) const OFF_OUTPUT: usize = 76; pub(super) const OFF_INPUT: usize = core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, input);
pub(super) const OFF_OUT_SEQ: usize =
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, out_seq);
pub(super) const OFF_OUTPUT: usize =
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, output);
/// Device-type selector the driver reads to choose which HID identity/descriptor it serves: 0 = /// 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. /// DualSense (the default — the section is zeroed), 1 = DualShock 4.
pub(super) const OFF_DEVTYPE: usize = 140; pub(super) const OFF_DEVTYPE: usize =
pub(super) const DEVTYPE_DUALSHOCK4: u8 = 1; 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 /// 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. /// 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. /// Dropping it removes the devnode (`SwDeviceClose`) and unmaps + closes the section.
struct DsWinPad { struct DsWinPad {
/// Per-session devnode from SwDeviceCreate, when it succeeds. `None` falls back to an out-of-band /// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
/// `pf_dualsense` devnode (installer/devgen). /// `None` falls back to an out-of-band `pf_dualsense` devnode (installer/devgen).
hsw: Option<HSWDEVICE>, _sw: Option<super::gamepad_raii::SwDevice>,
map: HANDLE, /// The named shared section the driver maps (RAII — unmapped + closed on drop).
view: *mut u8, shm: super::gamepad_raii::Shm,
seq: u8, seq: u8,
ts: u32, ts: u32,
last_out_seq: u32, last_out_seq: u32,
@@ -234,62 +231,16 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<HSWDEVICE> {
Ok(hsw) Ok(hsw)
} }
/// Create + map the named section `Global\pfds-shm-<index>`, zeroed, with a permissive DACL so the
/// WUDFHost (whatever account it runs as) can open it. Returns `(section handle, mapped base)`; the
/// caller stamps the device-type + initial input report and finally the magic. Shared by both Windows
/// pad backends (DualSense + DualShock 4).
pub(super) fn create_shm_section(index: u8) -> Result<(HANDLE, *mut u8)> {
let name = HSTRING::from(format!("Global\\pfds-shm-{index}"));
let mut psd = PSECURITY_DESCRIPTOR::default();
// SAFETY: the SDDL literal is valid; psd receives an allocated descriptor (freed by the OS when
// the process exits — acceptable for a host-lifetime object).
unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
w!("D:(A;;GA;;;WD)"),
SDDL_REVISION_1,
&mut psd,
None,
)?;
}
let sa = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: psd.0,
bInheritHandle: false.into(),
};
// SAFETY: anonymous (pagefile-backed) section of SHM_SIZE bytes with the SDDL above.
let map = unsafe {
CreateFileMappingW(
INVALID_HANDLE_VALUE,
Some(&sa),
PAGE_READWRITE,
0,
SHM_SIZE as u32,
PCWSTR(name.as_ptr()),
)?
};
// SAFETY: map is a valid section handle; map the whole thing.
let view = unsafe { MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, SHM_SIZE) };
if view.Value.is_null() {
// SAFETY: map is valid.
unsafe {
let _ = CloseHandle(map);
}
return Err(anyhow!("MapViewOfFile failed for {name}"));
}
let base = view.Value as *mut u8;
// SAFETY: base points at SHM_SIZE writable bytes.
unsafe { std::ptr::write_bytes(base, 0, SHM_SIZE) };
Ok((map, base))
}
impl DsWinPad { impl DsWinPad {
/// Create + map the section `Global\pfds-shm-<index>`, stamp the magic, then spawn the /// 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 /// `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`). /// for the pad's lifetime — dropping the pad removes it (`SwDeviceClose`).
fn open(index: u8) -> Result<DsWinPad> { 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 // 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). // once magic is set). The device-type stays 0 (DualSense — the section is already zeroed).
// SAFETY: base points at SHM_SIZE writable bytes. // SAFETY: base points at SHM_SIZE writable bytes.
@@ -318,10 +269,10 @@ impl DsWinPad {
None None
} }
}; };
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
Ok(DsWinPad { Ok(DsWinPad {
hsw, _sw,
map, shm,
view: base,
seq: 0, seq: 0,
ts: 0, ts: 0,
last_out_seq: 0, last_out_seq: 0,
@@ -334,22 +285,25 @@ impl DsWinPad {
self.ts = self.ts.wrapping_add(1); self.ts = self.ts.wrapping_add(1);
let mut r = [0u8; DS_INPUT_REPORT_LEN]; let mut r = [0u8; DS_INPUT_REPORT_LEN];
serialize_state(&mut r, st, self.seq, self.ts); serialize_state(&mut r, st, self.seq, self.ts);
// SAFETY: view points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64. // SAFETY: base 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()) }; 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 /// 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. /// [`DsFeedback`] for pad `pad`. Returns empty feedback if the driver hasn't published anything new.
fn service(&mut self, pad: u8) -> DsFeedback { fn service(&mut self, pad: u8) -> DsFeedback {
let mut fb = DsFeedback::default(); let mut fb = DsFeedback::default();
// SAFETY: view points at SHM_SIZE bytes. // SAFETY: base points at SHM_SIZE bytes.
let seq = unsafe { std::ptr::read_unaligned(self.view.add(OFF_OUT_SEQ) as *const u32) }; let seq =
unsafe { std::ptr::read_unaligned(self.shm.base().add(OFF_OUT_SEQ) as *const u32) };
if seq != self.last_out_seq { if seq != self.last_out_seq {
self.last_out_seq = seq; self.last_out_seq = seq;
let mut out = [0u8; 64]; let mut out = [0u8; 64];
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section. // SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
unsafe { 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); parse_ds_output(pad, &out, &mut fb);
} }
@@ -357,21 +311,6 @@ impl DsWinPad {
} }
} }
impl Drop for DsWinPad {
fn drop(&mut self) {
// SAFETY: hsw (if any) owns the devnode; view/map from MapViewOfFile/CreateFileMappingW.
unsafe {
if let Some(h) = self.hsw {
SwDeviceClose(h);
}
let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS {
Value: self.view as *mut c_void,
});
let _ = CloseHandle(self.map);
}
}
}
/// All virtual DualSense pads of a session — the Windows analogue of /// All virtual DualSense pads of a session — the Windows analogue of
/// [`DualSenseManager`](super::dualsense::DualSenseManager). Same method surface so the session input /// [`DualSenseManager`](super::dualsense::DualSenseManager). Same method surface so the session input
/// thread drives either backend identically. /// thread drives either backend identically.
@@ -9,8 +9,8 @@
use super::dualsense_proto::DsState; use super::dualsense_proto::DsState;
use super::dualsense_windows::{ use super::dualsense_windows::{
create_shm_section, create_swdevice, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE, create_swdevice, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE, OFF_INPUT, OFF_OUTPUT,
OFF_INPUT, OFF_OUTPUT, OFF_OUT_SEQ, SHM_MAGIC, OFF_OUT_SEQ, SHM_MAGIC, SHM_SIZE,
}; };
use super::dualshock4_proto::{ use super::dualshock4_proto::{
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_TOUCH_H, DS4_TOUCH_W, 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 crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use anyhow::Result; use anyhow::Result;
use punktfunk_core::quic::{HidOutput, RichInput}; use punktfunk_core::quic::{HidOutput, RichInput};
use std::ffi::c_void;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE}; use windows::core::HSTRING;
use windows::Win32::Foundation::{CloseHandle, HANDLE};
use windows::Win32::System::Memory::{UnmapViewOfFile, MEMORY_MAPPED_VIEW_ADDRESS};
/// A single virtual DualShock 4: the `SwDeviceCreate`'d `pf_ds4_<index>` devnode plus the mapped /// 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. /// shared section. Dropping it removes the devnode and unmaps + closes the section.
struct Ds4WinPad { struct Ds4WinPad {
hsw: Option<HSWDEVICE>, /// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
map: HANDLE, _sw: Option<super::gamepad_raii::SwDevice>,
view: *mut u8, /// The named shared section the driver maps (RAII — unmapped + closed on drop).
shm: super::gamepad_raii::Shm,
counter: u8, counter: u8,
ts: u16, ts: u16,
last_out_seq: u32, last_out_seq: u32,
@@ -39,7 +37,11 @@ impl Ds4WinPad {
/// Create + map the section, stamp `device_type = DualShock 4` + a neutral report + the magic, /// 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). /// then spawn the `pf_ds4_<index>` devnode (the driver loads on it and maps the section).
fn open(index: u8) -> Result<Ds4WinPad> { 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. // 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. // SAFETY: base points at SHM_SIZE writable bytes; OFF_DEVTYPE/OFF_INPUT are in range.
unsafe { unsafe {
@@ -65,10 +67,10 @@ impl Ds4WinPad {
None None
} }
}; };
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
Ok(Ds4WinPad { Ok(Ds4WinPad {
hsw, _sw,
map, shm,
view: base,
counter: 0, counter: 0,
ts: 0, ts: 0,
last_out_seq: 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 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]; let mut r = [0u8; DS4_INPUT_REPORT_LEN];
serialize_state(&mut r, st, self.counter, self.ts); serialize_state(&mut r, st, self.counter, self.ts);
// SAFETY: view points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64. // SAFETY: base 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()) }; 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 /// 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. /// [`Ds4Feedback`]. Returns empty feedback if the driver hasn't published anything new.
fn service(&mut self) -> Ds4Feedback { fn service(&mut self) -> Ds4Feedback {
let mut fb = Ds4Feedback::default(); let mut fb = Ds4Feedback::default();
// SAFETY: view points at SHM_SIZE bytes. // SAFETY: base points at SHM_SIZE bytes.
let seq = unsafe { std::ptr::read_unaligned(self.view.add(OFF_OUT_SEQ) as *const u32) }; let seq =
unsafe { std::ptr::read_unaligned(self.shm.base().add(OFF_OUT_SEQ) as *const u32) };
if seq != self.last_out_seq { if seq != self.last_out_seq {
self.last_out_seq = seq; self.last_out_seq = seq;
let mut out = [0u8; 64]; let mut out = [0u8; 64];
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section. // SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
unsafe { 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); 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 /// All virtual DualShock 4 pads of a session — the Windows analogue of
/// [`DualShock4Manager`](super::dualshock4::DualShock4Manager), with the same method surface as the /// [`DualShock4Manager`](super::dualshock4::DualShock4Manager), with the same method surface as the
/// Windows DualSense manager so the session input thread drives either backend identically. /// 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) };
}
}
@@ -21,30 +21,25 @@ use windows::core::{w, GUID, HRESULT, HSTRING, PCWSTR};
use windows::Win32::Devices::Enumeration::Pnp::{ use windows::Win32::Devices::Enumeration::Pnp::{
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO, SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
}; };
use windows::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}; use windows::Win32::Foundation::{CloseHandle, HANDLE};
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::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject}; use windows::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject};
// Shared-section layout — must match `packaging/windows/xusb-driver/src/lib.rs`. // Shared-section layout — the single source of truth is `pf_driver_proto::gamepad::XusbShm` (offset
const SHM_SIZE: usize = 64; // asserts pin every field; the `pf_xusb` driver maps the same struct). Derive the size/offsets/magic from
const SHM_MAGIC: u32 = 0x5558_4650; // "PFXU" // it so a layout change is a compile error, not a hand-synced literal (audit §6.1).
const OFF_PACKET: usize = 4; use pf_driver_proto::gamepad::XusbShm;
const OFF_BUTTONS: usize = 8; const SHM_SIZE: usize = core::mem::size_of::<XusbShm>();
const OFF_LT: usize = 10; const SHM_MAGIC: u32 = pf_driver_proto::gamepad::XUSB_MAGIC; // "PFXU"
const OFF_RT: usize = 11; const OFF_PACKET: usize = core::mem::offset_of!(XusbShm, packet);
const OFF_LX: usize = 12; const OFF_BUTTONS: usize = core::mem::offset_of!(XusbShm, buttons);
const OFF_LY: usize = 14; const OFF_LT: usize = core::mem::offset_of!(XusbShm, left_trigger);
const OFF_RX: usize = 16; const OFF_RT: usize = core::mem::offset_of!(XusbShm, right_trigger);
const OFF_RY: usize = 18; const OFF_LX: usize = core::mem::offset_of!(XusbShm, thumb_lx);
const OFF_RUMBLE_SEQ: usize = 24; const OFF_LY: usize = core::mem::offset_of!(XusbShm, thumb_ly);
const OFF_RUMBLE: usize = 28; // large @28, small @29 const OFF_RX: usize = core::mem::offset_of!(XusbShm, thumb_rx);
const OFF_RY: usize = core::mem::offset_of!(XusbShm, thumb_ry);
const OFF_RUMBLE_SEQ: usize = core::mem::offset_of!(XusbShm, rumble_seq);
const OFF_RUMBLE: usize = core::mem::offset_of!(XusbShm, rumble_large); // large @28, small @29
/// Context for the `SwDeviceCreate` completion callback: an event to signal + the HRESULT it reports. /// Context for the `SwDeviceCreate` completion callback: an event to signal + the HRESULT it reports.
#[repr(C)] #[repr(C)]
@@ -147,9 +142,10 @@ fn create_swdevice(index: u8) -> Result<HSWDEVICE> {
/// A single virtual Xbox 360 pad: the `pf_xusb_<index>` devnode plus the mapped shared section. /// A single virtual Xbox 360 pad: the `pf_xusb_<index>` devnode plus the mapped shared section.
struct XusbWinPad { struct XusbWinPad {
hsw: Option<HSWDEVICE>, /// Owns the `pf_xusb_<index>` devnode (dropped → `SwDeviceClose`). `None` if `SwDeviceCreate` failed.
map: HANDLE, _sw: Option<super::gamepad_raii::SwDevice>,
view: *mut u8, /// Owns `Global\pfxusb-shm-<index>` (the section + its mapped view; drop unmaps + closes).
shm: super::gamepad_raii::Shm,
packet: u32, packet: u32,
last_rumble_seq: u32, last_rumble_seq: u32,
} }
@@ -157,45 +153,13 @@ struct XusbWinPad {
impl XusbWinPad { impl XusbWinPad {
/// Create + map `Global\pfxusb-shm-<index>`, stamp the magic, then spawn the devnode. /// Create + map `Global\pfxusb-shm-<index>`, stamp the magic, then spawn the devnode.
fn open(index: u8) -> Result<XusbWinPad> { fn open(index: u8) -> Result<XusbWinPad> {
let name = HSTRING::from(format!("Global\\pfxusb-shm-{index}")); // 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.
// Permissive DACL so the WUDFHost (whatever account) can open the section. let shm = super::gamepad_raii::Shm::create(
let mut psd = PSECURITY_DESCRIPTOR::default(); &HSTRING::from(pf_driver_proto::gamepad::xusb_shm_name(index)),
// SAFETY: SDDL literal valid; psd receives an OS-freed descriptor (host-lifetime — fine). SHM_SIZE,
unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
w!("D:(A;;GA;;;WD)"),
SDDL_REVISION_1,
&mut psd,
None,
)?; )?;
} let base = shm.base();
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;
// Zero the section then stamp the magic LAST (the driver only accepts it once magic is set). // 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. // SAFETY: base points at SHM_SIZE writable bytes.
unsafe { unsafe {
@@ -209,10 +173,10 @@ impl XusbWinPad {
None None
} }
}; };
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
Ok(XusbWinPad { Ok(XusbWinPad {
hsw, _sw,
map, shm,
view: base,
packet: 0, packet: 0,
last_rumble_seq: 0, last_rumble_seq: 0,
}) })
@@ -223,50 +187,38 @@ impl XusbWinPad {
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn write_state(&mut self, buttons: u16, lt: u8, rt: u8, lx: i16, ly: i16, rx: i16, ry: i16) { 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); self.packet = self.packet.wrapping_add(1);
// SAFETY: view points at SHM_SIZE bytes; all offsets are in range. let base = self.shm.base();
// SAFETY: `base` is the start of the mapped section (`SHM_SIZE` bytes, owned by `Shm`); every
// `OFF_*` is a fixed in-range offset into it and `write_unaligned` handles the unaligned field
// writes. Single owner (`&mut self`), so no concurrent writer races these stores.
unsafe { unsafe {
std::ptr::write_unaligned(self.view.add(OFF_BUTTONS) as *mut u16, buttons); std::ptr::write_unaligned(base.add(OFF_BUTTONS) as *mut u16, buttons);
*self.view.add(OFF_LT) = lt; *base.add(OFF_LT) = lt;
*self.view.add(OFF_RT) = rt; *base.add(OFF_RT) = rt;
std::ptr::write_unaligned(self.view.add(OFF_LX) as *mut i16, lx); std::ptr::write_unaligned(base.add(OFF_LX) as *mut i16, lx);
std::ptr::write_unaligned(self.view.add(OFF_LY) as *mut i16, ly); std::ptr::write_unaligned(base.add(OFF_LY) as *mut i16, ly);
std::ptr::write_unaligned(self.view.add(OFF_RX) as *mut i16, rx); std::ptr::write_unaligned(base.add(OFF_RX) as *mut i16, rx);
std::ptr::write_unaligned(self.view.add(OFF_RY) as *mut i16, ry); std::ptr::write_unaligned(base.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_PACKET) as *mut u32, self.packet);
} }
} }
/// Poll the section for a game's rumble (the driver bumps `rumble_seq` on each SET_STATE). Returns /// 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. /// `(large, small)` motor levels (0..=255) when a new one arrived.
fn service(&mut self) -> Option<(u8, u8)> { fn service(&mut self) -> Option<(u8, u8)> {
// SAFETY: view points at SHM_SIZE bytes. let base = self.shm.base();
let seq = unsafe { std::ptr::read_unaligned(self.view.add(OFF_RUMBLE_SEQ) as *const u32) }; // 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 { if seq == self.last_rumble_seq {
return None; return None;
} }
self.last_rumble_seq = seq; self.last_rumble_seq = seq;
// SAFETY: rumble bytes at OFF_RUMBLE / OFF_RUMBLE+1. // SAFETY: rumble bytes at OFF_RUMBLE / OFF_RUMBLE+1.
let (large, small) = let (large, small) = unsafe { (*base.add(OFF_RUMBLE), *base.add(OFF_RUMBLE + 1)) };
unsafe { (*self.view.add(OFF_RUMBLE), *self.view.add(OFF_RUMBLE + 1)) };
Some((large, small)) 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, /// 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 /// now backed by the XUSB companion driver. Same method surface (`new`/`handle`/`pump_rumble`) the
/// session input thread already drives. /// session input thread already drives.
@@ -5,6 +5,9 @@
//! thread stays bound to its desktop and only reattaches (`OpenInputDesktop`/`SetThreadDesktop`) when //! thread stays bound to its desktop and only reattaches (`OpenInputDesktop`/`SetThreadDesktop`) when
//! `SendInput` reports a short write (the input desktop switched) — no per-event reattach overhead. //! `SendInput` reports a short write (the input desktop switched) — no per-event reattach overhead.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::Result; use anyhow::Result;
use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::input::{InputEvent, InputKind};
use std::mem::size_of; use std::mem::size_of;
@@ -35,7 +38,12 @@ pub struct SendInputInjector {
desktop: Option<HDESK>, desktop: Option<HDESK>,
} }
// Only ever used from the host's single injector thread (like SudoVdaDisplay). // SAFETY: `SendInputInjector` holds only an `Option<HDESK>` (a desktop handle). The host creates
// and drives it from a single dedicated injector thread; the handle is opened, rebound, and closed
// on whichever thread owns the value, and the type is not `Sync`, so there is never concurrent
// access. A desktop `HDESK` is not thread-affine for ownership (`CloseDesktop` works from any
// thread; `SetThreadDesktop` rebinds the current thread), so transferring ownership via `Send` is
// sound.
unsafe impl Send for SendInputInjector {} unsafe impl Send for SendInputInjector {}
impl SendInputInjector { impl SendInputInjector {
@@ -49,6 +57,12 @@ impl SendInputInjector {
/// Bind this thread to the desktop currently receiving input. UAC / lock screen / Ctrl-Alt-Del /// Bind this thread to the desktop currently receiving input. UAC / lock screen / Ctrl-Alt-Del
/// swap the input desktop; `SendInput` silently no-ops unless our thread is on it. /// swap the input desktop; `SendInput` silently no-ops unless our thread is on it.
fn reattach_input_desktop(&mut self) { fn reattach_input_desktop(&mut self) {
// SAFETY: `OpenInputDesktop`/`SetThreadDesktop`/`CloseDesktop` are FFI calls passed only
// by-value args (constant desktop flags, a `bool`, an access mask). `OpenInputDesktop`
// yields an owned `HDESK` only on `Ok`; we then either install it with `SetThreadDesktop`
// (closing the previously-owned handle exactly once) or close the fresh handle on failure —
// so every handle is closed exactly once and none is used after close. `SetThreadDesktop`
// only rebinds this calling thread, which is where the injector runs.
unsafe { unsafe {
match OpenInputDesktop( match OpenInputDesktop(
DESKTOP_CONTROL_FLAGS(0), DESKTOP_CONTROL_FLAGS(0),
@@ -75,12 +89,17 @@ impl SendInputInjector {
/// switched out from under us, e.g. into UAC/lock) do we reattach to the now-current input desktop /// switched out from under us, e.g. into UAC/lock) do we reattach to the now-current input desktop
/// and retry once. This serves both the normal and secure desktops with no steady-state overhead. /// and retry once. This serves both the normal and secure desktops with no steady-state overhead.
fn send(&mut self, inputs: &[INPUT]) -> Result<()> { fn send(&mut self, inputs: &[INPUT]) -> Result<()> {
// SAFETY: `inputs` is a live `&[INPUT]` slice that outlives this synchronous `SendInput`
// call; `size_of::<INPUT>()` is the exact per-element stride Win32 requires as `cbSize`. The
// call only reads the array (one event per element) and returns the count injected.
let n = unsafe { SendInput(inputs, size_of::<INPUT>() as i32) }; let n = unsafe { SendInput(inputs, size_of::<INPUT>() as i32) };
if n as usize == inputs.len() { if n as usize == inputs.len() {
return Ok(()); return Ok(());
} }
// Short write → the input desktop likely changed. Reattach + retry once. // Short write → the input desktop likely changed. Reattach + retry once.
self.reattach_input_desktop(); self.reattach_input_desktop();
// SAFETY: same as the first `SendInput` — `inputs` is the identical live slice outliving the
// call and `cbSize == size_of::<INPUT>()`; only re-issued after reattaching the input desktop.
let n = unsafe { SendInput(inputs, size_of::<INPUT>() as i32) }; let n = unsafe { SendInput(inputs, size_of::<INPUT>() as i32) };
if n as usize != inputs.len() { if n as usize != inputs.len() {
anyhow::bail!( anyhow::bail!(
@@ -95,6 +114,9 @@ impl SendInputInjector {
impl Drop for SendInputInjector { impl Drop for SendInputInjector {
fn drop(&mut self) { fn drop(&mut self) {
if let Some(h) = self.desktop.take() { if let Some(h) = self.desktop.take() {
// SAFETY: `h` is the `HDESK` this injector owned (moved out of `self.desktop`);
// `CloseDesktop` runs once here in `Drop` on that still-valid handle, with no later use —
// no double close.
unsafe { unsafe {
let _ = CloseDesktop(h); let _ = CloseDesktop(h);
} }
@@ -216,7 +238,11 @@ impl InputInjector for SendInputInjector {
} }
InputKind::KeyDown | InputKind::KeyUp => { InputKind::KeyDown | InputKind::KeyUp => {
let down = event.kind == InputKind::KeyDown; let down = event.kind == InputKind::KeyDown;
let vk = (event.code & 0xff) as u16; // client sends Windows VK // client sends Windows VK
let vk = (event.code & 0xff) as u16;
// SAFETY: `MapVirtualKeyExW` is a pure value translation (VK → scancode); all three
// args are by-value (`u32`, the `MAPVK_VK_TO_VSC_EX` map-type constant, a `None`
// HKL). It dereferences no pointer and returns a `u32` — FFI-`unsafe` only.
let sc_ex = unsafe { MapVirtualKeyExW(vk as u32, MAPVK_VK_TO_VSC_EX, None) }; let sc_ex = unsafe { MapVirtualKeyExW(vk as u32, MAPVK_VK_TO_VSC_EX, None) };
if sc_ex == 0 { if sc_ex == 0 {
return Ok(()); // unmappable -> drop return Ok(()); // unmappable -> drop
@@ -264,6 +290,8 @@ fn key(ki: KEYBDINPUT) -> INPUT {
} }
fn virtual_desktop_rect() -> (i32, i32, i32, i32) { fn virtual_desktop_rect() -> (i32, i32, i32, i32) {
// SAFETY: each `GetSystemMetrics` takes a single by-value `SYSTEM_METRICS_INDEX` constant and
// returns an `i32`; it dereferences no pointer and has no side effects — FFI-`unsafe` only.
unsafe { unsafe {
( (
GetSystemMetrics(SM_XVIRTUALSCREEN), GetSystemMetrics(SM_XVIRTUALSCREEN),
File diff suppressed because it is too large Load Diff
@@ -13,6 +13,9 @@
//! attaches none, the export yields an already-signaled sync_file (poll returns immediately) — no //! attaches none, the export yields an already-signaled sync_file (poll returns immediately) — no
//! wait, no harm, and `waited=false` tells us the driver doesn't fence (so zero-copy would still race). //! wait, no harm, and `waited=false` tells us the driver doesn't fence (so zero-copy would still race).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use std::os::fd::RawFd; use std::os::fd::RawFd;
// linux/dma-buf.h ioctls on the DMA_BUF_BASE ('b' = 0x62) magic. _IOWR = dir(3)<<30 | size<<16 | base<<8 | nr. // linux/dma-buf.h ioctls on the DMA_BUF_BASE ('b' = 0x62) magic. _IOWR = dir(3)<<30 | size<<16 | base<<8 | nr.
@@ -40,6 +43,11 @@ pub fn wait_read_ready(dmabuf_fd: RawFd, timeout_ms: i32) -> std::io::Result<boo
flags: DMA_BUF_SYNC_READ, flags: DMA_BUF_SYNC_READ,
fd: -1, fd: -1,
}; };
// SAFETY: `dmabuf_fd` is a live dmabuf fd supplied by the caller (borrowed for this call; we
// never close it). `DMA_BUF_IOCTL_EXPORT_SYNC_FILE` encodes `size_of::<DmaBufExportSyncFile>()`
// — the exact byte count the kernel copies — and `&mut req` is a live, correctly-sized
// `#[repr(C)]` struct the EXPORT_SYNC_FILE ioctl reads (`flags`) and writes (`fd`). `req`
// outlives this synchronous call and is not aliased elsewhere.
let r = unsafe { libc::ioctl(dmabuf_fd, DMA_BUF_IOCTL_EXPORT_SYNC_FILE, &mut req) }; let r = unsafe { libc::ioctl(dmabuf_fd, DMA_BUF_IOCTL_EXPORT_SYNC_FILE, &mut req) };
if r < 0 { if r < 0 {
return Err(std::io::Error::last_os_error()); return Err(std::io::Error::last_os_error());
@@ -54,11 +62,21 @@ pub fn wait_read_ready(dmabuf_fd: RawFd, timeout_ms: i32) -> std::io::Result<boo
revents: 0, revents: 0,
}; };
// Non-blocking probe: not-yet-signaled (poll==0) means the producer is still rendering. // Non-blocking probe: not-yet-signaled (poll==0) means the producer is still rendering.
// SAFETY: `&mut pfd` points at a single live `libc::pollfd` and `nfds == 1` matches that one
// element; `pfd.fd` is `sync_fd`, the sync_file fd just exported (already checked `>= 0`).
// `poll` reads `fd`/`events` and writes `revents` for this non-blocking (timeout 0) probe, then
// returns — `pfd` outlives the call and aliases nothing.
let pending = unsafe { libc::poll(&mut pfd, 1, 0) } == 0; let pending = unsafe { libc::poll(&mut pfd, 1, 0) } == 0;
if pending { if pending {
pfd.revents = 0; pfd.revents = 0;
// SAFETY: same live single-element `pfd` (its `revents` reset to 0 just above), `nfds == 1`,
// and `sync_fd` still open. This blocking `poll` (up to `timeout_ms`) waits for the render
// fence to signal; it reads `fd`/`events`, writes `revents`, and returns before `pfd` ends.
unsafe { libc::poll(&mut pfd, 1, timeout_ms) }; // block until the render fence signals unsafe { libc::poll(&mut pfd, 1, timeout_ms) }; // block until the render fence signals
} }
// SAFETY: `sync_fd` is the sync_file fd the EXPORT_SYNC_FILE ioctl created and handed us to own;
// this point is reached only when `sync_fd >= 0`, this `close` runs exactly once on it, and it is
// never used afterward — no double-close or use-after-close.
unsafe { libc::close(sync_fd) }; unsafe { libc::close(sync_fd) };
Ok(pending) Ok(pending)
} }
@@ -8,6 +8,8 @@
//! verified (ioctl numbers + a live signal→wait round trip), ready to wire in the moment a producer //! verified (ioctl numbers + a live signal→wait round trip), ready to wire in the moment a producer
//! gains working `SPA_META_SyncTimeline`. //! gains working `SPA_META_SyncTimeline`.
#![allow(dead_code)] #![allow(dead_code)]
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
//! //!
//! Compositors that render directly into the PipeWire buffer pool (Mutter's virtual //! Compositors that render directly into the PipeWire buffer pool (Mutter's virtual
//! monitors) hand buffers over at GPU-submit time; on drivers without implicit dmabuf //! monitors) hand buffers over at GPU-submit time; on drivers without implicit dmabuf
@@ -81,6 +83,8 @@ pub struct DrmSync {
impl DrmSync { impl DrmSync {
pub fn open() -> Result<DrmSync> { pub fn open() -> Result<DrmSync> {
let path = c"/dev/dri/renderD128"; let path = c"/dev/dri/renderD128";
// SAFETY: `path` is a 'static NUL-terminated C string literal; `open` only reads it as a
// filesystem path and returns an fd (or -1). No Rust memory is aliased or handed to the kernel.
let fd = unsafe { libc::open(path.as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) }; let fd = unsafe { libc::open(path.as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) };
if fd < 0 { if fd < 0 {
bail!("open /dev/dri/renderD128 for syncobj ops: {}", errno()); bail!("open /dev/dri/renderD128 for syncobj ops: {}", errno());
@@ -94,6 +98,9 @@ impl DrmSync {
fd: syncobj_fd, fd: syncobj_fd,
..Default::default() ..Default::default()
}; };
// SAFETY: `self.fd` is the live render-node fd from `open`; the request number encodes
// `size_of::<DrmSyncobjHandle>()` (the bytes the kernel copies), and `&mut req` is a live,
// correctly-sized `#[repr(C)]` struct the FD_TO_HANDLE ioctl reads (`fd`) and writes (`handle`).
let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_FD_TO_HANDLE, &mut req) }; let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_FD_TO_HANDLE, &mut req) };
if r < 0 { if r < 0 {
bail!("SYNCOBJ_FD_TO_HANDLE: {}", errno()); bail!("SYNCOBJ_FD_TO_HANDLE: {}", errno());
@@ -106,6 +113,8 @@ impl DrmSync {
handle, handle,
..Default::default() ..Default::default()
}; };
// SAFETY: `self.fd` is the live render-node fd; `DRM_IOCTL_SYNCOBJ_DESTROY` encodes
// `size_of::<DrmSyncobjDestroy>()`, and `&mut req` is a live correctly-sized struct the kernel reads.
unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_DESTROY, &mut req) }; unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_DESTROY, &mut req) };
} }
@@ -117,6 +126,8 @@ impl DrmSync {
tv_sec: 0, tv_sec: 0,
tv_nsec: 0, tv_nsec: 0,
}; };
// SAFETY: `CLOCK_MONOTONIC` is a valid clock id and `&mut now` is a live `libc::timespec` the
// kernel fills in; the call returns before `now` is read, so there is no aliasing/lifetime issue.
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut now) }; unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut now) };
let deadline = now.tv_sec * 1_000_000_000 + now.tv_nsec + timeout_ms as i64 * 1_000_000; let deadline = now.tv_sec * 1_000_000_000 + now.tv_nsec + timeout_ms as i64 * 1_000_000;
let handles = [handle]; let handles = [handle];
@@ -129,6 +140,11 @@ impl DrmSync {
flags: DRM_SYNCOBJ_WAIT_FLAGS_WAIT_FOR_SUBMIT, flags: DRM_SYNCOBJ_WAIT_FLAGS_WAIT_FOR_SUBMIT,
..Default::default() ..Default::default()
}; };
// SAFETY: `self.fd` is the live render-node fd; the request number encodes
// `size_of::<DrmSyncobjTimelineWait>()`; `&mut req` is a live correctly-sized struct. Its
// `handles`/`points` u64 fields hold the addresses of the local `handles`/`points` arrays, which
// outlive this synchronous call, and `count_handles == 1` matches their length — so every kernel
// read through those addresses stays in bounds.
let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_TIMELINE_WAIT, &mut req) }; let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_TIMELINE_WAIT, &mut req) };
let saved = errno(); let saved = errno();
self.destroy(handle); self.destroy(handle);
@@ -151,6 +167,10 @@ impl DrmSync {
count_handles: 1, count_handles: 1,
flags: 0, flags: 0,
}; };
// SAFETY: `self.fd` is the live render-node fd; the request number encodes
// `size_of::<DrmSyncobjTimelineArray>()`; `&mut req` is a live correctly-sized struct whose
// `handles`/`points` u64 fields address the local `handles`/`points` arrays (alive for this
// synchronous call, `count_handles == 1` matching their length).
let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_TIMELINE_SIGNAL, &mut req) }; let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_TIMELINE_SIGNAL, &mut req) };
let saved = errno(); let saved = errno();
self.destroy(handle); self.destroy(handle);
@@ -163,6 +183,8 @@ impl DrmSync {
impl Drop for DrmSync { impl Drop for DrmSync {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `self.fd` is the fd `open` returned; this `DrmSync` owns it exclusively and `close`
// runs exactly once (here, in `Drop`), so there is no double-close or use-after-close.
unsafe { libc::close(self.fd) }; unsafe { libc::close(self.fd) };
} }
} }
@@ -203,14 +225,19 @@ mod tests {
const CREATE: u64 = iowr(0xBF, std::mem::size_of::<Create>()); const CREATE: u64 = iowr(0xBF, std::mem::size_of::<Create>());
const HANDLE_TO_FD: u64 = iowr(0xC1, std::mem::size_of::<DrmSyncobjHandle>()); const HANDLE_TO_FD: u64 = iowr(0xC1, std::mem::size_of::<DrmSyncobjHandle>());
let mut c = Create::default(); let mut c = Create::default();
// SAFETY: `sync.fd` is the live render-node fd; `CREATE` encodes `size_of::<Create>()`, and
// `&mut c` is a live correctly-sized struct the kernel fills (`handle`).
assert!(unsafe { libc::ioctl(sync.fd, CREATE, &mut c) } >= 0); assert!(unsafe { libc::ioctl(sync.fd, CREATE, &mut c) } >= 0);
let mut h = DrmSyncobjHandle { let mut h = DrmSyncobjHandle {
handle: c.handle, handle: c.handle,
..Default::default() ..Default::default()
}; };
// SAFETY: `sync.fd` is live; `HANDLE_TO_FD` encodes `size_of::<DrmSyncobjHandle>()`; `&mut h`
// is a live correctly-sized struct (the kernel reads `handle`, writes `fd`).
assert!(unsafe { libc::ioctl(sync.fd, HANDLE_TO_FD, &mut h) } >= 0); assert!(unsafe { libc::ioctl(sync.fd, HANDLE_TO_FD, &mut h) } >= 0);
sync.signal_point(h.fd, 1).expect("signal"); sync.signal_point(h.fd, 1).expect("signal");
sync.wait_point(h.fd, 1, 100).expect("wait after signal"); sync.wait_point(h.fd, 1, 100).expect("wait after signal");
// SAFETY: `h.fd` is the fd HANDLE_TO_FD just exported; we own it and close it exactly once here.
unsafe { libc::close(h.fd) }; unsafe { libc::close(h.fd) };
sync.destroy(c.handle); sync.destroy(c.handle);
} }
@@ -11,6 +11,8 @@
//! thread) and ffmpeg's `hevc_nvenc` (encode thread); each thread makes it current before use. //! thread) and ffmpeg's `hevc_nvenc` (encode thread); each thread makes it current before use.
#![allow(non_camel_case_types, non_snake_case)] #![allow(non_camel_case_types, non_snake_case)]
// Every `unsafe` block/impl below carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use std::os::raw::{c_int, c_uint, c_void}; use std::os::raw::{c_int, c_uint, c_void};
@@ -128,8 +130,14 @@ struct CudaApi {
) -> CUresult, ) -> CUresult,
cuDestroyExternalMemory: unsafe extern "C" fn(CUexternalMemory) -> CUresult, cuDestroyExternalMemory: unsafe extern "C" fn(CUexternalMemory) -> CUresult,
} }
// The resolved fn pointers are plain addresses into a process-lifetime mapping; safe to share. // SAFETY: every field is a bare `extern "C" fn` address into the leaked, process-lifetime
// `libcuda` mapping (`cuda_api` `forget`s the `Library`, so it is never unloaded) — an immutable
// value with no interior mutability and no thread affinity. Moving the table to another thread
// cannot dangle (the code it points at stays mapped) or race (the fields are read-only).
unsafe impl Send for CudaApi {} unsafe impl Send for CudaApi {}
// SAFETY: as above — the table is a set of immutable fn-pointer addresses with no interior
// mutability, so concurrent shared reads from multiple threads cannot race; the driver entry
// points they address are themselves thread-safe.
unsafe impl Sync for CudaApi {} unsafe impl Sync for CudaApi {}
/// `CUresult` returned by the wrappers when `libcuda` isn't loaded (no NVIDIA driver). Non-zero so /// `CUresult` returned by the wrappers when `libcuda` isn't loaded (no NVIDIA driver). Non-zero so
@@ -143,6 +151,14 @@ static CUDA_API: OnceLock<Option<CudaApi>> = OnceLock::new();
/// (the expected case on AMD/Intel hosts) — logged at debug, not an error. /// (the expected case on AMD/Intel hosts) — logged at debug, not an error.
fn cuda_api() -> Option<&'static CudaApi> { fn cuda_api() -> Option<&'static CudaApi> {
CUDA_API CUDA_API
// SAFETY: `Library::new` runs `libcuda.so.1`'s initializers — it is the trusted NVIDIA
// driver library, so loading has no unexpected effects; `?`/`None` handle its absence.
// Each `lib.get::<T>(name)` asserts the symbol's real ABI equals `T`: every NUL-terminated
// name is a documented CUDA Driver API entry point and `T` is the exact
// `unsafe extern "C" fn(..)` signature from cuda.h/cudaGL.h (`_v2` for ctx/mem ops). Each
// `Symbol` only borrows `lib` until the end of the struct-literal statement; we deref-copy
// the raw fn-pointer out first, then `forget(lib)` leaks the mapping so those addresses
// stay valid for the whole process. Runs once under the `OnceLock` init — no aliasing.
.get_or_init(|| unsafe { .get_or_init(|| unsafe {
let lib = libloading::Library::new("libcuda.so.1") let lib = libloading::Library::new("libcuda.so.1")
.or_else(|_| libloading::Library::new("libcuda.so")) .or_else(|_| libloading::Library::new("libcuda.so"))
@@ -361,6 +377,12 @@ pub fn read_plane_to_host(
Height: height, Height: height,
..Default::default() ..Default::default()
}; };
// SAFETY: `copy_blocking` is unsafe because it issues a CUDA copy; its contract is a valid
// descriptor with the shared context current (the caller's responsibility — self-test path).
// `&copy` is a live local `#[repr(C)] CUDA_MEMCPY2D` that outlives the synchronous call:
// `srcDevice`/`srcPitch` are the caller's live pitched device plane, `dstHost` addresses the
// freshly-allocated `host` `Vec` of exactly `width_bytes*height` bytes, and `WidthInBytes`×
// `Height` fit both. The copy is synchronous, so `host` is fully written before we return it.
unsafe { copy_blocking(&copy, "cuMemcpy2DAsync_v2(dev->host)")? }; unsafe { copy_blocking(&copy, "cuMemcpy2DAsync_v2(dev->host)")? };
Ok(host) Ok(host)
} }
@@ -369,7 +391,13 @@ pub fn read_plane_to_host(
/// in a `OnceLock`; the raw `CUcontext` is thread-safe to make current from any thread. /// in a `OnceLock`; the raw `CUcontext` is thread-safe to make current from any thread.
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct Context(pub CUcontext); pub struct Context(pub CUcontext);
// SAFETY: `CUcontext` is an opaque CUDA driver handle, not a dereferenceable Rust pointer. It is
// created once and never destroyed (process lifetime), and the only thing done with it is
// `cuCtxSetCurrent`, which the Driver API explicitly allows from any thread — so transferring the
// handle to another thread cannot dangle or race (the driver owns the synchronization).
unsafe impl Send for Context {} unsafe impl Send for Context {}
// SAFETY: as above — the wrapped handle is an immutable opaque address and the driver does all the
// synchronization, so sharing `&Context` across threads is sound.
unsafe impl Sync for Context {} unsafe impl Sync for Context {}
static CONTEXT: OnceLock<Context> = OnceLock::new(); static CONTEXT: OnceLock<Context> = OnceLock::new();
@@ -382,6 +410,12 @@ pub fn context() -> Result<CUcontext> {
if cuda_api().is_none() { if cuda_api().is_none() {
bail!("libcuda.so.1 not available — no NVIDIA driver (CUDA zero-copy disabled)"); bail!("libcuda.so.1 not available — no NVIDIA driver (CUDA zero-copy disabled)");
} }
// SAFETY: we returned above unless `cuda_api()` is `Some`, so every wrapper here forwards into
// the live, leaked `libcuda` table rather than the not-loaded stub. `cuInit(0)` passes the
// API-required flags value 0. `&mut dev`/`&mut ctx` are live, zero/null-initialized stack
// out-params the driver writes the device handle / new context into; each outlives its
// synchronous call and they are distinct locals (no aliasing). `cuCtxCreate_v2` yields a valid
// `CUcontext` on success (`ck` bails otherwise), which becomes the block's value.
let ctx = unsafe { let ctx = unsafe {
ck(cuInit(0), "cuInit")?; ck(cuInit(0), "cuInit")?;
let mut dev: CUdevice = 0; let mut dev: CUdevice = 0;
@@ -401,6 +435,10 @@ pub fn context() -> Result<CUcontext> {
/// Make the shared context current on the calling thread (required before any CUDA op here). /// Make the shared context current on the calling thread (required before any CUDA op here).
pub fn make_current() -> Result<()> { pub fn make_current() -> Result<()> {
let ctx = context()?; let ctx = context()?;
// SAFETY: `ctx` came from `context()?`, so it is the live shared `CUcontext` and the driver
// table is present. `cuCtxSetCurrent` binds that opaque handle to the calling thread; it takes
// no Rust-memory pointer and is thread-safe (affects only this thread's current context), so
// there is no aliasing or lifetime hazard.
unsafe { ck(cuCtxSetCurrent(ctx), "cuCtxSetCurrent") } unsafe { ck(cuCtxSetCurrent(ctx), "cuCtxSetCurrent") }
} }
@@ -423,6 +461,12 @@ fn copy_stream() -> CUstream {
if let Some(s) = cell.get() { if let Some(s) = cell.get() {
return s; return s;
} }
// SAFETY: `copy_stream` runs with the shared context current (its doc contract), so the
// wrappers forward into the live `libcuda` table. `&mut least`/`&mut greatest` are live
// stack `i32`s the driver fills with the priority range; `&mut s` is a live null-init
// `CUstream` the driver writes the new stream into. All out-params outlive their
// synchronous calls and are distinct locals. On any non-zero result we fall back to a null
// (NULL-stream) value and never read an uninitialized handle.
let stream = unsafe { let stream = unsafe {
let (mut least, mut greatest) = (0i32, 0i32); let (mut least, mut greatest) = (0i32, 0i32);
if cuCtxGetStreamPriorityRange(&mut least, &mut greatest) != 0 { if cuCtxGetStreamPriorityRange(&mut least, &mut greatest) != 0 {
@@ -459,6 +503,11 @@ unsafe fn copy_blocking(copy: &CUDA_MEMCPY2D, what: &str) -> Result<()> {
fn alloc_pitched(width: u32, height: u32) -> Result<(CUdeviceptr, usize)> { fn alloc_pitched(width: u32, height: u32) -> Result<(CUdeviceptr, usize)> {
let mut ptr: CUdeviceptr = 0; let mut ptr: CUdeviceptr = 0;
let mut pitch: usize = 0; let mut pitch: usize = 0;
// SAFETY: `cuMemAllocPitch_v2` allocates a pitched device buffer (the wrapper forwards to the
// live table on any path that reached allocation). `&mut ptr` (`CUdeviceptr`) and `&mut pitch`
// (`usize`) are live, distinct stack out-params the driver writes the allocation pointer and
// its pitch into; both outlive the synchronous call. Width/height/element-size are by-value
// ints. No aliasing — two separate locals.
unsafe { unsafe {
ck( ck(
cuMemAllocPitch_v2( cuMemAllocPitch_v2(
@@ -486,6 +535,10 @@ fn alloc_pitched_nv12(
let mut y_pitch: usize = 0; let mut y_pitch: usize = 0;
let mut uv_ptr: CUdeviceptr = 0; let mut uv_ptr: CUdeviceptr = 0;
let mut uv_pitch: usize = 0; let mut uv_pitch: usize = 0;
// SAFETY: two independent `cuMemAllocPitch_v2` calls (wrapper → live table). `&mut y_ptr`/
// `&mut y_pitch` and `&mut uv_ptr`/`&mut uv_pitch` are live, distinct stack out-params the
// driver writes each plane's pointer and pitch into; all outlive their synchronous calls. The
// dimension/element-size args are by-value ints. No aliasing — four separate locals.
unsafe { unsafe {
ck( ck(
cuMemAllocPitch_v2( cuMemAllocPitch_v2(
@@ -524,6 +577,13 @@ struct PoolInner {
impl Drop for PoolInner { impl Drop for PoolInner {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: the pool only exists because allocation succeeded, so the driver table is live.
// `PoolInner` drops only once every `DeviceBuffer` that referenced it (each holds an `Arc`
// clone) has been recycled, so `free`/`free_uv` hold every outstanding allocation exactly
// once and nothing else still uses them — no double-free or use-after-free. We make the
// shared context current first (drop may run off the allocating thread) so `cuMemFree_v2`
// targets the right context. Each `p` is a `CUdeviceptr` previously returned by
// `cuMemAllocPitch_v2`; results are ignored (best-effort teardown).
unsafe { unsafe {
if let Some(c) = CONTEXT.get() { if let Some(c) = CONTEXT.get() {
let _ = cuCtxSetCurrent(c.0); let _ = cuCtxSetCurrent(c.0);
@@ -697,6 +757,12 @@ impl Drop for DeviceBuffer {
} }
} else { } else {
// The buffer may be freed on the encode thread; cuMemFree needs a current context. // The buffer may be freed on the encode thread; cuMemFree needs a current context.
// SAFETY: this is the un-pooled branch (`pool` is `None`), so this `DeviceBuffer`
// exclusively owns `self.ptr` (and `self.uv`'s `uv_ptr`), each returned by
// `cuMemAllocPitch_v2` and freed exactly once here — `drop` runs once and the
// `self.ptr == 0` guard above skips the sentinel/empty case, so no double-free. We set
// the shared context current first because drop may run on a thread where it isn't, and
// `cuMemFree_v2` needs it. Wrapper → live table; results ignored (teardown).
unsafe { unsafe {
if let Some(c) = CONTEXT.get() { if let Some(c) = CONTEXT.get() {
let _ = cuCtxSetCurrent(c.0); let _ = cuCtxSetCurrent(c.0);
@@ -745,6 +811,16 @@ impl RegisteredTexture {
/// unmap. The copy is synchronized (on our priority stream) before unmap so `dst` is ready /// unmap. The copy is synchronized (on our priority stream) before unmap so `dst` is ready
/// before the source dmabuf is recycled. Always unmaps, even if the copy errors. /// before the source dmabuf is recycled. Always unmaps, even if the copy errors.
pub fn copy_mapped_to(&mut self, dst: &DeviceBuffer) -> Result<()> { pub fn copy_mapped_to(&mut self, dst: &DeviceBuffer) -> Result<()> {
// SAFETY: `self.resource` is the valid `CUgraphicsResource` from a successful `register_gl`
// (its only constructor), so the wrappers forward to the live table; the caller holds the
// GL+CUDA contexts current (the registration's contract). `cuGraphicsMapResources` maps
// `count == 1` resource via `&mut self.resource` (a live field) on the default stream;
// `cuGraphicsSubResourceGetMappedArray` writes the mapped `CUarray` into the live local
// `array` (index 0, mip 0). On failure we unmap and bail (balanced). `&copy` is a live
// local `CUDA_MEMCPY2D` outliving the synchronous `copy_blocking`: `srcArray` is valid
// while mapped, `dstDevice`/`dstPitch` are `dst`'s live allocation, `width*4`×`height` fit
// both. `copy_blocking` syncs before we unmap, so the array stays valid through the copy;
// we always unmap afterward (even on error), keeping the map/unmap pair balanced.
unsafe { unsafe {
ck( ck(
cuGraphicsMapResources(1, &mut self.resource, std::ptr::null_mut()), cuGraphicsMapResources(1, &mut self.resource, std::ptr::null_mut()),
@@ -783,6 +859,14 @@ impl RegisteredTexture {
width_bytes: usize, width_bytes: usize,
height: usize, height: usize,
) -> Result<()> { ) -> Result<()> {
// SAFETY: identical contract to `copy_mapped_to` — `self.resource` is the valid
// `CUgraphicsResource` from `register_gl` (wrappers → live table; caller holds GL+CUDA
// contexts current). Map `count == 1` resource via the live `&mut self.resource`; the
// mapped `CUarray` is written into the live local `array` (index 0, mip 0); on failure we
// unmap and bail (balanced). `&copy` is a live local outliving the synchronous
// `copy_blocking`: `srcArray` valid while mapped, `dstDevice`/`dstPitch` are the caller's
// live plane, `width_bytes`×`height` fit it. We always unmap afterward, even on copy error,
// so the map/unmap pair stays balanced and the array outlives the copy.
unsafe { unsafe {
ck( ck(
cuGraphicsMapResources(1, &mut self.resource, std::ptr::null_mut()), cuGraphicsMapResources(1, &mut self.resource, std::ptr::null_mut()),
@@ -847,6 +931,10 @@ pub fn copy_device_to_device(
Height: src.height as usize, Height: src.height as usize,
..Default::default() ..Default::default()
}; };
// SAFETY: `copy_blocking` is unsafe (issues a CUDA copy); the caller must have the shared
// context current (documented). `&copy` is a live local device→device `CUDA_MEMCPY2D` outliving
// the synchronous call: `srcDevice`/`srcPitch` are `src`'s live allocation, `dstDevice`/
// `dstPitch` the caller's live region, `width*4`×`height` within both. Wrapper → live table.
unsafe { copy_blocking(&copy, "cuMemcpy2DAsync_v2(dev->dev)") } unsafe { copy_blocking(&copy, "cuMemcpy2DAsync_v2(dev->dev)") }
} }
@@ -888,6 +976,12 @@ pub fn copy_nv12_to_device(
Height: h / 2, Height: h / 2,
..Default::default() ..Default::default()
}; };
// SAFETY: two unsafe `copy_blocking` device→device copies; the caller must have the shared
// context current (documented). `&y`/`&uv` are live local `CUDA_MEMCPY2D`s outliving each
// synchronous call. All four device pointers are valid: `src.ptr`/`src_uv_ptr` come from a live
// NV12 `DeviceBuffer` (its `.uv` presence was checked via `ok_or_else`), `y_dst`/`uv_dst` are
// the caller's live NVENC surface planes; the luma copy is `w`×`h`, the chroma copy
// `(w/2)*2`×`h/2`, each within its planes. Wrappers → live table.
unsafe { unsafe {
copy_blocking(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)")?; copy_blocking(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)")?;
copy_blocking(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)") copy_blocking(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)")
@@ -897,6 +991,12 @@ pub fn copy_nv12_to_device(
impl Drop for RegisteredTexture { impl Drop for RegisteredTexture {
fn drop(&mut self) { fn drop(&mut self) {
if !self.resource.is_null() { if !self.resource.is_null() {
// SAFETY: `self.resource` is non-null (just checked) and is the valid
// `CUgraphicsResource` from `register_gl`, owned exclusively by this `RegisteredTexture`
// and unregistered exactly once here (drop runs once) — no use-after-free or
// double-unregister. `cuGraphicsUnregisterResource` releases the GL↔CUDA registration;
// wrapper → live table (the resource exists ⇒ the driver was present). Result ignored
// (best-effort teardown).
unsafe { unsafe {
let _ = cuGraphicsUnregisterResource(self.resource); let _ = cuGraphicsUnregisterResource(self.resource);
} }
@@ -913,7 +1013,11 @@ pub struct ExternalDmabuf {
pub size: u64, pub size: u64,
} }
// Raw driver handles; used from the single capture thread but moved with the importer. // SAFETY: the fields are opaque CUDA driver handles — an external-memory handle and a device
// pointer — not dereferenceable Rust memory, and the value is uniquely owned (no `Clone`). It is
// used from a single capture thread but constructed on / moved between threads with the importer;
// transferring these handles is sound because uniqueness rules out aliasing and they are destroyed
// exactly once in `Drop`. Only `Send` (not `Sync`) is asserted, matching the single-thread use.
unsafe impl Send for ExternalDmabuf {} unsafe impl Send for ExternalDmabuf {}
impl ExternalDmabuf { impl ExternalDmabuf {
@@ -921,6 +1025,9 @@ impl ExternalDmabuf {
/// from then on) and map its full `size` bytes to a device pointer. The shared context /// from then on) and map its full `size` bytes to a device pointer. The shared context
/// must be current. /// must be current.
pub fn import(fd: i32, size: u64) -> Result<ExternalDmabuf> { pub fn import(fd: i32, size: u64) -> Result<ExternalDmabuf> {
// SAFETY: `libc::dup` only reads the integer `fd` and returns a new descriptor (or -1); it
// touches no Rust memory and `fd` is the caller's still-owned dmabuf fd (not consumed
// here). No aliasing or lifetime concern — a pure syscall on an integer.
let dup = unsafe { libc::dup(fd) }; let dup = unsafe { libc::dup(fd) };
if dup < 0 { if dup < 0 {
bail!("dup(dmabuf fd) failed"); bail!("dup(dmabuf fd) failed");
@@ -938,8 +1045,17 @@ impl ExternalDmabuf {
}; };
desc.handle[0] = dup as u32 as u64; // union member `int fd` (little-endian low bytes) desc.handle[0] = dup as u32 as u64; // union member `int fd` (little-endian low bytes)
let mut ext: CUexternalMemory = std::ptr::null_mut(); let mut ext: CUexternalMemory = std::ptr::null_mut();
// SAFETY: `cuImportExternalMemory` imports the memory described by `&desc`, a live local
// `#[repr(C)] CUDA_EXTERNAL_MEMORY_HANDLE_DESC` (cuda.h 64-bit layout) that outlives this
// synchronous call: `type_` is OPAQUE_FD, `handle[0]` holds the dup'd fd in the union's
// `int fd` low bytes, `size` is set. `&mut ext` is a live null-init out-param the driver
// writes the imported handle into. The driver takes ownership of the fd only on success.
// Distinct locals → no aliasing. Wrapper → live table (caller holds the context current).
let r = unsafe { cuImportExternalMemory(&mut ext, &desc) }; let r = unsafe { cuImportExternalMemory(&mut ext, &desc) };
if r != 0 { if r != 0 {
// SAFETY: import failed (`r != 0`), so the driver did NOT take ownership of `dup`; we
// still own it and close it exactly once here on the error path (the success path never
// closes it — the driver does). `libc::close` acts on the integer fd alone.
unsafe { libc::close(dup) }; // import failed → the driver did not take the fd unsafe { libc::close(dup) }; // import failed → the driver did not take the fd
bail!("cuImportExternalMemory failed ({r}) — LINEAR dmabuf import unsupported?"); bail!("cuImportExternalMemory failed ({r}) — LINEAR dmabuf import unsupported?");
} }
@@ -949,8 +1065,17 @@ impl ExternalDmabuf {
..Default::default() ..Default::default()
}; };
let mut ptr: CUdeviceptr = 0; let mut ptr: CUdeviceptr = 0;
// SAFETY: maps a device pointer from `ext` (the valid `CUexternalMemory` just imported) per
// `&buf`, a live local `CUDA_EXTERNAL_MEMORY_BUFFER_DESC` (offset 0, full `size`) that
// outlives this synchronous call. `&mut ptr` is a live zero-init out-param the driver writes
// the mapped device address into; distinct locals → no aliasing. Wrapper → live table
// (context current).
let r = unsafe { cuExternalMemoryGetMappedBuffer(&mut ptr, ext, &buf) }; let r = unsafe { cuExternalMemoryGetMappedBuffer(&mut ptr, ext, &buf) };
if r != 0 { if r != 0 {
// SAFETY: mapping failed; `ext` is the valid `CUexternalMemory` we imported and
// exclusively own. We destroy it exactly once here on the error path (the success path
// instead moves it into the returned `ExternalDmabuf`, whose `Drop` destroys it),
// releasing the fd the driver took — no double-destroy or use-after-free.
unsafe { unsafe {
let _ = cuDestroyExternalMemory(ext); let _ = cuDestroyExternalMemory(ext);
} }
@@ -962,6 +1087,12 @@ impl ExternalDmabuf {
impl Drop for ExternalDmabuf { impl Drop for ExternalDmabuf {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: this `ExternalDmabuf` only exists after a successful import, so the driver table
// is live. It exclusively owns `self.ptr` (the mapped buffer) and `self.ext` (the external
// memory), each torn down exactly once here (drop runs once; guarded by `!= 0` / `!null`) —
// no double-free or use-after-free. We make the shared context current first because drop
// may run off the import thread, and we free the mapped buffer before destroying its
// backing external memory. Results ignored (best-effort teardown).
unsafe { unsafe {
if let Some(c) = CONTEXT.get() { if let Some(c) = CONTEXT.get() {
let _ = cuCtxSetCurrent(c.0); let _ = cuCtxSetCurrent(c.0);
@@ -996,5 +1127,10 @@ pub fn copy_pitched_to_buffer(
}; };
// copy_blocking syncs our priority stream before returning, so the copy is complete before the // copy_blocking syncs our priority stream before returning, so the copy is complete before the
// dmabuf is requeued to the producer. // dmabuf is requeued to the producer.
// SAFETY: `copy_blocking` is unsafe (issues a CUDA copy); the caller must have the shared
// context current (documented). `&copy` is a live local device→device `CUDA_MEMCPY2D` outliving
// the synchronous call: `srcDevice`/`srcPitch` are the caller's live mapped span (e.g. an
// `ExternalDmabuf`), `dstDevice`/`dstPitch` are `dst`'s live allocation, `width*4`×`height`
// within both. Wrapper → live table.
unsafe { copy_blocking(&copy, "cuMemcpy2DAsync_v2(ext->dev)") } unsafe { copy_blocking(&copy, "cuMemcpy2DAsync_v2(ext->dev)") }
} }
@@ -12,6 +12,8 @@
//! owned [`DeviceBuffer`] so the dmabuf can be returned to the compositor immediately. //! owned [`DeviceBuffer`] so the dmabuf can be returned to the compositor immediately.
#![allow(non_upper_case_globals)] #![allow(non_upper_case_globals)]
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::cuda::{self, DeviceBuffer}; use super::cuda::{self, DeviceBuffer};
use anyhow::{bail, ensure, Context as _, Result}; use anyhow::{bail, ensure, Context as _, Result};
@@ -415,6 +417,14 @@ impl Nv12Blit {
impl Drop for Nv12Blit { impl Drop for Nv12Blit {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: these GL names (textures/FBOs/VAO/programs) were all created by THIS `Nv12Blit`
// in `Nv12Blit::new` on the current GL context, which is still current because the owning
// `EglImporter` is dropped on its single capture thread (fields drop before
// `EglImporter::drop`, which never releases the context). `glDelete*` takes a count + a
// pointer to that many names: `&self.y_tex`/`&self.vao` are `&u32` to one live field (n=1);
// `[self.y_fbo, self.uv_fbo].as_ptr()` points at a 2-element temporary that lives for the
// whole `glDeleteFramebuffers` call (n=2 matches). The symbols dispatch through libGL
// (libglvnd) to the driver for the current context. Each name is deleted exactly once.
unsafe { unsafe {
glDeleteTextures(1, &self.y_tex); glDeleteTextures(1, &self.y_tex);
glDeleteTextures(1, &self.uv_tex); glDeleteTextures(1, &self.uv_tex);
@@ -459,7 +469,14 @@ pub struct EglImporter {
render_fd: c_int, render_fd: c_int,
} }
// The EGL handles are confined to the capture thread; the struct is moved there once. // SAFETY: `EglImporter` owns thread-affine handles — an EGLDisplay/contexts made current on one
// thread, a loaded GL proc pointer, a `gbm_device*`, a raw fd, and CUDA-registered GL textures —
// none safe to touch concurrently. It is constructed inside `pipewire_thread` on the dedicated
// `punktfunk-pipewire` thread, and every method (`import*`, `supported_modifiers`, `Drop`) runs on
// that same thread; it is never accessed through a shared `&` from another thread. `Send` asserts
// only that transferring *ownership* is sound (needed so the importer can live in the PipeWire
// stream's user-data, whose API imposes a `Send` bound) — the live handles are never used
// off-thread. `Sync` is deliberately NOT implied.
unsafe impl Send for EglImporter {} unsafe impl Send for EglImporter {}
impl EglImporter { impl EglImporter {
@@ -470,16 +487,38 @@ impl EglImporter {
// to the same DRM device CUDA-GL interop associates with, which the EGL device platform // to the same DRM device CUDA-GL interop associates with, which the EGL device platform
// did not (cuGraphicsGLRegisterImage rejected device-platform GL textures). // did not (cuGraphicsGLRegisterImage rejected device-platform GL textures).
let path = std::ffi::CString::new("/dev/dri/renderD128").unwrap(); let path = std::ffi::CString::new("/dev/dri/renderD128").unwrap();
// SAFETY: `path` is a live local `CString` (built from a string with no interior NUL, so it
// is NUL-terminated); `path.as_ptr()` is a valid pointer to that buffer which outlives this
// synchronous `open`. `open` only reads the path and returns a new fd (or -1); it neither
// retains the pointer nor writes through it, so there is no aliasing or lifetime hazard.
let render_fd = unsafe { libc::open(path.as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) }; let render_fd = unsafe { libc::open(path.as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) };
ensure!(render_fd >= 0, "open /dev/dri/renderD128 for GBM"); ensure!(render_fd >= 0, "open /dev/dri/renderD128 for GBM");
// SAFETY: `render_fd` is the live DRM render-node fd just returned by `open` and checked
// `>= 0`. `gbm_create_device` (libgbm, linked above) builds a `gbm_device` over that fd and
// returns a `*mut gbm_device` (or null); it borrows but does not take ownership of the fd,
// which `EglImporter` keeps open and closes only in `Drop` after `gbm_device_destroy`. No
// Rust-owned memory is passed, so there is nothing to alias.
let gbm = unsafe { gbm_create_device(render_fd) }; let gbm = unsafe { gbm_create_device(render_fd) };
if gbm.is_null() { if gbm.is_null() {
// SAFETY: reached only when `gbm_create_device` failed (null) — the fd was not consumed
// and no `EglImporter` exists yet to close it again, so this `close` runs exactly once on
// the live `render_fd`, releasing it before the error return. No double-close.
unsafe { libc::close(render_fd) }; unsafe { libc::close(render_fd) };
anyhow::bail!("gbm_create_device failed"); anyhow::bail!("gbm_create_device failed");
} }
// SAFETY: `Egl::load_required` dlopens the system libEGL and binds its entry points,
// trusting that libEGL (libglvnd) is a genuine EGL 1.5 implementation whose core symbols
// match the ABI the `khronos_egl` `EGL1_5` bindings declare. No Rust memory is passed; the
// returned instance is afterwards used only through the safe `khronos_egl` wrappers.
let egl: Egl = let egl: Egl =
unsafe { Egl::load_required() }.context("load libEGL (EGL 1.5 dynamic instance)")?; unsafe { Egl::load_required() }.context("load libEGL (EGL 1.5 dynamic instance)")?;
// SAFETY: `gbm` is the non-null `gbm_device*` created just above (checked), and
// `EGL_PLATFORM_GBM_KHR` is exactly the platform enum that pairs with a GBM device as the
// native-display handle, so the `gbm as NativeDisplayType` cast hands EGL a valid native
// display for the requested platform. `&[egl::ATTRIB_NONE]` is a properly terminated, empty
// attribute array borrowed for this synchronous call; EGL only reads it and returns an
// `EGLDisplay`, retaining no pointer into Rust memory.
let display = unsafe { let display = unsafe {
egl.get_platform_display( egl.get_platform_display(
EGL_PLATFORM_GBM_KHR, EGL_PLATFORM_GBM_KHR,
@@ -533,6 +572,13 @@ impl EglImporter {
.context("eglCreateContext(OpenGL)")?; .context("eglCreateContext(OpenGL)")?;
egl.make_current(display, None, None, Some(gl_ctx)) egl.make_current(display, None, None, Some(gl_ctx))
.context("eglMakeCurrent surfaceless (needs EGL_KHR_surfaceless_context)")?; .context("eglMakeCurrent surfaceless (needs EGL_KHR_surfaceless_context)")?;
// SAFETY: the GL context was made current on this thread just above, which `eglGetProcAddress`
// requires to return a usable pointer. The non-null (`?`-checked) pointer it returns for
// "glEGLImageTargetTexture2DOES" is the driver's implementation of that GL-OES entry point,
// whose real ABI is `void(GLenum, GLeglImageOES)` = `(u32, *mut c_void)` `extern "system"`.
// `EglImageTargetFn` is declared with exactly that signature, so the transmute only retypes a
// same-size, same-ABI thin function pointer (no value/representation change). The function is
// present because `EGL_EXT_image_dma_buf_import` was asserted on this display above.
let egl_image_target: EglImageTargetFn = unsafe { let egl_image_target: EglImageTargetFn = unsafe {
std::mem::transmute( std::mem::transmute(
egl.get_proc_address("glEGLImageTargetTexture2DOES") egl.get_proc_address("glEGLImageTargetTexture2DOES")
@@ -543,6 +589,10 @@ impl EglImporter {
// Create the shared CUDA context up front so import() is pure hot path. // Create the shared CUDA context up front so import() is pure hot path.
cuda::context().context("create CUDA context")?; cuda::context().context("create CUDA context")?;
// SAFETY: `egl::NO_CONTEXT` is EGL's defined sentinel (a null handle) for "no context";
// `Context::from_ptr` only stores the handle (it never dereferences it), so wrapping the
// null sentinel is sound and yields exactly the `EGL_NO_CONTEXT` value that
// `eglCreateImage(EGL_LINUX_DMA_BUF_EXT)` requires as its context argument later.
let no_ctx = unsafe { egl::Context::from_ptr(egl::NO_CONTEXT) }; let no_ctx = unsafe { egl::Context::from_ptr(egl::NO_CONTEXT) };
tracing::info!( tracing::info!(
"zero-copy EGL importer ready (GBM platform + GL texture interop, dma_buf_import + modifiers)" "zero-copy EGL importer ready (GBM platform + GL texture interop, dma_buf_import + modifiers)"
@@ -602,8 +652,21 @@ impl EglImporter {
let Some(sym) = self.egl.get_proc_address("eglQueryDmaBufModifiersEXT") else { let Some(sym) = self.egl.get_proc_address("eglQueryDmaBufModifiersEXT") else {
return Vec::new(); return Vec::new();
}; };
// SAFETY: `sym` is the non-null pointer `eglGetProcAddress("eglQueryDmaBufModifiersEXT")`
// returned (the `let-else` already bailed on `None`) — the driver's implementation of that
// EGL extension entry point. `QueryFn` is declared with that function's exact documented ABI
// (`EGLDisplay, EGLint, EGLint, EGLuint64* , EGLBoolean*, EGLint* -> EGLBoolean`), all
// `extern "system"`, so the transmute only retypes a same-size, same-ABI thin fn pointer.
let query: QueryFn = unsafe { std::mem::transmute(sym) }; let query: QueryFn = unsafe { std::mem::transmute(sym) };
let dpy = self.display.as_ptr(); let dpy = self.display.as_ptr();
// SAFETY: `dpy` is this importer's live, initialized `EGLDisplay`; `query` is the proc loaded
// just above. The first call passes null out-arrays with `max_modifiers == 0`, which the
// extension defines as "write only the count" — it writes solely through `&mut count` (a live
// local `i32`). For the second call, `mods`/`ext` are freshly allocated `Vec`s of exactly
// `count` elements and `max_modifiers == count`, so the driver writes at most `count`
// `u64`/`u32` entries (in bounds) plus the actual count through `&mut n` (a live local). All
// four Rust addresses outlive these synchronous calls and alias nothing else. `truncate` only
// shrinks, so even a misbehaving `n > count` cannot read out of bounds.
unsafe { unsafe {
let mut count: i32 = 0; let mut count: i32 = 0;
if query( if query(
@@ -699,6 +762,10 @@ impl EglImporter {
]); ]);
} }
attrs.push(egl::ATTRIB_NONE); attrs.push(egl::ATTRIB_NONE);
// SAFETY: `eglCreateImage(EGL_LINUX_DMA_BUF_EXT, ...)` mandates a NULL `EGLClientBuffer`
// (the source is described entirely by the attribute list built above), so wrapping
// `null_mut()` is the required value. `from_ptr` only stores the pointer without
// dereferencing it, so constructing it from null is sound.
let client = unsafe { egl::ClientBuffer::from_ptr(std::ptr::null_mut()) }; let client = unsafe { egl::ClientBuffer::from_ptr(std::ptr::null_mut()) };
let image = self let image = self
.egl .egl
@@ -733,11 +800,21 @@ impl EglImporter {
) -> Result<DeviceBuffer> { ) -> Result<DeviceBuffer> {
cuda::make_current()?; cuda::make_current()?;
if self.blit.as_ref().map(|b| (b.width, b.height)) != Some((width, height)) { if self.blit.as_ref().map(|b| (b.width, b.height)) != Some((width, height)) {
// SAFETY: `GlBlit::new` requires the GL context current on the calling thread and a
// current CUDA context. Both hold: this runs on the capture thread where
// `EglImporter::new` made the GL context current and never released it, and
// `cuda::make_current()?` ran at the top of this function. `width`/`height` are plain
// `Copy` frame dimensions.
self.blit = Some(unsafe { GlBlit::new(width, height)? }); self.blit = Some(unsafe { GlBlit::new(width, height)? });
} }
let egl_image_target = self.egl_image_target; let egl_image_target = self.egl_image_target;
let blit = self.blit.as_mut().unwrap(); let blit = self.blit.as_mut().unwrap();
// SAFETY: GL + CUDA contexts current on this thread; `image` is a valid EGLImage. // SAFETY: `GlBlit::run` requires a current GL context and a valid `EGLImage`. The GL context
// is current on this capture thread (made current in `EglImporter::new`, never released) and
// `cuda::make_current()` ran above; `egl_image_target` is the `glEGLImageTargetTexture2DOES`
// pointer loaded in `new`; `image` is the raw handle of the live `EGLImage` that
// `import_inner` created with `eglCreateImage` and destroys only AFTER this call returns, so
// it stays valid for the whole synchronous `run`.
unsafe { blit.run(egl_image_target, image)? }; unsafe { blit.run(egl_image_target, image)? };
// Persistent registration (mapped per frame) + a pooled buffer — no per-frame // Persistent registration (mapped per frame) + a pooled buffer — no per-frame
// cuGraphicsGLRegisterImage / cuMemAllocPitch. // cuGraphicsGLRegisterImage / cuMemAllocPitch.
@@ -757,11 +834,21 @@ impl EglImporter {
) -> Result<DeviceBuffer> { ) -> Result<DeviceBuffer> {
cuda::make_current()?; cuda::make_current()?;
if self.nv12_blit.as_ref().map(|b| (b.width, b.height)) != Some((width, height)) { if self.nv12_blit.as_ref().map(|b| (b.width, b.height)) != Some((width, height)) {
// SAFETY: `Nv12Blit::new` requires the GL context current on the calling thread and a
// current CUDA context. Both hold: this runs on the capture thread where
// `EglImporter::new` made the GL context current and never released it, and
// `cuda::make_current()?` ran at the top of this function. `width`/`height` are plain
// `Copy` frame dimensions.
self.nv12_blit = Some(unsafe { Nv12Blit::new(width, height)? }); self.nv12_blit = Some(unsafe { Nv12Blit::new(width, height)? });
} }
let egl_image_target = self.egl_image_target; let egl_image_target = self.egl_image_target;
let blit = self.nv12_blit.as_mut().unwrap(); let blit = self.nv12_blit.as_mut().unwrap();
// SAFETY: GL + CUDA contexts current on this thread; `image` is a valid EGLImage. // SAFETY: `Nv12Blit::run` requires a current GL context and a valid `EGLImage`. The GL
// context is current on this capture thread (made current in `EglImporter::new`, never
// released) and `cuda::make_current()` ran above; `egl_image_target` is the
// `glEGLImageTargetTexture2DOES` pointer loaded in `new`; `image` is the raw handle of the
// live `EGLImage` that `import_inner` created with `eglCreateImage` and destroys only AFTER
// this call returns, so it stays valid for the whole synchronous `run`.
unsafe { blit.run(egl_image_target, image)? }; unsafe { blit.run(egl_image_target, image)? };
let dst = blit.pool.get()?; let dst = blit.pool.get()?;
cuda::copy_mapped_nv12(&mut blit.y_registered, &mut blit.uv_registered, &dst)?; cuda::copy_mapped_nv12(&mut blit.y_registered, &mut blit.uv_registered, &dst)?;
@@ -787,9 +874,22 @@ impl EglImporter {
); );
cuda::make_current()?; cuda::make_current()?;
if self.nv12_blit.as_ref().map(|b| (b.width, b.height)) != Some((width, height)) { if self.nv12_blit.as_ref().map(|b| (b.width, b.height)) != Some((width, height)) {
// SAFETY: `Nv12Blit::new` requires the GL context current on the calling thread and a
// current CUDA context. Both hold: this self-test path runs on the thread that owns this
// `EglImporter` with its GL context current, and `cuda::make_current()?` ran just above.
// `width`/`height` are plain `Copy` scalars.
self.nv12_blit = Some(unsafe { Nv12Blit::new(width, height)? }); self.nv12_blit = Some(unsafe { Nv12Blit::new(width, height)? });
} }
let blit = self.nv12_blit.as_mut().unwrap(); let blit = self.nv12_blit.as_mut().unwrap();
// SAFETY: runs on the thread that owns this `EglImporter` with its GL context current.
// `blit.src_tex` is a texture this `Nv12Blit` owns; `glTexStorage2D` allocates immutable
// RGBA8 storage exactly once (guarded by `test_src_storage`) sized `width×height`.
// `glTexSubImage2D` then uploads exactly `width×height` RGBA8 texels, reading `width*height*4`
// bytes from `rgba.as_ptr()`; the caller already asserted `rgba.len() == width*height*4`, rows
// are `width*4` bytes (a multiple of the default 4-byte unpack alignment, so no row-padding
// over-read), and `rgba` is a live borrow that outlives this synchronous upload. `run_passes`
// then needs only the current GL context (no further Rust pointers). All GL names are this
// blit's own, alias no other live object, and nothing is retained past the calls.
unsafe { unsafe {
// Upload the host RGBA into `src_tex` (an immutable GL_RGBA8 backing must exist first; // Upload the host RGBA into `src_tex` (an immutable GL_RGBA8 backing must exist first;
// the live path never allocates it — it retargets `src_tex` via EGLImage instead). // the live path never allocates it — it retargets `src_tex` via EGLImage instead).
@@ -824,9 +924,16 @@ impl EglImporter {
impl Drop for EglImporter { impl Drop for EglImporter {
fn drop(&mut self) { fn drop(&mut self) {
if !self.gbm.is_null() { if !self.gbm.is_null() {
// SAFETY: `self.gbm` is the non-null `gbm_device*` from `gbm_create_device` in `new`
// (checked non-null here), owned exclusively by this `EglImporter` and destroyed exactly
// once (in `Drop`). It is freed BEFORE `render_fd` is closed below — the correct order,
// since the device borrowed that fd for its lifetime.
unsafe { gbm_device_destroy(self.gbm) }; unsafe { gbm_device_destroy(self.gbm) };
} }
if self.render_fd >= 0 { if self.render_fd >= 0 {
// SAFETY: `self.render_fd` is the fd `open` returned in `new` (checked `>= 0`), owned
// exclusively by this `EglImporter`; this `close` runs exactly once, after the gbm device
// that borrowed it has been destroyed. No double-close or use-after-close.
unsafe { libc::close(self.render_fd) }; unsafe { libc::close(self.render_fd) };
} }
} }
@@ -16,6 +16,9 @@
//! a stream's life). Falls back cleanly: any init/import error disables the importer and the //! a stream's life). Falls back cleanly: any init/import error disables the importer and the
//! CPU mmap path takes over. //! CPU mmap path takes over.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::cuda::{self, DeviceBuffer}; use super::cuda::{self, DeviceBuffer};
use anyhow::{anyhow, bail, Context as _, Result}; use anyhow::{anyhow, bail, Context as _, Result};
use ash::vk; use ash::vk;
@@ -51,12 +54,27 @@ pub struct VkBridge {
dst: Option<DstBuf>, dst: Option<DstBuf>,
} }
// Confined to the capture thread; moved there once. // SAFETY: `VkBridge` owns ash Vulkan handles (instance/device/queue/command pool+buffer/fence), a
// CUDA external-memory mapping, and an fd→buffer cache — none `Sync`, and a single queue +
// command buffer must be externally synchronized. It is created inside `EglImporter::import_linear`
// on the dedicated `punktfunk-pipewire` capture thread and every method (`import_linear`, `Drop`)
// runs on that thread; it is never shared via `&` across threads. `Send` asserts only that
// transferring ownership is sound (so the bridge can live inside the `Send` `EglImporter`); the live
// handles are never touched off-thread, and `Sync` is deliberately NOT implied.
unsafe impl Send for VkBridge {} unsafe impl Send for VkBridge {}
impl VkBridge { impl VkBridge {
/// Bring up Vulkan on the NVIDIA GPU with the external-memory extensions. /// Bring up Vulkan on the NVIDIA GPU with the external-memory extensions.
pub fn new() -> Result<VkBridge> { pub fn new() -> Result<VkBridge> {
// SAFETY: standard ash bring-up — every call is `unsafe` only because ash cannot statically
// verify Vulkan handle/CreateInfo validity. `ash::Entry::load` dlopens a real system
// libvulkan. Each `*CreateInfo`/`AllocateInfo` is built by ash's builders from locals (`app`,
// `exts`, `prio`, `qci`, and the inline infos) that all live for the duration of the
// synchronous `create_*`/`enumerate_*` call that reads them — in particular the
// `enabled_extension_names(&exts)` and `queue_priorities(&prio)` borrows outlive their calls.
// Every handle passed (`instance`, `phys`, `device`, `qf`, `cmd_pool`) was just created and
// checked via `?`/`ok_or_else` in this same function, so no invalid handle is ever used. This
// constructor shares nothing across threads.
unsafe { unsafe {
let entry = ash::Entry::load().context("load libvulkan")?; let entry = ash::Entry::load().context("load libvulkan")?;
let app = vk::ApplicationInfo::default().api_version(vk::API_VERSION_1_1); let app = vk::ApplicationInfo::default().api_version(vk::API_VERSION_1_1);
@@ -294,6 +312,19 @@ impl VkBridge {
height: u32, height: u32,
pool: &cuda::BufferPool, pool: &cuda::BufferPool,
) -> Result<DeviceBuffer> { ) -> Result<DeviceBuffer> {
// SAFETY: `fd` is the live dmabuf fd handed in by the caller (borrowed; `import_src` dup's it
// internally and Vulkan owns the dup). `libc::lseek` only queries the fd's size. The unsafe
// `import_src`/`ensure_dst` are called with a valid fd and a checked size. The bounds are
// proven: `import_src` asserts `size >= span` (so the cached `src_size >= span`),
// `copy_size = src_size.min(span)`, and `ensure_dst(copy_size)` makes `dst` at least
// `copy_size` — so the GPU `cmd_copy_buffer` of `copy_size` bytes reads/writes within both
// buffers, and the later CUDA pitched copy reading `[offset, span)` from `dst.cuda.ptr` (=
// `offset + stride*height = span <= copy_size`) stays inside the freshly-copied region. The
// `*Info`/`region`/`cmds`/`submit` are locals that outlive the synchronous calls reading them.
// `cmd`/`queue`/`fence` are this bridge's own handles, used on this single thread only. The
// host-side `wait_for_fences` fully retires the Vulkan copy BEFORE CUDA reads the shared
// memory, so there is no GPU write/read data race. `dst` is an `&self.dst` shared borrow that
// does not alias the `&self.device` calls.
unsafe { unsafe {
let span = offset as u64 + stride as u64 * height as u64; let span = offset as u64 + stride as u64 * height as u64;
if !self.src_cache.contains_key(&fd) { if !self.src_cache.contains_key(&fd) {
@@ -347,6 +378,15 @@ impl VkBridge {
impl Drop for VkBridge { impl Drop for VkBridge {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: runs once when the bridge is dropped on its owning capture thread.
// `device_wait_idle` first drains all in-flight GPU work, so no queued command still
// references these objects. Every handle freed (the `src_cache` buffers+memories, the `dst`
// buffer+memory, `fence`, `cmd_pool`, `device`, `instance`) was created by this `VkBridge`
// and owned exclusively by it, so each `destroy_*`/`free_*` runs exactly once with no
// double-free, in dependency order (child objects before `device`, `device` before
// `instance`). `dst.cuda` is dropped after `free_memory`, which is safe because CUDA holds
// its own dup'd OPAQUE_FD reference to the underlying allocation. No other thread touches
// these handles.
unsafe { unsafe {
let _ = self.device.device_wait_idle(); let _ = self.device.device_wait_idle();
for (_, s) in self.src_cache.drain() { for (_, s) in self.src_cache.drain() {
+22
View File
@@ -13,18 +13,30 @@
// Scaffold: trait methods and config paths are defined ahead of their backends. // Scaffold: trait methods and config paths are defined ahead of their backends.
#![allow(dead_code)] #![allow(dead_code)]
// Unsafe-proof program: every `unsafe {}` / `unsafe impl` in the crate must carry a `// SAFETY:`
// proof of why it is sound. This crate-root deny is the permanent, catch-all gate (it also covers
// any future module); individual files keep their own `#![deny(...)]` as belt-and-suspenders.
#![deny(clippy::undocumented_unsafe_blocks)]
mod audio; mod audio;
mod capture; mod capture;
mod config;
mod discovery; 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")] #[cfg(target_os = "linux")]
#[path = "linux/dmabuf_fence.rs"]
mod dmabuf_fence; mod dmabuf_fence;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "linux/drm_sync.rs"]
mod drm_sync; mod drm_sync;
mod encode; mod encode;
mod gamestream; mod gamestream;
mod hdr; mod hdr;
mod inject; mod inject;
#[cfg(target_os = "windows")]
#[path = "windows/interactive.rs"]
mod interactive;
mod library; mod library;
mod mgmt; mod mgmt;
mod mgmt_token; mod mgmt_token;
@@ -33,13 +45,23 @@ mod pipeline;
mod punktfunk1; mod punktfunk1;
mod pwinit; mod pwinit;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "windows/service.rs"]
mod service; mod service;
mod session_plan;
mod session_tuning; mod session_tuning;
mod spike; mod spike;
mod vdisplay; mod vdisplay;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "windows/wgc_helper.rs"]
mod wgc_helper; 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")] #[cfg(target_os = "linux")]
#[path = "linux/zerocopy/mod.rs"]
mod zerocopy; mod zerocopy;
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
+257 -134
View File
@@ -22,6 +22,9 @@
//! Trust: the host serves with its persistent identity (`~/.config/punktfunk/cert.pem`, shared //! Trust: the host serves with its persistent identity (`~/.config/punktfunk/cert.pem`, shared
//! with GameStream pairing) and logs the SHA-256 fingerprint clients pin. //! with GameStream pairing) and logs the SHA-256 fingerprint clients pin.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use punktfunk_core::config::{CompositorPref, FecConfig, FecScheme, GamepadPref, Role}; use punktfunk_core::config::{CompositorPref, FecConfig, FecScheme, GamepadPref, Role};
use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::input::{InputEvent, InputKind};
@@ -209,6 +212,10 @@ pub(crate) async fn serve(opts: Punktfunk1Options, np: Arc<NativePairing>) -> Re
// restores the box's autologin gaming session on idle, not per-disconnect — see // restores the box's autologin gaming session on idle, not per-disconnect — see
// `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it. // `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it.
let _restore_worker = crate::vdisplay::start_restore_worker(); let _restore_worker = crate::vdisplay::start_restore_worker();
// Host-lifetime cover-art warmer: fetches + caches GOG/Xbox cover art (no-auth api.gog.com /
// displaycatalog) off the hot path so `all_games()` (the library list + launch resolve) never
// blocks on the network. A no-op on a host whose stores all carry their own art.
let _art_warmer = crate::library::start_art_warmer();
// Pairing state (arming PIN + trust store) is shared with the management API. If it was armed // Pairing state (arming PIN + trust store) is shared with the management API. If it was armed
// at startup (the CLI flags), surface the PIN the headless operator reads from the log; the // at startup (the CLI flags), surface the PIN the headless operator reads from the log; the
// web console arms it on demand instead (a fresh, time-limited PIN). // web console arms it on demand instead (a fresh, time-limited PIN).
@@ -571,6 +578,11 @@ async fn serve_session(
// (`what's left` §3), resolve the command into the per-session VirtualDisplay via // (`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. // `set_launch_command` (as the GameStream path now does) so sessions can't stomp each other.
if let Some(id) = hello.launch.as_deref() { 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) { match crate::library::launch_command(id) {
Some(cmd) => { Some(cmd) => {
tracing::info!(launch_id = id, command = %cmd, "launching library title"); tracing::info!(launch_id = id, command = %cmd, "launching library title");
@@ -581,6 +593,8 @@ async fn serve_session(
"client requested a launch id not in this host's library — ignoring" "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 // Resolve the client's gamepad-backend preference (pure env/cfg check — no probing
@@ -599,7 +613,7 @@ async fn serve_session(
// opted in (PUNKTFUNK_10BIT). A client that can't decode 10-bit (caps bit clear, or an older // 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 // client) always gets the 8-bit stream. PUNKTFUNK_10BIT is the host policy gate until a
// mgmt/console toggle replaces it. // 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 client_supports_10bit = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_10BIT != 0;
let bit_depth: u8 = if host_wants_10bit && client_supports_10bit { let bit_depth: u8 = if host_wants_10bit && client_supports_10bit {
10 10
@@ -912,6 +926,10 @@ async fn serve_session(
let source = opts.source; let source = opts.source;
let (seconds, frames) = (opts.seconds, opts.frames); let (seconds, frames) = (opts.seconds, opts.frames);
let mode = hello.mode; 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 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 bit_depth = welcome.bit_depth; // resolved encode bit depth (8, or 10 when negotiated)
let stop_stream = stop.clone(); let stop_stream = stop.clone();
@@ -957,21 +975,23 @@ async fn serve_session(
Punktfunk1Source::Virtual => { Punktfunk1Source::Virtual => {
let compositor = compositor let compositor = compositor
.expect("the Virtual source resolves a compositor during the handshake"); .expect("the Virtual source resolves a compositor during the handshake");
virtual_stream( virtual_stream(SessionContext {
session, session,
mode, mode,
seconds, seconds,
stop_stream, stop: stop_stream,
&reconfig_rx, reconfig: reconfig_rx,
&keyframe_rx, keyframe: keyframe_rx,
compositor, compositor,
bitrate_kbps, bitrate_kbps,
bit_depth, bit_depth,
probe_rx, probe_rx,
probe_result_tx, probe_result_tx,
fec_target_dp, fec_target: fec_target_dp,
conn_stream, conn: conn_stream,
) #[cfg(target_os = "windows")]
launch: launch_for_dp,
})
} }
} }
}) })
@@ -1616,7 +1636,7 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool
/// Resolve the client's gamepad-backend preference (the env/logging shell around /// Resolve the client's gamepad-backend preference (the env/logging shell around
/// [`pick_gamepad`]). Always concrete — the `Welcome` reports what the session will drive. /// [`pick_gamepad`]). Always concrete — the `Welcome` reports what the session will drive.
fn resolve_gamepad(pref: GamepadPref) -> GamepadPref { 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( let chosen = pick_gamepad(
pref, pref,
env.as_deref(), env.as_deref(),
@@ -1683,7 +1703,7 @@ fn resolve_compositor(pref: CompositorPref) -> Result<crate::vdisplay::Composito
{ {
// Explicit operator override (legacy / CI / forcing a backend for a test) wins and is assumed // 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. // 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 { let detected = if overridden {
crate::vdisplay::detect().ok() crate::vdisplay::detect().ok()
} else { } else {
@@ -1948,6 +1968,11 @@ pub(crate) fn boost_thread_priority(critical: bool) {
// capture/encode (critical) and send (non-critical). // capture/encode (critical) and send (non-critical).
crate::session_tuning::on_hot_thread(); crate::session_tuning::on_hot_thread();
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
// SAFETY: `GetCurrentThread()` returns the constant pseudo-handle for the calling thread — always
// valid, thread-local in meaning, and never closed (no leak/double-close). `SetThreadPriority`
// takes that handle plus a `THREAD_PRIORITY_*` value the windows crate defines (HIGHEST or
// ABOVE_NORMAL here); it only reprioritizes this OS thread, borrows no Rust memory, and its
// `Result` is matched (a failure is logged, never UB). No pointers, lifetimes, or aliasing.
unsafe { unsafe {
use windows::Win32::System::Threading::{ use windows::Win32::System::Threading::{
GetCurrentThread, SetThreadPriority, THREAD_PRIORITY_ABOVE_NORMAL, GetCurrentThread, SetThreadPriority, THREAD_PRIORITY_ABOVE_NORMAL,
@@ -1975,6 +2000,10 @@ pub(crate) fn boost_thread_priority(critical: bool) {
// realtime CPU class can preempt the compositor AND the game's own render thread, adding the // realtime CPU class can preempt the compositor AND the game's own render thread, adding the
// very frame-time we refuse to add (opt-in only — see PUNKTFUNK_SCHED_RR). // very frame-time we refuse to add (opt-in only — see PUNKTFUNK_SCHED_RR).
let nice = if critical { -10 } else { -5 }; let nice = if critical { -10 } else { -5 };
// SAFETY: `setpriority` takes three by-value integers and no pointers, so there is nothing to
// alias or outlive. `PRIO_PROCESS` with `who == 0` targets the calling task on Linux and
// `nice` is in range; the call only adjusts this thread's scheduling nice value and returns an
// `int` we inspect. No memory is touched.
let rc = unsafe { libc::setpriority(libc::PRIO_PROCESS, 0, nice) }; let rc = unsafe { libc::setpriority(libc::PRIO_PROCESS, 0, nice) };
if rc == 0 { if rc == 0 {
tracing::debug!(critical, nice, "thread nice raised"); tracing::debug!(critical, nice, "thread nice raised");
@@ -2141,56 +2170,65 @@ 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 /// All per-session inputs for [`virtual_stream`] / [`virtual_stream_relay`], bundled so the session entry
/// stamped with the capture wall clock (the client derives per-frame pipeline latency). /// 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
/// `reconfig` delivers accepted mid-stream mode switches: the capture/encode pipeline is /// only consumer) — so the whole context moves into the stream thread and the borrow plumbing disappears.
/// rebuilt at the new mode (capturer drop tears down the PipeWire stream and, via its struct SessionContext {
/// keepalive, the virtual output) while the data-plane `session` continues untouched — /// The hardened data-plane `Session` (Leopard FEC + AES-GCM over UDP); moved into the send thread.
/// 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(
session: Session, session: Session,
/// The client's requested mode — the virtual output is created at exactly this WxH@Hz (no scaling).
mode: punktfunk_core::Mode, mode: punktfunk_core::Mode,
/// Stream duration cap (the persistent listener bounds back-to-back sessions).
seconds: u32, seconds: u32,
/// Session stop flag (set on disconnect / reconnect-preempt).
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
reconfig: &std::sync::mpsc::Receiver<punktfunk_core::Mode>, /// Accepted mid-stream mode switches — the pipeline is rebuilt at the new mode.
keyframe: &std::sync::mpsc::Receiver<()>, 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, compositor: crate::vdisplay::Compositor,
/// Negotiated encoder bitrate (kbps).
bitrate_kbps: u32, bitrate_kbps: u32,
/// Negotiated encode bit depth (8, or 10 = HEVC Main10).
bit_depth: u8, bit_depth: u8,
/// Speed-test burst requests (see [`service_probes`]).
probe_rx: std::sync::mpsc::Receiver<ProbeRequest>, probe_rx: std::sync::mpsc::Receiver<ProbeRequest>,
/// Speed-test results back to the control task.
probe_result_tx: tokio::sync::mpsc::UnboundedSender<ProbeResult>, 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>, fec_target: Arc<AtomicU8>,
/// The QUIC control connection (carries host→client 0xCE source-HDR metadata mid-stream).
conn: quinn::Connection, 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 // 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. // tail-calls the relay below. Elevate it so a CPU-heavy game can't deschedule our GPU submission.
boost_thread_priority(true); 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 // 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 // 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 // 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. // user, and stays the path on Linux.) See docs/windows-secure-desktop.md.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
if should_use_helper() { if plan.topology == crate::session_plan::SessionTopology::TwoProcessRelay {
return virtual_stream_relay( 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, session,
mode, mode,
seconds, seconds,
@@ -2204,8 +2242,9 @@ fn virtual_stream(
probe_result_tx, probe_result_tx,
fec_target, fec_target,
conn, conn,
); #[cfg(target_os = "windows")]
} launch,
} = ctx;
tracing::info!( tracing::info!(
compositor = compositor.id(), compositor = compositor.id(),
?mode, ?mode,
@@ -2213,30 +2252,24 @@ fn virtual_stream(
bit_depth, bit_depth,
"punktfunk/1 virtual display" "punktfunk/1 virtual display"
); );
// IDD-push reconnect preempt: a fresh connection means the prior client is gone. Hold IDD_SETUP_LOCK // Open the backend FIRST — on Windows this constructs the vdisplay backend, which initialises the
// across the preempt + pipeline build so a reconnect FLOOD can't run concurrent monitor // host-lifetime VirtualDisplayManager (§2.5). It does NO monitor work, so it must precede the IDD-push
// create/teardown. Then STOP the prior session (it ends cleanly while its monitor still composites // preempt below (which reaches the manager) — otherwise `vdm()` is called before init and panics.
// 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));
}
}
let mut vd = crate::vdisplay::open(compositor)?; 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) = 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). // Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us).
#[cfg(target_os = "windows")] #[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 // 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 // SOLE active output, goes into fullscreen independent-flip (one plane on one display) which Desktop
@@ -2251,7 +2284,18 @@ fn virtual_stream(
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
let _composed_flip = crate::capture::composed_flip::ForceComposedFlip::start(); 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; // 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. // 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") let burst_cap = std::env::var("PUNKTFUNK_PACE_BURST_KB")
@@ -2291,7 +2335,7 @@ fn virtual_stream(
let mut compositor = compositor; let mut compositor = compositor;
let (session_tx, session_rx) = std::sync::mpsc::channel::<SessionSwitch>(); let (session_tx, session_rx) = std::sync::mpsc::channel::<SessionSwitch>();
let watch = std::env::var_os("PUNKTFUNK_SESSION_WATCH").is_some() 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 _watcher = if watch {
let stop = stop.clone(); let stop = stop.clone();
std::thread::Builder::new() std::thread::Builder::new()
@@ -2324,6 +2368,12 @@ fn virtual_stream(
// compositing), NOT an encoder problem. Logged every 2 s when `PUNKTFUNK_PERF`. // compositing), NOT an encoder problem. Logged every 2 s when `PUNKTFUNK_PERF`.
let (mut diag_new, mut diag_repeat) = (0u64, 0u64); let (mut diag_new, mut diag_repeat) = (0u64, 0u64);
let mut diag_at = std::time::Instant::now(); let mut diag_at = std::time::Instant::now();
// Per-stage latency breakdown (PUNKTFUNK_PERF): per-call µs for the GPU-bound stages so we see
// exactly where the capture→encoded latency goes — cap=try_latest (ring read + colour convert),
// submit=encode_picture launch, wait=lock_bitstream (the scheduling wait + ASIC encode, the one
// that dominates under a GPU-saturating game).
let (mut st_cap, mut st_submit, mut st_wait): (Vec<u32>, Vec<u32>, Vec<u32>) =
(Vec::new(), Vec::new(), Vec::new());
while !stop.load(Ordering::SeqCst) && std::time::Instant::now() < deadline { while !stop.load(Ordering::SeqCst) && std::time::Instant::now() < deadline {
// Mid-stream session switch (the box flipped Gaming↔Desktop): rebuild the WHOLE backend in // Mid-stream session switch (the box flipped Gaming↔Desktop): rebuild the WHOLE backend in
// place — a different compositor at the SAME client mode — keeping the Session + send thread // place — a different compositor at the SAME client mode — keeping the Session + send thread
@@ -2362,6 +2412,7 @@ fn virtual_stream(
cur_mode, cur_mode,
bitrate_kbps, bitrate_kbps,
bit_depth, bit_depth,
plan,
)?; )?;
Ok((new_vd, pipe)) Ok((new_vd, pipe))
})(); })();
@@ -2405,7 +2456,7 @@ fn virtual_stream(
// Build the new pipeline BEFORE dropping the old one: the host already acked // 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 // the switch as accepted, so a rebuild failure must not kill an otherwise
// healthy session — keep streaming the current mode and log instead. // 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) => { Ok(next_pipe) => {
(capturer, enc, frame, interval) = next_pipe; (capturer, enc, frame, interval) = next_pipe;
cur_mode = new_mode; cur_mode = new_mode;
@@ -2429,7 +2480,12 @@ fn virtual_stream(
tracing::debug!("forcing keyframe (client decode recovery)"); tracing::debug!("forcing keyframe (client decode recovery)");
enc.request_keyframe(); enc.request_keyframe();
} }
match capturer.try_latest() { let t_cap = std::time::Instant::now();
let cap_result = capturer.try_latest();
if perf {
st_cap.push(t_cap.elapsed().as_micros() as u32);
}
match cap_result {
Ok(Some(f)) => { Ok(Some(f)) => {
frame = f; frame = f;
diag_new += 1; diag_new += 1;
@@ -2450,7 +2506,7 @@ fn virtual_stream(
tracing::warn!(error = %format!("{e:#}"), rebuild = capture_rebuilds, tracing::warn!(error = %format!("{e:#}"), rebuild = capture_rebuilds,
"capture lost — rebuilding pipeline in place"); "capture lost — rebuilding pipeline in place");
let (new_cap, new_enc, new_frame, new_interval) = 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")?; .context("rebuild after capture loss")?;
capturer = new_cap; capturer = new_cap;
enc = new_enc; enc = new_enc;
@@ -2468,6 +2524,20 @@ fn virtual_stream(
"capture diag: NEW frames from the source vs REPEATS (low new_fps at high send rate ⇒ \ "capture diag: NEW frames from the source vs REPEATS (low new_fps at high send rate ⇒ \
the source isn't producing frames, not an encode stall)" the source isn't producing frames, not an encode stall)"
); );
let wait_max = st_wait.iter().copied().max().unwrap_or(0);
tracing::info!(
cap_us_p50 = percentile(&mut st_cap, 0.50),
cap_us_p99 = percentile(&mut st_cap, 0.99),
submit_us_p50 = percentile(&mut st_submit, 0.50),
submit_us_p99 = percentile(&mut st_submit, 0.99),
wait_us_p50 = percentile(&mut st_wait, 0.50),
wait_us_p99 = percentile(&mut st_wait, 0.99),
wait_us_max = wait_max,
"stage perf (µs/call): cap=try_latest(ring+convert) submit=encode_picture wait=lock_bitstream(sched+ASIC)"
);
st_cap.clear();
st_submit.clear();
st_wait.clear();
diag_new = 0; diag_new = 0;
diag_repeat = 0; diag_repeat = 0;
diag_at = std::time::Instant::now(); diag_at = std::time::Instant::now();
@@ -2486,7 +2556,11 @@ fn virtual_stream(
// capturer hands a rotating ring of output textures, so it returns >1; other capturers default 1. // capturer hands a rotating ring of output textures, so it returns >1; other capturers default 1.
let depth = capturer.pipeline_depth().max(1); let depth = capturer.pipeline_depth().max(1);
let capture_ns = now_ns(); let capture_ns = now_ns();
let t_submit = std::time::Instant::now();
enc.submit(&frame).context("encoder submit")?; enc.submit(&frame).context("encoder submit")?;
if perf {
st_submit.push(t_submit.elapsed().as_micros() as u32);
}
// This frame's pacing deadline (the next frame's due time); the send thread spreads a big frame // This frame's pacing deadline (the next frame's due time); the send thread spreads a big frame
// up to here. Each in-flight frame carries its own (capture_ns, deadline) for when it's polled. // up to here. Each in-flight frame carries its own (capture_ns, deadline) for when it's polled.
next += interval; next += interval;
@@ -2497,7 +2571,12 @@ fn virtual_stream(
// the oldest submitted frame's AU — matching `inflight.pop_front()`. // the oldest submitted frame's AU — matching `inflight.pop_front()`.
let mut send_gone = false; let mut send_gone = false;
while inflight.len() >= depth { while inflight.len() >= depth {
let au = match enc.poll().context("encoder poll")? { let t_wait = std::time::Instant::now();
let polled = enc.poll().context("encoder poll")?;
if perf {
st_wait.push(t_wait.elapsed().as_micros() as u32);
}
let au = match polled {
Some(au) => au, Some(au) => au,
None => break, // no AU ready for a submitted frame (shouldn't happen — poll blocks) None => break, // no AU ready for a submitted frame (shouldn't happen — poll blocks)
}; };
@@ -2569,29 +2648,6 @@ fn virtual_stream(
Ok(()) 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 /// 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 /// 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 /// capture+encode the NORMAL desktop, and relays the helper's AUs onto the QUIC data plane via the
@@ -2603,27 +2659,30 @@ fn should_use_helper() -> bool {
/// helper at the new mode (and drops the stale-target DDA); keyframe requests forward to the active /// helper at the new mode (and drops the stale-target DDA); keyframe requests forward to the active
/// source. /// source.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[allow(clippy::too_many_arguments)] fn virtual_stream_relay(ctx: SessionContext) -> Result<()> {
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<()> {
use crate::capture::dxgi::WinCaptureTarget; use crate::capture::dxgi::WinCaptureTarget;
use crate::capture::wgc_relay::HelperRelay; use crate::capture::wgc_relay::HelperRelay;
use crate::capture::Capturer; // trait methods (set_active/next_frame) on the concrete DuplCapturer 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!( tracing::info!(
?mode, ?mode,
bitrate_kbps, bitrate_kbps,
@@ -2660,8 +2719,13 @@ fn virtual_stream_relay(
// The secure-desktop HDR drop (for the DDA leg) keys off the monitor's real state in the mux loop. // The secure-desktop HDR drop (for the DDA leg) keys off the monitor's real state in the mux loop.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
if bit_depth >= 10 { if bit_depth >= 10 {
// SAFETY: `set_advanced_color` is marked `unsafe` only because it drives the Win32 CCD API
// internally; it takes `target_id` by value (Copy `u32` — this session's live SudoVDA
// monitor's CCD target id) and sizes + owns every buffer it hands the OS on its own stack.
// We pass no pointers, so nothing must outlive the call and there is no aliasing; an
// unknown/absent target id simply returns false.
unsafe { unsafe {
if crate::vdisplay::sudovda::set_advanced_color(target.target_id, true) { if crate::win_display::set_advanced_color(target.target_id, true) {
// Let the colorspace change settle before WGC creates its capture item / detects HDR. // Let the colorspace change settle before WGC creates its capture item / detects HDR.
std::thread::sleep(std::time::Duration::from_millis(250)); std::thread::sleep(std::time::Duration::from_millis(250));
} }
@@ -2680,6 +2744,15 @@ fn virtual_stream_relay(
let (mut _keepalive, mut relay, mut target, mut effective_hz) = build(&mut vd, mode)?; let (mut _keepalive, mut relay, mut target, mut effective_hz) = build(&mut vd, mode)?;
let mut cur_mode = 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 // 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. // 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() { if std::env::var_os("PUNKTFUNK_IDD_PUSH_OBSERVE").is_some() {
@@ -2708,6 +2781,9 @@ fn virtual_stream_relay(
target.clone(), target.clone(),
Some((w, h, hz)), Some((w, h, hz)),
Box::new(()), 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, hdr,
) )
.context("open DDA for secure desktop")?; .context("open DDA for secure desktop")?;
@@ -2731,7 +2807,7 @@ fn virtual_stream_relay(
}) })
}; };
let perf = std::env::var("PUNKTFUNK_PERF").is_ok(); let perf = crate::config::config().perf;
let burst_cap = std::env::var("PUNKTFUNK_PACE_BURST_KB") let burst_cap = std::env::var("PUNKTFUNK_PACE_BURST_KB")
.ok() .ok()
.and_then(|s| s.parse::<usize>().ok()) .and_then(|s| s.parse::<usize>().ok())
@@ -2770,7 +2846,7 @@ fn virtual_stream_relay(
// the secure desktop's HDR independent-flip (it storms ACCESS_LOST → black), whereas the WGC helper // 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 // 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. // 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); // The authoritative Default↔Winlogon signal (requires SYSTEM to read the Winlogon desktop name);
// only needed when the DDA-on-secure path is enabled. // only needed when the DDA-on-secure path is enabled.
let watcher = dda_secure.then(crate::capture::desktop_watch::DesktopWatcher::start); let watcher = dda_secure.then(crate::capture::desktop_watch::DesktopWatcher::start);
@@ -2883,8 +2959,12 @@ fn virtual_stream_relay(
// desktop (the drop just churned + still went black). Instead, if the monitor is in HDR, // desktop (the drop just churned + still went black). Instead, if the monitor is in HDR,
// open DDA in HDR (FP16 DuplicateOutput1 → BT.2020 PQ Main10); the normal-desktop DDA // open DDA in HDR (FP16 DuplicateOutput1 → BT.2020 PQ Main10); the normal-desktop DDA
// overlay/flip issues that drove us to WGC don't apply to the composed Winlogon UI. // overlay/flip issues that drove us to WGC don't apply to the composed Winlogon UI.
let hdr = // SAFETY: `advanced_color_enabled` is `unsafe` only because it queries the Win32 CCD
unsafe { crate::vdisplay::sudovda::advanced_color_enabled(target.target_id) }; // API; it takes `target_id` by value (the live SudoVDA monitor's CCD target id) and
// allocates + owns every buffer it passes the OS internally. No caller pointer is
// involved, so nothing must outlive the call and there is no aliasing; a missing
// target id just yields false.
let hdr = unsafe { crate::win_display::advanced_color_enabled(target.target_id) };
dda = None; // reopen to capture the secure desktop dda = None; // reopen to capture the secure desktop
match open_dda(&target, cur_mode.width, cur_mode.height, effective_hz, hdr) { match open_dda(&target, cur_mode.width, cur_mode.height, effective_hz, hdr) {
Ok(mut p) => { Ok(mut p) => {
@@ -3041,6 +3121,7 @@ fn build_pipeline_with_retry(
mode: punktfunk_core::Mode, mode: punktfunk_core::Mode,
bitrate_kbps: u32, bitrate_kbps: u32,
bit_depth: u8, bit_depth: u8,
plan: crate::session_plan::SessionPlan,
) -> Result<Pipeline> { ) -> Result<Pipeline> {
// ~10s first-frame wait per attempt. 8 gives a ~90s budget for the SLOW case: a host-managed // ~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 // gamescope session cold-starting Steam Big Picture (the SteamOS/Bazzite takeover) can take
@@ -3050,7 +3131,7 @@ fn build_pipeline_with_retry(
const MAX_ATTEMPTS: u32 = 8; const MAX_ATTEMPTS: u32 = 8;
let mut backoff = std::time::Duration::from_millis(500); let mut backoff = std::time::Duration::from_millis(500);
for attempt in 1..=MAX_ATTEMPTS { 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) => { Ok(pipe) => {
if attempt > 1 { if attempt > 1 {
tracing::info!(attempt, "pipeline up after retry"); tracing::info!(attempt, "pipeline up after retry");
@@ -3109,6 +3190,7 @@ fn build_pipeline(
mode: punktfunk_core::Mode, mode: punktfunk_core::Mode,
bitrate_kbps: u32, bitrate_kbps: u32,
bit_depth: u8, bit_depth: u8,
plan: crate::session_plan::SessionPlan,
) -> Result<Pipeline> { ) -> Result<Pipeline> {
let vout = vd.create(mode).context("create virtual output")?; 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 // The backend reports the refresh it actually achieved in `preferred_mode.2` (KWin may cap a
@@ -3131,7 +3213,8 @@ fn build_pipeline(
// VIDEO_CAP_10BIT + host opted in via PUNKTFUNK_10BIT) is our HDR path → BT.2020 PQ Rgb10a2; // 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, // 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.) // which auto-detect HDR from the monitor state.)
let mut capturer = crate::capture::capture_virtual_output(vout, bit_depth >= 10) let mut capturer =
crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture)
.context("capture virtual output")?; .context("capture virtual output")?;
capturer.set_active(true); capturer.set_active(true);
let frame = capturer.next_frame().context("first frame")?; let frame = capturer.next_frame().context("first frame")?;
@@ -3306,12 +3389,27 @@ mod tests {
unsafe fn pull_verified(conn: *mut punktfunk_core::abi::PunktfunkConnection, count: u32) { unsafe fn pull_verified(conn: *mut punktfunk_core::abi::PunktfunkConnection, count: u32) {
use punktfunk_core::error::PunktfunkStatus; use punktfunk_core::error::PunktfunkStatus;
let mut got = 0u32; let mut got = 0u32;
// SAFETY: the inferred type is the `#[repr(C)]` POD `PunktfunkFrame` (a raw `*const u8`, a
// `usize`, and integer fields); all-zero is a valid bit pattern for every field (a null
// `data`, `len == 0`). It is only ever read after `next_au` below fully overwrites it on `Ok`,
// so the zeroed value is never observed.
let mut frame = unsafe { std::mem::zeroed() }; let mut frame = unsafe { std::mem::zeroed() };
while got < count { while got < count {
// SAFETY: `conn` is the live, non-null `*mut PunktfunkConnection` from `punktfunk_connect`
// (the caller asserts non-null and does not close it until after this returns), meeting the
// ABI's "valid handle". `&mut frame` is an exclusive, writable borrow of the local
// `PunktfunkFrame` that outlives this synchronous call. This single test thread is the only
// video puller, satisfying the one-video-thread rule.
match unsafe { match unsafe {
punktfunk_core::abi::punktfunk_connection_next_au(conn, &mut frame, 2000) punktfunk_core::abi::punktfunk_connection_next_au(conn, &mut frame, 2000)
} { } {
PunktfunkStatus::Ok => { PunktfunkStatus::Ok => {
// SAFETY: on `Ok`, `next_au` set `frame.data`/`frame.len` to the reassembled AU
// buffer the connection owns; per the ABI contract that borrow stays valid until
// the NEXT `next_au` call on this handle. We read the whole slice here (the assert
// + length-checked indexing) before the loop's next `next_au`, and `conn` outlives
// it — so the pointer is live, exactly `len` bytes, read-only, single-threaded (no
// aliasing/use-after-free).
let data = unsafe { std::slice::from_raw_parts(frame.data, frame.len) }; let data = unsafe { std::slice::from_raw_parts(frame.data, frame.len) };
let idx = u32::from_le_bytes(data[0..4].try_into().unwrap()); let idx = u32::from_le_bytes(data[0..4].try_into().unwrap());
assert_eq!( assert_eq!(
@@ -3359,6 +3457,11 @@ mod tests {
// Session 1: TOFU (no pin) — observe the host fingerprint. // Session 1: TOFU (no pin) — observe the host fingerprint.
let addr = std::ffi::CString::new("127.0.0.1").unwrap(); let addr = std::ffi::CString::new("127.0.0.1").unwrap();
let mut observed = [0u8; 32]; let mut observed = [0u8; 32];
// SAFETY: `addr` is a live `CString` ("127.0.0.1") whose `as_ptr()` is the NUL-terminated
// UTF-8 host string the contract requires; `pin_sha256`/cert/key are NULL (all permitted), and
// `observed.as_mut_ptr()` is the local `[u8; 32]` — exactly the 32 writable bytes the contract
// demands, not aliased during the call. Every pointer references a live local that outlives the
// blocking connect.
let conn = unsafe { let conn = unsafe {
punktfunk_connect( punktfunk_connect(
addr.as_ptr(), addr.as_ptr(),
@@ -3377,26 +3480,28 @@ mod tests {
assert_ne!(observed, [0u8; 32], "fingerprint not reported"); assert_ne!(observed, [0u8; 32], "fingerprint not reported");
let (mut w, mut h, mut hz) = (0u32, 0u32, 0u32); let (mut w, mut h, mut hz) = (0u32, 0u32, 0u32);
assert_eq!( // SAFETY: `conn` is the live, non-null connection handle just asserted above; `&mut w/h/hz` are
unsafe { punktfunk_connection_mode(conn, &mut w, &mut h, &mut hz) }, // exclusive, writable borrows of local `u32`s that outlive this synchronous call — the three
PunktfunkStatus::Ok // writable out-params the contract names.
); let st = unsafe { punktfunk_connection_mode(conn, &mut w, &mut h, &mut hz) };
assert_eq!(st, PunktfunkStatus::Ok);
assert_eq!((w, h, hz), (1280, 720, 60)); assert_eq!((w, h, hz), (1280, 720, 60));
// Mid-stream renegotiation: request a new mode, the host acks on the control // Mid-stream renegotiation: request a new mode, the host acks on the control
// stream, and punktfunk_connection_mode reflects the switch. // stream, and punktfunk_connection_mode reflects the switch.
assert_eq!( // SAFETY: `conn` is the live, non-null connection handle (the only pointer arg); the remaining
unsafe { // arguments are by-value integers. The handle outlives this non-blocking enqueue.
let st = unsafe {
punktfunk_core::abi::punktfunk_connection_request_mode(conn, 1920, 1080, 144) punktfunk_core::abi::punktfunk_connection_request_mode(conn, 1920, 1080, 144)
}, };
PunktfunkStatus::Ok assert_eq!(st, PunktfunkStatus::Ok);
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop { loop {
assert_eq!( // SAFETY: same as the earlier `punktfunk_connection_mode` call — `conn` is the live handle
unsafe { punktfunk_connection_mode(conn, &mut w, &mut h, &mut hz) }, // and `&mut w/h/hz` are exclusive writable borrows of locals that outlive this synchronous
PunktfunkStatus::Ok // call.
); let st = unsafe { punktfunk_connection_mode(conn, &mut w, &mut h, &mut hz) };
assert_eq!(st, PunktfunkStatus::Ok);
if (w, h, hz) == (1920, 1080, 144) { if (w, h, hz) == (1920, 1080, 144) {
break; break;
} }
@@ -3407,6 +3512,8 @@ mod tests {
std::thread::sleep(std::time::Duration::from_millis(20)); std::thread::sleep(std::time::Duration::from_millis(20));
} }
// SAFETY: `pull_verified` requires a live connection handle it alone pulls video from; `conn` is
// the open, non-null handle from `punktfunk_connect` and this is the only thread touching it.
unsafe { pull_verified(conn, 25) }; unsafe { pull_verified(conn, 25) };
let ev = punktfunk_core::input::InputEvent { let ev = punktfunk_core::input::InputEvent {
@@ -3417,13 +3524,19 @@ mod tests {
y: 2, y: 2,
flags: 0, flags: 0,
}; };
assert_eq!( // SAFETY: `conn` is the live handle; `&ev` borrows the local `InputEvent`, valid and immutable
unsafe { punktfunk_connection_send_input(conn, &ev) }, // for this synchronous enqueue — the contract's "valid InputEvent" pointer.
PunktfunkStatus::Ok let st = unsafe { punktfunk_connection_send_input(conn, &ev) };
); assert_eq!(st, PunktfunkStatus::Ok);
// SAFETY: `conn` was returned by `punktfunk_connect` and is never used after this call (session
// 2 below uses a fresh `conn2`); `close` takes ownership and frees the handle exactly once.
unsafe { punktfunk_connection_close(conn) }; unsafe { punktfunk_connection_close(conn) };
// Session 2 (same host process — the listener survived): pin the fingerprint. // Session 2 (same host process — the listener survived): pin the fingerprint.
// SAFETY: as for session 1 — `addr` is the live NUL-terminated host string; here
// `observed.as_ptr()` is the 32-byte pin (the fingerprint captured above, a valid `[u8; 32]`),
// `observed_sha256_out` is NULL and cert/key are NULL. All pointers reference live locals for
// the duration of the blocking connect.
let conn2 = unsafe { let conn2 = unsafe {
punktfunk_connect( punktfunk_connect(
addr.as_ptr(), addr.as_ptr(),
@@ -3439,11 +3552,17 @@ mod tests {
) )
}; };
assert!(!conn2.is_null(), "pinned reconnect failed"); assert!(!conn2.is_null(), "pinned reconnect failed");
// SAFETY: `conn2` is the live, non-null pinned handle, pulled only from this thread —
// `pull_verified`'s requirement.
unsafe { pull_verified(conn2, 25) }; unsafe { pull_verified(conn2, 25) };
// SAFETY: `conn2` came from `punktfunk_connect` and is not used after this; `close` frees it once.
unsafe { punktfunk_connection_close(conn2) }; unsafe { punktfunk_connection_close(conn2) };
// Session 3: a wrong pin must be rejected by the handshake. // Session 3: a wrong pin must be rejected by the handshake.
let bad = [0xAAu8; 32]; let bad = [0xAAu8; 32];
// SAFETY: same shape as the prior connects — `addr` is the live host string, `bad.as_ptr()` is
// the 32-byte `[0xAA; 32]` pin, and out/cert/key are NULL; all reference live locals across the
// blocking call. (The handshake is expected to fail and return NULL here, which is sound.)
let conn3 = unsafe { let conn3 = unsafe {
punktfunk_connect( punktfunk_connect(
addr.as_ptr(), addr.as_ptr(),
@@ -3463,6 +3582,8 @@ mod tests {
// The host saw the rejected handshake attempt as session 3? No — a TLS-failed // The host saw the rejected handshake attempt as session 3? No — a TLS-failed
// handshake never yields a connection, so accept() is still waiting. Connect once // handshake never yields a connection, so accept() is still waiting. Connect once
// more (TOFU) to complete the host's third session and let it exit. // more (TOFU) to complete the host's third session and let it exit.
// SAFETY: same as session 1's connect — `addr` is the live host string, pin/out/cert/key all
// NULL; the pointers reference live locals for the duration of the blocking connect.
let conn4 = unsafe { let conn4 = unsafe {
punktfunk_connect( punktfunk_connect(
addr.as_ptr(), addr.as_ptr(),
@@ -3478,7 +3599,9 @@ mod tests {
) )
}; };
assert!(!conn4.is_null()); assert!(!conn4.is_null());
// SAFETY: `conn4` is the live, non-null handle, pulled only from this thread.
unsafe { pull_verified(conn4, 25) }; unsafe { pull_verified(conn4, 25) };
// SAFETY: `conn4` came from `punktfunk_connect` and is unused after this; `close` frees it once.
unsafe { punktfunk_connection_close(conn4) }; unsafe { punktfunk_connection_close(conn4) };
host.join().unwrap().unwrap(); host.join().unwrap().unwrap();
+171
View File
@@ -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 45; the per-session encoder open still
/// resolves via `encode::windows_resolved_backend`, which is config-backed + GPU-vendor cached).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EncoderBackend {
/// Linux: NVENC vs VAAPI is auto-detected inside `encode::open_video` (not modeled here).
PlatformAuto,
Nvenc,
Amf,
Qsv,
Software,
}
impl EncoderBackend {
/// True if this backend encodes on the GPU (so the capturer should produce GPU-resident frames). Only
/// the software encoder takes CPU staging; `PlatformAuto` (Linux NVENC/VAAPI) is always GPU.
pub fn is_gpu(self) -> bool {
!matches!(self, EncoderBackend::Software)
}
}
/// The per-session decision, resolved once. `Copy` so it threads through the capture/encode chain
/// without ceremony (stage 4 folds it, with the rest of the arg soup, into a `SessionContext`).
#[derive(Clone, Copy, Debug)]
pub struct SessionPlan {
pub capture: CaptureBackend,
pub topology: SessionTopology,
pub encoder: EncoderBackend,
/// Handshake-negotiated encode bit depth (8, or 10 = HEVC Main10).
pub bit_depth: u8,
/// The IDD-push HDR hint (`bit_depth >= 10`) — the want-HDR flag the capturer was passed before.
/// Non-IDD-push Windows backends ignore it and auto-detect HDR from the monitor; Linux is 8-bit.
pub hdr: bool,
}
impl SessionPlan {
/// Resolve the whole plan once from [`config`](crate::config) + the negotiated `bit_depth`.
pub fn resolve(bit_depth: u8) -> Self {
SessionPlan {
capture: CaptureBackend::resolve(),
topology: resolve_topology(),
encoder: resolve_encoder(),
bit_depth,
hdr: bit_depth >= 10,
}
}
/// The capturer's target output format (Goal-1 stage 5): `gpu` from the already-resolved `encoder`
/// (no second backend probe), `hdr` from the plan. Handed into `capture::capture_virtual_output` so the
/// capturer never re-derives the encode backend.
pub fn output_format(&self) -> crate::capture::OutputFormat {
crate::capture::OutputFormat {
gpu: self.encoder.is_gpu(),
hdr: self.hdr,
}
}
}
/// Process topology. On Windows this is the former `punktfunk1::should_use_helper` logic verbatim; on
/// every other platform the session is always single-process.
#[cfg(target_os = "windows")]
fn resolve_topology() -> SessionTopology {
let cfg = crate::config::config();
// `NO_HELPER`/`NO_WGC` force single-process; IDD-push captures in-process in Session 0 (no helper);
// otherwise the helper runs when forced or when we're SYSTEM (in-process WGC can't activate there).
let helper = if cfg.no_helper || crate::capture::wgc_disabled() || cfg.idd_push {
false
} else {
cfg.force_helper || crate::capture::wgc_relay::running_as_system()
};
if helper {
SessionTopology::TwoProcessRelay
} else {
SessionTopology::SingleProcess
}
}
#[cfg(not(target_os = "windows"))]
fn resolve_topology() -> SessionTopology {
SessionTopology::SingleProcess
}
#[cfg(target_os = "windows")]
fn resolve_encoder() -> EncoderBackend {
match crate::encode::windows_resolved_backend() {
crate::encode::WindowsBackend::Nvenc => EncoderBackend::Nvenc,
crate::encode::WindowsBackend::Amf => EncoderBackend::Amf,
crate::encode::WindowsBackend::Qsv => EncoderBackend::Qsv,
crate::encode::WindowsBackend::Software => EncoderBackend::Software,
}
}
#[cfg(not(target_os = "windows"))]
fn resolve_encoder() -> EncoderBackend {
EncoderBackend::PlatformAuto
}
@@ -11,6 +11,9 @@
//! state) auto-revert at thread exit (= session end); the process-wide bits revert at process exit. //! state) auto-revert at thread exit (= session end); the process-wide bits revert at process exit.
//! See `docs/host-latency-plan.md` Tier 3A. //! See `docs/host-latency-plan.md` Tier 3A.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
mod imp { mod imp {
#![allow(non_snake_case)] #![allow(non_snake_case)]
@@ -49,6 +52,10 @@ mod imp {
/// Process-wide tuning, applied exactly once. Reverts at process exit. Best-effort: each call is /// Process-wide tuning, applied exactly once. Reverts at process exit. Best-effort: each call is
/// independent and a failure is ignored (e.g. a non-elevated host may not get HIGH class). /// independent and a failure is ignored (e.g. a non-elevated host may not get HIGH class).
fn tune_process_once() { fn tune_process_once() {
// SAFETY: each call is a C-ABI FFI into winmm/kernel32/dwmapi declared with a matching
// `extern "system"` signature; every argument is a plain integer (no pointers/buffers escape),
// and `GetCurrentProcess()` returns the current-process pseudo-handle (a constant, always valid,
// never closed). The body runs inside `get_or_init`, so it executes exactly once per process.
PROCESS_TUNED.get_or_init(|| unsafe { PROCESS_TUNED.get_or_init(|| unsafe {
// 1 ms timer granularity (default ~15.6 ms) — the floor for precise frame pacing and the // 1 ms timer granularity (default ~15.6 ms) — the floor for precise frame pacing and the
// encode|send split's sub-ms sleeps. // encode|send split's sub-ms sleeps.
@@ -70,6 +77,11 @@ mod imp {
/// thread exits, so a session that ends tears them down without explicit bookkeeping. /// thread exits, so a session that ends tears them down without explicit bookkeeping.
pub fn on_hot_thread() { pub fn on_hot_thread() {
tune_process_once(); tune_process_once();
// SAFETY: C-ABI FFI declared with matching `extern "system"` signatures. SetThreadExecutionState
// takes only flag bits. `task` is a local NUL-terminated UTF-16 buffer ("Games\0") alive for the
// whole block, so `task.as_ptr()` is a valid LPCWSTR for the call, and `&mut idx` is a live local
// u32 the call writes the task index into. The returned MMCSS handle is intentionally leaked (the
// OS reverts the characteristics at thread exit), so there is nothing to free or double-free.
unsafe { unsafe {
SetThreadExecutionState(ES_CONTINUOUS | ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED); SetThreadExecutionState(ES_CONTINUOUS | ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED);
let task: Vec<u16> = "Games\0".encode_utf16().collect(); let task: Vec<u16> = "Games\0".encode_utf16().collect();
+6 -1
View File
@@ -76,7 +76,12 @@ pub fn run(opts: Options) -> Result<()> {
refresh_hz: opts.fps, refresh_hz: opts.fps,
}) })
.context("create virtual output")?; .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")?
} }
}; };
+25 -30
View File
@@ -13,6 +13,9 @@
//! owned keepalive whose `Drop` releases the output (RAII — no explicit `destroy`). Capture //! owned keepalive whose `Drop` releases the output (RAII — no explicit `destroy`). Capture
//! consumes the node via [`crate::capture::capture_virtual_output`]. //! consumes the node via [`crate::capture::capture_virtual_output`].
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::Result; use anyhow::Result;
pub use punktfunk_core::Mode; pub use punktfunk_core::Mode;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -225,6 +228,8 @@ pub fn compositor_for_kind(kind: ActiveKind) -> Option<Compositor> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn default_runtime_dir() -> String { fn default_runtime_dir() -> String {
std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| { std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| {
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no
// memory — it just returns the calling process's real uid. Nothing is aliased or freed.
let uid = unsafe { libc::getuid() }; let uid = unsafe { libc::getuid() };
format!("/run/user/{uid}") format!("/run/user/{uid}")
}) })
@@ -245,6 +250,8 @@ fn default_bus(runtime: &str) -> String {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub fn detect_active_session() -> ActiveSession { pub fn detect_active_session() -> ActiveSession {
use std::os::unix::fs::MetadataExt; use std::os::unix::fs::MetadataExt;
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory —
// it just returns the calling process's real uid. Nothing is aliased or freed.
let uid = unsafe { libc::getuid() }; let uid = unsafe { libc::getuid() };
let xdg_runtime_dir = default_runtime_dir(); let xdg_runtime_dir = default_runtime_dir();
let dbus = default_bus(&xdg_runtime_dir); let dbus = default_bus(&xdg_runtime_dir);
@@ -479,7 +486,7 @@ pub fn apply_input_env(_chosen: Compositor) {}
/// a backend for a test), else the **live session** ([`detect_active_session`] — so a Bazzite box /// 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. /// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read.
pub fn detect() -> Result<Compositor> { 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() { return match v.trim().to_ascii_lowercase().as_str() {
"kwin" | "kde" | "plasma" => Ok(Compositor::Kwin), "kwin" | "kde" | "plasma" => Ok(Compositor::Kwin),
"wlroots" | "sway" | "hyprland" | "wlr" => Ok(Compositor::Wlroots), "wlroots" | "sway" | "hyprland" | "wlr" => Ok(Compositor::Wlroots),
@@ -529,15 +536,15 @@ pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
// Two virtual-display backends: the new pf-vdisplay IddCx driver (pf_vdisplay_proto) and the // The pf-vdisplay all-Rust IddCx driver is the sole virtual-display backend (the legacy SudoVDA
// shipping SudoVDA fallback. The compositor arg is moot on Windows. PUNKTFUNK_VDISPLAY overrides; // fallback was removed — its driver is no longer shipped). The compositor arg is moot on Windows.
// default auto-detects (prefer pf-vdisplay if its driver interface is present).
let _ = compositor; let _ = compositor;
if windows_use_pf_vdisplay() { 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()?)) Ok(Box::new(pf_vdisplay::PfVdisplayDisplay::new()?))
} else {
Ok(Box::new(sudovda::SudoVdaDisplay::new()?))
}
} }
#[cfg(not(any(target_os = "linux", target_os = "windows")))] #[cfg(not(any(target_os = "linux", target_os = "windows")))]
{ {
@@ -546,22 +553,6 @@ pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
} }
} }
/// Pick the Windows virtual-display backend. `PUNKTFUNK_VDISPLAY=pf|pf-vdisplay|pfvd` forces the new
/// pf-vdisplay IddCx driver; `=sudovda|sudo` forces the shipping SudoVDA driver; anything else (the
/// default) auto-detects, preferring pf-vdisplay if its device interface is enumerable.
#[cfg(target_os = "windows")]
fn windows_use_pf_vdisplay() -> bool {
match std::env::var("PUNKTFUNK_VDISPLAY")
.ok()
.as_deref()
.map(str::trim)
{
Some("pf") | Some("pf-vdisplay") | Some("pfvd") => true,
Some("sudovda") | Some("sudo") => false,
_ => pf_vdisplay::is_available(),
}
}
/// Readiness probe for `compositor`: is it up and able to create a virtual output *right /// 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 /// 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. /// on actual readiness instead of racing the compositor with a blind sleep.
@@ -582,11 +573,7 @@ pub fn probe(compositor: Compositor) -> Result<()> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
let _ = compositor; let _ = compositor;
if windows_use_pf_vdisplay() {
pf_vdisplay::probe() pf_vdisplay::probe()
} else {
sudovda::probe()
}
} }
#[cfg(not(any(target_os = "linux", target_os = "windows")))] #[cfg(not(any(target_os = "linux", target_os = "windows")))]
{ {
@@ -627,17 +614,25 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
std::sync::Arc::new(()) 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")] #[cfg(target_os = "linux")]
#[path = "vdisplay/linux/gamescope.rs"]
mod gamescope; mod gamescope;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "vdisplay/linux/kwin.rs"]
mod kwin; mod kwin;
#[cfg(target_os = "windows")]
#[path = "vdisplay/windows/manager.rs"]
pub(crate) mod manager;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "vdisplay/linux/mutter.rs"]
mod mutter; mod mutter;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "vdisplay/windows/pf_vdisplay.rs"]
pub(crate) mod pf_vdisplay; pub(crate) mod pf_vdisplay;
#[cfg(target_os = "windows")]
pub(crate) mod sudovda;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "vdisplay/linux/wlroots.rs"]
mod wlroots; mod wlroots;
#[cfg(test)] #[cfg(test)]
@@ -15,6 +15,8 @@
//! the KWin session's environment. //! the KWin session's environment.
#![allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)] #![allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{Mode, VirtualDisplay, VirtualOutput}; use super::{Mode, VirtualDisplay, VirtualOutput};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
@@ -495,6 +497,11 @@ fn run(
events: libc::POLLIN, events: libc::POLLIN,
revents: 0, revents: 0,
}; };
// SAFETY: `&mut pfd` points at a single live, fully-initialized `libc::pollfd` on the stack, and
// the count `1` matches that one-element array, so `poll` reads `fd`/`events` and writes `revents`
// strictly within `pfd`. `pfd.fd` is the Wayland connection's fd, valid because `conn` (and the
// `prepare_read` guard) are alive across the call. `poll` blocks up to 200 ms and writes only
// `revents`; `pfd` outlives the synchronous call and aliases nothing (a fresh local).
let r = unsafe { libc::poll(&mut pfd, 1, 200) }; let r = unsafe { libc::poll(&mut pfd, 1, 200) };
if r > 0 && (pfd.revents & libc::POLLIN) != 0 { if r > 0 && (pfd.revents & libc::POLLIN) != 0 {
let _ = guard.read(); let _ = guard.read();
@@ -1,724 +0,0 @@
//! Windows virtual-display backend driving **pf-vdisplay** — punktfunk's OWN IddCx Indirect Display
//! Driver (the clean-room replacement for SudoVDA). The Windows analogue of the Linux per-compositor
//! backends: [`create`](VirtualDisplay::create) adds a virtual monitor at the client's exact `WxH@Hz`
//! (the mode is baked into the ADD IOCTL — no EDID seeding), starts the mandatory watchdog ping, and
//! the returned [`VirtualOutput`]'s keepalive `Drop` removes it (RAII).
//!
//! Control surface: a device-interface-GUID + `CreateFileW` + `DeviceIoControl` IOCTL protocol, with
//! the wire contract OWNED by [`pf_vdisplay_proto::control`] (versioned + `#[repr(C)] Pod` structs,
//! NOT the SudoVDA ABI). No DLL, no named pipe. See `docs/windows-host-rewrite.md`.
//!
//! This is a faithful clone of [`super::sudovda`] (the shipping fallback) repointed at the new driver:
//! same reference-counted/lingering monitor lifecycle, same CCD isolation + active-mode forcing — those
//! backend-NEUTRAL helpers are REUSED from `sudovda` (a pf-vdisplay monitor's `target_id` is a real OS
//! target id, so the CCD/DXGI code works unchanged). Only the driver-specific bits (GUID, IOCTL codes,
//! request/reply structs, the version handshake) differ, per `pf_vdisplay_proto`.
use std::ffi::c_void;
use std::mem::size_of;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, Once};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use windows::core::{GUID, PCWSTR};
use windows::Win32::Devices::DeviceAndDriverInstallation::{
SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInterfaces, SetupDiGetClassDevsW,
SetupDiGetDeviceInterfaceDetailW, DIGCF_DEVICEINTERFACE, DIGCF_PRESENT,
SP_DEVICE_INTERFACE_DATA, SP_DEVICE_INTERFACE_DETAIL_DATA_W,
};
use windows::Win32::Foundation::{CloseHandle, HANDLE, LUID};
use windows::Win32::Storage::FileSystem::{
CreateFileW, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
};
use windows::Win32::System::IO::DeviceIoControl;
use pf_vdisplay_proto::control;
use super::{Mode, VirtualDisplay, VirtualOutput};
// Backend-NEUTRAL CCD/DXGI helpers reused from the SudoVDA backend (a pf-vdisplay monitor's target_id
// is a real OS target id, so these operate identically). The shared MON_GEN/CURRENT_MON_GEN generation
// counter is reused too, so the IDD-push stale-ring bail works regardless of which backend is active.
use super::sudovda::{
isolate_displays_ccd, resolve_gdi_name, resolve_render_adapter_luid, restore_displays_ccd,
set_active_mode, SavedConfig, CURRENT_MON_GEN, MON_GEN,
};
// pf-vdisplay device-interface GUID (pf_vdisplay_proto::PF_VDISPLAY_INTERFACE_GUID_U128). Deliberately
// NOT SudoVDA's `{e5bcc234-…}` — we own this driver, so a private interface GUID signals it and avoids
// any accidental coexistence with a real SudoVDA install.
const PF_VDISPLAY_INTERFACE: GUID =
GUID::from_u128(pf_vdisplay_proto::PF_VDISPLAY_INTERFACE_GUID_U128);
/// IDD-push mode: a new client connection preempts + recreates the monitor (single-client reconnect),
/// because a REUSED IddCx monitor's swap-chain is dead. Off → monitors are shared across sessions.
fn idd_push_mode() -> bool {
std::env::var_os("PUNKTFUNK_IDD_PUSH").is_some()
}
/// Monotonic per-session id keying a pf-vdisplay monitor for `IOCTL_ADD`/`IOCTL_REMOVE`. Unlike
/// SudoVDA's 16-byte GUID + pid-mangling, the proto keys monitors by a plain `u64` — the host-level
/// refcount manager (MGR) owns collision safety (a stale session can never REMOVE a live one), so a
/// simple monotonic counter suffices. Unique per (process, session) within this host's lifetime.
static NEXT_SESSION_ID: AtomicU64 = AtomicU64::new(1);
fn next_session_id() -> u64 {
NEXT_SESSION_ID.fetch_add(1, Ordering::Relaxed)
}
/// One `DeviceIoControl` round trip (METHOD_BUFFERED). `input`/`output` may be empty. Identical to the
/// SudoVDA backend's wrapper; struct<->bytes conversion happens at the call sites via `bytemuck`.
unsafe fn ioctl(h: HANDLE, code: u32, input: &[u8], output: &mut [u8]) -> Result<u32> {
let mut returned = 0u32;
let inp = (!input.is_empty()).then_some(input.as_ptr() as *const c_void);
let outp = (!output.is_empty()).then_some(output.as_mut_ptr() as *mut c_void);
DeviceIoControl(
h,
code,
inp,
input.len() as u32,
outp,
output.len() as u32,
Some(&mut returned),
None,
)
.with_context(|| format!("DeviceIoControl(code={code:#x})"))?;
Ok(returned)
}
/// Pin the pf-vdisplay IddCx's RENDER GPU to `luid` (the analogue of Apollo's `SetRenderAdapter`). No
/// output buffer. Issued on the driver handle BEFORE `IOCTL_ADD` to steer which GPU the new target
/// renders on — on a multi-adapter box this stops DXGI from reparenting the virtual output onto a
/// different adapter than the one we duplicate/encode on (the ACCESS_LOST storm).
///
/// NOTE: the pf-vdisplay driver currently returns `STATUS_NOT_IMPLEMENTED` for this IOCTL (a STEP-4
/// stub), so this call WILL fail today. Callers tolerate the `Err` (warn + continue) — exactly as the
/// SudoVDA backend tolerated the driver IGNORING the pin.
unsafe fn set_render_adapter(h: HANDLE, luid: LUID) -> Result<()> {
let req = control::SetRenderAdapterRequest {
luid_low: luid.LowPart,
luid_high: luid.HighPart,
};
let mut none: [u8; 0] = [];
ioctl(
h,
control::IOCTL_SET_RENDER_ADAPTER,
bytemuck::bytes_of(&req),
&mut none,
)
.map(|_| ())
.context("pf-vdisplay SET_RENDER_ADAPTER")
}
unsafe fn open_device() -> Result<HANDLE> {
let hdev = SetupDiGetClassDevsW(
Some(&PF_VDISPLAY_INTERFACE),
PCWSTR::null(),
None,
DIGCF_DEVICEINTERFACE | DIGCF_PRESENT,
)
.context("SetupDiGetClassDevsW(pf-vdisplay) — is the pf-vdisplay driver installed?")?;
let mut idata = SP_DEVICE_INTERFACE_DATA {
cbSize: size_of::<SP_DEVICE_INTERFACE_DATA>() as u32,
..Default::default()
};
SetupDiEnumDeviceInterfaces(hdev, None, &PF_VDISPLAY_INTERFACE, 0, &mut idata)
.context("SetupDiEnumDeviceInterfaces(pf-vdisplay)")?;
let mut required = 0u32;
let _ = SetupDiGetDeviceInterfaceDetailW(hdev, &idata, None, 0, Some(&mut required), None);
let mut buf = vec![0u8; required as usize];
let detail = buf.as_mut_ptr() as *mut SP_DEVICE_INTERFACE_DETAIL_DATA_W;
(*detail).cbSize = size_of::<SP_DEVICE_INTERFACE_DETAIL_DATA_W>() as u32;
SetupDiGetDeviceInterfaceDetailW(hdev, &idata, Some(detail), required, None, None)
.context("SetupDiGetDeviceInterfaceDetailW(pf-vdisplay)")?;
let handle = CreateFileW(
PCWSTR((*detail).DevicePath.as_ptr()),
0xC000_0000, // GENERIC_READ | GENERIC_WRITE
FILE_SHARE_READ | FILE_SHARE_WRITE,
None,
OPEN_EXISTING,
FILE_FLAGS_AND_ATTRIBUTES(0),
None,
)
.context("CreateFileW(pf-vdisplay device)")?;
let _ = SetupDiDestroyDeviceInfoList(hdev);
Ok(handle)
}
// ── Host-level reference-counted pf-vdisplay monitor lifecycle ───────────────────────────────────
//
// The virtual monitor is created on the first session and REUSED across sessions. When the last
// session disconnects the monitor LINGERS for a grace window (PUNKTFUNK_MONITOR_LINGER_MS, default
// 10 s): a reconnect within the window reuses it instantly (no new screen, no PnP connect/disconnect
// chime, no teardown/recreate kernel churn); after the window a background timer REMOVEs it so a
// physical-screen user gets their screen back. Overlapping sessions share one monitor via the
// refcount (teardown only at refs==0 + expired grace), so a stale session can never REMOVE a live
// session's monitor. The control-device HANDLE is opened once and kept for the host lifetime — it's a
// handle, not a screen, so it creates no phantom display.
/// The resources backing one live pf-vdisplay monitor (owned by [`MGR`], not by any session).
struct Monitor {
/// Per-session key for `IOCTL_ADD`/`IOCTL_REMOVE` (the proto keys monitors by a plain `u64`).
session_id: u64,
target_id: u32,
luid: LUID,
gdi_name: Option<String>,
mode: Mode,
stop: Arc<AtomicBool>,
pinger: Option<JoinHandle<()>>,
ccd_saved: Option<SavedConfig>,
/// Generation stamp (shared [`MON_GEN`]); a [`MonitorLease`] only releases if its gen still matches.
gen: u64,
}
enum MgrState {
Idle,
Active { mon: Monitor, refs: u32 },
Lingering { mon: Monitor, until: Instant },
}
struct Mgr {
/// Control-device handle (raw isize; `HANDLE` isn't `Send`). Opened once, kept for the host life.
device: Option<isize>,
watchdog_s: u32,
state: MgrState,
}
static MGR: Mutex<Mgr> = Mutex::new(Mgr {
device: None,
watchdog_s: 10,
state: MgrState::Idle,
});
/// The Windows pf-vdisplay backend. A marker — the monitor lifecycle lives in the global [`MGR`].
pub struct PfVdisplayDisplay;
impl PfVdisplayDisplay {
pub fn new() -> Result<Self> {
// Open the control device once (validates the driver is present + version-matches) + log the
// watchdog timeout.
let mut g = MGR.lock().unwrap();
mgr_ensure_device(&mut g)?;
Ok(Self)
}
}
impl Drop for PfVdisplayDisplay {
fn drop(&mut self) {
// Nothing: the control device + monitor lifecycle are host-level (owned by MGR) and
// deliberately outlive any single session so a reconnect can reuse the monitor.
}
}
impl VirtualDisplay for PfVdisplayDisplay {
fn name(&self) -> &'static str {
"pf-vdisplay"
}
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
// Delegate to the host-level manager: create the monitor, reuse a lingering one on reconnect,
// or join the live one — and hand back a lease whose Drop releases the refcount.
mgr_acquire(mode)
}
}
/// Create a fresh pf-vdisplay monitor at `mode` on the (host-level) control `device`. ADD the target,
/// start the watchdog ping, resolve the GDI name, force the client mode + (default) isolate to a sole
/// composited display. Returns the [`Monitor`] resources; the manager tracks its lifecycle
/// (refcount + linger).
unsafe fn create_monitor(device: isize, mode: Mode, watchdog_s: u32) -> Result<Monitor> {
let dev = HANDLE(device as *mut c_void);
{
// Fresh session id per created monitor (the manager refcount, not the id, prevents the
// cross-session REMOVE collision).
let session_id = next_session_id();
let add = control::AddRequest {
session_id,
width: mode.width,
height: mode.height,
refresh_hz: mode.refresh_hz,
_reserved: 0,
};
// SET_RENDER_ADAPTER is OPT-IN. By default we do NOT pin the render adapter — let the IDD use
// its natural adapter (Apollo-parity; avoids the cross-GPU mismatch ACCESS_LOST storm). Opt in
// with PUNKTFUNK_RENDER_ADAPTER=<name substring> or the IDD-push path (which MUST run NVENC on
// the discrete render GPU it pins here). NOTE: the pf-vdisplay driver currently returns
// STATUS_NOT_IMPLEMENTED for this IOCTL (a STEP-4 stub), so the call below is tolerated to fail.
let pinned = if std::env::var("PUNKTFUNK_RENDER_ADAPTER").is_ok() {
unsafe { resolve_render_adapter_luid() }
} else if std::env::var_os("PUNKTFUNK_IDD_PUSH").is_some() {
// P2 direct frame push: the host opens the driver's shared textures AND runs NVENC on the
// RENDER adapter, so on a hybrid box (dGPU + iGPU) it MUST be the discrete encoder GPU — an
// iGPU-rendered surface is untouchable by NVENC. pf-vdisplay HONORS SET_RENDER_ADAPTER (once
// implemented), so pin the discrete GPU; the driver also reports the resulting render LUID in
// the shared header, so the host binds correctly even if this is overridden.
tracing::info!("IDD push: pinning the discrete render GPU (SET_RENDER_ADAPTER)");
unsafe { resolve_render_adapter_luid() }
} else {
tracing::info!(
"pf-vdisplay SET_RENDER_ADAPTER skipped (no render pin — avoids cross-GPU mismatch; \
set PUNKTFUNK_RENDER_ADAPTER=<name> to force a specific render GPU)"
);
None
};
if let Some(luid) = pinned {
match unsafe { set_render_adapter(dev, luid) } {
Ok(()) => tracing::info!(
luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
"pf-vdisplay SET_RENDER_ADAPTER: pinned IDD render GPU"
),
// The driver currently stubs this IOCTL (STATUS_NOT_IMPLEMENTED) — warn + continue, do
// NOT propagate. The natural-adapter path still works (Apollo-parity).
Err(e) => tracing::warn!(
"pf-vdisplay SET_RENDER_ADAPTER failed (driver stub / not implemented — \
continuing): {e:#}"
),
}
}
let mut out = [0u8; size_of::<control::AddReply>()];
unsafe { ioctl(dev, control::IOCTL_ADD, bytemuck::bytes_of(&add), &mut out) }
.with_context(|| {
format!(
"pf-vdisplay ADD {}x{}@{}",
mode.width, mode.height, mode.refresh_hz
)
})?;
// `pod_read_unaligned` (NOT `from_bytes`): `out` is a stack `[u8; N]` with no guaranteed
// 4-byte alignment, and `from_bytes` PANICS on an alignment mismatch. This copies the bytes
// into a properly-aligned `AddReply` value.
let reply: control::AddReply =
bytemuck::pod_read_unaligned(&out[..size_of::<control::AddReply>()]);
let luid = LUID {
LowPart: reply.adapter_luid_low,
HighPart: reply.adapter_luid_high,
};
tracing::info!(
"pf-vdisplay created {}x{}@{} (target_id={}, adapter_luid={:#x})",
mode.width,
mode.height,
mode.refresh_hz,
reply.target_id,
luid.LowPart
);
if let Some(pin) = pinned {
if luid.LowPart == pin.LowPart && luid.HighPart == pin.HighPart {
tracing::info!("pf-vdisplay ADD render adapter matches the pinned GPU (pin took)");
} else {
tracing::warn!(
add = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
pinned = format!("{:08x}:{:08x}", pin.HighPart, pin.LowPart),
"pf-vdisplay ADD render adapter DIFFERS from pinned — driver ignored SET_RENDER_ADAPTER?"
);
}
}
// Mandatory keepalive: ping inside the watchdog window or the driver tears all displays down.
let stop = Arc::new(AtomicBool::new(false));
let device_raw = device;
let interval = Duration::from_millis(watchdog_s as u64 * 1000 / 3);
let stop_t = stop.clone();
let pinger = thread::spawn(move || {
let h = HANDLE(device_raw as *mut c_void);
let mut warned = false;
while !stop_t.load(Ordering::Relaxed) {
let mut none: [u8; 0] = [];
match unsafe { ioctl(h, control::IOCTL_PING, &[], &mut none) } {
Ok(_) => warned = false,
// A persistently failing PING means the cached control handle went invalid — the
// driver watchdog will then tear the monitor down mid-session. Surface it once.
Err(e) => {
if !warned {
tracing::warn!(
"pf-vdisplay keepalive PING failed (control handle lost?): {e:#}"
);
warned = true;
}
}
}
thread::sleep(interval);
}
});
// Resolve the capture target. May be None on a GPU-less box (target added but not activated
// into a WDDM path); the Windows capture backend will re-resolve once a GPU is present.
let mut gdi_name = None;
for _ in 0..15 {
thread::sleep(Duration::from_millis(200));
if let Some(n) = unsafe { resolve_gdi_name(reply.target_id) } {
gdi_name = Some(n);
break;
}
}
let mut ccd_saved: Option<SavedConfig> = None;
match &gdi_name {
Some(n) => {
tracing::info!("pf-vdisplay target {} -> {n}", reply.target_id);
// ADD only advertises the mode; force it active so DXGI captures the requested size.
set_active_mode(n, mode);
// Make the pf-vdisplay the SOLE active display (default). An EXTENDED (non-primary) IDD
// is NOT DWM-composited → Desktop Duplication gets a born-lost ACCESS_LOST; deactivating
// the other display(s) FIRST (CCD, atomic) leaves the virtual output as the sole →
// primary → composited desktop, so all content (incl. Winlogon) renders to it without a
// MODE_CHANGE_IN_PROGRESS storm. Opt out with PUNKTFUNK_NO_ISOLATE=1 (a box with a real
// second monitor to keep live).
if std::env::var("PUNKTFUNK_NO_ISOLATE").is_err() {
ccd_saved = unsafe { isolate_displays_ccd(reply.target_id) };
} else {
tracing::info!(
"display isolation skipped (PUNKTFUNK_NO_ISOLATE) — IDD stays extended"
);
}
thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture opens
}
None => tracing::warn!(
"pf-vdisplay target {} not yet an active display path (needs a WDDM GPU to activate)",
reply.target_id
),
}
Ok(Monitor {
session_id,
target_id: reply.target_id,
luid,
gdi_name,
mode,
stop,
pinger: Some(pinger),
ccd_saved,
gen: MON_GEN.fetch_add(1, Ordering::Relaxed),
})
}
}
impl Monitor {
/// The capture target handed to a session (`None` until the GDI name resolves).
fn target(&self) -> Option<crate::capture::dxgi::WinCaptureTarget> {
self.gdi_name
.clone()
.map(|n| crate::capture::dxgi::WinCaptureTarget {
adapter_luid: crate::capture::dxgi::pack_luid(self.luid),
gdi_name: n,
// target_id is stable across secure-desktop topology rebuilds; the GDI name is NOT,
// so capture re-resolves the name from this on every recovery.
target_id: self.target_id,
})
}
/// Stop the watchdog ping, re-attach the displays we detached, then REMOVE the monitor (by session
/// id). `device` is the host-level control handle. Consumes the monitor.
unsafe fn teardown(mut self, device: isize) {
self.stop.store(true, Ordering::Relaxed);
if let Some(j) = self.pinger.take() {
let _ = j.join();
}
// Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero displays.
if let Some(saved) = &self.ccd_saved {
restore_displays_ccd(saved);
}
let req = control::RemoveRequest {
session_id: self.session_id,
};
let mut none: [u8; 0] = [];
let h = HANDLE(device as *mut c_void);
if let Err(e) = ioctl(
h,
control::IOCTL_REMOVE,
bytemuck::bytes_of(&req),
&mut none,
) {
tracing::warn!("pf-vdisplay REMOVE failed: {e:#}");
} else {
tracing::info!("pf-vdisplay monitor removed");
}
}
}
/// Open the control device once + version/watchdog handshake; cache the handle (raw isize) in `g`.
fn mgr_ensure_device(g: &mut Mgr) -> Result<isize> {
if let Some(d) = g.device {
return Ok(d);
}
let device = unsafe { open_device()? };
// Single version+watchdog handshake. The proto intends a HARD protocol-version check (unlike
// SudoVDA's best-effort log) — a mismatched host/driver pair fails loudly here rather than
// corrupting the IOCTL stream.
let mut info_buf = [0u8; size_of::<control::InfoReply>()];
unsafe { ioctl(device, control::IOCTL_GET_INFO, &[], &mut info_buf) }
.context("pf-vdisplay IOCTL_GET_INFO (version handshake)")?;
// `pod_read_unaligned` (see the AddReply note): copies out of the unaligned stack buffer.
let info: control::InfoReply =
bytemuck::pod_read_unaligned(&info_buf[..size_of::<control::InfoReply>()]);
if info.protocol_version != pf_vdisplay_proto::PROTOCOL_VERSION {
// Close the handle before bailing so a retry re-opens cleanly.
unsafe {
let _ = CloseHandle(device);
}
anyhow::bail!(
"pf-vdisplay protocol mismatch: host expects {}, driver reports {} — install matching \
host + driver",
pf_vdisplay_proto::PROTOCOL_VERSION,
info.protocol_version
);
}
g.watchdog_s = info.watchdog_timeout_s.max(1);
tracing::info!(
"pf-vdisplay protocol {} (watchdog timeout {}s)",
info.protocol_version,
g.watchdog_s
);
// Reap monitors orphaned by a crashed/killed previous host instance before we create ours. This is
// a FIRST-CLASS op on pf-vdisplay (the driver returns SUCCESS), NOT a "send-and-hope" hack: without
// it an orphan lingers until the driver watchdog fires — but a still-pinging new session keeps
// resetting that watchdog, so orphans could accumulate.
{
let mut none: [u8; 0] = [];
if unsafe { ioctl(device, control::IOCTL_CLEAR_ALL, &[], &mut none) }.is_ok() {
tracing::info!("cleared orphaned virtual monitors on host startup");
} else {
tracing::warn!("pf-vdisplay IOCTL_CLEAR_ALL failed on startup (continuing)");
}
}
let raw = device.0 as isize;
g.device = Some(raw);
Ok(raw)
}
/// Linger window before a session-less monitor is torn down. A reconnect within it reuses the
/// monitor (no new screen / PnP chime); after it the monitor is REMOVEd so a physical screen returns.
fn linger_ms() -> u64 {
std::env::var("PUNKTFUNK_MONITOR_LINGER_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(10_000)
}
/// Acquire the shared monitor for a new session: join the live one (refcount++), reuse a lingering
/// one (reconfiguring if the client mode changed), or create one. The returned [`MonitorLease`]
/// releases the refcount on drop.
fn mgr_acquire(mode: Mode) -> Result<VirtualOutput> {
ensure_linger_timer();
let mut g = MGR.lock().unwrap();
let device = mgr_ensure_device(&mut g)?;
let watchdog_s = g.watchdog_s;
// IDD-push: a new connection while a monitor is live = a single-client RECONNECT (the prior client
// is gone — IDD-push is one display, no concurrency). A REUSED IddCx monitor's swap-chain is DEAD,
// so joining it would hand the new client a black screen until the old session times out. PREEMPT:
// tear the old monitor down (its teardown restores topology + IOCTL_REMOVEs) and fall through to
// create a FRESH one. The old session's lease is gen-stamped, so its later drop is ignored
// (mgr_release no-op) and can't tear down the new monitor.
if idd_push_mode()
&& matches!(
g.state,
MgrState::Active { .. } | MgrState::Lingering { .. }
)
{
if let MgrState::Active { mon, .. } | MgrState::Lingering { mon, .. } =
std::mem::replace(&mut g.state, MgrState::Idle)
{
tracing::info!(
old_target = mon.target_id,
"IDD-push reconnect — preempting the prior session, recreating a fresh monitor"
);
// teardown() — NOT drop() — sends IOCTL_REMOVE (and restores topology). `Monitor` has NO
// `Drop` impl, so a bare `drop(mon)` would orphan the IddCx monitor in the driver (never
// departed → leaks a live D3D device + a stuck swap-chain processor thread per reconnect).
unsafe { mon.teardown(device) };
// Let the OS finish the ASYNC IddCx monitor departure before the next ADD. A back-to-back
// REMOVE→ADD races the teardown and the ADD IOCTL is rejected under reconnect churn.
thread::sleep(Duration::from_millis(400));
}
}
// A live monitor already exists — join it (refcount++). This covers a concurrent session AND the
// build-then-drop overlap of a mid-stream Reconfigure / secure-return (the new lease is taken while
// the old is still held). If the requested mode differs, reconfigure the shared monitor to it so a
// Reconfigure actually applies (one shared monitor → sessions necessarily share a mode).
if let MgrState::Active { mon, refs } = &mut g.state {
*refs += 1;
let changed = mon.mode.width != mode.width
|| mon.mode.height != mode.height
|| mon.mode.refresh_hz != mode.refresh_hz;
if changed {
unsafe { mgr_reconfigure(mon, mode) };
}
tracing::info!(
refs = *refs,
"pf-vdisplay monitor reused (concurrent / reconfigure session)"
);
let pm = Some((mon.mode.width, mon.mode.height, mon.mode.refresh_hz));
let target = mon.target();
let gen = mon.gen;
CURRENT_MON_GEN.store(gen, Ordering::Relaxed);
return Ok(VirtualOutput {
node_id: 0,
preferred_mode: pm,
win_capture: target,
keepalive: Box::new(MonitorLease { gen }),
});
}
// Idle or Lingering: repurpose/create a monitor → Active{refs:1}.
let mon = match std::mem::replace(&mut g.state, MgrState::Idle) {
MgrState::Lingering { mut mon, .. } => {
tracing::info!("pf-vdisplay monitor reused (reconnect within the linger window)");
let changed = mon.mode.width != mode.width
|| mon.mode.height != mode.height
|| mon.mode.refresh_hz != mode.refresh_hz;
if changed {
unsafe { mgr_reconfigure(&mut mon, mode) };
}
mon
}
MgrState::Idle => unsafe { create_monitor(device, mode, watchdog_s)? },
MgrState::Active { .. } => unreachable!("handled above"),
};
let pm = Some((mon.mode.width, mon.mode.height, mon.mode.refresh_hz));
let target = mon.target();
let gen = mon.gen;
CURRENT_MON_GEN.store(gen, Ordering::Relaxed);
g.state = MgrState::Active { mon, refs: 1 };
Ok(VirtualOutput {
node_id: 0,
preferred_mode: pm,
win_capture: target,
keepalive: Box::new(MonitorLease { gen }),
})
}
/// Re-apply a (possibly new) mode to a reused monitor on reconnect, re-resolving its GDI name.
unsafe fn mgr_reconfigure(mon: &mut Monitor, mode: Mode) {
tracing::info!(
old = format!(
"{}x{}@{}",
mon.mode.width, mon.mode.height, mon.mode.refresh_hz
),
new = format!("{}x{}@{}", mode.width, mode.height, mode.refresh_hz),
"pf-vdisplay: reconfiguring reused monitor to the new client mode"
);
if let Some(n) = resolve_gdi_name(mon.target_id) {
mon.gdi_name = Some(n);
}
if let Some(n) = &mon.gdi_name {
set_active_mode(n, mode);
}
mon.mode = mode;
}
/// Release a session's hold: refcount-- ; when the last session leaves, LINGER before teardown.
/// `gen` is the lease's monitor generation: a STALE lease (its monitor was already torn down +
/// recreated under it — the IDD-push reconnect-preempt path) does nothing, so it can't decrement the
/// CURRENT (fresh) monitor's refcount and tear it down.
fn mgr_release(gen: u64) {
let mut g = MGR.lock().unwrap();
let stale = match &g.state {
MgrState::Active { mon, .. } | MgrState::Lingering { mon, .. } => mon.gen != gen,
MgrState::Idle => true,
};
if stale {
return;
}
g.state = match std::mem::replace(&mut g.state, MgrState::Idle) {
MgrState::Active { mon, refs } if refs > 1 => MgrState::Active {
mon,
refs: refs - 1,
},
MgrState::Active { mon, .. } => {
let ms = linger_ms();
tracing::info!(
linger_ms = ms,
"pf-vdisplay: last session left — lingering before teardown"
);
MgrState::Lingering {
mon,
until: Instant::now() + Duration::from_millis(ms),
}
}
other => other,
};
}
// NOTE: `wait_for_monitor_released` is NOT redefined here. Its only caller (`punktfunk1.rs`, the
// IDD-push reconnect preempt) reaches it as `crate::vdisplay::sudovda::wait_for_monitor_released`, and
// pf_vdisplay.rs never calls it internally (the preempt is done inline in `mgr_acquire` above), so a
// second copy here would be dead code waiting on the (separate) pf-vdisplay MGR. The two backends keep
// independent MGRs but only one is ever active — see the cross-MGR caveat in the implementation report.
/// Background timer (started once): tear down a monitor that has lingered past its deadline (→ Idle),
/// so a physical-screen user gets their screen back after they stop streaming.
fn ensure_linger_timer() {
static TIMER: Once = Once::new();
TIMER.call_once(|| {
let _ = thread::Builder::new()
.name("pf-vdisplay-linger".into())
.spawn(|| loop {
thread::sleep(Duration::from_millis(500));
let mut g = MGR.lock().unwrap();
let due = matches!(&g.state, MgrState::Lingering { until, .. } if Instant::now() >= *until);
if due {
let device = g.device.unwrap_or(0);
if let MgrState::Lingering { mon, .. } =
std::mem::replace(&mut g.state, MgrState::Idle)
{
drop(g); // release the lock before the REMOVE IOCTL + display restore
unsafe { mon.teardown(device) };
}
}
});
});
}
/// A session's lease on the shared monitor. Drop releases the refcount (→ linger when it hits 0),
/// UNLESS the monitor was already torn down + recreated under it (gen mismatch — the IDD-push
/// reconnect-preempt path), in which case the drop is a no-op so it can't tear down the new monitor.
struct MonitorLease {
gen: u64,
}
impl Drop for MonitorLease {
fn drop(&mut self) {
mgr_release(self.gen);
}
}
/// Readiness probe: can we open the pf-vdisplay control device?
pub fn probe() -> Result<()> {
let h = unsafe { open_device()? };
unsafe {
let _ = CloseHandle(h);
}
Ok(())
}
/// Is the pf-vdisplay driver present (device interface enumerable)?
pub fn is_available() -> bool {
unsafe { open_device().map(|h| CloseHandle(h)).is_ok() }
}
#[cfg(test)]
mod tests {
use super::*;
/// Live hardware round trip — skipped unless `PUNKTFUNK_PF_VDISPLAY_LIVE=1` (needs the pf-vdisplay
/// driver installed). Exercises the real trait path: open -> create -> hold -> drop (REMOVE).
#[test]
fn live_create_drop() {
if std::env::var("PUNKTFUNK_PF_VDISPLAY_LIVE").is_err() {
return;
}
let mut vd = PfVdisplayDisplay::new().expect("open pf-vdisplay");
let vout = vd
.create(Mode {
width: 1920,
height: 1080,
refresh_hz: 60,
})
.expect("create virtual display");
assert_eq!(vout.preferred_mode, Some((1920, 1080, 60)));
thread::sleep(Duration::from_secs(3));
drop(vout); // triggers REMOVE + stops the pinger
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,594 @@
//! Host-lifetime virtual-display **ownership model** (Goal-1 §2.5). One reference-counted monitor
//! lifecycle, shared by both Windows backends (SudoVDA + pf-vdisplay) instead of the two verbatim-
//! duplicated `MGR: Mutex<Mgr>` globals each backend used to carry.
//!
//! [`VirtualDisplayManager`] owns the earned Idle/Active/Lingering refcount machine + the linger timer +
//! a **typed** [`OwnedHandle`] control device (no more raw `isize` smuggled across the pinger/linger
//! threads). The backend differences — the IOCTL protocol and the per-monitor REMOVE key — are the only
//! thing behind the [`VdisplayDriver`] seam; the state machine, the render-adapter pin decision, the
//! GDI/CCD glue (`crate::win_display`), and the generation-stamped [`MonitorLease`] are backend-neutral.
//!
//! It's a process-wide singleton ([`vdm`]) initialised once with the chosen backend's driver — the
//! host runs exactly one virtual-display backend per process. The session holds a [`MonitorLease`];
//! its `Drop` releases the refcount (a *stale* lease — its monitor was preempted + recreated under it —
//! is a no-op, so it can never tear down the live monitor).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use std::os::windows::io::{AsRawHandle, OwnedHandle};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, Once, OnceLock};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use anyhow::Result;
use windows::Win32::Foundation::{HANDLE, LUID};
use super::{Mode, VirtualOutput};
use crate::win_display::{
isolate_displays_ccd, resolve_gdi_name, restore_displays_ccd, set_active_mode, SavedConfig,
};
/// The per-backend REMOVE key the driver stamps on ADD and consumes on REMOVE. SudoVDA keys monitors by
/// a fresh `GUID`; pf-vdisplay keys them by a monotonic `u64` session id.
#[derive(Clone, Copy)]
pub(crate) enum MonitorKey {
Guid(windows::core::GUID),
Session(u64),
}
/// What a backend's `add_monitor` returns: the REMOVE key + the OS target id + the render LUID.
pub(crate) struct AddedMonitor {
pub key: MonitorKey,
pub target_id: u32,
pub luid: LUID,
}
/// The backend-specific IOCTL surface — the *only* thing that differs between SudoVDA and pf-vdisplay.
/// Everything else (the refcount machine, the linger, the pinger, the CCD/GDI glue) is shared in
/// [`VirtualDisplayManager`]. `Send + Sync` because the manager (and so the boxed driver) is a
/// `&'static` singleton reached from the pinger + linger threads.
pub(crate) trait VdisplayDriver: Send + Sync {
fn name(&self) -> &'static str;
/// Find + open the control device, validate it (version handshake), read the watchdog timeout, and
/// reap monitors orphaned by a crashed previous host (`CLEAR_ALL`). Returns the owned handle +
/// watchdog seconds.
///
/// # Safety
/// Issues setup-API + `DeviceIoControl` calls; runs in the caller's apartment.
unsafe fn open(&self) -> Result<(OwnedHandle, u32)>;
/// ADD a virtual monitor at `mode`, pinning the IDD render GPU to `render_luid` first if `Some`.
/// Returns the REMOVE key + target id + the adapter LUID the driver actually used.
///
/// # Safety
/// `dev` must be the live control handle from [`open`](Self::open).
unsafe fn add_monitor(
&self,
dev: HANDLE,
mode: Mode,
render_luid: Option<LUID>,
) -> Result<AddedMonitor>;
/// REMOVE the monitor identified by `key`.
///
/// # Safety
/// `dev` must be the live control handle.
unsafe fn remove_monitor(&self, dev: HANDLE, key: &MonitorKey) -> Result<()>;
/// Watchdog keepalive PING (issued every `watchdog/3` from the pinger thread).
///
/// # Safety
/// `dev` must be the live control handle.
unsafe fn ping(&self, dev: HANDLE) -> Result<()>;
}
/// The resources backing one live virtual monitor (owned by the [`VirtualDisplayManager`] state, not by
/// any session). No `Drop` impl — [`teardown`](VirtualDisplayManager::teardown) must be called so the
/// REMOVE IOCTL fires (a bare drop would orphan the driver-side monitor).
struct Monitor {
key: MonitorKey,
target_id: u32,
luid: LUID,
gdi_name: Option<String>,
mode: Mode,
stop: Arc<AtomicBool>,
pinger: Option<JoinHandle<()>>,
ccd_saved: Option<SavedConfig>,
/// Generation stamp; a [`MonitorLease`] only releases if its gen still matches (stale-lease no-op).
gen: u64,
}
impl Monitor {
/// The capture target handed to a session (`None` until the GDI name resolves on a WDDM GPU).
fn target(&self) -> Option<crate::capture::dxgi::WinCaptureTarget> {
self.gdi_name
.clone()
.map(|n| crate::capture::dxgi::WinCaptureTarget {
adapter_luid: crate::capture::dxgi::pack_luid(self.luid),
gdi_name: n,
target_id: self.target_id,
})
}
}
enum MgrState {
Idle,
Active { mon: Monitor, refs: u32 },
Lingering { mon: Monitor, until: Instant },
}
/// The host-lifetime virtual-display manager: the single owner of the monitor lifecycle.
pub(crate) struct VirtualDisplayManager {
driver: Box<dyn VdisplayDriver>,
/// Control device, opened once on first acquire. Typed + `Send+Sync`, so the pinger/linger threads
/// share it via the `&'static` singleton with no raw-handle smuggling.
device: OnceLock<Arc<OwnedHandle>>,
watchdog_s: AtomicU32,
/// Monotonic lease-generation counter (was the `MON_GEN` global).
gen: AtomicU64,
state: Mutex<MgrState>,
/// Serializes IDD-push session SETUP (preempt + monitor create) so a reconnect flood can't run
/// concurrent monitor create/teardown — held by the session across the pipeline build (was the
/// `IDD_SETUP_LOCK` global in `punktfunk1`).
setup_lock: Mutex<()>,
/// The current IDD-push session's stop flag; a new connection signals the prior one to release its
/// monitor before the fresh one is created (was the `IDD_SESSION_STOP` global in `punktfunk1`).
idd_session_stop: Mutex<Option<Arc<AtomicBool>>>,
}
static VDM: OnceLock<VirtualDisplayManager> = OnceLock::new();
/// Initialise the process-wide manager with `driver` (the chosen backend) and return it. Idempotent: the
/// first backend to call wins (the host runs one backend per process), so a later call ignores its driver.
pub(crate) fn init(driver: Box<dyn VdisplayDriver>) -> &'static VirtualDisplayManager {
VDM.get_or_init(|| VirtualDisplayManager {
driver,
device: OnceLock::new(),
watchdog_s: AtomicU32::new(3),
gen: AtomicU64::new(1),
state: Mutex::new(MgrState::Idle),
setup_lock: Mutex::new(()),
idd_session_stop: Mutex::new(None),
})
}
/// The process-wide manager. Panics if reached before a backend called [`init`] — by construction a
/// session is only ever created after `vdisplay::open` constructed the backend (which calls `init`).
pub(crate) fn vdm() -> &'static VirtualDisplayManager {
VDM.get()
.expect("VirtualDisplayManager used before a backend initialised it")
}
impl VirtualDisplayManager {
pub(crate) fn backend_name(&self) -> &'static str {
self.driver.name()
}
/// Open + cache the control device (once). Called under the `state` lock so two racing acquires can't
/// double-open.
fn ensure_device(&self) -> Result<HANDLE> {
if let Some(d) = self.device.get() {
return Ok(HANDLE(d.as_raw_handle()));
}
// SAFETY: `VdisplayDriver::open` is `unsafe` only because it issues SetupAPI + `DeviceIoControl`
// FFI in the caller's apartment; `ensure_device` runs that on the acquiring thread under the
// `state` lock (callers hold it), so there is no concurrent open. `open` has no handle
// precondition to uphold, and the `OwnedHandle` it returns is the sole owner of the device.
let (handle, watchdog_s) = unsafe { self.driver.open()? };
self.watchdog_s.store(watchdog_s, Ordering::Relaxed);
let raw = HANDLE(handle.as_raw_handle());
let _ = self.device.set(Arc::new(handle));
Ok(raw)
}
/// The live control handle for the pinger/linger threads (lock-free: the device never changes once
/// opened). `None` only before the first acquire opened it.
fn device_handle(&self) -> Option<HANDLE> {
self.device.get().map(|d| HANDLE(d.as_raw_handle()))
}
/// Open + initialise the backend (validates the driver is present). Mirrors the old
/// `PfVdisplayDisplay::new`.
pub(crate) fn open_backend(&self) -> Result<()> {
// Hold the state lock across the open so two racing backends can't double-open the device.
let _guard = self.state.lock().unwrap();
self.ensure_device().map(|_| ())
}
/// Acquire the shared monitor for a new session: preempt-recreate under IDD-push, join a live one
/// (refcount++), reuse a lingering one, or create one. The returned [`MonitorLease`] releases the
/// refcount on drop.
pub(crate) fn acquire(&'static self, mode: Mode) -> Result<VirtualOutput> {
self.ensure_linger_timer();
let mut state = self.state.lock().unwrap();
let dev = self.ensure_device()?;
// IDD-push: a new connection while a monitor is live is a single-client RECONNECT (the prior
// client is gone). A REUSED IddCx swap-chain is DEAD, so joining it hands a black screen —
// PREEMPT: tear the old monitor down (its key/topology are restored) and create a fresh one. The
// old session's lease is gen-stamped, so its later drop is a no-op and can't tear down the new one.
if idd_push_mode() && matches!(*state, MgrState::Active { .. } | MgrState::Lingering { .. })
{
if let MgrState::Active { mon, .. } | MgrState::Lingering { mon, .. } =
std::mem::replace(&mut *state, MgrState::Idle)
{
tracing::info!(
old_target = mon.target_id,
"IDD-push reconnect — preempting the prior session, recreating a fresh monitor"
);
// SAFETY: `teardown` requires `dev` to be the live control handle; `dev` is the value
// `ensure_device()` returned above (the device is cached in the `OnceLock` and never
// closed for the manager's lifetime). `mon` was moved out of the prior `Active`/
// `Lingering` state by `mem::replace`, so it is exclusively owned here — no aliasing.
unsafe { self.teardown(dev, mon) };
// Let the OS finish the ASYNC monitor departure before the next ADD; a back-to-back
// REMOVE→ADD races the teardown and the ADD IOCTL is rejected under reconnect churn.
thread::sleep(Duration::from_millis(400));
}
}
// A live monitor already exists — join it (refcount++). Covers concurrent sessions AND the
// build-then-drop overlap of a mid-stream Reconfigure (the new lease is taken while the old is
// still held). Reconfigure the shared monitor if the requested mode differs.
if let MgrState::Active { mon, refs } = &mut *state {
*refs += 1;
if mon.mode != mode {
// SAFETY: `reconfigure` only manipulates the live display topology via the CCD/GDI
// helpers and needs an exclusive `&mut Monitor`. `mon` is the `&mut` into the current
// `Active` state, held under the `state` lock, so nothing else reconfigures it concurrently.
unsafe { self.reconfigure(mon, mode) };
}
tracing::info!(
refs = *refs,
backend = self.driver.name(),
"virtual monitor reused (concurrent / reconfigure session)"
);
return Ok(self.output_for(mon));
}
// Idle or Lingering: repurpose a lingering monitor / create a fresh one → Active{refs:1}.
let mon = match std::mem::replace(&mut *state, MgrState::Idle) {
MgrState::Lingering { mut mon, .. } => {
tracing::info!(
backend = self.driver.name(),
"virtual monitor reused (reconnect within the linger window)"
);
if mon.mode != mode {
// SAFETY: `reconfigure` needs an exclusive `&mut Monitor` and only touches the live
// display topology. `mon` is the local monitor just moved out of the `Lingering`
// state (sole owner), and we hold the `state` lock — no concurrent reconfigure.
unsafe { self.reconfigure(&mut mon, mode) };
}
mon
}
// SAFETY: `create_monitor` requires `dev` to be the live control handle; `dev` is the
// handle `ensure_device()` returned above (cached in the `OnceLock`, never closed for the
// manager's lifetime), and we hold the `state` lock.
MgrState::Idle => unsafe { self.create_monitor(dev, mode)? },
MgrState::Active { .. } => unreachable!("handled above"),
};
let out = self.output_for(&mon);
*state = MgrState::Active { mon, refs: 1 };
Ok(out)
}
/// Build the [`VirtualOutput`] (preferred mode + capture target + a fresh gen-stamped lease) for `mon`.
fn output_for(&'static self, mon: &Monitor) -> VirtualOutput {
VirtualOutput {
node_id: 0,
preferred_mode: Some((mon.mode.width, mon.mode.height, mon.mode.refresh_hz)),
win_capture: mon.target(),
keepalive: Box::new(MonitorLease {
mgr: self,
gen: mon.gen,
}),
}
}
/// Create a fresh monitor at `mode`: ADD via the driver (pinning the discrete render GPU under the
/// usual conditions), start the watchdog pinger, resolve the GDI name, force the mode + isolate to a
/// sole composited display.
///
/// # Safety
/// `dev` must be the live control handle.
unsafe fn create_monitor(&'static self, dev: HANDLE, mode: Mode) -> Result<Monitor> {
// SAFETY: `create_monitor`'s own `# Safety` contract guarantees `dev` is the live control
// handle; we forward it unchanged to `add_monitor`, whose precondition is exactly that.
// `resolve_render_pin()` returns an `Option<LUID>` by value (plain `Copy`), so no borrowed
// memory crosses the call.
let added = unsafe { self.driver.add_monitor(dev, mode, resolve_render_pin())? };
// Mandatory keepalive: ping inside the watchdog window or the driver tears all displays down.
// The pinger reaches the singleton for both the device + the driver — no raw-handle smuggle.
let stop = Arc::new(AtomicBool::new(false));
let interval =
Duration::from_millis(self.watchdog_s.load(Ordering::Relaxed) as u64 * 1000 / 3);
let stop_t = stop.clone();
let pinger = thread::spawn(move || {
let mut warned = false;
while !stop_t.load(Ordering::Relaxed) {
if let Some(h) = vdm().device_handle() {
// SAFETY: `ping` requires `dev` to be the live control handle. `h` is from
// `device_handle()` (the `Some` branch) — the `OnceLock<Arc<OwnedHandle>>` that,
// once set, is never cleared or closed for the process lifetime, so the handle is
// live for this call. The pinger thread only spins while the `&'static` manager
// singleton (and thus the device) lives.
match unsafe { vdm().driver.ping(h) } {
Ok(()) => warned = false,
Err(e) => {
if !warned {
tracing::warn!("virtual-display keepalive PING failed (control handle lost?): {e:#}");
warned = true;
}
}
}
}
thread::sleep(interval);
}
});
// Resolve the capture target. May be None on a GPU-less box (target added but not WDDM-activated);
// the capture backend re-resolves once a GPU is present.
let mut gdi_name = None;
for _ in 0..15 {
thread::sleep(Duration::from_millis(200));
// SAFETY: `resolve_gdi_name` is `unsafe` for its CCD (QueryDisplayConfig) FFI; it takes a
// plain `Copy` `u32` target id by value and returns an owned `String`, so no caller memory
// is borrowed across the call.
if let Some(n) = unsafe { resolve_gdi_name(added.target_id) } {
gdi_name = Some(n);
break;
}
}
let mut ccd_saved: Option<SavedConfig> = None;
match &gdi_name {
Some(n) => {
tracing::info!(backend = self.driver.name(), "target {} -> {n}", added.target_id);
// ADD only advertises the mode; force it active so DXGI captures the requested size.
set_active_mode(n, mode);
// Make the virtual display the SOLE active output (default): an EXTENDED (non-primary) IDD
// isn't DWM-composited on this box → Desktop Duplication born-losts. Deactivating the other
// display(s) first via the atomic CCD path promotes the IDD to a composited primary with no
// MODE_CHANGE storm. Opt out with PUNKTFUNK_NO_ISOLATE=1.
if std::env::var("PUNKTFUNK_NO_ISOLATE").is_err() {
// SAFETY: `isolate_displays_ccd` is `unsafe` for its CCD topology FFI; it takes a
// `Copy` `u32` by value and returns an owned `SavedConfig` snapshot (no borrowed
// memory crosses). It runs under the `state` lock, the sole mutator of the topology.
ccd_saved = unsafe { isolate_displays_ccd(added.target_id) };
} else {
tracing::info!("display isolation skipped (PUNKTFUNK_NO_ISOLATE) — IDD stays extended");
}
thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture opens
}
None => tracing::warn!(
"virtual-display target {} not yet an active display path (needs a WDDM GPU to activate)",
added.target_id
),
}
Ok(Monitor {
key: added.key,
target_id: added.target_id,
luid: added.luid,
gdi_name,
mode,
stop,
pinger: Some(pinger),
ccd_saved,
gen: self.gen.fetch_add(1, Ordering::Relaxed),
})
}
/// Re-apply a (possibly new) mode to a reused monitor on reconnect, re-resolving its GDI name.
///
/// # Safety
/// Touches the live display topology via the CCD/GDI helpers.
unsafe fn reconfigure(&self, mon: &mut Monitor, mode: Mode) {
tracing::info!(
old = format!(
"{}x{}@{}",
mon.mode.width, mon.mode.height, mon.mode.refresh_hz
),
new = format!("{}x{}@{}", mode.width, mode.height, mode.refresh_hz),
"virtual-display: reconfiguring reused monitor to the new client mode"
);
// SAFETY: `resolve_gdi_name` is `unsafe` for its CCD FFI; it takes the `Copy` `u32`
// `mon.target_id` by value and returns an owned `String`, so nothing borrowed crosses the call.
if let Some(n) = unsafe { resolve_gdi_name(mon.target_id) } {
mon.gdi_name = Some(n);
}
if let Some(n) = &mon.gdi_name {
set_active_mode(n, mode);
}
mon.mode = mode;
}
/// Stop the watchdog ping, re-attach the displays we detached, then REMOVE the monitor. Consumes it.
///
/// # Safety
/// `dev` must be the live control handle.
unsafe fn teardown(&self, dev: HANDLE, mut mon: Monitor) {
mon.stop.store(true, Ordering::Relaxed);
if let Some(j) = mon.pinger.take() {
let _ = j.join();
}
// Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero displays.
if let Some(saved) = &mon.ccd_saved {
restore_displays_ccd(saved);
}
// SAFETY: `teardown`'s own `# Safety` contract guarantees `dev` is the live control handle, and
// `remove_monitor` requires exactly that. `&mon.key` borrows the `MonitorKey` inside the
// still-owned `mon`, alive for this synchronous IOCTL, so the pointer the driver reads stays valid.
if let Err(e) = unsafe { self.driver.remove_monitor(dev, &mon.key) } {
tracing::warn!("virtual-display REMOVE failed: {e:#}");
} else {
tracing::info!(
backend = self.driver.name(),
"virtual-display monitor removed"
);
}
}
/// Release a session's hold (the [`MonitorLease`] `Drop`): refcount-- ; the last session leaving
/// LINGERs before teardown. A STALE lease (its monitor was preempted + recreated under it) is a
/// no-op, so it can't tear down the CURRENT monitor.
fn release(&self, gen: u64) {
let mut state = self.state.lock().unwrap();
let stale = match &*state {
MgrState::Active { mon, .. } | MgrState::Lingering { mon, .. } => mon.gen != gen,
MgrState::Idle => true,
};
if stale {
return;
}
*state = match std::mem::replace(&mut *state, MgrState::Idle) {
MgrState::Active { mon, refs } if refs > 1 => MgrState::Active {
mon,
refs: refs - 1,
},
MgrState::Active { mon, .. } => {
let ms = linger_ms();
tracing::info!(
linger_ms = ms,
"virtual-display: last session left — lingering before teardown"
);
MgrState::Lingering {
mon,
until: Instant::now() + Duration::from_millis(ms),
}
}
other => other,
};
}
/// Begin an IDD-push session setup (Goal-1 §2.5 — was the `IDD_SETUP_LOCK` / `IDD_SESSION_STOP` /
/// `wait_for_monitor_released` dance smeared across `punktfunk1`). Serializes via the setup lock,
/// registers THIS session's stop flag while signalling the PRIOR IDD-push session to stop, and waits
/// for it to release its monitor — so a reconnect (whose reused IddCx swap-chain is dead) preempts the
/// stale session cleanly before a fresh monitor is created. Returns the setup guard; the caller holds
/// it across the pipeline build, then drops it so the next reconnect can begin (and preempt this one).
pub(crate) fn begin_idd_setup(
&'static self,
stop: Arc<AtomicBool>,
) -> std::sync::MutexGuard<'static, ()> {
let guard = self.setup_lock.lock().unwrap();
let prev = self.idd_session_stop.lock().unwrap().replace(stop);
if let Some(prev_stop) = prev {
prev_stop.store(true, Ordering::SeqCst);
self.wait_for_monitor_released(Duration::from_secs(3));
}
guard
}
/// Wait (up to `timeout`) for the active monitor to be RELEASED (the MGR is no longer `Active`).
/// Used by the IDD-push reconnect preempt: after signalling the old session to stop, wait here so it
/// tears its monitor down cleanly before we acquire a fresh one.
pub(crate) fn wait_for_monitor_released(&self, timeout: Duration) {
let deadline = Instant::now() + timeout;
loop {
if !matches!(*self.state.lock().unwrap(), MgrState::Active { .. }) {
return;
}
if Instant::now() >= deadline {
tracing::warn!(
"IDD-push preempt: prior session didn't release the monitor within {timeout:?} — proceeding"
);
return;
}
thread::sleep(Duration::from_millis(25));
}
}
/// Background timer (started once): tear down a monitor that has lingered past its deadline (→ Idle),
/// so a physical-screen user gets their screen back after they stop streaming.
fn ensure_linger_timer(&'static self) {
static TIMER: Once = Once::new();
TIMER.call_once(|| {
thread::Builder::new()
.name("vdisplay-linger".into())
.spawn(move || loop {
thread::sleep(Duration::from_millis(500));
let due = {
let g = self.state.lock().unwrap();
matches!(&*g, MgrState::Lingering { until, .. } if Instant::now() >= *until)
};
if !due {
continue;
}
let Some(dev) = self.device_handle() else {
continue;
};
let taken = {
let mut g = self.state.lock().unwrap();
if matches!(&*g, MgrState::Lingering { until, .. } if Instant::now() >= *until) {
if let MgrState::Lingering { mon, .. } =
std::mem::replace(&mut *g, MgrState::Idle)
{
Some(mon)
} else {
None
}
} else {
None
}
};
if let Some(mon) = taken {
// SAFETY: `teardown` requires `dev` to be the live control handle; `dev` is from
// `self.device_handle()` (the `Some` checked just above), i.e. the cached
// `OwnedHandle` live for the process lifetime. `mon` was moved out of the
// `Lingering` state under the `state` lock, so it is exclusively owned here.
unsafe { self.teardown(dev, mon) };
}
})
.ok();
});
}
}
/// The session's refcount handle. `Drop` releases the manager's refcount; a stale lease (its monitor was
/// preempted + recreated under it) is a no-op.
struct MonitorLease {
mgr: &'static VirtualDisplayManager,
gen: u64,
}
impl Drop for MonitorLease {
fn drop(&mut self) {
self.mgr.release(self.gen);
}
}
/// IDD-push mode: a new client connection preempts + recreates the monitor (single-client reconnect),
/// because a REUSED IddCx monitor's swap-chain is dead. Off → monitors are shared across sessions.
fn idd_push_mode() -> bool {
crate::config::config().idd_push
}
/// The render-GPU pin decision (backend-neutral): pin the discrete render GPU when explicitly requested,
/// or under IDD-push (the host runs NVENC on the render adapter, so it MUST be the discrete encoder GPU
/// on a hybrid box). `None` = let the IDD use its natural adapter (Apollo parity — avoids the cross-GPU
/// ACCESS_LOST storm SudoVDA hit when pinned).
fn resolve_render_pin() -> Option<LUID> {
if crate::config::config().render_adapter.is_some() {
// SAFETY: `resolve_render_adapter_luid` is `unsafe` only for its DXGI factory FFI; it takes no
// arguments and returns an `Option<LUID>` by value, so there is no input/borrow to keep valid.
unsafe { crate::win_adapter::resolve_render_adapter_luid() }
} else if crate::config::config().idd_push {
tracing::info!("IDD push: pinning the discrete render GPU (SET_RENDER_ADAPTER)");
// SAFETY: as above — `resolve_render_adapter_luid` takes no arguments and returns an
// `Option<LUID>` by value; the `unsafe` covers only its DXGI factory enumeration FFI.
unsafe { crate::win_adapter::resolve_render_adapter_luid() }
} else {
tracing::info!(
"SET_RENDER_ADAPTER skipped (Apollo-parity: no render pin; set PUNKTFUNK_RENDER_ADAPTER=<name> to force one)"
);
None
}
}
/// Linger window before a session-less monitor is torn down (default 10 s; `PUNKTFUNK_MONITOR_LINGER_MS`).
fn linger_ms() -> u64 {
std::env::var("PUNKTFUNK_MONITOR_LINGER_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(10_000)
}
@@ -0,0 +1,379 @@
//! Windows virtual-display backend driving **pf-vdisplay** — punktfunk's OWN IddCx Indirect Display
//! Driver (the clean-room replacement for SudoVDA). The Windows analogue of the Linux per-compositor
//! backends: [`create`](VirtualDisplay::create) adds a virtual monitor at the client's exact `WxH@Hz`
//! (the mode is baked into the ADD IOCTL — no EDID seeding), starts the mandatory watchdog ping, and
//! the returned [`VirtualOutput`]'s keepalive `Drop` removes it (RAII).
//!
//! Control surface: a device-interface-GUID + `CreateFileW` + `DeviceIoControl` IOCTL protocol, with
//! the wire contract OWNED by [`pf_driver_proto::control`] (versioned + `#[repr(C)] Pod` structs,
//! NOT the SudoVDA ABI). No DLL, no named pipe. See `docs/windows-host-rewrite.md`.
//!
//! This is a faithful clone of [`super::sudovda`] (the shipping fallback) repointed at the new driver:
//! same reference-counted/lingering monitor lifecycle, same CCD isolation + active-mode forcing — those
//! backend-NEUTRAL helpers are REUSED from `sudovda` (a pf-vdisplay monitor's `target_id` is a real OS
//! target id, so the CCD/DXGI code works unchanged). Only the driver-specific bits (GUID, IOCTL codes,
//! request/reply structs, the version handshake) differ, per `pf_driver_proto`.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use std::ffi::c_void;
use std::mem::size_of;
use std::os::windows::io::{FromRawHandle, OwnedHandle};
use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::{Context, Result};
use windows::core::{GUID, PCWSTR};
use windows::Win32::Devices::DeviceAndDriverInstallation::{
SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInterfaces, SetupDiGetClassDevsW,
SetupDiGetDeviceInterfaceDetailW, DIGCF_DEVICEINTERFACE, DIGCF_PRESENT,
SP_DEVICE_INTERFACE_DATA, SP_DEVICE_INTERFACE_DETAIL_DATA_W,
};
use windows::Win32::Foundation::{CloseHandle, HANDLE, LUID};
use windows::Win32::Storage::FileSystem::{
CreateFileW, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
};
use windows::Win32::System::IO::DeviceIoControl;
use pf_driver_proto::control;
use super::manager::{AddedMonitor, MonitorKey, VdisplayDriver};
use super::{Mode, VirtualDisplay, VirtualOutput};
// pf-vdisplay device-interface GUID (pf_driver_proto::PF_VDISPLAY_INTERFACE_GUID_U128). Deliberately
// NOT SudoVDA's `{e5bcc234-…}` — we own this driver, so a private interface GUID signals it and avoids
// any accidental coexistence with a real SudoVDA install.
const PF_VDISPLAY_INTERFACE: GUID =
GUID::from_u128(pf_driver_proto::PF_VDISPLAY_INTERFACE_GUID_U128);
/// Monotonic per-session id keying a pf-vdisplay monitor for `IOCTL_ADD`/`IOCTL_REMOVE`. Unlike
/// SudoVDA's 16-byte GUID + pid-mangling, the proto keys monitors by a plain `u64` — the host-level
/// refcount manager (MGR) owns collision safety (a stale session can never REMOVE a live one), so a
/// simple monotonic counter suffices. Unique per (process, session) within this host's lifetime.
static NEXT_SESSION_ID: AtomicU64 = AtomicU64::new(1);
fn next_session_id() -> u64 {
NEXT_SESSION_ID.fetch_add(1, Ordering::Relaxed)
}
/// One `DeviceIoControl` round trip (METHOD_BUFFERED). `input`/`output` may be empty. Identical to the
/// SudoVDA backend's wrapper; struct<->bytes conversion happens at the call sites via `bytemuck`.
unsafe fn ioctl(h: HANDLE, code: u32, input: &[u8], output: &mut [u8]) -> Result<u32> {
let mut returned = 0u32;
let inp = (!input.is_empty()).then_some(input.as_ptr() as *const c_void);
let outp = (!output.is_empty()).then_some(output.as_mut_ptr() as *mut c_void);
DeviceIoControl(
h,
code,
inp,
input.len() as u32,
outp,
output.len() as u32,
Some(&mut returned),
None,
)
.with_context(|| format!("DeviceIoControl(code={code:#x})"))?;
Ok(returned)
}
/// Pin the pf-vdisplay IddCx's RENDER GPU to `luid` (the analogue of Apollo's `SetRenderAdapter`). No
/// output buffer. Issued on the driver handle BEFORE `IOCTL_ADD` to steer which GPU the new target
/// renders on — on a multi-adapter box this stops DXGI from reparenting the virtual output onto a
/// different adapter than the one we duplicate/encode on (the ACCESS_LOST storm).
///
/// NOTE: the pf-vdisplay driver currently returns `STATUS_NOT_IMPLEMENTED` for this IOCTL (a STEP-4
/// stub), so this call WILL fail today. Callers tolerate the `Err` (warn + continue) — exactly as the
/// SudoVDA backend tolerated the driver IGNORING the pin.
unsafe fn set_render_adapter(h: HANDLE, luid: LUID) -> Result<()> {
let req = control::SetRenderAdapterRequest {
luid_low: luid.LowPart,
luid_high: luid.HighPart,
};
let mut none: [u8; 0] = [];
ioctl(
h,
control::IOCTL_SET_RENDER_ADAPTER,
bytemuck::bytes_of(&req),
&mut none,
)
.map(|_| ())
.context("pf-vdisplay SET_RENDER_ADAPTER")
}
unsafe fn open_device() -> Result<HANDLE> {
let hdev = SetupDiGetClassDevsW(
Some(&PF_VDISPLAY_INTERFACE),
PCWSTR::null(),
None,
DIGCF_DEVICEINTERFACE | DIGCF_PRESENT,
)
.context("SetupDiGetClassDevsW(pf-vdisplay) — is the pf-vdisplay driver installed?")?;
let mut idata = SP_DEVICE_INTERFACE_DATA {
cbSize: size_of::<SP_DEVICE_INTERFACE_DATA>() as u32,
..Default::default()
};
SetupDiEnumDeviceInterfaces(hdev, None, &PF_VDISPLAY_INTERFACE, 0, &mut idata)
.context("SetupDiEnumDeviceInterfaces(pf-vdisplay)")?;
let mut required = 0u32;
let _ = SetupDiGetDeviceInterfaceDetailW(hdev, &idata, None, 0, Some(&mut required), None);
let mut buf = vec![0u8; required as usize];
let detail = buf.as_mut_ptr() as *mut SP_DEVICE_INTERFACE_DETAIL_DATA_W;
(*detail).cbSize = size_of::<SP_DEVICE_INTERFACE_DETAIL_DATA_W>() as u32;
SetupDiGetDeviceInterfaceDetailW(hdev, &idata, Some(detail), required, None, None)
.context("SetupDiGetDeviceInterfaceDetailW(pf-vdisplay)")?;
let handle = CreateFileW(
PCWSTR((*detail).DevicePath.as_ptr()),
0xC000_0000, // GENERIC_READ | GENERIC_WRITE
FILE_SHARE_READ | FILE_SHARE_WRITE,
None,
OPEN_EXISTING,
FILE_FLAGS_AND_ATTRIBUTES(0),
None,
)
.context("CreateFileW(pf-vdisplay device)")?;
let _ = SetupDiDestroyDeviceInfoList(hdev);
Ok(handle)
}
/// The pf-vdisplay IOCTL surface behind the shared [`VirtualDisplayManager`](super::manager::VirtualDisplayManager)
/// (Goal-1 §2.5) — the wire contract is owned by `pf_driver_proto::control` (versioned, hard-checked).
pub(crate) struct PfVdisplayDriver;
impl VdisplayDriver for PfVdisplayDriver {
fn name(&self) -> &'static str {
"pf-vdisplay"
}
unsafe fn open(&self) -> Result<(OwnedHandle, u32)> {
// SAFETY: `open_device` is `unsafe` only because it issues SetupAPI enumeration + `CreateFileW`
// FFI; it takes no arguments and returns an owned raw `HANDLE` (or `Err`). Called here on the
// backend-init thread, with no precondition beyond a valid thread context.
let device = unsafe { open_device()? };
// HARD protocol-version check (unlike SudoVDA's best-effort log): a mismatched host/driver pair
// fails loudly here rather than corrupting the IOCTL stream.
let mut info_buf = [0u8; size_of::<control::InfoReply>()];
// SAFETY: `ioctl` requires `h` to be a valid device handle and its slices to be valid for the
// call. `device` is the live handle just returned by `open_device`. `IOCTL_GET_INFO` takes no
// input (`&[]`) and writes into `info_buf`, a stack `[u8; size_of::<InfoReply>()]` whose length
// is passed as the output size — so `DeviceIoControl` can't write OOB — and which outlives this
// synchronous call.
unsafe { ioctl(device, control::IOCTL_GET_INFO, &[], &mut info_buf) }
.context("pf-vdisplay IOCTL_GET_INFO (version handshake)")?;
let info: control::InfoReply =
bytemuck::pod_read_unaligned(&info_buf[..size_of::<control::InfoReply>()]);
if info.protocol_version != pf_driver_proto::PROTOCOL_VERSION {
// SAFETY: `device` is the valid raw handle from `open_device` and has NOT yet been wrapped
// in an `OwnedHandle` (that happens only on the success path below), so this error path is
// the sole owner closing it exactly once — no double-close.
unsafe {
let _ = CloseHandle(device);
}
anyhow::bail!(
"pf-vdisplay protocol mismatch: host expects {}, driver reports {} — install matching \
host + driver",
pf_driver_proto::PROTOCOL_VERSION,
info.protocol_version
);
}
let watchdog_s = info.watchdog_timeout_s.max(1);
tracing::info!(
"pf-vdisplay protocol {} (watchdog timeout {}s)",
info.protocol_version,
watchdog_s
);
// Reap monitors orphaned by a crashed previous host — a FIRST-CLASS op (driver returns SUCCESS).
let mut none: [u8; 0] = [];
// SAFETY: `device` is the live handle from `open_device` (still owned here, before it is wrapped
// below). `IOCTL_CLEAR_ALL` has no input and no output: `&[]` and the empty `none` slice pass
// zero-length buffers, so nothing is read or written through them.
if unsafe { ioctl(device, control::IOCTL_CLEAR_ALL, &[], &mut none) }.is_ok() {
tracing::info!("cleared orphaned virtual monitors on host startup");
} else {
tracing::warn!("pf-vdisplay IOCTL_CLEAR_ALL failed on startup (continuing)");
}
Ok((
// SAFETY: `device` is the valid handle from `open_device`, still owned here and NOT closed
// on this success path (the error paths above close it and return). `from_raw_handle`'s
// contract — caller owns a valid handle — holds, so ownership transfers cleanly into the
// `OwnedHandle`: exactly one owner, which `CloseHandle`s it on drop.
unsafe { OwnedHandle::from_raw_handle(device.0 as _) },
watchdog_s,
))
}
unsafe fn add_monitor(
&self,
dev: HANDLE,
mode: Mode,
render_luid: Option<LUID>,
) -> Result<AddedMonitor> {
let session_id = next_session_id();
let add = control::AddRequest {
session_id,
width: mode.width,
height: mode.height,
refresh_hz: mode.refresh_hz,
_reserved: 0,
};
// SET_RENDER_ADAPTER (opt-in; pf-vdisplay IMPLEMENTS it). Non-fatal on failure: the driver reports
// its real render LUID in the shared header, so the host binds correctly even if this is ignored.
if let Some(luid) = render_luid {
// SAFETY: `add_monitor`'s `# Safety` contract guarantees `dev` is the live control handle,
// which is `set_render_adapter`'s precondition; we forward it unchanged. `luid` is a plain
// `Copy` `LUID` passed by value — no borrow crosses the call.
match unsafe { set_render_adapter(dev, luid) } {
Ok(()) => tracing::info!(
luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
"pf-vdisplay SET_RENDER_ADAPTER: pinned IDD render GPU"
),
Err(e) => tracing::warn!(
"pf-vdisplay SET_RENDER_ADAPTER failed (continuing on the natural adapter): {e:#}"
),
}
}
let mut out = [0u8; size_of::<control::AddReply>()];
// SAFETY: per `add_monitor`'s contract `dev` is the live control handle. `bytemuck::bytes_of(&add)`
// borrows the local `AddRequest` (alive across this synchronous call) as the input bytes, and
// `out` is a stack `[u8; size_of::<AddReply>()]` whose length bounds the kernel's write — both
// buffers outlive the call.
unsafe { ioctl(dev, control::IOCTL_ADD, bytemuck::bytes_of(&add), &mut out) }
.with_context(|| {
format!(
"pf-vdisplay ADD {}x{}@{}",
mode.width, mode.height, mode.refresh_hz
)
})?;
// `pod_read_unaligned` (NOT `from_bytes`): `out` is a stack `[u8; N]` with no guaranteed 4-byte
// alignment, and `from_bytes` PANICS on a mismatch. This copies into an aligned `AddReply`.
let reply: control::AddReply =
bytemuck::pod_read_unaligned(&out[..size_of::<control::AddReply>()]);
let luid = LUID {
LowPart: reply.adapter_luid_low,
HighPart: reply.adapter_luid_high,
};
tracing::info!(
"pf-vdisplay created {}x{}@{} (target_id={}, adapter_luid={:#x})",
mode.width,
mode.height,
mode.refresh_hz,
reply.target_id,
luid.LowPart
);
if let Some(pin) = render_luid {
if luid.LowPart == pin.LowPart && luid.HighPart == pin.HighPart {
tracing::info!("pf-vdisplay ADD render adapter matches the pinned GPU (pin took)");
} else {
tracing::warn!(
add = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
pinned = format!("{:08x}:{:08x}", pin.HighPart, pin.LowPart),
"pf-vdisplay ADD render adapter DIFFERS from pinned — driver ignored SET_RENDER_ADAPTER?"
);
}
}
Ok(AddedMonitor {
key: MonitorKey::Session(session_id),
target_id: reply.target_id,
luid,
})
}
unsafe fn remove_monitor(&self, dev: HANDLE, key: &MonitorKey) -> Result<()> {
let MonitorKey::Session(session_id) = key else {
anyhow::bail!("pf-vdisplay: unexpected monitor key kind");
};
let req = control::RemoveRequest {
session_id: *session_id,
};
let mut none: [u8; 0] = [];
// SAFETY: per `remove_monitor`'s contract `dev` is the live control handle. `bytes_of(&req)`
// borrows the local `RemoveRequest` for the duration of this synchronous call as the input
// bytes; `none` is empty, so there is no output buffer.
unsafe {
ioctl(
dev,
control::IOCTL_REMOVE,
bytemuck::bytes_of(&req),
&mut none,
)
}
.map(|_| ())
}
unsafe fn ping(&self, dev: HANDLE) -> Result<()> {
let mut none: [u8; 0] = [];
// SAFETY: per `ping`'s contract `dev` is the live control handle. `IOCTL_PING` has no input
// (`&[]`) and no output (`none` is empty), so no memory is read or written through the buffers.
unsafe { ioctl(dev, control::IOCTL_PING, &[], &mut none) }.map(|_| ())
}
}
/// The Windows pf-vdisplay virtual-display backend. A marker — the lifecycle lives in the shared
/// [`VirtualDisplayManager`](super::manager::VirtualDisplayManager).
pub struct PfVdisplayDisplay;
impl PfVdisplayDisplay {
pub fn new() -> Result<Self> {
super::manager::init(Box::new(PfVdisplayDriver)).open_backend()?;
Ok(Self)
}
}
impl VirtualDisplay for PfVdisplayDisplay {
fn name(&self) -> &'static str {
"pf-vdisplay"
}
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
super::manager::vdm().acquire(mode)
}
}
/// Readiness probe: can we open the pf-vdisplay control device?
pub fn probe() -> Result<()> {
// SAFETY: `open_device` is `unsafe` only for its SetupAPI + `CreateFileW` FFI; no arguments, returns
// an owned raw `HANDLE` (or `Err`).
let h = unsafe { open_device()? };
// SAFETY: `h` is the handle just opened by `open_device` in this function, owned here and not yet
// handed anywhere else, so this closes it exactly once — no double-close, no use-after-close.
unsafe {
let _ = CloseHandle(h);
}
Ok(())
}
/// Is the pf-vdisplay driver present (device interface enumerable)?
pub fn is_available() -> bool {
// SAFETY: `open_device` returns an owned raw `HANDLE`; on `Ok(h)` the handle is moved into the
// closure (sole owner) and closed exactly once via `CloseHandle`, on `Err` there is nothing to
// close — so no double-close and no leak of an opened handle. The `unsafe` covers both FFI calls.
unsafe { open_device().map(|h| CloseHandle(h)).is_ok() }
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
use std::time::Duration;
/// Live hardware round trip — skipped unless `PUNKTFUNK_PF_VDISPLAY_LIVE=1` (needs the pf-vdisplay
/// driver installed). Exercises the real trait path: open -> create -> hold -> drop (REMOVE).
#[test]
fn live_create_drop() {
if std::env::var("PUNKTFUNK_PF_VDISPLAY_LIVE").is_err() {
return;
}
let mut vd = PfVdisplayDisplay::new().expect("open pf-vdisplay");
let vout = vd
.create(Mode {
width: 1920,
height: 1080,
refresh_hz: 60,
})
.expect("create virtual display");
assert_eq!(vout.preferred_mode, Some((1920, 1080, 60)));
thread::sleep(Duration::from_secs(3));
drop(vout); // triggers REMOVE + stops the pinger
}
}
@@ -0,0 +1,126 @@
//! Launch a process into the interactive user session from the SYSTEM host.
//!
//! The Windows host runs as a LocalSystem SCM service. To *launch* a game/launcher so it renders onto
//! the captured desktop — and so the user's protocol handlers (`HKCU\Software\Classes`), UWP/appx
//! activation, and each store's auth/entitlement context resolve — the process must run in the
//! interactive session under the **logged-in user's** token, not SYSTEM and not session 0.
//!
//! This is the same `WTSGetActiveConsoleSessionId → WTSQueryUserToken → DuplicateTokenEx →
//! CreateProcessAsUserW(winsta0\\default)` primitive the WGC helper relay uses
//! ([`crate::capture::wgc_relay`]), factored out for the library launch path
//! ([`crate::library::launch_title`]).
//!
//! IMPORTANT — use the **user** token (`WTSQueryUserToken`), NOT a session-retargeted SYSTEM token
//! (the host-spawn in [`crate::service`] duplicates the SYSTEM token and only changes its session id;
//! that is correct for launching *our own* streamer, but a store launcher needs the real user's token
//! for activation + auth). The host process itself stays SYSTEM.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::{bail, Context, Result};
use std::path::Path;
use windows::core::{PCWSTR, PWSTR};
use windows::Win32::Foundation::{CloseHandle, HANDLE};
use windows::Win32::Security::{
DuplicateTokenEx, SecurityImpersonation, TokenPrimary, TOKEN_ALL_ACCESS,
};
use windows::Win32::System::Environment::{CreateEnvironmentBlock, DestroyEnvironmentBlock};
use windows::Win32::System::RemoteDesktop::{WTSGetActiveConsoleSessionId, WTSQueryUserToken};
use windows::Win32::System::Threading::{
CreateProcessAsUserW, CREATE_UNICODE_ENVIRONMENT, PROCESS_INFORMATION, STARTUPINFOW,
};
/// Spawn `cmdline` in the active console session, under the logged-in user's token, on the
/// interactive desktop (`winsta0\default`). Returns the new process id.
///
/// Fire-and-forget: the launched game/launcher outlives this call, so the host does not track the
/// child — its handles are closed before returning (the process keeps running). The environment is
/// the user's block merged with the host's `PUNKTFUNK_*`/`RUST_LOG` (same merge the WGC helper uses),
/// so `host.env` settings propagate.
///
/// Requires the host to run as SYSTEM (`WTSQueryUserToken` needs `SE_TCB`). Fails when no interactive
/// user is logged on (a pre-login / freshly-booted box can stream the login desktop but cannot
/// auto-launch a store title until someone signs in).
pub fn spawn_in_active_session(cmdline: &str, workdir: Option<&Path>) -> Result<u32> {
// SAFETY: `spawn_inner` is unsafe only for its Win32 FFI; it has no caller-side preconditions — it
// validates the session/token itself and owns every handle it opens — so calling it is always sound.
unsafe { spawn_inner(cmdline, workdir) }
}
unsafe fn spawn_inner(cmdline: &str, workdir: Option<&Path>) -> Result<u32> {
// The user token of the active console session (requires the host to be SYSTEM).
let session = WTSGetActiveConsoleSessionId();
if session == 0xFFFF_FFFF {
bail!("no active console session (no interactive user is logged on)");
}
let mut user_token = HANDLE::default();
WTSQueryUserToken(session, &mut user_token)
.context("WTSQueryUserToken (host must be SYSTEM; needs a logged-on interactive user)")?;
// A primary token for CreateProcessAsUserW.
let mut primary = HANDLE::default();
let dup = DuplicateTokenEx(
user_token,
TOKEN_ALL_ACCESS,
None,
SecurityImpersonation,
TokenPrimary,
&mut primary,
);
let _ = CloseHandle(user_token);
dup.context("DuplicateTokenEx(TokenPrimary)")?;
// The user's environment block (PATH/USERPROFILE/SystemRoot for handler + DLL resolution), MERGED
// with the host's PUNKTFUNK_*/RUST_LOG vars — same shared helper the WGC helper + service spawns use.
let mut env_block: *mut core::ffi::c_void = std::ptr::null_mut();
let _ = CreateEnvironmentBlock(&mut env_block, Some(primary), false);
let merged_env = crate::capture::wgc_relay::merged_env_block(env_block as *const u16);
if !env_block.is_null() {
let _ = DestroyEnvironmentBlock(env_block);
}
// The game/launcher must appear on the interactive desktop the host is capturing.
let mut desktop: Vec<u16> = "winsta0\\default\0".encode_utf16().collect();
let si = STARTUPINFOW {
cb: std::mem::size_of::<STARTUPINFOW>() as u32,
lpDesktop: PWSTR(desktop.as_mut_ptr()),
..Default::default()
};
let mut cmd: Vec<u16> = cmdline.encode_utf16().chain(std::iter::once(0)).collect();
let workdir_w: Option<Vec<u16>> = workdir.map(|d| {
d.as_os_str()
.to_string_lossy()
.encode_utf16()
.chain(std::iter::once(0))
.collect()
});
let cwd = match &workdir_w {
Some(w) => PCWSTR(w.as_ptr()),
None => PCWSTR::null(),
};
let mut pi = PROCESS_INFORMATION::default();
let created = CreateProcessAsUserW(
Some(primary),
None,
Some(PWSTR(cmd.as_mut_ptr())),
None,
None,
false, // no handle inheritance — fire-and-forget GUI launch, no stdio relay
CREATE_UNICODE_ENVIRONMENT,
Some(merged_env.as_ptr() as *const core::ffi::c_void),
cwd,
&si,
&mut pi,
);
let _ = CloseHandle(primary);
created.context("CreateProcessAsUserW (interactive-session launch)")?;
let pid = pi.dwProcessId;
// We don't supervise the child (it owns its own window/lifetime) — close the handles the API gave us.
let _ = CloseHandle(pi.hProcess);
let _ = CloseHandle(pi.hThread);
Ok(pid)
}
@@ -21,10 +21,14 @@
//! loaded into the service's environment and carried to the host child. Logs land in //! loaded into the service's environment and carried to the host child. Logs land in
//! `%ProgramData%\punktfunk\logs\`. //! `%ProgramData%\punktfunk\logs\`.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use std::ffi::{c_void, OsString}; use std::ffi::{c_void, OsString};
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicIsize, Ordering}; use std::sync::OnceLock;
use std::time::Duration; use std::time::Duration;
use windows::core::{PCWSTR, PWSTR}; use windows::core::{PCWSTR, PWSTR};
@@ -64,14 +68,19 @@ const SERVICE_DESCRIPTION: &str =
/// legacy GCM nonce reuse — security-review #5/#9; native clients only). /// legacy GCM nonce reuse — security-review #5/#9; native clients only).
const DEFAULT_HOST_CMD: &str = "serve --gamestream"; const DEFAULT_HOST_CMD: &str = "serve --gamestream";
/// Event handles shared between the SCM control handler (which signals them) and the supervision loop /// The STOP and SESSION manual-reset events, shared between the SCM control handler (a capture-free
/// (which waits on them). Stored as raw `isize` so the `'static + Send` handler can reach them without /// `'static` closure that SIGNALS them) and the supervision loop (which WAITS on them). They live in
/// a non-`Send` `HANDLE` capture. Set once in `run_service`. /// `OnceLock`s — a static the handler can reach without capturing a non-`Send` `HANDLE` — and each owns
static STOP_EVENT: AtomicIsize = AtomicIsize::new(0); /// its handle (`OwnedHandle`) for the process lifetime: the service process exits right after
static SESSION_EVENT: AtomicIsize = AtomicIsize::new(0); /// `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 { /// Borrow an event's handle for the control handler's `SetEvent`. `None` until `run_service` creates the
HANDLE(a.load(Ordering::Relaxed) as *mut c_void) /// 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>`. /// Dispatch `service <sub>`.
@@ -199,12 +208,24 @@ fn run_service() -> Result<()> {
// Two manual-reset events: STOP (set once, never reset) and SESSION (set on a console // Two manual-reset events: STOP (set once, never reset) and SESSION (set on a console
// connect/disconnect, reset by the supervisor after it reacts). // connect/disconnect, reset by the supervisor after it reacts).
let stop = // SAFETY: CreateEventW with null attributes (None), manual-reset=true, initial-state=false and a null
// name passes no pointers into Rust memory; it returns a fresh, owned event HANDLE (or Err, via `?`).
// Nothing aliases or outlives the call.
let stop_raw =
unsafe { CreateEventW(None, true, false, PCWSTR::null()) }.context("CreateEvent stop")?; unsafe { CreateEventW(None, true, false, PCWSTR::null()) }.context("CreateEvent stop")?;
let session = unsafe { CreateEventW(None, true, false, PCWSTR::null()) } // SAFETY: as above — a second fresh manual-reset event; no pointers into Rust memory, no aliasing.
let session_raw = unsafe { CreateEventW(None, true, false, PCWSTR::null()) }
.context("CreateEvent session")?; .context("CreateEvent session")?;
STOP_EVENT.store(stop.0 as isize, Ordering::Relaxed); // Own each event handle (the OS reaps them at process exit); the handler reaches them through the
SESSION_EVENT.store(session.0 as isize, Ordering::Relaxed); // OnceLocks, while `supervise` waits on the borrowed `HANDLE`s. SAFETY: each is a fresh CreateEventW
// handle we own — take ownership exactly once.
let stop_owned = unsafe { OwnedHandle::from_raw_handle(stop_raw.0) };
// SAFETY: `session_raw` is the other fresh CreateEventW handle nothing else owns — take ownership once.
let session_owned = unsafe { OwnedHandle::from_raw_handle(session_raw.0) };
let stop = HANDLE(stop_owned.as_raw_handle());
let session = HANDLE(session_owned.as_raw_handle());
let _ = STOP_EVENT.set(stop_owned); // set once per process
let _ = SESSION_EVENT.set(session_owned);
// The control handler captures nothing — it reaches the events through the statics, so it stays // 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 // `Fn + Send + 'static`. Session lock/unlock are handled inside the host (DesktopWatcher), so we
@@ -212,7 +233,12 @@ fn run_service() -> Result<()> {
let handler = move |control| -> ServiceControlHandlerResult { let handler = move |control| -> ServiceControlHandlerResult {
match control { match control {
ServiceControl::Stop | ServiceControl::Preshutdown | ServiceControl::Shutdown => { ServiceControl::Stop | ServiceControl::Preshutdown | ServiceControl::Shutdown => {
unsafe { SetEvent(load_event(&STOP_EVENT)) }.ok(); if let Some(h) = event_handle(&STOP_EVENT) {
// SAFETY: `h` borrows the STOP event HANDLE from the STOP_EVENT OwnedHandle, set for
// the whole process lifetime and never closed before exit, so it is open here; SetEvent
// only signals the event and passes no Rust memory.
unsafe { SetEvent(h) }.ok();
}
ServiceControlHandlerResult::NoError ServiceControlHandlerResult::NoError
} }
ServiceControl::SessionChange(param) => { ServiceControl::SessionChange(param) => {
@@ -221,7 +247,12 @@ fn run_service() -> Result<()> {
param.reason, param.reason,
ConsoleConnect | ConsoleDisconnect | SessionLogon ConsoleConnect | ConsoleDisconnect | SessionLogon
) { ) {
unsafe { SetEvent(load_event(&SESSION_EVENT)) }.ok(); if let Some(h) = event_handle(&SESSION_EVENT) {
// SAFETY: `h` borrows the SESSION event HANDLE from the SESSION_EVENT OwnedHandle,
// alive for the whole process lifetime and never closed before exit; SetEvent only
// signals the event and passes no Rust memory.
unsafe { SetEvent(h) }.ok();
}
} }
ServiceControlHandlerResult::NoError ServiceControlHandlerResult::NoError
} }
@@ -258,10 +289,8 @@ fn run_service() -> Result<()> {
controls_accepted: ServiceControlAccept::empty(), controls_accepted: ServiceControlAccept::empty(),
..running ..running
}); });
unsafe { // The STOP/SESSION events stay owned by the OnceLocks for the process lifetime (the OS reaps them at
let _ = CloseHandle(stop); // exit); NOT closing them while the SCM handler could still fire avoids a use-after-close.
let _ = CloseHandle(session);
}
result result
} }
@@ -280,7 +309,10 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
.collect(); .collect();
// Kill-on-close job so a service crash never orphans the SYSTEM host; BREAKAWAY_OK lets the host // Kill-on-close job so a service crash never orphans the SYSTEM host; BREAKAWAY_OK lets the host
// still spawn the WGC helper. // still spawn the WGC helper. Owned: dropping it at function exit (KILL_ON_JOB_CLOSE) reaps any
// straggler still inside it — no manual CloseHandle(job).
// SAFETY: `make_job` is unsafe only for its Win32 FFI; it has no caller preconditions and creates +
// immediately takes RAII ownership of the job object, so calling it here is sound.
let job = unsafe { make_job() }.context("create job object")?; let job = unsafe { make_job() }.context("create job object")?;
let mut restarts: u32 = 0; let mut restarts: u32 = 0;
@@ -288,6 +320,8 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
if wait_one(stop, 0) { if wait_one(stop, 0) {
break; break;
} }
// SAFETY: WTSGetActiveConsoleSessionId takes no arguments and returns the active console session
// id (or 0xFFFFFFFF); it passes no pointers, so the call is always sound.
let session = unsafe { WTSGetActiveConsoleSessionId() }; let session = unsafe { WTSGetActiveConsoleSessionId() };
if session == 0xFFFF_FFFF { if session == 0xFFFF_FFFF {
// No interactive session yet (boot / fully logged out). Wait, but wake on stop/session. // No interactive session yet (boot / fully logged out). Wait, but wake on stop/session.
@@ -295,12 +329,19 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
if wait_any(&[stop, session_ev], 3000) == Some(0) { if wait_any(&[stop, session_ev], 3000) == Some(0) {
break; break;
} }
// SAFETY: `session_ev` is the SESSION event HANDLE borrowed from the SESSION_EVENT OwnedHandle,
// alive for the process lifetime; ResetEvent only clears its signalled state, no Rust memory.
unsafe { ResetEvent(session_ev) }.ok(); unsafe { ResetEvent(session_ev) }.ok();
continue; continue;
} }
let pi = match unsafe { spawn_host(session, &cmdline, &workdir, job) } { // BORROW the owned job handle for AssignProcessToJobObject inside spawn_host.
Ok(pi) => pi, let job_h = HANDLE(job.as_raw_handle());
// SAFETY: `spawn_host` is unsafe only for its Win32 FFI. `session` is a valid console session id
// (checked != 0xFFFFFFFF above), `cmdline`/`workdir` are live borrows for the call, and `job_h`
// borrows the still-live `job` OwnedHandle — every argument is valid for the call's duration.
let child = match unsafe { spawn_host(session, &cmdline, &workdir, job_h) } {
Ok(child) => child,
Err(e) => { Err(e) => {
tracing::error!("failed to launch host into session {session}: {e:#}"); tracing::error!("failed to launch host into session {session}: {e:#}");
if wait_one(stop, 3000) { if wait_one(stop, 3000) {
@@ -309,23 +350,33 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
continue; 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. // 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 { match reason {
Some(0) => { Some(0) => {
// Stop: terminate the child and exit. // Stop: terminate the child and exit (the `child` drop closes its handles).
// SAFETY: `proc_h` is a HANDLE copy of the still-live `child.process` OwnedHandle (not
// dropped until end of iteration), so the process handle is open; TerminateProcess only
// signals termination by handle and passes no Rust memory.
unsafe { unsafe {
let _ = TerminateProcess(pi.hProcess, 0); let _ = TerminateProcess(proc_h, 0);
let _ = CloseHandle(pi.hProcess);
let _ = CloseHandle(pi.hThread);
} }
break; break;
} }
Some(1) => { Some(1) => {
// Session change: relaunch only if the active console session actually moved. // Session change: relaunch only if the active console session actually moved.
// SAFETY: `session_ev` borrows the process-lifetime SESSION_EVENT OwnedHandle; ResetEvent
// only clears its signalled state and passes no Rust memory.
unsafe { ResetEvent(session_ev) }.ok(); unsafe { ResetEvent(session_ev) }.ok();
// SAFETY: WTSGetActiveConsoleSessionId takes no arguments and passes no pointers.
let now = unsafe { WTSGetActiveConsoleSessionId() }; let now = unsafe { WTSGetActiveConsoleSessionId() };
if now != session { if now != session {
tracing::info!( tracing::info!(
@@ -333,20 +384,20 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
new = now, new = now,
"console session changed — relaunching host" "console session changed — relaunching host"
); );
// SAFETY: `proc_h` copies the still-live `child.process` OwnedHandle (dropped only at
// end of iteration), so the handle is open; TerminateProcess only signals by handle.
unsafe { unsafe {
let _ = TerminateProcess(pi.hProcess, 0); let _ = TerminateProcess(proc_h, 0);
let _ = CloseHandle(pi.hProcess);
let _ = CloseHandle(pi.hThread);
} }
restarts = 0; restarts = 0;
continue; continue;
} }
// Same session (e.g. a stray notification) — keep waiting on the same child. // Same session (e.g. a stray notification) — keep waiting on the same child.
let r = wait_any(&[stop, pi.hProcess], INFINITE); let r = wait_any(&[stop, proc_h], INFINITE);
// SAFETY: `proc_h` copies the still-live `child.process` OwnedHandle (dropped only at end
// of iteration), so the handle is open; TerminateProcess only signals by handle.
unsafe { unsafe {
let _ = TerminateProcess(pi.hProcess, 0); let _ = TerminateProcess(proc_h, 0);
let _ = CloseHandle(pi.hProcess);
let _ = CloseHandle(pi.hThread);
} }
if r == Some(0) { if r == Some(0) {
break; break;
@@ -354,12 +405,9 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
// child exited → fall through to relaunch // 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"); tracing::warn!("host process exited — relaunching");
unsafe {
let _ = CloseHandle(pi.hProcess);
let _ = CloseHandle(pi.hThread);
}
} }
} }
@@ -368,36 +416,43 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
if wait_one(stop, backoff) { if wait_one(stop, backoff) {
break; break;
} }
// `child` drops here (end of iteration) → its process + thread handles close before relaunch.
} }
unsafe { // `job` (OwnedHandle) drops at function exit, closing the job object → KILL_ON_JOB_CLOSE reaps
// Dropping the job (KILL_ON_JOB_CLOSE) reaps any straggler in it. // any straggler still inside it.
let _ = CloseHandle(job);
}
tracing::info!("supervision loop ended"); tracing::info!("supervision loop ended");
Ok(()) Ok(())
} }
/// `true` if `h` is signalled within `ms`. /// `true` if `h` is signalled within `ms`.
fn wait_one(h: HANDLE, ms: u32) -> bool { fn wait_one(h: HANDLE, ms: u32) -> bool {
// SAFETY: `&[h]` is a live one-element HANDLE slice the caller keeps open across the wait; the kernel
// reads exactly one handle (the binding derives the count from the slice length), bWaitAll=false,
// `ms` is a timeout — no pointers escape and the array is only read for this synchronous call.
unsafe { WaitForMultipleObjects(&[h], false, ms) == WAIT_OBJECT_0 } unsafe { WaitForMultipleObjects(&[h], false, ms) == WAIT_OBJECT_0 }
} }
/// Wait on several handles; returns the index of the first signalled, or `None` on timeout. /// Wait on several handles; returns the index of the first signalled, or `None` on timeout.
fn wait_any(handles: &[HANDLE], ms: u32) -> Option<usize> { fn wait_any(handles: &[HANDLE], ms: u32) -> Option<usize> {
// SAFETY: `handles` is a live slice the caller keeps open across the wait; WaitForMultipleObjects
// reads exactly `handles.len()` handles (the binding derives the count from the slice), bWaitAll=false,
// `ms` is a timeout — the array is only read for this synchronous call and no pointers escape it.
let r = unsafe { WaitForMultipleObjects(handles, false, ms) }; let r = unsafe { WaitForMultipleObjects(handles, false, ms) };
let idx = r.0.wrapping_sub(WAIT_OBJECT_0.0); let idx = r.0.wrapping_sub(WAIT_OBJECT_0.0);
(idx < handles.len() as u32).then_some(idx as usize) (idx < handles.len() as u32).then_some(idx as usize)
} }
/// A kill-on-close + breakaway-ok job object. /// A kill-on-close + breakaway-ok job object, returned as an `OwnedHandle` (auto-`CloseHandle` on drop).
unsafe fn make_job() -> Result<HANDLE> { unsafe fn make_job() -> Result<OwnedHandle> {
let job = CreateJobObjectW(None, PCWSTR::null()).context("CreateJobObjectW")?; 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(); let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
info.BasicLimitInformation.LimitFlags = info.BasicLimitInformation.LimitFlags =
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK; JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK;
SetInformationJobObject( SetInformationJobObject(
job, HANDLE(job.as_raw_handle()),
JobObjectExtendedLimitInformation, JobObjectExtendedLimitInformation,
&info as *const _ as *const c_void, &info as *const _ as *const c_void,
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32, std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
@@ -406,13 +461,24 @@ unsafe fn make_job() -> Result<HANDLE> {
Ok(job) 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( unsafe fn spawn_host(
session_id: u32, session_id: u32,
cmdline: &str, cmdline: &str,
workdir: &[u16], workdir: &[u16],
job: HANDLE, job: HANDLE,
) -> Result<PROCESS_INFORMATION> { ) -> Result<Child> {
// 1) A primary SYSTEM token retargeted to the active console session: duplicate THIS process's // 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 // (LocalSystem) token, then set its session id. SYSTEM holds SE_TCB so SetTokenInformation
// (TokenSessionId) is permitted. // (TokenSessionId) is permitted.
@@ -494,7 +560,14 @@ unsafe fn spawn_host(
// Best-effort: keep the host inside the kill-on-close job. // Best-effort: keep the host inside the kill-on-close job.
let _ = AssignProcessToJobObject(job, pi.hProcess); 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). /// Open `path` for appending, as an INHERITABLE handle (so the child can use it as stdout/stderr).
@@ -621,6 +694,10 @@ fn ensure_default_host_env() -> Result<()> {
# Force one with nvenc | amf | qsv | sw (software H.264). amf/qsv need an FFmpeg-built host.\n\ # Force one with nvenc | amf | qsv | sw (software H.264). amf/qsv need an FFmpeg-built host.\n\
PUNKTFUNK_ENCODER=auto\n\ PUNKTFUNK_ENCODER=auto\n\
PUNKTFUNK_VIDEO_SOURCE=virtual\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\ PUNKTFUNK_SECURE_DDA=1\n\
RUST_LOG=info\n\ RUST_LOG=info\n\
\n\ \n\
@@ -12,6 +12,9 @@
//! //!
//! Wire framing on stdout, per AU: `[u32 len LE][u64 pts_ns LE][u8 keyframe][len bytes data]`. //! Wire framing on stdout, per AU: `[u32 len LE][u64 pts_ns LE][u8 keyframe][len bytes data]`.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use crate::capture::{dxgi::WinCaptureTarget, wgc::WgcCapturer, Capturer}; use crate::capture::{dxgi::WinCaptureTarget, wgc::WgcCapturer, Capturer};
use crate::encode::{self, Codec}; use crate::encode::{self, Codec};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
@@ -72,6 +75,9 @@ pub fn run(opts: HelperOptions) -> Result<()> {
.name("pf-present-trigger".into()) .name("pf-present-trigger".into())
.spawn(move || { .spawn(move || {
tracing::info!("present-trigger: starting D3D present loop on the virtual display"); tracing::info!("present-trigger: starting D3D present loop on the virtual display");
// SAFETY: `present_trigger` is unsafe only for its Win32/D3D11 FFI; it has no caller
// preconditions (it creates and exclusively owns its own window, device, and swapchain on
// this dedicated thread), so the call is sound.
if let Err(e) = unsafe { present_trigger(w, h) } { if let Err(e) = unsafe { present_trigger(w, h) } {
tracing::warn!("present-trigger error: {e:#}"); tracing::warn!("present-trigger error: {e:#}");
} }
@@ -135,7 +141,7 @@ pub fn run(opts: HelperOptions) -> Result<()> {
// the GPU scheduling priority the SYSTEM host stamps on us, not pipeline depth. // 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 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 frames = 0u64;
let mut repeats = 0u64; // frames where no newer capture had arrived (duplicate re-encode) 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) let mut cap_ns = 0u64; // time in try_latest (capture + video-processor convert)
@@ -0,0 +1,66 @@
//! Backend-neutral DXGI adapter selection.
//!
//! The discrete render-GPU LUID picker used to live in the SudoVDA backend (`vdisplay::sudovda`) — a
//! historical accident, since it is display-utility, not SudoVDA-specific. It lives here so the capturers
//! (IDD-push) and the pf-vdisplay backend depend on it as a *peer* instead of reaching into the SudoVDA
//! module — breaking that circular reach-in, which let the SudoVDA backend be dropped without losing this
//! helper (audit §9 / Goal 2 — done). This is the plan's `windows/adapter.rs`.
use windows::Win32::Foundation::LUID;
/// Pick the discrete render GPU LUID: the adapter with the most `DedicatedVideoMemory`, skipping
/// WARP / Basic-Render and the SudoVDA software adapter (≈0 VRAM). `PUNKTFUNK_RENDER_ADAPTER=<substring>`
/// forces a match by Description (Apollo's `adapter_name`). Used by the IDD direct-push capturer (to
/// create its shared textures on the same discrete GPU it pins, where NVENC runs) and SET_RENDER_ADAPTER.
///
/// # Safety
/// Creates + enumerates a DXGI factory; the COM calls run in the caller's apartment (the existing callers
/// already satisfy this).
pub(crate) unsafe fn resolve_render_adapter_luid() -> Option<LUID> {
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIFactory1};
let want = crate::config::config()
.render_adapter
.clone()
.filter(|s| !s.is_empty());
let factory: IDXGIFactory1 = CreateDXGIFactory1().ok()?;
let mut best: Option<(LUID, u64, String)> = None;
let mut i = 0u32;
while let Ok(a) = factory.EnumAdapters1(i) {
i += 1;
let Ok(d) = a.GetDesc1() else { continue };
let name = String::from_utf16_lossy(&d.Description);
let name = name.trim_end_matches('\u{0}').to_string();
let lname = name.to_ascii_lowercase();
if lname.contains("basic render") || lname.contains("warp") {
continue; // never pin to the software rasterizer
}
if let Some(w) = &want {
if lname.contains(&w.to_ascii_lowercase()) {
tracing::info!(
adapter = name,
"render adapter chosen by PUNKTFUNK_RENDER_ADAPTER"
);
return Some(d.AdapterLuid);
}
continue;
}
let vram = d.DedicatedVideoMemory as u64; // SudoVDA software adapter ≈ 0 → loses to the dGPU
if best.as_ref().is_none_or(|(_, v, _)| vram > *v) {
best = Some((d.AdapterLuid, vram, name));
}
}
match best {
Some((luid, vram, name)) => {
tracing::info!(
adapter = name,
vram_mb = vram / (1024 * 1024),
"render adapter chosen (max VRAM)"
);
Some(luid)
}
None => {
tracing::warn!("no suitable render adapter found for SET_RENDER_ADAPTER");
None
}
}
}
@@ -0,0 +1,425 @@
//! Backend-neutral Windows display utilities — the CCD (QueryDisplayConfig) + GDI helpers shared by the
//! virtual-display backends (pf-vdisplay, SudoVDA) and the capturers (IDD-push, WGC, DDA): GDI-name
//! resolution, advanced-color (HDR) get/set, active-mode set, and CCD topology isolate/restore.
//!
//! These are display-utility, NOT SudoVDA-specific (a pf-vdisplay monitor's target_id is a real OS target
//! id, so they operate identically), so they live here rather than in the SudoVDA backend — breaking the
//! circular reach-in where the capturers + the pf-vdisplay backend reached into `vdisplay::sudovda` for
//! them, which let the SudoVDA backend be dropped without losing them (audit §9 / Goal 2 — done). The
//! plan's `windows/display_ccd.rs`. Extracted verbatim from the former SudoVDA backend before its removal.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use std::mem::size_of;
use windows::core::PCWSTR;
use windows::Win32::Devices::Display::{
DisplayConfigGetDeviceInfo, DisplayConfigSetDeviceInfo, GetDisplayConfigBufferSizes,
QueryDisplayConfig, SetDisplayConfig, DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO,
DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_SET_ADVANCED_COLOR_STATE,
DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_MODE_INFO, DISPLAYCONFIG_PATH_INFO,
DISPLAYCONFIG_SET_ADVANCED_COLOR_STATE, DISPLAYCONFIG_SOURCE_DEVICE_NAME,
QDC_ONLY_ACTIVE_PATHS, SDC_ALLOW_CHANGES, SDC_APPLY, SDC_FORCE_MODE_ENUMERATION,
SDC_SAVE_TO_DATABASE, SDC_USE_SUPPLIED_DISPLAY_CONFIG,
};
use windows::Win32::Graphics::Gdi::{
ChangeDisplaySettingsExW, EnumDisplaySettingsW, CDS_TEST, CDS_UPDATEREGISTRY, DEVMODEW,
DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY, DM_PELSHEIGHT, DM_PELSWIDTH,
ENUM_CURRENT_SETTINGS, ENUM_DISPLAY_SETTINGS_MODE,
};
use crate::vdisplay::Mode;
/// Resolve the `\\.\DisplayN` GDI name for a SudoVDA target id via the CCD API. Returns `None`
/// until the OS activates the target into the desktop topology (needs a real WDDM GPU; on a
/// GPU-less box this stays `None` even though ADD succeeded).
pub(crate) unsafe fn resolve_gdi_name(target_id: u32) -> Option<String> {
let mut np = 0u32;
let mut nm = 0u32;
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() {
return None;
}
let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize];
let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize];
if QueryDisplayConfig(
QDC_ONLY_ACTIVE_PATHS,
&mut np,
paths.as_mut_ptr(),
&mut nm,
modes.as_mut_ptr(),
None,
)
.is_err()
{
return None;
}
for p in paths.iter().take(np as usize) {
if p.targetInfo.id == target_id {
let mut src = DISPLAYCONFIG_SOURCE_DEVICE_NAME::default();
src.header.r#type = DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME;
src.header.size = size_of::<DISPLAYCONFIG_SOURCE_DEVICE_NAME>() as u32;
src.header.adapterId = p.sourceInfo.adapterId;
src.header.id = p.sourceInfo.id;
if DisplayConfigGetDeviceInfo(&mut src.header) == 0 {
let name = String::from_utf16_lossy(&src.viewGdiDeviceName);
return Some(name.trim_end_matches('\u{0}').to_string());
}
}
}
None
}
/// The virtual display's CURRENT active resolution `(width, height)` via the GDI/CCD API, or `None` if the
/// target isn't an active display yet / the query fails. The IDD-push capturer sizes its ring to this
/// ACTUAL mode and polls it to recreate the ring when it changes — a fullscreen game can change the
/// virtual display's mode out from under the session-negotiated one (game-capture bug GB1).
///
/// # Safety
/// Calls the GDI/CCD APIs; safe to call from any thread.
pub(crate) unsafe fn active_resolution(target_id: u32) -> Option<(u32, u32)> {
let gdi = resolve_gdi_name(target_id)?;
let wname: Vec<u16> = gdi.encode_utf16().chain(std::iter::once(0)).collect();
let mut dm = DEVMODEW {
dmSize: size_of::<DEVMODEW>() as u16,
..Default::default()
};
let ok = EnumDisplaySettingsW(PCWSTR(wname.as_ptr()), ENUM_CURRENT_SETTINGS, &mut dm).as_bool();
if !ok || dm.dmPelsWidth == 0 || dm.dmPelsHeight == 0 {
return None;
}
Some((dm.dmPelsWidth, dm.dmPelsHeight))
}
/// Toggle the SudoVDA target's advanced-color (HDR) state via the CCD API. Disabling HDR while on the
/// secure (Winlogon) desktop makes it render SDR/composed so DXGI Desktop Duplication can capture it
/// (the HDR fullscreen independent-flip otherwise storms `ACCESS_LOST` → black); re-enable on return so
/// WGC keeps HDR on the normal desktop. Returns true on a successful `DisplayConfigSetDeviceInfo`.
pub(crate) unsafe fn set_advanced_color(target_id: u32, enable: bool) -> bool {
let mut np = 0u32;
let mut nm = 0u32;
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() {
return false;
}
let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize];
let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize];
if QueryDisplayConfig(
QDC_ONLY_ACTIVE_PATHS,
&mut np,
paths.as_mut_ptr(),
&mut nm,
modes.as_mut_ptr(),
None,
)
.is_err()
{
return false;
}
for p in paths.iter().take(np as usize) {
if p.targetInfo.id == target_id {
let mut s = DISPLAYCONFIG_SET_ADVANCED_COLOR_STATE::default();
s.header.r#type = DISPLAYCONFIG_DEVICE_INFO_SET_ADVANCED_COLOR_STATE;
s.header.size = size_of::<DISPLAYCONFIG_SET_ADVANCED_COLOR_STATE>() as u32;
s.header.adapterId = p.targetInfo.adapterId;
s.header.id = p.targetInfo.id;
s.Anonymous.value = enable as u32; // bit 0 = enableAdvancedColor
let rc = DisplayConfigSetDeviceInfo(&s.header);
tracing::info!(
target_id,
enable,
rc,
"SudoVDA set advanced-color (HDR) state"
);
return rc == 0;
}
}
tracing::warn!(
target_id,
"set_advanced_color: target not found in active paths"
);
false
}
/// Read the SudoVDA target's CURRENT advanced-color (HDR) state via the CCD API — i.e. whether HDR is
/// actually ON for the virtual display right now (e.g. because the user toggled it in Windows display
/// settings). The capture/encode pipeline follows the monitor's real colorspace (WGC → FP16 → NVENC
/// Main10 BT.2020 PQ), so this is the authoritative "is this an HDR session" signal — NOT the
/// handshake-negotiated bit depth. Returns false if the target isn't found / the query fails.
pub(crate) unsafe fn advanced_color_enabled(target_id: u32) -> bool {
let mut np = 0u32;
let mut nm = 0u32;
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() {
return false;
}
let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize];
let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize];
if QueryDisplayConfig(
QDC_ONLY_ACTIVE_PATHS,
&mut np,
paths.as_mut_ptr(),
&mut nm,
modes.as_mut_ptr(),
None,
)
.is_err()
{
return false;
}
for p in paths.iter().take(np as usize) {
if p.targetInfo.id == target_id {
let mut info = DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO::default();
info.header.r#type = DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO;
info.header.size = size_of::<DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO>() as u32;
info.header.adapterId = p.targetInfo.adapterId;
info.header.id = p.targetInfo.id;
if DisplayConfigGetDeviceInfo(&mut info.header) == 0 {
// value bit 1 = advancedColorEnabled (bit 0 = advancedColorSupported).
return (info.Anonymous.value & 0x2) != 0;
}
return false;
}
}
false
}
/// Force the freshly-added SudoVDA monitor to the client's exact `WxH@Hz`. The ADD IOCTL only
/// ADVERTISES the mode; Windows otherwise activates an IDD target at a 1280x720 default, so the
/// ACTIVE mode (what DXGI Desktop Duplication captures) must be set explicitly. CDS_TEST first so a
/// mode the driver didn't advertise just leaves the default instead of erroring the session.
// pub(crate) so vdisplay::pf_vdisplay can reuse this backend-neutral CCD/GDI mode-set helper
// (a pf-vdisplay monitor's GDI name is a real OS device name, so it works unchanged).
pub(crate) fn set_active_mode(gdi_name: &str, mode: Mode) {
let wname: Vec<u16> = gdi_name.encode_utf16().chain(std::iter::once(0)).collect();
// Enumerate the modes the driver actually advertises for this output and pick the best match for
// the requested RESOLUTION: the exact refresh if present, else the highest advertised refresh
// <= requested, else the highest available at that resolution. The SudoVDA ADD IOCTL advertises
// the client mode, but a very high pixel rate (e.g. 5120x1440@240 = 1.77 Gpix/s) can be clamped
// or absent — falling back to a lower refresh AT THE SAME RESOLUTION keeps the client's
// resolution (what the user sees) instead of collapsing to the 1280x720/1920x1080 OS default.
let mut at_res: Vec<u32> = Vec::new();
let mut res_set: std::collections::BTreeSet<(u32, u32)> = std::collections::BTreeSet::new();
let mut i = 0u32;
loop {
let mut dm = DEVMODEW {
dmSize: size_of::<DEVMODEW>() as u16,
..Default::default()
};
// SAFETY: `wname` is a live NUL-terminated UTF-16 device name (built above) whose pointer stays
// valid for the call; `&mut dm` is a live DEVMODEW with `dmSize` set that EnumDisplaySettingsW
// fills in for mode index `i`. Both outlive this synchronous call; the API only reads the name
// and writes `dm`, so nothing aliases.
let ok = unsafe {
EnumDisplaySettingsW(
PCWSTR(wname.as_ptr()),
ENUM_DISPLAY_SETTINGS_MODE(i),
&mut dm,
)
}
.as_bool();
if !ok {
break;
}
i += 1;
res_set.insert((dm.dmPelsWidth, dm.dmPelsHeight));
if dm.dmPelsWidth == mode.width && dm.dmPelsHeight == mode.height {
at_res.push(dm.dmDisplayFrequency);
}
}
let chosen_hz = if at_res.contains(&mode.refresh_hz) {
mode.refresh_hz
} else if let Some(hz) = at_res
.iter()
.copied()
.filter(|&hz| hz <= mode.refresh_hz)
.max()
{
hz
} else if let Some(hz) = at_res.iter().copied().max() {
hz
} else {
mode.refresh_hz // resolution not advertised at all; attempt anyway (likely -> OS default)
};
if at_res.is_empty() {
tracing::warn!(
"{gdi_name}: driver advertises no {}x{} mode (top advertised: {:?}); attempting @{} anyway",
mode.width,
mode.height,
res_set.iter().rev().take(8).collect::<Vec<_>>(),
mode.refresh_hz
);
} else if chosen_hz != mode.refresh_hz {
tracing::info!(
"{gdi_name}: {}x{}@{} not advertised; using {}x{}@{} (advertised refreshes here: {:?})",
mode.width,
mode.height,
mode.refresh_hz,
mode.width,
mode.height,
chosen_hz,
at_res
);
}
// Set ONLY this output's mode in place (size/refresh/bpp; NO DM_POSITION). Do NOT promote it to
// PRIMARY here and do NOT write a GLOBAL topology: promoting the IDD to primary at (0,0) while the
// box's leftover basic display is still active contests the topology and storms
// DXGI_ERROR_MODE_CHANGE_IN_PROGRESS (measured live). The IDD is made the sole → primary →
// DWM-composited display by the CCD isolation in create() (which deactivates the other display
// first), so a sole display is already primary and needs no CDS_SET_PRIMARY here.
let dm = DEVMODEW {
dmSize: size_of::<DEVMODEW>() as u16,
dmFields: DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY | DM_BITSPERPEL,
dmBitsPerPel: 32,
dmPelsWidth: mode.width,
dmPelsHeight: mode.height,
dmDisplayFrequency: chosen_hz,
..Default::default()
};
// SAFETY: `wname` is a live NUL-terminated UTF-16 device name and `&dm` is a live DEVMODEW describing
// the requested mode; both outlive the call. CDS_TEST only validates the mode (no apply), the two
// trailing args are null, and the API only reads its inputs.
let test = unsafe {
ChangeDisplaySettingsExW(PCWSTR(wname.as_ptr()), Some(&dm), None, CDS_TEST, None)
};
if test != DISP_CHANGE_SUCCESSFUL {
tracing::warn!(
result = test.0,
"{gdi_name}: driver rejected {}x{}@{} (mode not advertised?) — leaving OS default",
mode.width,
mode.height,
chosen_hz
);
return;
}
// SAFETY: same inputs as the CDS_TEST call above — `wname` (live NUL-terminated device name) and
// `&dm` (live DEVMODEW) both outlive the call; CDS_UPDATEREGISTRY applies the already-validated mode,
// and the API only reads its inputs.
let apply = unsafe {
ChangeDisplaySettingsExW(
PCWSTR(wname.as_ptr()),
Some(&dm),
None,
CDS_UPDATEREGISTRY,
None,
)
};
if apply == DISP_CHANGE_SUCCESSFUL {
tracing::info!(
"{gdi_name}: active mode set to {}x{}@{}",
mode.width,
mode.height,
chosen_hz
);
} else {
tracing::warn!(
result = apply.0,
"{gdi_name}: failed to apply {}x{}@{}",
mode.width,
mode.height,
chosen_hz
);
}
}
/// Saved active display topology, for restoring on teardown.
// pub(crate) so vdisplay::pf_vdisplay's Monitor can hold the same saved-topology type.
pub(crate) type SavedConfig = (Vec<DISPLAYCONFIG_PATH_INFO>, Vec<DISPLAYCONFIG_MODE_INFO>);
/// `DISPLAYCONFIG_PATH_ACTIVE` (wingdi.h) — the `flags` bit marking a path active. The `windows` crate
/// doesn't export it, so define it here.
const DISPLAYCONFIG_PATH_ACTIVE: u32 = 0x0000_0001;
/// Robust display isolation via the CCD API. The naive GDI approach (EnumDisplayDevices +
/// ChangeDisplaySettings) MISSES displays on a hybrid box — an iGPU-attached physical monitor isn't
/// flagged `ATTACHED_TO_DESKTOP` in the GDI enum, so it's never detached and the secure desktop /
/// lock screen lands on IT while our virtual output freezes. `QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS)`
/// sees every active path; we deactivate all of them EXCEPT the SudoVDA target's, leaving the virtual
/// display as the sole desktop so ALL content (incl. Winlogon) renders to it. Apollo isolates the same
/// way (CCD). Returns the original active config to restore on teardown.
// pub(crate) so vdisplay::pf_vdisplay can reuse this backend-neutral CCD isolation helper
// (it operates on a real OS target id — a pf-vdisplay monitor's target_id qualifies).
pub(crate) unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option<SavedConfig> {
let mut np = 0u32;
let mut nm = 0u32;
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() {
return None;
}
let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize];
let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize];
if QueryDisplayConfig(
QDC_ONLY_ACTIVE_PATHS,
&mut np,
paths.as_mut_ptr(),
&mut nm,
modes.as_mut_ptr(),
None,
)
.is_err()
{
return None;
}
paths.truncate(np as usize);
modes.truncate(nm as usize);
let saved = (paths.clone(), modes.clone());
let mut others = 0u32;
for p in paths.iter_mut() {
if p.targetInfo.id == keep_target_id {
continue;
}
if p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0 {
p.flags &= !DISPLAYCONFIG_PATH_ACTIVE; // mark this path inactive
others += 1;
}
}
if others == 0 {
// The virtual path shows active in the CCD database (from set_active_mode's legacy
// ChangeDisplaySettingsExW), but a legacy mode-set does NOT drive the IddCx adapter's
// EVT_IDD_CX_ADAPTER_COMMIT_MODES — and without COMMIT_MODES the OS never calls
// ASSIGN_SWAPCHAIN, so the driver never receives composed frames. Force an explicit CCD
// SetDisplayConfig commit of the (sole) virtual path so the IddCx path actually activates.
// SDC_FORCE_MODE_ENUMERATION makes the OS re-enumerate + re-commit even though the CCD DB
// already lists the path active.
let rc = SetDisplayConfig(
Some(paths.as_slice()),
Some(modes.as_slice()),
SDC_APPLY
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
| SDC_ALLOW_CHANGES
| SDC_SAVE_TO_DATABASE
| SDC_FORCE_MODE_ENUMERATION,
);
tracing::info!("display isolate (CCD): forced CCD re-commit of sole virtual path {keep_target_id} rc={rc:#x} (drives IddCx COMMIT_MODES → ASSIGN_SWAPCHAIN)");
return Some(saved);
}
let rc = SetDisplayConfig(
Some(paths.as_slice()),
Some(modes.as_slice()),
SDC_APPLY
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
| SDC_ALLOW_CHANGES
| SDC_FORCE_MODE_ENUMERATION,
);
if rc == 0 {
tracing::info!("display isolate (CCD): deactivated {others} other display(s) — SudoVDA target {keep_target_id} is now the sole desktop");
} else {
tracing::warn!("display isolate (CCD): SetDisplayConfig failed rc={rc:#x} (tried to deactivate {others} path(s))");
}
Some(saved)
}
/// Restore the topology saved by [`isolate_displays_ccd`] (teardown, before the virtual output is
/// removed), re-activating the displays we deactivated.
// pub(crate) so vdisplay::pf_vdisplay can reuse this backend-neutral CCD restore helper.
pub(crate) unsafe fn restore_displays_ccd(saved: &SavedConfig) {
let (paths, modes) = saved;
if paths.is_empty() {
return;
}
let rc = SetDisplayConfig(
Some(paths.as_slice()),
Some(modes.as_slice()),
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES,
);
tracing::info!("display isolate (CCD): restored original topology rc={rc:#x}");
}
+14 -84
View File
@@ -11,8 +11,8 @@
"@tanstack/react-start": "^1.121.0", "@tanstack/react-start": "^1.121.0",
"@unom/style": "^0.4.4", "@unom/style": "^0.4.4",
"@unom/ui": "^0.8.16", "@unom/ui": "^0.8.16",
"fumadocs-core": "^16.10.1", "fumadocs-core": "^16.10.5",
"fumadocs-ui": "^16.10.1", "fumadocs-ui": "^16.10.5",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^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-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=="], "@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-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=="], "@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-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=="], "@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-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-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-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=="], "@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-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=="], "@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-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-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=="], "@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=="], "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-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=="], "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=="], "@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-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=="], "@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=="], "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=="], "h3/cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="],
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "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=="], "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/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=="], "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=="], "@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=="], "@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=="], "archiver-utils/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
+2 -2
View File
@@ -16,8 +16,8 @@
"@tanstack/react-start": "^1.121.0", "@tanstack/react-start": "^1.121.0",
"@unom/style": "^0.4.4", "@unom/style": "^0.4.4",
"@unom/ui": "^0.8.16", "@unom/ui": "^0.8.16",
"fumadocs-core": "^16.10.1", "fumadocs-core": "^16.10.5",
"fumadocs-ui": "^16.10.1", "fumadocs-ui": "^16.10.5",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
+18 -10
View File
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { createFileRoute, Link } from '@tanstack/react-router' import { createFileRoute, Link } from '@tanstack/react-router'
import { ApiReferenceReact } from '@scalar/api-reference-react' 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 // @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 // (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 // reference renders unstyled. Load it as a route-scoped <link> (same pattern as
@@ -148,15 +147,24 @@ body.light-mode {
` `
function ApiReference() { function ApiReference() {
// Follow the docs' own light/dark switch (Fumadocs drives next-themes). Scalar // Follow the docs' own light/dark switch and hide Scalar's own toggle, so the
// has no way to auto-detect the host theme, so we feed it the resolved theme // Fumadocs toggle stays the single source of truth. Fumadocs drives next-themes
// and hide its own toggle — the Fumadocs toggle stays the single source of // with `attribute: "class"`, which writes the resolved theme as a class on
// truth. `mounted` avoids a hydration flash (resolvedTheme is undefined on the // <html> — we read THAT class directly rather than next-themes' useTheme().
// server); default to dark to match the docs' default. // The class is the authoritative, already-resolved signal (system → light/dark
const { resolvedTheme } = useTheme() // included) and, unlike the React context, can't be desynced when bridging into
const [mounted, setMounted] = useState(false) // Scalar's separate Vue app. Default to dark (the docs default) so SSR and the
useEffect(() => setMounted(true), []) // first client render agree — no hydration flash; the observer then syncs to the
const isDark = !mounted || resolvedTheme !== 'light' // 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 // Scalar pollutes global scope and never cleans up: it appends a persistent
// <style id="scalar-style"> to <head> that includes a *global* // <style id="scalar-style"> to <head> that includes a *global*
+377
View File
@@ -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.
+430
View File
@@ -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 4050 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` 1317 | 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`→4050 | 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`→4050 ⇒ **(b) source ceiling** — the compositor/IDD only produced
4050 unique frames. No encode/priority fix exceeds that number. Go to §5.F.
- both `fps` and `uniq`→4050, with `encode_ms` 1317 ⇒ **(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 (≈48); 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 +12 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
([100200 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 25 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 1317 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.
+9
View File
@@ -1,5 +1,14 @@
# Host latency & the GPU-contention collapse — analysis + prioritized plan # 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 Scope: Windows + Linux GameStream/punktfunk1 hosts. Priority: **latency**, and specifically the
"saturating game starves the stream" headache: "saturating game starves the stream" headache:
File diff suppressed because it is too large Load Diff
+30 -4
View File
@@ -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. | | `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-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. | | `../../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/`. | | `pf-vdisplay/` | **Vendored** signed pf-vdisplay driver: `pf_vdisplay.inf` / `pf_vdisplay.cat` / `pf_vdisplay.dll` / `punktfunk-driver.cer`. Built from `drivers/`. |
| `vdisplay-driver/` | The all-Rust IddCx **driver source** (`pf-vdisplay` crate + vendored `wdf-umdf*` bindings) + `deploy-dev.ps1` (build/sign/install for dev). | | `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). | | `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 > **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 > [`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 > **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`) > `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 > 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 > 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 > leaves persistent phantom devices) **is** fetched + SHA-256-verified from its pinned release in
> `stage-pf-vdisplay.ps1`. > `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) ## Build locally (Windows, MSVC + Windows SDK + Inno Setup)
```powershell ```powershell
+3 -3
View File
@@ -398,7 +398,7 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
name = "pf-vdisplay" name = "pf-vdisplay"
version = "0.0.1" version = "0.0.1"
dependencies = [ dependencies = [
"pf-vdisplay-proto", "pf-driver-proto",
"thiserror", "thiserror",
"wdk", "wdk",
"wdk-build", "wdk-build",
@@ -408,7 +408,7 @@ dependencies = [
] ]
[[package]] [[package]]
name = "pf-vdisplay-proto" name = "pf-driver-proto"
version = "0.0.1" version = "0.0.1"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
@@ -776,7 +776,7 @@ dependencies = [
name = "wdk-probe" name = "wdk-probe"
version = "0.0.1" version = "0.0.1"
dependencies = [ dependencies = [
"pf-vdisplay-proto", "pf-driver-proto",
"wdk", "wdk",
"wdk-build", "wdk-build",
"wdk-sys", "wdk-sys",
+2 -2
View File
@@ -4,7 +4,7 @@
# #
# Separate from the main cargo workspace (own [workspace] root) because driver crates are cdylibs built # 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 # 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] [workspace]
resolver = "2" resolver = "2"
members = ["wdk-probe", "wdk-iddcx", "pf-vdisplay"] members = ["wdk-probe", "wdk-iddcx", "pf-vdisplay"]
@@ -20,7 +20,7 @@ wdk = "0.4.1"
wdk-sys = "0.5.1" wdk-sys = "0.5.1"
wdk-build = "0.5.1" wdk-build = "0.5.1"
wdk-iddcx = { path = "wdk-iddcx" } 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` # 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 # ApiSubset (M1 — bindgens iddcx/1.10/IddCx.h reusing wdk_default for WDF type-identity). Redirect ALL
+80
View File
@@ -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 # 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). # (deleted once on-glass parity is reached, per docs/windows-host-rewrite.md §14 STEP 8).
[package] [package]
name = "pf-vdisplay" name = "pf-vdisplay"
@@ -23,7 +23,7 @@ wdk-build.workspace = true
wdk.workspace = true wdk.workspace = true
wdk-sys = { workspace = true, features = ["iddcx"] } wdk-sys = { workspace = true, features = ["iddcx"] }
wdk-iddcx.workspace = true 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 # 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 # 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. # 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). ; 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 ; 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 ; 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, ; 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). ; 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. // 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` // `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). // 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; let mut version = pod_init!(iddcx::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() };
version.Size = core::mem::size_of::<iddcx::IDDCX_ENDPOINT_VERSION>() as u32; version.Size = core::mem::size_of::<iddcx::IDDCX_ENDPOINT_VERSION>() as u32;
version.MajorVer = env!("CARGO_PKG_VERSION_MAJOR").parse().unwrap_or(0); version.MajorVer = env!("CARGO_PKG_VERSION_MAJOR").parse().unwrap_or(0);
version.MinorVer = env!("CARGO_PKG_VERSION_MINOR").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 // 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 // rejects with INVALID_PARAMETER (ddivalidation.cpp:797) — set it to NONE (1) like upstream. THIS was
// the on-glass adapter-init blocker. // the on-glass adapter-init blocker.
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized let mut diag = pod_init!(iddcx::IDDCX_ENDPOINT_DIAGNOSTIC_INFO);
// 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() };
diag.Size = core::mem::size_of::<iddcx::IDDCX_ENDPOINT_DIAGNOSTIC_INFO>() as u32; diag.Size = core::mem::size_of::<iddcx::IDDCX_ENDPOINT_DIAGNOSTIC_INFO>() as u32;
diag.GammaSupport = iddcx::IDDCX_FEATURE_IMPLEMENTATION::IDDCX_FEATURE_IMPLEMENTATION_NONE; diag.GammaSupport = iddcx::IDDCX_FEATURE_IMPLEMENTATION::IDDCX_FEATURE_IMPLEMENTATION_NONE;
diag.TransmissionType = iddcx::IDDCX_TRANSMISSION_TYPE::IDDCX_TRANSMISSION_TYPE_WIRED_OTHER; 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.pFirmwareVersion = (&raw mut version).cast();
diag.pHardwareVersion = (&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; let mut caps = pod_init!(iddcx::IDDCX_ADAPTER_CAPS);
// the required `.Size` (+ flags/limits/diag) are set immediately below.
let mut caps: iddcx::IDDCX_ADAPTER_CAPS = unsafe { core::mem::zeroed() };
caps.Size = core::mem::size_of::<iddcx::IDDCX_ADAPTER_CAPS>() as u32; 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 // 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 // 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, // 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). // 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; let mut attr = pod_init!(wdk_sys::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() };
attr.Size = core::mem::size_of::<wdk_sys::WDF_OBJECT_ATTRIBUTES>() as u32; attr.Size = core::mem::size_of::<wdk_sys::WDF_OBJECT_ATTRIBUTES>() as u32;
attr.ExecutionLevel = wdk_sys::_WDF_EXECUTION_LEVEL::WdfExecutionLevelInheritFromParent; attr.ExecutionLevel = wdk_sys::_WDF_EXECUTION_LEVEL::WdfExecutionLevelInheritFromParent;
attr.SynchronizationScope = attr.SynchronizationScope =
@@ -122,9 +114,7 @@ pub fn init_adapter(device: WDFDEVICE) -> NTSTATUS {
pCaps: &raw mut caps, pCaps: &raw mut caps,
ObjectAttributes: &raw mut attr, ObjectAttributes: &raw mut attr,
}; };
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized IDARG_OUT_ADAPTER_INIT let mut out = pod_init!(iddcx::IDARG_OUT_ADAPTER_INIT);
// (an out-param the framework fills).
let mut out: iddcx::IDARG_OUT_ADAPTER_INIT = unsafe { core::mem::zeroed() };
// SAFETY: `init`/`out` are valid local storage; IddCxAdapterInitAsync reads the caps synchronously // 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. // (the adapter object itself is delivered later via adapter_init_finished). Called once per device.
let st = unsafe { wdk_iddcx::IddCxAdapterInitAsync(&init, &mut out) }; let st = unsafe { wdk_iddcx::IddCxAdapterInitAsync(&init, &mut out) };
@@ -142,3 +132,24 @@ pub fn set_adapter(adapter: iddcx::IDDCX_ADAPTER) {
pub(crate) fn adapter() -> Option<iddcx::IDDCX_ADAPTER> { pub(crate) fn adapter() -> Option<iddcx::IDDCX_ADAPTER> {
ADAPTER.get().map(|a| a.0) ADAPTER.get().map(|a| a.0)
} }
/// Honor the host's `IOCTL_SET_RENDER_ADAPTER`: pin the GPU the IddCx swap-chain renders on. On a hybrid
/// iGPU+dGPU box the OS may otherwise pick the iGPU to render the virtual monitor, so the host's shared
/// ring textures (created on the NVENC dGPU) can't be opened → `DRV_STATUS_TEX_FAIL` → the host's 20 s
/// black bail. Pinning the render adapter to the encode GPU fixes that. Unconditional — NOT the
/// SudoVDA-parity default-off branch (`docs/windows-host-rewrite.md` §2.8). Returns
/// `STATUS_NOT_FOUND` if called before the adapter exists.
pub fn set_render_adapter(luid_low: u32, luid_high: i32) -> NTSTATUS {
let Some(adapter) = adapter() else {
return crate::STATUS_NOT_FOUND;
};
let mut in_args = pod_init!(iddcx::IDARG_IN_ADAPTERSETRENDERADAPTER);
in_args.PreferredRenderAdapter = wdk_sys::LUID {
LowPart: luid_low,
HighPart: luid_high,
};
dbglog!("[pf-vd] set_render_adapter -> {luid_high:08x}:{luid_low:08x}");
// SAFETY: `adapter` is the stashed IddCx adapter; `in_args` is valid local storage read synchronously.
unsafe { wdk_iddcx::IddCxAdapterSetRenderAdapter(adapter, &in_args) };
STATUS_SUCCESS
}
@@ -33,9 +33,19 @@ pub unsafe extern "C" fn adapter_init_finished(
) -> NTSTATUS { ) -> NTSTATUS {
dbglog!("[pf-vd] adapter_init_finished"); dbglog!("[pf-vd] adapter_init_finished");
crate::adapter::set_adapter(adapter); crate::adapter::set_adapter(adapter);
crate::control::start_watchdog();
STATUS_SUCCESS 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`. /// SDR mode list for an EDID monitor: EDID-serial lookup → count-then-fill `IDDCX_MONITOR_MODE`.
pub unsafe extern "C" fn parse_monitor_description( pub unsafe extern "C" fn parse_monitor_description(
p_in: *const iddcx::IDARG_IN_PARSEMONITORDESCRIPTION, p_in: *const iddcx::IDARG_IN_PARSEMONITORDESCRIPTION,
@@ -70,9 +80,7 @@ pub unsafe extern "C" fn parse_monitor_description(
// SAFETY: `pMonitorModes` points to >= `count` IDDCX_MONITOR_MODE entries (validated above). // 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) }; 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()) { 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; let mut mode = pod_init!(iddcx::IDDCX_MONITOR_MODE);
// the required `.Size` (+ origin / signal info) are set immediately below.
let mut mode: iddcx::IDDCX_MONITOR_MODE = unsafe { core::mem::zeroed() };
mode.Size = core::mem::size_of::<iddcx::IDDCX_MONITOR_MODE>() as u32; 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.Origin = iddcx::IDDCX_MONITOR_MODE_ORIGIN::IDDCX_MONITOR_MODE_ORIGIN_MONITORDESCRIPTOR;
mode.MonitorVideoSignalInfo = mode.MonitorVideoSignalInfo =
@@ -121,9 +129,7 @@ pub unsafe extern "C" fn parse_monitor_description2(
// SAFETY: `pMonitorModes` points to >= `count` IDDCX_MONITOR_MODE2 entries (validated above). // 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) }; 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()) { 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; let mut mode = pod_init!(iddcx::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() };
mode.Size = core::mem::size_of::<iddcx::IDDCX_MONITOR_MODE2>() as u32; 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.Origin = iddcx::IDDCX_MONITOR_MODE_ORIGIN::IDDCX_MONITOR_MODE_ORIGIN_MONITORDESCRIPTOR;
mode.MonitorVideoSignalInfo = mode.MonitorVideoSignalInfo =
@@ -219,7 +225,7 @@ pub unsafe extern "C" fn query_target_info(
) -> NTSTATUS { ) -> NTSTATUS {
// SAFETY: p_out is the framework's (uninitialised) out buffer; zero then set the one field we report. // SAFETY: p_out is the framework's (uninitialised) out buffer; zero then set the one field we report.
unsafe { 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; (*p_out).TargetCaps = iddcx::IDDCX_TARGET_CAPS::IDDCX_TARGET_CAPS_HIGH_COLOR_SPACE;
} }
STATUS_SUCCESS STATUS_SUCCESS
@@ -317,7 +323,7 @@ pub unsafe extern "C" fn unassign_swap_chain(monitor: iddcx::IDDCX_MONITOR) -> N
STATUS_SUCCESS 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. /// `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( pub unsafe extern "C" fn device_io_control(
_device: WDFDEVICE, _device: WDFDEVICE,
@@ -1,40 +1,88 @@
//! The `pf-vdisplay-proto` control plane (`EvtIddCxDeviceIoControl`). The host opens the device interface //! The `pf-driver-proto` control plane (`EvtIddCxDeviceIoControl`). The host opens the device interface
//! (`PF_VDISPLAY_INTERFACE_GUID`) and drives the low-frequency IOCTLs: GET_INFO (version handshake), PING //! (`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 //! (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 `()`). //! path completes the `WDFREQUEST` exactly once (the `EVT_IDD_CX_DEVICE_IO_CONTROL` shape returns `()`).
use core::sync::atomic::{AtomicU64, Ordering}; use core::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, Instant};
use pf_vdisplay_proto::control; use pf_driver_proto::control;
use wdk_iddcx::nt_success; use wdk_iddcx::nt_success;
use wdk_sys::{NTSTATUS, WDFREQUEST, call_unsafe_wdf_function_binding}; use wdk_sys::{NTSTATUS, WDFREQUEST, call_unsafe_wdf_function_binding};
use crate::{STATUS_INVALID_PARAMETER, STATUS_NOT_FOUND, STATUS_NOT_IMPLEMENTED, STATUS_SUCCESS}; use crate::{STATUS_INVALID_PARAMETER, STATUS_NOT_FOUND, STATUS_SUCCESS};
/// The host must PING within this window or the watchdog reaps all monitors (STEP 4: the watchdog thread). /// The host must send an IOCTL within this window (it PINGs on a `timeout/3` timer) or the watchdog
/// treats it as gone and reaps every monitor. Reported to the host via [`control::IOCTL_GET_INFO`].
const WATCHDOG_TIMEOUT_S: u32 = 10; const WATCHDOG_TIMEOUT_S: u32 = 10;
/// Keepalive counter — PING bumps it; STEP 4's watchdog thread samples it to detect a gone host. /// Host-liveness counter — EVERY inbound IOCTL bumps it; [`start_watchdog`]'s thread samples it.
static WATCHDOG_PINGS: AtomicU64 = AtomicU64::new(0); static WATCHDOG_PINGS: AtomicU64 = AtomicU64::new(0);
/// Spawns the watchdog thread exactly once (idempotent across re-entrant adapter inits).
static WATCHDOG_STARTED: AtomicBool = AtomicBool::new(false);
/// Start the host-liveness watchdog (once, from `adapter_init_finished`).
///
/// Previously [`WATCHDOG_PINGS`] was bumped but NEVER sampled (no thread existed) — so a host that died
/// without a cooperative REMOVE (crash / `TerminateProcess`) left its virtual monitor + swap-chain
/// worker + pooled D3D device wedged in WUDFHost until the next host start's CLEAR_ALL, and a
/// not-restarted host left the orphan monitor in the desktop topology indefinitely
/// (`docs/windows-host-rewrite.md` §2.8). This thread closes that: if no IOCTL arrives for
/// `WATCHDOG_TIMEOUT_S` while monitors exist, it departs them all.
///
/// (A WDF `EvtFileClose` on the control handle would be more immediate — the plan's preferred §3.4
/// option — but the polling watchdog matches the proven oracle and needs no IddCx file-object plumbing.)
pub fn start_watchdog() {
if WATCHDOG_STARTED.swap(true, Ordering::SeqCst) {
return;
}
let tick = Duration::from_secs(u64::from((WATCHDOG_TIMEOUT_S / 3).max(1)));
let timeout = Duration::from_secs(u64::from(WATCHDOG_TIMEOUT_S));
std::thread::spawn(move || {
let mut last = WATCHDOG_PINGS.load(Ordering::Relaxed);
let mut last_change = Instant::now();
loop {
std::thread::sleep(tick);
let cur = WATCHDOG_PINGS.load(Ordering::Relaxed);
if cur != last {
last = cur;
last_change = Instant::now();
continue;
}
// No IOCTL since `last_change`. A live host PINGs every `timeout/3`, so this only trips once
// the host is truly gone; only reap when there's something to reap.
if last_change.elapsed() >= timeout && crate::monitor::has_monitors() {
let n = crate::monitor::reap_orphaned(Duration::from_secs(3));
if n > 0 {
dbglog!(
"[pf-vd] watchdog: no host IOCTL in {WATCHDOG_TIMEOUT_S}s — host gone, departed {n} monitor(s)"
);
}
last_change = Instant::now(); // don't re-reap every tick
}
}
});
}
/// Dispatch one control IOCTL and complete the request. /// Dispatch one control IOCTL and complete the request.
/// ///
/// # Safety /// # Safety
/// `request` is the framework-provided `WDFREQUEST` for an `EvtIddCxDeviceIoControl` call. /// `request` is the framework-provided `WDFREQUEST` for an `EvtIddCxDeviceIoControl` call.
pub unsafe fn dispatch(request: WDFREQUEST, ioctl_code: u32) { pub unsafe fn dispatch(request: WDFREQUEST, ioctl_code: u32) {
// Every inbound IOCTL is host liveness (the host PINGs on a timer, plus ADD/REMOVE/GET_INFO/…) —
// bump the watchdog at the top so it only fires once the host has gone truly silent. See
// [`start_watchdog`].
WATCHDOG_PINGS.fetch_add(1, Ordering::Relaxed);
match ioctl_code { match ioctl_code {
control::IOCTL_GET_INFO => { control::IOCTL_GET_INFO => {
let reply = control::InfoReply { let reply = control::InfoReply {
protocol_version: pf_vdisplay_proto::PROTOCOL_VERSION, protocol_version: pf_driver_proto::PROTOCOL_VERSION,
watchdog_timeout_s: WATCHDOG_TIMEOUT_S, watchdog_timeout_s: WATCHDOG_TIMEOUT_S,
}; };
// SAFETY: `request` is the framework WDFREQUEST. // SAFETY: `request` is the framework WDFREQUEST.
unsafe { write_output_complete(request, &reply) }; unsafe { write_output_complete(request, &reply) };
} }
control::IOCTL_PING => { control::IOCTL_PING => complete(request, STATUS_SUCCESS),
WATCHDOG_PINGS.fetch_add(1, Ordering::Relaxed);
complete(request, STATUS_SUCCESS);
}
// SAFETY: `request` is the framework WDFREQUEST. // SAFETY: `request` is the framework WDFREQUEST.
control::IOCTL_ADD => unsafe { add(request) }, control::IOCTL_ADD => unsafe { add(request) },
// SAFETY: `request` is the framework WDFREQUEST. // SAFETY: `request` is the framework WDFREQUEST.
@@ -43,12 +91,34 @@ pub unsafe fn dispatch(request: WDFREQUEST, ioctl_code: u32) {
crate::monitor::clear_all(); crate::monitor::clear_all();
complete(request, STATUS_SUCCESS); complete(request, STATUS_SUCCESS);
} }
// SET_RENDER_ADAPTER (hybrid-GPU render pin): STEP 4 (next). // SAFETY: `request` is the framework WDFREQUEST.
control::IOCTL_SET_RENDER_ADAPTER => complete(request, STATUS_NOT_IMPLEMENTED), control::IOCTL_SET_RENDER_ADAPTER => unsafe { set_render_adapter(request) },
_ => complete(request, STATUS_NOT_FOUND), _ => complete(request, STATUS_NOT_FOUND),
} }
} }
/// Sanity bounds for a requested mode — generous (covers any real client) but rejects zero/absurd
/// values that would otherwise feed the EDID/mode math unchecked.
fn valid_mode(width: u32, height: u32, refresh_hz: u32) -> bool {
(1..=16384).contains(&width)
&& (1..=16384).contains(&height)
&& (1..=1000).contains(&refresh_hz)
}
/// `IOCTL_SET_RENDER_ADAPTER`: pin the IddCx render adapter (hybrid-GPU IDD-push).
///
/// # Safety
/// `request` is the framework `WDFREQUEST`.
unsafe fn set_render_adapter(request: WDFREQUEST) {
// SAFETY: `request` is the framework WDFREQUEST.
let Some(req) = (unsafe { read_input::<control::SetRenderAdapterRequest>(request) }) else {
complete(request, STATUS_INVALID_PARAMETER);
return;
};
let st = crate::adapter::set_render_adapter(req.luid_low, req.luid_high);
complete(request, st);
}
/// `IOCTL_ADD`: create a virtual monitor at the requested mode → reply with the OS target id + LUID. /// `IOCTL_ADD`: create a virtual monitor at the requested mode → reply with the OS target id + LUID.
/// ///
/// # Safety /// # Safety
@@ -59,6 +129,10 @@ unsafe fn add(request: WDFREQUEST) {
complete(request, STATUS_INVALID_PARAMETER); complete(request, STATUS_INVALID_PARAMETER);
return; return;
}; };
if !valid_mode(req.width, req.height, req.refresh_hz) {
complete(request, STATUS_INVALID_PARAMETER);
return;
}
let Some((target_id, luid_low, luid_high)) = let Some((target_id, luid_low, luid_high)) =
crate::monitor::create_monitor(req.session_id, req.width, req.height, req.refresh_hz) crate::monitor::create_monitor(req.session_id, req.width, req.height, req.refresh_hz)
else { else {
@@ -38,8 +38,7 @@ pub unsafe extern "system" fn driver_entry(
registry_path: PCUNICODE_STRING, registry_path: PCUNICODE_STRING,
) -> NTSTATUS { ) -> NTSTATUS {
dbglog!("[pf-vd] DriverEntry"); dbglog!("[pf-vd] DriverEntry");
// SAFETY: zeroed then Size + the device-add callback set, per the WDF_DRIVER_CONFIG contract. let mut config = pod_init!(WDF_DRIVER_CONFIG);
let mut config: WDF_DRIVER_CONFIG = unsafe { core::mem::zeroed() };
config.Size = core::mem::size_of::<WDF_DRIVER_CONFIG>() as ULONG; config.Size = core::mem::size_of::<WDF_DRIVER_CONFIG>() as ULONG;
config.EvtDriverDeviceAdd = Some(driver_add); config.EvtDriverDeviceAdd = Some(driver_add);
// SAFETY: driver + registry_path are loader-provided; config is valid for the call. // 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 { extern "C" fn driver_add(_driver: WDFDRIVER, mut init: PWDFDEVICE_INIT) -> NTSTATUS {
dbglog!("[pf-vd] driver_add"); dbglog!("[pf-vd] driver_add");
// Defer adapter creation to the first D0 entry. // Defer adapter creation to the first D0 entry.
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized let mut pnp = pod_init!(WDF_PNPPOWER_EVENT_CALLBACKS);
// 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() };
pnp.Size = core::mem::size_of::<WDF_PNPPOWER_EVENT_CALLBACKS>() as ULONG; pnp.Size = core::mem::size_of::<WDF_PNPPOWER_EVENT_CALLBACKS>() as ULONG;
pnp.EvtDeviceD0Entry = Some(callbacks::device_d0_entry); pnp.EvtDeviceD0Entry = Some(callbacks::device_d0_entry);
// SAFETY: init is the framework-provided device-init; pnp is valid for the call. // 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). // 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; let mut cfg = pod_init!(iddcx::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() };
cfg.Size = core::mem::size_of::<iddcx::IDD_CX_CLIENT_CONFIG>() as u32; cfg.Size = core::mem::size_of::<iddcx::IDD_CX_CLIENT_CONFIG>() as u32;
cfg.EvtIddCxAdapterInitFinished = Some(callbacks::adapter_init_finished); cfg.EvtIddCxAdapterInitFinished = Some(callbacks::adapter_init_finished);
cfg.EvtIddCxParseMonitorDescription = Some(callbacks::parse_monitor_description); 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(); 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. // 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; let mut dev_attr = pod_init!(wdk_sys::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() };
dev_attr.Size = core::mem::size_of::<wdk_sys::WDF_OBJECT_ATTRIBUTES>() as u32; 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.ExecutionLevel = wdk_sys::_WDF_EXECUTION_LEVEL::WdfExecutionLevelInheritFromParent;
dev_attr.SynchronizationScope = dev_attr.SynchronizationScope =
wdk_sys::_WDF_SYNCHRONIZATION_SCOPE::WdfSynchronizationScopeInheritFromParent; wdk_sys::_WDF_SYNCHRONIZATION_SCOPE::WdfSynchronizationScopeInheritFromParent;
dev_attr.ContextTypeInfo = &DEVICE_CTX.0; 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. // SAFETY: init configured above; dev_attr is a valid context-typed attributes block.
let status = unsafe { let status = unsafe {
call_unsafe_wdf_function_binding!(WdfDeviceCreate, &mut init, &mut dev_attr, &mut device) 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 // 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 // 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.) // 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 { let guid = GUID {
Data1: d1, Data1: d1,
Data2: d2, Data2: d2,
@@ -11,12 +11,12 @@
//! //!
//! Host counterpart: `crates/punktfunk-host/src/capture/idd_push.rs`. The shared `SharedHeader` layout, //! 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 //! 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). //! 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`). //! Ported from the proven oracle (`packaging/windows/vdisplay-driver/pf-vdisplay/src/frame_transport.rs`).
//! Differences from the oracle: //! 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!`; //! * `dbglog!` replaces `log::info!`;
//! * the optional fixed-name `Global\pfvd-dbg` `DebugBlock` bring-up channel is SKIPPED (not on the data //! * 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` //! 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 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, DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED, DRV_STATUS_TEX_FAIL, FrameToken, MAGIC, RING_LEN,
SharedHeader, event_name, header_name, texture_name, 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`] /// 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. /// detects that so `run_core` re-attaches to the new-format textures instead of dropping every frame.
generation: u32, 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. // 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. // SAFETY: `header` is the mapped host header; `dxgi_format` lives within it.
ring_format: unsafe { (*header).dxgi_format }, ring_format: unsafe { (*header).dxgi_format },
generation, generation,
mismatch_logged: false,
}) })
} }
@@ -281,9 +285,28 @@ impl FramePublisher {
let mut desc = D3D11_TEXTURE2D_DESC::default(); let mut desc = D3D11_TEXTURE2D_DESC::default();
// SAFETY: `surface` is a live ID3D11Texture2D (borrowed from IddCx); `desc` is a valid local out-param. // SAFETY: `surface` is a live ID3D11Texture2D (borrowed from IddCx); `desc` is a valid local out-param.
unsafe { surface.GetDesc(&mut desc) }; 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; return;
} }
self.mismatch_logged = false;
let start = self.next; let start = self.next;
for attempt in 0..ring_len { for attempt in 0..ring_len {
let slot = (start + attempt) % 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 //! 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` //! 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 //! (14 IddCx callbacks + the PnP `EvtDeviceD0Entry`, all stubs) sized via the versioned
@@ -9,6 +9,10 @@
//! control plane + monitor/modes (STEP 4), and swap-chain/IDD-push (STEP 5-6) fill the stubs in. //! control plane + monitor/modes (STEP 4), and swap-chain/IDD-push (STEP 5-6) fill the stubs in.
#![allow(non_snake_case, clippy::missing_safety_doc)] #![allow(non_snake_case, clippy::missing_safety_doc)]
// P0 lint (audit §8): an unsafe op inside an `unsafe fn` must be in an explicit `unsafe {}` block, so the
// fn-level `unsafe` never silently blesses the whole body. (The per-site `// SAFETY:` discipline already
// landed in STEP 8.)
#![deny(unsafe_op_in_unsafe_fn)]
#[macro_use] #[macro_use]
mod log; mod log;
@@ -1,26 +1,75 @@
//! Minimal driver logger (matches the DualSense driver). DebugView can't capture the UMDF host across //! Minimal driver logger. `OutputDebugStringA` always (ETW/DebugView); the optional world-writable file
//! session 0, so besides `OutputDebugStringA` we append to a world-writable file readable over SSH. Used //! (`C:\Users\Public\pfvd-driver.log`, readable over SSH) is now OPT-IN — debug builds, or the
//! only for bring-up/diagnostics; cheap and best-effort (ignores all errors). //! `PFVD_DEBUG_LOG` env var, only — so a RELEASE build never writes it (audit §4.4: it was an
//! info-leak/DoS surface). Best-effort; ignores all errors. Production driver-state visibility is the
//! SharedHeader `driver_status` channel, not this file.
unsafe extern "system" { unsafe extern "system" {
fn OutputDebugStringA(s: *const u8); fn OutputDebugStringA(s: *const u8);
} }
/// Whether the world-writable bring-up file log is enabled (resolved once). Off in release builds unless
/// `PFVD_DEBUG_LOG` is set.
fn file_log_enabled() -> bool {
use std::sync::OnceLock;
static ON: OnceLock<bool> = OnceLock::new();
*ON.get_or_init(|| cfg!(debug_assertions) || std::env::var_os("PFVD_DEBUG_LOG").is_some())
}
/// Process-lifetime append handle to the bring-up log, opened ONCE (by whichever thread logs first) and
/// shared via a `Mutex` — so the swap-chain WORKER thread's writes land too. Per-call open/append raced
/// the control thread and/or could fail under the worker's restricted token, hiding exactly the
/// swap-chain-processor lines a game-break repro needs (game-capture bug S3). `flush` after each line so a
/// crash/stall doesn't lose the tail.
fn file_appender() -> Option<&'static std::sync::Mutex<std::fs::File>> {
use std::sync::OnceLock;
static APPENDER: OnceLock<Option<std::sync::Mutex<std::fs::File>>> = OnceLock::new();
APPENDER
.get_or_init(|| {
if !file_log_enabled() {
return None;
}
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open("C:\\Users\\Public\\pfvd-driver.log")
.ok()
.map(std::sync::Mutex::new)
})
.as_ref()
}
pub fn log(s: &str) { pub fn log(s: &str) {
if let Ok(c) = std::ffi::CString::new(s) { if let Ok(c) = std::ffi::CString::new(s) {
// SAFETY: `c` is a valid NUL-terminated string for the duration of the call. // SAFETY: `c` is a valid NUL-terminated string for the duration of the call.
unsafe { OutputDebugStringA(c.as_ptr().cast()) }; unsafe { OutputDebugStringA(c.as_ptr().cast()) };
} }
use std::io::Write; use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new() if let Some(m) = file_appender() {
.create(true) if let Ok(mut f) = m.lock() {
.append(true)
.open("C:\\Users\\Public\\pfvd-driver.log")
{
let _ = writeln!(f, "{s}"); let _ = writeln!(f, "{s}");
let _ = f.flush();
}
} }
} }
macro_rules! dbglog { macro_rules! dbglog {
($($a:tt)*) => { $crate::log::log(&::std::format!($($a)*)) }; ($($a:tt)*) => { $crate::log::log(&::std::format!($($a)*)) };
} }
/// Zero-initialise a C POD struct (windows-rs / WDK / IddCx). These are `#[repr(C)]` framework structs
/// whose all-zero bit pattern is a valid zero-initialised value; the caller stamps the required
/// `.Size`/etc fields immediately after. Centralises the `unsafe { core::mem::zeroed() }` the IddCx/WDF
/// bring-up needs — pass the type EXPLICITLY (`pod_init!(T)`) so it works without a binding annotation.
/// Made crate-visible by the same `#[macro_use] mod log;` in `lib.rs` that exports `dbglog!`.
macro_rules! pod_init {
($t:ty) => {{
// SAFETY: $t is a C POD (windows-rs/WDK/IddCx struct); its all-zero bit pattern is a valid
// zero-initialised value and the caller sets the required .Size/etc fields immediately after.
// `unused_unsafe`: pod_init! is also expanded at call sites already inside an `unsafe` block
// (where this `unsafe` is redundant), but it IS required at the non-unsafe sites — so allow it.
#[allow(unused_unsafe)]
let zeroed = unsafe { ::core::mem::zeroed::<$t>() };
zeroed
}};
}
@@ -2,11 +2,10 @@
//! ([`crate::control`], `IOCTL_ADD`): each carries the requested mode (advertised as preferred) plus the //! ([`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 //! `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 //! 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::Mutex;
use std::sync::atomic::{AtomicU32, Ordering}; use std::time::{Duration, Instant};
use std::time::Instant;
use wdk_sys::iddcx; use wdk_sys::iddcx;
@@ -60,9 +59,59 @@ pub struct MonitorObject {
// SAFETY: the raw IddCx monitor handle is framework-managed; access is serialized by MONITOR_MODES. // SAFETY: the raw IddCx monitor handle is framework-managed; access is serialized by MONITOR_MODES.
unsafe impl Send for MonitorObject {} 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()); pub static MONITOR_MODES: Mutex<Vec<MonitorObject>> = Mutex::new(Vec::new());
/// Monitor id / EDID-serial counter (unique per created monitor).
static NEXT_ID: AtomicU32 = AtomicU32::new(1); /// True if any virtual monitor currently exists — the host-gone watchdog only reaps when there's
/// something to reap (see [`crate::control::start_watchdog`]).
pub fn has_monitors() -> bool {
MONITOR_MODES.lock().map(|l| !l.is_empty()).unwrap_or(false)
}
/// Depart every monitor that has existed at least `grace` — the host-gone watchdog reap
/// ([`crate::control::start_watchdog`]). The grace skips a just-created monitor (the host adds it, then
/// starts pinging) so a momentarily-stale ping timer can't nuke a brand-new monitor. Returns the count
/// departed. Same lock discipline as [`remove_monitor`]: drop each worker (which RAII-joins its thread)
/// OUTSIDE the `MONITOR_MODES` lock, then depart.
pub fn reap_orphaned(grace: Duration) -> usize {
let mut drained: Vec<(
Option<iddcx::IDDCX_MONITOR>,
Option<crate::swap_chain_processor::SwapChainProcessor>,
)> = {
let Ok(mut lock) = MONITOR_MODES.lock() else {
return 0;
};
let mut taken = Vec::new();
let mut i = 0;
while i < lock.len() {
if lock[i].created_at.elapsed() >= grace {
let mut m = lock.remove(i);
taken.push((m.object, m.swap_chain_processor.take()));
} else {
i += 1;
}
}
taken
};
let n = drained.len();
for (_, processor) in &mut drained {
drop(processor.take());
}
for (object, _) in drained {
if let Some(m) = object {
// SAFETY: `m` is a live IddCx monitor handle; departure tears it down.
unsafe { wdk_iddcx::IddCxMonitorDeparture(m) };
}
}
n
}
/// Fallback modes appended after the requested mode, so a topology change still has options. /// Fallback modes appended after the requested mode, so a topology change still has options.
fn default_modes() -> Vec<Mode> { fn default_modes() -> Vec<Mode> {
@@ -86,17 +135,19 @@ pub fn display_info(
height: u32, height: u32,
refresh_rate: u32, refresh_rate: u32,
) -> wdk_sys::DISPLAYCONFIG_VIDEO_SIGNAL_INFO { ) -> wdk_sys::DISPLAYCONFIG_VIDEO_SIGNAL_INFO {
let clock_rate = refresh_rate * (height + 4) * (height + 4) + 1000; // Compute in u64 then saturate the u32 rational numerators: the old u32 `refresh*(h+4)^2` overflows
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized // for a large mode (e.g. 8K@240), which panics→aborts the extern-"C" mode DDI in a debug build.
// DISPLAYCONFIG_VIDEO_SIGNAL_INFO; every meaningful field is assigned below. // Identical for every real mode; only an absurd (also now bounds-rejected) mode saturates.
let mut si: wdk_sys::DISPLAYCONFIG_VIDEO_SIGNAL_INFO = unsafe { core::mem::zeroed() }; let clock_rate: u64 = u64::from(refresh_rate) * u64::from(height + 4) * u64::from(height + 4) + 1000;
si.pixelRate = u64::from(clock_rate); let clock_rate_u32 = u32::try_from(clock_rate).unwrap_or(u32::MAX);
let mut si = pod_init!(wdk_sys::DISPLAYCONFIG_VIDEO_SIGNAL_INFO);
si.pixelRate = clock_rate;
si.hSyncFreq = wdk_sys::DISPLAYCONFIG_RATIONAL { si.hSyncFreq = wdk_sys::DISPLAYCONFIG_RATIONAL {
Numerator: clock_rate, Numerator: clock_rate_u32,
Denominator: height + 4, Denominator: height + 4,
}; };
si.vSyncFreq = wdk_sys::DISPLAYCONFIG_RATIONAL { si.vSyncFreq = wdk_sys::DISPLAYCONFIG_RATIONAL {
Numerator: clock_rate, Numerator: clock_rate_u32,
Denominator: (height + 4) * (height + 4), Denominator: (height + 4) * (height + 4),
}; };
si.activeSize = wdk_sys::DISPLAYCONFIG_2DREGION { si.activeSize = wdk_sys::DISPLAYCONFIG_2DREGION {
@@ -120,9 +171,7 @@ pub fn target_mode(width: u32, height: u32, refresh_rate: u32) -> iddcx::IDDCX_T
cx: width, cx: width,
cy: height, cy: height,
}; };
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized let mut si = pod_init!(wdk_sys::DISPLAYCONFIG_VIDEO_SIGNAL_INFO);
// DISPLAYCONFIG_VIDEO_SIGNAL_INFO; every meaningful field is assigned below.
let mut si: wdk_sys::DISPLAYCONFIG_VIDEO_SIGNAL_INFO = unsafe { core::mem::zeroed() };
si.pixelRate = u64::from(refresh_rate) * u64::from(width) * u64::from(height); si.pixelRate = u64::from(refresh_rate) * u64::from(width) * u64::from(height);
si.hSyncFreq = wdk_sys::DISPLAYCONFIG_RATIONAL { si.hSyncFreq = wdk_sys::DISPLAYCONFIG_RATIONAL {
Numerator: refresh_rate * height, Numerator: refresh_rate * height,
@@ -138,9 +187,7 @@ pub fn target_mode(width: u32, height: u32, refresh_rate: u32) -> iddcx::IDDCX_T
wdk_sys::DISPLAYCONFIG_SCANLINE_ORDERING::DISPLAYCONFIG_SCANLINE_ORDERING_PROGRESSIVE; wdk_sys::DISPLAYCONFIG_SCANLINE_ORDERING::DISPLAYCONFIG_SCANLINE_ORDERING_PROGRESSIVE;
// videoStandard=255, vSyncFreqDivider=1 (bits 16..21) => 255 | (1<<16). // videoStandard=255, vSyncFreqDivider=1 (bits 16..21) => 255 | (1<<16).
si.__bindgen_anon_1.videoStandard = 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; let mut tm = pod_init!(iddcx::IDDCX_TARGET_MODE);
// the required `.Size` (+ signal info) are set immediately below.
let mut tm: iddcx::IDDCX_TARGET_MODE = unsafe { core::mem::zeroed() };
tm.Size = core::mem::size_of::<iddcx::IDDCX_TARGET_MODE>() as u32; tm.Size = core::mem::size_of::<iddcx::IDDCX_TARGET_MODE>() as u32;
tm.TargetVideoSignalInfo = wdk_sys::DISPLAYCONFIG_TARGET_MODE { tm.TargetVideoSignalInfo = wdk_sys::DISPLAYCONFIG_TARGET_MODE {
targetVideoSignalInfo: si, targetVideoSignalInfo: si,
@@ -157,9 +204,7 @@ pub fn target_mode(width: u32, height: u32, refresh_rate: u32) -> iddcx::IDDCX_T
pub fn wire_bits() -> iddcx::IDDCX_WIRE_BITS_PER_COMPONENT { pub fn wire_bits() -> iddcx::IDDCX_WIRE_BITS_PER_COMPONENT {
let rgb = iddcx::IDDCX_BITS_PER_COMPONENT::IDDCX_BITS_PER_COMPONENT_8 let rgb = iddcx::IDDCX_BITS_PER_COMPONENT::IDDCX_BITS_PER_COMPONENT_8
| iddcx::IDDCX_BITS_PER_COMPONENT::IDDCX_BITS_PER_COMPONENT_10; | iddcx::IDDCX_BITS_PER_COMPONENT::IDDCX_BITS_PER_COMPONENT_10;
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized let mut w = pod_init!(iddcx::IDDCX_WIRE_BITS_PER_COMPONENT);
// IDDCX_WIRE_BITS_PER_COMPONENT; every field is assigned below.
let mut w: iddcx::IDDCX_WIRE_BITS_PER_COMPONENT = unsafe { core::mem::zeroed() };
w.Rgb = rgb; w.Rgb = rgb;
w.YCbCr444 = iddcx::IDDCX_BITS_PER_COMPONENT::IDDCX_BITS_PER_COMPONENT_NONE; w.YCbCr444 = iddcx::IDDCX_BITS_PER_COMPONENT::IDDCX_BITS_PER_COMPONENT_NONE;
w.YCbCr422 = iddcx::IDDCX_BITS_PER_COMPONENT::IDDCX_BITS_PER_COMPONENT_NONE; w.YCbCr422 = iddcx::IDDCX_BITS_PER_COMPONENT::IDDCX_BITS_PER_COMPONENT_NONE;
@@ -172,9 +217,7 @@ pub fn wire_bits() -> iddcx::IDDCX_WIRE_BITS_PER_COMPONENT {
/// zeroed. /// zeroed.
pub fn target_mode2(width: u32, height: u32, refresh_rate: u32) -> iddcx::IDDCX_TARGET_MODE2 { pub fn target_mode2(width: u32, height: u32, refresh_rate: u32) -> iddcx::IDDCX_TARGET_MODE2 {
let m1 = target_mode(width, height, refresh_rate); 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; let mut tm = pod_init!(iddcx::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() };
tm.Size = core::mem::size_of::<iddcx::IDDCX_TARGET_MODE2>() as u32; tm.Size = core::mem::size_of::<iddcx::IDDCX_TARGET_MODE2>() as u32;
tm.TargetVideoSignalInfo = m1.TargetVideoSignalInfo; tm.TargetVideoSignalInfo = m1.TargetVideoSignalInfo;
tm.BitsPerComponent = wire_bits(); tm.BitsPerComponent = wire_bits();
@@ -248,7 +291,7 @@ pub fn take_swap_chain_processor(
} }
/// `IOCTL_ADD`: create + arrive a virtual monitor at `width`x`height`@`refresh`. Returns the OS /// `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). /// or `None` on failure (no adapter yet / IddCx error).
pub fn create_monitor( pub fn create_monitor(
session_id: u64, session_id: u64,
@@ -257,8 +300,16 @@ pub fn create_monitor(
refresh: u32, refresh: u32,
) -> Option<(u32, u32, i32)> { ) -> Option<(u32, u32, i32)> {
let adapter = crate::adapter::adapter()?; 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 { let mut modes = vec![Mode {
width, width,
height, height,
@@ -266,8 +317,17 @@ pub fn create_monitor(
}]; }];
modes.extend(default_modes()); modes.extend(default_modes());
// Register the (pending) monitor so the mode DDIs can find it by EDID-serial id before arrival. // Register the (pending) monitor so the mode DDIs can find it by EDID-serial id before arrival, under a
if let Ok(mut lock) = MONITOR_MODES.lock() { // 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 { lock.push(MonitorObject {
object: None, object: None,
id, id,
@@ -279,15 +339,12 @@ pub fn create_monitor(
swap_chain_processor: None, swap_chain_processor: None,
created_at: Instant::now(), created_at: Instant::now(),
}); });
} else { id
return None; };
}
// EDID (serial = id) describes the monitor; the OS calls back into parse_monitor_description. // EDID (serial = id) describes the monitor; the OS calls back into parse_monitor_description.
let mut edid = crate::edid::Edid::generate_with(id); let mut edid = crate::edid::Edid::generate_with(id);
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized let mut desc = pod_init!(iddcx::IDDCX_MONITOR_DESCRIPTION);
// IDDCX_MONITOR_DESCRIPTION; the required `.Size`/Type/DataSize/pData are set immediately below.
let mut desc: iddcx::IDDCX_MONITOR_DESCRIPTION = unsafe { core::mem::zeroed() };
desc.Size = core::mem::size_of::<iddcx::IDDCX_MONITOR_DESCRIPTION>() as u32; 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.Type = iddcx::IDDCX_MONITOR_DESCRIPTION_TYPE::IDDCX_MONITOR_DESCRIPTION_TYPE_EDID;
desc.DataSize = edid.len() as u32; desc.DataSize = edid.len() as u32;
@@ -295,9 +352,7 @@ pub fn create_monitor(
// reads through `pData` SYNCHRONOUSLY, before `edid` drops — the pointer never escapes the call. // reads through `pData` SYNCHRONOUSLY, before `edid` drops — the pointer never escapes the call.
desc.pData = edid.as_mut_ptr().cast(); desc.pData = edid.as_mut_ptr().cast();
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized IDDCX_MONITOR_INFO; let mut info = pod_init!(iddcx::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() };
info.Size = core::mem::size_of::<iddcx::IDDCX_MONITOR_INFO>() as u32; info.Size = core::mem::size_of::<iddcx::IDDCX_MONITOR_INFO>() as u32;
info.MonitorContainerId = container_guid(id); info.MonitorContainerId = container_guid(id);
info.MonitorType = info.MonitorType =
@@ -305,9 +360,7 @@ pub fn create_monitor(
info.ConnectorIndex = id; info.ConnectorIndex = id;
info.MonitorDescription = desc; info.MonitorDescription = desc;
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized WDF_OBJECT_ATTRIBUTES; let mut attr = pod_init!(wdk_sys::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() };
attr.Size = core::mem::size_of::<wdk_sys::WDF_OBJECT_ATTRIBUTES>() as u32; attr.Size = core::mem::size_of::<wdk_sys::WDF_OBJECT_ATTRIBUTES>() as u32;
attr.ExecutionLevel = wdk_sys::_WDF_EXECUTION_LEVEL::WdfExecutionLevelInheritFromParent; attr.ExecutionLevel = wdk_sys::_WDF_EXECUTION_LEVEL::WdfExecutionLevelInheritFromParent;
attr.SynchronizationScope = attr.SynchronizationScope =
@@ -317,9 +370,7 @@ pub fn create_monitor(
ObjectAttributes: &raw mut attr, ObjectAttributes: &raw mut attr,
pMonitorInfo: &raw mut info, pMonitorInfo: &raw mut info,
}; };
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized IDARG_OUT_MONITORCREATE let mut create_out = pod_init!(iddcx::IDARG_OUT_MONITORCREATE);
// (an out-param the framework fills).
let mut create_out: iddcx::IDARG_OUT_MONITORCREATE = unsafe { core::mem::zeroed() };
// SAFETY: adapter is a valid IddCx adapter; create_in points to valid local storage read synchronously. // 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) }; let st = unsafe { wdk_iddcx::IddCxMonitorCreate(adapter, &create_in, &mut create_out) };
dbglog!("[pf-vd] IddCxMonitorCreate(id={id}) -> {st:#x}"); dbglog!("[pf-vd] IddCxMonitorCreate(id={id}) -> {st:#x}");
@@ -335,9 +386,7 @@ pub fn create_monitor(
} }
// Tell the OS the monitor is plugged in. // 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 let mut arrival_out = pod_init!(iddcx::IDARG_OUT_MONITORARRIVAL);
// (an out-param the framework fills).
let mut arrival_out: iddcx::IDARG_OUT_MONITORARRIVAL = unsafe { core::mem::zeroed() };
// SAFETY: `monitor` is the just-created IddCx monitor handle. // SAFETY: `monitor` is the just-created IddCx monitor handle.
let st = unsafe { wdk_iddcx::IddCxMonitorArrival(monitor, &mut arrival_out) }; let st = unsafe { wdk_iddcx::IddCxMonitorArrival(monitor, &mut arrival_out) };
dbglog!("[pf-vd] IddCxMonitorArrival(id={id}) -> {st:#x}"); dbglog!("[pf-vd] IddCxMonitorArrival(id={id}) -> {st:#x}");
@@ -411,6 +460,27 @@ pub fn clear_all() {
} }
} }
/// `EvtCleanupCallback` (device removal, [`crate::callbacks::device_cleanup`]): drop every monitor's heavy
/// resources — the swap-chain processor workers (each RAII-joins its thread + deletes its swap-chain) — and
/// clear the list, WITHOUT `IddCxMonitorDeparture` (the framework tears the IddCx monitors down together
/// with the departing device; departing here would double-tear). Frees our worker threads promptly even
/// though the per-devnode WUDFHost (`ProcessSharingDisabled`) would also reap them when it exits.
pub fn cleanup_for_device_removal() {
let mut drained: Vec<Option<crate::swap_chain_processor::SwapChainProcessor>> = {
let Ok(mut lock) = MONITOR_MODES.lock() else {
return;
};
lock.drain(..)
.map(|mut m| m.swap_chain_processor.take())
.collect()
};
// Drop the workers (join their threads) AFTER releasing the lock — joining under MONITOR_MODES would
// head-block the control plane (same discipline as remove_monitor / clear_all).
for processor in &mut drained {
drop(processor.take());
}
}
/// Drop a pending entry by id (create failed before arrival). /// Drop a pending entry by id (create failed before arrival).
fn remove_by_id(id: u32) { fn remove_by_id(id: u32) {
if let Ok(mut lock) = MONITOR_MODES.lock() { if let Ok(mut lock) = MONITOR_MODES.lock() {
@@ -418,6 +488,17 @@ fn remove_by_id(id: u32) {
} }
} }
/// The lowest monitor id (≥1) not currently live. Reusing freed ids (instead of a monotonic counter) keeps
/// the connector index / EDID serial / container GUID bounded to the number of concurrent monitors, so a
/// fresh ADD reuses a departed monitor's OS target slot rather than allocating a new one and orphaning the
/// old (the ghost-monitor accumulation that wedges ADD at 0x80070490 ERROR_NOT_FOUND). Caller holds
/// `MONITOR_MODES`. With ≤ N live ids, a free one always exists in `1..=N+1` (pigeonhole).
fn alloc_monitor_id(modes: &[MonitorObject]) -> u32 {
(1u32..=modes.len() as u32 + 1)
.find(|id| !modes.iter().any(|m| m.id == *id))
.unwrap_or(1)
}
/// A deterministic, monitor-unique container GUID (groups targets into a physical device). Derived from /// 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. /// `id` so it is stable + collision-free without a random source.
fn container_guid(id: u32) -> wdk_sys::GUID { 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. // 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 let mut set_device = pod_init!(IDARG_IN_SWAPCHAINSETDEVICE);
// IDARG_IN_SWAPCHAINSETDEVICE; the `pDevice` field is set immediately below.
let mut set_device: IDARG_IN_SWAPCHAINSETDEVICE = unsafe { core::mem::zeroed() };
set_device.pDevice = dxgi_device.as_raw().cast(); set_device.pDevice = dxgi_device.as_raw().cast();
let mut set_ok = false; let mut set_ok = false;
let mut terminated = 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 // 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 // success branch below. Built zeroed + field-assigned (driver style) so a bindgen field-set
// difference can't break a positional struct literal. // difference can't break a positional struct literal.
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized let mut in_args = pod_init!(IDARG_IN_RELEASEANDACQUIREBUFFER2);
// IDARG_IN_RELEASEANDACQUIREBUFFER2; the required `.Size`/AcquireSystemMemoryBuffer are set below.
let mut in_args: IDARG_IN_RELEASEANDACQUIREBUFFER2 = unsafe { core::mem::zeroed() };
#[allow(clippy::cast_possible_truncation)] #[allow(clippy::cast_possible_truncation)]
{ {
in_args.Size = size_of::<IDARG_IN_RELEASEANDACQUIREBUFFER2>() as u32; in_args.Size = size_of::<IDARG_IN_RELEASEANDACQUIREBUFFER2>() as u32;
} }
in_args.AcquireSystemMemoryBuffer = 0; 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 // 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). // `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 let mut buffer = pod_init!(IDARG_OUT_RELEASEANDACQUIREBUFFER2);
// IDARG_OUT_RELEASEANDACQUIREBUFFER2 (an out-param the framework fills).
let mut buffer: IDARG_OUT_RELEASEANDACQUIREBUFFER2 = unsafe { core::mem::zeroed() };
// SAFETY: driver is loaded; `swap_chain` is valid; in/out point to valid local storage. // SAFETY: driver is loaded; `swap_chain` is valid; in/out point to valid local storage.
let hr: NTSTATUS = unsafe { let hr: NTSTATUS = unsafe {
wdk_iddcx::IddCxSwapChainReleaseAndAcquireBuffer2( wdk_iddcx::IddCxSwapChainReleaseAndAcquireBuffer2(
@@ -10,6 +10,8 @@
//! code — handled at the call site in STEP 5). //! code — handled at the call site in STEP 5).
#![no_std] #![no_std]
#![allow(non_snake_case, clippy::missing_safety_doc)] #![allow(non_snake_case, clippy::missing_safety_doc)]
// P0 lint (audit §8): require explicit `unsafe {}` blocks inside `unsafe fn`s.
#![deny(unsafe_op_in_unsafe_fn)]
pub use wdk_sys::iddcx; pub use wdk_sys::iddcx;
@@ -1,6 +1,6 @@
# M0/M1 toolchain probe: the smallest possible UMDF2 driver on windows-drivers-rs (crates.io wdk 0.5). # 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 # 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. # build graph, and (3) what the produced DLL's PE FORCE_INTEGRITY (/INTEGRITYCHECK) bit is. NOT shipped.
[package] [package]
name = "wdk-probe" 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 # 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)? # do its WDF/DXGI types resolve to wdk-sys's (so the generated module compiles)?
wdk-sys = { workspace = true, features = ["iddcx"] } 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 → //! crate's Cargo.toml). DriverEntry → WdfDriverCreate → (EvtDeviceAdd) IddCxDeviceInitConfig →
//! WdfDeviceCreate → IddCxDeviceInitialize → IddCxAdapterInitAsync: enough to exercise the wdk-sys WDF //! 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 //! 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)] #![allow(non_snake_case, clippy::missing_safety_doc)]
@@ -18,10 +18,10 @@ use wdk_sys::{
const STATUS_SUCCESS: NTSTATUS = 0; 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. /// path-dep + that the no_std bytemuck ABI crate compiles for a UMDF cdylib). `#[used]` keeps it.
#[used] #[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 /// 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 /// `#ifndef IDD_STUB` branch of `IddCxFuncEnum.h` (which normally emits it) is compiled out under
+1 -1
View File
@@ -141,7 +141,7 @@ $defines = @(
) )
# --- stage the pf-vdisplay virtual-display driver bundle -------------------------------------- # --- 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. # packaging/windows/pf-vdisplay/. It replaced the vendored SudoVDA C++ driver.
if (-not $NoDriver) { if (-not $NoDriver) {
$stage = Join-Path $OutDir 'stage' $stage = Join-Path $OutDir 'stage'
@@ -2,7 +2,7 @@
; pf-vdisplay - punktfunk virtual display, UMDF2 IddCx driver INF (template; stampinf -> .inf). ; 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 ; 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 ; 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, ; 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). ; 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."
+130
View File
@@ -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."
+3 -3
View File
@@ -4,11 +4,11 @@
driver + the fetched nefcon device tool. driver + the fetched nefcon device tool.
.DESCRIPTION .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 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= packaging/windows/pf-vdisplay/ (signer punktfunk-ds-test — shared with the gamepad drivers — Class=
Display, HWID root\pf_vdisplay). Rebuild + re-vendor with 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 + 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 SHA-256-verify it (it provides nefconc.exe, used to create the root-enumerated device node — pnputil
can't). can't).
@@ -36,7 +36,7 @@ New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
# --- vendored pf-vdisplay driver -------------------------------------------------------------- # --- vendored pf-vdisplay driver --------------------------------------------------------------
$inf = Get-ChildItem -Path $VendorDir -Filter pf_vdisplay.inf -ErrorAction SilentlyContinue | Select-Object -First 1 $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 Copy-Item (Join-Path $VendorDir '*') $OutDir -Force
Write-Host "==> vendored pf-vdisplay staged from $VendorDir" 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
View File
@@ -1,510 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "bindgen"
version = "0.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f"
dependencies = [
"bitflags",
"cexpr",
"clang-sys",
"itertools",
"log",
"prettyplease",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex",
"syn",
]
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
dependencies = [
"bytemuck_derive",
]
[[package]]
name = "bytemuck_derive"
version = "1.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clang-sys"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pf-vdisplay"
version = "0.1.0"
dependencies = [
"anyhow",
"bytemuck",
"log",
"thiserror",
"wdf-umdf",
"wdf-umdf-sys",
"windows",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rustc-hash"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "wdf-umdf"
version = "0.1.0"
dependencies = [
"paste",
"thiserror",
"wdf-umdf-sys",
]
[[package]]
name = "wdf-umdf-sys"
version = "0.1.0"
dependencies = [
"bindgen",
"bytemuck",
"paste",
"thiserror",
"winreg",
]
[[package]]
name = "windows"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
dependencies = [
"windows-core",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
dependencies = [
"windows-implement",
"windows-interface",
"windows-result",
"windows-strings",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-implement"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-strings"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [
"windows-result",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winreg"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
dependencies = [
"cfg-if",
"windows-sys",
]
@@ -1,26 +0,0 @@
# pf-vdisplay — punktfunk Windows virtual display (IddCx), in Rust.
#
# A self-contained driver workspace (NOT built on windows-drivers-rs like the gamepad drivers — IddCx
# functions are direct IddCxStub exports the WDF function-table macro can't reach, so a unified bindgen
# is the cleaner base). The wdf-umdf-sys / wdf-umdf binding crates are vendored from MolotovCherry's
# MIT-licensed virtual-display-rs (see LICENSE.virtual-display-rs); pf-vdisplay is our driver, swapping
# its named-pipe IPC for the SudoVDA-compatible IOCTL control plane our host already speaks.
[workspace]
resolver = "2"
members = ["wdf-umdf-sys", "wdf-umdf", "pf-vdisplay"]
[profile.release]
strip = true
codegen-units = 1
lto = true
[workspace.lints.rust]
unsafe_op_in_unsafe_fn = "deny"
[workspace.lints.clippy]
pedantic = { level = "warn", priority = -1 }
multiple_unsafe_ops_per_block = "deny"
ignored_unit_patterns = "allow"
missing_errors_doc = "allow"
module_inception = "allow"
module_name_repetitions = "allow"

Some files were not shown because too many files have changed in this diff Show More