9 Commits

Author SHA1 Message Date
enricobuehler af13f0b749 chore(release): 0.6.0
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
audit / cargo-audit (push) Has been cancelled
android-screenshots / screenshots (push) Successful in 2m18s
android / android (push) Successful in 4m13s
decky / build-publish (push) Successful in 26s
windows-host / package (push) Successful in 6m36s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m10s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m50s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 35s
release / apple (push) Successful in 7m53s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m32s
deb / build-publish (push) Successful in 9m52s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m21s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 53s
web-screenshots / screenshots (push) Successful in 2m38s
ci / web (push) Successful in 48s
ci / rust (push) Successful in 11m43s
linux-client-screenshots / screenshots (push) Successful in 1m33s
flatpak / build-publish (push) Successful in 4m8s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 10m10s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m49s
docker / deploy-docs (push) Successful in 25s
ci / docs-site (push) Successful in 57s
ci / bench (push) Successful in 5m9s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:19:18 +00:00
enricobuehler d285d4a0b2 fix(tray): live-probe the web console instead of sniffing the install layout
windows-drivers / probe-and-proto (push) Successful in 29s
audit / cargo-audit (push) Successful in 1m31s
apple / swift (push) Successful in 1m8s
windows-drivers / driver-build (push) Successful in 1m35s
android / android (push) Successful in 4m45s
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m0s
release / apple (push) Successful in 7m35s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows-host / package (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
The "Open web console" entry was gated on {exe dir}\web\web-run.cmd (Windows)
/ the punktfunk-web unit file (Linux) — which misses consoles run from a repo
checkout (the RTX box, caught on-glass) and shows a dead entry while an
installed console is stopped. The poller now probes https://127.0.0.1:<web
port>/ each cycle (any HTTP response = up, transport failure = down) and the
menu follows live on both platforms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:17:01 +00:00
enricobuehler 04f370999c fix(web): pin the sidebar at viewport height
Sticky h-dvh sidebar: long pages scroll the content, not the nav — the flex
stretch was pushing the language switcher below the fold; overflow-y-auto keeps
the nav usable on short viewports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:09:52 +00:00
enricobuehler 2c937855b3 fix(packaging/windows): Windows 11 22H2 floor + tray install task + stale console-port fixes
The OS floor is now enforced at install time (MinVersion=10.0.22621 with an
explanatory [Messages] override): pf-vdisplay is built against IddCx 1.10, and
on Windows 10 (incl. LTSC) / Win11 21H2 the device fails start with Code 10
STATUS_DEVICE_POWER_FAILURE (field-reported). Docs (site requirements/install/
windows-host pages + README) state the floor; new docs-site Security page.

Installer also gains the trayicon task (punktfunk-tray.exe file + HKLM Run key,
post-install launch as the signed-in user, upgrade taskkill + uninstall
--quit/taskkill choreography before file deletion), and the wizard/cleanup
text/port sweeps move off the stale :3000 web-console references to :47992
(cleanups sweep both for upgrades from old installs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:09:52 +00:00
enricobuehler 8005b11faf feat(tray): system-tray status icon for the host (Windows + Linux)
New crates/punktfunk-tray — a small per-user companion showing the host service
state at a glance (running / stopped / starting / degraded / failed + the live
session in the tooltip) with one-click actions: open web console, approve a
pending pairing request, start/stop/restart, open logs. No more digging through
logs to learn whether the service came back after a reboot or an update.

Status is service-manager-FIRST (SCM / systemd user unit — a port squatter can
never fake Running), then the new loopback-only unauthenticated
GET /api/v1/local/summary (counts/booleans only; the mgmt token and cert.pem
are SYSTEM/Admins-DACL'd on Windows, so a non-elevated tray cannot bearer-auth).

Windows: windows_subsystem binary (a console exe in the Run key would flash a
terminal at sign-in), Shell_NotifyIcon + hidden window, per-session single
instance, TaskbarCreated re-add, --quit for the uninstaller; service actions
elevate per click via ShellExecuteW "runas" onto the new
`punktfunk-host service restart` (stop → wait Stopped → start).
Linux: ksni/StatusNotifierItem over zbus, systemctl --user actions (no polkit),
/etc/xdg/autostart entry whose --autostart self-gates to actual host users.
Icons: scripts/gen-tray-icons.py (pure stdlib) renders the brand lens + status
dot into committed .ico/hicolor assets; deb/rpm/arch ship binary+autostart+icons.

Live-validated: Linux on the headless KDE session (SNI registration, state
transitions, menu-driven start, dbusmenu layout); Windows on the RTX box
(session-1 launch with no NIM_ADD failure, single instance, --quit, restart
round-trip, summary loopback-200/LAN-401).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:09:35 +00:00
enricobuehler 01fcb01019 fix(encode/windows): resolve NVENC at runtime — AMD/Intel hosts no longer crash at start
The nvenc build linked nvEncodeAPI64.dll's entry points at load time, so a
--features nvenc binary hard-crashed on any box without the NVIDIA driver
(AMD/Intel). Entry points now come from a runtime LoadLibrary table
(encode/windows/nvenc.rs load_api); a missing DLL just falls through the
encoder auto-detect to AMF/QSV/software. The generated import lib and all its
plumbing (gen-nvenc-importlib.ps1, nvenc.def, PUNKTFUNK_NVENC_LIB_DIR,
setup-build-env wiring) are gone.

Live-validated on the RTX 4090 box (NVENC session, 7000+ frames).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:09:18 +00:00
enricobuehler 95a08e99c3 feat(host/windows): seal the host↔driver channels (frame + gamepad, proto v2)
Frame ring (pf-vdisplay) and both gamepad SHM channels move off named Global\
objects (openable by any sibling LocalService) to UNNAMED sections/events whose
handles the host DuplicateHandles into the driver's verified WUDFHost with least
access — frame delivery over the SYSTEM+admins-only IOCTL_SET_FRAME_CHANNEL,
pads over a 32-byte named bootstrap mailbox (pid + handle value only, DoS-bounded;
HID minidrivers have no control device). Driver-validated pad_index kills
cross-pad redirects; v1↔v2 mixes fail closed with diagnosis logs on both sides.
Sibling-LocalService denial proven empirically (design/idd-push-security.md,
design/gamepad-channel-sealing.md).

Driver-side raw ops now live behind pf-umdf-util (checked shm accessors, the
forbid(unsafe_code) ChannelClient state machine, WDF request tokens) — the pad
drivers' logic is 100% safe Rust; whole drivers workspace clippy-gated in CI.

driver install --gamepad now sweeps SWD\punktfunk phantom devnodes: a re-created
SwDevice REVIVES the old devnode with its previously-bound driver (never
re-ranks), so an upgrade otherwise leaves the old driver serving — or, across
the v1→v2 fence, a dead pad (found live on the RTX box).

On-glass validated on the RTX 4090 box: frame path 7007 frames p50 2.06 ms
cross-machine; DualSense + XUSB "sealed pad channel mapped"/proto=2 attach via
both the test harness and a real streaming session; phantom-sweep repro.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:08:56 +00:00
enricobuehler a3e1ea2b44 fix(android/ci): retry transient Play API failures in play-upload.py
apple / swift (push) Successful in 1m9s
apple / screenshots (push) Successful in 4m2s
android / android (push) Successful in 11m51s
ci / web (push) Successful in 1m0s
ci / docs-site (push) Successful in 1m13s
ci / rust (push) Successful in 4m30s
deb / build-publish (push) Successful in 3m35s
ci / bench (push) Successful in 4m47s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
decky / build-publish (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m59s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 10m3s
docker / deploy-docs (push) Successful in 20s
The uploader only caught HTTPError — a URLError (TLS "EOF occurred in
violation of protocol", the failure that dropped two release uploads on
2026-07-02) or a Google 5xx killed the job outright. Retry those with
3/9/27 s backoff; 4xx still fails fast. The edits API is transactional
until commit, so re-sending is safe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:05:27 +00:00
enricobuehler 6686fcdded fix(gamestream/tests): sender_delivers_batches flaked under CI load — burst overflowed the default socket buffer
apple / swift (push) Successful in 1m12s
apple / screenshots (push) Successful in 4m26s
windows-host / package (push) Successful in 6m25s
ci / rust (push) Successful in 5m5s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 1m4s
android / android (push) Failing after 10m7s
deb / build-publish (push) Successful in 3m35s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m38s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m53s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m53s
docker / deploy-docs (push) Successful in 18s
The test burst 3×100 1200 B datagrams into an undrained loopback socket: at
~2.5 KB kernel truesize each, the default ~212 KB rmem holds only ~80, so on
a starved CI runner (parallel release builds) the kernel silently dropped the
overflow and the recv loop could never reach 300 — surfacing as WouldBlock
after the 3 s timeout. Size the burst (3×20) to fit the default buffer even
with zero concurrent draining, and give recv a starvation-tolerant 10 s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:35:23 +00:00
102 changed files with 5792 additions and 1418 deletions
+13 -3
View File
@@ -131,11 +131,21 @@ jobs:
# dispatched provisioning workflow landing on a different one. Path is relative to the job # dispatched provisioning workflow landing on a different one. Path is relative to the job
# working-directory (packaging/windows/drivers). Near-noop once the toolchain is present. # working-directory (packaging/windows/drivers). Near-noop once the toolchain is present.
run: ../../../scripts/ci/ensure-windows-toolchain.ps1 run: ../../../scripts/ci/ensure-windows-toolchain.ps1
- name: cargo build the driver workspace (wdk-probe + wdk-iddcx + pf-vdisplay) - name: cargo build the driver workspace (wdk-probe + wdk-iddcx + pf-vdisplay + gamepad drivers)
# Whole workspace: wdk-probe (toolchain/surface-assert probe) + wdk-iddcx (DDI wrappers) + # Whole workspace: wdk-probe (toolchain/surface-assert probe) + wdk-iddcx (DDI wrappers) +
# pf-vdisplay (the real IddCx driver). pf-vdisplay linking proves the IddCx call sites resolve # pf-vdisplay (the real IddCx driver) + pf-umdf-util (the safe UMDF primitive layer) + the two
# against IddCxStub end-to-end (M1 step 2 gate). # gamepad drivers. pf-vdisplay linking proves the IddCx call sites resolve against IddCxStub
# end-to-end (M1 step 2 gate); the gamepad drivers prove pf-umdf-util's WDF dispatch links.
run: cargo build -v run: cargo build -v
- name: cargo clippy the shipped drivers (-D warnings — enforces the unsafe-audit gates)
# The gamepad drivers' business logic is 100% safe (it moved onto pf-umdf-util, the audited
# unsafe layer); pf-vdisplay + wdk-iddcx are inherently FFI-bound but every `unsafe {}` carries a
# `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` +
# `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a
# toolchain-only probe crate and is excluded.)
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-dualsense -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
- name: cargo fmt --check the safe-layer + gamepad drivers
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense --check
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build - name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
run: | run: |
# explicit --target (.cargo/config.toml) -> output under the triple subdir. # explicit --target (.cargo/config.toml) -> output under the triple subdir.
+13 -10
View File
@@ -23,8 +23,9 @@
# (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1. # (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1.
# #
# GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer. # GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer.
# - NVENC (NVIDIA, direct SDK): the only link need is nvencodeapi.lib, synthesised from a 2-export # - NVENC (NVIDIA, direct SDK): nothing needed at build time — the entry points are resolved at
# .def with llvm-dlltool (no GPU/SDK at build time). # RUNTIME from the driver's nvEncodeAPI64.dll (a link-time import would kill the binary on
# AMD/Intel-only boxes before main).
# - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN lgpl-shared # - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN lgpl-shared
# tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer. # tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer.
# lgpl-shared (not gpl-shared) keeps those bundled DLLs LGPL (we never use the GPL-only x264/x265). # lgpl-shared (not gpl-shared) keeps those bundled DLLs LGPL (we never use the GPL-only x264/x265).
@@ -37,6 +38,7 @@ on:
paths: paths:
- 'crates/punktfunk-host/**' - 'crates/punktfunk-host/**'
- 'crates/punktfunk-core/**' - 'crates/punktfunk-core/**'
- 'crates/punktfunk-tray/**'
- 'packaging/windows/**' - 'packaging/windows/**'
- 'scripts/windows/**' - 'scripts/windows/**'
- 'web/**' - 'web/**'
@@ -109,21 +111,22 @@ jobs:
"PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
Write-Output "host version $v" Write-Output "host version $v"
- name: Generate NVENC import lib
shell: pwsh
run: |
& packaging/windows/nvenc/gen-nvenc-importlib.ps1 -OutDir C:\t\nvenc
"PUNKTFUNK_NVENC_LIB_DIR=C:\t\nvenc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Build (release, nvenc + amf-qsv) - name: Build (release, nvenc + amf-qsv)
shell: pwsh shell: pwsh
# All-vendor host: NVENC (NVIDIA, direct SDK) + AMF/QSV (AMD/Intel, libavcodec via FFMPEG_DIR). # All-vendor host: NVENC (NVIDIA, direct SDK) + AMF/QSV (AMD/Intel, libavcodec via FFMPEG_DIR).
run: cargo build --release -p punktfunk-host --features nvenc,amf-qsv run: cargo build --release -p punktfunk-host --features nvenc,amf-qsv
- name: Clippy (host, Windows) - name: Build (release, status tray)
shell: pwsh
# The per-user notification-area companion the installer bundles (punktfunk-tray.exe).
run: cargo build --release -p punktfunk-tray
- name: Clippy (host + tray, Windows)
shell: pwsh shell: pwsh
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code). # First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
run: cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings run: |
cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
cargo clippy -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer) - name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
shell: pwsh shell: pwsh
+47 -3
View File
@@ -100,16 +100,39 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
work with no kernel bus driver (validated live: slot connected, state + rumble round-trip; Xbox One work with no kernel bus driver (validated live: slot connected, state + rumble round-trip; Xbox One
folds to this 360 path). All three UMDF drivers (DualSense/DS4 + XUSB) are built from source in CI folds to this 360 path). All three UMDF drivers (DualSense/DS4 + XUSB) are built from source in CI
(`packaging/windows/drivers/`) and installed by the Inno Setup installer via (`packaging/windows/drivers/`) and installed by the Inno Setup installer via
`punktfunk-host.exe driver install --gamepad`. `punktfunk-host.exe driver install --gamepad`. The gamepad drivers' **business logic is 100 % safe
Rust**: every raw shared-memory / sealed-channel / WDF-request operation lives behind
`pf-umdf-util` (the audited unsafe layer — `section::MappedView` checked accessors, the
`#![forbid(unsafe_code)]` `channel::ChannelClient` state machine, `wdf::Request` tokens), so a
memory-safety bug can only live in that one small crate. The whole drivers workspace is lint-gated
(`deny(unsafe_op_in_unsafe_fn)` + `deny(clippy::undocumented_unsafe_blocks)`) with a
`cargo clippy -D warnings` step in `windows-drivers.yml`; pf-vdisplay stays FFI-bound (D3D11/IddCx)
but every `unsafe {}` there now carries a `// SAFETY:` proof (unsafe-audited, not unsafe-free).
**Multi-pad ready**: the host stamps each pad's index into the device Location (`pszDeviceLocation`), **Multi-pad ready**: the host stamps each pad's index into the device Location (`pszDeviceLocation`),
the driver reads it (`WdfDeviceAllocAndQueryProperty`) to map its own `*-shm-<index>`, and the driver reads it (`WdfDeviceAllocAndQueryProperty`) to map its own `*-shm-<index>`, and
`UmdfHostProcessSharing=ProcessSharingDisabled` gives each pad its own host (per-pad statics) — `UmdfHostProcessSharing=ProcessSharingDisabled` gives each pad its own host (per-pad statics) —
validated live with 2 distinct XInput slots + 2 DualSense pads. (Client-side multi-pad forwarding is validated live with 2 distinct XInput slots + 2 DualSense pads. (Client-side multi-pad forwarding is
the remaining piece.) the remaining piece.)
- **Windows host: implemented and shipping (all-vendor, x64-only).** `#[cfg(windows)]` backends - **Windows host: implemented and shipping (all-vendor, x64-only, Windows 11 22H2+).** The OS floor
is HARD: pf-vdisplay is built against IddCx 1.10 (1.10 stub + HDR `*2` DDIs + FP16 caps, no runtime
downgrade) — on Windows 10 (incl. LTSC) / Win11 21H2 the driver installs but the device fails start
with Code 10 `STATUS_DEVICE_POWER_FAILURE` (field-reported 2026-07); the installer gates on
`MinVersion=10.0.22621`. `#[cfg(windows)]` backends
behind the same traits as Linux — **IDD-push capture** straight into the in-house all-Rust IddCx behind the same traits as Linux — **IDD-push capture** straight into the in-house all-Rust IddCx
**pf-vdisplay** virtual display (`capture/windows/idd_push.rs`, `vdisplay/windows/pf_vdisplay.rs`; **pf-vdisplay** virtual display (`capture/windows/idd_push.rs`, `vdisplay/windows/pf_vdisplay.rs`;
DXGI Desktop Duplication / WGC as fallbacks, `capture/windows/dxgi.rs`), GPU encode (NVENC DXGI Desktop Duplication / WGC as fallbacks, `capture/windows/dxgi.rs`). The host↔driver frame
ring is a **sealed channel** (proto v2, `design/idd-push-security.md`): all shared objects
UNNAMED, handles `DuplicateHandle`d into the driver's WUDFHost and delivered as values over
`IOCTL_SET_FRAME_CHANNEL` (SY+BA-only control device) — only the two endpoint processes can ever
reach a frame (DDA's isolation property in user mode; adopt-on-success handle-ownership contract,
newest-delivery-wins re-attach). *Sealed channel: CI-pending + on-glass revalidation pending.*
The **gamepad SHM channels are sealed the same way** (gamepad proto v2,
`design/gamepad-channel-sealing.md`): the pad DATA sections (`XusbShm`/`PadShm`, now with a
driver-validated `pad_index`) are unnamed + handle-duplicated into the pad WUDFHost
(`gamepad_raii.rs` `PadChannel`); since the HID minidrivers have no control device, the handshake
runs over a tiny named bootstrap mailbox (`Global\pf…-boot-<i>`, pid + handle value only — tampering
is DoS-bounded). *Sealed pad channel: needs both pad drivers redeployed with the host, physical-pad
validation pending.* GPU encode (NVENC
`--features nvenc`; AMD/Intel `--features amf-qsv`), SendInput + the in-house UMDF gamepad drivers `--features nvenc`; AMD/Intel `--features amf-qsv`), SendInput + the in-house UMDF gamepad drivers
(`inject/windows/`), WASAPI loopback + virtual mic (`audio/windows/wasapi_*`). **Keyboard wire (`inject/windows/`), WASAPI loopback + virtual mic (`audio/windows/wasapi_*`). **Keyboard wire
convention: US-positional VKs** (every first-party client sends the physical key's US-layout VK; convention: US-positional VKs** (every first-party client sends the physical key's US-layout VK;
@@ -155,6 +178,25 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
HKLM-registered by the installer. **Live-validated: Doom: The Dark Ages enables HDR over the virtual HKLM-registered by the installer. **Live-validated: Doom: The Dark Ages enables HDR over the virtual
display.** **AMF/QSV is CI-green but not yet on-glass validated** (no AMD/Intel Windows box in the display.** **AMF/QSV is CI-green but not yet on-glass validated** (no AMD/Intel Windows box in the
lab); NVENC is live-validated. Newer/less battle-tested than the Linux host. Packaging: `packaging/windows/`. lab); NVENC is live-validated. Newer/less battle-tested than the Linux host. Packaging: `packaging/windows/`.
- **Status tray (`crates/punktfunk-tray`, Windows + Linux).** A small per-user companion binary
showing the host service state at a glance (running / stopped / starting / degraded / failed +
streaming session in the tooltip) with one-click actions: open web console, approve-pairing
shortcut, start/stop/restart, open logs, exit. Status precedence is **service manager first**
(SCM / systemd user unit — a port-squatter can't fake Running), then the new **loopback-only
unauthenticated** `GET /api/v1/local/summary` (counts/booleans only — no PINs/fingerprints/names;
gated in `require_auth` by peer address, needed because `mgmt-token`/`cert.pem` are
SYSTEM/Admins-DACL'd on Windows so a non-elevated tray cannot bearer-auth). Windows:
`#![windows_subsystem = "windows"]` hidden-window + `Shell_NotifyIconW` (per-session `Local\`
mutex, TaskbarCreated re-add, `--quit` for the uninstaller), actions elevate per click via
`ShellExecuteW "runas"` on `punktfunk-host.exe service start|stop|restart` (new `service restart`
subcommand: stop → wait Stopped → start); installed by the Inno `trayicon` task (HKLM Run key).
Linux: ksni (SNI over zbus, `async-io`+`blocking` features), `systemctl --user` actions (no
polkit), `/etc/xdg/autostart` entry whose `--autostart` self-gates (silent exit unless
`~/.config/punktfunk` exists or the unit is enabled); deb/rpm/arch ship binary + autostart +
hicolor icons. Icons generated by `scripts/gen-tray-icons.py` (pure-stdlib; committed .ico/.png,
brand lens + status dot). *Linux live-validated on the headless KDE session (SNI registration,
stop/start transitions, menu-driven start, dbusmenu layout); Windows code MSVC-cross-type-checked
+ clippy-clean but real Windows CI build + on-glass validation pending.*
## What's left ## What's left
@@ -447,6 +489,7 @@ crates/punktfunk-host/
capture/{linux/,windows/{dxgi,idd_push}}.rs · audio/{linux/,windows/wasapi_*}.rs capture/{linux/,windows/{dxgi,idd_push}}.rs · audio/{linux/,windows/wasapi_*}.rs
windows/{service,install,interactive}.rs SCM service + in-binary driver/web install windows/{service,install,interactive}.rs SCM service + in-binary driver/web install
capture.rs · encode.rs · audio.rs · gpu.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs · stats_recorder.rs · library.rs capture.rs · encode.rs · audio.rs · gpu.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs · stats_recorder.rs · library.rs
crates/punktfunk-tray/ per-user status tray (Win32 Shell_NotifyIcon · Linux ksni/SNI); icons via scripts/gen-tray-icons.py
clients/probe/ punktfunk/1 reference/probe client (headless test/measurement tool) clients/probe/ punktfunk/1 reference/probe client (headless test/measurement tool)
clients/linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3) clients/linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3)
clients/windows/ native Windows client (WinUI 3 via windows-reactor · D3D11 · WASAPI · SDL3) clients/windows/ native Windows client (WinUI 3 via windows-reactor · D3D11 · WASAPI · SDL3)
@@ -454,6 +497,7 @@ clients/apple/ native macOS/iOS/tvOS client (Swift · VideoToolbox · GameCon
clients/android/ native Android client (Kotlin app + native/ Rust JNI core over punktfunk-core) clients/android/ native Android client (Kotlin app + native/ Rust JNI core over punktfunk-core)
clients/decky/ Steam Deck Decky plugin clients/decky/ Steam Deck Decky plugin
packaging/windows/drivers/{pf-vdisplay,pf-dualsense,pf-xusb}/ in-house UMDF drivers (built from source in CI) packaging/windows/drivers/{pf-vdisplay,pf-dualsense,pf-xusb}/ in-house UMDF drivers (built from source in CI)
packaging/windows/drivers/pf-umdf-util/ audited unsafe layer (safe shm + sealed-channel + WDF request primitives) — gamepad drivers' logic is 100% safe over it
web/ TanStack web console over the mgmt API (status · devices · pairing · GPU selection · performance graphs) web/ TanStack web console over the mgmt API (status · devices · pairing · GPU selection · performance graphs)
packaging/ apt(deb) · RPM/COPR · Arch/sysext · Flatpak · Bazzite bootc · Windows host installer (per-dir READMEs) packaging/ apt(deb) · RPM/COPR · Arch/sysext · Flatpak · Bazzite bootc · Windows host installer (per-dir READMEs)
tools/{loss-harness,latency-probe}/ measurement (plan §10) tools/{loss-harness,latency-probe}/ measurement (plan §10)
Generated
+177 -8
View File
@@ -228,6 +228,67 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "async-executor"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix",
]
[[package]] [[package]]
name = "async-recursion" name = "async-recursion"
version = "1.1.1" version = "1.1.1"
@@ -239,6 +300,30 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "async-signal"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.89"
@@ -434,6 +519,19 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "blocking"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.3" version = "3.20.3"
@@ -2002,9 +2100,26 @@ dependencies = [
"libloading", "libloading",
] ]
[[package]]
name = "ksni"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da9eeb3f510b6148ae68f963af2c1fbb0de4d9e4e05f82813cfb319837c3ad2b"
dependencies = [
"async-executor",
"async-io",
"async-lock",
"futures-channel",
"futures-lite",
"futures-util",
"pastey",
"serde",
"zbus",
]
[[package]] [[package]]
name = "latency-probe" name = "latency-probe"
version = "0.5.1" version = "0.6.0"
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
@@ -2136,7 +2251,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "loss-harness" name = "loss-harness"
version = "0.5.1" version = "0.6.0"
dependencies = [ dependencies = [
"punktfunk-core", "punktfunk-core",
] ]
@@ -2561,6 +2676,12 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]] [[package]]
name = "pem" name = "pem"
version = "3.0.6" version = "3.0.6"
@@ -2599,6 +2720,17 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "piper"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]] [[package]]
name = "pipewire" name = "pipewire"
version = "0.9.2" version = "0.9.2"
@@ -2654,6 +2786,20 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "polyval" name = "polyval"
version = "0.6.2" version = "0.6.2"
@@ -2729,7 +2875,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-android" name = "punktfunk-client-android"
version = "0.5.1" version = "0.6.0"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"jni", "jni",
@@ -2743,7 +2889,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-linux" name = "punktfunk-client-linux"
version = "0.5.1" version = "0.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -2765,7 +2911,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-windows" name = "punktfunk-client-windows"
version = "0.5.1" version = "0.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -2788,7 +2934,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-core" name = "punktfunk-core"
version = "0.5.1" version = "0.6.0"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"bytes", "bytes",
@@ -2818,7 +2964,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-host" name = "punktfunk-host"
version = "0.5.1" version = "0.6.0"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
@@ -2887,7 +3033,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-probe" name = "punktfunk-probe"
version = "0.5.1" version = "0.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"mdns-sd", "mdns-sd",
@@ -2899,6 +3045,23 @@ dependencies = [
"tracing-subscriber", "tracing-subscriber",
] ]
[[package]]
name = "punktfunk-tray"
version = "0.6.0"
dependencies = [
"anyhow",
"ksni",
"libc",
"rustls",
"serde",
"serde_json",
"sha2",
"ureq",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
"windows-service",
"winresource",
]
[[package]] [[package]]
name = "quick-error" name = "quick-error"
version = "1.2.3" version = "1.2.3"
@@ -5221,8 +5384,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285"
dependencies = [ dependencies = [
"async-broadcast", "async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion", "async-recursion",
"async-task",
"async-trait", "async-trait",
"blocking",
"enumflags2", "enumflags2",
"event-listener", "event-listener",
"futures-core", "futures-core",
+2 -1
View File
@@ -4,6 +4,7 @@ members = [
"crates/punktfunk-core", "crates/punktfunk-core",
"crates/punktfunk-host", "crates/punktfunk-host",
"crates/punktfunk-host/vendor/usbip-sim", "crates/punktfunk-host/vendor/usbip-sim",
"crates/punktfunk-tray",
"crates/pf-driver-proto", "crates/pf-driver-proto",
"clients/probe", "clients/probe",
"clients/linux", "clients/linux",
@@ -16,7 +17,7 @@ members = [
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"] exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
[workspace.package] [workspace.package]
version = "0.5.1" version = "0.6.0"
edition = "2021" edition = "2021"
rust-version = "1.82" rust-version = "1.82"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+2 -2
View File
@@ -49,7 +49,7 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
| **Core**`punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened | | **Core**`punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened |
| **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads | | **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads |
| **Native protocol**`punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation | | **Native protocol**`punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation |
| **Windows host** (x64) | 🟡 Implemented & shipping as a signed installer: DXGI/WGC capture · its own all-Rust IddCx **virtual display** (secure-desktop capable) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green | | **Windows host** (Windows 11 22H2+, x64) | 🟡 Implemented & shipping as a signed installer: DXGI/WGC capture · its own all-Rust IddCx **virtual display** (secure-desktop capable) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test | | **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test |
| **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch | | **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch |
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing | | **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing |
@@ -82,7 +82,7 @@ Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu — GNOME](https://docs.punktfunk.unom.io/docs/ubuntu-gnome) · [KDE](https://docs.punktfunk.unom.io/docs/ubuntu-kde) | | **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu — GNOME](https://docs.punktfunk.unom.io/docs/ubuntu-gnome) · [KDE](https://docs.punktfunk.unom.io/docs/ubuntu-kde) |
| **Fedora / Bazzite** (rpm-ostree) | `rpm-ostree install punktfunk punktfunk-web` *(or the bootc image)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) · [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) | | **Fedora / Bazzite** (rpm-ostree) | `rpm-ostree install punktfunk punktfunk-web` *(or the bootc image)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) · [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
| **Arch / Steam Deck** (PKGBUILD / sysext) | `makepkg -si` *(Arch)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) | | **Arch / Steam Deck** (PKGBUILD / sysext) | `makepkg -si` *(Arch)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
| **Windows** (x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) | | **Windows** (11 22H2+, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status). `punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
After install, run `punktfunk-host serve` inside your desktop session (the secure native default; After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
+96 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0", "name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0" "identifier": "MIT OR Apache-2.0"
}, },
"version": "0.5.1" "version": "0.6.0"
}, },
"paths": { "paths": {
"/api/v1/clients": { "/api/v1/clients": {
@@ -578,6 +578,41 @@
} }
} }
}, },
"/api/v1/local/summary": {
"get": {
"tags": [
"host"
],
"summary": "Local status summary for the tray icon",
"description": "Non-sensitive status (counts and booleans only — no PIN values, no fingerprints, no device\nnames). Unauthenticated, but served to loopback peers only.",
"operationId": "getLocalSummary",
"responses": {
"200": {
"description": "Non-sensitive local host status (loopback peers only)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LocalSummary"
}
}
}
},
"401": {
"description": "Non-loopback peer",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
},
"security": [
{}
]
}
},
"/api/v1/logs": { "/api/v1/logs": {
"get": { "get": {
"tags": [ "tags": [
@@ -2083,6 +2118,66 @@
} }
} }
}, },
"LocalSummary": {
"type": "object",
"description": "Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source.",
"required": [
"version",
"video_streaming",
"audio_streaming",
"paired_clients",
"native_paired_clients",
"pin_pending",
"pending_approvals"
],
"properties": {
"audio_streaming": {
"type": "boolean",
"description": "True while the audio stream thread is running."
},
"native_paired_clients": {
"type": "integer",
"format": "int32",
"description": "Number of paired native (punktfunk/1) devices.",
"minimum": 0
},
"paired_clients": {
"type": "integer",
"format": "int32",
"description": "Number of pinned (paired) GameStream client certificates.",
"minimum": 0
},
"pending_approvals": {
"type": "integer",
"format": "int32",
"description": "Native pairing knocks awaiting the operator's approval (count only).",
"minimum": 0
},
"pin_pending": {
"type": "boolean",
"description": "True while a GameStream pairing handshake is parked waiting for the user's PIN."
},
"session": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/SessionInfo",
"description": "The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop)."
}
]
},
"version": {
"type": "string",
"description": "Host version (mirrors `/health`)."
},
"video_streaming": {
"type": "boolean",
"description": "True while the video stream thread is running."
}
}
},
"LogEntry": { "LogEntry": {
"type": "object", "type": "object",
"description": "One captured log event.", "description": "One captured log event.",
+19 -2
View File
@@ -37,13 +37,30 @@ def call(method, url, token=None, data=None, content_type=None, want_json=True):
headers["Authorization"] = f"Bearer {token}" headers["Authorization"] = f"Bearer {token}"
if content_type: if content_type:
headers["Content-Type"] = content_type headers["Content-Type"] = content_type
# Transient-fault retries: googleapis.com occasionally drops the TLS session ("EOF
# occurred in violation of protocol" — failed two release uploads on 2026-07-02) or
# answers 5xx. Retry those with backoff; 4xx raises immediately (a real API error).
# The edits API is transactional until commit, so re-sending any of these is safe.
last = None
for attempt in range(4):
if attempt:
delay = 3**attempt
print(f"transient Play API failure ({last}); retry {attempt}/3 in {delay}s")
time.sleep(delay)
req = urllib.request.Request(url, data=data, method=method, headers=headers) req = urllib.request.Request(url, data=data, method=method, headers=headers)
try: try:
with urllib.request.urlopen(req, timeout=300) as r: with urllib.request.urlopen(req, timeout=300) as r:
body = r.read() body = r.read()
except urllib.error.HTTPError as e:
raise ApiError(e.code, method, url, e.read().decode("utf-8", "replace"))
return json.loads(body) if (want_json and body) else body return json.loads(body) if (want_json and body) else body
except urllib.error.HTTPError as e:
if e.code >= 500:
last = f"HTTP {e.code}"
continue
raise ApiError(e.code, method, url, e.read().decode("utf-8", "replace"))
except urllib.error.URLError as e:
last = str(getattr(e, "reason", e))
continue
sys.exit(f"ERROR: {method} {url} still failing after retries: {last}")
def load_sa(): def load_sa():
+216 -47
View File
@@ -2,11 +2,17 @@
//! //!
//! Two planes: //! Two planes:
//! * [`control`] — the low-frequency `DeviceIoControl` plane (add/remove a virtual monitor, pin the //! * [`control`] — the low-frequency `DeviceIoControl` plane (add/remove a virtual monitor, pin the
//! render adapter, keepalive, info, clear-all). Owned, clean, versioned — NOT the SudoVDA ABI. //! render adapter, keepalive, info, clear-all, deliver the frame channel). Owned, clean, versioned —
//! * [`frame`] — the IDD-push frame transport: the host creates a ring of shared keyed-mutex textures //! NOT the SudoVDA ABI.
//! (+ a header + a frame-ready event) and the driver opens them and publishes composited frames into //! * [`frame`] — the IDD-push frame transport: the host creates a ring of **unnamed** shared
//! them. This crate owns the [`frame::SharedHeader`] layout, the [`frame::FrameToken`] packing, the //! keyed-mutex textures (+ a header + a frame-ready event), duplicates their handles into the
//! `Global\` object-name scheme, and the driver-status codes. //! driver's WUDFHost process and delivers the handle VALUES over
//! [`control::IOCTL_SET_FRAME_CHANNEL`]; the driver publishes composited frames into them. There is
//! deliberately no object-name scheme: an unnamed object cannot be enumerated, opened by name, or
//! pre-created ("squatted") — only the two endpoint processes ever hold a handle to any frame object
//! (the sealed channel, `design/idd-push-security.md`). This crate owns the [`frame::SharedHeader`]
//! layout, the [`frame::FrameToken`] packing, the channel-delivery struct, and the driver-status
//! codes.
//! //!
//! Both planes were previously hand-duplicated, byte-for-byte, across `idd_push.rs`/`frame_transport.rs` //! Both planes were previously hand-duplicated, byte-for-byte, across `idd_push.rs`/`frame_transport.rs`
//! and `vdisplay/sudovda.rs`/`control.rs` with only "must match" comments guarding them. Defining them //! and `vdisplay/sudovda.rs`/`control.rs` with only "must match" comments guarding them. Defining them
@@ -43,16 +49,22 @@ pub const fn interface_guid_fields() -> (u32, u16, u16, [u8; 8]) {
/// Bumped on any incompatible change to either plane. Exchanged via [`control::IOCTL_GET_INFO`]; host /// Bumped on any incompatible change to either plane. Exchanged via [`control::IOCTL_GET_INFO`]; host
/// and driver assert a match at startup so a mismatched pair fails loudly instead of corrupting. /// and driver assert a match at startup so a mismatched pair fails loudly instead of corrupting.
pub const PROTOCOL_VERSION: u32 = 1; /// v2: the sealed frame channel — the frame objects are unnamed and delivered by handle duplication
/// ([`control::IOCTL_SET_FRAME_CHANNEL`]), and [`control::AddReply`] grew `wudf_pid` (the duplication
/// target). A v1 driver has no channel-delivery IOCTL and expects named objects, so the pairing is
/// incompatible by design.
pub const PROTOCOL_VERSION: u32 = 2;
/// `CTL_CODE(FILE_DEVICE_UNKNOWN = 0x22, func, METHOD_BUFFERED = 0, FILE_ANY_ACCESS = 0)`. /// `CTL_CODE(FILE_DEVICE_UNKNOWN = 0x22, func, METHOD_BUFFERED = 0, FILE_ANY_ACCESS = 0)`.
pub const fn ctl_code(func: u32) -> u32 { pub const fn ctl_code(func: u32) -> u32 {
(0x22u32 << 16) | (func << 2) (0x22u32 << 16) | (func << 2)
} }
/// The control (`DeviceIoControl`) plane: add/remove a virtual monitor + adapter pin + keepalive. /// The control (`DeviceIoControl`) plane: add/remove a virtual monitor + adapter pin + keepalive +
/// frame-channel delivery.
pub mod control { pub mod control {
use super::ctl_code; use super::ctl_code;
use super::frame::RING_LEN;
use bytemuck::{Pod, Zeroable}; use bytemuck::{Pod, Zeroable};
// Contiguous op space at 0x900 — distinct from SudoVDA's gappy 0x800/0x888/0x8FF numbering. // Contiguous op space at 0x900 — distinct from SudoVDA's gappy 0x800/0x888/0x8FF numbering.
@@ -69,6 +81,10 @@ pub mod control {
/// Tear down every virtual monitor (host-startup orphan reap). No payload. First-class op — NOT the /// Tear down every virtual monitor (host-startup orphan reap). No payload. First-class op — NOT the
/// SudoVDA "send-and-hope-it's-ignored" hack. /// SudoVDA "send-and-hope-it's-ignored" hack.
pub const IOCTL_CLEAR_ALL: u32 = ctl_code(0x905); pub const IOCTL_CLEAR_ALL: u32 = ctl_code(0x905);
/// Deliver a monitor's IDD-push frame channel: the handle VALUES of the unnamed shared objects the
/// host duplicated into the driver's WUDFHost process. Input [`SetFrameChannelRequest`]. Sent once
/// after the ring is created and again on every mid-session ring recreate (HDR-mode flip).
pub const IOCTL_SET_FRAME_CHANNEL: u32 = ctl_code(0x906);
/// `IOCTL_ADD` input. A monotonic `session_id` keys the monitor (the host's refcount manager owns /// `IOCTL_ADD` input. A monotonic `session_id` keys the monitor (the host's refcount manager owns
/// collision safety — no more SudoVDA's 16-byte GUID + pid-mangling). The driver advertises this /// collision safety — no more SudoVDA's 16-byte GUID + pid-mangling). The driver advertises this
@@ -103,6 +119,11 @@ pub mod control {
/// `_reserved` (offset 12): an un-upgraded driver leaves it `0`, so the host can tell its /// `_reserved` (offset 12): an un-upgraded driver leaves it `0`, so the host can tell its
/// preference was ignored (stale driver) and log it instead of silently losing per-client config. /// preference was ignored (stale driver) and log it instead of silently losing per-client config.
pub resolved_monitor_id: u32, pub resolved_monitor_id: u32,
/// The driver's own process id (the WUDFHost hosting `pf_vdisplay`) — the target the host
/// duplicates the unnamed frame-object handles INTO (`OpenProcess(PROCESS_DUP_HANDLE)` +
/// `DuplicateHandle`, then [`IOCTL_SET_FRAME_CHANNEL`]). Reported per-ADD, not per-open, so a
/// WUDFHost restart between sessions can never leave the host duplicating into a dead process.
pub wudf_pid: u32,
} }
/// `IOCTL_REMOVE` input. /// `IOCTL_REMOVE` input.
@@ -129,6 +150,39 @@ pub mod control {
pub watchdog_timeout_s: u32, pub watchdog_timeout_s: u32,
} }
/// `IOCTL_SET_FRAME_CHANNEL` input — the sealed frame channel's bootstrap. Every handle field is a
/// handle VALUE already duplicated into the driver's WUDFHost process by the host; receiving it, the
/// driver OWNS those handles (it closes whatever it doesn't consume — a replaced, invalid, or
/// unmatched delivery must not leak entries in its own handle table).
///
/// Handle values are only meaningful inside the target process's handle table, so this struct is
/// harmless to any third party: reading it leaks nothing openable, and spoofing it (were the control
/// device reachable — it is ACL'd to SYSTEM + admins) could at worst feed the driver values that
/// don't resolve, a DoS of the attacker's own session. The frame objects themselves are unnamed and
/// therefore unreachable by any process that isn't one of the two endpoints.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
pub struct SetFrameChannelRequest {
/// The OS target id from [`AddReply`] — which monitor this channel belongs to.
pub target_id: u32,
/// The ring generation these textures belong to (must match the shared header's generation at
/// attach time; a stale delivery is dropped by the driver — a fresh one follows every recreate).
pub generation: u32,
/// How many leading entries of `texture_handles` are valid (`1..=`[`RING_LEN`]).
pub ring_len: u32,
pub _pad: u32,
/// The shared-header file-mapping handle (the driver maps it and writes status/publish tokens).
pub header_handle: u64,
/// The frame-ready auto-reset event handle (the driver signals it after each publish).
pub event_handle: u64,
/// The ring textures' shared NT handles (opened via `ID3D11Device1::OpenSharedResource1`).
pub texture_handles: [u64; RING_LEN_USIZE],
}
/// [`RING_LEN`] as a usize for the `texture_handles` array length (the wire struct sizes the array
/// at the compile-time maximum; `ring_len` says how many entries are live).
pub const RING_LEN_USIZE: usize = RING_LEN as usize;
// 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.) The `offset_of!` // 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. // asserts additionally catch a SAME-SIZE field reorder, which the size+Pod checks alone miss.
@@ -142,11 +196,20 @@ pub mod control {
assert!(offset_of!(AddRequest, refresh_hz) == 16); assert!(offset_of!(AddRequest, refresh_hz) == 16);
assert!(offset_of!(AddRequest, preferred_monitor_id) == 20); assert!(offset_of!(AddRequest, preferred_monitor_id) == 20);
assert!(size_of::<AddReply>() == 16); assert!(size_of::<AddReply>() == 20);
assert!(offset_of!(AddReply, adapter_luid_low) == 0); assert!(offset_of!(AddReply, adapter_luid_low) == 0);
assert!(offset_of!(AddReply, adapter_luid_high) == 4); assert!(offset_of!(AddReply, adapter_luid_high) == 4);
assert!(offset_of!(AddReply, target_id) == 8); assert!(offset_of!(AddReply, target_id) == 8);
assert!(offset_of!(AddReply, resolved_monitor_id) == 12); assert!(offset_of!(AddReply, resolved_monitor_id) == 12);
assert!(offset_of!(AddReply, wudf_pid) == 16);
assert!(size_of::<SetFrameChannelRequest>() == 32 + 8 * RING_LEN_USIZE);
assert!(offset_of!(SetFrameChannelRequest, target_id) == 0);
assert!(offset_of!(SetFrameChannelRequest, generation) == 4);
assert!(offset_of!(SetFrameChannelRequest, ring_len) == 8);
assert!(offset_of!(SetFrameChannelRequest, header_handle) == 16);
assert!(offset_of!(SetFrameChannelRequest, event_handle) == 24);
assert!(offset_of!(SetFrameChannelRequest, texture_handles) == 32);
assert!(size_of::<RemoveRequest>() == 8); assert!(size_of::<RemoveRequest>() == 8);
assert!(offset_of!(RemoveRequest, session_id) == 0); assert!(offset_of!(RemoveRequest, session_id) == 0);
@@ -161,11 +224,12 @@ pub mod control {
}; };
} }
/// The IDD-push frame transport: the host-created shared ring header, the publish token, the names, and /// The IDD-push frame transport: the host-created shared ring header, the publish token, and the
/// the driver-status codes. The texture ring itself is host-created D3D11 keyed-mutex textures (opened /// driver-status codes. The texture ring itself is host-created **unnamed** D3D11 keyed-mutex textures;
/// by name on the driver side); only the *layout/contract* lives here. /// the driver reaches them (and the header + event) only through handles the host duplicated into its
/// process and delivered via [`crate::control::IOCTL_SET_FRAME_CHANNEL`] — the sealed channel. Only the
/// *layout/contract* lives here.
pub mod frame { pub mod frame {
use alloc::string::String;
use bytemuck::{Pod, Zeroable}; use bytemuck::{Pod, Zeroable};
/// Header magic (`"PFVD"` LE). The host stamps it LAST (after the ring textures exist) so the driver /// Header magic (`"PFVD"` LE). The host stamps it LAST (after the ring textures exist) so the driver
@@ -195,8 +259,10 @@ pub mod frame {
pub struct SharedHeader { pub struct SharedHeader {
pub magic: u32, pub magic: u32,
pub version: u32, pub version: u32,
/// Bumped by the host on a ring recreate (HDR-mode flip → new texture format/names). The driver /// Bumped by the host on a ring recreate (HDR-mode flip → new texture format + a fresh
/// re-attaches when it changes; a publish carries it so the host rejects a stale-ring publish. /// [`control::IOCTL_SET_FRAME_CHANNEL`](crate::control::IOCTL_SET_FRAME_CHANNEL) delivery). The
/// driver re-attaches when it changes; a publish carries it so the host rejects a stale-ring
/// publish.
pub generation: u32, pub generation: u32,
pub ring_len: u32, pub ring_len: u32,
pub width: u32, pub width: u32,
@@ -245,21 +311,6 @@ pub mod frame {
} }
} }
/// `Global\pfvd-hdr-<target>` — the shared metadata header mapping name.
pub fn header_name(target_id: u32) -> String {
alloc::format!("Global\\pfvd-hdr-{target_id}")
}
/// `Global\pfvd-evt-<target>` — the frame-ready auto-reset event name.
pub fn event_name(target_id: u32) -> String {
alloc::format!("Global\\pfvd-evt-{target_id}")
}
/// `Global\pfvd-tex-<target>-<generation>-<slot>` — a ring texture's shared-handle name. The
/// generation in the name means a recreate's new textures never collide with the old ring's
/// not-yet-released handles.
pub fn texture_name(target_id: u32, generation: u32, slot: u32) -> String {
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 // 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 // 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. // `dxgi_format` is what 8-aligns the `u64 latest` at offset 32 — assert that too.
@@ -292,8 +343,10 @@ pub mod frame {
/// (`design/windows-host-rewrite.md` §2.7). Owning them here with `Pod` derives + `offset_of!` /// (`design/windows-host-rewrite.md` §2.7). Owning them here with `Pod` derives + `offset_of!`
/// asserts makes a one-sided edit a compile error. /// asserts makes a one-sided edit a compile error.
/// ///
/// The host creates the section (privileged, permissive DACL so the restricted WUDFHost token can /// Since v2 the channel is **sealed** (`design/gamepad-channel-sealing.md`, mirroring the frame
/// open it) and the driver maps it. Layout only; the section itself is host-created shared memory. /// channel): the host creates the DATA section ([`XusbShm`]/[`PadShm`]) UNNAMED (SYSTEM-only DACL)
/// and duplicates its handle into the driver's WUDFHost; only the tiny [`PadBootstrap`] mailbox
/// stays named (it carries nothing exploitable). Layout only; the sections are host-created.
pub mod gamepad { pub mod gamepad {
use alloc::string::String; use alloc::string::String;
use bytemuck::{Pod, Zeroable}; use bytemuck::{Pod, Zeroable};
@@ -316,15 +369,68 @@ pub mod gamepad {
/// The section starts zeroed, so `0` always means "no driver has attached (yet)"; a pre-health /// The section starts zeroed, so `0` always means "no driver has attached (yet)"; a pre-health
/// driver never writes the field and reads as not-attached, which the host log line calls out /// driver never writes the field and reads as not-attached, which the host log line calls out
/// (the remedy is the same: reinstall the drivers). Bump on a gamepad-layout change. /// (the remedy is the same: reinstall the drivers). Bump on a gamepad-layout change.
pub const GAMEPAD_PROTO_VERSION: u32 = 1; ///
/// v2: the **sealed pad channel** (`design/gamepad-channel-sealing.md`) — the DATA section
/// ([`XusbShm`]/[`PadShm`]) is UNNAMED and reaches the driver only as a handle the host duplicated
/// into its WUDFHost, bootstrapped through the named [`PadBootstrap`] mailbox; the DATA section
/// gained `pad_index` (carved from reserved space) so the driver rejects a cross-pad delivery.
/// A v1 driver opens `Global\pf…-shm-<i>` (which no longer exists) and a v1 host never creates
/// the mailbox a v2 driver polls, so a mixed pairing fails closed either way.
pub const GAMEPAD_PROTO_VERSION: u32 = 2;
/// `Global\pfxusb-shm-<index>` — the virtual Xbox 360 (XInput) shared section. /// Bootstrap-mailbox magic (`"PFBT"` LE) — the host stamps it LAST (after `host_proto`), so a
pub fn xusb_shm_name(index: u8) -> String { /// driver only trusts a fully-initialized mailbox.
alloc::format!("Global\\pfxusb-shm-{index}") pub const BOOT_MAGIC: u32 = 0x5442_4650;
/// `Global\pfxusb-boot-<index>` — the virtual Xbox 360 pad's bootstrap mailbox ([`PadBootstrap`]).
pub fn xusb_boot_name(index: u8) -> String {
alloc::format!("Global\\pfxusb-boot-{index}")
} }
/// `Global\pfds-shm-<index>` — the virtual DualSense / DualShock 4 shared section. /// `Global\pfds-boot-<index>` — the DualSense / DualShock 4 pad's bootstrap mailbox
pub fn pad_shm_name(index: u8) -> String { /// ([`PadBootstrap`]).
alloc::format!("Global\\pfds-shm-{index}") pub fn pad_boot_name(index: u8) -> String {
alloc::format!("Global\\pfds-boot-{index}")
}
/// The per-pad bootstrap mailbox (32 B, named `Global\pf…-boot-<index>`, SY+LS DACL) — the ONLY
/// named object left on the gamepad channel. It exists because the pad drivers are UMDF HID
/// minidrivers with no control device (hidclass owns the stack), so there is no IOCTL to hand the
/// driver a duplicated handle or learn its WUDFHost pid; this mailbox is the late-bound handshake:
///
/// 1. host creates it (zeroed), stamps `host_proto` then `magic` (in that order);
/// 2. driver opens it by name (pad index from `pszDeviceLocation`), writes `driver_proto`, and —
/// iff `host_proto` matches its own version — publishes `driver_pid`;
/// 3. host polls `driver_pid`, verifies the pid is a genuine WUDFHost, duplicates the unnamed DATA
/// section into it, then writes `data_handle` + `handle_pid` and bumps `handle_seq` LAST;
/// 4. driver sees a fresh `handle_seq` addressed to its own pid, maps `data_handle`, and validates
/// the mapped section's magic + `pad_index` before use.
///
/// Deliberately safe to leave named + LS-openable: it carries only pids (not sensitive) and a
/// handle VALUE (meaningless outside the target WUDFHost's handle table). A sibling LocalService
/// that tampers with it can at worst mis-route a delivery — a gamepad DoS, never a read or an
/// injection (it cannot place a valid section handle in the WUDFHost, and the driver's
/// magic+`pad_index` validation rejects any handle that doesn't resolve to this pad's section).
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
pub struct PadBootstrap {
/// [`BOOT_MAGIC`], host-stamped last at creation.
pub magic: u32,
/// The host's [`GAMEPAD_PROTO_VERSION`]. A driver whose own version differs must NOT publish
/// its pid (fail closed) — it still writes `driver_proto` so the host can log the mismatch.
pub host_proto: u32,
/// The driver's WUDFHost process id (driver-written; `0` = no driver yet). The duplication
/// target the host verifies (`verify_is_wudfhost`) before duplicating the DATA section into it.
pub driver_pid: u32,
/// The driver's [`GAMEPAD_PROTO_VERSION`] (driver-written; diagnostics only).
pub driver_proto: u32,
/// The DATA-section handle VALUE the host duplicated into `handle_pid`'s handle table
/// (host-written; valid only inside that process).
pub data_handle: u64,
/// The pid `data_handle` was duplicated for — a driver whose pid differs ignores the delivery.
pub handle_pid: u32,
/// Bumped by the host (host-global monotonic, never 0) AFTER `data_handle`/`handle_pid` are in
/// place — the driver's new-delivery trigger.
pub handle_seq: u32,
} }
/// Virtual Xbox 360 (XInput) shared section (64 B). The host writes the XInput state (a bumped /// Virtual Xbox 360 (XInput) shared section (64 B). The host writes the XInput state (a bumped
@@ -356,7 +462,12 @@ pub mod gamepad {
/// Bumped by the driver on every serviced XInput IOCTL — proves the game-visible path (it /// Bumped by the driver on every serviced XInput IOCTL — proves the game-visible path (it
/// only advances while something polls the slot, so a static value is not an error). /// only advances while something polls the slot, so a static value is not an error).
pub driver_heartbeat: u32, pub driver_heartbeat: u32,
pub _reserved1: [u8; 24], /// The pad index this section serves (host-stamped before the magic). The driver validates it
/// against its own `pszDeviceLocation` index when it maps the delivered handle, so a mis-routed
/// (or bootstrap-tampered) cross-pad delivery is rejected instead of silently cross-wiring two
/// pads. Carved from v1 reserved space (v2).
pub pad_index: u32,
pub _reserved1: [u8; 20],
} }
/// Virtual DualSense / DualShock 4 shared section (256 B). The host writes the `0x01`-style HID /// Virtual DualSense / DualShock 4 shared section (256 B). The host writes the `0x01`-style HID
@@ -384,7 +495,10 @@ pub mod gamepad {
/// Bumped by the driver's ~125 Hz timer each tick — a true liveness heartbeat (unlike the /// Bumped by the driver's ~125 Hz timer each tick — a true liveness heartbeat (unlike the
/// XUSB one, this advances whenever the driver is loaded, game or not). /// XUSB one, this advances whenever the driver is loaded, game or not).
pub driver_heartbeat: u32, pub driver_heartbeat: u32,
pub _reserved1: [u8; 104], /// The pad index this section serves (host-stamped before the magic) — see
/// [`XusbShm::pad_index`]. Carved from v1 reserved space (v2).
pub pad_index: u32,
pub _reserved1: [u8; 100],
} }
// Offsets are the wire contract the shipped drivers already read by hand — pin every one. A failing // Offsets are the wire contract the shipped drivers already read by hand — pin every one. A failing
@@ -408,6 +522,7 @@ pub mod gamepad {
assert!(offset_of!(XusbShm, rumble_small) == 29); assert!(offset_of!(XusbShm, rumble_small) == 29);
assert!(offset_of!(XusbShm, driver_proto) == 32); assert!(offset_of!(XusbShm, driver_proto) == 32);
assert!(offset_of!(XusbShm, driver_heartbeat) == 36); assert!(offset_of!(XusbShm, driver_heartbeat) == 36);
assert!(offset_of!(XusbShm, pad_index) == 40);
assert!(size_of::<PadShm>() == 256); assert!(size_of::<PadShm>() == 256);
assert!(offset_of!(PadShm, magic) == 0); assert!(offset_of!(PadShm, magic) == 0);
@@ -417,6 +532,16 @@ pub mod gamepad {
assert!(offset_of!(PadShm, device_type) == 140); assert!(offset_of!(PadShm, device_type) == 140);
assert!(offset_of!(PadShm, driver_proto) == 144); assert!(offset_of!(PadShm, driver_proto) == 144);
assert!(offset_of!(PadShm, driver_heartbeat) == 148); assert!(offset_of!(PadShm, driver_heartbeat) == 148);
assert!(offset_of!(PadShm, pad_index) == 152);
assert!(size_of::<PadBootstrap>() == 32);
assert!(offset_of!(PadBootstrap, magic) == 0);
assert!(offset_of!(PadBootstrap, host_proto) == 4);
assert!(offset_of!(PadBootstrap, driver_pid) == 8);
assert!(offset_of!(PadBootstrap, driver_proto) == 12);
assert!(offset_of!(PadBootstrap, data_handle) == 16);
assert!(offset_of!(PadBootstrap, handle_pid) == 24);
assert!(offset_of!(PadBootstrap, handle_seq) == 28);
}; };
} }
@@ -487,28 +612,71 @@ mod tests {
adapter_luid_high: -2, adapter_luid_high: -2,
target_id: 262, target_id: 262,
resolved_monitor_id: 7, resolved_monitor_id: 7,
wudf_pid: 4242,
}; };
let rbytes = bytemuck::bytes_of(&reply); let rbytes = bytemuck::bytes_of(&reply);
assert_eq!(rbytes.len(), 16); assert_eq!(rbytes.len(), 20);
assert_eq!(*bytemuck::from_bytes::<control::AddReply>(rbytes), reply); assert_eq!(*bytemuck::from_bytes::<control::AddReply>(rbytes), reply);
// resolved_monitor_id occupies the old `_reserved` slot at offset 12 — byte-compatible. // resolved_monitor_id occupies the old `_reserved` slot at offset 12 — byte-compatible.
assert_eq!(rbytes[12..16], 7u32.to_le_bytes()); assert_eq!(rbytes[12..16], 7u32.to_le_bytes());
// The v2 duplication-target pid trails at offset 16.
assert_eq!(rbytes[16..20], 4242u32.to_le_bytes());
} }
#[test] #[test]
fn names_are_stable() { fn frame_channel_request_roundtrips_through_bytes() {
assert_eq!(frame::header_name(10), "Global\\pfvd-hdr-10"); let mut req = control::SetFrameChannelRequest {
assert_eq!(frame::event_name(10), "Global\\pfvd-evt-10"); target_id: 262,
assert_eq!(frame::texture_name(10, 3, 5), "Global\\pfvd-tex-10-3-5"); generation: 3,
ring_len: frame::RING_LEN,
_pad: 0,
header_handle: 0x0000_0000_0000_1a2c,
event_handle: 0x0000_0000_0000_1b30,
texture_handles: [0; control::RING_LEN_USIZE],
};
for (k, t) in req.texture_handles.iter_mut().enumerate() {
*t = 0x2000 + k as u64 * 4;
}
let bytes = bytemuck::bytes_of(&req);
assert_eq!(bytes.len(), 32 + 8 * control::RING_LEN_USIZE);
assert_eq!(
*bytemuck::from_bytes::<control::SetFrameChannelRequest>(bytes),
req
);
// The handle values ride at 8-byte alignment from offset 16 (header, event, then the ring).
assert_eq!(bytes[16..24], 0x1a2cu64.to_le_bytes());
assert_eq!(bytes[24..32], 0x1b30u64.to_le_bytes());
assert_eq!(bytes[32..40], 0x2000u64.to_le_bytes());
} }
#[test] #[test]
fn gamepad_names_and_magics_are_stable() { fn gamepad_names_and_magics_are_stable() {
assert_eq!(gamepad::xusb_shm_name(0), "Global\\pfxusb-shm-0"); assert_eq!(gamepad::xusb_boot_name(0), "Global\\pfxusb-boot-0");
assert_eq!(gamepad::pad_shm_name(2), "Global\\pfds-shm-2"); assert_eq!(gamepad::pad_boot_name(2), "Global\\pfds-boot-2");
// Lock the exact u32 magics the shipped host/drivers use (inject/{gamepad,dualsense}_windows.rs). // 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::XUSB_MAGIC, 0x5558_4650);
assert_eq!(gamepad::PAD_MAGIC, 0x5046_4453); assert_eq!(gamepad::PAD_MAGIC, 0x5046_4453);
// "PFBT" little-endian.
assert_eq!(gamepad::BOOT_MAGIC.to_le_bytes(), *b"PFBT");
}
#[test]
fn pad_bootstrap_roundtrips_through_bytes() {
let b = gamepad::PadBootstrap {
magic: gamepad::BOOT_MAGIC,
host_proto: gamepad::GAMEPAD_PROTO_VERSION,
driver_pid: 1234,
driver_proto: gamepad::GAMEPAD_PROTO_VERSION,
data_handle: 0x0000_0000_0000_2a4c,
handle_pid: 1234,
handle_seq: 7,
};
let bytes = bytemuck::bytes_of(&b);
assert_eq!(bytes.len(), 32);
assert_eq!(*bytemuck::from_bytes::<gamepad::PadBootstrap>(bytes), b);
// The handle value rides 8-aligned at offset 16; the seq trails at 28 (written LAST by the host).
assert_eq!(bytes[16..24], 0x2a4cu64.to_le_bytes());
assert_eq!(bytes[28..32], 7u32.to_le_bytes());
} }
#[test] #[test]
@@ -521,6 +689,7 @@ mod tests {
control::IOCTL_PING, control::IOCTL_PING,
control::IOCTL_GET_INFO, control::IOCTL_GET_INFO,
control::IOCTL_CLEAR_ALL, control::IOCTL_CLEAR_ALL,
control::IOCTL_SET_FRAME_CHANNEL,
]; ];
for (i, a) in all.iter().enumerate() { for (i, a) in all.iter().enumerate() {
for b in &all[i + 1..] { for b in &all[i + 1..] {
+5 -4
View File
@@ -232,10 +232,11 @@ pf-driver-proto = { path = "../pf-driver-proto" }
bytemuck = { version = "1.19", features = ["derive"] } bytemuck = { version = "1.19", features = ["derive"] }
[features] [features]
# NVENC hardware encode (Windows). OFF by default: it pulls the NVENC SDK, and the host then needs # NVENC hardware encode (Windows). OFF by default (it pulls the NVENC SDK crate); nothing is
# the NVENC entry points (NvEncodeAPICreateInstance / NvEncodeAPIGetMaxSupportedVersion) at link # needed at link time — the entry points are resolved at RUNTIME from the driver's
# time — i.e. `nvencodeapi.lib` from the NVIDIA Video Codec SDK (or an import lib generated from # nvEncodeAPI64.dll (encode/windows/nvenc.rs `load_api`), so the same binary starts fine on
# nvEncodeAPI64.dll) on the linker path. Build the GPU host with `--features nvenc`. # AMD/Intel-only boxes and falls through to AMF/QSV/software. Build the GPU host with
# `--features nvenc`.
nvenc = ["dep:nvidia-video-codec-sdk"] nvenc = ["dep:nvidia-video-codec-sdk"]
# AMD/Intel hardware encode on Windows (AMF/QSV via ffmpeg-next). OFF by default: it needs a # AMD/Intel hardware encode on Windows (AMF/QSV via ffmpeg-next). OFF by default: it needs a
# `FFMPEG_DIR` (BtbN lgpl-shared — includes `*_amf`/`*_qsv`; the GPL-only x264/x265 are never used, # `FFMPEG_DIR` (BtbN lgpl-shared — includes `*_amf`/`*_qsv`; the GPL-only x264/x265 are never used,
+6 -15
View File
@@ -1,10 +1,9 @@
//! Build script. The only thing it does: with the `nvenc` feature (Windows GPU host), tell the //! Build script: stamps the build version. NVENC deliberately needs NOTHING here — the entry
//! linker to pull the NVENC import library. The NVENC entry points //! points (`NvEncodeAPICreateInstance` / `NvEncodeAPIGetMaxSupportedVersion`) live in
//! (`NvEncodeAPICreateInstance` / `NvEncodeAPIGetMaxSupportedVersion`) live in `nvEncodeAPI64.dll` //! `nvEncodeAPI64.dll`, which only exists where the NVIDIA driver is installed, so
//! (shipped with the NVIDIA driver), so the host links against `nvencodeapi.lib`. Point //! `encode/windows/nvenc.rs` resolves them at RUNTIME (`LoadLibraryExW`). The former link-time
//! `PUNKTFUNK_NVENC_LIB_DIR` at a directory containing `nvencodeapi.lib` — from the NVIDIA Video //! import (`cargo:rustc-link-lib=nvencodeapi`) made the Windows loader kill the all-vendor host
//! Codec SDK, or an import lib generated from the driver's `nvEncodeAPI64.dll` //! binary on every AMD/Intel-only box before `main` ("nvencodeapi64.dll was not found").
//! (`lib /def:nvenc.def /machine:x64 /out:nvencodeapi.lib` with the two exports above).
fn main() { fn main() {
// Build provenance: stamp the exact package/build version into the binary so a running host // Build provenance: stamp the exact package/build version into the binary so a running host
// can report what it is (mgmt /health, the startup log, `--version`) and a stale/shadowed // can report what it is (mgmt /health, the startup log, `--version`) and a stale/shadowed
@@ -18,12 +17,4 @@ fn main() {
.unwrap_or_else(|| std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "unknown".into())); .unwrap_or_else(|| std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "unknown".into()));
println!("cargo:rustc-env=PUNKTFUNK_VERSION={version}"); println!("cargo:rustc-env=PUNKTFUNK_VERSION={version}");
println!("cargo:rerun-if-env-changed=PUNKTFUNK_BUILD_VERSION"); println!("cargo:rerun-if-env-changed=PUNKTFUNK_BUILD_VERSION");
if std::env::var_os("CARGO_FEATURE_NVENC").is_some() {
if let Some(dir) = std::env::var_os("PUNKTFUNK_NVENC_LIB_DIR") {
println!("cargo:rustc-link-search=native={}", dir.to_string_lossy());
}
println!("cargo:rustc-link-lib=dylib=nvencodeapi");
println!("cargo:rerun-if-env-changed=PUNKTFUNK_NVENC_LIB_DIR");
}
} }
@@ -42,6 +42,10 @@ pub struct WinCaptureTarget {
pub gdi_name: String, pub gdi_name: String,
/// Stable SudoVDA target id — re-resolved to the current GDI name on every recovery. /// Stable SudoVDA target id — re-resolved to the current GDI name on every recovery.
pub target_id: u32, pub target_id: u32,
/// The pf-vdisplay driver's WUDFHost pid (from the ADD reply) — the process the IDD-push capturer
/// duplicates the sealed frame channel's handles INTO (`idd_push::ChannelBroker`). `0` = unknown
/// (a pre-v2 pairing can't occur — the version handshake is hard — so this only guards misuse).
pub wudf_pid: u32,
} }
/// A GPU-resident captured texture (future NVENC-D3D11 zero-copy path). /// A GPU-resident captured texture (future NVENC-D3D11 zero-copy path).
@@ -1,14 +1,20 @@
//! P2 direct frame push (kill DDA) — HOST side. The pf-vdisplay driver's WUDFHost canNOT create named //! P2 direct frame push (kill DDA) — HOST side, over the **sealed channel**
//! kernel objects, so — exactly like the gamepad UMDF drivers (`inject/dualsense_windows.rs`) — the //! (`design/idd-push-security.md`). The frame channel carries whole-desktop pixels, so its protection
//! HOST (privileged) CREATES the shared header + frame-ready event + ring of keyed-mutex textures //! must match DDA's (where capturer and consumer are one process and there is no openable channel at
//! (`Global\` names, scoped `D:(A;;GA;;;SY)(A;;GA;;;LS)` to SYSTEM + the driver's LocalService host — //! all): the HOST (SYSTEM) creates the shared header + frame-ready event + ring of keyed-mutex textures
//! see `shared_object_sa`) on the discrete render GPU, and the driver only OPENS them and copies frames in. We then consume the ring //! **UNNAMED** on the discrete render GPU — nothing to enumerate, open by name, or pre-create
//! straight into the zero-copy NVENC path — no DXGI Desktop Duplication, no `win32u` hook. Gated by //! ("squat") — then DUPLICATES the handles into the pf-vdisplay driver's WUDFHost process
//! `PUNKTFUNK_IDD_PUSH`. Driver counterpart: `packaging/windows/drivers/pf-vdisplay/src/ //! ([`ChannelBroker`]; SYSTEM can `DuplicateHandle` into the LocalService host, the reverse is
//! correctly denied, which is why the HOST is the broker) and delivers the handle VALUES over the
//! SYSTEM-only control device (`IOCTL_SET_FRAME_CHANNEL`). A handle value is meaningless outside the
//! target process's handle table, so the bootstrap's ACL is not load-bearing; the only way to reach the
//! frames is to already be one of the two endpoint processes. The driver copies frames in; we consume
//! the ring straight into the zero-copy NVENC path — no DXGI Desktop Duplication, no `win32u` hook.
//! Gated by `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 channel-delivery struct and the publish token all come from
//! [`pf_driver_proto::frame`] (which OWNS the contract, with `const` size asserts) — both sides //! [`pf_driver_proto`] (which OWNS the contract, with `const` size asserts) — both sides `use` it, so
//! `use` it, so drift is a compile error rather than a "must match" comment. //! drift is a compile error rather than a "must match" comment.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)] #![deny(clippy::undocumented_unsafe_blocks)]
@@ -16,12 +22,15 @@
use super::dxgi::{make_device, D3d11Frame, HdrP010Converter, VideoConverter, WinCaptureTarget}; 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_driver_proto::frame; use pf_driver_proto::{control, frame};
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
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, PCWSTR, PWSTR};
use windows::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE, LUID}; use windows::Win32::Foundation::{
DuplicateHandle, DUPLICATE_CLOSE_SOURCE, DUPLICATE_HANDLE_OPTIONS, DUPLICATE_SAME_ACCESS,
HANDLE, INVALID_HANDLE_VALUE, LUID,
};
use windows::Win32::Graphics::Direct3D11::{ use windows::Win32::Graphics::Direct3D11::{
ID3D11Device, ID3D11DeviceContext, ID3D11ShaderResourceView, ID3D11Texture2D, ID3D11Device, ID3D11DeviceContext, ID3D11ShaderResourceView, ID3D11Texture2D,
D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX, D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX,
@@ -42,47 +51,43 @@ use windows::Win32::System::Memory::{
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS, CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE, MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
}; };
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject}; use windows::Win32::System::Threading::{
CreateEventW, GetCurrentProcess, OpenProcess, QueryFullProcessImageNameW, WaitForSingleObject,
PROCESS_DUP_HANDLE, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION,
};
// 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_driver_proto::frame`; both sides // `DRV_STATUS_*` codes and the channel-delivery struct — lives in `pf_driver_proto`; both sides
// `use frame::*`, so a layout/name/code drift is a compile error (the proto has `const` size asserts). // `use` it, so a layout/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, SharedHeader, DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED, DRV_STATUS_TEX_FAIL, MAGIC, RING_LEN,
DRV_STATUS_TEX_FAIL, MAGIC, RING_LEN, VERSION, VERSION,
}; };
/// `DXGI_SHARED_RESOURCE_READ | _WRITE` for `CreateSharedHandle`/`OpenSharedResourceByName`. Local (not /// `DXGI_SHARED_RESOURCE_READ | _WRITE` for `CreateSharedHandle`/`OpenSharedResourceByName`. Local (not
/// part of the proto contract — it is a DXGI sharing-API arg, mirrored on the driver side). /// part of the proto contract — it is a DXGI sharing-API arg, mirrored on the driver side).
const DXGI_SHARED_RESOURCE_RW: u32 = 0x8000_0000 | 0x1; const DXGI_SHARED_RESOURCE_RW: u32 = 0x8000_0000 | 0x1;
/// Least access the driver needs on the duplicated **header section**: map it read/write (it reads the
/// layout + writes `driver_status`/`driver_render_luid`/the publish token). `SECTION_MAP_READ |
/// SECTION_MAP_WRITE` (== the driver's `FILE_MAP_READ | FILE_MAP_WRITE` map flag). Duplicating with
/// exactly this — instead of `DUPLICATE_SAME_ACCESS`, which would copy the host's full-access creator
/// handle — is the "grant least privilege" discipline for unnamed shared objects (Raymond Chen,
/// *"unnamed objects aren't safe just because they're unnamed"*): a compromised driver's handle can't
/// `WRITE_DAC`/`WRITE_OWNER`/`DELETE` the object, only map it.
const SECTION_MAP_RW: u32 = 0x0004 | 0x0002;
/// Least access the driver needs on the duplicated **frame-ready event**: it only `SetEvent`s it, which
/// requires `EVENT_MODIFY_STATE`. (The host holds `SYNCHRONIZE` on its own handle to wait.)
const EVENT_MODIFY_STATE: u32 = 0x0002;
/// Host-owned output-ring depth: distinct NVENC-input textures rotated per frame so the in-flight /// Host-owned output-ring depth: distinct NVENC-input textures rotated per frame so the in-flight
/// encode of frame N and the convert/copy of frame N+1 never touch the same texture. 3 covers a /// encode of frame N and the convert/copy of frame N+1 never touch the same texture. 3 covers a
/// pipeline depth of 2 with one slot of margin. /// pipeline depth of 2 with one slot of margin.
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 /// Monotonic per-process generation stamped into the header + every publish token, so the host rejects
/// independent of the per-target header. NOT part of `pf_driver_proto` (a host-side bring-up channel, /// a stale-ring publish and the driver detects a recreate. (With unnamed textures there is no name
/// not the data path); the matching `DebugBlock` lives in the OLD oracle driver's `frame_transport.rs`. /// collision to avoid — the generation's remaining job is the recreate/stale-publish handshake.)
#[repr(C)]
struct DebugBlock {
magic: u32,
run_core_entries: u32,
resolved_target_id: u32,
header_open_attempts: u32,
last_open_error: u32,
header_opened: u32,
render_luid_low: u32,
render_luid_high: i32,
frames_acquired: u32,
_pad: u32,
}
const DBG_NAME: &str = "Global\\pfvd-dbg";
const DBG_MAGIC: u32 = 0x4742_4450;
/// Monotonic per-process generation: each capturer instance stamps its ring-texture names with a
/// fresh value so a retried/overlapping `open()` never collides with a previous attempt's not-yet-
/// released shared-handle names (`DXGI_ERROR_NAME_ALREADY_EXISTS`). The driver reads it from the header.
static IDD_GENERATION: AtomicU32 = AtomicU32::new(1); static IDD_GENERATION: AtomicU32 = AtomicU32::new(1);
fn now_ns() -> u64 { fn now_ns() -> u64 {
@@ -94,7 +99,7 @@ fn now_ns() -> u64 {
/// RAII wrapper for a file-mapping object + its mapped view: on drop the view is `UnmapViewOfFile`'d, /// 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). /// 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 /// A `header` 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 /// 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). /// OS mapping, so the borrowed pointer stays valid).
struct MappedSection { struct MappedSection {
@@ -122,10 +127,9 @@ impl Drop for MappedSection {
struct HostSlot { struct HostSlot {
tex: ID3D11Texture2D, tex: ID3D11Texture2D,
mutex: IDXGIKeyedMutex, mutex: IDXGIKeyedMutex,
/// The named shared-resource handle, held only to keep the resource alive (the driver opens it by /// The UNNAMED shared-resource NT handle: keeps the resource alive for the session AND is the
/// NAME). An [`OwnedHandle`] so it closes on drop (was a manual `CloseHandle` in a `Drop` impl); /// source the [`ChannelBroker`] duplicates into the driver's WUDFHost (the ONLY way the driver can
/// never read directly — its sole purpose is the RAII close. /// reach this texture — there is no name to open). An [`OwnedHandle`] so it closes on drop.
#[allow(dead_code)]
shared: OwnedHandle, 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
@@ -168,28 +172,238 @@ impl Drop for KeyedMutexGuard<'_> {
} }
} }
/// Confirm the process is a genuine system WUDFHost — `%SystemRoot%\System32\WUDFHost.exe` — before a
/// broker duplicates sensitive handles into it. The pid is driver-reported (the frame channel's
/// [`control::AddReply::wudf_pid`], or the gamepad bootstrap's `driver_pid`); a spoofed devnode / a
/// tampered mailbox could name an arbitrary process to receive the channel, so this is the
/// confused-deputy gate. Best-effort image-path identity is proportionate: a fully-compromised REAL
/// driver is already a channel endpoint, and any *other* process (attacker exe, a non-driver pid)
/// fails this WUDFHost image check. `what` names the channel in the error (e.g. `"frame-channel"`);
/// shared with the gamepad sealed channel (`inject/windows/gamepad_raii.rs`).
///
/// # Safety
/// `process` must be a live process handle carrying `PROCESS_QUERY_LIMITED_INFORMATION`.
pub(crate) unsafe fn verify_is_wudfhost(process: HANDLE, wudf_pid: u32, what: &str) -> Result<()> {
let mut buf = [0u16; 512];
let mut len = buf.len() as u32;
// SAFETY: `process` carries QUERY_LIMITED per the contract; `buf`/`len` are a valid out-buffer and
// its capacity, and on success `len` is updated to the count of UTF-16 units written (no NUL).
unsafe {
QueryFullProcessImageNameW(
process,
PROCESS_NAME_WIN32,
PWSTR(buf.as_mut_ptr()),
&mut len,
)
.with_context(|| format!("QueryFullProcessImageNameW on the {what} pid"))?;
}
let path = String::from_utf16_lossy(&buf[..len as usize]);
let got = path.to_ascii_lowercase().replace('/', "\\");
let sysroot = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string());
let expected = format!("{}\\system32\\wudfhost.exe", sysroot.to_ascii_lowercase());
if got != expected {
bail!(
"{what} pid {wudf_pid} is not the system WUDFHost (image={path:?}, expected \
{expected:?}) — refusing to duplicate the channel's handles into it (spoofed driver / \
wrong devnode?)"
);
}
Ok(())
}
/// The sealed channel's handle-duplication broker (`design/idd-push-security.md`): the frame objects
/// are unnamed, so the ONLY way the driver can reach them is handles this broker duplicates into its
/// WUDFHost process and delivers — as bare handle VALUES — over the SYSTEM-only control device
/// (`IOCTL_SET_FRAME_CHANNEL`). Ownership is a strict hand-off: on IOCTL success the DRIVER owns the
/// duplicates (it closes them); on any failure [`Self::send`] reaps every duplicate it already made
/// (`DUPLICATE_CLOSE_SOURCE`), so a half-delivered channel never leaks handles in WUDFHost.
struct ChannelBroker {
/// `PROCESS_DUP_HANDLE` handle to the driver's WUDFHost (pid from the ADD reply;
/// `ProcessSharingDisabled` makes that process exclusively pf-vdisplay's).
process: OwnedHandle,
/// The pf-vdisplay control device — owned by the `VirtualDisplayManager`, never closed for the
/// process lifetime, so holding the bare `HANDLE` is sound.
control: HANDLE,
}
impl ChannelBroker {
/// Open the duplication target. Fails when the driver predates the sealed channel (`wudf_pid == 0`
/// can't survive the v2 version handshake, but guard anyway) or the WUDFHost is gone (device
/// restart mid-open) — either way the caller fails the capture open cleanly.
///
/// `wudf_pid` comes from the driver's ADD reply, so before we duplicate whole-desktop frame handles
/// INTO it we VERIFY it is a genuine system WUDFHost ([`verify_is_wudfhost`]). Without that check a
/// spoofed devnode (same interface GUID) could name an arbitrary process and receive the frames; a
/// fully-compromised REAL pf_vdisplay driver is already a frame endpoint, so this specifically closes
/// the reachable-without-owning-the-driver case (`design/idd-push-security.md` §hardening).
fn open(wudf_pid: u32) -> Result<Self> {
if wudf_pid == 0 {
bail!("driver reported no WUDFHost pid for the frame channel");
}
let control = crate::vdisplay::manager::control_device_handle().context(
"pf-vdisplay control device not open (monitor not created via the manager?)",
)?;
// SAFETY: plain FFI; `wudf_pid` is a copy. The handle (checked by `?`) is owned solely here and
// moved into the `OwnedHandle` (single owner, closes on drop); `verify_is_wudfhost` borrows it
// for the duration of the synchronous check and forms no lasting alias.
let process = unsafe {
let h = OpenProcess(
PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION,
false,
wudf_pid,
)
.context("OpenProcess(PROCESS_DUP_HANDLE) on the driver's WUDFHost")?;
let process = OwnedHandle::from_raw_handle(h.0 as _);
verify_is_wudfhost(HANDLE(process.as_raw_handle()), wudf_pid, "frame-channel")?;
process
};
Ok(Self { process, control })
}
/// Duplicate `h` into the WUDFHost handle table, returning the handle VALUE valid there (and only
/// there — the value is meaningless in any other process). `access = Some(rights)` grants the
/// driver's handle exactly those rights (least privilege — see [`SECTION_MAP_RW`]);
/// `access = None` copies the source handle's access (`DUPLICATE_SAME_ACCESS`), used only where the
/// source is already scoped (the DXGI shared-texture handles, minted by `CreateSharedHandle` with
/// just `DXGI_SHARED_RESOURCE_READ|WRITE`).
///
/// # Safety
/// `h` must be a live handle of the current process.
unsafe fn dup_into(&self, h: HANDLE, access: Option<u32>) -> Result<u64> {
let mut out = HANDLE::default();
let (desired, options) = match access {
Some(rights) => (rights, DUPLICATE_HANDLE_OPTIONS(0)),
None => (0, DUPLICATE_SAME_ACCESS),
};
// SAFETY: `h` is live per the contract; `self.process` is the live PROCESS_DUP_HANDLE target;
// `&mut out` is a valid out-param. Either an explicit least-privilege access mask (options == 0)
// or `DUPLICATE_SAME_ACCESS` (desired ignored) — never both.
unsafe {
DuplicateHandle(
GetCurrentProcess(),
h,
HANDLE(self.process.as_raw_handle()),
&mut out,
desired,
false,
options,
)
}
.context("DuplicateHandle into the driver's WUDFHost")?;
Ok(out.0 as usize as u64)
}
/// Close a handle VALUE inside the WUDFHost table (the failure-path reaper): `DUPLICATE_CLOSE_SOURCE`
/// with no target closes the source handle regardless of the (ignored) result.
fn close_remote(&self, value: u64) {
if value == 0 {
return;
}
// SAFETY: `self.process` is the live duplication target and `value` is a handle value THIS
// broker just created in that process's table (callers only pass back `dup_into` results the
// driver never received); closing it there cannot touch any other process's handles.
unsafe {
let _ = DuplicateHandle(
HANDLE(self.process.as_raw_handle()),
HANDLE(value as usize as *mut core::ffi::c_void),
HANDLE::default(),
std::ptr::null_mut(),
0,
false,
DUPLICATE_CLOSE_SOURCE,
);
}
}
/// Duplicate the whole ring (header + event + every slot texture) into WUDFHost and deliver the
/// values via `IOCTL_SET_FRAME_CHANNEL`. All-or-nothing: on any failure every duplicate already
/// made is reaped remotely and an error returns (the caller fails the open / logs the recreate).
/// The ownership contract with the driver is adopt-on-success only — it closes the handles iff the
/// IOCTL succeeded, we reap them iff it didn't, so no value is ever closed twice.
///
/// # Safety
/// `header` and `event` must be live handles of the current process (the capturer's own section +
/// event, borrowed for this synchronous call).
unsafe fn send(
&self,
target_id: u32,
generation: u32,
header: HANDLE,
event: HANDLE,
slots: &[HostSlot],
) -> Result<()> {
debug_assert!(slots.len() <= control::RING_LEN_USIZE);
let mut req = control::SetFrameChannelRequest {
target_id,
generation,
ring_len: slots.len() as u32,
_pad: 0,
header_handle: 0,
event_handle: 0,
texture_handles: [0; control::RING_LEN_USIZE],
};
// SAFETY: `header`/`event` are live per this fn's contract; each slot's `shared` is the live
// `OwnedHandle` the slot keeps for exactly this purpose.
let result = unsafe { self.duplicate_and_deliver(&mut req, header, event, slots) };
if result.is_err() {
// The driver never adopted the delivery — reap every remote duplicate so nothing lingers.
self.close_remote(req.header_handle);
self.close_remote(req.event_handle);
for v in req.texture_handles {
self.close_remote(v);
}
}
result
}
/// The fallible middle of [`Self::send`]: fill `req` with fresh duplicates, then issue the IOCTL.
/// Split out so `send` can reap whatever landed in `req` when any step errors.
///
/// # Safety
/// As [`Self::send`].
unsafe fn duplicate_and_deliver(
&self,
req: &mut control::SetFrameChannelRequest,
header: HANDLE,
event: HANDLE,
slots: &[HostSlot],
) -> Result<()> {
// SAFETY: forwarded from the caller's contract — `header`/`event`/each `slot.shared` are live
// handles of this process, and `self.control` is the manager's control handle, never closed for
// the process lifetime (`send_frame_channel`'s precondition).
unsafe {
// Least privilege per handle: the header maps read/write, the event is only signalled, and
// the textures keep their already-scoped `CreateSharedHandle` access (see `dup_into`).
req.header_handle = self.dup_into(header, Some(SECTION_MAP_RW))?;
req.event_handle = self.dup_into(event, Some(EVENT_MODIFY_STATE))?;
for (k, s) in slots.iter().enumerate() {
req.texture_handles[k] = self.dup_into(HANDLE(s.shared.as_raw_handle()), None)?;
}
crate::vdisplay::pf_vdisplay::send_frame_channel(self.control, req)
}
}
}
/// Creates + owns the shared ring; yields the driver's frames as [`FramePayload::D3d11`]. /// Creates + owns the shared ring; yields the driver's frames as [`FramePayload::D3d11`].
pub struct IddPushCapturer { pub struct IddPushCapturer {
device: ID3D11Device, device: ID3D11Device,
context: ID3D11DeviceContext, context: ID3D11DeviceContext,
target_id: u32, target_id: u32,
/// Owns the shared-header file mapping + its mapped view (RAII unmap-then-close). Declared BEFORE /// 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 /// `header`, which is a raw pointer borrowed into this view via [`MappedSection::ptr`]. Also the
/// directly (the `header` pointer is) — held purely so the mapping outlives the capturer. /// duplication source for the driver's header handle on every [`ChannelBroker::send`].
#[allow(dead_code)]
section: MappedSection, section: MappedSection,
header: *mut SharedHeader, header: *mut SharedHeader,
event: OwnedHandle, event: OwnedHandle,
/// Owns the bring-up debug section (mapping + view), or `None` when the debug block wasn't created. /// The sealed channel's handle-duplication broker (WUDFHost process + control device); used at open
/// Never read directly (the `dbg_block` pointer is) — held purely for the RAII unmap/close. /// and again on every ring recreate to deliver fresh duplicates.
#[allow(dead_code)] broker: ChannelBroker,
dbg_section: Option<MappedSection>,
dbg_block: *mut DebugBlock,
width: u32, width: u32,
height: u32, height: u32,
slots: Vec<HostSlot>, slots: Vec<HostSlot>,
/// The ring/texture generation, bumped every time the ring is recreated at a new format (the /// The ring/texture generation, bumped every time the ring is recreated at a new format (the
/// display's HDR mode flipped). Stamped into the texture names + the header so the driver re-attaches. /// display's HDR mode flipped). Stamped into the header + each delivery so the driver re-attaches
/// (and so stale-ring publishes are rejected).
generation: u32, generation: u32,
/// The CLIENT's advertised 10-bit capability (= negotiated `bit_depth >= 10`). Only used at `open` /// The CLIENT's advertised 10-bit capability (= negotiated `bit_depth >= 10`). Only used at `open`
/// to PROACTIVELY enable advanced color (so a 10-bit client gets HDR without a manual toggle); it /// to PROACTIVELY enable advanced color (so a 10-bit client gets HDR without a manual toggle); it
@@ -228,25 +442,31 @@ pub struct IddPushCapturer {
status_logged: bool, status_logged: bool,
_keepalive: Box<dyn Send>, _keepalive: Box<dyn Send>,
} }
// SAFETY: `IddPushCapturer` is `!Send` only because of its `*mut SharedHeader`/`*mut DebugBlock` raw // SAFETY: `IddPushCapturer` is `!Send` only because of its `*mut SharedHeader` raw pointer (and the
// pointers (and the COM interfaces). It is created, used, and dropped by a SINGLE thread — the owning // COM interfaces / the broker's bare control `HANDLE`, which is process-global and never closed). It is
// capture/encode thread — never shared: the `ID3D11DeviceContext` is the device's IMMEDIATE context // created, used, and dropped by a SINGLE thread — the owning capture/encode thread — never shared: the
// (single-threaded by D3D11 contract) and is only ever touched from that thread, and the header/ // `ID3D11DeviceContext` is the device's IMMEDIATE context (single-threaded by D3D11 contract) and is
// dbg_block pointers (into mappings this struct owns) are only dereferenced there. `Send` transfers // only ever touched from that thread, and the header pointer (into the mapping this struct owns) is
// ownership to one thread at a time with NO concurrent access; we do not (and must not) claim `Sync`. // 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 {}
/// Build a `SECURITY_ATTRIBUTES` granting GENERIC_ALL to **SYSTEM** (the host creates+publishes the /// Build a `SECURITY_ATTRIBUTES` granting GENERIC_ALL to **SYSTEM only** — `D:P(A;;GA;;;SY)`, protected
/// shared event + texture ring) and **LocalService** (the account the pf_vdisplay WUDFHost runs under, /// (no inherited ACEs), `bInheritHandle: false`. The sealed channel makes this the strictly-minimal
/// which consumes them) — `D:(A;;GA;;;SY)(A;;GA;;;LS)`, the same scoping as the gamepad section. The /// DACL: the objects are UNNAMED and the driver reaches them via **duplicated handles** (which carry the
/// old SDDL granted **Everyone** (`WD`), which let any local user open the `Global\pfvd-*` objects and /// source handle's access — `OpenSharedResourceByName`/`OpenSharedResource1` on a handle does not
/// read captured screen frames (security-review 2026-06-28 #5). Verified on the RTX box (2026-06-29): /// re-check the object DACL against the opener), so the pf_vdisplay WUDFHost (LocalService) no longer
/// the WUDFHost token is `S-1-5-19` (LocalService), SYSTEM integrity, zero restricted SIDs — so SY+LS /// needs a DACL ACE. Dropping the `LS` ACE removes the last theoretical surface where a leaked handle or
/// suffices for the driver and excludes normal user processes. `psd` must outlive `sa`. /// a name-grown-by-accident could be opened by the (many-service-shared) LocalService SID. Empirically
/// confirmed unreachable regardless: a LocalService token is DACL-denied `OpenProcess` on the WUDFHost
/// (`PROCESS_DUP_HANDLE`/`VM_READ`/even `QUERY_LIMITED` → ACCESS_DENIED, tested on the RTX box
/// 2026-07-03), so it cannot dup the handles out either. History: `Global\`-named + world-openable
/// (`WD`, security-review 2026-06-28 #5) → SY+LS-scoped → nameless → now SY-only. `psd` must outlive
/// `sa`. See `design/idd-push-security.md`.
unsafe fn shared_object_sa() -> Result<(SECURITY_ATTRIBUTES, PSECURITY_DESCRIPTOR)> { unsafe fn shared_object_sa() -> Result<(SECURITY_ATTRIBUTES, PSECURITY_DESCRIPTOR)> {
let mut psd = PSECURITY_DESCRIPTOR::default(); let mut psd = PSECURITY_DESCRIPTOR::default();
ConvertStringSecurityDescriptorToSecurityDescriptorW( ConvertStringSecurityDescriptorToSecurityDescriptorW(
w!("D:(A;;GA;;;SY)(A;;GA;;;LS)"), w!("D:P(A;;GA;;;SY)"),
SDDL_REVISION_1, SDDL_REVISION_1,
&mut psd, &mut psd,
None, None,
@@ -262,20 +482,18 @@ unsafe fn shared_object_sa() -> Result<(SECURITY_ATTRIBUTES, PSECURITY_DESCRIPTO
impl IddPushCapturer { impl IddPushCapturer {
/// Create the `RING_LEN` shared keyed-mutex textures for one ring generation, at `format` (matched /// Create the `RING_LEN` shared keyed-mutex textures for one ring generation, at `format` (matched
/// to the display's composition format — FP16 in HDR, BGRA in SDR). Each is shared by the name /// to the display's composition format — FP16 in HDR, BGRA in SDR). Each is shared through an
/// `pfvd-tex-<target>-<generation>-<k>` so the driver opens it; a fresh generation gives fresh names /// UNNAMED NT handle (nothing to open by name — the sealed channel); the driver reaches it only via
/// (so a recreate never collides with the old ring's not-yet-released handles). /// the duplicate the [`ChannelBroker`] sends after the ring is published.
unsafe fn create_ring_slots( unsafe fn create_ring_slots(
device: &ID3D11Device, device: &ID3D11Device,
target_id: u32,
generation: u32,
w: u32, w: u32,
h: u32, h: u32,
format: DXGI_FORMAT, format: DXGI_FORMAT,
) -> Result<Vec<HostSlot>> { ) -> Result<Vec<HostSlot>> {
let (sa, _psd) = shared_object_sa()?; let (sa, _psd) = shared_object_sa()?;
let mut slots = Vec::new(); let mut slots = Vec::new();
for k in 0..RING_LEN { for _ in 0..RING_LEN {
let desc = D3D11_TEXTURE2D_DESC { let desc = D3D11_TEXTURE2D_DESC {
Width: w, Width: w,
Height: h, Height: h,
@@ -304,7 +522,7 @@ impl IddPushCapturer {
.CreateSharedHandle( .CreateSharedHandle(
Some(&sa as *const SECURITY_ATTRIBUTES), Some(&sa as *const SECURITY_ATTRIBUTES),
DXGI_SHARED_RESOURCE_RW, DXGI_SHARED_RESOURCE_RW,
&HSTRING::from(texture_name(target_id, generation, k)), PCWSTR::null(), // UNNAMED — reachable only through the broker's duplicate
) )
.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`). // Own the shared handle so the slot's `Drop` closes it via RAII (was a manual `CloseHandle`).
@@ -381,22 +599,22 @@ impl IddPushCapturer {
// `u32` target id; they read/flip CCD display config and return owned values, borrowing nothing. // `u32` target id; they read/flip CCD display config and return owned values, borrowing nothing.
// - `CreateDXGIFactory1`, `EnumAdapterByLuid`, `make_device`, `shared_object_sa`, `CreateFileMappingW`, // - `CreateDXGIFactory1`, `EnumAdapterByLuid`, `make_device`, `shared_object_sa`, `CreateFileMappingW`,
// `MapViewOfFile`, `CreateEventW`, and `create_ring_slots` are all `?`-checked, so every returned // `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 // interface/handle/view is non-error before use; `&sa`/`&adapter`/`&device` are live borrows that
// are live borrows that outlive each synchronous call, and `sa.lpSecurityDescriptor` stays valid // outlive each synchronous call, and `sa.lpSecurityDescriptor` stays valid because its backing
// because its backing `_psd` is held in scope for the whole block. // `_psd` is held in scope for the whole block.
// - The header mapping is created AND viewed at `bytes == size_of::<SharedHeader>().max(64)`; the // - 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 // 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 // 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 // `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 // within those `bytes` and write THROUGH the raw pointer without forming any `&mut`.
// 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!` // - 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 // 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 // `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`. // 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 // - `broker.send` requires live `header`/`event` handles of this process: both borrow the just-
// `section`/`dbg_section` into `me` leaves them valid (see the `MappedSection` doc comment). // created owned section/event for the duration of that synchronous call.
// - `header` points into the OS mapping, NOT into the `MappedSection` struct, so moving `section`
// into `me` leaves it valid (see the `MappedSection` doc comment).
unsafe { unsafe {
// If we ENABLE advanced color for a 10-bit client, trust it (the driver will compose FP16) and // 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 // size the ring FP16 directly — don't race the advanced_color_enabled poll, which may not have
@@ -428,14 +646,14 @@ impl IddPushCapturer {
let (sa, _psd) = shared_object_sa()?; let (sa, _psd) = shared_object_sa()?;
let bytes = std::mem::size_of::<SharedHeader>().max(64); let bytes = std::mem::size_of::<SharedHeader>().max(64);
// Header. // Header — UNNAMED (the sealed channel: the driver gets a duplicated handle, not a name).
let map = CreateFileMappingW( let map = CreateFileMappingW(
INVALID_HANDLE_VALUE, INVALID_HANDLE_VALUE,
Some(&sa), Some(&sa),
PAGE_READWRITE, PAGE_READWRITE,
0, 0,
bytes as u32, bytes as u32,
&HSTRING::from(header_name(target.target_id)), PCWSTR::null(),
) )
.context("CreateFileMapping(IDD-push header)")?; .context("CreateFileMapping(IDD-push header)")?;
// Own the mapping handle so it (and its view) free via `MappedSection` RAII even on bail. // Own the mapping handle so it (and its view) free via `MappedSection` RAII even on bail.
@@ -463,69 +681,45 @@ impl IddPushCapturer {
// reads this into its `ring_format` and drops any surface that doesn't match. // reads this into its `ring_format` and drops any surface that doesn't match.
(*header).dxgi_format = ring_fmt.0 as u32; (*header).dxgi_format = ring_fmt.0 as u32;
// Frame-ready event (auto-reset). // Frame-ready event (auto-reset) — UNNAMED, like everything on this channel.
let event = CreateEventW( let event = CreateEventW(Some(&sa), false, false, PCWSTR::null())
Some(&sa),
false,
false,
&HSTRING::from(event_name(target.target_id)),
)
.context("CreateEvent(IDD-push)")?; .context("CreateEvent(IDD-push)")?;
let event = OwnedHandle::from_raw_handle(event.0 as _); 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 = Self::create_ring_slots(&device, w, h, ring_fmt)?;
Self::create_ring_slots(&device, target.target_id, generation, w, h, ring_fmt)?;
// Bring-up debug block (fixed name) — the driver writes diagnostics here. Best-effort. // Publish: magic LAST (Release) — the ring must be fully initialized before the driver
let dbg_bytes = std::mem::size_of::<DebugBlock>(); // (which receives the channel strictly afterwards) can observe MAGIC.
let (dbg_section, dbg_block) = match CreateFileMappingW(
INVALID_HANDLE_VALUE,
Some(&sa),
PAGE_READWRITE,
0,
dbg_bytes as u32,
&HSTRING::from(DBG_NAME),
) {
Ok(dm) => {
// 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() {
(None, std::ptr::null_mut()) // `dm` drops → mapping closed
} else {
let section = MappedSection {
handle: dm,
view: dv,
};
let p = section.ptr::<DebugBlock>();
std::ptr::write_bytes(p.cast::<u8>(), 0, dbg_bytes);
(*p).magic = DBG_MAGIC;
(Some(section), p)
}
}
Err(_) => (None, std::ptr::null_mut()),
};
// Publish: magic LAST (Release) — signals the driver the ring is ready to open.
std::sync::atomic::fence(Ordering::Release); std::sync::atomic::fence(Ordering::Release);
(*(std::ptr::addr_of!((*header).magic) as *const AtomicU32)) (*(std::ptr::addr_of!((*header).magic) as *const AtomicU32))
.store(MAGIC, Ordering::Release); .store(MAGIC, Ordering::Release);
// Deliver the sealed channel: duplicate header + event + every slot texture into the
// driver's WUDFHost and hand it the values over the control device. All-or-nothing (the
// broker reaps its remote duplicates on failure), and a failure fails the open — without
// the delivery the driver can never attach.
let broker = ChannelBroker::open(target.wudf_pid)?;
broker
.send(
target.target_id,
generation,
HANDLE(section.handle.as_raw_handle()),
HANDLE(event.as_raw_handle()),
&slots,
)
.context("deliver IDD-push frame channel to the driver")?;
tracing::info!( tracing::info!(
target_id = target.target_id, target_id = target.target_id,
wudf_pid = target.wudf_pid,
render_luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart), render_luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
mode = format!("{w}x{h}"), mode = format!("{w}x{h}"),
display_hdr, display_hdr,
client_10bit, client_10bit,
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 sealed ring + delivered the channel; waiting for the driver \
to attach + publish"
); );
let me = Self { let me = Self {
device, device,
@@ -534,8 +728,7 @@ impl IddPushCapturer {
section, section,
header, header,
event, event,
dbg_section, broker,
dbg_block,
width: w, width: w,
height: h, height: h,
slots, slots,
@@ -659,34 +852,6 @@ impl IddPushCapturer {
} }
} }
/// Log the driver's bring-up diagnostics (the fixed-name debug block) — independent of the
/// per-target header, so it tells us whether the swap-chain processor ran, what target_id it
/// resolved, whether the header opened (+ error), and whether frames flowed.
fn log_debug_block(&self) {
if self.dbg_block.is_null() {
tracing::warn!("IDD push DEBUG: no debug block");
return;
}
// SAFETY: `self.dbg_block` was just checked non-null (the early return above); it points into the
// owned `dbg_section` mapping sized exactly `size_of::<DebugBlock>()` and page-aligned, so it is
// valid + aligned for `DebugBlock`. `d` is a short-lived SHARED reference used only to read the
// fields below; we never form `&mut` into this region, and the driver's cross-process writes are
// aligned `u32`s that don't tear (best-effort bring-up diagnostics).
let d = unsafe { &*self.dbg_block };
tracing::error!(
run_core_entries = d.run_core_entries,
resolved_target_id = d.resolved_target_id,
header_open_attempts = d.header_open_attempts,
last_open_error = format!("0x{:08x}", d.last_open_error),
header_opened = d.header_opened,
driver_render_luid = format!("{:08x}:{:08x}", d.render_luid_high, d.render_luid_low),
frames_acquired = d.frames_acquired,
"IDD push DEBUG: driver-reported diagnostics (run_core_entries=0 ⇒ swap-chain processor \
never ran; resolved_target_id≠ours ⇒ name mismatch; last_open_error 0x80070002 ⇒ header \
not found; frames_acquired=0 ⇒ idle display)"
);
}
/// The output texture format + the [`PixelFormat`] NVENC encodes, driven SOLELY by the DISPLAY's HDR /// The output texture format + the [`PixelFormat`] NVENC encodes, driven SOLELY by the DISPLAY's HDR
/// state (like the WGC path): HDR → `P010` (BT.2020 PQ 10-bit limited) → NVENC Main10, and the client /// state (like the WGC path): HDR → `P010` (BT.2020 PQ 10-bit limited) → NVENC Main10, and the client
/// auto-detects PQ from the HEVC VUI; SDR → `Nv12` (BT.709 8-bit limited). Both are native YUV so /// auto-detects PQ from the HEVC VUI; SDR → `Nv12` (BT.709 8-bit limited). Both are native YUV so
@@ -712,9 +877,10 @@ impl IddPushCapturer {
} }
/// 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 and DELIVERS the
/// header's `latest` so we don't consume a stale slot from the old ring; drops the conversion /// new channel (fresh duplicates of the header + event + the new textures — every delivery is a
/// textures so they rebuild at the new format. /// self-contained handle set the driver owns); clears the header's `latest` so we don't consume a
/// stale slot from the old ring; drops the conversion textures so they rebuild at the new format.
fn recreate_ring(&mut self, new_display_hdr: bool, new_w: u32, new_h: u32) -> 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.width = new_w;
@@ -725,16 +891,8 @@ impl IddPushCapturer {
// borrow of `self.device` (the capturer's own device, on which the slots are created) plus plain // 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 // `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`. // returned slot's texture + keyed mutex belongs to that same `self.device`.
let new_slots = unsafe { let new_slots =
Self::create_ring_slots( unsafe { Self::create_ring_slots(&self.device, self.width, self.height, fmt)? };
&self.device,
self.target_id,
new_gen,
self.width,
self.height,
fmt,
)?
};
// SAFETY: `self.header` is the live, owned shared-header mapping (page-aligned, sized for a // 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 // `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 // references) of correctly-aligned `u64`/`u32` fields, valid for `AtomicU64`/`AtomicU32`; the
@@ -759,6 +917,26 @@ impl IddPushCapturer {
} }
self.slots = new_slots; // drops the old slots → closes their shared handles + SRVs self.slots = new_slots; // drops the old slots → closes their shared handles + SRVs
self.generation = new_gen; self.generation = new_gen;
// Deliver the new generation's channel. The driver's old publisher sees the generation bump
// (`is_stale`), drops (closing its old handles), and re-attaches from this delivery. On failure
// the broker already reaped its remote duplicates; the recover-or-drop window in `try_consume`
// then ends the session cleanly (the driver can never attach to an undelivered ring).
// SAFETY: `broker.send` requires live `header`/`event` handles of this process — both borrow the
// owned `self.section.handle`/`self.event` for the duration of the synchronous call.
if let Err(e) = unsafe {
self.broker.send(
self.target_id,
new_gen,
HANDLE(self.section.handle.as_raw_handle()),
HANDLE(self.event.as_raw_handle()),
&self.slots,
)
} {
tracing::warn!(
error = %format!("{e:#}"),
"IDD push: frame-channel re-delivery failed after ring recreate"
);
}
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.video_conv = None; // converters are sized + HDR-specific → rebuild at the new mode
@@ -982,44 +1160,6 @@ impl IddPushCapturer {
} }
} }
/// Diagnostic observer (O3.1): create the IDD-push ring + debug block as the SYSTEM host (LocalSystem
/// — proper privileges, the gamepad pattern) ALONGSIDE the normal WGC path, which provides the
/// presentation trigger. Logs whether the driver's `run_core` ran and pushed frames into a
/// host-created ring — resolving the `run_core=0` ambiguity (a user-created ring may be unwritable by
/// the driver). Gated by `PUNKTFUNK_IDD_PUSH_OBSERVE`; spawns a short-lived sampling thread.
pub fn spawn_observer(target: WinCaptureTarget, preferred: Option<(u32, u32, u32)>) {
std::thread::spawn(move || {
let tid = target.target_id;
tracing::info!(
target_id = tid,
"IDD push OBSERVER: creating host ring (LocalSystem) + debug block alongside WGC"
);
match IddPushCapturer::open(target, preferred, false, Box::new(())) {
Ok(mut cap) => {
let mut frames = 0u32;
for _ in 0..40 {
match cap.try_consume() {
Ok(Some(_)) => frames += 1,
Ok(None) => {}
Err(e) => tracing::warn!("IDD push OBSERVER: consume error: {e:#}"),
}
std::thread::sleep(Duration::from_millis(750));
}
tracing::info!(
target_id = tid,
frames_from_ring = frames,
"IDD push OBSERVER: sampling done"
);
cap.log_debug_block();
}
Err((e, _keep)) => tracing::warn!(
target_id = tid,
"IDD push OBSERVER: ring open failed: {e:#}"
),
}
});
}
/// The selected render GPU LUID (where the encoder runs), falling back to the monitor's `OsAdapterLuid`. /// The selected render GPU LUID (where the encoder 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) = crate::win_adapter::resolve_render_adapter_luid() { if let Some(l) = crate::win_adapter::resolve_render_adapter_luid() {
@@ -1046,7 +1186,6 @@ impl Capturer for IddPushCapturer {
return Ok(f); return Ok(f);
} }
if Instant::now() > deadline { if Instant::now() > deadline {
self.log_debug_block();
// SAFETY: four in-bounds, aligned reads of the live, owned shared-header mapping — the same // 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; // best-effort diagnostic fields as `log_driver_status_once` (aligned word reads can't tear;
// no reference into the shared region is formed). // no reference into the shared region is formed).
@@ -1093,8 +1232,10 @@ impl Capturer for IddPushCapturer {
impl Drop for IddPushCapturer { impl Drop for IddPushCapturer {
fn drop(&mut self) { fn drop(&mut self) {
self.slots.clear(); self.slots.clear();
// The shared header + debug sections (`MappedSection`) and the frame-ready `event` // The shared header section (`MappedSection`), the frame-ready `event` (`OwnedHandle`) and the
// (`OwnedHandle`) free themselves via RAII (each unmaps its view, then closes its handle). // broker's WUDFHost process handle free themselves via RAII (unmap view, then close handle)
// _keepalive drops after, REMOVEing the virtual display. // nothing of this session's channel outlives the capturer on the host side; the driver's
// duplicates die with its publisher / monitor / WUDFHost (teardown invariant,
// `design/idd-push-security.md`). _keepalive drops after, REMOVEing the virtual display.
} }
} }
+1 -1
View File
@@ -530,7 +530,7 @@ fn open_video_backend(
{ {
anyhow::bail!( anyhow::bail!(
"NVENC requested/detected but this host was built without it — rebuild \ "NVENC requested/detected but this host was built without it — rebuild \
with `--features nvenc` (needs the NVENC SDK's nvencodeapi.lib at link time)" with `--features nvenc`"
) )
} }
} }
+242 -54
View File
@@ -1,7 +1,10 @@
//! NVENC hardware encoder (Windows, D3D11 input) — zero-copy capture→encode on the GPU. //! NVENC hardware encoder (Windows, D3D11 input) — zero-copy capture→encode on the GPU.
//! //!
//! Drives the raw NVENC API via `nvidia_video_codec_sdk::{sys, ENCODE_API}` (the safe `Encoder` //! Drives the raw NVENC API via the `nvidia_video_codec_sdk` `sys` types and a **runtime-loaded**
//! wrapper is CUDA-only). Opens an encode session bound to the **same** `ID3D11Device` as the DXGI //! entry table ([`EncodeApi`] — the crate's `ENCODE_API`/safe `Encoder` are deliberately unused:
//! the safe wrapper is CUDA-only, and its statically-declared entry points would put a load-time
//! `nvEncodeAPI64.dll` import on the all-vendor binary, killing it on every AMD/Intel-only box).
//! Opens an encode session bound to the **same** `ID3D11Device` as the DXGI
//! capturer (the device is carried on `FramePayload::D3d11`), and **encodes the capturer's texture in //! capturer (the device is carried on `FramePayload::D3d11`), and **encodes the capturer's texture in
//! place** — it registers each input texture with NVENC once (cached by pointer) and `encode_picture`s //! place** — it registers each input texture with NVENC once (cached by pointer) and `encode_picture`s
//! it directly, with NO per-frame `CopyResource`. (That's safe because the host encode loop is //! it directly, with NO per-frame `CopyResource`. (That's safe because the host encode loop is
@@ -10,8 +13,10 @@
//! pipelined, the capturer must hand a ring of textures.) Mirrors the Linux NVENC config: CBR + //! pipelined, the capturer must hand a ring of textures.) Mirrors the Linux NVENC config: CBR +
//! ultra-low-latency, infinite GOP, P-frames only, forced-IDR for RFI, in-band SPS/PPS each keyframe. //! ultra-low-latency, infinite GOP, P-frames only, forced-IDR for RFI, in-band SPS/PPS each keyframe.
//! //!
//! 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 and
//! `open`/`submit` only succeed on a GPU box. The software encoder (`super::sw`) is the fallback. //! **starts driver-less** (the DLL resolves at runtime; on an AMD/Intel box [`try_api`] fails
//! cleanly and the AMF/QSV/software backends carry the session). The software encoder
//! (`super::sw`) is the fallback.
//! //!
//! **Two-thread async retrieve** (`PUNKTFUNK_NVENC_ASYNC=1`, opt-in until on-glass validated — //! **Two-thread async retrieve** (`PUNKTFUNK_NVENC_ASYNC=1`, opt-in until on-glass validated —
//! gpu-contention plan §5.B): the NVENC guide mandates that the main thread only *submit* //! gpu-contention plan §5.B): the NVENC guide mandates that the main thread only *submit*
@@ -44,7 +49,182 @@ use windows::Win32::Graphics::Direct3D11::{ID3D11Device, ID3D11Texture2D};
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject}; use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject};
use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv; use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv;
use nvidia_video_codec_sdk::ENCODE_API as API;
// ---------------------------------------------------------------------------------------------
// Runtime-loaded NVENC entry table.
//
// The NVENC entry points live in `nvEncodeAPI64.dll`, which exists ONLY where the NVIDIA driver
// is installed. They must be resolved at runtime (`LoadLibraryExW` + `GetProcAddress`), never as
// a link-time import: the shipped host binary compiles the `nvenc` feature in unconditionally,
// and a load-time DLL import makes the Windows loader refuse to start the process on every
// AMD/Intel-only box ("nvencodeapi64.dll was not found", before `main`) — `encode.rs` never gets
// the chance to dispatch to AMF/QSV. This is the Windows analogue of the Linux host's dlopen'd
// libcuda. Only the two real DLL exports are resolved by name; the rest of the table comes back
// through `NvEncodeAPICreateInstance`.
// ---------------------------------------------------------------------------------------------
/// The `NV_ENCODE_API_FUNCTION_LIST` entries this encoder uses, unwrapped once at load so call
/// sites stay `(api().encode_picture)(…)`. Field names mirror the sdk crate's `EncodeAPI`, whose
/// lazy static must NOT be referenced — it calls the statically-declared externs, which is what
/// demanded the import lib at link time.
struct EncodeApi {
open_encode_session_ex: unsafe extern "C" fn(
*mut nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS,
*mut *mut c_void,
) -> nv::NVENCSTATUS,
initialize_encoder:
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_INITIALIZE_PARAMS) -> nv::NVENCSTATUS,
destroy_encoder: unsafe extern "C" fn(*mut c_void) -> nv::NVENCSTATUS,
get_encode_caps: unsafe extern "C" fn(
*mut c_void,
nv::GUID,
*mut nv::NV_ENC_CAPS_PARAM,
*mut core::ffi::c_int,
) -> nv::NVENCSTATUS,
get_encode_preset_config_ex: unsafe extern "C" fn(
*mut c_void,
nv::GUID,
nv::GUID,
nv::NV_ENC_TUNING_INFO,
*mut nv::NV_ENC_PRESET_CONFIG,
) -> nv::NVENCSTATUS,
create_bitstream_buffer: unsafe extern "C" fn(
*mut c_void,
*mut nv::NV_ENC_CREATE_BITSTREAM_BUFFER,
) -> nv::NVENCSTATUS,
destroy_bitstream_buffer:
unsafe extern "C" fn(*mut c_void, nv::NV_ENC_OUTPUT_PTR) -> nv::NVENCSTATUS,
lock_bitstream:
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_LOCK_BITSTREAM) -> nv::NVENCSTATUS,
unlock_bitstream: unsafe extern "C" fn(*mut c_void, nv::NV_ENC_OUTPUT_PTR) -> nv::NVENCSTATUS,
register_resource:
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_REGISTER_RESOURCE) -> nv::NVENCSTATUS,
unregister_resource:
unsafe extern "C" fn(*mut c_void, nv::NV_ENC_REGISTERED_PTR) -> nv::NVENCSTATUS,
map_input_resource:
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_MAP_INPUT_RESOURCE) -> nv::NVENCSTATUS,
unmap_input_resource:
unsafe extern "C" fn(*mut c_void, nv::NV_ENC_INPUT_PTR) -> nv::NVENCSTATUS,
encode_picture:
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_PIC_PARAMS) -> nv::NVENCSTATUS,
register_async_event:
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_EVENT_PARAMS) -> nv::NVENCSTATUS,
unregister_async_event:
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_EVENT_PARAMS) -> nv::NVENCSTATUS,
invalidate_ref_frames: unsafe extern "C" fn(*mut c_void, u64) -> nv::NVENCSTATUS,
}
/// Local `NVENCSTATUS` → `Result` (replaces the sdk's `result_without_string`, which lives in the
/// crate's `safe` module — code this file must not pull in, see [`EncodeApi`]). The raw status's
/// Debug repr (`NV_ENC_ERR_INVALID_PARAM`, …) is the error payload.
trait NvStatusExt {
fn nv_ok(self) -> std::result::Result<(), nv::NVENCSTATUS>;
}
impl NvStatusExt for nv::NVENCSTATUS {
fn nv_ok(self) -> std::result::Result<(), nv::NVENCSTATUS> {
match self {
nv::NVENCSTATUS::NV_ENC_SUCCESS => Ok(()),
err => Err(err),
}
}
}
/// Resolve the table once per process. `Err` = NVENC genuinely unavailable on this machine (no
/// NVIDIA driver/DLL, or a driver older than our headers) — the entry points
/// ([`NvencD3d11Encoder::open`], [`probe_can_encode_444`]) gate on it and the AMF/QSV/software
/// backends carry on.
fn try_api() -> std::result::Result<&'static EncodeApi, &'static str> {
static TABLE: std::sync::OnceLock<std::result::Result<EncodeApi, String>> =
std::sync::OnceLock::new();
TABLE
.get_or_init(|| {
let table = load_api();
if let Err(e) = &table {
// Once per process. Only reachable when something resolved to NVENC on this box
// (backend misdetect or a forced PUNKTFUNK_ENCODER=nvenc) — say why it will fail.
tracing::warn!("NVENC API unavailable: {e}");
}
table
})
.as_ref()
.map_err(|e| e.as_str())
}
/// The loaded table, for call sites past a [`try_api`] gate — a live session (or the probe's own
/// gate) implies the load succeeded, and the table lives for the process lifetime.
fn api() -> &'static EncodeApi {
try_api().expect("NVENC call before a successful try_api() gate")
}
fn load_api() -> std::result::Result<EncodeApi, String> {
use windows::core::{s, w};
use windows::Win32::System::LibraryLoader::{
GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32,
};
// SAFETY: `LoadLibraryExW`/`GetProcAddress` take static NUL-terminated names; the
// System32-only search path keeps a planted DLL out of the SYSTEM-service process. The two
// transmutes cast the resolved exports to their documented prototypes (nvEncodeAPI.h), the
// same contract the C SDK's own loader applies. `NvEncodeAPIGetMaxSupportedVersion` writes
// one u32 through a live pointer; `NvEncodeAPICreateInstance` fills `list`, a stack-local
// `#[repr(C)]` function list with `version` set, only during the call. The module is never
// freed, so every extracted function pointer stays valid for the process lifetime.
unsafe {
let module = LoadLibraryExW(w!("nvEncodeAPI64.dll"), None, LOAD_LIBRARY_SEARCH_SYSTEM32)
.map_err(|e| format!("nvEncodeAPI64.dll not loadable (no NVIDIA driver?): {e}"))?;
let get_version = GetProcAddress(module, s!("NvEncodeAPIGetMaxSupportedVersion"))
.ok_or("nvEncodeAPI64.dll exports no NvEncodeAPIGetMaxSupportedVersion")?;
let create_instance = GetProcAddress(module, s!("NvEncodeAPICreateInstance"))
.ok_or("nvEncodeAPI64.dll exports no NvEncodeAPICreateInstance")?;
let get_version: unsafe extern "C" fn(*mut u32) -> nv::NVENCSTATUS =
std::mem::transmute(get_version);
let create_instance: unsafe extern "C" fn(
*mut nv::NV_ENCODE_API_FUNCTION_LIST,
) -> nv::NVENCSTATUS = std::mem::transmute(create_instance);
let mut version = 0u32;
get_version(&mut version)
.nv_ok()
.map_err(|e| format!("NvEncodeAPIGetMaxSupportedVersion: {e:?}"))?;
// The sdk's assert_versions_match, minus the panic: an older driver is a clean Err.
let (major, minor) = (version >> 4, version & 0xf);
if (major, minor) < (nv::NVENCAPI_MAJOR_VERSION, nv::NVENCAPI_MINOR_VERSION) {
return Err(format!(
"driver NVENC API {major}.{minor} is older than the host's headers {}.{}\
update the NVIDIA driver",
nv::NVENCAPI_MAJOR_VERSION,
nv::NVENCAPI_MINOR_VERSION
));
}
let mut list = nv::NV_ENCODE_API_FUNCTION_LIST {
version: nv::NV_ENCODE_API_FUNCTION_LIST_VER,
..Default::default()
};
create_instance(&mut list)
.nv_ok()
.map_err(|e| format!("NvEncodeAPICreateInstance: {e:?}"))?;
const MISSING: &str = "NvEncodeAPICreateInstance left an entry point unfilled";
Ok(EncodeApi {
open_encode_session_ex: list.nvEncOpenEncodeSessionEx.ok_or(MISSING)?,
initialize_encoder: list.nvEncInitializeEncoder.ok_or(MISSING)?,
destroy_encoder: list.nvEncDestroyEncoder.ok_or(MISSING)?,
get_encode_caps: list.nvEncGetEncodeCaps.ok_or(MISSING)?,
get_encode_preset_config_ex: list.nvEncGetEncodePresetConfigEx.ok_or(MISSING)?,
create_bitstream_buffer: list.nvEncCreateBitstreamBuffer.ok_or(MISSING)?,
destroy_bitstream_buffer: list.nvEncDestroyBitstreamBuffer.ok_or(MISSING)?,
lock_bitstream: list.nvEncLockBitstream.ok_or(MISSING)?,
unlock_bitstream: list.nvEncUnlockBitstream.ok_or(MISSING)?,
register_resource: list.nvEncRegisterResource.ok_or(MISSING)?,
unregister_resource: list.nvEncUnregisterResource.ok_or(MISSING)?,
map_input_resource: list.nvEncMapInputResource.ok_or(MISSING)?,
unmap_input_resource: list.nvEncUnmapInputResource.ok_or(MISSING)?,
encode_picture: list.nvEncEncodePicture.ok_or(MISSING)?,
register_async_event: list.nvEncRegisterAsyncEvent.ok_or(MISSING)?,
unregister_async_event: list.nvEncUnregisterAsyncEvent.ok_or(MISSING)?,
invalidate_ref_frames: list.nvEncInvalidateRefFrames.ok_or(MISSING)?,
})
}
}
// Output bitstream buffers = max in-flight encodes. The helper deep-pipelines (submits several frames // Output bitstream buffers = max in-flight encodes. The helper deep-pipelines (submits several frames
// before locking the oldest) so per-frame GPU-scheduling waits OVERLAP instead of serializing under a // before locking the oldest) so per-frame GPU-scheduling waits OVERLAP instead of serializing under a
@@ -143,7 +323,7 @@ fn retrieve_loop(
outputBitstream: job.bs as *mut c_void, outputBitstream: job.bs as *mut c_void,
..Default::default() ..Default::default()
}; };
match (API.lock_bitstream)(enc as *mut c_void, &mut lock).result_without_string() { match (api().lock_bitstream)(enc as *mut c_void, &mut lock).nv_ok() {
Ok(()) => { Ok(()) => {
let data = std::slice::from_raw_parts( let data = std::slice::from_raw_parts(
lock.bitstreamBufferPtr as *const u8, lock.bitstreamBufferPtr as *const u8,
@@ -155,7 +335,7 @@ fn retrieve_loop(
nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_IDR nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_IDR
| nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_I | nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_I
); );
let _ = (API.unlock_bitstream)(enc as *mut c_void, job.bs as *mut c_void); let _ = (api().unlock_bitstream)(enc as *mut c_void, job.bs as *mut c_void);
Ok((data, keyframe)) Ok((data, keyframe))
} }
Err(e) => Err(format!("lock_bitstream (async): {e:?}")), Err(e) => Err(format!("lock_bitstream (async): {e:?}")),
@@ -255,6 +435,11 @@ impl NvencD3d11Encoder {
bit_depth: u8, bit_depth: u8,
chroma: ChromaFormat, chroma: ChromaFormat,
) -> Result<Self> { ) -> Result<Self> {
// The runtime DLL load is the real "is NVENC possible here" gate: fail the open with a
// clear reason (backend misdetect / forced PUNKTFUNK_ENCODER=nvenc on a non-NVIDIA box)
// instead of an opaque session error on the first frame. Every later NVENC call in this
// file sits behind this gate (or the probe's), so the infallible `api()` is sound.
try_api().map_err(|e| anyhow!("NVENC unavailable: {e}"))?;
Ok(Self { Ok(Self {
encoder: ptr::null_mut(), encoder: ptr::null_mut(),
codec, codec,
@@ -309,11 +494,11 @@ impl NvencD3d11Encoder {
// Unmap any in-flight inputs, then unregister every cached texture and destroy the bitstreams. // Unmap any in-flight inputs, then unregister every cached texture and destroy the bitstreams.
for (_, map, _) in &self.pending { for (_, map, _) in &self.pending {
if !map.is_null() { if !map.is_null() {
let _ = (API.unmap_input_resource)(self.encoder, *map); let _ = (api().unmap_input_resource)(self.encoder, *map);
} }
} }
for (reg, _tex) in self.regs.values() { for (reg, _tex) in self.regs.values() {
let _ = (API.unregister_resource)(self.encoder, *reg); let _ = (api().unregister_resource)(self.encoder, *reg);
} }
// Async events: unregister from the session, then close the Win32 handles. // Async events: unregister from the session, then close the Win32 handles.
for &ev in &self.events { for &ev in &self.events {
@@ -322,14 +507,14 @@ impl NvencD3d11Encoder {
completionEvent: ev as *mut c_void, completionEvent: ev as *mut c_void,
..Default::default() ..Default::default()
}; };
let _ = (API.unregister_async_event)(self.encoder, &mut ep); let _ = (api().unregister_async_event)(self.encoder, &mut ep);
let _ = CloseHandle(HANDLE(ev as *mut c_void)); let _ = CloseHandle(HANDLE(ev as *mut c_void));
} }
self.events.clear(); self.events.clear();
for &bs in &self.bitstreams { for &bs in &self.bitstreams {
let _ = (API.destroy_bitstream_buffer)(self.encoder, bs); let _ = (api().destroy_bitstream_buffer)(self.encoder, bs);
} }
let _ = (API.destroy_encoder)(self.encoder); let _ = (api().destroy_encoder)(self.encoder);
self.regs.clear(); // drops the texture clones, releasing our refs self.regs.clear(); // drops the texture clones, releasing our refs
self.bitstreams.clear(); self.bitstreams.clear();
self.pending.clear(); self.pending.clear();
@@ -350,9 +535,7 @@ impl NvencD3d11Encoder {
reserved: [0; 62], reserved: [0; 62],
}; };
let mut val: i32 = 0; let mut val: i32 = 0;
match (API.get_encode_caps)(enc, self.codec_guid, &mut param, &mut val) match (api().get_encode_caps)(enc, self.codec_guid, &mut param, &mut val).nv_ok() {
.result_without_string()
{
Ok(()) => val, Ok(()) => val,
Err(_) => 0, Err(_) => 0,
} }
@@ -374,8 +557,8 @@ impl NvencD3d11Encoder {
..Default::default() ..Default::default()
}; };
let mut enc: *mut c_void = ptr::null_mut(); let mut enc: *mut c_void = ptr::null_mut();
(API.open_encode_session_ex)(&mut params, &mut enc) (api().open_encode_session_ex)(&mut params, &mut enc)
.result_without_string() .nv_ok()
.map_err(|e| { .map_err(|e| {
anyhow!("NVENC open_encode_session_ex (caps probe): {e:?} (no NVIDIA GPU?)") anyhow!("NVENC open_encode_session_ex (caps probe): {e:?} (no NVIDIA GPU?)")
})?; })?;
@@ -392,7 +575,7 @@ impl NvencD3d11Encoder {
nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_CUSTOM_VBV_BUF_SIZE, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_CUSTOM_VBV_BUF_SIZE,
); );
let async_enc = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT); let async_enc = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT);
let _ = (API.destroy_encoder)(enc); let _ = (api().destroy_encoder)(enc);
// Reject an over-range mode with a clear message instead of an opaque InvalidParam. // Reject an over-range mode with a clear message instead of an opaque InvalidParam.
if wmax > 0 && hmax > 0 && (self.width as i32 > wmax || self.height as i32 > hmax) { if wmax > 0 && hmax > 0 && (self.width as i32 > wmax || self.height as i32 > hmax) {
@@ -449,8 +632,8 @@ impl NvencD3d11Encoder {
..Default::default() ..Default::default()
}; };
let mut enc: *mut c_void = ptr::null_mut(); let mut enc: *mut c_void = ptr::null_mut();
(API.open_encode_session_ex)(&mut params, &mut enc) (api().open_encode_session_ex)(&mut params, &mut enc)
.result_without_string() .nv_ok()
.map_err(|e| anyhow!("NVENC open_encode_session_ex: {e:?} (no NVIDIA GPU?)"))?; .map_err(|e| anyhow!("NVENC open_encode_session_ex: {e:?} (no NVIDIA GPU?)"))?;
// Seed the P1 + ultra-low-latency preset config. // Seed the P1 + ultra-low-latency preset config.
@@ -462,16 +645,16 @@ impl NvencD3d11Encoder {
}, },
..Default::default() ..Default::default()
}; };
if let Err(e) = (API.get_encode_preset_config_ex)( if let Err(e) = (api().get_encode_preset_config_ex)(
enc, enc,
self.codec_guid, self.codec_guid,
nv::NV_ENC_PRESET_P1_GUID, nv::NV_ENC_PRESET_P1_GUID,
nv::NV_ENC_TUNING_INFO::NV_ENC_TUNING_INFO_ULTRA_LOW_LATENCY, nv::NV_ENC_TUNING_INFO::NV_ENC_TUNING_INFO_ULTRA_LOW_LATENCY,
&mut preset, &mut preset,
) )
.result_without_string() .nv_ok()
{ {
let _ = (API.destroy_encoder)(enc); let _ = (api().destroy_encoder)(enc);
return Err(anyhow!("get_encode_preset_config_ex: {e:?}")); return Err(anyhow!("get_encode_preset_config_ex: {e:?}"));
} }
let mut cfg = preset.presetCfg; let mut cfg = preset.presetCfg;
@@ -613,10 +796,10 @@ impl NvencD3d11Encoder {
// splitEncodeMode is a C bitfield — set via the generated accessor, not a struct field. // splitEncodeMode is a C bitfield — set via the generated accessor, not a struct field.
init.set_splitEncodeMode(split_mode); init.set_splitEncodeMode(split_mode);
match (API.initialize_encoder)(enc, &mut init).result_without_string() { match (api().initialize_encoder)(enc, &mut init).nv_ok() {
Ok(()) => Ok(enc), Ok(()) => Ok(enc),
Err(e) => { Err(e) => {
let _ = (API.destroy_encoder)(enc); let _ = (api().destroy_encoder)(enc);
Err(anyhow!("initialize_encoder: {e:?}")) Err(anyhow!("initialize_encoder: {e:?}"))
} }
} }
@@ -624,8 +807,8 @@ 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 // SAFETY: every call below goes through a function pointer resolved once from the
// `nvidia_video_codec_sdk::ENCODE_API` (`nvEncodeAPI`) table, or through this type's own // runtime-loaded [`EncodeApi`] table (`api()`, gated in `open`), or through this type's own
// `unsafe fn`s whose contract is met here. `query_caps`/`try_open_session` receive `device`, // `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 // 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 // open NVENC session handle or an `Err`. `destroy_encoder` is only ever called on a handle a
@@ -729,7 +912,7 @@ impl NvencD3d11Encoder {
match self.try_open_session(device, mid, split_mode, use_async) { match self.try_open_session(device, mid, split_mode, use_async) {
Ok(e) => { Ok(e) => {
if !best.is_null() { if !best.is_null() {
let _ = (API.destroy_encoder)(best); let _ = (api().destroy_encoder)(best);
} }
best = e; best = e;
best_bps = mid; best_bps = mid;
@@ -778,8 +961,8 @@ impl NvencD3d11Encoder {
version: nv::NV_ENC_CREATE_BITSTREAM_BUFFER_VER, version: nv::NV_ENC_CREATE_BITSTREAM_BUFFER_VER,
..Default::default() ..Default::default()
}; };
(API.create_bitstream_buffer)(enc, &mut cb) (api().create_bitstream_buffer)(enc, &mut cb)
.result_without_string() .nv_ok()
.map_err(|e| anyhow!("create_bitstream_buffer: {e:?}"))?; .map_err(|e| anyhow!("create_bitstream_buffer: {e:?}"))?;
self.bitstreams.push(cb.bitstreamBuffer); self.bitstreams.push(cb.bitstreamBuffer);
} }
@@ -795,8 +978,8 @@ impl NvencD3d11Encoder {
completionEvent: ev.0, completionEvent: ev.0,
..Default::default() ..Default::default()
}; };
(API.register_async_event)(enc, &mut ep) (api().register_async_event)(enc, &mut ep)
.result_without_string() .nv_ok()
.map_err(|e| anyhow!("register_async_event: {e:?}"))?; .map_err(|e| anyhow!("register_async_event: {e:?}"))?;
self.events.push(ev.0 as usize); self.events.push(ev.0 as usize);
} }
@@ -852,7 +1035,7 @@ impl NvencD3d11Encoder {
// path's poll-side unmap, exactly once per mapping. // path's poll-side unmap, exactly once per mapping.
unsafe { unsafe {
if !map.is_null() { if !map.is_null() {
let _ = (API.unmap_input_resource)(self.encoder, map); let _ = (api().unmap_input_resource)(self.encoder, map);
} }
} }
let (data, keyframe) = done.result.map_err(|e| anyhow!("{e}"))?; let (data, keyframe) = done.result.map_err(|e| anyhow!("{e}"))?;
@@ -953,7 +1136,7 @@ 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 // SAFETY: every NVENC call goes through a function pointer from the runtime-loaded `EncodeApi` table
// and takes `self.encoder`, the live session `init_session` just established (non-null on the // 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 = // path that reaches here). `NV_ENC_REGISTER_RESOURCE rr` has `version =
// NV_ENC_REGISTER_RESOURCE_VER` and registers `frame.texture` — a D3D11 texture from // NV_ENC_REGISTER_RESOURCE_VER` and registers `frame.texture` — a D3D11 texture from
@@ -986,8 +1169,8 @@ impl Encoder for NvencD3d11Encoder {
bufferUsage: nv::NV_ENC_BUFFER_USAGE::NV_ENC_INPUT_IMAGE, bufferUsage: nv::NV_ENC_BUFFER_USAGE::NV_ENC_INPUT_IMAGE,
..Default::default() ..Default::default()
}; };
(API.register_resource)(self.encoder, &mut rr) (api().register_resource)(self.encoder, &mut rr)
.result_without_string() .nv_ok()
.map_err(|e| anyhow!("register_resource: {e:?}"))?; .map_err(|e| anyhow!("register_resource: {e:?}"))?;
self.regs self.regs
.insert(key, (rr.registeredResource, frame.texture.clone())); .insert(key, (rr.registeredResource, frame.texture.clone()));
@@ -999,8 +1182,8 @@ impl Encoder for NvencD3d11Encoder {
registeredResource: reg, registeredResource: reg,
..Default::default() ..Default::default()
}; };
(API.map_input_resource)(self.encoder, &mut mp) (api().map_input_resource)(self.encoder, &mut mp)
.result_without_string() .nv_ok()
.map_err(|e| anyhow!("map_input_resource: {e:?}"))?; .map_err(|e| anyhow!("map_input_resource: {e:?}"))?;
let pts = self.frame_idx as u64; let pts = self.frame_idx as u64;
@@ -1076,8 +1259,8 @@ impl Encoder for NvencD3d11Encoder {
Codec::Av1 => {} Codec::Av1 => {}
} }
} }
(API.encode_picture)(self.encoder, &mut pic) (api().encode_picture)(self.encoder, &mut pic)
.result_without_string() .nv_ok()
.map_err(|e| anyhow!("encode_picture: {e:?}"))?; .map_err(|e| anyhow!("encode_picture: {e:?}"))?;
self.pending self.pending
.push_back((self.bitstreams[slot], mp.mappedResource, captured.pts_ns)); .push_back((self.bitstreams[slot], mp.mappedResource, captured.pts_ns));
@@ -1149,7 +1332,7 @@ 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. // SAFETY: `invalidate_ref_frames` is a function pointer from the runtime-loaded `EncodeApi` table.
// `self.encoder` was checked non-null at the top of this fn and is the live session; this runs // `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 // 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 // clamped to `[oldest_in_dpb, frame_idx - 1]` above, so it names a frame still in the session's
@@ -1157,8 +1340,8 @@ impl Encoder for NvencD3d11Encoder {
// lifetime concern. // 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)
.result_without_string() .nv_ok()
.is_err() .is_err()
{ {
return false; // any failure → fall back to IDR return false; // any failure → fall back to IDR
@@ -1195,7 +1378,7 @@ impl Encoder for NvencD3d11Encoder {
}; };
// SAFETY: a non-empty `pending` implies `submit` ran, so `self.encoder` is the live session // 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 // (`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` // pointers from the runtime-loaded `EncodeApi` 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` // (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 // targeted; `lock_bitstream` blocks until that encode finishes, so on success
// `lock.bitstreamBufferPtr` is non-null and points at `lock.bitstreamSizeInBytes` bytes of // `lock.bitstreamBufferPtr` is non-null and points at `lock.bitstreamSizeInBytes` bytes of
@@ -1209,8 +1392,8 @@ impl Encoder for NvencD3d11Encoder {
outputBitstream: bs, outputBitstream: bs,
..Default::default() ..Default::default()
}; };
(API.lock_bitstream)(self.encoder, &mut lock) (api().lock_bitstream)(self.encoder, &mut lock)
.result_without_string() .nv_ok()
.map_err(|e| anyhow!("lock_bitstream: {e:?}"))?; .map_err(|e| anyhow!("lock_bitstream: {e:?}"))?;
let data = std::slice::from_raw_parts( let data = std::slice::from_raw_parts(
lock.bitstreamBufferPtr as *const u8, lock.bitstreamBufferPtr as *const u8,
@@ -1221,11 +1404,11 @@ impl Encoder for NvencD3d11Encoder {
lock.pictureType, lock.pictureType,
nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_IDR | nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_I nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_IDR | nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_I
); );
(API.unlock_bitstream)(self.encoder, bs) (api().unlock_bitstream)(self.encoder, bs)
.result_without_string() .nv_ok()
.map_err(|e| anyhow!("unlock_bitstream: {e:?}"))?; .map_err(|e| anyhow!("unlock_bitstream: {e:?}"))?;
if !map.is_null() { if !map.is_null() {
let _ = (API.unmap_input_resource)(self.encoder, map); let _ = (api().unmap_input_resource)(self.encoder, map);
} }
Ok(Some(EncodedFrame { Ok(Some(EncodedFrame {
data, data,
@@ -1267,6 +1450,11 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
if codec != Codec::H265 { if codec != Codec::H265 {
return false; return false;
} }
// No loadable NVENC on this box (non-NVIDIA / no driver) → the honest 4:4:4 answer is "no".
// This is also the `api()` gate for every NVENC call below.
if try_api().is_err() {
return false;
}
// SAFETY: a self-contained probe owning every handle it creates. `CreateDXGIFactory1`/ // SAFETY: a self-contained probe owning every handle it creates. `CreateDXGIFactory1`/
// `EnumAdapterByLuid` return owned COM objects or err (→ default-adapter fallback). // `EnumAdapterByLuid` return owned COM objects or err (→ default-adapter fallback).
// `D3D11CreateDevice` (explicit adapter + UNKNOWN driver type, or NULL adapter + HARDWARE) // `D3D11CreateDevice` (explicit adapter + UNKNOWN driver type, or NULL adapter + HARDWARE)
@@ -1321,8 +1509,8 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
..Default::default() ..Default::default()
}; };
let mut enc: *mut c_void = ptr::null_mut(); let mut enc: *mut c_void = ptr::null_mut();
if (API.open_encode_session_ex)(&mut params, &mut enc) if (api().open_encode_session_ex)(&mut params, &mut enc)
.result_without_string() .nv_ok()
.is_err() .is_err()
{ {
return false; return false;
@@ -1333,11 +1521,11 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
reserved: [0; 62], reserved: [0; 62],
}; };
let mut val: i32 = 0; let mut val: i32 = 0;
let ok = (API.get_encode_caps)(enc, nv::NV_ENC_CODEC_HEVC_GUID, &mut param, &mut val) let ok = (api().get_encode_caps)(enc, nv::NV_ENC_CODEC_HEVC_GUID, &mut param, &mut val)
.result_without_string() .nv_ok()
.is_ok() .is_ok()
&& val != 0; && val != 0;
let _ = (API.destroy_encoder)(enc); let _ = (api().destroy_encoder)(enc);
ok ok
} }
} }
+12 -5
View File
@@ -820,8 +820,10 @@ mod tests {
#[test] #[test]
fn sender_delivers_batches() { fn sender_delivers_batches() {
let rx_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); let rx_sock = UdpSocket::bind("127.0.0.1:0").unwrap();
// Generous: on a CI host saturated by parallel release builds, this thread can be
// starved for whole seconds between recv() wakeups.
rx_sock rx_sock
.set_read_timeout(Some(Duration::from_secs(3))) .set_read_timeout(Some(Duration::from_secs(10)))
.unwrap(); .unwrap();
let tx_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); let tx_sock = UdpSocket::bind("127.0.0.1:0").unwrap();
tx_sock.connect(rx_sock.local_addr().unwrap()).unwrap(); tx_sock.connect(rx_sock.local_addr().unwrap()).unwrap();
@@ -837,10 +839,15 @@ mod tests {
) )
.unwrap(); .unwrap();
// 3 frames of 100 packets, content-tagged for verification. // 3 frames of 20 packets, content-tagged for verification. The TOTAL burst must fit
// the receive socket's DEFAULT buffer even if this thread never drains concurrently
// (a starved CI runner): a 1200 B datagram costs ~2.5 KB kernel truesize, and the
// default rmem (~212 KB) holds only ~80 — a bigger burst gets silently dropped by
// the kernel and the test can never complete (the old 3×100 flaked exactly there).
const PER_FRAME: usize = 20;
let mut sent = Vec::new(); let mut sent = Vec::new();
for f in 0..3u8 { for f in 0..3u8 {
let batch: PacketBatch = (0..100u8) let batch: PacketBatch = (0..PER_FRAME as u8)
.map(|i| { .map(|i| {
let mut p = vec![0u8; 1200]; let mut p = vec![0u8; 1200];
p[0] = f; p[0] = f;
@@ -859,10 +866,10 @@ mod tests {
let n = rx_sock.recv(&mut buf).expect("packet within timeout"); let n = rx_sock.recv(&mut buf).expect("packet within timeout");
assert_eq!(n, 1200); assert_eq!(n, 1200);
let (f, i) = (buf[0] as usize, buf[1] as usize); let (f, i) = (buf[0] as usize, buf[1] as usize);
assert_eq!(&buf[..n], &sent[f * 100 + i][..], "payload intact"); assert_eq!(&buf[..n], &sent[f * PER_FRAME + i][..], "payload intact");
got += 1; got += 1;
} }
assert_eq!(got, 300); assert_eq!(got, 3 * PER_FRAME);
assert!(running.load(Ordering::SeqCst), "no spurious client-gone"); assert!(running.load(Ordering::SeqCst), "no spurious client-gone");
} }
} }
@@ -1,15 +1,16 @@
//! Virtual Sony DualSense on Windows via the UMDF minidriver (`packaging/windows/dualsense-driver`). //! Virtual Sony DualSense on Windows via the UMDF minidriver (`packaging/windows/drivers/pf-dualsense`).
//! //!
//! The Windows analogue of the Linux UHID backend ([`super::dualsense`]): same [`DsState`] model and //! The Windows analogue of the Linux UHID backend ([`super::dualsense`]): same [`DsState`] model and
//! the same byte-level report codec ([`super::dualsense_proto`]), but a different transport. Where //! the same byte-level report codec ([`super::dualsense_proto`]), but a different transport. Where
//! the Linux backend writes report `0x01` to `/dev/uhid` and reads report `0x02` via `UHID_OUTPUT`, //! the Linux backend writes report `0x01` to `/dev/uhid` and reads report `0x02` via `UHID_OUTPUT`,
//! the Windows backend talks to the UMDF driver over a **named shared-memory section** //! the Windows backend talks to the UMDF driver over an **unnamed shared DATA section** (256 B `PadShm`:
//! `Global\pfds-shm-<idx>` (256 B: magic `u32@0`, input report `@8`, output seq `u32@72`, output //! magic `u32@0`, input report `@8`, output seq `u32@72`, output report `@76`) reached over the
//! report `@76`). The host creates the section (privileged → a permissive SDDL so the WUDFHost can //! **sealed channel** ([`PadChannel`], `design/gamepad-channel-sealing.md`): the host duplicates the
//! open it); the driver maps it from its timer, feeds game `READ_REPORT`s from the input bytes, and //! section handle into the driver's WUDFHost, bootstrapped via the named `Global\pfds-boot-<idx>`
//! publishes a game's `0x02` (rumble / lightbar / player-LEDs / adaptive triggers) into the output //! mailbox. The driver feeds game `READ_REPORT`s from the input bytes and publishes a game's `0x02`
//! bytes. `hidclass` gates the device stack, so this user-mode IPC is the only viable channel (a //! (rumble / lightbar / player-LEDs / adaptive triggers) into the output bytes. `hidclass` gates the
//! UMDF driver has no control device); see `windows-dualsense-scoping.md`. //! device stack, so this user-mode IPC is the only viable channel (a UMDF driver has no control
//! device); see `windows-dualsense-scoping.md`.
//! //!
//! Device lifecycle: each pad `SwDeviceCreate`s a `pf_pad_<index>` software devnode (hardware id //! Device lifecycle: each pad `SwDeviceCreate`s a `pf_pad_<index>` software devnode (hardware id
//! `pf_dualsense`, enumerator `punktfunk`) on open and `SwDeviceClose`s it on drop, so the virtual //! `pf_dualsense`, enumerator `punktfunk`) on open and `SwDeviceClose`s it on drop, so the virtual
@@ -20,12 +21,13 @@ use super::dualsense_proto::{
parse_ds_output, serialize_state, DsFeedback, DsState, DS_INPUT_REPORT_LEN, DS_TOUCH_H, parse_ds_output, serialize_state, DsFeedback, DsState, DS_INPUT_REPORT_LEN, DS_TOUCH_H,
DS_TOUCH_W, DS_TOUCH_W,
}; };
use super::gamepad_raii::PadChannel;
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use punktfunk_core::quic::{HidOutput, RichInput}; use punktfunk_core::quic::{HidOutput, RichInput};
use std::ffi::c_void; use std::ffi::c_void;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use windows::core::{w, GUID, HRESULT, HSTRING, PCWSTR}; use windows::core::{w, GUID, HRESULT, 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,
}; };
@@ -49,17 +51,19 @@ pub(super) const OFF_DEVTYPE: usize =
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, device_type); core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, device_type);
pub(super) const OFF_DRIVER_PROTO: usize = pub(super) const OFF_DRIVER_PROTO: usize =
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, driver_proto); core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, driver_proto);
pub(super) const OFF_PAD_INDEX: usize =
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, pad_index);
pub(super) const DEVTYPE_DUALSHOCK4: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSHOCK4; 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 sealed shared-memory channel.
/// Dropping it removes the devnode (`SwDeviceClose`) and unmaps + closes the section. /// Dropping it removes the devnode (`SwDeviceClose`) and closes both sections.
struct DsWinPad { struct DsWinPad {
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop). /// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
/// `None` falls back to an out-of-band `pf_dualsense` devnode (installer/devgen). /// `None` falls back to an out-of-band `pf_dualsense` devnode (installer/devgen).
_sw: Option<super::gamepad_raii::SwDevice>, _sw: Option<super::gamepad_raii::SwDevice>,
/// The named shared section the driver maps (RAII — unmapped + closed on drop). /// The sealed channel: unnamed DATA section (`PadShm`) + bootstrap mailbox + handle delivery.
shm: super::gamepad_raii::Shm, channel: PadChannel,
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis. /// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
attach: super::gamepad_raii::DriverAttach, attach: super::gamepad_raii::DriverAttach,
seq: u8, seq: u8,
@@ -184,7 +188,7 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
.encode_utf16() .encode_utf16()
.chain(std::iter::once(0)) .chain(std::iter::once(0))
.collect(); .collect();
// The pad index, stamped into the device Location — the driver reads it to map `pfds-shm-<index>` // The pad index, stamped into the device Location — the driver reads it to poll `pfds-boot-<index>`
// (multi-pad). The buffer outlives the SwDeviceCreate call (we wait on the event before return). // (multi-pad). The buffer outlives the SwDeviceCreate call (we wait on the event before return).
let loc: Vec<u16> = format!("{}", p.container_index) let loc: Vec<u16> = format!("{}", p.container_index)
.encode_utf16() .encode_utf16()
@@ -266,17 +270,20 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
} }
impl DsWinPad { impl DsWinPad {
/// Create + map the section `Global\pfds-shm-<index>`, stamp the magic, then spawn the /// Create the sealed channel (unnamed DATA section + `Global\pfds-boot-<index>` mailbox), stamp
/// `root\pf_dualsense` devnode (the driver loads on it and maps the section). The devnode lives /// the pad index + neutral report + the magic LAST, then spawn the `pf_pad_<index>` devnode (the
/// for the pad's lifetime — dropping the pad removes it (`SwDeviceClose`). /// driver loads on it and receives the DATA handle over the bootstrap). The devnode lives for the
/// pad's lifetime — dropping the pad removes it (`SwDeviceClose`).
fn open(index: u8) -> Result<DsWinPad> { fn open(index: u8) -> Result<DsWinPad> {
let shm_name = pf_driver_proto::gamepad::pad_shm_name(index); let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
let shm = super::gamepad_raii::Shm::create(&HSTRING::from(shm_name.as_str()), SHM_SIZE)?; let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
let base = shm.base(); let base = channel.data_base();
// Stamp the neutral input report, then the magic LAST (the driver only accepts the section // Stamp the pad index (the driver validates it on attach) + the neutral input report, then
// once magic is set). The device-type stays 0 (DualSense — the section is already zeroed). // the magic LAST (the driver only accepts the section once magic is set). The device-type
// SAFETY: base points at SHM_SIZE writable bytes. // stays 0 (DualSense — the section arrives zeroed).
// SAFETY: base points at SHM_SIZE writable bytes; OFF_PAD_INDEX/OFF_INPUT are in range.
unsafe { unsafe {
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS_INPUT_REPORT_LEN], { std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS_INPUT_REPORT_LEN], {
let mut r = [0u8; DS_INPUT_REPORT_LEN]; let mut r = [0u8; DS_INPUT_REPORT_LEN];
serialize_state(&mut r, &DsState::neutral(), 0, 0); serialize_state(&mut r, &DsState::neutral(), 0, 0);
@@ -286,7 +293,7 @@ impl DsWinPad {
} }
// Spawn the per-session devnode via SwDeviceCreate; `SwDeviceClose` removes it on drop. On the // Spawn the per-session devnode via SwDeviceCreate; `SwDeviceClose` removes it on drop. On the
// rare failure we keep the section + data plane and fall back to an out-of-band `pf_dualsense` // rare failure we keep the section + data plane and fall back to an out-of-band `pf_dualsense`
// devnode (installer / dev-box devgen). // devnode (installer / dev-box devgen) — its persistent driver polls the same mailbox name.
let inst = format!("pf_pad_{index}"); let inst = format!("pf_pad_{index}");
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile { let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
instance: &inst, instance: &inst,
@@ -302,14 +309,17 @@ impl DsWinPad {
} }
}; };
let _sw = hsw.map(super::gamepad_raii::SwDevice::new); let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
// Bounded eager delivery so the driver holds the DATA section before hidclass asks it for
// descriptors (the driver reads `device_type` from the section to pick its HID identity).
channel.deliver_eager(Duration::from_millis(1500));
Ok(DsWinPad { Ok(DsWinPad {
_sw, _sw,
shm, channel,
attach: super::gamepad_raii::DriverAttach::new( attach: super::gamepad_raii::DriverAttach::new(
"pf_dualsense", "pf_dualsense",
"pf_dualsense.inf", "pf_dualsense.inf",
"C:\\Users\\Public\\pfds-driver.log", "C:\\Users\\Public\\pfds-driver.log",
shm_name, boot_name,
instance_id, instance_id,
), ),
seq: 0, seq: 0,
@@ -326,30 +336,40 @@ impl DsWinPad {
serialize_state(&mut r, st, self.seq, self.ts); serialize_state(&mut r, st, self.seq, self.ts);
// SAFETY: base 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 { unsafe {
std::ptr::copy_nonoverlapping(r.as_ptr(), self.shm.base().add(OFF_INPUT), r.len()) std::ptr::copy_nonoverlapping(
r.as_ptr(),
self.channel.data_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 /// [`DsFeedback`] for pad `pad`. Returns empty feedback if the driver hasn't published anything
/// new. Also feeds the driver-attach health watcher (the driver's ~125 Hz timer stamps /// new. Also ticks the sealed-channel delivery and feeds the driver-attach health watcher (the
/// `driver_proto` while it has the section mapped). /// driver's ~125 Hz timer stamps `driver_proto` while it has the section mapped).
fn service(&mut self, pad: u8) -> DsFeedback { fn service(&mut self, pad: u8) -> DsFeedback {
self.channel.pump();
let mut fb = DsFeedback::default(); let mut fb = DsFeedback::default();
// SAFETY: base points at SHM_SIZE bytes. // SAFETY: base points at SHM_SIZE bytes.
let proto = unsafe { let proto = unsafe {
std::ptr::read_unaligned(self.shm.base().add(OFF_DRIVER_PROTO) as *const u32) std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32)
}; };
self.attach.observe(proto); self.attach.observe(proto);
// SAFETY: base points at SHM_SIZE bytes. // SAFETY: base points at SHM_SIZE bytes.
let seq = let seq = unsafe {
unsafe { std::ptr::read_unaligned(self.shm.base().add(OFF_OUT_SEQ) as *const u32) }; std::ptr::read_unaligned(self.channel.data_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.shm.base().add(OFF_OUTPUT), out.as_mut_ptr(), 64) std::ptr::copy_nonoverlapping(
self.channel.data_base().add(OFF_OUTPUT),
out.as_mut_ptr(),
64,
)
}; };
parse_ds_output(pad, &out, &mut fb); parse_ds_output(pad, &out, &mut fb);
} }
@@ -1,33 +1,33 @@
//! Virtual Sony DualShock 4 on Windows via the UMDF minidriver — the PS4 sibling of //! Virtual Sony DualShock 4 on Windows via the UMDF minidriver — the PS4 sibling of
//! [`super::dualsense_windows`]. Same transport (a per-session `SwDeviceCreate` devnode + the //! [`super::dualsense_windows`]. Same transport (a per-session `SwDeviceCreate` devnode + the sealed
//! `Global\pfds-shm-<idx>` shared section the driver maps), same controller model ([`DsState`]); only //! shared-memory channel bootstrapped via `Global\pfds-boot-<idx>`), same controller model
//! the PnP identity (`VID_054C&PID_09CC`, hardware id `pf_dualshock4`) and the report codec //! ([`DsState`]); only the PnP identity (`VID_054C&PID_09CC`, hardware id `pf_dualshock4`) and the
//! ([`super::dualshock4_proto`]) differ. The host stamps `device_type = 1` (DualShock 4) into the //! report codec ([`super::dualshock4_proto`]) differ. The host stamps `device_type = 1` (DualShock 4)
//! section so the one UMDF driver serves the DS4 descriptor / attributes / features instead of the //! into the DATA section so the one UMDF driver serves the DS4 descriptor / attributes / features
//! DualSense ones. Feedback is motor rumble (universal 0xCA plane) + the lightbar (0xCD `Led`); a DS4 //! instead of the DualSense ones. Feedback is motor rumble (universal 0xCA plane) + the lightbar
//! has no adaptive triggers / player LEDs. //! (0xCD `Led`); a DS4 has no adaptive triggers / player LEDs.
use super::dualsense_proto::DsState; use super::dualsense_proto::DsState;
use super::dualsense_windows::{ use super::dualsense_windows::{
create_swdevice, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE, OFF_DRIVER_PROTO, OFF_INPUT, create_swdevice, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE, OFF_DRIVER_PROTO, OFF_INPUT,
OFF_OUTPUT, OFF_OUT_SEQ, SHM_MAGIC, SHM_SIZE, OFF_OUTPUT, OFF_OUT_SEQ, OFF_PAD_INDEX, 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,
}; };
use super::gamepad_raii::PadChannel;
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::time::{Duration, Instant}; use std::time::{Duration, Instant};
use windows::core::HSTRING;
/// 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 sealed
/// shared section. Dropping it removes the devnode and unmaps + closes the section. /// shared-memory channel. Dropping it removes the devnode and closes both sections.
struct Ds4WinPad { struct Ds4WinPad {
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop). /// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
_sw: Option<super::gamepad_raii::SwDevice>, _sw: Option<super::gamepad_raii::SwDevice>,
/// The named shared section the driver maps (RAII — unmapped + closed on drop). /// The sealed channel: unnamed DATA section (`PadShm`) + bootstrap mailbox + handle delivery.
shm: super::gamepad_raii::Shm, channel: PadChannel,
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis. /// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
attach: super::gamepad_raii::DriverAttach, attach: super::gamepad_raii::DriverAttach,
counter: u8, counter: u8,
@@ -36,16 +36,19 @@ struct Ds4WinPad {
} }
impl Ds4WinPad { impl Ds4WinPad {
/// Create + map the section, stamp `device_type = DualShock 4` + a neutral report + the magic, /// Create the sealed channel, stamp `device_type = DualShock 4` + the pad index + a neutral
/// then spawn the `pf_ds4_<index>` devnode (the driver loads on it and maps the section). /// report + the magic LAST, then spawn the `pf_ds4_<index>` devnode (the driver loads on it and
/// receives the DATA handle over the bootstrap).
fn open(index: u8) -> Result<Ds4WinPad> { fn open(index: u8) -> Result<Ds4WinPad> {
let shm_name = pf_driver_proto::gamepad::pad_shm_name(index); let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
let shm = super::gamepad_raii::Shm::create(&HSTRING::from(shm_name.as_str()), SHM_SIZE)?; let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
let base = shm.base(); let base = channel.data_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), pad index, neutral report,
// SAFETY: base points at SHM_SIZE writable bytes; OFF_DEVTYPE/OFF_INPUT are in range. // magic LAST.
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range.
unsafe { unsafe {
*base.add(OFF_DEVTYPE) = DEVTYPE_DUALSHOCK4; *base.add(OFF_DEVTYPE) = DEVTYPE_DUALSHOCK4;
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS4_INPUT_REPORT_LEN], { std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS4_INPUT_REPORT_LEN], {
let mut r = [0u8; DS4_INPUT_REPORT_LEN]; let mut r = [0u8; DS4_INPUT_REPORT_LEN];
serialize_state(&mut r, &DsState::neutral(), 0, 0); serialize_state(&mut r, &DsState::neutral(), 0, 0);
@@ -68,14 +71,18 @@ impl Ds4WinPad {
} }
}; };
let _sw = hsw.map(super::gamepad_raii::SwDevice::new); let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
// Bounded eager delivery — for the DS4 this is what closes the identity race: the driver
// must read `device_type = 1` from the delivered DATA section before hidclass asks it for
// descriptors, or the pad would enumerate with the (default) DualSense identity.
channel.deliver_eager(Duration::from_millis(1500));
Ok(Ds4WinPad { Ok(Ds4WinPad {
_sw, _sw,
shm, channel,
attach: super::gamepad_raii::DriverAttach::new( attach: super::gamepad_raii::DriverAttach::new(
"pf_dualshock4", "pf_dualshock4",
"pf_dualsense.inf", // one driver package serves both HID identities "pf_dualsense.inf", // one driver package serves both HID identities
"C:\\Users\\Public\\pfds-driver.log", "C:\\Users\\Public\\pfds-driver.log",
shm_name, boot_name,
instance_id, instance_id,
), ),
counter: 0, counter: 0,
@@ -92,29 +99,40 @@ impl Ds4WinPad {
serialize_state(&mut r, st, self.counter, self.ts); serialize_state(&mut r, st, self.counter, self.ts);
// SAFETY: base 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 { unsafe {
std::ptr::copy_nonoverlapping(r.as_ptr(), self.shm.base().add(OFF_INPUT), r.len()) std::ptr::copy_nonoverlapping(
r.as_ptr(),
self.channel.data_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. Also /// [`Ds4Feedback`]. Returns empty feedback if the driver hasn't published anything new. Also
/// feeds the driver-attach health watcher (the driver's ~125 Hz timer stamps `driver_proto`). /// ticks the sealed-channel delivery and feeds the driver-attach health watcher (the driver's
/// ~125 Hz timer stamps `driver_proto`).
fn service(&mut self) -> Ds4Feedback { fn service(&mut self) -> Ds4Feedback {
self.channel.pump();
let mut fb = Ds4Feedback::default(); let mut fb = Ds4Feedback::default();
// SAFETY: base points at SHM_SIZE bytes. // SAFETY: base points at SHM_SIZE bytes.
let proto = unsafe { let proto = unsafe {
std::ptr::read_unaligned(self.shm.base().add(OFF_DRIVER_PROTO) as *const u32) std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32)
}; };
self.attach.observe(proto); self.attach.observe(proto);
// SAFETY: base points at SHM_SIZE bytes. // SAFETY: base points at SHM_SIZE bytes.
let seq = let seq = unsafe {
unsafe { std::ptr::read_unaligned(self.shm.base().add(OFF_OUT_SEQ) as *const u32) }; std::ptr::read_unaligned(self.channel.data_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.shm.base().add(OFF_OUTPUT), out.as_mut_ptr(), 64) std::ptr::copy_nonoverlapping(
self.channel.data_base().add(OFF_OUTPUT),
out.as_mut_ptr(),
64,
)
}; };
parse_ds4_output(&out, &mut fb); parse_ds4_output(&out, &mut fb);
} }
@@ -1,14 +1,29 @@
//! Per-pad Windows resource RAII for the gamepad backends (DualSense / DualShock 4 / XUSB). //! Per-pad Windows resource RAII + the **sealed gamepad channel** broker (DualSense / DualShock 4 /
//! XUSB backends).
//! //!
//! Each virtual pad owns two OS resources: the named shared-memory section (+ its mapped view) the //! Each virtual pad owns three OS resources: the **unnamed** DATA section the `pf_dualsense`/`pf_xusb`
//! `pf_dualsense`/`pf_xusb` driver reads, and the `SwDeviceCreate`'d software devnode the driver loads //! driver works against (`XusbShm`/`PadShm`), the tiny **named** bootstrap mailbox
//! on. Before this module, all three backends hand-rolled the same `CreateFileMappingW` + //! (`pf_driver_proto::gamepad::PadBootstrap`) that hands the driver a duplicated handle to it, and the
//! `MapViewOfFile` and an identical `Drop` doing `SwDeviceClose` + `UnmapViewOfFile` + `CloseHandle` — //! `SwDeviceCreate`'d software devnode the driver loads on. [`Shm`] and [`SwDevice`] own the resources
//! easy to drift or leak on an error path. [`Shm`] and [`SwDevice`] own those resources with RAII, so a //! with RAII; [`PadChannel`] owns the two sections plus the delivery handshake.
//! backend just holds them and the cleanup (and ordering) happens by construction. //!
//! **Why the channel is sealed** (`design/gamepad-channel-sealing.md`): the DATA section used to be a
//! `Global\pf…-shm-<index>` named section with an SY+LS DACL, which let any *sibling LocalService*
//! process open it by name to read the live controller input or inject/forge input and rumble — the
//! same name-open vector the frame ring closed (`design/idd-push-security.md`). The DATA section is now
//! UNNAMED with a SYSTEM-only DACL and reaches the driver exclusively as a handle this host duplicated
//! into its WUDFHost (a duplicated handle carries the source's access, so no LS ACE is needed). The pad
//! drivers are UMDF HID minidrivers with **no control device** (hidclass owns the stack), so unlike the
//! frame channel there is no IOCTL to deliver the handle or learn the WUDFHost pid — hence the
//! late-bound [`PadBootstrap`] mailbox handshake, the one *named* object left. It carries only pids and
//! a handle VALUE (meaningless outside the target process), so tampering with it yields at worst a
//! gamepad DoS, never a read or an injection; the empirical floor from the frame work holds here too
//! (a LocalService token is DACL-denied `OpenProcess` on a UMDF WUDFHost for every access right).
use anyhow::{anyhow, Result}; use anyhow::{anyhow, bail, Context, Result};
use std::os::windows::io::{FromRawHandle, OwnedHandle}; use pf_driver_proto::gamepad::{PadBootstrap, BOOT_MAGIC, GAMEPAD_PROTO_VERSION};
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use std::sync::atomic::{fence, AtomicU32, AtomicU64, Ordering};
use std::sync::OnceLock; use std::sync::OnceLock;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use windows::core::{w, HSTRING, PCWSTR}; use windows::core::{w, HSTRING, PCWSTR};
@@ -17,7 +32,10 @@ use windows::Win32::Devices::DeviceAndDriverInstallation::{
CM_PROB, CR_SUCCESS, DN_DRIVER_LOADED, DN_HAS_PROBLEM, DN_STARTED, CM_PROB, CR_SUCCESS, DN_DRIVER_LOADED, DN_HAS_PROBLEM, DN_STARTED,
}; };
use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE}; use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE};
use windows::Win32::Foundation::INVALID_HANDLE_VALUE; use windows::Win32::Foundation::{
DuplicateHandle, GetLastError, SetLastError, DUPLICATE_HANDLE_OPTIONS, ERROR_ALREADY_EXISTS,
HANDLE, INVALID_HANDLE_VALUE, WIN32_ERROR,
};
use windows::Win32::Security::Authorization::{ use windows::Win32::Security::Authorization::{
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
}; };
@@ -26,54 +44,102 @@ use windows::Win32::System::Memory::{
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS, CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE, MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
}; };
use windows::Win32::System::Threading::{
GetCurrentProcess, OpenProcess, PROCESS_DUP_HANDLE, PROCESS_QUERY_LIMITED_INFORMATION,
};
/// A named, anonymous (pagefile-backed) shared section + its mapped read/write view. RAII: drop unmaps /// Least access the pad driver needs on the duplicated DATA section: it only MAPS it read/write, so
/// the view, then the [`OwnedHandle`] closes the section handle (in that order). Replaces the three /// `SECTION_MAP_READ | SECTION_MAP_WRITE` (== the driver's `FILE_MAP_RW`). Granted explicitly in
/// backends' hand-duplicated `CreateFileMappingW` + `MapViewOfFile` + manual `Drop`. /// [`PadChannel::deliver_to`] instead of `DUPLICATE_SAME_ACCESS` (least privilege for the sealed
/// /// section — the driver's handle then can't take ownership / change security / delete the object).
/// SDDL `D:(A;;GA;;;SY)(A;;GA;;;LS)`: GENERIC_ALL to **SYSTEM** (the host creates the section and const SECTION_MAP_RW: u32 = 0x0004 | 0x0002;
/// writes the live HID input report into it) and **LocalService** (the account the UMDF driver's
/// WUDFHost runs under, which reads it). The old SDDL granted **Everyone** (`WD`) — on the (mistaken) /// An anonymous (pagefile-backed) shared section + its mapped read/write view. RAII: drop unmaps the
/// assumption the driver needed a restricted token's broad access — letting any local user /// view, then the [`OwnedHandle`] closes the section handle (in that order). Created either
/// `OpenFileMapping` the section to inject controller input or tamper the trusted channel /// [unnamed](Self::create_unnamed) (the sealed DATA section — reachable only by handle duplication) or
/// (security-review 2026-06-28 #5). Verified on the RTX box (2026-06-29): the WUDFHost token is /// [named](Self::create_named) (the bootstrap mailbox the driver opens by name).
/// `S-1-5-19` (LocalService), SYSTEM integrity, with **zero restricted SIDs** — so scoping to SY+LS is
/// sufficient for the driver and excludes normal (medium-IL, non-service) user processes.
pub(super) struct Shm { pub(super) struct Shm {
/// Owns the section handle (closed on drop). Held only for ownership — never read after construction. /// Owns the section handle (closed on drop). Also the duplication source for the sealed channel —
_handle: OwnedHandle, /// see [`Shm::raw_handle`].
handle: OwnedHandle,
view: MEMORY_MAPPED_VIEW_ADDRESS, view: MEMORY_MAPPED_VIEW_ADDRESS,
} }
impl Shm { /// Build a `SECURITY_ATTRIBUTES` from an SDDL literal (`psd` is OS-allocated and leaked — acceptable
/// Create + zero a `size`-byte section named `name`, mapped read/write. The section handle is owned /// for the handful of pad channels a host creates; it must outlive the returned `SECURITY_ATTRIBUTES`).
/// immediately, so any failure below (or the returned `Shm`'s drop) closes it. fn sddl_sa(sddl: PCWSTR) -> Result<SECURITY_ATTRIBUTES> {
pub(super) fn create(name: &HSTRING, size: usize) -> Result<Shm> {
let mut psd = PSECURITY_DESCRIPTOR::default(); let mut psd = PSECURITY_DESCRIPTOR::default();
// SAFETY: the SDDL literal is valid; `psd` receives an OS-allocated descriptor (freed at process // SAFETY: the SDDL literal is valid; `psd` receives an OS-allocated descriptor (leaked — see above).
// exit — acceptable for a host-lifetime object).
unsafe { unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW( ConvertStringSecurityDescriptorToSecurityDescriptorW(
w!("D:(A;;GA;;;SY)(A;;GA;;;LS)"), sddl,
SDDL_REVISION_1, SDDL_REVISION_1,
&mut psd, &mut psd,
None, None,
)?; )?;
} }
let sa = SECURITY_ATTRIBUTES { Ok(SECURITY_ATTRIBUTES {
nLength: core::mem::size_of::<SECURITY_ATTRIBUTES>() as u32, nLength: core::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: psd.0, lpSecurityDescriptor: psd.0,
bInheritHandle: false.into(), bInheritHandle: false.into(),
}; })
// SAFETY: an anonymous (pagefile-backed) section of `size` bytes with the SDDL above. }
impl Shm {
/// Create + zero an **unnamed** `size`-byte section, mapped read/write — the sealed DATA section.
/// SDDL `D:P(A;;GA;;;SY)` (SYSTEM-only, protected): with no name there is nothing to enumerate,
/// open, or squat, and the driver reaches it through a duplicated handle, which carries the
/// source's access without re-checking the object DACL (the exact property the frame ring
/// validated on-glass — `design/idd-push-security.md`).
pub(super) fn create_unnamed(size: usize) -> Result<Shm> {
let sa = sddl_sa(w!("D:P(A;;GA;;;SY)"))?;
Self::create_inner(&sa, PCWSTR::null(), size).context("create unnamed gamepad DATA section")
}
/// Create + zero a **named** `size`-byte section, mapped read/write — the bootstrap mailbox. SDDL
/// `D:(A;;GA;;;SY)(A;;GA;;;LS)`: SYSTEM (this host) + LocalService (the driver's WUDFHost opens it
/// by name). Safe to leave name-openable because it carries nothing exploitable (see the module
/// docs). **Squat-checked**: `Global\` names are creatable by any service holding
/// `SeCreateGlobalPrivilege` (LocalService has it), so if the name already exists —
/// `ERROR_ALREADY_EXISTS`, meaning `CreateFileMappingW` silently *opened* a pre-existing object we
/// don't control — we close and retry briefly (our own driver holds the name for microseconds per
/// poll tick), then fail loudly rather than run the handshake through an attacker-owned (or
/// another host instance's) mailbox.
pub(super) fn create_named(name: &HSTRING, size: usize) -> Result<Shm> {
let sa = sddl_sa(w!("D:(A;;GA;;;SY)(A;;GA;;;LS)"))?;
for attempt in 0..5 {
if attempt > 0 {
std::thread::sleep(Duration::from_millis(50));
}
// SAFETY: clearing the thread error slot so ERROR_ALREADY_EXISTS below is unambiguous.
unsafe { SetLastError(WIN32_ERROR(0)) };
let shm = Self::create_inner(&sa, PCWSTR(name.as_ptr()), size)
.with_context(|| format!("create gamepad bootstrap mailbox {name}"))?;
// SAFETY: read immediately after the create; windows-rs only touches the error slot on
// failure, so a success here preserves CreateFileMappingW's ALREADY_EXISTS signal.
if unsafe { GetLastError() } != ERROR_ALREADY_EXISTS {
return Ok(shm);
}
// `shm` drops here → unmap + close our handle to the foreign object, then retry.
}
bail!(
"bootstrap mailbox {name} already exists and stayed alive across retries — another \
punktfunk-host instance is serving this pad index, or a local service is squatting the \
name (gamepad DoS attempt?)"
);
}
fn create_inner(sa: &SECURITY_ATTRIBUTES, name: PCWSTR, size: usize) -> Result<Shm> {
// SAFETY: an anonymous (pagefile-backed) section of `size` bytes with the caller's SDDL; the
// descriptor behind `sa` outlives this call (leaked by `sddl_sa`).
let map = unsafe { let map = unsafe {
CreateFileMappingW( CreateFileMappingW(
INVALID_HANDLE_VALUE, INVALID_HANDLE_VALUE,
Some(&sa), Some(sa),
PAGE_READWRITE, PAGE_READWRITE,
0, 0,
size as u32, size as u32,
PCWSTR(name.as_ptr()), name,
)? )?
}; };
// SAFETY: `map` is a fresh section handle we own; take ownership immediately so that the early // SAFETY: `map` is a fresh section handle we own; take ownership immediately so that the early
@@ -84,14 +150,11 @@ impl Shm {
let view = unsafe { MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, size) }; let view = unsafe { MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, size) };
if view.Value.is_null() { if view.Value.is_null() {
// `handle` drops here → closes the section. No view to unmap. // `handle` drops here → closes the section. No view to unmap.
return Err(anyhow!("MapViewOfFile failed for {name}")); return Err(anyhow!("MapViewOfFile failed"));
} }
// SAFETY: `view` points at `size` writable bytes (just mapped). // SAFETY: `view` points at `size` writable bytes (just mapped).
unsafe { core::ptr::write_bytes(view.Value as *mut u8, 0, size) }; unsafe { core::ptr::write_bytes(view.Value as *mut u8, 0, size) };
Ok(Shm { Ok(Shm { handle, view })
_handle: handle,
view,
})
} }
/// The mapped section's base pointer. Stable for the `Shm`'s lifetime (moving the `Shm` does not /// The mapped section's base pointer. Stable for the `Shm`'s lifetime (moving the `Shm` does not
@@ -99,11 +162,16 @@ impl Shm {
pub(super) fn base(&self) -> *mut u8 { pub(super) fn base(&self) -> *mut u8 {
self.view.Value as *mut u8 self.view.Value as *mut u8
} }
/// The section handle as a borrowed `HANDLE` (the sealed channel's duplication source).
fn raw_handle(&self) -> HANDLE {
HANDLE(self.handle.as_raw_handle())
}
} }
impl Drop for Shm { impl Drop for Shm {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `view` came from `MapViewOfFile`; unmap it BEFORE the `_handle` field closes the // SAFETY: `view` came from `MapViewOfFile`; unmap it BEFORE the `handle` field closes the
// section (struct fields drop only after this `Drop::drop` returns). // section (struct fields drop only after this `Drop::drop` returns).
unsafe { unsafe {
let _ = UnmapViewOfFile(self.view); let _ = UnmapViewOfFile(self.view);
@@ -111,6 +179,230 @@ impl Drop for Shm {
} }
} }
// ── The sealed-channel bootstrap broker ─────────────────────────────────────────────────────────
/// Global delivery sequence for [`PadBootstrap::handle_seq`] — host-wide monotonic and never 0, so two
/// consecutive pads on the same index can't hand the (persistent, out-of-band-devnode) driver the same
/// seq twice. Starts at 1.
static BOOT_SEQ: AtomicU32 = AtomicU32::new(1);
/// Hard cap on delivery attempts per pad: each attempt duplicates a handle into a WUDFHost, so a
/// tampered mailbox flapping `driver_pid` must not mint unbounded remote handles (DoS containment).
/// A legitimate pad needs exactly one (a driver restart within one pad lifetime is not a thing —
/// the WUDFHost dies with the devnode).
const MAX_DELIVERY_ATTEMPTS: u32 = 16;
/// One pad's sealed host↔driver channel: the unnamed DATA section (the real `XusbShm`/`PadShm`), the
/// named bootstrap mailbox, and the delivery state machine ([`Self::pump`]) that hands the driver's
/// WUDFHost a duplicated DATA handle once it publishes its pid. Owns both sections (RAII teardown —
/// dropping the channel closes the mailbox, whose *name* then disappears, which is how a persistent
/// (out-of-band-devnode) driver detects the host is gone).
pub(super) struct PadChannel {
data: Shm,
boot: Shm,
boot_name: String,
/// Last `driver_pid` acted on (delivered or rejected) — never retry the same value, so a failed
/// verify can't be spun into a hot loop by a static mailbox.
last_seen_pid: u32,
attempts: u32,
delivered: bool,
warned_proto: bool,
warned_cap: bool,
}
impl PadChannel {
/// Create the unnamed DATA section (`data_size` bytes, zeroed — the caller stamps its layout and
/// magic) plus the named bootstrap mailbox, stamped `host_proto` first and `BOOT_MAGIC` last so a
/// driver only trusts a fully-initialized mailbox.
pub(super) fn create(boot_name: String, data_size: usize) -> Result<PadChannel> {
let data = Shm::create_unnamed(data_size)?;
let boot = Shm::create_named(
&HSTRING::from(boot_name.as_str()),
core::mem::size_of::<PadBootstrap>(),
)?;
let base = boot.base();
// SAFETY: `base` is the live, page-aligned mailbox view (>= size_of::<PadBootstrap>()); the
// field offsets are pinned by the proto's asserts and naturally aligned, so the atomic views
// are valid. `host_proto` is published BEFORE `magic` (Release) — a driver that observes the
// magic (Acquire) sees the version.
unsafe {
(*(base.add(core::mem::offset_of!(PadBootstrap, host_proto)) as *const AtomicU32))
.store(GAMEPAD_PROTO_VERSION, Ordering::Relaxed);
fence(Ordering::Release);
(*(base.add(core::mem::offset_of!(PadBootstrap, magic)) as *const AtomicU32))
.store(BOOT_MAGIC, Ordering::Release);
}
Ok(PadChannel {
data,
boot,
boot_name,
last_seen_pid: 0,
attempts: 0,
delivered: false,
warned_proto: false,
warned_cap: false,
})
}
/// The DATA section's mapped base (the host side of `XusbShm`/`PadShm`).
pub(super) fn data_base(&self) -> *mut u8 {
self.data.base()
}
/// The bootstrap mailbox name (log labelling).
pub(super) fn boot_name(&self) -> &str {
&self.boot_name
}
/// Atomic `u32` load from a mailbox field.
fn boot_load(&self, off: usize) -> u32 {
// SAFETY: the mailbox view is live (owned by `self.boot`), page-aligned, and every
// `PadBootstrap` u32 field offset is 4-aligned (proto asserts), so the atomic view is valid;
// no reference into the shared region outlives the load.
unsafe { (*(self.boot.base().add(off) as *const AtomicU32)).load(Ordering::Acquire) }
}
/// One tick of the delivery state machine — called from the pad's regular service pump (≤4 ms
/// cadence) and from [`Self::deliver_eager`]. Cheap when idle: two atomic loads.
pub(super) fn pump(&mut self) {
// Version diagnostics: the driver writes its own proto version even when it refuses to
// publish a pid (host/driver mismatch), so the operator sees WHY the pad never attaches.
let drv_proto = self.boot_load(core::mem::offset_of!(PadBootstrap, driver_proto));
if drv_proto != 0 && drv_proto != GAMEPAD_PROTO_VERSION && !self.warned_proto {
self.warned_proto = true;
tracing::warn!(
mailbox = %self.boot_name,
driver_proto = drv_proto,
host_proto = GAMEPAD_PROTO_VERSION,
"gamepad driver/host protocol mismatch on the bootstrap mailbox — update the \
drivers: punktfunk-host.exe driver install --gamepad"
);
}
let pid = self.boot_load(core::mem::offset_of!(PadBootstrap, driver_pid));
if pid == 0 || pid == self.last_seen_pid {
return;
}
self.last_seen_pid = pid;
if self.attempts >= MAX_DELIVERY_ATTEMPTS {
if !self.warned_cap {
self.warned_cap = true;
tracing::warn!(
mailbox = %self.boot_name,
attempts = self.attempts,
"gamepad channel delivery cap reached — the bootstrap mailbox keeps changing \
its driver pid (tampering?); no further handles will be duplicated"
);
}
return;
}
self.attempts += 1;
match self.deliver_to(pid) {
Ok(seq) => {
self.delivered = true;
tracing::info!(
mailbox = %self.boot_name,
wudf_pid = pid,
seq,
"sealed gamepad channel delivered (DATA handle duplicated into the driver's \
WUDFHost)"
);
}
Err(e) => {
tracing::warn!(
mailbox = %self.boot_name,
pid,
error = %format!("{e:#}"),
"sealed gamepad channel delivery failed — will retry when the mailbox reports \
a different driver pid"
);
}
}
}
/// Duplicate the DATA section into `pid`'s handle table (after verifying it is a genuine
/// WUDFHost) and publish the handle value + owning pid, bumping `handle_seq` LAST. The driver
/// adopts the handle by consuming the delivery; an unconsumed duplicate dies with the target
/// process (nothing to reap — there is no fallible step after the duplication).
fn deliver_to(&self, pid: u32) -> Result<u32> {
// SAFETY: plain FFI; the handle (checked by `?`) is owned solely here and moved into the
// `OwnedHandle` (single owner, closes on drop); `verify_is_wudfhost` borrows it for the
// synchronous check and forms no lasting alias.
let process = unsafe {
let h = OpenProcess(
PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION,
false,
pid,
)
.context("OpenProcess(PROCESS_DUP_HANDLE) on the mailbox-reported pid")?;
let process = OwnedHandle::from_raw_handle(h.0 as _);
crate::capture::idd_push::verify_is_wudfhost(
HANDLE(process.as_raw_handle()),
pid,
"gamepad-channel",
)?;
process
};
let mut remote = HANDLE::default();
// SAFETY: `self.data.raw_handle()` is the live section handle this channel owns;
// `process` is the live PROCESS_DUP_HANDLE target; `&mut remote` is a valid out-param.
// Least privilege: the pad driver only MAPS the DATA section read/write (its `FILE_MAP_RW` =
// `SECTION_MAP_READ | SECTION_MAP_WRITE`), so grant exactly that instead of copying our
// full-access creator handle via `DUPLICATE_SAME_ACCESS` (Chen: don't over-grant unnamed
// shared objects — a compromised driver's handle then can't `WRITE_DAC`/`DELETE` the section).
unsafe {
DuplicateHandle(
GetCurrentProcess(),
self.data.raw_handle(),
HANDLE(process.as_raw_handle()),
&mut remote,
SECTION_MAP_RW,
false,
DUPLICATE_HANDLE_OPTIONS(0),
)
.context("DuplicateHandle(gamepad DATA section) into the driver's WUDFHost")?;
}
let value = remote.0 as usize as u64;
let base = self.boot.base();
let seq = BOOT_SEQ.fetch_add(1, Ordering::Relaxed);
// SAFETY: live, page-aligned mailbox view; `data_handle` is 8-aligned and `handle_pid`/
// `handle_seq` 4-aligned (proto asserts). The handle value + owning pid are published BEFORE
// the seq (Release) — a driver that observes the new seq (Acquire) sees a complete delivery.
unsafe {
(*(base.add(core::mem::offset_of!(PadBootstrap, data_handle)) as *const AtomicU64))
.store(value, Ordering::Relaxed);
(*(base.add(core::mem::offset_of!(PadBootstrap, handle_pid)) as *const AtomicU32))
.store(pid, Ordering::Relaxed);
fence(Ordering::Release);
(*(base.add(core::mem::offset_of!(PadBootstrap, handle_seq)) as *const AtomicU32))
.store(seq, Ordering::Release);
}
Ok(seq)
}
/// Bounded wait at pad-open: pump until the mailbox produces a driver pid we act on (delivered or
/// rejected) or `timeout` passes. Closes the identity race for the DualShock 4 (the driver reads
/// `device_type` from the DATA section when hidclass asks for descriptors — the channel should be
/// attached by then); the regular service pump takes over afterwards either way.
pub(super) fn deliver_eager(&mut self, timeout: Duration) {
let deadline = Instant::now() + timeout;
loop {
self.pump();
if self.last_seen_pid != 0 || Instant::now() >= deadline {
if !self.delivered {
tracing::debug!(
mailbox = %self.boot_name,
"eager gamepad-channel delivery window passed without an attach — the \
service pump keeps polling (driver-attach diagnosis follows if it stays \
silent)"
);
}
return;
}
std::thread::sleep(Duration::from_millis(10));
}
}
}
/// A `SwDeviceCreate`'d software devnode; drop removes it via `SwDeviceClose`. Replaces the manual /// A `SwDeviceCreate`'d software devnode; drop removes it via `SwDeviceClose`. Replaces the manual
/// `SwDeviceClose` each backend used to call in its `Drop`. /// `SwDeviceClose` each backend used to call in its `Drop`.
pub(super) struct SwDevice(HSWDEVICE); pub(super) struct SwDevice(HSWDEVICE);
@@ -151,7 +443,7 @@ pub(super) struct DriverAttach {
inf: &'static str, inf: &'static str,
/// The driver's own debug log, referenced in the diagnosis line. /// The driver's own debug log, referenced in the diagnosis line.
driver_log: &'static str, driver_log: &'static str,
/// Section name, for log lines. /// Bootstrap-mailbox name, for log lines (the DATA section is unnamed).
shm_name: String, shm_name: String,
/// PnP instance id of the SwDeviceCreate'd devnode (`None` on the out-of-band fallback path). /// PnP instance id of the SwDeviceCreate'd devnode (`None` on the out-of-band fallback path).
instance_id: Option<String>, instance_id: Option<String>,
@@ -241,8 +533,8 @@ impl DriverAttach {
devnode = %devnode, devnode = %devnode,
driver_log = self.driver_log, driver_log = self.driver_log,
"gamepad driver has not attached to the shared section — the virtual pad exists but no \ "gamepad driver has not attached to the shared section — the virtual pad exists but no \
driver is serving it (games will not see it); an old (pre-health) driver also reads as \ driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \
not-attached: update with punktfunk-host.exe driver install --gamepad" reads as not-attached: update with punktfunk-host.exe driver install --gamepad"
); );
} }
} }
@@ -1,23 +1,23 @@
//! Windows virtual Xbox 360 gamepad via the punktfunk **XUSB companion** UMDF driver //! Windows virtual Xbox 360 gamepad via the punktfunk **XUSB companion** UMDF driver
//! (`packaging/windows/xusb-driver`) — the in-tree replacement for ViGEmBus. One virtual Xbox 360 //! (`packaging/windows/drivers/pf-xusb`) — the in-tree replacement for ViGEmBus. One virtual Xbox 360
//! controller per client pad index, visible to classic **XInput** (`XInputGetState`) with no kernel //! controller per client pad index, visible to classic **XInput** (`XInputGetState`) with no kernel
//! bus driver: each pad `SwDeviceCreate`s a `pf_xusb_<index>` devnode (the driver loads on it and //! bus driver: each pad `SwDeviceCreate`s a `pf_xusb_<index>` devnode (the driver loads on it and
//! registers `GUID_DEVINTERFACE_XUSB`) and the host pushes the XInput state into the shared section //! registers `GUID_DEVINTERFACE_XUSB`) and the host pushes the XInput state into an **unnamed** shared
//! `Global\pfxusb-shm-<index>`. GameStream/Moonlight already speak the XInput conventions (low-16 //! DATA section the driver reaches over the **sealed channel** ([`PadChannel`] — handle duplicated
//! button bits, sticks 32768..32767 +Y up, triggers 0..255), so the state copy is ~1:1. //! into its WUDFHost, bootstrapped via `Global\pfxusb-boot-<index>`; see
//! `design/gamepad-channel-sealing.md`). GameStream/Moonlight already speak the XInput conventions
//! (low-16 button bits, sticks 32768..32767 +Y up, triggers 0..255), so the state copy is ~1:1.
//! //!
//! Rumble flows back the other way: a game writes force-feedback via `XInputSetState`, the driver //! Rumble flows back the other way: a game writes force-feedback via `XInputSetState`, the driver
//! parses the `SET_STATE` packet into the shared section, and [`GamepadManager::pump_rumble`] relays //! parses the `SET_STATE` packet into the shared section, and [`GamepadManager::pump_rumble`] relays
//! level changes to the client (the universal 0xCA plane), mirroring the Linux `EV_FF` read path. //! level changes to the client (the universal 0xCA plane), mirroring the Linux `EV_FF` read path.
//!
//! NB: the driver currently maps `Global\pfxusb-shm-0` (hardcoded), so a single pad (index 0) is
//! fully correct; mixed multi-pad needs the driver to read its own index first (same limitation as
//! the DualSense backend).
use super::gamepad_raii::PadChannel;
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use std::ffi::c_void; use std::ffi::c_void;
use windows::core::{w, GUID, HRESULT, HSTRING, PCWSTR}; use std::time::Duration;
use windows::core::{w, GUID, HRESULT, 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,
}; };
@@ -41,6 +41,7 @@ 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_SEQ: usize = core::mem::offset_of!(XusbShm, rumble_seq);
const OFF_RUMBLE: usize = core::mem::offset_of!(XusbShm, rumble_large); // large @28, small @29 const OFF_RUMBLE: usize = core::mem::offset_of!(XusbShm, rumble_large); // large @28, small @29
const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(XusbShm, driver_proto); const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(XusbShm, driver_proto);
const OFF_PAD_INDEX: usize = core::mem::offset_of!(XusbShm, pad_index);
/// 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,
/// and the PnP instance id PnP assigned (captured for devnode health diagnostics). /// and the PnP instance id PnP assigned (captured for devnode health diagnostics).
@@ -100,7 +101,7 @@ fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option<String>)> {
.encode_utf16() .encode_utf16()
.chain(std::iter::once(0)) .chain(std::iter::once(0))
.collect(); .collect();
// The pad index, stamped into the device Location — the driver reads it to map `pfxusb-shm-<index>` // The pad index, stamped into the device Location — the driver reads it to poll `pfxusb-boot-<index>`
// (multi-pad). The buffer must outlive the SwDeviceCreate call (it does; we wait on the event). // (multi-pad). The buffer must outlive the SwDeviceCreate call (it does; we wait on the event).
let loc: Vec<u16> = format!("{index}") let loc: Vec<u16> = format!("{index}")
.encode_utf16() .encode_utf16()
@@ -171,12 +172,13 @@ fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option<String>)> {
Ok((hsw, ctx.instance_id())) Ok((hsw, ctx.instance_id()))
} }
/// 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 sealed shared-memory channel.
struct XusbWinPad { struct XusbWinPad {
/// Owns the `pf_xusb_<index>` devnode (dropped → `SwDeviceClose`). `None` if `SwDeviceCreate` failed. /// Owns the `pf_xusb_<index>` devnode (dropped → `SwDeviceClose`). `None` if `SwDeviceCreate` failed.
_sw: Option<super::gamepad_raii::SwDevice>, _sw: Option<super::gamepad_raii::SwDevice>,
/// Owns `Global\pfxusb-shm-<index>` (the section + its mapped view; drop unmaps + closes). /// The sealed channel: the unnamed DATA section (the `XusbShm`) + the bootstrap mailbox + the
shm: super::gamepad_raii::Shm, /// handle-delivery state machine (drop closes both sections).
channel: PadChannel,
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis. /// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
attach: super::gamepad_raii::DriverAttach, attach: super::gamepad_raii::DriverAttach,
packet: u32, packet: u32,
@@ -184,17 +186,18 @@ struct XusbWinPad {
} }
impl XusbWinPad { impl XusbWinPad {
/// Create + map `Global\pfxusb-shm-<index>`, stamp the magic, then spawn the devnode. /// Create the sealed channel (unnamed DATA section + `Global\pfxusb-boot-<index>` mailbox), stamp
/// the pad index then the magic LAST, spawn the devnode, and eagerly deliver the DATA handle once
/// the driver publishes its pid.
fn open(index: u8) -> Result<XusbWinPad> { fn open(index: u8) -> Result<XusbWinPad> {
// Permissive-DACL named section the WUDFHost (whatever account) can open; `Shm` owns the let boot_name = pf_driver_proto::gamepad::xusb_boot_name(index);
// section handle + its mapped view (zero-filled) and unmaps/closes on drop. let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
let shm_name = pf_driver_proto::gamepad::xusb_shm_name(index); let base = channel.data_base();
let shm = super::gamepad_raii::Shm::create(&HSTRING::from(shm_name.as_str()), SHM_SIZE)?; // The section arrives zeroed; stamp the pad index (the driver validates it against its own
let base = shm.base(); // devnode index on attach) then 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; OFF_PAD_INDEX is in range.
// SAFETY: base points at SHM_SIZE writable bytes.
unsafe { unsafe {
std::ptr::write_bytes(base, 0, SHM_SIZE); std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC); std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
} }
let (hsw, instance_id) = match create_swdevice(index) { let (hsw, instance_id) = match create_swdevice(index) {
@@ -205,14 +208,18 @@ impl XusbWinPad {
} }
}; };
let _sw = hsw.map(super::gamepad_raii::SwDevice::new); let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
// Bounded eager delivery: the driver's EvtDeviceAdd publishes its pid right away; handing it
// the DATA handle before we return means the pad is live for the game's first XInput poll.
// On a missing/old driver this waits out the window once and the service pump takes over.
channel.deliver_eager(Duration::from_millis(1500));
Ok(XusbWinPad { Ok(XusbWinPad {
_sw, _sw,
shm, channel,
attach: super::gamepad_raii::DriverAttach::new( attach: super::gamepad_raii::DriverAttach::new(
"pf_xusb", "pf_xusb",
"pf_xusb.inf", "pf_xusb.inf",
"C:\\Users\\Public\\pfxusb-driver.log", "C:\\Users\\Public\\pfxusb-driver.log",
shm_name, boot_name,
instance_id, instance_id,
), ),
packet: 0, packet: 0,
@@ -225,7 +232,7 @@ 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);
let base = self.shm.base(); let base = self.channel.data_base();
// SAFETY: `base` is the start of the mapped section (`SHM_SIZE` bytes, owned by `Shm`); every // 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 // `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. // writes. Single owner (`&mut self`), so no concurrent writer races these stores.
@@ -242,10 +249,12 @@ impl XusbWinPad {
} }
/// 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. Also feeds the driver-attach /// `(large, small)` motor levels (0..=255) when a new one arrived. Also ticks the sealed-channel
/// health watcher (the driver stamps `driver_proto` at device add + on every serviced IOCTL). /// delivery (a late-binding driver gets its handle here) and feeds the driver-attach health
/// watcher (the driver stamps `driver_proto` once it maps the delivered section + per IOCTL).
fn service(&mut self) -> Option<(u8, u8)> { fn service(&mut self) -> Option<(u8, u8)> {
let base = self.shm.base(); self.channel.pump();
let base = self.channel.data_base();
// SAFETY: base points at SHM_SIZE bytes. // SAFETY: base points at SHM_SIZE bytes.
let proto = unsafe { std::ptr::read_unaligned(base.add(OFF_DRIVER_PROTO) as *const u32) }; let proto = unsafe { std::ptr::read_unaligned(base.add(OFF_DRIVER_PROTO) as *const u32) };
self.attach.observe(proto); self.attach.observe(proto);
+1 -1
View File
@@ -739,7 +739,7 @@ NOTES:
"\nWINDOWS SERVICE (end-user deployment — replaces a manual launch):\n\ "\nWINDOWS SERVICE (end-user deployment — replaces a manual launch):\n\
\x20 punktfunk-host service install register an auto-start SYSTEM service + firewall rules\n\ \x20 punktfunk-host service install register an auto-start SYSTEM service + firewall rules\n\
\x20 punktfunk-host service uninstall remove the service + firewall rules\n\ \x20 punktfunk-host service uninstall remove the service + firewall rules\n\
\x20 punktfunk-host service start|stop|status\n\ \x20 punktfunk-host service start|stop|restart|status\n\
\x20 config: %ProgramData%\\punktfunk\\host.env\n\ \x20 config: %ProgramData%\\punktfunk\\host.env\n\
\nWINDOWS DIAGNOSTICS:\n\ \nWINDOWS DIAGNOSTICS:\n\
\x20 punktfunk-host hdr-p010-selftest GPU colour check for the PUNKTFUNK_HDR_SHADER_P010 path\n\ \x20 punktfunk-host hdr-p010-selftest GPU colour check for the PUNKTFUNK_HDR_SHADER_P010 path\n\
+141 -1
View File
@@ -157,6 +157,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(list_gpus)) .routes(routes!(list_gpus))
.routes(routes!(set_gpu_preference)) .routes(routes!(set_gpu_preference))
.routes(routes!(get_status)) .routes(routes!(get_status))
.routes(routes!(get_local_summary))
.routes(routes!(list_paired_clients)) .routes(routes!(list_paired_clients))
.routes(routes!(unpair_client)) .routes(routes!(unpair_client))
.routes(routes!(get_pairing_status)) .routes(routes!(get_pairing_status))
@@ -353,6 +354,30 @@ struct StreamInfo {
codec: ApiCodec, codec: ApiCodec,
} }
/// Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,
/// no fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see
/// `require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the
/// per-user tray process cannot authenticate — this narrow read-only route is its status source.
#[derive(Serialize, ToSchema)]
struct LocalSummary {
/// Host version (mirrors `/health`).
version: String,
/// True while the video stream thread is running.
video_streaming: bool,
/// True while the audio stream thread is running.
audio_streaming: bool,
/// The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop).
session: Option<SessionInfo>,
/// Number of pinned (paired) GameStream client certificates.
paired_clients: u32,
/// Number of paired native (punktfunk/1) devices.
native_paired_clients: u32,
/// True while a GameStream pairing handshake is parked waiting for the user's PIN.
pin_pending: bool,
/// Native pairing knocks awaiting the operator's approval (count only).
pending_approvals: u32,
}
/// A paired (certificate-pinned) Moonlight client. /// A paired (certificate-pinned) Moonlight client.
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
struct PairedClient { struct PairedClient {
@@ -488,13 +513,34 @@ where
/// Auth gate on the `/api/v1` routes: a paired client cert (mTLS, from anywhere) or the bearer token /// Auth gate on the `/api/v1` routes: a paired client cert (mTLS, from anywhere) or the bearer token
/// (from a **loopback** peer only) — required always (the host runs with a token by construction). /// (from a **loopback** peer only) — required always (the host runs with a token by construction).
/// `/api/v1/health` stays open for probes. The cert path authorizes only the read-only allowlist /// `/api/v1/health` stays open for probes; `/api/v1/local/summary` is open to loopback peers only
/// (the tray icon's status source). The cert path authorizes only the read-only allowlist
/// ([`cert_may_access`]); the bearer path authorizes the full admin surface and is therefore confined /// ([`cert_may_access`]); the bearer path authorizes the full admin surface and is therefore confined
/// to loopback so it is never LAN-exposed even when the listener binds all interfaces by default. /// to loopback so it is never LAN-exposed even when the listener binds all interfaces by default.
async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next) -> Response { async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next) -> Response {
if req.uri().path() == "/api/v1/health" { if req.uri().path() == "/api/v1/health" {
return next.run(req).await; // liveness probe is always open return next.run(req).await; // liveness probe is always open
} }
// The tray icon's status source: non-sensitive counts/booleans only, unauthenticated but
// confined to LOOPBACK peers. The bearer-token file (and cert.pem) are SYSTEM/Administrators-
// DACL'd on Windows, so the per-user tray process cannot authenticate — this one narrow
// read-only route is deliberately all it needs. Not on the cert allowlist: LAN mTLS clients
// already have the richer `/status`. (No PeerAddr ⇒ a unit test → treat as loopback, matching
// the bearer path below.)
if req.uri().path() == "/api/v1/local/summary" {
let from_loopback = req
.extensions()
.get::<PeerAddr>()
.is_none_or(|a| a.0.ip().is_loopback());
return if from_loopback {
next.run(req).await
} else {
api_error(
StatusCode::UNAUTHORIZED,
"the local summary is loopback-only",
)
};
}
// A paired native client authenticates by its mTLS certificate — the same identity + trust the // A paired native client authenticates by its mTLS certificate — the same identity + trust the
// QUIC data plane uses. But "paired to STREAM" is not "paired to ADMINISTER": a streaming cert // QUIC data plane uses. But "paired to STREAM" is not "paired to ADMINISTER": a streaming cert
// authorizes only the safe, read-only status routes, NOT state-changing or pairing-administration // authorizes only the safe, read-only status routes, NOT state-changing or pairing-administration
@@ -944,6 +990,45 @@ async fn get_status(State(st): State<Arc<MgmtState>>) -> Json<RuntimeStatus> {
}) })
} }
/// Local status summary for the tray icon
///
/// Non-sensitive status (counts and booleans only — no PIN values, no fingerprints, no device
/// names). Unauthenticated, but served to loopback peers only.
#[utoipa::path(
get,
path = "/local/summary",
tag = "host",
operation_id = "getLocalSummary",
// Override the document-global bearerAuth: loopback peers are exempt in `require_auth`.
security(()),
responses(
(status = OK, description = "Non-sensitive local host status (loopback peers only)", body = LocalSummary),
(status = UNAUTHORIZED, description = "Non-loopback peer", body = ApiError),
)
)]
async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<LocalSummary> {
let session = st.app.launch.lock().unwrap().map(|l| SessionInfo {
width: l.width,
height: l.height,
fps: l.fps,
});
let (native_paired_clients, pending_approvals) = st
.native
.as_ref()
.map(|n| (n.status().paired_clients, n.pending().len() as u32))
.unwrap_or((0, 0));
Json(LocalSummary {
version: env!("PUNKTFUNK_VERSION").into(),
video_streaming: st.app.streaming.load(Ordering::SeqCst),
audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst),
session,
paired_clients: st.app.paired.lock().unwrap().len() as u32,
native_paired_clients,
pin_pending: st.app.pairing.pin.awaiting_pin(),
pending_approvals,
})
}
/// List paired clients /// List paired clients
#[utoipa::path( #[utoipa::path(
get, get,
@@ -2031,6 +2116,61 @@ mod tests {
assert_eq!(body["abi_version"], punktfunk_core::ABI_VERSION); assert_eq!(body["abi_version"], punktfunk_core::ABI_VERSION);
} }
/// The tray's `/local/summary` is unauthenticated for LOOPBACK peers only — a LAN peer is
/// rejected even though the route needs no bearer token, and the body never carries secret
/// material (no PIN values, no fingerprints, no device names — counts/booleans only).
#[tokio::test]
async fn local_summary_is_loopback_only_and_non_sensitive() {
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(
Some(
std::env::temp_dir()
.join(format!("pf-mgmt-summary-{}.json", std::process::id())),
),
None,
false,
)
.unwrap(),
);
np.add("secret-device-name", "deadbeefcafe0123").unwrap();
let app = test_app_native(test_state(), np);
// Loopback peer, NO auth header → 200 with the expected shape.
let mut req = get_req("/api/v1/local/summary");
req.extensions_mut()
.insert(PeerAddr("127.0.0.1:40000".parse().unwrap()));
let (status, body) = send(&app, req).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["video_streaming"], false);
assert_eq!(body["native_paired_clients"], 1);
assert_eq!(body["pending_approvals"], 0);
assert!(body["version"].is_string());
// No secret material anywhere in the body (paired name / fingerprint must not leak).
let raw = body.to_string();
assert!(
!raw.contains("deadbeefcafe0123") && !raw.contains("secret-device-name"),
"summary must not leak fingerprints or device names: {raw}"
);
// The same request from a LAN peer → rejected (route is loopback-gated, not just tokenless).
let mut req = get_req("/api/v1/local/summary");
req.extensions_mut()
.insert(PeerAddr("192.168.1.50:40000".parse().unwrap()));
let (status, _) = send(&app, req).await;
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"the local summary must be rejected for a LAN peer"
);
// IPv6 loopback counts as loopback.
let mut req = get_req("/api/v1/local/summary");
req.extensions_mut()
.insert(PeerAddr("[::1]:40000".parse().unwrap()));
let (status, _) = send(&app, req).await;
assert_eq!(status, StatusCode::OK, "::1 is a loopback peer");
}
#[tokio::test] #[tokio::test]
async fn bearer_token_is_enforced() { async fn bearer_token_is_enforced() {
let app = test_app(test_state(), Some("sekrit")); let app = test_app(test_state(), Some("sekrit"));
@@ -39,11 +39,13 @@ pub(crate) enum MonitorKey {
Session(u64), Session(u64),
} }
/// What a backend's `add_monitor` returns: the REMOVE key + the OS target id + the render LUID. /// What a backend's `add_monitor` returns: the REMOVE key + the OS target id + the render LUID + the
/// driver's WUDFHost pid (the sealed frame channel's handle-duplication target).
pub(crate) struct AddedMonitor { pub(crate) struct AddedMonitor {
pub key: MonitorKey, pub key: MonitorKey,
pub target_id: u32, pub target_id: u32,
pub luid: LUID, pub luid: LUID,
pub wudf_pid: u32,
} }
/// The backend-specific IOCTL surface — the *only* thing that differs between SudoVDA and pf-vdisplay. /// The backend-specific IOCTL surface — the *only* thing that differs between SudoVDA and pf-vdisplay.
@@ -91,6 +93,9 @@ struct Monitor {
key: MonitorKey, key: MonitorKey,
target_id: u32, target_id: u32,
luid: LUID, luid: LUID,
/// The driver's WUDFHost pid (from the ADD reply) — carried into [`WinCaptureTarget`] so the
/// IDD-push capturer knows where to duplicate the sealed frame channel's handles.
wudf_pid: u32,
gdi_name: Option<String>, gdi_name: Option<String>,
mode: Mode, mode: Mode,
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
@@ -109,6 +114,7 @@ impl Monitor {
adapter_luid: crate::capture::dxgi::pack_luid(self.luid), adapter_luid: crate::capture::dxgi::pack_luid(self.luid),
gdi_name: n, gdi_name: n,
target_id: self.target_id, target_id: self.target_id,
wudf_pid: self.wudf_pid,
}) })
} }
} }
@@ -166,6 +172,14 @@ pub(crate) fn vdm() -> &'static VirtualDisplayManager {
.expect("VirtualDisplayManager used before a backend initialised it") .expect("VirtualDisplayManager used before a backend initialised it")
} }
/// The live pf-vdisplay control-device handle, for the IDD-push capturer's sealed-channel delivery
/// (`IOCTL_SET_FRAME_CHANNEL`). Safe to hand out as a bare `HANDLE`: the device lives in a `OnceLock`
/// that is never cleared or closed for the process lifetime. `None` before the first backend open —
/// impossible for a capturer, which only exists on a monitor the manager created.
pub(crate) fn control_device_handle() -> Option<HANDLE> {
VDM.get().and_then(VirtualDisplayManager::device_handle)
}
impl VirtualDisplayManager { impl VirtualDisplayManager {
pub(crate) fn backend_name(&self) -> &'static str { pub(crate) fn backend_name(&self) -> &'static str {
self.driver.name() self.driver.name()
@@ -436,6 +450,7 @@ impl VirtualDisplayManager {
key: added.key, key: added.key,
target_id: added.target_id, target_id: added.target_id,
luid: added.luid, luid: added.luid,
wudf_pid: added.wudf_pid,
gdi_name, gdi_name,
mode, mode,
stop, stop,
@@ -158,6 +158,33 @@ unsafe fn set_render_adapter(h: HANDLE, luid: LUID) -> Result<()> {
.context("pf-vdisplay SET_RENDER_ADAPTER") .context("pf-vdisplay SET_RENDER_ADAPTER")
} }
/// Deliver a monitor's sealed frame channel to the driver: the handle values `req` carries were just
/// duplicated into the driver's WUDFHost by the IDD-push capturer's broker (`idd_push::ChannelBroker`),
/// and on IOCTL success the DRIVER owns them. No output buffer. The caller reaps the remote duplicates
/// on failure (the broker's `DUPLICATE_CLOSE_SOURCE` sweep) so no path leaks WUDFHost handles.
///
/// # Safety
/// `dev` must be a live pf-vdisplay control handle (see [`super::manager::control_device_handle`]).
pub(crate) unsafe fn send_frame_channel(
dev: HANDLE,
req: &control::SetFrameChannelRequest,
) -> Result<()> {
let mut none: [u8; 0] = [];
// SAFETY: per this fn's contract `dev` is the live control handle. `bytes_of(req)` borrows the
// caller's request 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_SET_FRAME_CHANNEL,
bytemuck::bytes_of(req),
&mut none,
)
}
.map(|_| ())
.context("pf-vdisplay SET_FRAME_CHANNEL")
}
unsafe fn open_device() -> Result<HANDLE> { unsafe fn open_device() -> Result<HANDLE> {
let hdev = SetupDiGetClassDevsW( let hdev = SetupDiGetClassDevsW(
Some(&PF_VDISPLAY_INTERFACE), Some(&PF_VDISPLAY_INTERFACE),
@@ -354,12 +381,13 @@ impl VdisplayDriver for PfVdisplayDriver {
HighPart: reply.adapter_luid_high, HighPart: reply.adapter_luid_high,
}; };
tracing::info!( tracing::info!(
"pf-vdisplay created {}x{}@{} (target_id={}, adapter_luid={:#x})", "pf-vdisplay created {}x{}@{} (target_id={}, adapter_luid={:#x}, wudf_pid={})",
mode.width, mode.width,
mode.height, mode.height,
mode.refresh_hz, mode.refresh_hz,
reply.target_id, reply.target_id,
luid.LowPart luid.LowPart,
reply.wudf_pid
); );
// Per-client identity diagnostic: did the driver honor the host's preferred (stable) monitor id? // Per-client identity diagnostic: did the driver honor the host's preferred (stable) monitor id?
// A pre-Phase-2 driver leaves resolved_monitor_id=0 (it ignored the field); a current driver echoes // A pre-Phase-2 driver leaves resolved_monitor_id=0 (it ignored the field); a current driver echoes
@@ -395,6 +423,7 @@ impl VdisplayDriver for PfVdisplayDriver {
key: MonitorKey::Session(session_id), key: MonitorKey::Session(session_id),
target_id: reply.target_id, target_id: reply.target_id,
luid, luid,
wudf_pid: reply.wudf_pid,
}) })
} }
@@ -162,9 +162,28 @@ fn install_gamepad(dir: &Path) -> Result<()> {
eprintln!("warning: pnputil /add-driver {} failed", inf.display()); eprintln!("warning: pnputil /add-driver {} failed", inf.display());
} }
} }
// Sweep pad devnodes, INCLUDING phantoms a host crash / service stop left behind: a re-created
// SwDevice with a known instance id REVIVES the existing devnode with its previously-bound
// driver — it never re-ranks against the store — so after an upgrade the old driver keeps
// serving (or, across the v1→v2 sealed-channel fence, fails closed and the pad plays dead).
// Proven in the field on the RTX box: a v1 phantom pinned the old package through a v2
// install. The devnodes are per-session objects the host recreates on demand, so removing
// them at driver-install time is always safe; the next pad binds the fresh package.
remove_pad_devnodes();
Ok(()) Ok(())
} }
/// `pnputil /remove-device` every punktfunk virtual-pad devnode (live or phantom).
fn remove_pad_devnodes() {
for id in pad_instance_ids() {
if run_quiet("pnputil", &["/remove-device", &id]) {
println!("removed stale pad devnode {id}");
} else {
eprintln!("warning: pnputil /remove-device {id} failed");
}
}
}
// ── `driver uninstall [--gamepad]` ────────────────────────────────────────────────────────────── // ── `driver uninstall [--gamepad]` ──────────────────────────────────────────────────────────────
// The uninstaller's cleanup counterpart (Inno [UninstallRun]) — the field report was that our // The uninstaller's cleanup counterpart (Inno [UninstallRun]) — the field report was that our
// virtual-device drivers survived an uninstall. Removes the pf-vdisplay device node(s) + driver // virtual-device drivers survived an uninstall. Removes the pf-vdisplay device node(s) + driver
@@ -204,6 +223,9 @@ fn uninstall_pf_vdisplay() -> Result<()> {
} }
fn uninstall_gamepad() -> Result<()> { fn uninstall_gamepad() -> Result<()> {
// Devnodes first (incl. phantoms — the same ghost-device complaint the vdisplay uninstall
// fixed), then the store packages.
remove_pad_devnodes();
delete_store_drivers(&["pf_dualsense", "pf_dualshock4", "pf_xusb"]); delete_store_drivers(&["pf_dualsense", "pf_dualshock4", "pf_xusb"]);
Ok(()) Ok(())
} }
@@ -235,6 +257,28 @@ fn pf_vdisplay_instance_ids() -> Vec<String> {
ids ids
} }
/// Instance IDs of punktfunk virtual-pad devnodes (`SWD\PUNKTFUNK\…`), INCLUDING phantoms left by
/// a host crash / service stop (`pnputil /enum-devices` lists disconnected devnodes too). Same
/// un-localized VALUE-side parsing as [`pf_vdisplay_instance_ids`]; matched on the instance-id
/// prefix itself — the pads span two device classes (HIDClass + System), so no `/class` filter.
fn pad_instance_ids() -> Vec<String> {
let out = run_capture("pnputil", &["/enum-devices"]);
let mut ids = Vec::new();
for block in out.split("\r\n\r\n").flat_map(|b| b.split("\n\n")) {
let Some(first) = block.lines().find(|l| !l.trim().is_empty()) else {
continue;
};
let Some((_, value)) = first.split_once(':') else {
continue;
};
let id = value.trim();
if id.to_ascii_uppercase().starts_with("SWD\\PUNKTFUNK\\") && !id.contains(' ') {
ids.push(id.to_string());
}
}
ids
}
/// Delete every driver-store package (`%WINDIR%\INF\oem*.inf`) whose INF text mentions one of /// Delete every driver-store package (`%WINDIR%\INF\oem*.inf`) whose INF text mentions one of
/// `needles` — our driver names are unique enough that a content match identifies the package /// `needles` — our driver names are unique enough that a content match identifies the package
/// without parsing `pnputil /enum-drivers`' localized output. `/uninstall /force` also unbinds it /// without parsing `pnputil /enum-drivers`' localized output. `/uninstall /force` also unbinds it
@@ -91,6 +91,7 @@ pub fn main(args: &[String]) -> Result<()> {
Some("uninstall") => uninstall(), Some("uninstall") => uninstall(),
Some("start") => sc(&["start", SERVICE_NAME]), Some("start") => sc(&["start", SERVICE_NAME]),
Some("stop") => sc(&["stop", SERVICE_NAME]), Some("stop") => sc(&["stop", SERVICE_NAME]),
Some("restart") => restart(),
Some("status") => sc(&["query", SERVICE_NAME]), Some("status") => sc(&["query", SERVICE_NAME]),
_ => { _ => {
eprintln!( eprintln!(
@@ -102,6 +103,7 @@ pub fn main(args: &[String]) -> Result<()> {
\x20 punktfunk-host service uninstall stop + remove the service + firewall rules\n\ \x20 punktfunk-host service uninstall stop + remove the service + firewall rules\n\
\x20 punktfunk-host service start start the service now\n\ \x20 punktfunk-host service start start the service now\n\
\x20 punktfunk-host service stop stop the service\n\ \x20 punktfunk-host service stop stop the service\n\
\x20 punktfunk-host service restart stop, wait for exit, start again\n\
\x20 punktfunk-host service status query the service\n\n\ \x20 punktfunk-host service status query the service\n\n\
Config: %ProgramData%\\punktfunk\\host.env Logs: %ProgramData%\\punktfunk\\logs\\" Config: %ProgramData%\\punktfunk\\host.env Logs: %ProgramData%\\punktfunk\\logs\\"
); );
@@ -691,6 +693,40 @@ fn install(args: &[String]) -> Result<()> {
Ok(()) Ok(())
} }
/// `service restart`: stop, wait for the service to actually reach Stopped (a bare
/// `sc stop && sc start` races the stop — START fails with "instance already running" while the
/// old process winds down), then start. The tray icon's Restart action runs this, elevated.
fn restart() -> Result<()> {
use windows_service::service::{ServiceAccess, ServiceState};
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
.context("open Service Control Manager (run elevated)")?;
let svc = manager
.open_service(
SERVICE_NAME,
ServiceAccess::STOP | ServiceAccess::QUERY_STATUS | ServiceAccess::START,
)
.context("open service (run elevated)")?;
// Best-effort stop: ERROR_SERVICE_NOT_ACTIVE just means restart == start.
let _ = svc.stop();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
let state = svc.query_status().context("query service status")?;
if state.current_state == ServiceState::Stopped {
break;
}
if std::time::Instant::now() >= deadline {
anyhow::bail!("service did not stop within 30 s");
}
std::thread::sleep(std::time::Duration::from_millis(250));
}
svc.start(&[] as &[&std::ffi::OsStr])
.context("start service")?;
println!("Restarted service '{SERVICE_NAME}'.");
Ok(())
}
fn uninstall() -> Result<()> { fn uninstall() -> Result<()> {
use windows_service::service::ServiceAccess; use windows_service::service::ServiceAccess;
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess}; use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
+56
View File
@@ -0,0 +1,56 @@
[package]
name = "punktfunk-tray"
description = "System-tray status icon for the punktfunk streaming host (Windows notification area / Linux StatusNotifierItem)"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
[[bin]]
name = "punktfunk-tray"
path = "src/main.rs"
# Deliberately does NOT depend on punktfunk-host: the tray needs only the service name, the mgmt
# port, and the summary JSON shape — a dependency would drag the whole host (FFmpeg, PipeWire, …)
# into a 2 MB helper and make it un-buildable standalone. Non-Windows/non-Linux targets build a
# stub main (same pattern as the platform-gated clients).
[dependencies]
anyhow = "1"
[target.'cfg(any(windows, target_os = "linux"))'.dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Loopback HTTPS poll of GET /api/v1/local/summary. Same sync ureq + rustls(ring) stack and
# custom-verifier pattern as the Linux client's library fetch (clients/linux/src/library.rs) —
# but ring-only (no default aws-lc-rs provider: it needs a C toolchain per target and the agent
# pins the ring provider explicitly anyway).
ureq = { version = "2", default-features = false, features = ["tls"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
sha2 = "0.10"
[target.'cfg(windows)'.dependencies]
# SCM QUERY_STATUS works unprivileged — the service-state probe. Same crate the host service uses.
windows-service = "0.7"
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_Graphics_Gdi",
"Win32_Security", # CreateMutexW's SECURITY_ATTRIBUTES parameter type
"Win32_System_LibraryLoader",
"Win32_System_Threading",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
[target.'cfg(target_os = "linux")'.dependencies]
# StatusNotifierItem (pure Rust, zbus — the same zbus the host already pulls via ashpd). The tray
# is a plain-threads poller, so the blocking API over the small async-io executor (`blocking`
# alone is just the wrapper — zbus still needs an executor; no tokio runtime in a tray icon).
ksni = { version = "0.3", default-features = false, features = ["async-io", "blocking"] }
libc = "0.2"
# Build-time icon embedding (exe icon + the status-variant tray icons), host-gated like the
# Windows client's build.rs — cross-builds from Linux CI runners skip it.
[target.'cfg(windows)'.build-dependencies]
winresource = "0.1"
+27
View File
@@ -0,0 +1,27 @@
//! Embed the Windows version-info + icon resources into `punktfunk-tray.exe`: ordinal 1 is the
//! exe/file icon, ordinals 26 are the status-variant tray icons `src/win.rs` loads by id
//! (running / stopped / error / streaming / degraded). Same winresource pattern as
//! `clients/windows/build.rs`.
fn main() {
// cfg(windows) is the HOST (skips the Linux/macOS workspace stub build); CARGO_CFG_WINDOWS
// is the TARGET (mirrors the Windows client's build.rs).
#[cfg(windows)]
if std::env::var_os("CARGO_CFG_WINDOWS").is_some() {
let branding = "../../packaging/windows/branding";
let icons = [
(format!("{branding}/punktfunk.ico"), "1"),
(format!("{branding}/punktfunk-tray-running.ico"), "2"),
(format!("{branding}/punktfunk-tray-stopped.ico"), "3"),
(format!("{branding}/punktfunk-tray-error.ico"), "4"),
(format!("{branding}/punktfunk-tray-streaming.ico"), "5"),
(format!("{branding}/punktfunk-tray-degraded.ico"), "6"),
];
let mut res = winresource::WindowsResource::new();
for (path, id) in &icons {
println!("cargo:rerun-if-changed={path}");
res.set_icon_with_id(path, id);
}
res.compile().expect("embed windows icon resources");
}
}
+272
View File
@@ -0,0 +1,272 @@
//! Linux tray: a StatusNotifierItem (ksni/zbus) fed by the status poller. The host runs as the
//! systemd **user** unit `punktfunk-host.service`, so start/stop/restart are plain
//! `systemctl --user` calls — no polkit, no elevation. KDE (the project's primary Linux desktop)
//! renders SNI natively; GNOME needs the AppIndicator extension (without it the icon is invisible
//! — `--autostart` exits silently rather than erroring at every login).
use std::os::fd::AsRawFd;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use crate::status::{self, Poller, TrayStatus};
/// The tray's D-Bus/menu model. `status` + `web_console` are the mutable state; the poller
/// rewrites them via `Handle::update`, which re-emits the SNI properties (icon, tooltip, menu).
struct HostTray {
status: TrayStatus,
web_port: u16,
/// The console answered the poller's live loopback probe — the "Open web console" entry is
/// shown iff opening it would actually work (repo-run consoles included, stopped ones not).
web_console: bool,
/// Filled right after `spawn` (the poller needs the tray handle first) — lets menu actions
/// force an immediate re-poll instead of waiting out the cadence.
poller: Arc<OnceLock<Poller>>,
}
impl HostTray {
fn systemctl(&self, verb: &str) {
let _ = std::process::Command::new("systemctl")
.args(["--user", verb, status::UNIT_NAME])
.status();
if let Some(p) = self.poller.get() {
p.poke();
}
}
fn open_console(&self) {
let url = format!("https://127.0.0.1:{}", self.web_port);
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
}
}
impl ksni::Tray for HostTray {
fn id(&self) -> String {
"punktfunk-tray".into()
}
fn title(&self) -> String {
"punktfunk host".into()
}
fn status(&self) -> ksni::Status {
match &self.status {
TrayStatus::Error(_) => ksni::Status::NeedsAttention,
s if s.pairing_attention() => ksni::Status::NeedsAttention,
_ => ksni::Status::Active,
}
}
/// Hicolor theme names (installed by the packages); `icon_pixmap` below is the fallback so a
/// `cargo run` from the repo shows an icon too.
fn icon_name(&self) -> String {
match &self.status {
TrayStatus::Running(_) if self.status.is_streaming() => {
"punktfunk-tray-streaming".into()
}
TrayStatus::Running(_) => "punktfunk-tray".into(),
TrayStatus::Starting | TrayStatus::Degraded => "punktfunk-tray-degraded".into(),
TrayStatus::Error(_) => "punktfunk-tray-error".into(),
TrayStatus::Stopped | TrayStatus::NotInstalled => "punktfunk-tray-stopped".into(),
}
}
fn icon_pixmap(&self) -> Vec<ksni::Icon> {
// Same dot palette as scripts/gen-tray-icons.py.
let rgb = match &self.status {
TrayStatus::Running(_) if self.status.is_streaming() => (0xb4, 0x4c, 0xf0), // violet
TrayStatus::Running(_) => (0x2e, 0xcc, 0x71), // green
TrayStatus::Starting | TrayStatus::Degraded => (0xf0, 0xa0, 0x30), // amber
TrayStatus::Error(_) => (0xe7, 0x4c, 0x3c), // red
TrayStatus::Stopped | TrayStatus::NotInstalled => (0x8a, 0x8a, 0x8a), // gray
};
vec![dot_icon(22, rgb), dot_icon(48, rgb)]
}
fn tool_tip(&self) -> ksni::ToolTip {
ksni::ToolTip {
title: self.status.headline(),
..Default::default()
}
}
fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
use ksni::menu::*;
let running = matches!(
self.status,
TrayStatus::Running(_) | TrayStatus::Starting | TrayStatus::Degraded
);
let startable = matches!(
self.status,
TrayStatus::Stopped | TrayStatus::Error(_) | TrayStatus::NotInstalled
);
vec![
StandardItem {
label: self.status.headline(),
enabled: false,
..Default::default()
}
.into(),
MenuItem::Separator,
StandardItem {
label: "Open web console".into(),
visible: self.web_console,
activate: Box::new(|t: &mut Self| t.open_console()),
..Default::default()
}
.into(),
StandardItem {
label: "Approve pairing request…".into(),
visible: self.web_console && self.status.pairing_attention(),
activate: Box::new(|t: &mut Self| t.open_console()),
..Default::default()
}
.into(),
MenuItem::Separator,
StandardItem {
label: "Start host".into(),
visible: startable && !matches!(self.status, TrayStatus::NotInstalled),
activate: Box::new(|t: &mut Self| t.systemctl("start")),
..Default::default()
}
.into(),
StandardItem {
label: "Stop host".into(),
visible: running,
activate: Box::new(|t: &mut Self| t.systemctl("stop")),
..Default::default()
}
.into(),
StandardItem {
label: "Restart host".into(),
visible: running || matches!(self.status, TrayStatus::Error(_)),
activate: Box::new(|t: &mut Self| t.systemctl("restart")),
..Default::default()
}
.into(),
MenuItem::Separator,
StandardItem {
label: "Exit tray".into(),
activate: Box::new(|_: &mut Self| std::process::exit(0)),
..Default::default()
}
.into(),
]
}
/// Keep waiting when the watcher drops (plasmashell restart, GNOME shell reload) — the item
/// re-registers when it returns. Only `--autostart` runs get here with SNI truly absent, and
/// lingering invisibly is the documented trade-off (see `assume_sni_available` below).
fn watcher_offline(&self, _reason: ksni::OfflineReason) -> bool {
true
}
}
/// A flat antialiased status dot — the pixmap fallback when the hicolor icons aren't installed
/// (dev runs from `target/`). ARGB32, network byte order (per the SNI spec).
fn dot_icon(size: i32, (r, g, b): (u8, u8, u8)) -> ksni::Icon {
let mut data = Vec::with_capacity((size * size * 4) as usize);
let center = (size as f32 - 1.0) / 2.0;
let radius = size as f32 * 0.38;
for y in 0..size {
for x in 0..size {
let d = ((x as f32 - center).powi(2) + (y as f32 - center).powi(2)).sqrt();
// 1 px antialiasing ramp at the rim.
let alpha = ((radius - d + 0.5).clamp(0.0, 1.0) * 255.0) as u8;
data.extend_from_slice(&[alpha, r, g, b]);
}
}
ksni::Icon {
width: size,
height: size,
data,
}
}
/// Does this user's box run (or intend to run) a punktfunk host? Gates `--autostart` so the
/// packaged autostart entry doesn't put an icon in every desktop user's tray.
fn host_present() -> bool {
if status::punktfunk_config_dir().is_some_and(|d| d.exists()) {
return true;
}
std::process::Command::new("systemctl")
.args(["--user", "--quiet", "is-enabled", status::UNIT_NAME])
.status()
.is_ok_and(|s| s.success())
}
/// One tray per session: `flock` on a runtime-dir lockfile (held for the process lifetime).
fn acquire_instance_lock() -> Option<std::fs::File> {
let dir = std::env::var_os("XDG_RUNTIME_DIR")
.map(std::path::PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(dir.join("punktfunk-tray.lock"))
.ok()?;
// SAFETY: `file` is an open, owned fd for the duration of the call; LOCK_NB makes this a
// non-blocking advisory lock attempt with no other side effects.
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
(rc == 0).then_some(file)
}
pub fn run(args: crate::Args) -> anyhow::Result<()> {
if args.quit {
// Windows-only convenience for the uninstaller; nothing to do here.
return Ok(());
}
if args.autostart && !host_present() {
return Ok(()); // not a host box — stay out of this user's tray
}
let Some(_lock) = acquire_instance_lock() else {
return Ok(()); // another instance already runs in this session
};
let poller_slot = Arc::new(OnceLock::new());
let tray = HostTray {
status: TrayStatus::Stopped, // placeholder; the poller fires within its first cycle
web_port: args.web_port,
web_console: false, // live-probed by the poller within its first cycle
poller: poller_slot.clone(),
};
// Autostart races the desktop (the watcher may register after us) → be lenient and wait for
// it. A manual launch should fail loudly instead (e.g. GNOME without the AppIndicator
// extension) so the user learns why there is no icon.
use ksni::blocking::TrayMethods;
let handle = match tray.assume_sni_available(args.autostart).spawn() {
Ok(h) => h,
Err(e) if args.autostart => {
eprintln!("punktfunk-tray: no StatusNotifier host ({e}); exiting");
return Ok(());
}
Err(e) => anyhow::bail!(
"no StatusNotifier tray available ({e}) — on GNOME, install the AppIndicator extension"
),
};
let dead = Arc::new(AtomicBool::new(false));
let dead_flag = dead.clone();
let update_handle = handle.clone();
let poller = Poller::spawn(
args.mgmt_addr.clone(),
args.mgmt_port,
args.web_port,
Box::new(move |st, console_up| {
let updated = update_handle.update(|t: &mut HostTray| {
t.status = st;
t.web_console = console_up;
});
if updated.is_none() {
dead_flag.store(true, Ordering::SeqCst); // tray service shut down
}
}),
);
let _ = poller_slot.set(poller);
// The SNI service runs on its own thread; park here until it dies (shell logout etc.).
while !dead.load(Ordering::SeqCst) && !handle.is_closed() {
std::thread::sleep(std::time::Duration::from_secs(2));
}
Ok(())
}
+97
View File
@@ -0,0 +1,97 @@
//! punktfunk-tray — a small per-user system-tray companion for the punktfunk host service.
//!
//! Shows at a glance whether the host is running / stopped / degraded / failed (no more digging
//! through logs after a reboot or an update), and offers the common one-click actions: open the
//! web console, start/stop/restart the service (UAC-elevated per action on Windows,
//! `systemctl --user` on Linux), review a pending pairing request, exit.
//!
//! Status comes from two sources, service manager FIRST (a fake listener on the mgmt port can
//! never make a stopped service look running): the SCM / systemd user unit for the process state,
//! then the host's loopback-only unauthenticated `GET /api/v1/local/summary` for the streaming
//! details. Windows-subsystem binary — a console exe in the HKLM Run key would flash a terminal
//! window at every sign-in.
#![cfg_attr(windows, windows_subsystem = "windows")]
#[cfg(target_os = "linux")]
mod linux;
#[cfg(any(windows, target_os = "linux"))]
mod status;
#[cfg(windows)]
mod win;
/// CLI configuration (hand-rolled parse, house style). The mgmt address/port default to the
/// host's defaults; they are flags because the tray cannot read `host.env` on Windows (it is
/// DACL-locked to SYSTEM/Administrators), so an operator who moved `--mgmt-bind` adjusts the
/// autostart command line instead.
pub struct Args {
/// Ask an already-running tray instance to exit (Windows; used by the uninstaller).
pub quit: bool,
/// Launched from the desktop autostart entry: exit silently when this box doesn't run a host
/// (Linux; the package installs the autostart file for every desktop user).
pub autostart: bool,
/// Management API address to poll (loopback only; the summary route rejects anything else).
pub mgmt_addr: String,
pub mgmt_port: u16,
/// Web console port for the "Open web console" action.
pub web_port: u16,
}
impl Default for Args {
fn default() -> Self {
Args {
quit: false,
autostart: false,
mgmt_addr: "127.0.0.1".into(),
mgmt_port: 47990,
web_port: 47992,
}
}
}
fn parse_args() -> anyhow::Result<Args> {
let mut args = Args::default();
let mut it = std::env::args().skip(1);
while let Some(a) = it.next() {
let mut value = |flag: &str| {
it.next()
.ok_or_else(|| anyhow::anyhow!("{flag} needs a value"))
};
match a.as_str() {
"--quit" => args.quit = true,
"--autostart" => args.autostart = true,
"--mgmt-addr" => args.mgmt_addr = value("--mgmt-addr")?,
"--mgmt-port" => args.mgmt_port = value("--mgmt-port")?.parse()?,
"--web-port" => args.web_port = value("--web-port")?.parse()?,
"--version" | "-V" => {
println!("punktfunk-tray {}", env!("CARGO_PKG_VERSION"));
std::process::exit(0);
}
other => anyhow::bail!(
"unknown argument '{other}'\n\nUSAGE:\n punktfunk-tray [--autostart] [--quit] \
[--mgmt-addr <IP>] [--mgmt-port <N>] [--web-port <N>]"
),
}
}
Ok(args)
}
fn main() -> anyhow::Result<()> {
let args = parse_args()?;
run(args)
}
#[cfg(windows)]
fn run(args: Args) -> anyhow::Result<()> {
win::run(args)
}
#[cfg(target_os = "linux")]
fn run(args: Args) -> anyhow::Result<()> {
linux::run(args)
}
#[cfg(not(any(windows, target_os = "linux")))]
fn run(_args: Args) -> anyhow::Result<()> {
// Workspace-stub build (macOS CI etc.) — the tray ships on Windows and Linux only.
anyhow::bail!("punktfunk-tray supports Windows and Linux hosts only")
}
+506
View File
@@ -0,0 +1,506 @@
//! Host status model + the poller thread feeding the platform tray implementations.
//!
//! Two sources, service manager FIRST: the SCM (Windows) / systemd user unit (Linux) decides
//! stopped-vs-running — a malicious local process squatting the mgmt port while the service is
//! down can never make the tray say Running. Only when the service manager reports Running does
//! the poller consult the host's loopback-only `GET /api/v1/local/summary` for streaming detail.
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
/// What the service manager reports for the host service.
#[derive(Clone, Debug, PartialEq)]
pub enum ServiceState {
NotInstalled,
Stopped,
StartPending,
StopPending,
Running,
/// Linux `ActiveState=failed` (with the sub-state), or a Windows stop with a failure exit code.
Failed(String),
}
/// `GET /api/v1/local/summary` — the non-sensitive counts/booleans the host serves to loopback
/// peers without authentication (mgmt.rs `LocalSummary`). Unknown fields are ignored so a newer
/// host can grow the summary without breaking an older tray.
#[derive(Clone, Debug, PartialEq, serde::Deserialize)]
pub struct Summary {
pub version: String,
pub video_streaming: bool,
pub audio_streaming: bool,
pub session: Option<SessionInfo>,
pub paired_clients: u32,
pub native_paired_clients: u32,
pub pin_pending: bool,
pub pending_approvals: u32,
}
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
pub struct SessionInfo {
pub width: u32,
pub height: u32,
pub fps: u32,
}
/// What the icon shows.
#[derive(Clone, Debug, PartialEq)]
pub enum TrayStatus {
NotInstalled,
Stopped,
/// Service starting, or running with the mgmt API not answering yet (within [`START_GRACE`]).
Starting,
Running(Summary),
/// Service running but the summary unreachable past the grace period — amber, not red: a
/// custom `PUNKTFUNK_HOST_CMD` (no mgmt API) or a relocated `--mgmt-bind` is legitimate.
Degraded,
Error(String),
}
impl TrayStatus {
/// One-line headline for the tooltip / the disabled menu header.
pub fn headline(&self) -> String {
match self {
TrayStatus::NotInstalled => "punktfunk host — not installed".into(),
TrayStatus::Stopped => "punktfunk host — stopped".into(),
TrayStatus::Starting => "punktfunk host — starting…".into(),
TrayStatus::Degraded => "punktfunk host — running (status unavailable)".into(),
TrayStatus::Error(e) => format!("punktfunk host — failed ({e})"),
TrayStatus::Running(s) => match (&s.session, s.video_streaming) {
(Some(sess), true) => format!(
"punktfunk host {} — streaming {}×{}@{}",
s.version, sess.width, sess.height, sess.fps
),
(_, true) => format!("punktfunk host {} — streaming", s.version),
_ => format!("punktfunk host {} — idle", s.version),
},
}
}
pub fn is_streaming(&self) -> bool {
matches!(self, TrayStatus::Running(s) if s.video_streaming)
}
/// A pairing attempt is waiting on the operator (shown as an extra menu entry).
pub fn pairing_attention(&self) -> bool {
matches!(self, TrayStatus::Running(s) if s.pin_pending || s.pending_approvals > 0)
}
}
/// How long a running service may leave the summary unreachable before Starting turns Degraded.
/// Also re-applied mid-life: the SYSTEM supervisor relaunching a crashed host child looks like
/// "running, briefly unreachable" — that shows as Starting again, not an alarming flicker to red.
pub const START_GRACE: Duration = Duration::from_secs(15);
/// Pure status mapping (unit-tested): service-manager state first, summary second, grace third.
pub fn map_status(svc: &ServiceState, summary: Option<Summary>, grace_expired: bool) -> TrayStatus {
match svc {
ServiceState::NotInstalled => TrayStatus::NotInstalled,
ServiceState::Stopped | ServiceState::StopPending => TrayStatus::Stopped,
ServiceState::StartPending => TrayStatus::Starting,
ServiceState::Failed(e) => TrayStatus::Error(e.clone()),
ServiceState::Running => match summary {
Some(s) => TrayStatus::Running(s),
None if !grace_expired => TrayStatus::Starting,
None => TrayStatus::Degraded,
},
}
}
// ── Poller ──────────────────────────────────────────────────────────────────────────────────────
pub struct Poller {
shared: Arc<Shared>,
}
struct Shared {
poked: Mutex<bool>,
cv: Condvar,
}
impl Poller {
/// Spawn the poll thread; `on_change(status, console_up)` fires (from that thread) whenever
/// either changes. `console_up` is a live loopback probe of the web console on `web_port` —
/// ground truth for the "Open web console" menu entry (a layout sniff would miss consoles run
/// from a repo checkout, and shows a dead entry while an installed console is still starting).
pub fn spawn(
mgmt_addr: String,
mgmt_port: u16,
web_port: u16,
on_change: Box<dyn Fn(TrayStatus, bool) + Send>,
) -> Poller {
let shared = Arc::new(Shared {
poked: Mutex::new(false),
cv: Condvar::new(),
});
let thread_shared = shared.clone();
std::thread::Builder::new()
.name("status-poll".into())
.spawn(move || poll_loop(&thread_shared, &mgmt_addr, mgmt_port, web_port, on_change))
.expect("spawn status-poll thread");
Poller { shared }
}
/// Force an immediate re-poll (right after a start/stop/restart menu action).
pub fn poke(&self) {
*self.shared.poked.lock().unwrap() = true;
self.shared.cv.notify_one();
}
}
fn poll_loop(
shared: &Shared,
mgmt_addr: &str,
mgmt_port: u16,
web_port: u16,
on_change: Box<dyn Fn(TrayStatus, bool) + Send>,
) {
// IPv6 literals bracketed, like the Linux client's `base_url`.
let url = if mgmt_addr.contains(':') {
format!("https://[{mgmt_addr}]:{mgmt_port}/api/v1/local/summary")
} else {
format!("https://{mgmt_addr}:{mgmt_port}/api/v1/local/summary")
};
let console_url = format!("https://127.0.0.1:{web_port}/");
let agent = agent(load_pin());
let mut last: Option<(TrayStatus, bool)> = None;
// When the summary became unreachable while the service was running (grace anchor).
// Runs for the process lifetime (the tray exits by process exit; nothing to unwind).
let mut unreachable_since: Option<Instant> = None;
loop {
let svc = probe_service();
let summary = if svc == ServiceState::Running {
let s = fetch_summary(&agent, &url);
match s {
Some(_) => unreachable_since = None,
None if unreachable_since.is_none() => unreachable_since = Some(Instant::now()),
None => {}
}
s
} else {
unreachable_since = None;
None
};
let grace_expired = unreachable_since.is_some_and(|t| t.elapsed() >= START_GRACE);
let status = map_status(&svc, summary, grace_expired);
let console_up = probe_console(&agent, &console_url);
if last.as_ref() != Some(&(status.clone(), console_up)) {
on_change(status.clone(), console_up);
last = Some((status, console_up));
}
// 3 s while there is anything to watch; back off when the box just doesn't run a host.
let cadence = match last.as_ref().map(|(s, _)| s) {
Some(TrayStatus::Stopped) | Some(TrayStatus::NotInstalled) => Duration::from_secs(10),
_ => Duration::from_secs(3),
};
let mut poked = shared.poked.lock().unwrap();
if !*poked {
(poked, _) = shared.cv.wait_timeout(poked, cadence).unwrap();
}
*poked = false;
}
}
/// Is the web console answering on loopback? Any HTTP response (incl. the login redirect / 401)
/// counts as up — only a transport failure (nothing listening, TLS handshake dead) means down.
fn probe_console(agent: &ureq::Agent, url: &str) -> bool {
match agent.get(url).call() {
Ok(_) => true,
Err(ureq::Error::Status(..)) => true,
Err(_) => false,
}
}
// ── Summary fetch (loopback HTTPS) ──────────────────────────────────────────────────────────────
fn fetch_summary(agent: &ureq::Agent, url: &str) -> Option<Summary> {
let body = agent.get(url).call().ok()?.into_string().ok()?;
serde_json::from_str(&body).ok()
}
/// The host identity cert's SHA-256, when `cert.pem` is readable (Linux: same-user file). On
/// Windows the file is SYSTEM/Administrators-DACL'd, so the per-user tray can't pin — `None` =
/// accept any cert. That is acceptable here: the connection is loopback, carries no credentials,
/// and only *reads* non-sensitive data; stopped-vs-running is decided by the service manager, so
/// a port-squatter gains nothing but a fake "streaming" tooltip on an already-compromised box.
fn load_pin() -> Option<[u8; 32]> {
use rustls::pki_types::pem::PemObject;
use sha2::Digest;
let pem = std::fs::read(punktfunk_config_dir()?.join("cert.pem")).ok()?;
let der = rustls::pki_types::CertificateDer::from_pem_slice(&pem).ok()?;
Some(sha2::Sha256::digest(der.as_ref()).into())
}
/// The host's config dir, mirroring `gamestream::config_dir()` without linking the host crate:
/// `PUNKTFUNK_CONFIG_DIR` override, else `$XDG_CONFIG_HOME`/`~/.config` + `punktfunk` (Linux).
/// `None` on Windows — everything the tray would read there is SYSTEM/Admins-DACL'd anyway.
pub fn punktfunk_config_dir() -> Option<std::path::PathBuf> {
if let Some(d) = std::env::var_os("PUNKTFUNK_CONFIG_DIR") {
if !d.is_empty() {
return Some(std::path::PathBuf::from(d));
}
}
#[cfg(target_os = "linux")]
{
if let Some(x) = std::env::var_os("XDG_CONFIG_HOME") {
if !x.is_empty() {
return Some(std::path::PathBuf::from(x).join("punktfunk"));
}
}
std::env::var_os("HOME").map(|h| {
std::path::PathBuf::from(h)
.join(".config")
.join("punktfunk")
})
}
#[cfg(not(target_os = "linux"))]
None
}
/// A sync HTTPS agent over the same rustls(ring) stack the rest of the workspace uses, with a
/// pin-or-accept-any verifier (the Linux client's `PinVerify` pattern, `library.rs`).
fn agent(pin: Option<[u8; 32]>) -> ureq::Agent {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let cfg = rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.expect("rustls default protocol versions")
.dangerous()
.with_custom_certificate_verifier(Arc::new(PinVerify { pin }))
.with_no_client_auth();
ureq::AgentBuilder::new()
.tls_config(Arc::new(cfg))
.timeout_connect(Duration::from_secs(2))
.timeout(Duration::from_secs(2))
.build()
}
/// Trust = the SHA-256 of the host's self-signed leaf (or any cert when un-pinned). Handshake
/// signatures are still verified for real — CertificateVerify proves the peer holds the key.
#[derive(Debug)]
struct PinVerify {
pin: Option<[u8; 32]>,
}
impl rustls::client::danger::ServerCertVerifier for PinVerify {
fn verify_server_cert(
&self,
end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
use sha2::Digest;
if let Some(expected) = self.pin {
let fp: [u8; 32] = sha2::Sha256::digest(end_entity.as_ref()).into();
if fp != expected {
return Err(rustls::Error::InvalidCertificate(
rustls::CertificateError::ApplicationVerificationFailure,
));
}
}
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
// ── Service-manager probe ───────────────────────────────────────────────────────────────────────
/// The SCM name registered by `punktfunk-host service install` (windows/service.rs SERVICE_NAME).
#[cfg(windows)]
pub const SERVICE_NAME: &str = "PunktfunkHost";
#[cfg(windows)]
pub fn probe_service() -> ServiceState {
use windows_service::service::{ServiceAccess, ServiceExitCode, ServiceState as Scm};
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
// CONNECT + QUERY_STATUS work unprivileged. Re-opened every poll on purpose: a reinstall
// (delete + create) invalidates old handles, and this picks the new service up within a poll.
let Ok(manager) = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
else {
return ServiceState::NotInstalled;
};
let Ok(svc) = manager.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS) else {
return ServiceState::NotInstalled; // ERROR_SERVICE_DOES_NOT_EXIST et al.
};
let Ok(status) = svc.query_status() else {
return ServiceState::NotInstalled;
};
match status.current_state {
Scm::StartPending => ServiceState::StartPending,
Scm::StopPending => ServiceState::StopPending,
Scm::Running | Scm::ContinuePending | Scm::PausePending | Scm::Paused => {
ServiceState::Running
}
Scm::Stopped => match status.exit_code {
// 0 = clean; 1077 = never started since boot (ERROR_SERVICE_NEVER_HAS_BEEN_RUN? no —
// "no attempts to start have been made"): both are an ordinary Stopped, not a failure.
ServiceExitCode::Win32(0) | ServiceExitCode::Win32(1077) => ServiceState::Stopped,
ServiceExitCode::Win32(code) => ServiceState::Failed(format!("exit code {code}")),
ServiceExitCode::ServiceSpecific(code) => {
ServiceState::Failed(format!("service error {code}"))
}
},
}
}
/// The systemd user unit the Linux packages install (scripts/punktfunk-host.service).
#[cfg(target_os = "linux")]
pub const UNIT_NAME: &str = "punktfunk-host.service";
#[cfg(target_os = "linux")]
pub fn probe_service() -> ServiceState {
// `systemctl show` exits 0 even for unknown units (LoadState=not-found) — parse, don't rely
// on the exit code.
let Ok(out) = std::process::Command::new("systemctl")
.args([
"--user",
"show",
UNIT_NAME,
"--property=LoadState,ActiveState,SubState",
])
.output()
else {
return ServiceState::NotInstalled; // no systemctl → nothing to watch
};
let text = String::from_utf8_lossy(&out.stdout);
let prop = |key: &str| {
text.lines()
.find_map(|l| l.strip_prefix(key)?.strip_prefix('='))
.unwrap_or("")
.to_string()
};
if prop("LoadState") == "not-found" {
return ServiceState::NotInstalled;
}
match prop("ActiveState").as_str() {
"active" | "reloading" => ServiceState::Running,
"activating" => ServiceState::StartPending,
"deactivating" => ServiceState::StopPending,
"failed" => ServiceState::Failed(prop("SubState")),
_ => ServiceState::Stopped, // "inactive" and anything new
}
}
#[cfg(test)]
mod tests {
use super::*;
fn summary(streaming: bool) -> Summary {
Summary {
version: "0.5.1".into(),
video_streaming: streaming,
audio_streaming: streaming,
session: streaming.then_some(SessionInfo {
width: 2560,
height: 1440,
fps: 120,
}),
paired_clients: 1,
native_paired_clients: 2,
pin_pending: false,
pending_approvals: 0,
}
}
/// The full (service state × summary × grace) table.
#[test]
fn status_mapping_table() {
use ServiceState as S;
use TrayStatus as T;
let cases: Vec<(S, Option<Summary>, bool, T)> = vec![
(S::NotInstalled, None, false, T::NotInstalled),
(S::Stopped, None, false, T::Stopped),
(S::StopPending, None, false, T::Stopped),
(S::StartPending, None, false, T::Starting),
(
S::Failed("code 3".into()),
None,
false,
T::Error("code 3".into()),
),
// Running + summary → Running regardless of grace.
(
S::Running,
Some(summary(false)),
true,
T::Running(summary(false)),
),
// Running + unreachable: Starting within grace, Degraded past it.
(S::Running, None, false, T::Starting),
(S::Running, None, true, T::Degraded),
// A summary while the SCM says Stopped is impossible by construction (the poller only
// fetches when Running) — but the mapping must still trust the service manager.
(S::Stopped, Some(summary(true)), false, T::Stopped),
];
for (svc, sum, grace, want) in cases {
assert_eq!(
map_status(&svc, sum.clone(), grace),
want,
"{svc:?} {sum:?} grace={grace}"
);
}
}
#[test]
fn headline_shows_session_and_reason() {
assert_eq!(
TrayStatus::Running(summary(true)).headline(),
"punktfunk host 0.5.1 — streaming 2560×1440@120"
);
assert_eq!(
TrayStatus::Running(summary(false)).headline(),
"punktfunk host 0.5.1 — idle"
);
assert!(TrayStatus::Error("exit code 3".into())
.headline()
.contains("exit code 3"));
assert!(TrayStatus::Degraded
.headline()
.contains("status unavailable"));
}
#[test]
fn pairing_attention_flags() {
let mut s = summary(false);
assert!(!TrayStatus::Running(s.clone()).pairing_attention());
s.pending_approvals = 1;
assert!(TrayStatus::Running(s.clone()).pairing_attention());
s.pending_approvals = 0;
s.pin_pending = true;
assert!(TrayStatus::Running(s).pairing_attention());
assert!(!TrayStatus::Degraded.pairing_attention());
}
}
+473
View File
@@ -0,0 +1,473 @@
//! Windows tray: a hidden top-level window + `Shell_NotifyIconW`, fed by the status poller.
//!
//! The host service (`PunktfunkHost`, LocalSystem) supervises from session 0 and its `serve`
//! child runs as SYSTEM — neither can own a per-user tray icon, so this is a separate small
//! process the installer puts in the HKLM `Run` key (one instance per interactive session,
//! enforced by a `Local\` mutex). Start/Stop/Restart open one UAC consent prompt each
//! (`ShellExecuteW "runas"` on `punktfunk-host.exe service …`) — service control is deliberately
//! left admin-gated rather than DACL-opened to every local user.
use std::os::windows::ffi::OsStrExt;
use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering};
use std::sync::{Mutex, OnceLock};
use windows::core::{w, PCWSTR};
use windows::Win32::Foundation::{
GetLastError, ERROR_ALREADY_EXISTS, HWND, LPARAM, LRESULT, WPARAM,
};
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::System::Threading::CreateMutexW;
use windows::Win32::UI::Shell::{
ShellExecuteW, Shell_NotifyIconW, NIF_ICON, NIF_MESSAGE, NIF_SHOWTIP, NIF_TIP, NIM_ADD,
NIM_DELETE, NIM_MODIFY, NIM_SETVERSION, NIN_SELECT, NOTIFYICONDATAW, NOTIFYICON_VERSION_4,
};
use windows::Win32::UI::WindowsAndMessaging::{
AppendMenuW, CreatePopupMenu, CreateWindowExW, DefWindowProcW, DestroyMenu, DestroyWindow,
DispatchMessageW, FindWindowW, GetCursorPos, GetMessageW, LoadIconW, PostMessageW,
PostQuitMessage, RegisterClassW, RegisterWindowMessageW, SetForegroundWindow,
SetMenuDefaultItem, TrackPopupMenuEx, TranslateMessage, HICON, MF_GRAYED, MF_SEPARATOR,
MF_STRING, MSG, SW_HIDE, SW_SHOWNORMAL, TPM_BOTTOMALIGN, TPM_RIGHTBUTTON, WINDOW_EX_STYLE,
WM_APP, WM_CLOSE, WM_COMMAND, WM_CONTEXTMENU, WM_DESTROY, WM_ENDSESSION, WM_NULL, WNDCLASSW,
WS_OVERLAPPED,
};
use crate::status::{Poller, TrayStatus};
/// Keyboard "select" on the icon (Enter/Space) — `NIN_SELECT | NINF_KEY`; the windows crate
/// exports only NIN_SELECT.
const NIN_KEYSELECT: u32 = NIN_SELECT | 0x1;
/// Posted by the poller thread when the status changed (never touch TLS on the UI thread).
const WMAPP_STATUS: u32 = WM_APP + 2;
/// The notify-icon callback message (NOTIFYICON_VERSION_4 semantics).
const WMAPP_NOTIFYCALLBACK: u32 = WM_APP + 1;
// Menu command ids (WM_COMMAND LOWORD(wParam)).
const IDM_HEADER: usize = 0x0100; // disabled status line
const IDM_OPEN_WEB: usize = 0x0101;
const IDM_START: usize = 0x0102;
const IDM_STOP: usize = 0x0103;
const IDM_RESTART: usize = 0x0104;
const IDM_LOGS: usize = 0x0105;
const IDM_EXIT: usize = 0x0106;
const IDM_PAIRING: usize = 0x0107;
/// Icon resource ordinals (embedded by build.rs).
fn icon_ordinal(status: &TrayStatus) -> u16 {
match status {
TrayStatus::Running(_) if status.is_streaming() => 5,
TrayStatus::Running(_) => 2,
TrayStatus::Stopped | TrayStatus::NotInstalled => 3,
TrayStatus::Error(_) => 4,
TrayStatus::Starting | TrayStatus::Degraded => 6,
}
}
/// Global tray state — a tray has exactly one window and one wndproc, which cannot carry a
/// closure environment, so the state lives in a `OnceLock` set before window creation.
struct App {
hwnd: AtomicIsize,
status: Mutex<TrayStatus>,
poller: OnceLock<Poller>,
/// `TaskbarCreated` broadcast id — Explorer restarted, re-add the icon.
taskbar_created: u32,
/// `punktfunk-host.exe` next to this exe (the installer lays both in `{app}`).
host_exe: Option<std::path::PathBuf>,
/// The console answered the poller's live loopback probe — the "Open web console" entry is
/// shown iff opening it would actually work (repo-run consoles included, stopped ones not).
web_console: AtomicBool,
web_port: u16,
}
static APP: OnceLock<App> = OnceLock::new();
fn app() -> &'static App {
APP.get().expect("APP initialized before window creation")
}
fn to_wide(s: &str) -> Vec<u16> {
std::ffi::OsStr::new(s).encode_wide().chain([0]).collect()
}
/// Best-effort log for a windows-subsystem process (no stderr): `%LOCALAPPDATA%\punktfunk\tray.log`.
fn log(msg: &str) {
let Some(base) = std::env::var_os("LOCALAPPDATA") else {
return;
};
let dir = std::path::PathBuf::from(base).join("punktfunk");
let _ = std::fs::create_dir_all(&dir);
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(dir.join("tray.log"))
{
use std::io::Write;
let _ = writeln!(f, "{msg}");
}
}
pub fn run(args: crate::Args) -> anyhow::Result<()> {
let _ = args.autostart; // Linux-only flag, accepted for a uniform command line
if args.quit {
return quit_existing();
}
// One tray per session: `Local\` scopes the mutex to this logon session, so fast-user-switched
// sessions each keep their own icon. Handle deliberately leaked (held for the process life).
// SAFETY: CreateMutexW with a valid nul-terminated name and no security attributes; the
// returned handle is never closed (process-lifetime singleton guard).
let already = unsafe {
match CreateMutexW(None, false, w!("Local\\PunktfunkTray")) {
Ok(_) => GetLastError() == ERROR_ALREADY_EXISTS,
Err(_) => false, // can't tell — carry on rather than losing the icon
}
};
if already {
return Ok(());
}
let host_exe = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join("punktfunk-host.exe")))
.filter(|p| p.exists());
// SAFETY: RegisterWindowMessageW with a static nul-terminated literal.
let taskbar_created = unsafe { RegisterWindowMessageW(w!("TaskbarCreated")) };
APP.set(App {
hwnd: AtomicIsize::new(0),
status: Mutex::new(TrayStatus::Stopped),
poller: OnceLock::new(),
taskbar_created,
host_exe,
web_console: AtomicBool::new(false), // live-probed by the poller within its first cycle
web_port: args.web_port,
})
.ok()
.expect("run() is called once");
// Hidden top-level window (NOT message-only — those never receive the TaskbarCreated
// broadcast, which is how the icon survives an Explorer restart).
// SAFETY: standard window-class registration + creation; the class name literal outlives the
// call, wndproc is a valid extern "system" fn, and the window is created on this thread which
// then runs the message loop.
let hwnd = unsafe {
let hinstance = GetModuleHandleW(None)?;
let class = WNDCLASSW {
lpfnWndProc: Some(wndproc),
hInstance: hinstance.into(),
lpszClassName: w!("PunktfunkTrayWindow"),
..Default::default()
};
if RegisterClassW(&class) == 0 {
anyhow::bail!("RegisterClassW failed: {:?}", GetLastError());
}
CreateWindowExW(
WINDOW_EX_STYLE(0),
w!("PunktfunkTrayWindow"),
w!("punktfunk tray"),
WS_OVERLAPPED,
0,
0,
0,
0,
None,
None,
Some(hinstance.into()),
None,
)?
};
app().hwnd.store(hwnd.0 as isize, Ordering::SeqCst);
// First NIM_ADD retried across the logon race (the taskbar may not exist yet at sign-in).
let mut added = false;
for _ in 0..10 {
if update_icon(hwnd, true) {
added = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
if !added {
log("Shell_NotifyIconW(NIM_ADD) kept failing — no taskbar?");
}
// The poller owns all network/SCM I/O; it only posts a message here.
let poller = Poller::spawn(
args.mgmt_addr.clone(),
args.mgmt_port,
args.web_port,
Box::new(move |st, console_up| {
*app().status.lock().unwrap() = st;
app().web_console.store(console_up, Ordering::SeqCst);
let hwnd = HWND(app().hwnd.load(Ordering::SeqCst) as *mut _);
// SAFETY: PostMessageW is documented thread-safe; a stale/destroyed hwnd fails
// harmlessly with an error we ignore.
unsafe {
let _ = PostMessageW(Some(hwnd), WMAPP_STATUS, WPARAM(0), LPARAM(0));
}
}),
);
let _ = app().poller.set(poller);
// SAFETY: classic message pump on the window's owning thread.
unsafe {
let mut msg = MSG::default();
while GetMessageW(&mut msg, None, 0, 0).into() {
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
Ok(())
}
/// `--quit`: ask a running instance (this session) to exit — used by the uninstaller before file
/// deletion. High-IL callers may message a medium-IL window (UIPI blocks only low→high).
fn quit_existing() -> anyhow::Result<()> {
// SAFETY: FindWindowW/PostMessageW on a class-name literal; both fail harmlessly when no
// instance is running.
unsafe {
if let Ok(hwnd) = FindWindowW(w!("PunktfunkTrayWindow"), PCWSTR::null()) {
let _ = PostMessageW(Some(hwnd), WM_CLOSE, WPARAM(0), LPARAM(0));
}
}
Ok(())
}
/// Build/refresh the notify icon from the current status. Returns false when the shell rejected
/// the call (no taskbar yet).
fn update_icon(hwnd: HWND, add: bool) -> bool {
let status = app().status.lock().unwrap().clone();
let mut nid = NOTIFYICONDATAW {
cbSize: std::mem::size_of::<NOTIFYICONDATAW>() as u32,
hWnd: hwnd,
uID: 1,
uFlags: NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_SHOWTIP,
uCallbackMessage: WMAPP_NOTIFYCALLBACK,
..Default::default()
};
// SAFETY: LoadIconW by ordinal from this exe's embedded resources (build.rs); the ordinal is
// one of the ids compiled in, and a failure falls back to a null icon rather than UB.
nid.hIcon = unsafe {
LoadIconW(
Some(GetModuleHandleW(None).unwrap_or_default().into()),
PCWSTR(icon_ordinal(&status) as usize as *const u16),
)
}
.unwrap_or(HICON(std::ptr::null_mut()));
// Tooltip: truncate to the szTip capacity (127 UTF-16 units + nul).
let tip = to_wide(&status.headline());
let n = tip.len().min(nid.szTip.len() - 1);
nid.szTip[..n].copy_from_slice(&tip[..n]);
// SAFETY: nid is fully initialized with a correct cbSize; NIM_* calls only read it.
unsafe {
if add {
if !Shell_NotifyIconW(NIM_ADD, &nid).as_bool() {
return false;
}
let mut v = nid;
v.Anonymous.uVersion = NOTIFYICON_VERSION_4;
let _ = Shell_NotifyIconW(NIM_SETVERSION, &v);
true
} else {
if !Shell_NotifyIconW(NIM_MODIFY, &nid).as_bool() {
// Icon vanished (Explorer crash we missed) — re-add.
return update_icon(hwnd, true);
}
true
}
}
}
/// The right-click menu, rebuilt from the live status each time.
fn show_menu(hwnd: HWND) {
let status = app().status.lock().unwrap().clone();
let running = matches!(
status,
TrayStatus::Running(_) | TrayStatus::Starting | TrayStatus::Degraded
);
let startable = matches!(status, TrayStatus::Stopped | TrayStatus::Error(_));
let can_control = app().host_exe.is_some();
// SAFETY: menu handle created and destroyed here; AppendMenuW copies the item strings, whose
// wide buffers outlive each call. TrackPopupMenuEx requires the foreground quirk handled
// below (SetForegroundWindow before, WM_NULL after) per the Shell_NotifyIcon docs.
unsafe {
let Ok(menu) = CreatePopupMenu() else { return };
let add = |id: usize, text: &str, grayed: bool| {
let wide = to_wide(text);
let flags = if grayed {
MF_STRING | MF_GRAYED
} else {
MF_STRING
};
let _ = AppendMenuW(menu, flags, id, PCWSTR(wide.as_ptr()));
};
add(IDM_HEADER, &status.headline(), true);
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
if app().web_console.load(Ordering::SeqCst) {
add(IDM_OPEN_WEB, "Open web console", false);
let _ = SetMenuDefaultItem(menu, IDM_OPEN_WEB as u32, 0);
if status.pairing_attention() {
add(IDM_PAIRING, "Approve pairing request…", false);
}
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
}
if can_control {
if startable {
add(IDM_START, "Start host", false);
}
if running {
add(IDM_STOP, "Stop host", false);
add(IDM_RESTART, "Restart host", false);
} else if matches!(status, TrayStatus::Error(_)) {
add(IDM_RESTART, "Restart host", false);
}
}
add(IDM_LOGS, "Open logs folder", false);
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
add(IDM_EXIT, "Exit tray", false);
let mut pt = Default::default();
let _ = GetCursorPos(&mut pt);
let _ = SetForegroundWindow(hwnd);
let _ = TrackPopupMenuEx(
menu,
(TPM_RIGHTBUTTON | TPM_BOTTOMALIGN).0,
pt.x,
pt.y,
hwnd,
None,
);
let _ = PostMessageW(Some(hwnd), WM_NULL, WPARAM(0), LPARAM(0));
let _ = DestroyMenu(menu);
}
}
/// `ShellExecuteW` "open" on a URL / folder.
fn shell_open(hwnd: HWND, target: &str) {
let wide = to_wide(target);
// SAFETY: all strings nul-terminated and live across the call.
unsafe {
ShellExecuteW(
Some(hwnd),
w!("open"),
PCWSTR(wide.as_ptr()),
PCWSTR::null(),
PCWSTR::null(),
SW_SHOWNORMAL,
);
}
}
/// One UAC prompt per service action: relaunch the host exe elevated with `service <verb>`.
/// A declined prompt (ERROR_CANCELLED) is deliberately ignored.
fn elevate_service(hwnd: HWND, verb: &str) {
let Some(exe) = app().host_exe.as_ref() else {
return;
};
let exe_w = to_wide(&exe.to_string_lossy());
let params = to_wide(&format!("service {verb}"));
// SAFETY: nul-terminated strings live across the call; "runas" spawns the elevated child
// (hidden console — the tray re-polls for the outcome instead of scraping its output).
unsafe {
ShellExecuteW(
Some(hwnd),
w!("runas"),
PCWSTR(exe_w.as_ptr()),
PCWSTR(params.as_ptr()),
PCWSTR::null(),
SW_HIDE,
);
}
if let Some(p) = app().poller.get() {
p.poke();
}
}
fn open_web_console(hwnd: HWND) {
shell_open(hwnd, &format!("https://localhost:{}", app().web_port));
}
fn open_logs(hwnd: HWND) {
let Some(base) = std::env::var_os("ProgramData") else {
return;
};
let dir = std::path::PathBuf::from(base)
.join("punktfunk")
.join("logs");
shell_open(hwnd, &dir.to_string_lossy());
}
extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
let Some(app) = APP.get() else {
// SAFETY: pass-through for messages arriving before APP is set (CreateWindowExW sends
// WM_NCCREATE/WM_CREATE synchronously — APP is set before that, but stay defensive).
return unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) };
};
match msg {
WMAPP_STATUS => {
update_icon(hwnd, false);
LRESULT(0)
}
WMAPP_NOTIFYCALLBACK => {
// NOTIFYICON_VERSION_4: LOWORD(lParam) is the event.
match (lparam.0 as u32) & 0xffff {
WM_CONTEXTMENU => show_menu(hwnd),
x if x == NIN_SELECT || x == NIN_KEYSELECT => {
if app.web_console.load(Ordering::SeqCst) {
open_web_console(hwnd);
} else {
show_menu(hwnd);
}
}
_ => {}
}
LRESULT(0)
}
WM_COMMAND => {
match (wparam.0) & 0xffff {
IDM_OPEN_WEB => open_web_console(hwnd),
IDM_PAIRING => open_web_console(hwnd),
IDM_START => elevate_service(hwnd, "start"),
IDM_STOP => elevate_service(hwnd, "stop"),
IDM_RESTART => elevate_service(hwnd, "restart"),
IDM_LOGS => open_logs(hwnd),
// SAFETY: DestroyWindow on the wndproc's own window/thread.
IDM_EXIT => unsafe {
let _ = DestroyWindow(hwnd);
},
_ => {}
}
LRESULT(0)
}
WM_CLOSE | WM_ENDSESSION => {
// SAFETY: as above — triggers WM_DESTROY below.
unsafe {
let _ = DestroyWindow(hwnd);
}
LRESULT(0)
}
WM_DESTROY => {
let nid = NOTIFYICONDATAW {
cbSize: std::mem::size_of::<NOTIFYICONDATAW>() as u32,
hWnd: hwnd,
uID: 1,
..Default::default()
};
// SAFETY: minimal, correctly sized nid; NIM_DELETE only reads hWnd/uID.
unsafe {
let _ = Shell_NotifyIconW(NIM_DELETE, &nid);
PostQuitMessage(0);
}
LRESULT(0)
}
m if m == app.taskbar_created => {
// Explorer restarted — the icon is gone; add it back.
update_icon(hwnd, true);
LRESULT(0)
}
// SAFETY: default handling for everything else.
_ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) },
}
}
+236
View File
@@ -0,0 +1,236 @@
# Handoff — sealing the gamepad SHM channels
Status: **implemented (Option A), 2026-07-03 — Windows CI + on-glass validation pending.** The design
below was implemented as proposed; the "Implementation notes" section records what was actually built
and the deltas. Remaining: build + sign + redeploy both pad drivers, then the hardware validation plan
(§Validation) — it needs a physical controller on the box.
This closes the one open residual left by the IDD-push sealed-channel work
(`design/idd-push-security.md`): frames were sealed; the gamepad input/output channel was not.
## Unsafe hygiene (2026-07-03 follow-up — the drivers' `unsafe` was confined)
After the seal landed, the pad drivers' `unsafe` footprint (raw `OpenFileMapping`/`MapViewOfFile`,
`read_unaligned`, the whole bootstrap state machine as bare-pointer arithmetic) was pulled into a new
audited crate **`pf-umdf-util`** (`packaging/windows/drivers/pf-umdf-util/`), so the drivers benefit
from Rust instead of being C-in-Rust:
- `section::MappedView` — a mapped section wrapped as bounds- + alignment-checked accessors
(`load_u32`/`store_u32`/`read_bytes`/…). Callers never see the base pointer; an out-of-range offset
asserts instead of corrupting. `ViewCell` holds the adopted view as a leaked `&'static` (the
re-delivery-must-not-unmap rule, now type-enforced).
- `channel::ChannelClient` — the ENTIRE sealed-channel driver side (publish pid → adopt handle →
validate magic+`pad_index`), as a **`#![forbid(unsafe_code)]`** module over `MappedView`. One
implementation both pad drivers share (was hand-duplicated).
- `wdf::{Request, query_location_index, retrieve_next_request}` — the WDF request/memory/property FFI
behind safe methods; a callback turns its raw `WDFREQUEST` into a `Request` token once (the only
`unsafe` at the driver boundary), and completion consumes the token.
Result: `pf-xusb`/`pf-dualsense` business logic is **100 % safe Rust**; the only remaining `unsafe` in
them is the unavoidable WDF *setup* FFI in `DriverEntry`/`EvtDeviceAdd`/the timer, each with a
`// SAFETY:` proof. The display driver `pf-vdisplay` is inherently FFI-bound (D3D11 / IddCx DDIs /
cross-process textures) so it can't be unsafe-*free*, but it's now unsafe-*audited*: every `unsafe {}`
carries a proof. Both invariants are lint-gated across the whole drivers workspace
(`#![deny(unsafe_op_in_unsafe_fn)]` + `#![deny(clippy::undocumented_unsafe_blocks)]`) and enforced by
a new `cargo clippy -D warnings` step in `windows-drivers.yml`. Verified on the RTX box (.173): the
whole workspace builds + clippies + fmt-checks clean; both gamepad DLLs still produce.
## Implementation notes (what was built, 2026-07-03)
- **Contract** (`pf_driver_proto::gamepad`, `GAMEPAD_PROTO_VERSION = 2`): `PadBootstrap` (32 B —
`magic "PFBT"`, `host_proto`, `driver_pid`, `driver_proto`, `data_handle: u64`, `handle_pid`,
`handle_seq`) with `Pod` + `offset_of!` asserts; `xusb_boot_name`/`pad_boot_name`
(`Global\pf…-boot-<index>`) REPLACE the old `*_shm_name` fns (the DATA-section name is gone);
`XusbShm`/`PadShm` gained `pad_index` (carved from reserved space) so the DRIVER validates a
delivery resolves to *its own* pad — the authentic-side answer to the "redirect the dup into a
different pad's WUDFHost" hardening note (the section content is host-written and unreachable by a
sibling LS, so the check can't be spoofed). Both pad drivers now path-dep `pf-driver-proto` (as
pf-vdisplay does) instead of hand-synced literals.
- **Host** (`inject/windows/gamepad_raii.rs`): `Shm::create_unnamed` (DATA, `D:P(A;;GA;;;SY)`) +
`Shm::create_named` (mailbox, SY+LS, **squat-checked**`ERROR_ALREADY_EXISTS` on create is
close+retry×5 then a hard error, so the handshake never runs through a pre-created object; this also
turns the previously-silent two-hosts-same-index cross-wire into a loud failure). `PadChannel` owns
both + the delivery state machine: poll `driver_pid``OpenProcess`
`verify_is_wudfhost` (now shared with the frame broker in `capture/windows/idd_push.rs`) →
`DuplicateHandle` → publish `data_handle`/`handle_pid`, bump `handle_seq` last (Release). Pumped
from each backend's existing service tick (≤4 ms) + a bounded **eager delivery** (1.5 s) at pad-open
so the DS4's `device_type` is readable before hidclass asks for descriptors. Delivery attempts are
**capped at 16 per pad** so a tampered flapping mailbox can't mint unbounded remote handles. Same
pid never retried (failed verify can't be spun into a hot loop).
- **Drivers** (`pf-xusb`, `pf-dualsense`): per-tick `pump_bootstrap()` (the DS timer / every XUSB
IOCTL + a bounded EvtDeviceAdd worker thread for XUSB's no-game-running case) opens the mailbox *by
name each time* — the name existing doubles as host-liveness, replacing the old per-access section
open; mailbox gone → detach (DS additionally resets the pended-read report to neutral instead of
the old frozen-last-state behavior). The driver writes `driver_proto` always but publishes its pid
**only when `host_proto` matches** (fail closed both ways: v1 host never creates a mailbox a v2
driver polls; a v1 driver opens a name that no longer exists). A delivery is adopted once
(CAS on `handle_seq`, reset when the mailbox disappears so a new host session's counter can't
collide), mapped, and validated: `magic` AND `pad_index == SHM_INDEX` — else unmapped + ignored
(the handle is deliberately NOT closed on validation failure: a tampered value could name an
unrelated handle in the driver's own table). The adopted view is cached and never unmapped
(re-delivery swaps + leaks the old 64/256 B mapping on purpose — a concurrent reader may hold it).
Driver log line for validation step 3: `sealed pad channel mapped (index …)`.
- **Not built:** Option B (devnode custom properties). The residual named mailbox is documented and
DoS-bounded; migrate later if it's ever deemed worth removing.
## The problem (why this exists)
Each virtual pad's host↔driver channel is a **named** shared-memory section:
- `Global\pfxusb-shm-<index>` (64 B, [`pf_driver_proto::gamepad::XusbShm`]) — virtual Xbox 360 / XInput.
- `Global\pfds-shm-<index>` (256 B, [`pf_driver_proto::gamepad::PadShm`]) — virtual DualSense / DualShock 4.
Both are created by the SYSTEM host with DACL `D:(A;;GA;;;SY)(A;;GA;;;LS)` (`inject/windows/gamepad_raii.rs`
`Shm::create`) so the driver's WUDFHost (LocalService) can open them by name. That means **a sibling
LocalService process can `OpenFileMapping` the section by name** and:
- **read** the victim's live controller input (buttons/sticks/gyro/touchpad — host→driver `input` region), and
- **inject/forge** gamepad input or rumble (write the `input` region → the driver feeds it to whatever game
has focus; write the `output` region + bump `out_seq` → forge rumble/LED back to the client).
This is the *same* name-open vector we closed for frames, one module over. Severity is lower than desktop
capture (it's game-controller I/O, scoped to the focused app, and requires the attacker to already have
LocalService code execution), but it is real and it is inconsistent to leave named next to a sealed frame ring.
**Not a stopgap:** randomizing the section name is inadequate — the object namespace is enumerable with
`NtQueryDirectoryObject`, so a random name is discoverable. (Same reason it was rejected for frames.) The fix
is to remove the name.
## Why it isn't already sealed the frame way
The frame channel seals cleanly because pf-vdisplay has a **control device** (the IddCx device interface):
the host duplicates the unnamed handles into the driver's WUDFHost and delivers the values over
`IOCTL_SET_FRAME_CHANNEL`, and the driver reports its own pid in the `IOCTL_ADD` reply.
The pad drivers (`pf-dualsense`, `pf-xusb`) are **UMDF HID minidrivers with no control device** — hidclass
owns the device stack and blocks a freely-openable control interface. That is *why* they use a named section
in the first place. So there is no IOCTL to (a) hand the driver a duplicated handle or (b) learn the driver's
WUDFHost pid. Compounding it: `pszDeviceLocation` (the existing host→driver property) is fixed at
`SwDeviceCreate` time — **before** the WUDFHost process exists — so the host can't duplicate a handle into a
not-yet-created process and stamp its value there. A bidirectional, late-bound handshake is required.
## Current architecture (what to modify)
Host (`crates/punktfunk-host/src/inject/windows/`):
- `gamepad_raii.rs``Shm::create(name, size)` creates the **named** section (SY+LS SDDL) + maps it;
`SwDevice` wraps the `SwDeviceCreate` devnode.
- `gamepad_windows.rs` (XUSB), `dualsense_windows.rs` (DualSense/DS4), `dualshock4_windows.rs` — each creates
its `Shm`, then `create_swdevice(index)` / `create_swdevice(profile)` which stamps the pad **index** into
`info.pszDeviceLocation` (a UTF-16 decimal string) and creates `pf_xusb_<index>` / `pf_pad_<index>`.
Driver (`packaging/windows/drivers/pf-{xusb,dualsense}/src/lib.rs`):
- `query_shm_index(device)``WdfDeviceAllocAndQueryProperty(DevicePropertyLocationInformation)` → parses the
decimal → `SHM_INDEX` static.
- On first control activity it builds `format!("Global\\pf…-shm-{}", SHM_INDEX)`, `OpenFileMappingW` +
`MapViewOfFile`. The dualsense driver also runs a ~125 Hz timer (writes `driver_heartbeat`) — an existing
poll loop to piggyback a bootstrap-wait on.
Contract (`crates/pf-driver-proto/src/lib.rs` `mod gamepad`): owns `XusbShm`/`PadShm` layouts, the magics,
`xusb_shm_name`/`pad_shm_name`, `device_type`, `GAMEPAD_PROTO_VERSION`, and the driver_proto/heartbeat fields.
## Proposed design — a late-bound bootstrap handshake
Split each pad's channel into **(1) an unnamed DATA section** (the real `XusbShm`/`PadShm`, host↔driver) and
**(2) a tiny bootstrap mailbox** that carries only a magic + the driver's pid + a handle value. The handshake:
1. **Host**, per pad: create the DATA section **unnamed** (`CreateFileMappingW` with `PCWSTR::null()`, DACL
`D:P(A;;GA;;;SY)` — SYSTEM-only, exactly as the sealed frame ring now uses; the driver reaches it by
duplicated handle, which carries access, so no LS ACE is needed). Then create the devnode via
`SwDeviceCreate`, stamping the pad index into `pszDeviceLocation` **as today** (the index still identifies
*which* pad's bootstrap the driver should use).
2. **Driver** `EvtDeviceAdd`: read the index (unchanged `query_shm_index`). Write `std::process::id()` where
the host can read it, then **poll** (piggyback the existing timer) for a delivered handle value; map the
DATA section from it once non-zero.
3. **Host**: learn the driver's pid, `OpenProcess(PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION)`,
**verify it is the WUDFHost servicing this pad's devnode** (see hardening note), `DuplicateHandle` the
DATA section into the WUDFHost, and deliver the resulting handle value back to the driver.
Two viable transports for steps 23's pid-out / handle-in (pick one):
- **Option A — named bootstrap mailbox** (`Global\pf…-boot-<index>`, ~32 B, SY+LS): host creates it; driver
opens it by name (index from location), writes `driver_pid`, spins on `data_handle` != 0; host polls
`driver_pid`, dups the DATA section in, writes `data_handle` + a ready seq. **Safe to leave named + SY+LS**
because it carries *only* a pid (not sensitive) and a handle value (meaningless outside the target WUDFHost)
— identical to the frame channel's "the bootstrap ACL is not load-bearing" argument. A sibling LS that reads
it learns nothing exploitable; one that tampers it can at worst feed a bogus pid/handle → the driver maps a
value that doesn't resolve in its own table → **DoS, not a breach** (the attacker cannot place a valid
section handle in the WUDFHost, so it cannot make the driver map an attacker-controlled section). *Fastest to
build — reuses the existing named-section + poll machinery.*
- **Option B — devnode custom properties** (no `Global\` object at all): driver writes its pid via
`WdfDeviceAssignProperty(DEVPROPKEY_pf_pad_pid)`; host reads it via `CM_Get_DevNode_PropertyW` /
`SetupDiGetDevicePropertyW`, dups in, writes a `DEVPROPKEY_pf_pad_handle` property; driver re-queries it in
its timer. Tighter (property store isn't world-readable like the Global namespace) but more moving parts and
UMDF-property-write ergonomics to prove out. *Cleaner end-state.*
Recommendation: **build Option A first** (small, mirrors the frame channel, gets the DATA section unnamed —
which is the actual isolation win, proven by #3 below), then optionally migrate the bootstrap to Option B if
the residual named mailbox is deemed worth removing.
## Reuse the frame-channel precedent
- **Ownership/adopt-on-success** discipline from `capture/windows/idd_push.rs` `ChannelBroker` — exactly one
side ever closes a duplicated handle value; reap remote duplicates (`DUPLICATE_CLOSE_SOURCE`) on any failure.
- **`verify_is_wudfhost`** (`idd_push.rs`) — before duplicating into the driver-reported pid, confirm it's
`%SystemRoot%\System32\WUDFHost.exe`. **Strengthen it here**: also confirm the pid is the host *servicing
this pad's devnode* (walk devnode → process, e.g. via the driver writing a per-pad nonce it echoes, or a
devnode/PID association) so a tampered bootstrap can't redirect the dup into a *different* pad's WUDFHost.
- **Contract in `pf_driver_proto::gamepad`** — add the bootstrap layout (`PadBootstrap { magic, driver_pid,
data_handle: u64, seq }`) with `Pod` + `offset_of!` asserts, bump `GAMEPAD_PROTO_VERSION`, and (Option A)
keep `pad_shm_name`/`xusb_shm_name` only for the bootstrap mailbox, dropping the data-section name.
- **SDDL** on the DATA section: `D:P(A;;GA;;;SY)` (SYSTEM-only) — validated safe for a duplicated-handle
consumer on the frame ring (the driver's `OpenSharedResource`/`MapViewOfFile` on a handle does not re-check
the object DACL).
## Security properties after the change
- The **DATA section is unnamed** and only ever handle-duplicated into the pad WUDFHost. Empirically
(`design/idd-push-security.md`, RTX box 2026-07-03) a **LocalService token is DACL-denied `OpenProcess` on a
UMDF WUDFHost for every access right incl. `QUERY_LIMITED`** — so a sibling LS cannot dup the handle out or
read the WUDFHost's memory. Unnamed + unopenable-host ⇒ no sibling-LS path to the input/output data. This is
the same guarantee the frame channel now has, and it rests on the same verified property.
- **Residual (Option A):** the bootstrap mailbox stays named + SY+LS, but carries only a pid + handle value →
worst case a sibling LS causes a **gamepad DoS**, never a read or injection. Option B removes even that.
- **Unchanged inherent limits:** admin/SYSTEM = total; the game reading the pad sees the input by design.
## Validation plan (needs hardware)
The blocker for calling this done is that it **requires a physical controller on the box** — the memory notes
repeatedly flag the gamepad path as "needs a physical pad to live-verify," and neither the probe nor a
synthetic client exercises a real game reading the virtual pad.
1. Build + sign + redeploy `pf-dualsense` and `pf-xusb` (same loop as pf-vdisplay:
`packaging/windows/drivers/deploy-dev.ps1` per driver, or `redeploy-*`; DriverVer must strictly increase).
Bump `GAMEPAD_PROTO_VERSION` — a v_new host against a v_old pad driver (or vice-versa) must fail closed, so
deploy host + both pad drivers together.
2. Connect a real client with a physical controller; confirm in a game that input works and rumble/LED return.
3. Driver log (`C:\Users\Public\pfds-driver.log` / `pfxusb-driver.log` in debug builds): confirm the driver
reports its pid, receives a handle, and maps the DATA section (add a `dbglog!` "sealed pad channel mapped").
4. Re-run the **sibling-LS `OpenFileMapping` test**: from a LocalService scheduled task, attempt to open the
old `Global\pf…-shm-<index>` name — it must now **fail (name gone)**, and attempting to open the bootstrap
(Option A) must yield only pid+handle bytes. (Reuse the scheduled-task P/Invoke harness from the #3 frame
test — see the session that produced `design/idd-push-security.md`.)
5. Multi-pad: two controllers → two devnodes, two unnamed DATA sections, two bootstraps by index; confirm no
cross-talk and clean teardown (`SwDeviceClose` + host handle close; the WUDFHost dies with its devnode).
## Risks / gotchas
- **Regression risk to a working feature.** Gamepad input currently works on glass; this reroutes its
bootstrap. Keep the change behind the `GAMEPAD_PROTO_VERSION` bump and be ready to revert both drivers.
- **Chicken-and-egg timing.** The driver loads and wants the handle before the host has dup'd it — the poll
loop must tolerate a bounded wait (mirror the frame path's `wait_for_attach`, ~4 s) and the driver must not
block `EvtDeviceAdd` on it (spin in the timer, not the add callback).
- **Handle value in shared memory is a `u64`.** A WUDFHost handle value is process-local; writing it to the
bootstrap is safe (meaningless elsewhere), but the driver must treat it as untrusted (validate the mapped
DATA section's magic before use — the existing `XusbShm`/`PadShm` magic already gives this).
- **Two drivers, one contract.** DualSense and DualShock 4 share `pf-dualsense`/`PadShm`; XUSB is separate.
Factor the bootstrap into `pf_driver_proto::gamepad` so both drivers + the host use one definition (as the
frame channel does).
## Effort
Medium — comparable to the frame sealed-channel change but across **two** drivers plus the host inject code,
and gated on **physical-controller validation** that can't be driven over SSH. Files: `pf_driver_proto`
(gamepad module), `inject/windows/{gamepad_raii,gamepad_windows,dualsense_windows,dualshock4_windows}.rs`,
`packaging/windows/drivers/pf-{xusb,dualsense}/src/lib.rs`. Reference implementation: the frame sealed channel
(`capture/windows/idd_push.rs` + `packaging/windows/drivers/pf-vdisplay/src/{control,monitor,frame_transport}.rs`
+ `pf_driver_proto` `control`/`frame`).
+145
View File
@@ -0,0 +1,145 @@
# IDD-push frame channel — security model (the sealed channel)
Status: **implemented** (host `capture/windows/idd_push.rs` + driver
`packaging/windows/drivers/pf-vdisplay/src/{control,monitor,frame_transport}.rs`, contract
`crates/pf-driver-proto` v2). Windows CI-validated; on-glass validation pending.
## What is being protected
The IDD-push path moves **whole-desktop frames** — including the secure desktop (UAC prompts, the
lock screen) — from the pf-vdisplay driver (UMDF, running in a `WUDFHost.exe` under LocalService)
into the SYSTEM host for encoding. That data is SYSTEM-tier-sensitive, and because we bypass the OS
capture APIs (Desktop Duplication / WGC), **we own the isolation those APIs would have provided.**
DDA's isolation property is that capturer and consumer are the same process: there is no openable
channel at all — to reach the frames you must own the capturing process. The sealed channel
reproduces exactly that property for our two-process design.
## The design
```
┌──────────────────────────┐ control device (SY+BA only) ┌───────────────────────────┐
│ Host (SYSTEM service) │ ── IOCTL_SET_FRAME_CHANNEL: handle ────▶ │ pf-vdisplay driver │
│ creates header/event/ │ VALUES only (integers) │ (WUDFHost, LocalService) │
│ ring textures UNNAMED, │ │ maps/opens the duplicated │
│ DuplicateHandle()s them │ ◀── frames via keyed-mutex textures ──── │ handles; publishes frames │
│ INTO WUDFHost, encodes │ (no names anywhere) │ │
└──────────────────────────┘ └───────────────────────────┘
trust boundary: only these two processes ever hold a handle to any frame object
```
1. **Every frame object is unnamed** (header section, frame-ready event, all ring textures —
`CreateFileMappingW`/`CreateEventW`/`CreateSharedHandle` with a null name). An unnamed object is
in no namespace: it cannot be enumerated (`NtQueryDirectoryObject` can't see it), cannot be
opened by name, and cannot be pre-created ("squatted"). It can be shared **only** by handle
duplication.
2. **The host is the broker.** SYSTEM opens the driver's WUDFHost with `PROCESS_DUP_HANDLE` (the pid
comes from the `IOCTL_ADD` reply, per-monitor, so a WUDFHost restart can't leave us duplicating
into a dead process) and `DuplicateHandle`s each object in. The reverse direction — LocalService
injecting into SYSTEM — is correctly denied by the OS, which is why the broker must be the host.
3. **The bootstrap carries only integers.** `IOCTL_SET_FRAME_CHANNEL` delivers the duplicated handle
*values*. A handle value is only meaningful inside the target process's handle table: a third
party that read (or even forged) the message would learn nothing openable and could at most feed
values that don't resolve — a DoS of its own session, not a read. The bootstrap's ACL is therefore
**not load-bearing**; we still restrict the control device to `D:P(A;;GA;;;SY)(A;;GA;;;BA)`
(INF `Security`), because ADD/REMOVE/CLEAR_ALL shouldn't be world-callable either.
Net result: the only way to reach the frames is to already run code as SYSTEM (the host) or inside
that specific WUDFHost (the driver) — DDA's property, achieved in user mode.
## Why user-mode, not a kernel driver
Ring level does not govern cross-process memory visibility — the handle/VAD access checks do; a user
process cannot `ReadProcessMemory` a LocalService process regardless of rings. What kernel-mode
*would* change is the blast radius of a driver bug: UMDF caps a pf-vdisplay compromise at the
LocalService token, a KMDF display driver would make it ring-0 full-system. Least-blast-radius is
the reason punktfunk ships **zero** kernel drivers (the gamepad stack dropped ViGEmBus for UMDF for
the same reason). The correct control for "SYSTEM-tier data in the channel" is sealing the channel —
done above — not raising the ring.
## Handle-lifetime invariants (the auditable list)
1. Frame objects unnamed; bootstrap carries only handle values. ✔ by construction
2. `bInheritHandle: false` on every object — no child inherits a handle. ✔
3. Zero-init header + atomic `magic`-last publish (the driver never acts on a half-initialized
ring); generation-tagged publish tokens reject stale-ring frames. ✔
4. Attacker-influenced header fields are bounds-checked before use (generation/seq/slot unpacking;
`ring_len` clamped; the driver validates `IOCTL_SET_FRAME_CHANNEL` before adopting anything). ✔
5. **Adopt-on-success-only:** the driver owns (and eventually closes) the delivered handles iff the
IOCTL completed successfully; on ANY error completion it leaves them untouched and the host reaps
its remote duplicates (`DUPLICATE_CLOSE_SOURCE`). Exactly one side closes each value — no
double-close of possibly-reused handle values, no leak on a half-delivered channel. ✔
6. Single ownership inside the driver: each delivery lives in exactly one place (monitor stash →
publisher), and whichever owner dies — replaced stash, dropped publisher, removed monitor, reaped
watchdog, departed device — closes the handles (`FrameChannel`/publisher `Drop`). Host-side
objects are RAII (`MappedSection`, `OwnedHandle`); nothing survives the capturer. ✔
7. The object DACL is `D:P(A;;GA;;;SY)`**SYSTEM only, protected**. Since the driver reaches the
objects via duplicated handles (which carry their own access; `OpenSharedResource1` on a handle does
not re-check the object DACL), the LocalService ACE was dropped — the minimal DACL. ✔ *(on-glass
confirmed 2026-07-03: the driver still attaches + delivers frames with SYSTEM-only objects.)*
8. **The duplication target is verified.** Before duplicating frame handles into `AddReply.wudf_pid`,
the host confirms that pid is `%SystemRoot%\System32\WUDFHost.exe` (`verify_is_wudfhost`). A spoofed
devnode advertising our interface GUID cannot redirect frames to an arbitrary process. ✔
9. **Handles are duplicated with least privilege, not `DUPLICATE_SAME_ACCESS`.** The driver's copy of
the header section is `SECTION_MAP_READ|WRITE` (matched by the driver mapping `FILE_MAP_READ|WRITE`,
not `FILE_MAP_ALL_ACCESS`), the frame-ready event is `EVENT_MODIFY_STATE` (the driver only signals
it), and the ring textures keep their already-scoped `CreateSharedHandle` access
(`DXGI_SHARED_RESOURCE_READ|WRITE`). So a compromised driver's handles can map/signal but cannot
`WRITE_DAC`/`WRITE_OWNER`/`DELETE` the objects — the "give unnamed shared objects proper (minimal)
security attributes, because `DuplicateHandle` can still reach them" discipline (Raymond Chen,
*devblogs 2015-06-04*). Marginal here (the driver is already a trusted frame endpoint) but correct
hygiene, and it applies identically to the gamepad DATA section. ✔ *(on-glass confirmed 2026-07-03:
the driver attaches + streams `frames=7035` with the least-access header handle.)*
Ring recreation (mid-session HDR flip) and host build-retries re-deliver a complete fresh handle set;
the driver treats a pending delivery as newest-wins (a retry's ring is a *different* header mapping,
whose generation bump an old publisher can never observe).
## Empirical verification (2026-07-03, RTX box)
The headline claim — "reaching a frame requires already being one of the two endpoint processes" —
was tested, not just argued. A **LocalService-token** process (scheduled task, the sibling-service
stand-in) attempting `OpenProcess` on the pf_vdisplay WUDFHost was **denied every access right**:
`PROCESS_DUP_HANDLE`, `PROCESS_VM_READ`, `PROCESS_QUERY_INFORMATION`, and even
`PROCESS_QUERY_LIMITED_INFORMATION``ERROR_ACCESS_DENIED`. The `QUERY_LIMITED` denial is decisive:
it is a read-class right MIC permits across integrity levels, so its denial is a **DACL exclusion of
the LocalService SID**, not an integrity ceiling — meaning even a higher-integrity LocalService
*service* is denied (LocalService lacks `SeDebugPrivilege`, so it cannot bypass the DACL). Combined
with the objects being unnamed, a sibling LocalService has **no reachable path to a frame**: no
name to open, no way to dup the handles out of WUDFHost, no way to read WUDFHost's memory. The
baseline (an elevated admin, holding `SeDebugPrivilege`) opened WUDFHost freely — expected, and the
reason "admin/SYSTEM = total" stays on the residual list below.
## Residual limits — the honest floor
* **The virtual display is a real monitor.** Any process in the interactive session can capture it
through the ordinary OS APIs (DDA/WGC/BitBlt), exactly as it can capture any physical monitor.
That floor is identical for every virtual-display streaming stack (Sunshine + VDD, Apollo/SudoVDA);
the sealed channel keeps *our* transport above that floor rather than below it. **This is the single
most realistic way for unprivileged session code to see the streamed pixels, and it is outside our
channel entirely.**
* **The gamepad channels are now sealed too** (2026-07-03, `design/gamepad-channel-sealing.md`,
gamepad proto v2 — on-glass validation pending): the pad DATA sections (`XusbShm`/`PadShm`) are
UNNAMED with `D:P(A;;GA;;;SY)`, handle-duplicated into the pad's WUDFHost by the host broker
(`inject/windows/gamepad_raii.rs` `PadChannel`, reusing this design's `verify_is_wudfhost` +
adopt-on-success discipline), and the driver validates the mapped section's magic + `pad_index`
before use. The pad drivers have no control device (hidclass), so the handshake runs over a tiny
**named bootstrap mailbox** (`Global\pf…-boot-<index>`, SY+LS, `PadBootstrap`) that carries only
pids and a handle value — nothing exploitable; the *residual* is that a sibling LocalService can
tamper the mailbox for a **gamepad DoS** (never a read or an injection; deliveries are capped, and
the mailbox is squat-checked at create). The old sibling-LS read/inject vector on
`Global\pf…-shm-*` is gone — the names no longer exist.
* **Admin / SYSTEM = total.** The control device is `D:P(A;;GA;;;SY)(A;;GA;;;BA)`, so an admin can drive
`IOCTL_SET_FRAME_CHANNEL` (DoS a live session) and, with `SeDebugPrivilege`, dup a section into
WUDFHost to exfiltrate; and an admin can plant a fake devnode with our interface GUID to impersonate
the driver. All admin-gated (no non-privileged escalation), but the control plane is explicitly not a
boundary against admin. The host↔driver channel has no mutual authentication beyond the `GET_INFO`
version handshake + the `verify_is_wudfhost` image check.
* **`WDA_EXCLUDEFROMCAPTURE` windows are visible.** IDD-push taps the *present* side, not the
*capture* side, so windows that exclude themselves from capture still appear in the stream — true
of every virtual-display streaming stack. Untested on our lab box; treat as expected behavior.
* **DRM/HDCP:** protected content is blanked by DWM at composition, and HDCP is a monitor↔GPU
handshake an indirect display cannot satisfy — neither is bypassed by this path.
* IDD-push is currently the **sole Windows capture path** (DDA and the WGC relay were removed). An
OS-mediated-capture-only mode would trade away secure-desktop capture and latency; if a deployment
requires it, that's a feature request, not a toggle that exists today.
+14 -3
View File
@@ -45,7 +45,10 @@ interactive session for secure-desktop capture (why MSIX is unusable - see
| `packaging/windows/drivers/pf-dualsense/` `pf-xusb/` | `build-gamepad-drivers.ps1` (sign the workspace build) | `pf_{dualsense,xusb}.{dll,inf,cat}` + shared `.cer` | | `packaging/windows/drivers/pf-dualsense/` `pf-xusb/` | `build-gamepad-drivers.ps1` (sign the workspace build) | `pf_{dualsense,xusb}.{dll,inf,cat}` + shared `.cer` |
| `packaging/windows/pf-vkhdr-layer/` | `pack-host-installer.ps1` (`cargo build --release`) | `pf_vkhdr_layer.dll` + `.json` | | `packaging/windows/pf-vkhdr-layer/` | `pack-host-installer.ps1` (`cargo build --release`) | `pf_vkhdr_layer.dll` + `.json` |
| `web/` | `scripts/windows/build-web.ps1` (`bun run build`) | self-contained `.output` | | `web/` | `scripts/windows/build-web.ps1` (`bun run build`) | self-contained `.output` |
| `packaging/windows/nvenc/nvenc.def` | `gen-nvenc-importlib.ps1` (llvm-dlltool) | `nvencodeapi.lib` (link import, no GPU/SDK) |
(NVENC needs no build artifact: its entry points are resolved at runtime from the driver's
`nvEncodeAPI64.dll` — a link-time import would prevent the all-vendor exe from starting on
AMD/Intel-only machines.)
## 3. The driver workspace - `packaging/windows/drivers/` ## 3. The driver workspace - `packaging/windows/drivers/`
@@ -118,8 +121,9 @@ needs, on the runner:
to the runner default). *History:* LLVM 21.1.2 was briefly pinned (`C:\llvm-21`) to dodge a to the runner default). *History:* LLVM 21.1.2 was briefly pinned (`C:\llvm-21`) to dodge a
bindgen-0.71 layout-test overflow on clang 22; the 0.72 bump retired that pin, so there's now one bindgen-0.71 layout-test overflow on clang 22; the 0.72 bump retired that pin, so there's now one
toolchain for both driver builds (the pack and `windows-drivers.yml`). toolchain for both driver builds (the pack and `windows-drivers.yml`).
- NVENC import lib synthesised from a 2-export `.def` via `llvm-dlltool` (`gen-nvenc-importlib.ps1`) - - NVENC needs nothing at build time: the entry points are runtime-loaded from the driver's
no GPU or NVIDIA SDK at build time. `nvEncodeAPI64.dll` (`encode/windows/nvenc.rs` `load_api`). A link-time import would stop the
all-vendor exe from even starting on AMD/Intel-only machines.
- `FFMPEG_DIR` (the BtbN gpl-shared x64 tree) for the AMD/Intel AMF/QSV link; NASM + CMake + - `FFMPEG_DIR` (the BtbN gpl-shared x64 tree) for the AMD/Intel AMF/QSV link; NASM + CMake +
`CMAKE_POLICY_VERSION_MINIMUM=3.5` for the CMake-from-source deps (aws-lc, opus). `CMAKE_POLICY_VERSION_MINIMUM=3.5` for the CMake-from-source deps (aws-lc, opus).
- **Gotcha:** `CARGO_HOME` must be an ASCII path (a non-ASCII username breaks SDL3's MSVC precompiled - **Gotcha:** `CARGO_HOME` must be an ASCII path (a non-ASCII username breaks SDL3's MSVC precompiled
@@ -143,6 +147,13 @@ tasks** (all default-checked): install the pf-vdisplay driver, install the gamep
HDR Vulkan layer, start the service. Silent install: `/VERYSILENT` (omit a task with HDR Vulkan layer, start the service. Silent install: `/VERYSILENT` (omit a task with
`/MERGETASKS="!installdriver"`). `/MERGETASKS="!installdriver"`).
**OS floor: Windows 11 22H2 (build 22621)**`MinVersion=10.0.22621`, with a `[Messages]
WinVersionTooLowError` override naming the requirement. pf-vdisplay is built against **IddCx 1.10**
(the 1.10 `IddCxStub`, HDR `*2` DDIs, FP16 caps; no runtime `IddCxGetVersion` downgrade), which first
shipped in Windows 11 22H2 — on Windows 10 (incl. LTSC) / Windows 11 21H2 the driver package installs
but the device fails start with Code 10 `STATUS_DEVICE_POWER_FAILURE` (field-reported on Windows 10
LTSC, 2026-07). The installer gate turns that late failure into an upfront message.
Install-time work runs from `punktfunk-host.exe` subcommands, **not** locale-parsed PowerShell *files* - Install-time work runs from `punktfunk-host.exe` subcommands, **not** locale-parsed PowerShell *files* -
the `[Run]` section calls `driver install [--gamepad] --dir <stage>` and `web setup --app-dir <app> the `[Run]` section calls `driver install [--gamepad] --dir <stage>` and `web setup --app-dir <app>
[--password-file <f>]` (`crates/punktfunk-host/src/windows/install.rs`). This is the ANSI-codepage [--password-file <f>]` (`crates/punktfunk-host/src/windows/install.rs`). This is the ANSI-codepage
+23 -13
View File
@@ -82,12 +82,18 @@ query.
**IDD-push is the universal primary path.** Capture comes straight from the driver's shared keyed-mutex **IDD-push is the universal primary path.** Capture comes straight from the driver's shared keyed-mutex
texture ring (`capture/windows/idd_push.rs`) — no Desktop Duplication, no `win32u` reparenting hook. The texture ring (`capture/windows/idd_push.rs`) — no Desktop Duplication, no `win32u` reparenting hook. The
host creates the ring; the driver opens it (permissive `D:(A;;GA;;;WD)` SDDL). The generation-tagged host creates the ring as a **sealed channel** (proto v2, `design/idd-push-security.md`): the header,
`latest = gen<<40 | seq<<8 | slot` stale-ring reject kills the HDR-flip garbage frame; a host-owned frame-ready event, and ring textures are **unnamed** (nothing to enumerate, open by name, or squat), and
3-slot `OUT_RING` rotated per frame is the texture-ownership contract that enables `pipeline_depth=2` the host `DuplicateHandle`s them into the driver's WUDFHost and delivers the handle *values* over the
(convert/copy on the 3D engine overlapping NVENC on the ASIC). It captures the **secure desktop** SYSTEM+admins-only control device (`IOCTL_SET_FRAME_CHANNEL`), so only the two endpoint processes can
(Winlogon/UAC/lock) directly (validated 2026-06-25), so there is no separate secure capturer in the ever reach a frame — DDA's isolation property in user mode. (The objects keep a `D:(A;;GA;;;SY)(A;;GA;;;LS)`
primary path. DACL as defense-in-depth; it is no longer the isolation boundary. This supersedes the earlier named-ring
scheme, which was world-openable `Global\pfvd-*` (`D:(A;;GA;;;WD)`) then SY+LS-scoped.) The
generation-tagged `latest = gen<<40 | seq<<8 | slot` stale-ring reject kills the HDR-flip garbage frame;
a host-owned 3-slot `OUT_RING` rotated per frame is the texture-ownership contract that enables
`pipeline_depth=2` (convert/copy on the 3D engine overlapping NVENC on the ASIC). It captures the
**secure desktop** (Winlogon/UAC/lock) directly (validated 2026-06-25), so there is no separate secure
capturer in the primary path.
- **Open-time fallback:** `IddPushCapturer::open` waits a bounded ~4 s for a *first frame* (not just - **Open-time fallback:** `IddPushCapturer::open` waits a bounded ~4 s for a *first frame* (not just
`DRV_STATUS_OPENED`); on attach failure it returns the keepalive back so `capture.rs` opens **DDA** on `DRV_STATUS_OPENED`); on attach failure it returns the keepalive back so `capture.rs` opens **DDA** on
@@ -120,10 +126,12 @@ loss-recovery by query (only Windows direct-NVENC overrides it; the GameStream l
### 2.5 Host↔driver ABI & the `pf-vdisplay` driver ### 2.5 Host↔driver ABI & the `pf-vdisplay` driver
`pf-driver-proto` is one `no_std` crate in both build graphs. It owns the **frame plane** (`FrameToken` `pf-driver-proto` is one `no_std` crate in both build graphs. It owns the **frame plane** (`FrameToken`
+ `Global\pfvd-*` names), the **control plane** (a fresh interface GUID — *not* SudoVDA's `e5bcc234`; + `SharedHeader`; since proto v2 the frame objects are **unnamed** — no `Global\pfvd-*` names — and are
contiguous `0x900` IOCTL ops; a `GET_INFO` version handshake the host **asserts** + bails on mismatch), delivered by handle duplication over `IOCTL_SET_FRAME_CHANNEL`, the *sealed channel*:
and the **gamepad SHM** (`XusbShm`/`PadShm` incl. `device_type`). `bytemuck`-`Pod` + `size_of` **and** `design/idd-push-security.md`), the **control plane** (a fresh interface GUID — *not* SudoVDA's
`offset_of!` asserts make ABI drift a **compile error**. `e5bcc234`; contiguous `0x900` IOCTL ops; a `GET_INFO` version handshake the host **asserts** + bails on
mismatch), and the **gamepad SHM** (`XusbShm`/`PadShm` incl. `device_type`). `bytemuck`-`Pod` +
`size_of` **and** `offset_of!` asserts make ABI drift a **compile error**.
The driver (`packaging/windows/drivers/pf-vdisplay/src/`) is an all-Rust UMDF IddCx driver on The driver (`packaging/windows/drivers/pf-vdisplay/src/`) is an all-Rust UMDF IddCx driver on
`windows-drivers-rs` + the `iddcx` `wdk-sys` subset; the STEP 08 build is the checklist in §6.3, its `windows-drivers-rs` + the `iddcx` `wdk-sys` subset; the STEP 08 build is the checklist in §6.3, its
@@ -200,8 +208,10 @@ These are expensive empirical wins; keep them intact when touching the code:
the hot-loop `KeyedMutexGuard`, and the driver's `pod_init!`; all box-validated, clean `sc stop` in the hot-loop `KeyedMutexGuard`, and the driver's `pod_init!`; all box-validated, clean `sc stop` in
~1 s). The driver already has the deny. Revisit D1-host as a final discipline pass (staged per-module) ~1 s). The driver already has the deny. Revisit D1-host as a final discipline pass (staged per-module)
if desired. if desired.
5. **M6 scaffolding cleanup** delete the bring-up diagnostics (`spawn_observer`/`DebugBlock` in 5. **M6 scaffolding cleanup** — the bring-up diagnostics (`spawn_observer`/`DebugBlock` in
`idd_push.rs`) and, once full parity is proven on glass, the host monoliths. `idd_push.rs`) were deleted with the sealed-channel change (they were the last fixed-name
`Global\` objects on the frame path); once full parity is proven on glass, the host monoliths
remain.
**Explicitly NOT doing (stability decision): E1 — driver `DeviceContext` ownership + per-`IDDCX_MONITOR` **Explicitly NOT doing (stability decision): E1 — driver `DeviceContext` ownership + per-`IDDCX_MONITOR`
`EvtCleanupCallback`.** The current process-global design is *sound*: IddCx DDIs receive only an `EvtCleanupCallback`.** The current process-global design is *sound*: IddCx DDIs receive only an
@@ -260,7 +270,7 @@ Local pre-push checks (this Linux box can't compile the Windows paths):
cargo test -p pf-driver-proto # the ABI crate (cross-platform) cargo test -p pf-driver-proto # the ABI crate (cross-platform)
cargo check -p punktfunk-host # Linux paths; win_* mods are #[cfg(windows)] cargo check -p punktfunk-host # Linux paths; win_* mods are #[cfg(windows)]
cargo clippy -p punktfunk-host --all-targets -- -D warnings cargo clippy -p punktfunk-host --all-targets -- -D warnings
# Windows host clippy (on the box): PUNKTFUNK_NVENC_LIB_DIR=C:\t\nvenc; # Windows host clippy (on the box; NVENC needs no import lib — runtime-loaded):
# cargo clippy -p punktfunk-host --features nvenc --target x86_64-pc-windows-msvc -- -D warnings # cargo clippy -p punktfunk-host --features nvenc --target x86_64-pc-windows-msvc -- -D warnings
# Driver build (on the box): cd packaging/windows/drivers; Version_Number=10.0.26100.0; # Driver build (on the box): cd packaging/windows/drivers; Version_Number=10.0.26100.0;
# LIBCLANG_PATH='C:\Program Files\LLVM\bin'; cargo build # LIBCLANG_PATH='C:\Program Files\LLVM\bin'; cargo build
+3
View File
@@ -19,6 +19,9 @@ mid-stream. You flip between Gaming Mode and Desktop with Bazzite's normal Steam
> pure desktop machine, [Ubuntu/Fedora KDE](/docs/ubuntu-kde) or [GNOME](/docs/ubuntu-gnome) are > pure desktop machine, [Ubuntu/Fedora KDE](/docs/ubuntu-kde) or [GNOME](/docs/ubuntu-gnome) are
> simpler. > simpler.
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
> the machine, so keep it on a trusted LAN or VPN and require pairing.
## Install ## Install
The host ships as an RPM in punktfunk's **Gitea RPM registry** (public), so a Bazzite / Fedora The host ships as an RPM in punktfunk's **Gitea RPM registry** (public), so a Bazzite / Fedora
+3
View File
@@ -10,6 +10,9 @@ systemd service and uses KWin to create per-client virtual displays, captured ze
> Validated live on **Fedora 44 KDE Plasma** with an RTX 4090: KWin virtual output + full > Validated live on **Fedora 44 KDE Plasma** with an RTX 4090: KWin virtual output + full
> zero-copy capture. Everything below is the reproducible flow — paste it on a fresh box. > zero-copy capture. Everything below is the reproducible flow — paste it on a fresh box.
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
> the machine, so keep it on a trusted LAN or VPN and require pairing.
The setup has three parts: **NVIDIA driver****host RPM****KWin streaming session**. The setup has three parts: **NVIDIA driver****host RPM****KWin streaming session**.
## 1. NVIDIA driver (RPM Fusion akmod) ## 1. NVIDIA driver (RPM Fusion akmod)
+9 -5
View File
@@ -6,7 +6,11 @@ description: Install the punktfunk host — on Linux from its package registry,
On Linux, the package registries are the real distribution channel. Pick your distro, add the repo, and On Linux, the package registries are the real distribution channel. Pick your distro, add the repo, and
install with your native package manager. Each row links to the full per-distro guide (add the repo, install with your native package manager. Each row links to the full per-distro guide (add the repo,
first-run steps, the web console) — those are the source of truth, so this page doesn't duplicate them. first-run steps, the web console) — those are the source of truth, so this page doesn't duplicate them.
On **Windows** (NVIDIA), the host ships as a signed installer instead — see [Windows](#windows-nvidia). On **Windows**, the host ships as a signed installer instead — see [Windows](#windows).
> **First, read [Security & Safe Use](/docs/security).** A streaming host is remote control of the
> machine. It's built for trusted local networks — don't expose it to the internet, and be thoughtful
> about which machine you host on (especially on Windows).
## Pick your distro ## Pick your distro
@@ -26,10 +30,10 @@ tracks new builds automatically.
> at the **canary** channel instead (`canary` apt distribution / `*-canary` rpm group). See > at the **canary** channel instead (`canary` apt distribution / `*-canary` rpm group). See
> [Release Channels](/docs/channels). > [Release Channels](/docs/channels).
## Windows (NVIDIA) ## Windows
punktfunk also runs as a native host on **Windows 10/11 (x64) with an NVIDIA GPU**, shipped as a punktfunk also runs as a native host on **Windows 11 22H2+ (x64)**, shipped as a signed
signed installer — see [Windows Host](/docs/windows-host) for what it includes and its limitations. installer — see [Windows Host](/docs/windows-host) for what it includes and its limitations.
1. From the [packages page](https://git.unom.io/unom/-/packages) (generic group), download the newest 1. From the [packages page](https://git.unom.io/unom/-/packages) (generic group), download the newest
**`punktfunk-host-setup-<ver>.exe`** and its matching **`.cer`**. **`punktfunk-host-setup-<ver>.exe`** and its matching **`.cer`**.
@@ -53,7 +57,7 @@ fallback without one. More detail — including the CLI `punktfunk-host service
## What the packages are ## What the packages are
- **`punktfunk-host`** — the streaming host. Install this on your Linux + NVIDIA gaming machine. - **`punktfunk-host`** — the streaming host. Install this on your Linux gaming machine.
- **`punktfunk-web`** — the browser management console (pairing + status). Recommended alongside the - **`punktfunk-web`** — the browser management console (pairing + status). Recommended alongside the
host; on RPM list it explicitly (`rpm-ostree install punktfunk punktfunk-web`). host; on RPM list it explicitly (`rpm-ostree install punktfunk punktfunk-web`).
- **`punktfunk-client`** — the GTK4 desktop client, for streaming *to* a Linux box (also shipped via - **`punktfunk-client`** — the GTK4 desktop client, for streaming *to* a Linux box (also shipped via
+1
View File
@@ -3,6 +3,7 @@
"pages": [ "pages": [
"index", "index",
"how-it-works", "how-it-works",
"security",
"quickstart", "quickstart",
"install", "install",
"---Host Setup---", "---Host Setup---",
+6 -2
View File
@@ -5,16 +5,20 @@ description: From nothing to streaming — set up a host and connect your first
This is the shortest path to a working stream. Each step links to the details. This is the shortest path to a working stream. Each step links to the details.
> A streaming host is remote control of the machine, so it's built for **trusted local networks** — keep
> it on your LAN or a VPN and don't expose it to the internet. Two minutes on
> [Security & Safe Use](/docs/security) before you start is worth it.
## 1. Set up the host ## 1. Set up the host
On your Linux + NVIDIA machine, follow the guide for your system: On your Linux gaming machine (NVIDIA, AMD, or Intel GPU), follow the guide for your system:
- [Ubuntu — GNOME](/docs/ubuntu-gnome) - [Ubuntu — GNOME](/docs/ubuntu-gnome)
- [Ubuntu — KDE Plasma](/docs/ubuntu-kde) - [Ubuntu — KDE Plasma](/docs/ubuntu-kde)
- [Fedora — KDE Plasma](/docs/fedora-kde) - [Fedora — KDE Plasma](/docs/fedora-kde)
- [Bazzite — gamescope / Steam](/docs/bazzite) - [Bazzite — gamescope / Steam](/docs/bazzite)
Each one covers the NVIDIA driver, the dependencies, and how to build and run the host. Check the Each one covers the GPU driver, the dependencies, and how to build and run the host. Check the
[Requirements](/docs/requirements) first if you're not sure your machine is a fit. [Requirements](/docs/requirements) first if you're not sure your machine is a fit.
## 2. Start the host ## 2. Start the host
+10 -3
View File
@@ -20,8 +20,9 @@ environments it supports today, each with its own guide:
Other wlroots compositors (Sway/Hyprland) also work but aren't a primary target. If your desktop isn't Other wlroots compositors (Sway/Hyprland) also work but aren't a primary target. If your desktop isn't
listed, the host still needs one of these compositor backends to create a virtual display. listed, the host still needs one of these compositor backends to create a virtual display.
> **Windows host:** punktfunk also runs as a native host on **Windows 10/11 (x64)** — a signed > **Windows host:** punktfunk also runs as a native host on **Windows 11 22H2 or newer (x64)** — a
> installer that registers a service and bundles a virtual-display driver. It encodes on NVIDIA > signed installer that registers a service and bundles a virtual-display driver (whose driver-
> framework needs make 22H2 the hard floor — Windows 10 is not supported). It encodes on NVIDIA
> (NVENC), AMD (AMF), or Intel (QSV), with a software fallback, and is newer than the Linux host; see > (NVENC), AMD (AMF), or Intel (QSV), with a software fallback, and is newer than the Linux host; see
> [Windows Host](/docs/windows-host). > [Windows Host](/docs/windows-host).
@@ -63,10 +64,16 @@ Minimum compositor versions (newer is fine):
## Network ## Network
- Host and client on the **same network** — a LAN, or a VPN that puts them on one subnet. punktfunk - Host and client on the **same network** — a LAN, or a VPN that puts them on one subnet. punktfunk
assumes a trusted local network; it's not built to be exposed to the public internet. assumes a trusted local network; it's **not built to be exposed to the public internet — don't
port-forward it.** To stream from outside your home, use a VPN so the remote client is on the same
private subnet.
- For best results, a wired or fast Wi-Fi link. The host can run a built-in **speed test** to pick a - For best results, a wired or fast Wi-Fi link. The host can run a built-in **speed test** to pick a
bitrate for your link (see [Configuration](/docs/configuration)). bitrate for your link (see [Configuration](/docs/configuration)).
> **Before you set up a host, read [Security & Safe Use](/docs/security).** A streaming host is
> remote control of the machine — it's important to understand what that exposes, why to keep it on a
> trusted network, and how pairing protects you.
## A client ## A client
You also need something to stream *to* — see [Connect a Client](/docs/clients). There are native You also need something to stream *to* — see [Connect a Client](/docs/clients). There are native
@@ -91,7 +91,8 @@ session unit — see [Bazzite](/docs/bazzite).
On Windows the host runs as a `LocalSystem` service that launches into the interactive session, so it On Windows the host runs as a `LocalSystem` service that launches into the interactive session, so it
captures the secure desktop (UAC / lock screen) and survives reboots with nobody logged in — the same captures the secure desktop (UAC / lock screen) and survives reboots with nobody logged in — the same
model Sunshine/Apollo use. model Sunshine/Apollo use. Because it runs at that privilege level, keep it on a trusted network and be
deliberate about which machine you host on — see [Security & Safe Use](/docs/security).
The easy path is the **signed installer**: download `punktfunk-host-setup-<ver>.exe` from the package The easy path is the **signed installer**: download `punktfunk-host-setup-<ver>.exe` from the package
registry ([`punktfunk-host-windows`](https://git.unom.io/unom/-/packages)) and run it. It drops the host registry ([`punktfunk-host-windows`](https://git.unom.io/unom/-/packages)) and run it. It drops the host
+153
View File
@@ -0,0 +1,153 @@
---
title: Security & Safe Use
description: What a streaming host actually exposes, why to keep it on a trusted network, and how punktfunk protects you.
---
Read this before you put a host on a network you don't fully control. punktfunk is built to be secure
**on a trusted local network**, and that's the setting we support today. This page is upfront about what
a streaming host is, what protects it, and where the honest limits are.
> **The short version**
> - **Keep the host on a network you trust** — your home LAN, or a private VPN that puts host and client
> on the same subnet. **Do not port-forward it to the public internet.**
> - **A streaming host is remote control of the machine.** Anyone who can stream to it sees the screen
> and can move the mouse, type, and act as a controller — the same as sitting at the keyboard.
> - **Pairing is the security boundary.** Require pairing (the default), pick a strong console
> password, and review your paired devices from time to time.
> - **Be thoughtful about *which* machine you run it on** — especially on Windows, where the host runs
> with high system privileges so it can do its job. Prefer a dedicated or gaming PC over one holding
> your most sensitive data.
## What a streaming host really is
Low-latency desktop and game streaming means two things travel over the network: **the screen goes
out, and input comes back in.** A paired client doesn't just watch — it drives. Its mouse, keyboard,
and controller are injected into the host's desktop, so **for anything it can reach, a streaming client
is equivalent to a person sitting at that machine.**
That's the feature. It's also the risk to understand:
- The host can capture the **secure desktop** — UAC elevation prompts and the lock screen — so a
connected client can see and interact with those too. (This is what lets you unlock and administer a
headless box remotely; it's the same capability Sunshine and Apollo provide.)
- Injected input isn't sandboxed to a game. Whoever is streaming can alt-tab, open a terminal, read
files, or change settings — whatever the logged-in session can do.
This is true of **every** remote-access and game-streaming tool, not just punktfunk. The takeaway isn't
"don't use it" — it's "treat access to your host the way you'd treat handing someone your unlocked
keyboard." The rest of this page is about making sure only people you intend can get that access.
## Keep it on a trusted network
**punktfunk assumes a trusted local network. It is not designed, tested, or hardened to be exposed to
the public internet — do not port-forward it.** There is no WAN-hardening story yet: no rate-limited
public authentication gateway, no DDoS protection, no assumption that hostile traffic is constantly
probing the ports. Exposing the streaming ports directly to the internet puts an interactive
control surface for your machine in front of the entire world.
If you want to stream from outside your home, tunnel in instead of opening up:
- **Use a VPN** — WireGuard, Tailscale, or your router's built-in VPN. This puts your remote client on
the *same private subnet* as the host, so from punktfunk's point of view it's still a local
connection, and the tunnel (not punktfunk) handles internet-facing authentication and encryption.
Discovery, pairing, and streaming then work exactly as they do at home.
- **Don't** map a router port to the host. A port-forward turns "trusted LAN service" into
"internet-facing service" with none of the protections that implies.
A note for **portable machines**: the installer opens the streaming ports on the firewall for *all*
network profiles, including Public. That's convenient at home but means that if you take a laptop host
onto an untrusted network — a café, a hotel, a conference — other devices on that network can reach the
ports and attempt to pair. Pairing still protects you (an attacker who doesn't know the PIN can't get
in), but the safest habit is to stop the host service, or firewall it off, when you're on a network you
don't control.
## What actually protects you
punktfunk has **no accounts and no cloud**. Trust is established directly, device-to-device, and then
pinned. The layers, from the outside in:
- **Pairing is required by default.** A new device can't stream until it completes a one-time
**PIN pairing ceremony** (SPAKE2): the host shows a 4-digit PIN, you enter it on the client, and the
exchange cryptographically binds both identities. An attacker who doesn't know the PIN gets a
*single online guess* — no offline cracking, no dictionary attack. See
[Pairing & Trust](/docs/pairing).
- **Identities are pinned.** After pairing, the client remembers the host's certificate fingerprint and
the host stores the client's. Reconnects are automatic and mutually authenticated; if a host's
fingerprint ever changes, the client refuses to auto-trust it and forces re-pairing.
- **The admin surface is loopback-only.** The management API's read-only status is reachable by paired
clients over the LAN (authenticated by their certificate), but every state-changing action — arming
pairing, removing devices, session control — is honored **only from the local machine** (the web
console connects over loopback). It is never exposed to the network.
- **The web console has its own password.** On Windows it's set during install (a strong random default)
and stored readable only by Administrators and SYSTEM.
**GameStream / Moonlight compatibility is the weak-crypto path — trusted LAN only.** To interoperate
with stock Moonlight clients, punktfunk can speak the legacy GameStream protocol, which pairs over
plain HTTP and uses older encryption. It is **opt-in** (`serve --gamestream`) and appropriate only on a
network you fully trust. The default native `punktfunk/1` protocol is the secure path (modern AEAD
crypto, pinned identities); leave GameStream off unless you specifically need Moonlight.
## Choosing which machine to host on
We've put real work into hardening the host — sealed capture and gamepad channels, no kernel drivers,
loopback-gated admin, pinned trust — and we'll keep at it. But security is also about *blast radius*:
if a host is ever compromised, or you misconfigure trust, what does the attacker get? So pick the
machine with that in mind.
### The Windows host runs with high privileges
To capture the secure desktop (UAC, lock screen) and stream across reboots with nobody logged in, the
Windows host installs a service that runs as **`LocalSystem` (SYSTEM)** — the highest local privilege on
Windows. This is the same design Sunshine and Apollo use, and it's what makes headless, log-in-optional
streaming possible. It also means the host is a high-value component: a compromise of the host, or a
device you paired that you shouldn't have, is a foothold at the most powerful level of that machine.
We mitigate this deliberately:
- **Zero kernel drivers.** The virtual display and all three virtual gamepads are **user-mode (UMDF)**
drivers, so a driver bug is contained to a restricted service account — never ring-0, never
full-system. (This is why punktfunk dropped ViGEmBus.)
- **Sealed internal channels.** The desktop-frame ring and the gamepad input/output channels are
passed between the host and its drivers as duplicated handles to unnamed objects, so another local
service can't open them by name to read your screen or forge controller input. (Details:
[`idd-push-security.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/idd-push-security.md)
and [`gamepad-channel-sealing.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/gamepad-channel-sealing.md).)
- **Secrets are locked down.** The management token, the host identity key, and the console password
are stored with Administrators/SYSTEM-only permissions.
**The honest floor still applies.** None of this defends against an attacker who is *already* an
administrator or SYSTEM on the box — at that level they own the machine regardless of punktfunk. And a
virtual display is a real monitor: any process already running in your desktop session can capture it
through the ordinary OS screen-capture APIs, exactly as it could capture a physical monitor. That floor
is the same for every virtual-display streaming stack.
**Recommendation:** run the Windows host on a **dedicated or gaming PC**, not on a machine that also
holds your most sensitive material (work laptop, financial records, the box with your password vault).
A gaming rig you stream from is a great fit; your primary secrets machine is not.
### The Linux host runs as your desktop user
The Linux host runs inside your normal desktop session as your **regular user account**, not root — so a
worst-case compromise is scoped to that user rather than the whole system. The same network guidance
applies: keep it on a trusted LAN or a VPN, require pairing, and don't expose it to the internet.
## A short hardening checklist
- **Require pairing** — it's the default; don't run `--open` / `--allow-tofu` except on a network you
fully trust and control.
- **Use a strong console password** and keep it out of shared documents.
- **Stay on a trusted network** — LAN or VPN. Never port-forward to the internet.
- **Leave GameStream off** unless you specifically need Moonlight compatibility.
- **Review paired devices** in the web console periodically; remove anything you don't recognize.
- **Keep the host updated** — security fixes ship in new builds.
- **On portable hosts**, stop the service when you're on an untrusted network.
## For the technically curious
The deeper security design lives in the repository, and it's candid about residual limits:
- [`design/idd-push-security.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/idd-push-security.md) — the sealed frame channel (why the Windows capture path is isolated), and its honest floor.
- [`design/gamepad-channel-sealing.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/gamepad-channel-sealing.md) — the sealed gamepad channel.
- [`design/security-review-2026-06-28.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/security-review-2026-06-28.md) and [`design/security-review.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/security-review.md) — the standing security reviews.
Found a security issue? Please report it privately rather than opening a public issue.
+3
View File
@@ -12,6 +12,9 @@ desktop-class SteamOS box is a natural always-on streaming host. The **Steam Dec
device we can test on today, so it's what these instructions are validated against; the same device we can test on today, so it's what these instructions are validated against; the same
on-device build works on any SteamOS 3 system. on-device build works on any SteamOS 3 system.
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
> the machine, so keep it on a trusted LAN or VPN and require pairing.
SteamOS is an immutable, read-only Arch base, so the host isn't a system package. Instead a single SteamOS is an immutable, read-only Arch base, so the host isn't a system package. Instead a single
script builds the host **natively inside a Debian-trixie distrobox** (ABI-matched to SteamOS's script builds the host **natively inside a Debian-trixie distrobox** (ABI-matched to SteamOS's
FFmpeg/glibc — the binary then runs natively on SteamOS) and wires it up as systemd user services. FFmpeg/glibc — the binary then runs natively on SteamOS) and wires it up as systemd user services.
+11
View File
@@ -73,6 +73,17 @@ Then log out and back in. On other distros this is `sudo usermod -aG input $USER
concurrent native sessions (up to 4 by default); heavy load is usually bitrate-bound, so concurrent native sessions (up to 4 by default); heavy load is usually bitrate-bound, so
lower the bitrate first. lower the bitrate first.
## Windows: "punktfunk Virtual Display" shows Code 10 in Device Manager
Sessions end with *"pf-vdisplay driver interface not found"* and Device Manager shows the
**punktfunk Virtual Display** device failed with **Code 10** (`STATUS_DEVICE_POWER_FAILURE`).
This means your Windows version is too old. The virtual-display driver requires the **IddCx 1.10**
driver framework, which first shipped in **Windows 11 22H2 (build 22621)** — on Windows 10
(including LTSC) and Windows 11 21H2 the driver installs but cannot start. Reinstalling won't help;
the fix is updating to Windows 11 22H2 or newer. (Current installers refuse to run on older
Windows for this reason; if you see this, the host was likely installed with an older installer.)
## Still stuck? ## Still stuck?
Run the host with `RUST_LOG=info` (or `debug`) and check `journalctl --user -u punktfunk-host` for the Run the host with `RUST_LOG=info` (or `debug`) and check `journalctl --user -u punktfunk-host` for the
+3 -1
View File
@@ -6,7 +6,9 @@ description: Set up a punktfunk host on Ubuntu with the GNOME desktop (Mutter).
Set up a punktfunk host on **Ubuntu** (Desktop or Server) running **GNOME**. The host uses GNOME's Set up a punktfunk host on **Ubuntu** (Desktop or Server) running **GNOME**. The host uses GNOME's
Mutter compositor to create a per-client virtual display. Tested on Ubuntu 24.04+ and GNOME 48+. Mutter compositor to create a per-client virtual display. Tested on Ubuntu 24.04+ and GNOME 48+.
> New to this? Skim [Requirements](/docs/requirements) first. > New to this? Skim [Requirements](/docs/requirements) first, and read
> [Security & Safe Use](/docs/security) — a streaming host is remote control of the machine, so keep it
> on a trusted LAN or VPN and require pairing.
## 1. NVIDIA driver ## 1. NVIDIA driver
+3 -1
View File
@@ -6,7 +6,9 @@ description: Set up a punktfunk host on Ubuntu with KDE Plasma (KWin).
Set up a punktfunk host on **Ubuntu** running **KDE Plasma**. The host uses KDE's KWin compositor to Set up a punktfunk host on **Ubuntu** running **KDE Plasma**. The host uses KDE's KWin compositor to
create a per-client virtual display. Needs **KWin 6.5.6 or newer**. create a per-client virtual display. Needs **KWin 6.5.6 or newer**.
> New to this? Skim [Requirements](/docs/requirements) first. > New to this? Skim [Requirements](/docs/requirements) first, and read
> [Security & Safe Use](/docs/security) — a streaming host is remote control of the machine, so keep it
> on a trusted LAN or VPN and require pairing.
## 1. NVIDIA driver ## 1. NVIDIA driver
+19 -3
View File
@@ -3,7 +3,7 @@ title: "Windows Host"
description: "Run the Punktfunk streaming host on a Windows PC — a first-class, all-vendor, virtual-display host." description: "Run the Punktfunk streaming host on a Windows PC — a first-class, all-vendor, virtual-display host."
--- ---
Set up a Punktfunk host on a **Windows 10/11 PC** and stream its desktop or games to any Punktfunk or Set up a Punktfunk host on a **Windows 11 PC (22H2 or newer)** and stream its desktop or games to any Punktfunk or
[Moonlight](/docs/moonlight) client. A signed installer registers a Windows service that streams at the [Moonlight](/docs/moonlight) client. A signed installer registers a Windows service that streams at the
client's **exact resolution and refresh** via Punktfunk's own **virtual display** — including client's **exact resolution and refresh** via Punktfunk's own **virtual display** — including
**HDR10** (10-bit BT.2020 PQ) when your Windows desktop is in HDR mode. The virtual display is created **HDR10** (10-bit BT.2020 PQ) when your Windows desktop is in HDR mode. The virtual display is created
@@ -12,13 +12,22 @@ the secure desktop (UAC prompts, the lock screen).
> New to this? Skim [Requirements](/docs/requirements) first. > New to this? Skim [Requirements](/docs/requirements) first.
> **Read [Security & Safe Use](/docs/security) before you set this up.** The Windows host runs as a
> `LocalSystem` service (so it can capture the secure desktop and stream headless), which makes it a
> high-privilege component — keep it on a trusted network, never expose it to the internet, and prefer
> a dedicated or gaming PC over a machine that holds your most sensitive data.
> This page is about the Windows **host** — streaming *from* a Windows PC. To stream *to* a Windows PC, > This page is about the Windows **host** — streaming *from* a Windows PC. To stream *to* a Windows PC,
> see the [Windows client](/docs/clients#windows-desktop-client). > see the [Windows client](/docs/clients#windows-desktop-client).
## Requirements ## Requirements
- **Windows 10 or 11, x64.** ARM64 is not built (no ARM64 NVIDIA driver, and the virtual-display - **Windows 11 22H2 (build 22621) or newer, x64.** Windows 10 — including LTSC — and Windows 11
driver is x64-only). 21H2 are **not supported**: the virtual-display driver needs the IddCx 1.10 driver framework,
which first shipped in Windows 11 22H2. On older Windows the driver installs but can't start
("punktfunk Virtual Display" shows **Code 10** in Device Manager and streaming fails); the
installer therefore refuses to run there. ARM64 is not built either (no ARM64 NVIDIA driver, and
the virtual-display driver is x64-only).
- **A GPU for hardware encode** — the host auto-detects the vendor: - **A GPU for hardware encode** — the host auto-detects the vendor:
- **NVIDIA** → NVENC - **NVIDIA** → NVENC
- **AMD** → AMF - **AMD** → AMF
@@ -96,6 +105,13 @@ prompts, the lock screen) and keep streaming across reboots with nobody logged i
Sunshine and Apollo use. Service registration, firewall rules, and the supervisor all live in Sunshine and Apollo use. Service registration, firewall rules, and the supervisor all live in
`punktfunk-host service install`; the installer just lays the exe down and calls it elevated. `punktfunk-host service install`; the installer just lays the exe down and calls it elevated.
Running as SYSTEM is what makes headless, log-in-optional streaming work — and it's why the host is a
high-privilege component worth being deliberate about. punktfunk mitigates this with **zero kernel
drivers** (the virtual display and gamepads are user-mode UMDF drivers), **sealed internal channels**
between the host and its drivers, and Administrators/SYSTEM-only permissions on its secrets. See
[Security & Safe Use](/docs/security) for the full picture, including why we recommend not hosting on
your most sensitive machine.
### One core, Windows backends ### One core, Windows backends
Most of Punktfunk is platform-agnostic. `punktfunk-core` (protocol, FEC, crypto, session, transport, Most of Punktfunk is platform-agnostic. `punktfunk-core` (protocol, FEC, crypto, session, transport,
+12 -1
View File
@@ -50,7 +50,7 @@ build() {
# The host's zero-copy FFI link-needs libcuda at build time; nvidia-utils provides it on an # The host's zero-copy FFI link-needs libcuda at build time; nvidia-utils provides it on an
# NVIDIA builder. On a GPU-less builder symlink the CUDA stub into the link path first (same # NVIDIA builder. On a GPU-less builder symlink the CUDA stub into the link path first (same
# caveat the RPM documents): ln -s "$(find / -name libcuda.so -path '*stubs*'|head -1)" /usr/lib/ # caveat the RPM documents): ln -s "$(find / -name libcuda.so -path '*stubs*'|head -1)" /usr/lib/
cargo build --release --locked -p punktfunk-host -p punktfunk-client-linux cargo build --release --locked -p punktfunk-host -p punktfunk-client-linux -p punktfunk-tray
# Management web console (opt-in): the Nitro `bun`-preset .output bundle (Bun.serve TLS), # Management web console (opt-in): the Nitro `bun`-preset .output bundle (Bun.serve TLS),
# built AND run with bun. # built AND run with bun.
if [ "${PF_WITH_WEB:-0}" = 1 ]; then if [ "${PF_WITH_WEB:-0}" = 1 ]; then
@@ -95,6 +95,17 @@ package_punktfunk-host() {
# connect). See the file's header comment. # connect). See the file's header comment.
install -Dm0644 "$R/packaging/linux/io.unom.Punktfunk.Host.desktop" \ install -Dm0644 "$R/packaging/linux/io.unom.Punktfunk.Host.desktop" \
"$pkgdir/usr/share/applications/io.unom.Punktfunk.Host.desktop" "$pkgdir/usr/share/applications/io.unom.Punktfunk.Host.desktop"
# Status tray: per-user SNI icon + XDG autostart entry (self-gating: --autostart exits silently
# for users who don't run a host) + the hicolor status icons it names.
install -Dm0755 "$T/punktfunk-tray" "$pkgdir/usr/bin/punktfunk-tray"
install -Dm0644 "$R/packaging/linux/io.unom.Punktfunk.Tray.desktop" \
"$pkgdir/etc/xdg/autostart/io.unom.Punktfunk.Tray.desktop"
local sz png
for sz in 22x22 48x48; do
for png in "$R"/packaging/linux/icons/hicolor/$sz/apps/*.png; do
install -Dm0644 "$png" "$pkgdir/usr/share/icons/hicolor/$sz/apps/$(basename "$png")"
done
done
# headless session helpers + env templates + OpenAPI doc # headless session helpers + env templates + OpenAPI doc
install -Dm0755 "$R/scripts/headless/run-headless-kde.sh" "$pkgdir/usr/share/punktfunk/headless/run-headless-kde.sh" install -Dm0755 "$R/scripts/headless/run-headless-kde.sh" "$pkgdir/usr/share/punktfunk/headless/run-headless-kde.sh"
install -Dm0755 "$R/scripts/headless/run-headless-sway.sh" "$pkgdir/usr/share/punktfunk/headless/run-headless-sway.sh" install -Dm0755 "$R/scripts/headless/run-headless-sway.sh" "$pkgdir/usr/share/punktfunk/headless/run-headless-sway.sh"
+15
View File
@@ -28,6 +28,11 @@ if [ ! -x "$BIN" ]; then
echo "==> building $PKG (release)" echo "==> building $PKG (release)"
PUNKTFUNK_BUILD_VERSION="$VERSION" cargo build --release -p "$PKG" --locked # stamp --version (build.rs) PUNKTFUNK_BUILD_VERSION="$VERSION" cargo build --release -p "$PKG" --locked # stamp --version (build.rs)
fi fi
TRAY_BIN="target/release/punktfunk-tray"
if [ ! -x "$TRAY_BIN" ]; then
echo "==> building punktfunk-tray (release)"
cargo build --release -p punktfunk-tray --locked
fi
STAGE="$(mktemp -d)" STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT trap 'rm -rf "$STAGE"' EXIT
@@ -57,6 +62,16 @@ sed -i 's#%h/punktfunk/scripts/headless/run-headless-kde.sh#/usr/share/punktfunk
# connect, so it has to be present before the host ever connects. See the file's header comment. # connect, so it has to be present before the host ever connects. See the file's header comment.
install -Dm0644 packaging/linux/io.unom.Punktfunk.Host.desktop \ install -Dm0644 packaging/linux/io.unom.Punktfunk.Host.desktop \
"$STAGE/usr/share/applications/io.unom.Punktfunk.Host.desktop" "$STAGE/usr/share/applications/io.unom.Punktfunk.Host.desktop"
# Status tray: the per-user SNI icon + its XDG autostart entry (self-gating: --autostart exits
# silently for users who don't run a host) + the hicolor status icons it names.
install -Dm0755 "$TRAY_BIN" "$STAGE/usr/bin/punktfunk-tray"
install -Dm0644 packaging/linux/io.unom.Punktfunk.Tray.desktop \
"$STAGE/etc/xdg/autostart/io.unom.Punktfunk.Tray.desktop"
for sz in 22x22 48x48; do
for png in packaging/linux/icons/hicolor/$sz/apps/*.png; do
install -Dm0644 "$png" "$STAGE/usr/share/icons/hicolor/$sz/apps/$(basename "$png")"
done
done
install -Dm0755 scripts/headless/run-headless-kde.sh "$SHAREDIR/headless/run-headless-kde.sh" install -Dm0755 scripts/headless/run-headless-kde.sh "$SHAREDIR/headless/run-headless-kde.sh"
install -Dm0755 scripts/headless/run-headless-sway.sh "$SHAREDIR/headless/run-headless-sway.sh" install -Dm0755 scripts/headless/run-headless-sway.sh "$SHAREDIR/headless/run-headless-sway.sh"
install -Dm0644 scripts/headless/kde-authorized "$SHAREDIR/headless/kde-authorized" install -Dm0644 scripts/headless/kde-authorized "$SHAREDIR/headless/kde-authorized"
Binary file not shown.

After

Width:  |  Height:  |  Size: 473 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 868 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 866 B

@@ -0,0 +1,15 @@
[Desktop Entry]
Type=Application
Name=punktfunk host status
Comment=Tray icon showing the punktfunk host service status
# --autostart exits silently unless this user actually runs a host (~/.config/punktfunk exists or
# the punktfunk-host user unit is enabled) — the package installs this for every desktop user.
Exec=/usr/bin/punktfunk-tray --autostart
Icon=punktfunk-tray
# Autostart-only: not a launcher entry (launch it from a terminal as `punktfunk-tray` if wanted).
NoDisplay=true
# KDE: start after plasmashell so the StatusNotifierWatcher is up (harmless elsewhere; the tray
# also waits for the watcher when started early).
X-KDE-autostart-after=panel
X-GNOME-Autostart-enabled=true
Categories=Network;Utility;
+15 -1
View File
@@ -167,7 +167,7 @@ export RUSTUP_TOOLCHAIN=stable
# Stamp the exact NVR into the binary for --version / mgmt /health provenance (build.rs reads it). # Stamp the exact NVR into the binary for --version / mgmt /health provenance (build.rs reads it).
export PUNKTFUNK_BUILD_VERSION="%{version}-%{release}" export PUNKTFUNK_BUILD_VERSION="%{version}-%{release}"
# --locked: reproducible from (commit + Cargo.lock), matching the .deb build path. # --locked: reproducible from (commit + Cargo.lock), matching the .deb build path.
cargo build --release --locked -p punktfunk-host -p punktfunk-client-linux cargo build --release --locked -p punktfunk-host -p punktfunk-client-linux -p punktfunk-tray
%if %{with web} %if %{with web}
# Management web console: build the Nitro SSR bundle with bun (the `bun` preset + our Bun.serve # Management web console: build the Nitro SSR bundle with bun (the `bun` preset + our Bun.serve
@@ -211,6 +211,17 @@ sed -i 's#%h/punktfunk/scripts/headless/run-headless-kde.sh#%{_datadir}/%{name}/
install -Dm0644 packaging/linux/io.unom.Punktfunk.Host.desktop \ install -Dm0644 packaging/linux/io.unom.Punktfunk.Host.desktop \
%{buildroot}%{_datadir}/applications/io.unom.Punktfunk.Host.desktop %{buildroot}%{_datadir}/applications/io.unom.Punktfunk.Host.desktop
# Status tray: the per-user SNI icon + its XDG autostart entry (self-gating: --autostart exits
# silently for users who don't run a host) + the hicolor status icons it names.
install -Dm0755 target/release/punktfunk-tray %{buildroot}%{_bindir}/punktfunk-tray
install -Dm0644 packaging/linux/io.unom.Punktfunk.Tray.desktop \
%{buildroot}%{_sysconfdir}/xdg/autostart/io.unom.Punktfunk.Tray.desktop
for sz in 22x22 48x48; do
for png in packaging/linux/icons/hicolor/$sz/apps/*.png; do
install -Dm0644 "$png" %{buildroot}%{_datadir}/icons/hicolor/$sz/apps/"$(basename "$png")"
done
done
# --- client subpackage --- # --- client subpackage ---
install -Dm0755 target/release/punktfunk-client %{buildroot}%{_bindir}/punktfunk-client install -Dm0755 target/release/punktfunk-client %{buildroot}%{_bindir}/punktfunk-client
install -Dm0644 packaging/linux/io.unom.Punktfunk.desktop \ install -Dm0644 packaging/linux/io.unom.Punktfunk.desktop \
@@ -275,11 +286,14 @@ install -Dm0644 web/web.env.example %{buildroot}%{_datadir}/punkt
%license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt %license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt
%doc README.md design/implementation-plan.md packaging/README.md %doc README.md design/implementation-plan.md packaging/README.md
%{_bindir}/punktfunk-host %{_bindir}/punktfunk-host
%{_bindir}/punktfunk-tray
%{_udevrulesdir}/60-punktfunk.rules %{_udevrulesdir}/60-punktfunk.rules
%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf %{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
%{_userunitdir}/punktfunk-host.service %{_userunitdir}/punktfunk-host.service
%{_userunitdir}/punktfunk-kde-session.service %{_userunitdir}/punktfunk-kde-session.service
%{_datadir}/applications/io.unom.Punktfunk.Host.desktop %{_datadir}/applications/io.unom.Punktfunk.Host.desktop
%{_sysconfdir}/xdg/autostart/io.unom.Punktfunk.Tray.desktop
%{_datadir}/icons/hicolor/*/apps/punktfunk-tray*.png
%dir /etc/gamescope-session-plus %dir /etc/gamescope-session-plus
%dir /etc/gamescope-session-plus/sessions.d %dir /etc/gamescope-session-plus/sessions.d
%config(noreplace) /etc/gamescope-session-plus/sessions.d/steam %config(noreplace) /etc/gamescope-session-plus/sessions.d/steam
+15 -7
View File
@@ -5,6 +5,19 @@ generic package registry (`punktfunk-host-windows`) by `.gitea/workflows/windows
> Full picture (drivers-from-source, toolchain, CI, dev loop): **[`design/windows-build-and-packaging.md`](../../design/windows-build-and-packaging.md)**. This README is the `packaging/windows/` file index. > Full picture (drivers-from-source, toolchain, CI, dev loop): **[`design/windows-build-and-packaging.md`](../../design/windows-build-and-packaging.md)**. This README is the `packaging/windows/` file index.
## Windows 11 22H2+ only (no Windows 10)
The installer refuses anything below **Windows 11 22H2 (build 22621)**`MinVersion=10.0.22621` in
`punktfunk-host.iss`, with a `[Messages]` override naming the requirement. The floor comes from the
**pf-vdisplay** driver: it is built against the **IddCx 1.10** class extension (the HDR `*2` DDIs +
the FP16 adapter cap, linked via the 1.10 `IddCxStub`, no runtime `IddCxGetVersion` downgrade), and
IddCx 1.10 first shipped in Windows 11 22H2. On older Windows — **all of Windows 10 including LTSC,
and Windows 11 21H2** — the driver *package* installs fine, but the device then fails to start with
**Code 10 `STATUS_DEVICE_POWER_FAILURE`** in Device Manager and every session dies with "pf-vdisplay
driver interface not found". Gating the installer turns that late, confusing failure into an upfront
message. (Down-level SDR-only support would need a runtime IddCx version check in the driver —
tracked as a possible future feature, not planned.)
## x64 only (no ARM64) ## x64 only (no ARM64)
Unlike the client (which ships x64 + ARM64 MSIX), the host is **x64-only by design**. It is coupled to Unlike the client (which ships x64 + ARM64 MSIX), the host is **x64-only by design**. It is coupled to
@@ -112,7 +125,6 @@ fresh install uses the generated random console password — read it from
| `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). | | `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. | | `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. | | `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). |
| `pf-vkhdr-layer/` | **HDR Vulkan layer** (standalone `cdylib`): lets Vulkan games (Doom: The Dark Ages, etc.) enable HDR over the virtual display by advertising the HDR surface formats the NVIDIA/AMD ICDs hide on an indirect display. Built by the packer, laid into `{app}\vklayer`, registered under `HKLM64\…\Khronos\Vulkan\ImplicitLayers` (opt-out *Install the HDR Vulkan layer* task). Self-gated on the display's HDR state. See its README. | | `pf-vkhdr-layer/` | **HDR Vulkan layer** (standalone `cdylib`): lets Vulkan games (Doom: The Dark Ages, etc.) enable HDR over the virtual display by advertising the HDR surface formats the NVIDIA/AMD ICDs hide on an indirect display. Built by the packer, laid into `{app}\vklayer`, registered under `HKLM64\…\Khronos\Vulkan\ImplicitLayers` (opt-out *Install the HDR Vulkan layer* task). Self-gated on the display's HDR state. See its README. |
> **Drivers are built from source, not vendored.** All three (pf-vdisplay + the gamepad pf-dualsense / > **Drivers are built from source, not vendored.** All three (pf-vdisplay + the gamepad pf-dualsense /
@@ -154,14 +166,10 @@ the recovery. From a Linux box drive either over SSH, e.g.
## Build locally (Windows, MSVC + Windows SDK + Inno Setup) ## Build locally (Windows, MSVC + Windows SDK + Inno Setup)
```powershell ```powershell
# 1. import lib for the nvenc link # 1. build the host (NVENC needs no import lib — its entry points are runtime-loaded)
pwsh -File packaging\windows\nvenc\gen-nvenc-importlib.ps1 -OutDir C:\t\nvenc
$env:PUNKTFUNK_NVENC_LIB_DIR = 'C:\t\nvenc'
# 2. build the host
cargo build --release -p punktfunk-host --features nvenc cargo build --release -p punktfunk-host --features nvenc
# 3. pack (self-signed unless MSIX_CERT_PFX_B64/MSIX_CERT_PASSWORD are set; -NoDriver to skip pf-vdisplay) # 2. pack (self-signed unless MSIX_CERT_PFX_B64/MSIX_CERT_PASSWORD are set; -NoDriver to skip pf-vdisplay)
pwsh -File packaging\windows\pack-host-installer.ps1 -Version 0.0.0-dev -TargetDir C:\t\release -OutDir C:\t\out pwsh -File packaging\windows\pack-host-installer.ps1 -Version 0.0.0-dev -TargetDir C:\t\release -OutDir C:\t\out
``` ```
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

+12
View File
@@ -405,11 +405,21 @@ dependencies = [
name = "pf-dualsense" name = "pf-dualsense"
version = "0.0.1" version = "0.0.1"
dependencies = [ dependencies = [
"pf-driver-proto",
"pf-umdf-util",
"wdk", "wdk",
"wdk-build", "wdk-build",
"wdk-sys", "wdk-sys",
] ]
[[package]]
name = "pf-umdf-util"
version = "0.0.1"
dependencies = [
"pf-driver-proto",
"wdk-sys",
]
[[package]] [[package]]
name = "pf-vdisplay" name = "pf-vdisplay"
version = "0.0.1" version = "0.0.1"
@@ -427,6 +437,8 @@ dependencies = [
name = "pf-xusb" name = "pf-xusb"
version = "0.0.1" version = "0.0.1"
dependencies = [ dependencies = [
"pf-driver-proto",
"pf-umdf-util",
"wdk", "wdk",
"wdk-build", "wdk-build",
"wdk-sys", "wdk-sys",
+2 -1
View File
@@ -7,7 +7,7 @@
# crates/pf-driver-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", "pf-dualsense", "pf-xusb"] members = ["wdk-probe", "wdk-iddcx", "pf-umdf-util", "pf-vdisplay", "pf-dualsense", "pf-xusb"]
[workspace.package] [workspace.package]
edition = "2024" edition = "2024"
@@ -20,6 +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-umdf-util = { path = "pf-umdf-util" }
pf-driver-proto = { path = "../../../crates/pf-driver-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`
@@ -23,6 +23,8 @@ wdk-build.workspace = true
[dependencies] [dependencies]
wdk.workspace = true wdk.workspace = true
wdk-sys.workspace = true wdk-sys.workspace = true
pf-driver-proto.workspace = true
pf-umdf-util.workspace = true
[features] [features]
default = ["hid"] default = ["hid"]
@@ -85,6 +85,9 @@ silently breaks them:
- **Multi-pad** works via `UmdfHostProcessSharing=ProcessSharingDisabled` — each pad gets its own - **Multi-pad** works via `UmdfHostProcessSharing=ProcessSharingDisabled` — each pad gets its own
WUDFHost (so the per-instance statics don't collide), and the driver reads its pad index from the WUDFHost (so the per-instance statics don't collide), and the driver reads its pad index from the
device Location (`WdfDeviceAllocAndQueryProperty`) to map its own `*-shm-<index>` channel. device Location (`WdfDeviceAllocAndQueryProperty`) to poll its own `*-boot-<index>` bootstrap
mailbox (the DATA section itself is unnamed — the sealed pad channel,
`design/gamepad-channel-sealing.md` — and its `pad_index` is validated against this index on
attach).
- Port of the WDK `vhidmini2` UMDF2 sample; the DualSense identity + 273-byte descriptor + feature - Port of the WDK `vhidmini2` UMDF2 sample; the DualSense identity + 273-byte descriptor + feature
blobs `0x05`/`0x09`/`0x20` come from `crates/punktfunk-host/src/inject/dualsense.rs`. blobs `0x05`/`0x09`/`0x20` come from `crates/punktfunk-host/src/inject/dualsense.rs`.
+179 -316
View File
@@ -1,36 +1,39 @@
// punktfunk virtual DualSense — UMDF2 HID minidriver (M0 spike). // punktfunk virtual DualSense / DualShock 4 — UMDF2 HID minidriver.
// //
// A Rust port of the WDK `vhidmini2` UMDF2 sample, reconfigured to present a Sony DualSense // A Rust port of the WDK `vhidmini2` UMDF2 sample, reconfigured to present a Sony DualSense
// (VID 054C / PID 0CE6) using the inputtino report descriptor + feature blobs punktfunk already // (VID 054C / PID 0CE6) or DualShock 4 (device_type=1) using the inputtino report descriptor +
// ships in `inject/dualsense.rs`. Its purpose for M0(b) is to (1) enumerate as a genuine DualSense // feature blobs punktfunk already ships in `inject/{dualsense,dualshock4}.rs`. Games see a genuine
// and (2) LOG every output report the game writes — the adaptive-trigger `0x02` gate. // HID PS controller; the host streams input in / reads output (rumble/lightbar/triggers) back.
// //
// No WDF object contexts: this is a singleton virtual device, so per-device state lives in statics. // No WDF object contexts: this is a singleton virtual device, so per-device state lives in statics.
// All WDF calls go through `call_unsafe_wdf_function_binding!`; HID/WDF structs are hand-built. // The host channel is the **sealed pad channel** (design/gamepad-channel-sealing.md, proto v2): the
// whole handshake + all shared-memory access lives in `pf_umdf_util` (the audited unsafe layer), so
// this crate's channel/HID/IOCTL logic is 100% SAFE Rust. The only `unsafe` here is the unavoidable
// WDF setup FFI in DriverEntry/EvtDeviceAdd/the timer, each with a `// SAFETY:` proof.
#![allow(non_snake_case, non_upper_case_globals, clippy::missing_safety_doc)] #![allow(non_snake_case, non_upper_case_globals, clippy::missing_safety_doc)]
// Every remaining `unsafe {}` (all WDF setup FFI) must carry a `// SAFETY:` proof.
#![deny(unsafe_op_in_unsafe_fn)]
#![deny(clippy::undocumented_unsafe_blocks)]
use core::ffi::c_void; use core::sync::atomic::{AtomicBool, AtomicPtr, AtomicU32, Ordering};
use core::sync::atomic::{AtomicPtr, AtomicU32, Ordering};
use pf_driver_proto::gamepad::PadShm;
use pf_umdf_util::channel::{ChannelClient, ChannelConfig};
use pf_umdf_util::wdf::{self, Request};
use wdk_sys::{ use wdk_sys::{
NTSTATUS, PCUNICODE_STRING, PDRIVER_OBJECT, PWDFDEVICE_INIT, ULONG, WDF_DRIVER_CONFIG, NTSTATUS, PCUNICODE_STRING, PDRIVER_OBJECT, PWDFDEVICE_INIT, ULONG, WDF_DRIVER_CONFIG,
WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, WDF_NO_OBJECT_ATTRIBUTES, WDF_OBJECT_ATTRIBUTES, WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, WDF_NO_OBJECT_ATTRIBUTES, WDF_OBJECT_ATTRIBUTES,
WDF_TIMER_CONFIG, WDFDEVICE, WDFDRIVER, WDFMEMORY, WDFQUEUE, WDFQUEUE__, WDFREQUEST, WDFTIMER, WDF_TIMER_CONFIG, WDFDEVICE, WDFDRIVER, WDFQUEUE, WDFQUEUE__, WDFREQUEST, WDFTIMER,
call_unsafe_wdf_function_binding, windows::OutputDebugStringA, call_unsafe_wdf_function_binding, windows::OutputDebugStringA,
}; };
// ---- NTSTATUS values ---- // ---- NTSTATUS values ----
const STATUS_SUCCESS: NTSTATUS = 0; const STATUS_SUCCESS: NTSTATUS = 0;
const STATUS_UNSUCCESSFUL: NTSTATUS = 0xC000_0001u32 as NTSTATUS;
const STATUS_NOT_IMPLEMENTED: NTSTATUS = 0xC000_0002u32 as NTSTATUS; const STATUS_NOT_IMPLEMENTED: NTSTATUS = 0xC000_0002u32 as NTSTATUS;
const STATUS_INVALID_PARAMETER: NTSTATUS = 0xC000_000Du32 as NTSTATUS; const STATUS_INVALID_PARAMETER: NTSTATUS = 0xC000_000Du32 as NTSTATUS;
const STATUS_INVALID_BUFFER_SIZE: NTSTATUS = 0xC000_0206u32 as NTSTATUS;
#[inline] use pf_umdf_util::nt_success;
fn nt_success(s: NTSTATUS) -> bool {
s >= 0
}
// ---- HID minidriver IOCTLs: CTL_CODE(FILE_DEVICE_KEYBOARD=0x0b, id, METHOD_NEITHER=3, ANY) ---- // ---- HID minidriver IOCTLs: CTL_CODE(FILE_DEVICE_KEYBOARD=0x0b, id, METHOD_NEITHER=3, ANY) ----
const fn hid_ctl(id: u32) -> u32 { const fn hid_ctl(id: u32) -> u32 {
@@ -225,26 +228,45 @@ static MANUAL_QUEUE: AtomicPtr<WDFQUEUE__> = AtomicPtr::new(core::ptr::null_mut(
/// to pended game READ_REPORTs. Defaults to neutral until the host connects. /// to pended game READ_REPORTs. Defaults to neutral until the host connects.
static INPUT_REPORT: std::sync::Mutex<[u8; 64]> = std::sync::Mutex::new(NEUTRAL_REPORT); static INPUT_REPORT: std::sync::Mutex<[u8; 64]> = std::sync::Mutex::new(NEUTRAL_REPORT);
// ---- user-mode shared-memory IPC with the punktfunk host ---- // ---- the sealed pad channel: layouts + offsets from pf_driver_proto (drift = compile error) ----
// UMDF runs in WUDFHost.exe (user-mode) and hidclass blocks a control channel on the device stack // UMDF runs in WUDFHost.exe (user-mode) and hidclass blocks a control channel on the device stack
// (custom interface CreateFile → err 31; custom IOCTL on the HID handle → err 1) and UMDF has no // (custom interface CreateFile → err 31; custom IOCTL on the HID handle → err 1) and UMDF has no
// control device, so the host channel is a named section the (privileged) host CREATES and the driver // control device. So the DATA section (`PadShm`, 256 B — input report @8, output seq @72, output
// OPENS. Layout (256 B, must match pf_driver_proto::gamepad::PadShm): magic u32 @0 ("PFDS"), // report @76, device_type @140, health marks @144/@148, pad_index @152) is UNNAMED and reached only
// input_seq u32 @4, input_report[64] @8, output_seq u32 @72, output_report[64] @76, // through a handle the SYSTEM host duplicated into this WUDFHost, bootstrapped over the named mailbox
// device_type u8 @140, driver_proto u32 @144 (we stamp GAMEPAD_PROTO_VERSION = the host's // `Global\pfds-boot-<index>`. The handshake + all shared-memory access live in `pf_umdf_util`.
// driver-attach health signal), driver_heartbeat u32 @148 (we bump per timer tick = liveness). const SHM_MAGIC: u32 = pf_driver_proto::gamepad::PAD_MAGIC; // "PFDS"
const FILE_MAP_RW: u32 = 0x0002 | 0x0004; // FILE_MAP_WRITE | FILE_MAP_READ const SHM_SIZE: usize = core::mem::size_of::<PadShm>();
const SHM_MAGIC: u32 = 0x5046_4453; // "PFDS" little-endian const GAMEPAD_PROTO_VERSION: u32 = pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION;
const SHM_SIZE: usize = 256;
const GAMEPAD_PROTO_VERSION: u32 = 1; // must match pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION
static LOGGED_SHM: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false);
// kernel32 file-mapping APIs (resolved via std's kernel32 import; UMDF permits file mapping). // PadShm field offsets (the driver reads input + device_type, writes output + health marks).
unsafe extern "system" { const OFF_INPUT: usize = core::mem::offset_of!(PadShm, input);
fn OpenFileMappingW(access: u32, inherit: i32, name: *const u16) -> *mut c_void; const OFF_OUT_SEQ: usize = core::mem::offset_of!(PadShm, out_seq);
fn MapViewOfFile(h: *mut c_void, access: u32, hi: u32, lo: u32, len: usize) -> *mut c_void; const OFF_OUTPUT: usize = core::mem::offset_of!(PadShm, output);
fn UnmapViewOfFile(addr: *const c_void) -> i32; const OFF_DEVICE_TYPE: usize = core::mem::offset_of!(PadShm, device_type);
fn CloseHandle(h: *mut c_void) -> i32; const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(PadShm, driver_proto);
const OFF_DRIVER_HEARTBEAT: usize = core::mem::offset_of!(PadShm, driver_heartbeat);
const OFF_PAD_INDEX: usize = core::mem::offset_of!(PadShm, pad_index);
/// The sealed-channel client (per-pad: `ProcessSharingDisabled` gives each pad its own WUDFHost, so
/// this static is per-pad). The handshake/adoption/validation state machine lives in `pf_umdf_util`.
static CHANNEL: ChannelClient = ChannelClient::new();
/// The last observed `device_type` (0 = DualSense, 1 = DualShock 4) — the neutral-report shape when
/// the channel detaches, and the fallback identity while unattached.
static LAST_DEVTYPE: AtomicU32 = AtomicU32::new(0);
/// device_type()'s bounded first-read wait fires at most once (see its docs).
static DEVTYPE_WAITED: AtomicBool = AtomicBool::new(false);
/// This pad's channel config (magic/size/pad_index offset + our logger).
fn channel_cfg() -> ChannelConfig {
ChannelConfig {
tag: "pf-ds",
boot_name_prefix: "Global\\pfds-boot-",
data_magic: SHM_MAGIC,
data_size: SHM_SIZE,
pad_index_off: OFF_PAD_INDEX,
log,
}
} }
fn log(s: &str) { fn log(s: &str) {
@@ -289,59 +311,6 @@ pub unsafe extern "system" fn driver_entry(
} }
} }
/// The pad index this device serves (which `pfds-shm-<index>` section to map). The host stamps it into
/// the device Location (`pszDeviceLocation`); the driver reads it in EvtDeviceAdd. With
/// `UmdfHostProcessSharing=ProcessSharingDisabled` (the INF) each pad gets its own WUDFHost, so this
/// static is per-pad — the basis for multi-pad.
static SHM_INDEX: AtomicU32 = AtomicU32::new(0);
/// DEVICE_REGISTRY_PROPERTY: DevicePropertyLocationInformation (not re-exported at the wdk_sys root).
const DEVICE_PROPERTY_LOCATION_INFORMATION: i32 = 10;
/// Read the pad index the host stamped into the device Location (a NUL-terminated UTF-16 decimal
/// string). Defaults to 0 (single-pad) if absent.
fn query_shm_index(device: WDFDEVICE) -> u32 {
let mut mem: WDFMEMORY = core::ptr::null_mut();
// SAFETY: device valid; property = LocationInformation; pool ignored in UMDF; mem receives the handle.
let st = unsafe {
call_unsafe_wdf_function_binding!(
WdfDeviceAllocAndQueryProperty,
device,
DEVICE_PROPERTY_LOCATION_INFORMATION,
0,
WDF_NO_OBJECT_ATTRIBUTES,
&mut mem
)
};
if !nt_success(st) || mem.is_null() {
return 0;
}
let mut len: usize = 0;
// SAFETY: mem valid.
let buf = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, mem, &mut len) }
as *const u16;
if buf.is_null() {
return 0;
}
let mut idx: u32 = 0;
let mut any = false;
for i in 0..(len / 2).min(8) {
// SAFETY: buf valid for len bytes; i < len/2.
let c = unsafe { *buf.add(i) };
if c == 0 {
break;
}
if (0x30..=0x39).contains(&c) {
idx = idx.wrapping_mul(10).wrapping_add((c - 0x30) as u32);
any = true;
}
}
if any {
idx
} else {
0
}
}
extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INIT) -> NTSTATUS { extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INIT) -> NTSTATUS {
log("[pf-ds] EvtDeviceAdd"); log("[pf-ds] EvtDeviceAdd");
@@ -364,8 +333,9 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
return st; return st;
} }
let shm_idx = query_shm_index(device); // SAFETY: `device` is the live device just created — the exact contract this fn requires.
SHM_INDEX.store(shm_idx, Ordering::Relaxed); let shm_idx = unsafe { wdf::query_location_index(device) };
CHANNEL.set_index(shm_idx);
dbglog!("[pf-ds] shm index = {shm_idx}"); dbglog!("[pf-ds] shm index = {shm_idx}");
// Default parallel queue handling all IOCTLs. // Default parallel queue handling all IOCTLs.
@@ -428,6 +398,8 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
tcfg.EvtTimerFunc = Some(evt_timer); tcfg.EvtTimerFunc = Some(evt_timer);
tcfg.Period = 8; // ms tcfg.Period = 8; // ms
tcfg.AutomaticSerialization = 1; // TRUE — UMDF requires a serialized timer (vhidmini2 pattern) tcfg.AutomaticSerialization = 1; // TRUE — UMDF requires a serialized timer (vhidmini2 pattern)
// SAFETY: a zeroed WDF_OBJECT_ATTRIBUTES is a valid all-null attributes struct; we set Size + the
// fields we use below.
let mut tattr: WDF_OBJECT_ATTRIBUTES = unsafe { core::mem::zeroed() }; let mut tattr: WDF_OBJECT_ATTRIBUTES = unsafe { core::mem::zeroed() };
tattr.Size = core::mem::size_of::<WDF_OBJECT_ATTRIBUTES>() as ULONG; tattr.Size = core::mem::size_of::<WDF_OBJECT_ATTRIBUTES>() as ULONG;
tattr.ParentObject = manual_queue.cast(); tattr.ParentObject = manual_queue.cast();
@@ -458,141 +430,73 @@ extern "C" fn evt_io_device_control(
_input_len: usize, _input_len: usize,
ioctl: ULONG, ioctl: ULONG,
) { ) {
let mut complete = true; // SAFETY: `request` is the live request for THIS EvtIoDeviceControl invocation — exactly the
// contract `Request::new` requires. Everything after is safe (the token owns completion).
let request = unsafe { Request::new(request) };
// Skip the 8ms READ_REPORT cadence so the log stays readable during a game test; // Skip the 8ms READ_REPORT cadence so the log stays readable during a game test;
// the 0x02 OUTPUT report (the gate) and the descriptor handshake still log. // the 0x02 OUTPUT report (the gate) and the descriptor handshake still log.
if ioctl != IOCTL_HID_READ_REPORT { if ioctl != IOCTL_HID_READ_REPORT {
dbglog!("[pf-ds] ioctl 0x{ioctl:08x} out={_output_len} in={_input_len}"); dbglog!("[pf-ds] ioctl 0x{ioctl:08x} out={_output_len} in={_input_len}");
} }
let status: NTSTATUS = match ioctl {
IOCTL_HID_GET_DEVICE_DESCRIPTOR => { // READ_REPORT forwards to the manual queue (the timer completes it) — this CONSUMES the request
copy_to_output(request, if device_type() == 1 { &DS4_HID_DESC } else { &HID_DESC }) // token, so it's handled apart from the status-and-complete paths below.
if ioctl == IOCTL_HID_READ_REPORT {
let mq: WDFQUEUE = MANUAL_QUEUE.load(Ordering::SeqCst);
// SAFETY: `mq` is the manual queue created in EvtDeviceAdd (a live WDFQUEUE of this device).
match unsafe { request.forward_to_queue(mq) } {
Ok(()) => {} // framework owns it now (completed by the timer)
Err((req, st)) => req.complete(st), // forward failed → complete with the error
} }
IOCTL_HID_GET_DEVICE_ATTRIBUTES => copy_to_output(request, &hid_attrs(device_type() == 1)), return;
IOCTL_HID_GET_REPORT_DESCRIPTOR => copy_to_output( }
request,
if device_type() == 1 { let status: NTSTATUS = match ioctl {
IOCTL_HID_GET_DEVICE_DESCRIPTOR => request.copy_to_output(if device_type() == 1 {
&DS4_HID_DESC
} else {
&HID_DESC
}),
IOCTL_HID_GET_DEVICE_ATTRIBUTES => request.copy_to_output(&hid_attrs(device_type() == 1)),
IOCTL_HID_GET_REPORT_DESCRIPTOR => request.copy_to_output(if device_type() == 1 {
&DS4_RDESC[..] &DS4_RDESC[..]
} else { } else {
&DUALSENSE_RDESC[..] &DUALSENSE_RDESC[..]
}, }),
),
IOCTL_HID_READ_REPORT => {
let mq: WDFQUEUE = MANUAL_QUEUE.load(Ordering::SeqCst);
// SAFETY: request valid; mq is the manual queue created in EvtDeviceAdd.
let st = unsafe {
call_unsafe_wdf_function_binding!(WdfRequestForwardToIoQueue, request, mq)
};
if nt_success(st) {
complete = false;
STATUS_SUCCESS
} else {
st
}
}
IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => { IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => {
on_output_report(request, ioctl) on_output_report(&request, ioctl)
} }
IOCTL_UMDF_HID_SET_FEATURE => { IOCTL_UMDF_HID_SET_FEATURE => {
log("[pf-ds] SET_FEATURE (stub ok)"); log("[pf-ds] SET_FEATURE (stub ok)");
STATUS_SUCCESS STATUS_SUCCESS
} }
IOCTL_UMDF_HID_GET_FEATURE => on_get_feature(request), IOCTL_UMDF_HID_GET_FEATURE => on_get_feature(&request),
IOCTL_UMDF_HID_GET_INPUT_REPORT => { IOCTL_UMDF_HID_GET_INPUT_REPORT => {
copy_to_output(request, &neutral_report(device_type() == 1)) request.copy_to_output(&neutral_report(device_type() == 1))
} }
IOCTL_HID_GET_STRING => on_get_string(request), IOCTL_HID_GET_STRING => on_get_string(&request),
_ => STATUS_NOT_IMPLEMENTED, _ => STATUS_NOT_IMPLEMENTED,
}; };
if ioctl != IOCTL_HID_READ_REPORT { dbglog!("[pf-ds] ioctl 0x{ioctl:08x} -> 0x{:08x}", status as u32);
dbglog!( request.complete(status);
"[pf-ds] ioctl 0x{ioctl:08x} -> 0x{:08x} complete={complete}",
status as u32
);
}
if complete {
// SAFETY: request valid and not forwarded.
unsafe { call_unsafe_wdf_function_binding!(WdfRequestComplete, request, status) };
}
}
// Copy `src` into the request's output memory and set the completed byte count.
fn copy_to_output(request: WDFREQUEST, src: &[u8]) -> NTSTATUS {
let mut mem: WDFMEMORY = core::ptr::null_mut();
// SAFETY: request valid; mem receives the memory handle.
let st = unsafe {
call_unsafe_wdf_function_binding!(WdfRequestRetrieveOutputMemory, request, &mut mem)
};
if !nt_success(st) {
return st;
}
let mut outlen: usize = 0;
// SAFETY: mem valid; outlen receives the buffer size.
let _ = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, mem, &mut outlen) };
if outlen < src.len() {
return STATUS_INVALID_BUFFER_SIZE;
}
// SAFETY: mem valid; src is a valid buffer of src.len() bytes.
let st = unsafe {
call_unsafe_wdf_function_binding!(
WdfMemoryCopyFromBuffer,
mem,
0usize,
src.as_ptr() as *mut c_void,
src.len()
)
};
if !nt_success(st) {
return st;
}
// SAFETY: request valid.
unsafe {
call_unsafe_wdf_function_binding!(WdfRequestSetInformation, request, src.len() as u64)
};
STATUS_SUCCESS
} }
// The 0x02 gate: a game writing an output report (rumble / lightbar / ADAPTIVE TRIGGERS). Per the // The 0x02 gate: a game writing an output report (rumble / lightbar / ADAPTIVE TRIGGERS). Per the
// UMDF marshalling convention the report data is the *input* buffer and the report id is carried in // UMDF marshalling convention the report data is the *input* buffer and the report id is carried in
// the *output* buffer length. We log it. // the *output* buffer length. We log it, then publish it to the DATA section for the host.
fn on_output_report(request: WDFREQUEST, ioctl: ULONG) -> NTSTATUS { fn on_output_report(request: &Request, ioctl: ULONG) -> NTSTATUS {
let mut inmem: WDFMEMORY = core::ptr::null_mut(); let (bytes, inlen) = match request.input_bytes(64) {
// SAFETY: request valid. Ok(v) => v,
let st = unsafe { Err(st) => return st,
call_unsafe_wdf_function_binding!(WdfRequestRetrieveInputMemory, request, &mut inmem)
}; };
if !nt_success(st) { let report_id = request.output_buffer_len() as u32; // report id, UMDF convention
return st;
}
let mut inlen: usize = 0;
// SAFETY: inmem valid.
let inbuf = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, inmem, &mut inlen) }
as *const u8;
// report id from output-buffer length (UMDF convention).
let mut report_id: u32 = 0;
let mut outmem: WDFMEMORY = core::ptr::null_mut();
// SAFETY: request valid; output memory is optional here.
if nt_success(unsafe {
call_unsafe_wdf_function_binding!(WdfRequestRetrieveOutputMemory, request, &mut outmem)
}) {
let mut outlen: usize = 0;
// SAFETY: outmem valid.
let _ =
unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, outmem, &mut outlen) };
report_id = outlen as u32;
}
let n = inlen.min(48);
let mut hex = String::new(); let mut hex = String::new();
if !inbuf.is_null() { for b in bytes.iter().take(48) {
// SAFETY: inbuf valid for inlen bytes; we read at most n.
let bytes = unsafe { core::slice::from_raw_parts(inbuf, n) };
for b in bytes {
hex.push_str(&format!("{b:02x} ")); hex.push_str(&format!("{b:02x} "));
} }
}
let kind = if ioctl == IOCTL_HID_WRITE_REPORT { let kind = if ioctl == IOCTL_HID_WRITE_REPORT {
"WRITE_REPORT" "WRITE_REPORT"
} else { } else {
@@ -600,45 +504,29 @@ fn on_output_report(request: WDFREQUEST, ioctl: ULONG) -> NTSTATUS {
}; };
dbglog!("[pf-ds] *** OUTPUT {kind} reportId={report_id} len={inlen} data: {hex}"); dbglog!("[pf-ds] *** OUTPUT {kind} reportId={report_id} len={inlen} data: {hex}");
// Publish the game's 0x02 output report to shared memory for the host (rumble / lightbar / // Publish the game's 0x02 output report to the sealed DATA section for the host (rumble /
// player-LEDs / adaptive triggers). output_report @76, output_seq @72. // lightbar / player-LEDs / adaptive triggers), then bump the host-polled output seq.
if !inbuf.is_null() && inlen > 0 { if !bytes.is_empty()
let n = inlen.min(64); && let Some(view) = CHANNEL.data()
with_shm(|view| { {
// SAFETY: view is a mapped 256-byte section; write the report then bump the host-polled seq. view.write_bytes(OFF_OUTPUT, &bytes);
unsafe { let seq = view.read_u32(OFF_OUT_SEQ).wrapping_add(1);
core::ptr::copy_nonoverlapping(inbuf, view.add(76), n); view.write_u32(OFF_OUT_SEQ, seq);
let seqp = view.add(72) as *mut u32;
let seq = core::ptr::read_unaligned(seqp).wrapping_add(1);
core::ptr::write_unaligned(seqp, seq);
}
});
} }
// SAFETY: request valid. request.set_information(inlen as u64);
unsafe { call_unsafe_wdf_function_binding!(WdfRequestSetInformation, request, inlen as u64) };
STATUS_SUCCESS STATUS_SUCCESS
} }
// GET_FEATURE: report id from the input buffer; reply with the matching DualSense feature blob. // GET_FEATURE: report id from the input buffer; reply with the matching DualSense/DualShock 4 blob.
fn on_get_feature(request: WDFREQUEST) -> NTSTATUS { fn on_get_feature(request: &Request) -> NTSTATUS {
let mut inmem: WDFMEMORY = core::ptr::null_mut(); let (bytes, _) = match request.input_bytes(1) {
// SAFETY: request valid. Ok(v) => v,
let st = unsafe { Err(st) => return st,
call_unsafe_wdf_function_binding!(WdfRequestRetrieveInputMemory, request, &mut inmem)
}; };
if !nt_success(st) { let Some(&report_id) = bytes.first() else {
return st;
}
let mut inlen: usize = 0;
// SAFETY: inmem valid.
let inbuf = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, inmem, &mut inlen) }
as *const u8;
if inbuf.is_null() || inlen < 1 {
return STATUS_INVALID_PARAMETER; return STATUS_INVALID_PARAMETER;
} };
// SAFETY: inbuf valid for >=1 byte.
let report_id = unsafe { *inbuf };
// DualSense uses feature ids 0x05/0x09/0x20; DualShock 4 uses 0x02/0x12/0xa3. // DualSense uses feature ids 0x05/0x09/0x20; DualShock 4 uses 0x02/0x12/0xa3.
let blob: &[u8] = match (device_type() == 1, report_id) { let blob: &[u8] = match (device_type() == 1, report_id) {
(false, 0x05) => &DS_FEATURE_CALIBRATION, (false, 0x05) => &DS_FEATURE_CALIBRATION,
@@ -652,31 +540,21 @@ fn on_get_feature(request: WDFREQUEST) -> NTSTATUS {
return STATUS_INVALID_PARAMETER; return STATUS_INVALID_PARAMETER;
} }
}; };
copy_to_output(request, blob) request.copy_to_output(blob)
} }
// IOCTL_HID_GET_STRING: the input is a ULONG whose low word is the string id and whose high word is // IOCTL_HID_GET_STRING: the input is a ULONG whose low word is the string id and whose high word is
// the language id. Reply with the requested device string as a NUL-terminated UTF-16 buffer. Native // the language id. Reply with the requested device string as a NUL-terminated UTF-16 buffer. Native
// PS5 / Steam code reads these (HidD_GetProductString / HidD_GetSerialNumberString — the serial is one // PS5 / Steam code reads these (HidD_GetProductString / HidD_GetSerialNumberString — the serial is one
// way they tell USB from BT); the old default returned STATUS_NOT_IMPLEMENTED, leaving them blank. // way they tell USB from BT). Observed live: Windows polls ids 0x0E/0x0F/0x10 (lang 0x0409)
// Observed live on this device, Windows polls ids 0x0E/0x0F/0x10 (lang 0x0409) cyclically — the // cyclically — the manufacturer/product/serial slots — NOT the 0/1/2 HID_STRING_ID_* constants; both.
// manufacturer/product/serial slots — NOT the 0/1/2 HID_STRING_ID_* constants; we map both forms. fn on_get_string(request: &Request) -> NTSTATUS {
fn on_get_string(request: WDFREQUEST) -> NTSTATUS { let (bytes, _) = match request.input_bytes(4) {
let mut inmem: WDFMEMORY = core::ptr::null_mut(); Ok(v) => v,
// SAFETY: request valid. Err(st) => return st,
let st = unsafe {
call_unsafe_wdf_function_binding!(WdfRequestRetrieveInputMemory, request, &mut inmem)
}; };
if !nt_success(st) { let id_val: u32 = if bytes.len() >= 4 {
return st; u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
}
let mut inlen: usize = 0;
// SAFETY: inmem valid.
let inbuf = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, inmem, &mut inlen) }
as *const u8;
// SAFETY: inbuf is valid for inlen bytes; read the 4-byte id value when present.
let id_val: u32 = if !inbuf.is_null() && inlen >= 4 {
unsafe { core::ptr::read_unaligned(inbuf as *const u32) }
} else { } else {
0 0
}; };
@@ -706,96 +584,81 @@ fn on_get_string(request: WDFREQUEST) -> NTSTATUS {
} }
} }
}; };
let mut wide: Vec<u16> = s.encode_utf16().collect(); let mut wide: Vec<u8> = Vec::with_capacity(s.len() * 2 + 2);
wide.push(0); // NUL terminator for u in s.encode_utf16() {
// SAFETY: reinterpret the UTF-16 buffer as bytes for the byte-oriented copy_to_output. wide.extend_from_slice(&u.to_le_bytes());
let bytes = unsafe { core::slice::from_raw_parts(wide.as_ptr() as *const u8, wide.len() * 2) }; }
copy_to_output(request, bytes) wide.extend_from_slice(&[0, 0]); // NUL terminator (UTF-16)
request.copy_to_output(&wide)
} }
// Open + map the host's shared-memory section (Global\pfds-shm-0) and run `f` against the mapped base /// The host's device-type selector from the sealed DATA section (`device_type` @140): 0 = DualSense
// if it exists with a valid magic, then unmap. NOT cached: re-mapped per access so the driver always /// (default), 1 = DualShock 4. Read fresh on each enumeration query — cheap. If the channel hasn't
// sees the current section (UMDF groups all devices in one WUDFHost, and the host may recreate the /// attached when hidclass first asks (the host stamps the section + eager-delivers before
// section across restarts — a cached view would go stale). ~125 maps/s from the timer = negligible. /// `SwDeviceCreate` returns, but the handshake can be a few ms behind), pump the channel briefly —
fn with_shm<F: FnOnce(*mut u8)>(f: F) { /// ONCE — for the delivery: a DS4 pad must not enumerate with the default DualSense identity because
let name: Vec<u16> = format!("Global\\pfds-shm-{}", SHM_INDEX.load(Ordering::Relaxed)) /// of a lost race. After that one bounded wait, fall back to the last observed type.
.encode_utf16() fn device_type() -> u8 {
.chain(std::iter::once(0)) if let Some(view) = CHANNEL.data() {
.collect(); let t = view.read_u8(OFF_DEVICE_TYPE);
// SAFETY: name is a valid NUL-terminated UTF-16 string. LAST_DEVTYPE.store(t as u32, Ordering::Relaxed);
let h = unsafe { OpenFileMappingW(FILE_MAP_RW, 0, name.as_ptr()) }; return t;
if h.is_null() {
return;
} }
// SAFETY: h is a valid mapping handle; map the whole section. The view keeps the section alive, if !DEVTYPE_WAITED.swap(true, Ordering::SeqCst) {
// so the handle can be closed right away. let cfg = channel_cfg();
let view = unsafe { MapViewOfFile(h, FILE_MAP_RW, 0, 0, SHM_SIZE) } as *mut u8; for _ in 0..100 {
unsafe { CloseHandle(h) }; if let Some(view) = CHANNEL.pump(&cfg) {
if view.is_null() { let t = view.read_u8(OFF_DEVICE_TYPE);
return; LAST_DEVTYPE.store(t as u32, Ordering::Relaxed);
return t;
}
std::thread::sleep(std::time::Duration::from_millis(10));
} }
// SAFETY: view points at >= 4 mapped bytes.
let magic = unsafe { core::ptr::read_unaligned(view as *const u32) };
if magic == SHM_MAGIC {
if !LOGGED_SHM.swap(true, Ordering::Relaxed) {
dbglog!( dbglog!(
"[pf-ds] control: shared memory mapped (Global\\pfds-shm-{})", "[pf-ds] device_type: sealed channel not attached within 1s — defaulting to the last observed identity"
SHM_INDEX.load(Ordering::Relaxed)
); );
} }
f(view); LAST_DEVTYPE.load(Ordering::Relaxed) as u8
}
// SAFETY: view came from MapViewOfFile.
unsafe { UnmapViewOfFile(view as *const c_void) };
}
/// The host's device-type selector from shared memory (`device_type` byte @140): 0 = DualSense
/// (default), 1 = DualShock 4. Read fresh on each enumeration query — cheap, and the host stamps the
/// section before `SwDeviceCreate`, so it's set by the time hidclass asks for the descriptor /
/// attributes. Defaults to DualSense if the section isn't mapped yet (magic absent).
fn device_type() -> u8 {
let mut t = 0u8;
with_shm(|view| {
// SAFETY: view points at a mapped 256-byte section; the device-type byte is at offset 140.
t = unsafe { *view.add(140) };
});
t
} }
extern "C" fn evt_timer(timer: WDFTIMER) { extern "C" fn evt_timer(timer: WDFTIMER) {
// Pull the latest host input report from shared memory (if the host has connected). // One sealed-channel tick: publish our pid / adopt a delivery / detect host-gone, then pull the
with_shm(|view| { // latest host input report from the attached DATA section (all safe, via pf_umdf_util).
match CHANNEL.pump(&channel_cfg()) {
Some(view) => {
let mut buf = [0u8; 64]; let mut buf = [0u8; 64];
// SAFETY: view points at a mapped 256-byte section; input lives at offset 8..72. view.read_bytes(OFF_INPUT, &mut buf);
unsafe { core::ptr::copy_nonoverlapping(view.add(8), buf.as_mut_ptr(), 64) }; if buf[0] == 0x01
if buf[0] == 0x01 { && let Ok(mut g) = INPUT_REPORT.lock()
if let Ok(mut g) = INPUT_REPORT.lock() { {
*g = buf; *g = buf;
} }
} // Health marks the host watches: driver_proto (attach signal, idempotent) and
// Health marks the host watches: driver_proto @144 (attach signal, idempotent) and // driver_heartbeat (+1 per ~8 ms tick = liveness). Lets the host tell "driver bound
// driver_heartbeat @148 (+1 per ~8 ms tick = liveness). Lets the host tell "driver bound
// and alive" apart from "driver package missing/failed to bind". // and alive" apart from "driver package missing/failed to bind".
// SAFETY: view points at a mapped 256-byte section; proto @144, heartbeat @148. view.write_u32(OFF_DRIVER_PROTO, GAMEPAD_PROTO_VERSION);
unsafe { let hb = view.read_u32(OFF_DRIVER_HEARTBEAT).wrapping_add(1);
core::ptr::write_unaligned(view.add(144) as *mut u32, GAMEPAD_PROTO_VERSION); view.write_u32(OFF_DRIVER_HEARTBEAT, hb);
let hb = view.add(148) as *mut u32;
core::ptr::write_unaligned(hb, core::ptr::read_unaligned(hb).wrapping_add(1));
} }
}); None => {
// SAFETY: timer valid; parent is the manual queue. // Host gone (mailbox name vanished) or channel not attached yet: feed games the neutral
// report instead of a frozen last state (matters for the persistent out-of-band devnode,
// which outlives host sessions).
if let Ok(mut g) = INPUT_REPORT.lock() {
*g = neutral_report(LAST_DEVTYPE.load(Ordering::Relaxed) == 1);
}
}
}
// Complete the next pended READ_REPORT with the current input report (safe queue/request API).
// SAFETY: the timer's parent object is the manual queue (set in EvtDeviceAdd); the framework
// guarantees a live handle here.
let queue = let queue =
unsafe { call_unsafe_wdf_function_binding!(WdfTimerGetParentObject, timer) } as WDFQUEUE; unsafe { call_unsafe_wdf_function_binding!(WdfTimerGetParentObject, timer) } as WDFQUEUE;
let mut request: WDFREQUEST = core::ptr::null_mut(); // SAFETY: `queue` is that live manual queue — the exact contract `retrieve_next_request` needs.
// SAFETY: queue valid; request receives the next pended request if any. if let Some(request) = unsafe { wdf::retrieve_next_request(queue) } {
let st = unsafe {
call_unsafe_wdf_function_binding!(WdfIoQueueRetrieveNextRequest, queue, &mut request)
};
if nt_success(st) {
let report = INPUT_REPORT.lock().map(|g| *g).unwrap_or(NEUTRAL_REPORT); let report = INPUT_REPORT.lock().map(|g| *g).unwrap_or(NEUTRAL_REPORT);
let s = copy_to_output(request, &report); let st = request.copy_to_output(&report);
// SAFETY: request valid and dequeued. request.complete(st);
unsafe { call_unsafe_wdf_function_binding!(WdfRequestComplete, request, s) };
} }
let _ = STATUS_UNSUCCESSFUL; // keep the const referenced
} }
@@ -0,0 +1,17 @@
# pf-umdf-util - the audited unsafe-primitive layer under the punktfunk UMDF gamepad drivers.
# Everything a pad driver does with raw pointers or Win32/WDF FFI lives HERE, behind small safe
# (or explicitly-contracted unsafe) APIs, so the driver crates' business logic is 100% safe Rust:
# section - MappedView: bounds+alignment-checked shared-memory access (atomics for sync fields)
# channel - ChannelClient: the sealed pad channel's driver-side state machine (a SAFE module)
# wdf - Request/queue/device-property helpers over call_unsafe_wdf_function_binding
[package]
name = "pf-umdf-util"
edition.workspace = true
version.workspace = true
license.workspace = true
publish = false
description = "punktfunk UMDF driver util: safe shared-memory + sealed-channel + WDF request primitives"
[dependencies]
wdk-sys.workspace = true
pf-driver-proto.workspace = true
@@ -0,0 +1,192 @@
//! The sealed pad channel, driver side (`design/gamepad-channel-sealing.md`, gamepad proto v2):
//! poll the named bootstrap mailbox by index, publish our pid (iff the host's proto version
//! matches), adopt the host-delivered DATA-section handle, and validate the mapped section's magic
//! and `pad_index` before use. One implementation shared by `pf-xusb` and `pf-dualsense` (they used
//! to hand-duplicate it), parameterized by [`ChannelConfig`].
//!
//! This module **forbids `unsafe`**: the entire state machine is safe Rust over
//! [`section`](crate::section)'s checked accessors — the memory-safety surface of the sealed
//! channel lives in that module alone.
#![forbid(unsafe_code)]
use crate::section::{MappedView, ViewCell, close_handle_value};
use core::mem::offset_of;
use core::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use pf_driver_proto::gamepad::{BOOT_MAGIC, GAMEPAD_PROTO_VERSION, PadBootstrap};
// PadBootstrap field offsets (the mailbox handshake; pinned by pf_driver_proto's asserts).
const BOOT_OFF_MAGIC: usize = offset_of!(PadBootstrap, magic);
const BOOT_OFF_HOST_PROTO: usize = offset_of!(PadBootstrap, host_proto);
const BOOT_OFF_DRIVER_PID: usize = offset_of!(PadBootstrap, driver_pid);
const BOOT_OFF_DRIVER_PROTO: usize = offset_of!(PadBootstrap, driver_proto);
const BOOT_OFF_DATA_HANDLE: usize = offset_of!(PadBootstrap, data_handle);
const BOOT_OFF_HANDLE_PID: usize = offset_of!(PadBootstrap, handle_pid);
const BOOT_OFF_HANDLE_SEQ: usize = offset_of!(PadBootstrap, handle_seq);
const BOOT_SIZE: usize = core::mem::size_of::<PadBootstrap>();
/// What varies between the two pad drivers.
pub struct ChannelConfig {
/// Log-line prefix (`"pf-xusb"` / `"pf-ds"`).
pub tag: &'static str,
/// Mailbox name prefix, completed with the pad index (`"Global\\pfxusb-boot-"` / `"Global\\pfds-boot-"`).
pub boot_name_prefix: &'static str,
/// The DATA section's magic (`XUSB_MAGIC` / `PAD_MAGIC`).
pub data_magic: u32,
/// The DATA section's size (`size_of::<XusbShm>()` / `size_of::<PadShm>()`).
pub data_size: usize,
/// `offset_of!(…Shm, pad_index)` in the DATA section.
pub pad_index_off: usize,
/// The driver's logger (each driver tees to its own debug file).
pub log: fn(&str),
}
/// Per-pad channel state (a `static` in each driver — per-pad because
/// `UmdfHostProcessSharing=ProcessSharingDisabled` gives each pad its own WUDFHost).
pub struct ChannelClient {
/// The pad index from the devnode Location (which mailbox to poll + the `pad_index` the
/// delivered DATA section must carry).
index: AtomicU32,
/// The adopted DATA view; leaked-on-publish (see [`ViewCell`]) so a re-delivery can never
/// unmap a view a concurrent callback still reads through.
data: ViewCell,
/// The last `handle_seq` consumed (CAS-guarded so concurrent pumps adopt a delivery exactly
/// once). Reset to 0 when the mailbox disappears, so a NEW host session's delivery is always
/// fresh even if its (per-host-process) seq counter collides with the previous session's.
consumed_seq: AtomicU32,
logged_proto_mismatch: AtomicBool,
logged_pid: AtomicBool,
}
impl Default for ChannelClient {
fn default() -> Self {
Self::new()
}
}
impl ChannelClient {
pub const fn new() -> ChannelClient {
ChannelClient {
index: AtomicU32::new(0),
data: ViewCell::new(),
consumed_seq: AtomicU32::new(0),
logged_proto_mismatch: AtomicBool::new(false),
logged_pid: AtomicBool::new(false),
}
}
/// Set the pad index (from the devnode Location, in `EvtDeviceAdd`).
pub fn set_index(&self, idx: u32) {
self.index.store(idx, Ordering::Relaxed);
}
pub fn index(&self) -> u32 {
self.index.load(Ordering::Relaxed)
}
/// The adopted DATA view regardless of mailbox liveness — for write paths where acting on a
/// stale section is harmless (the pump owns the detach semantics).
pub fn data(&self) -> Option<&'static MappedView> {
self.data.get()
}
/// One tick of the sealed-channel state machine: publish our pid (+ proto version) in the
/// mailbox, adopt a delivered DATA handle, and return the attached DATA view — `None` while
/// unattached, on a host/driver version mismatch (fail closed), or when the mailbox is gone
/// (host gone). The mailbox is re-opened by name on every call: the name existing doubles as
/// host-liveness (the host closes it when the pad is torn down).
pub fn pump(&self, cfg: &ChannelConfig) -> Option<&'static MappedView> {
let name = format!("{}{}", cfg.boot_name_prefix, self.index());
let boot = match MappedView::open_named(&name, BOOT_SIZE) {
Some(b) => b,
None => {
// Mailbox gone → the host (or this pad) is gone. Forget the consumed seq so the
// NEXT host session's first delivery always reads as fresh.
self.consumed_seq.store(0, Ordering::Relaxed);
return None;
}
};
// Acquire pairs with the host's Release magic store, so a valid magic implies `host_proto`
// is visible. A missing/garbled magic reads as "no usable mailbox" (same as absent).
if boot.load_u32(BOOT_OFF_MAGIC, Ordering::Acquire) != BOOT_MAGIC {
self.consumed_seq.store(0, Ordering::Relaxed);
return None;
}
// Publish our proto version first (idempotent) — the host logs a mismatch even when we
// refuse to publish a pid below.
boot.store_u32(
BOOT_OFF_DRIVER_PROTO,
GAMEPAD_PROTO_VERSION,
Ordering::Relaxed,
);
let host_proto = boot.load_u32(BOOT_OFF_HOST_PROTO, Ordering::Relaxed);
if host_proto != GAMEPAD_PROTO_VERSION {
if !self.logged_proto_mismatch.swap(true, Ordering::Relaxed) {
(cfg.log)(&format!(
"[{}] host proto {host_proto} != driver proto {GAMEPAD_PROTO_VERSION}\
refusing the handshake (update host + drivers together)",
cfg.tag
));
}
return None; // version mismatch — fail closed
}
let mypid = std::process::id();
if boot.load_u32(BOOT_OFF_DRIVER_PID, Ordering::Relaxed) != mypid {
boot.store_u32(BOOT_OFF_DRIVER_PID, mypid, Ordering::Release);
if !self.logged_pid.swap(true, Ordering::Relaxed) {
(cfg.log)(&format!("[{}] bootstrap: published pid {mypid}", cfg.tag));
}
}
// A delivery addressed to us we haven't consumed? CAS so concurrent pumps (worker thread /
// timer + IOCTL paths) adopt exactly once.
let seq = boot.load_u32(BOOT_OFF_HANDLE_SEQ, Ordering::Acquire);
let cur = self.consumed_seq.load(Ordering::Relaxed);
if seq != 0
&& seq != cur
&& boot.load_u32(BOOT_OFF_HANDLE_PID, Ordering::Relaxed) == mypid
&& self
.consumed_seq
.compare_exchange(cur, seq, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
{
self.adopt(cfg, boot.load_u64(BOOT_OFF_DATA_HANDLE, Ordering::Relaxed));
}
self.data()
}
/// Map + validate a delivered DATA-section handle VALUE (untrusted until the mapped section
/// carries our magic AND our pad index). On success we own the handle (adopt-on-success) and
/// close it — the view keeps the section alive. On validation failure the handle is
/// deliberately NOT closed: a tampered value could name an unrelated handle in our own table.
fn adopt(&self, cfg: &ChannelConfig, value: u64) {
let Some(view) = MappedView::from_handle_value(value, cfg.data_size) else {
if value != 0 {
(cfg.log)(&format!(
"[{}] delivered DATA handle 0x{value:x} did not map — ignoring",
cfg.tag
));
}
return;
};
let magic = view.load_u32(0, Ordering::Relaxed);
let idx = view.load_u32(cfg.pad_index_off, Ordering::Relaxed);
let want = self.index();
if magic != cfg.data_magic || idx != want {
(cfg.log)(&format!(
"[{}] delivered DATA section failed validation (magic 0x{magic:08x}, pad_index \
{idx}, want {want}) — ignoring",
cfg.tag
));
// `view` drops here → unmapped; the handle stays open (see above).
return;
}
// The value resolved to OUR pad's section, so it is the handle the host duplicated for us —
// we own it; the (about-to-be-leaked) view keeps the section alive after the close.
close_handle_value(value);
self.data.set(view);
(cfg.log)(&format!(
"[{}] sealed pad channel mapped (index {want})",
cfg.tag
));
}
}
@@ -0,0 +1,35 @@
//! The audited unsafe-primitive layer under the punktfunk UMDF gamepad drivers (`pf-xusb`,
//! `pf-dualsense`).
//!
//! A UMDF driver cannot be literally free of `unsafe` — WDF dispatch, Win32 section mapping and
//! cross-process shared memory are FFI by nature. What Rust *can* buy is confining every raw
//! operation to one small, reviewed layer with explicit contracts, so the drivers' business logic
//! (the sealed-channel state machine, report plumbing, IOCTL policy) is **100 % safe code** and a
//! memory-safety bug can only live in this crate. Three modules:
//!
//! * [`section`] — [`section::MappedView`]: bounds- and alignment-checked access to a mapped shared
//! section (atomics for the cross-process sync fields), plus the leaked-view [`section::ViewCell`].
//! * [`channel`] — [`channel::ChannelClient`]: the sealed pad channel's driver side
//! (`design/gamepad-channel-sealing.md`), a **`#[forbid(unsafe_code)]` module** — the entire
//! handshake/validation/adoption state machine is safe Rust over [`section`]'s API.
//! * [`wdf`] — [`wdf::Request`] + queue/device-property helpers: each framework callback converts
//! its raw `WDFREQUEST` into a token exactly once (`unsafe`, with the framework's validity as the
//! contract); everything after that is safe.
//!
//! Lint gates (mirrored in every driver crate, enforced by the drivers CI clippy step):
//! `unsafe_op_in_unsafe_fn` + `clippy::undocumented_unsafe_blocks` — every remaining `unsafe {}`
//! must carry a `// SAFETY:` proof.
#![deny(unsafe_op_in_unsafe_fn)]
#![deny(clippy::undocumented_unsafe_blocks)]
pub mod channel;
pub mod section;
pub mod wdf;
/// `NT_SUCCESS` — an NTSTATUS is an error iff negative.
#[inline]
#[must_use]
pub const fn nt_success(status: wdk_sys::NTSTATUS) -> bool {
status >= 0
}
@@ -0,0 +1,241 @@
//! Safe access to Win32 shared-memory sections: [`MappedView`] wraps a mapped view of a known
//! length and exposes bounds- and alignment-checked accessors, so callers never touch the raw base
//! pointer. Cross-process sync fields (seqs, pids, handle values) go through real atomics; bulk
//! report regions use plain unaligned copies, guarded by the channel protocol's seq fields — the
//! same access discipline the host side uses (`inject/windows/gamepad_raii.rs`).
use core::ffi::c_void;
use core::sync::atomic::{AtomicPtr, AtomicU32, AtomicU64, Ordering};
const FILE_MAP_RW: u32 = 0x0002 | 0x0004; // FILE_MAP_WRITE | FILE_MAP_READ
// kernel32 file-mapping APIs (resolved via std's kernel32 import; UMDF permits file mapping).
unsafe extern "system" {
fn OpenFileMappingW(access: u32, inherit: i32, name: *const u16) -> *mut c_void;
fn MapViewOfFile(h: *mut c_void, access: u32, hi: u32, lo: u32, len: usize) -> *mut c_void;
fn UnmapViewOfFile(addr: *const c_void) -> i32;
fn CloseHandle(h: *mut c_void) -> i32;
}
/// A read/write view over a mapped shared section of exactly `len` bytes. Every accessor
/// bounds-checks (and, for the atomic ones, alignment-checks) its offset, so no caller can read or
/// write outside the mapping — the offsets are `offset_of!` constants from `pf_driver_proto`, making
/// a failed check a compile-shaped logic bug (it aborts the WUDFHost rather than corrupting).
///
/// Concurrency: the peer process writes the section concurrently. Fields used for cross-process
/// synchronization must be accessed through the `load_*`/`store_*` atomic accessors; the bulk
/// byte/scalar accessors are plain unaligned accesses whose consistency is guarded by the channel
/// protocol (seq-fenced publishes), exactly as on the host side.
pub struct MappedView {
base: *mut u8,
len: usize,
}
// SAFETY: `MappedView` is a pointer + length over an OS mapping that stays valid until
// `UnmapViewOfFile` in `Drop` (or forever, once leaked into a `ViewCell`). All access goes through
// the checked accessors — atomics for shared sync fields, unaligned reads/writes for bulk data —
// none of which require a single-thread owner, so sharing/sending the view across the driver's
// callback threads is sound.
unsafe impl Send for MappedView {}
// SAFETY: as above — `&MappedView` only exposes accessors that are safe under concurrent use.
unsafe impl Sync for MappedView {}
impl MappedView {
/// Open the named section `name` and map its first `len` bytes read/write. `None` if the name
/// does not exist (e.g. the host is gone) or the mapping fails. The section handle is closed
/// immediately — the view keeps the section alive.
pub fn open_named(name: &str, len: usize) -> Option<MappedView> {
let wide: Vec<u16> = name.encode_utf16().chain(std::iter::once(0)).collect();
// SAFETY: `wide` is a valid NUL-terminated UTF-16 string for the duration of the call.
let h = unsafe { OpenFileMappingW(FILE_MAP_RW, 0, wide.as_ptr()) };
if h.is_null() {
return None;
}
// SAFETY: `h` is the valid mapping handle just opened; map `len` bytes read/write. The view
// keeps the section alive, so the handle can be closed right away.
let base = unsafe { MapViewOfFile(h, FILE_MAP_RW, 0, 0, len) } as *mut u8;
// SAFETY: `h` is the valid handle from `OpenFileMappingW`, owned solely by this function.
unsafe { CloseHandle(h) };
if base.is_null() {
return None;
}
Some(MappedView { base, len })
}
/// Map `len` bytes of a section from a raw handle VALUE (the sealed channel's delivery — a
/// handle the host duplicated into this process). `None` if the value does not resolve to a
/// mappable section. The handle itself is NOT consumed — the caller decides after validating
/// the mapped content (see [`close_handle_value`]).
pub fn from_handle_value(value: u64, len: usize) -> Option<MappedView> {
if value == 0 {
return None;
}
// SAFETY: `MapViewOfFile` on an arbitrary handle value is safe — it fails (returns null)
// unless the value resolves to a section handle in this process's table with RW access.
let base = unsafe { MapViewOfFile(value as usize as *mut c_void, FILE_MAP_RW, 0, 0, len) }
as *mut u8;
if base.is_null() {
return None;
}
Some(MappedView { base, len })
}
/// Assert `off..off+n` is inside the view and, for atomics, `align`-aligned. The view base is
/// page-aligned (`MapViewOfFile`), so field alignment reduces to offset alignment.
#[inline]
fn check(&self, off: usize, n: usize, align: usize) {
assert!(
off.is_multiple_of(align) && off.checked_add(n).is_some_and(|end| end <= self.len),
"MappedView access out of bounds/alignment (off={off}, n={n}, len={})",
self.len
);
}
/// Atomic `u32` load at `off` (must be 4-aligned) — the cross-process sync accessor.
#[inline]
pub fn load_u32(&self, off: usize, order: Ordering) -> u32 {
self.check(off, 4, 4);
// SAFETY: `off` is in-bounds + 4-aligned per `check`, and the page-aligned mapping stays
// valid while `&self` lives; an `AtomicU32` view over shared memory is the defined way to
// race the peer process.
unsafe { (*(self.base.add(off) as *const AtomicU32)).load(order) }
}
/// Atomic `u32` store at `off` (must be 4-aligned).
#[inline]
pub fn store_u32(&self, off: usize, v: u32, order: Ordering) {
self.check(off, 4, 4);
// SAFETY: as `load_u32` — in-bounds, aligned, valid for `&self`'s lifetime.
unsafe { (*(self.base.add(off) as *const AtomicU32)).store(v, order) }
}
/// Atomic `u64` load at `off` (must be 8-aligned).
#[inline]
pub fn load_u64(&self, off: usize, order: Ordering) -> u64 {
self.check(off, 8, 8);
// SAFETY: as `load_u32`, with 8-byte size/alignment checked.
unsafe { (*(self.base.add(off) as *const AtomicU64)).load(order) }
}
/// Plain byte read at `off` (bulk-region accessor — protocol-guarded, see the type docs).
#[inline]
pub fn read_u8(&self, off: usize) -> u8 {
self.check(off, 1, 1);
// SAFETY: in-bounds per `check`; a one-byte read cannot tear.
unsafe { *self.base.add(off) }
}
/// Plain byte write at `off`.
#[inline]
pub fn write_u8(&self, off: usize, v: u8) {
self.check(off, 1, 1);
// SAFETY: in-bounds per `check`; a one-byte write cannot tear.
unsafe { *self.base.add(off) = v }
}
/// Plain (unaligned) `u16` read at `off`.
#[inline]
pub fn read_u16(&self, off: usize) -> u16 {
self.check(off, 2, 1);
// SAFETY: in-bounds per `check`; `read_unaligned` has no alignment requirement.
unsafe { core::ptr::read_unaligned(self.base.add(off) as *const u16) }
}
/// Plain (unaligned) `u32` read at `off` — the bulk-region accessor for a DATA-section scalar
/// (host-written state / a driver-written publish counter; consistency comes from the channel
/// protocol's seq fences, not from this access, exactly as on the host side).
#[inline]
pub fn read_u32(&self, off: usize) -> u32 {
self.check(off, 4, 1);
// SAFETY: in-bounds per `check`; `read_unaligned` has no alignment requirement.
unsafe { core::ptr::read_unaligned(self.base.add(off) as *const u32) }
}
/// Plain (unaligned) `u32` write at `off` (bulk-region accessor).
#[inline]
pub fn write_u32(&self, off: usize, v: u32) {
self.check(off, 4, 1);
// SAFETY: in-bounds per `check`; `write_unaligned` has no alignment requirement.
unsafe { core::ptr::write_unaligned(self.base.add(off) as *mut u32, v) }
}
/// Plain (unaligned) `i16` read at `off`.
#[inline]
pub fn read_i16(&self, off: usize) -> i16 {
self.check(off, 2, 1);
// SAFETY: in-bounds per `check`; `read_unaligned` has no alignment requirement.
unsafe { core::ptr::read_unaligned(self.base.add(off) as *const i16) }
}
/// Copy `dst.len()` bytes out of the view starting at `off`.
pub fn read_bytes(&self, off: usize, dst: &mut [u8]) {
self.check(off, dst.len(), 1);
// SAFETY: the source range is in-bounds per `check`; `dst` is a live exclusive borrow of
// `dst.len()` writable bytes and cannot overlap the foreign mapping.
unsafe { core::ptr::copy_nonoverlapping(self.base.add(off), dst.as_mut_ptr(), dst.len()) }
}
/// Copy `src` into the view starting at `off`.
pub fn write_bytes(&self, off: usize, src: &[u8]) {
self.check(off, src.len(), 1);
// SAFETY: the destination range is in-bounds per `check`; `src` is a live borrow that
// cannot overlap the foreign mapping.
unsafe { core::ptr::copy_nonoverlapping(src.as_ptr(), self.base.add(off), src.len()) }
}
}
impl Drop for MappedView {
fn drop(&mut self) {
// SAFETY: `base` is the live view from `MapViewOfFile`, unmapped exactly once (here).
unsafe {
UnmapViewOfFile(self.base as *const c_void);
}
}
}
/// Close a raw handle VALUE owned by this process — the sealed channel's adopt-on-success step
/// (the mapped view keeps the section alive after the close). Closing a value that is not a live
/// handle of this process is a logic error the OS rejects (returns FALSE); it is not memory-unsafe.
pub fn close_handle_value(value: u64) {
if value == 0 {
return;
}
// SAFETY: `CloseHandle` validates the value against this process's handle table; no memory is
// dereferenced through it.
unsafe { CloseHandle(value as usize as *mut c_void) };
}
/// A lock-free cell holding the driver's adopted DATA view as a **leaked** `&'static MappedView`.
/// [`set`](Self::set) leaks the new view (and abandons the old one) instead of ever unmapping:
/// a concurrent framework callback may still be reading through a previously-returned reference, so
/// the mapping must never be torn down — a deliberate, bounded leak (one small view per delivery,
/// at most a handful per pad lifetime).
pub struct ViewCell(AtomicPtr<MappedView>);
impl Default for ViewCell {
fn default() -> Self {
Self::new()
}
}
impl ViewCell {
pub const fn new() -> ViewCell {
ViewCell(AtomicPtr::new(core::ptr::null_mut()))
}
/// The current view, if one was published. The `'static` lifetime is real: published views are
/// leaked and never unmapped.
pub fn get(&self) -> Option<&'static MappedView> {
let p = self.0.load(Ordering::Acquire);
// SAFETY: `p` is either null or a `Box::leak`ed `MappedView` published by `set`, which is
// never dropped or unmapped — so the reference is valid for the process lifetime.
(!p.is_null()).then(|| unsafe { &*p })
}
/// Publish `view`, leaking it (and abandoning — NOT freeing — any previous view; see the type
/// docs for why the old mapping must stay alive).
pub fn set(&self, view: MappedView) {
let leaked: &'static mut MappedView = Box::leak(Box::new(view));
self.0.swap(leaked, Ordering::Release);
}
}
@@ -0,0 +1,208 @@
//! Safe(ly-contracted) helpers over the WDF request/memory/property DDIs the pad drivers use. The
//! pattern: a framework callback converts its raw `WDFREQUEST` into a [`Request`] token **once**
//! (`unsafe`, the framework's validity guarantee is the contract); every operation after that is a
//! safe method, and completion consumes the token so a request cannot be completed twice or used
//! after completion from safe code.
use wdk_sys::{
NTSTATUS, WDF_NO_OBJECT_ATTRIBUTES, WDFDEVICE, WDFMEMORY, WDFQUEUE, WDFREQUEST,
call_unsafe_wdf_function_binding,
};
const STATUS_INVALID_BUFFER_SIZE: NTSTATUS = 0xC000_0206u32 as NTSTATUS;
/// DEVICE_REGISTRY_PROPERTY: DevicePropertyLocationInformation (the const isn't re-exported at the
/// wdk_sys root; the value is stable WDM).
const DEVICE_PROPERTY_LOCATION_INFORMATION: i32 = 10;
#[inline]
fn nt_success(s: NTSTATUS) -> bool {
s >= 0
}
/// A validity token for one framework-delivered `WDFREQUEST`. Not `Copy`/`Clone`: completing or
/// forwarding consumes it, so safe code cannot touch a request the framework already owns again.
pub struct Request(WDFREQUEST);
impl Request {
/// Wrap the raw request handed to the current framework callback.
///
/// # Safety
/// `raw` must be the live, framework-provided `WDFREQUEST` of the callback invocation this is
/// called from (WDF owns handle validity; a forged/dangling handle is framework UB).
pub unsafe fn new(raw: WDFREQUEST) -> Request {
Request(raw)
}
/// Complete the request with `status` (consumes the token — the framework owns it afterwards).
pub fn complete(self, status: NTSTATUS) {
// SAFETY: `self.0` is the live callback request per `Request::new`'s contract, not yet
// completed or forwarded (both consume the token).
unsafe { call_unsafe_wdf_function_binding!(WdfRequestComplete, self.0, status) };
}
/// Copy `src` into the request's (buffered) output buffer and set the completed byte count.
/// Returns the status to complete with (`STATUS_INVALID_BUFFER_SIZE` if the buffer is short).
pub fn copy_to_output(&self, src: &[u8]) -> NTSTATUS {
let mut mem: WDFMEMORY = core::ptr::null_mut();
// SAFETY: `self.0` is the live callback request; `mem` receives the memory handle.
let st = unsafe {
call_unsafe_wdf_function_binding!(WdfRequestRetrieveOutputMemory, self.0, &mut mem)
};
if !nt_success(st) {
return st;
}
let mut outlen: usize = 0;
// SAFETY: `mem` is the valid memory object just retrieved; `outlen` receives its size.
let _ = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, mem, &mut outlen) };
if outlen < src.len() {
return STATUS_INVALID_BUFFER_SIZE;
}
// SAFETY: `mem` is valid and at least `src.len()` bytes; `src` is a live borrow.
let st = unsafe {
call_unsafe_wdf_function_binding!(
WdfMemoryCopyFromBuffer,
mem,
0usize,
src.as_ptr() as *mut core::ffi::c_void,
src.len()
)
};
if !nt_success(st) {
return st;
}
// SAFETY: `self.0` is the live callback request.
unsafe {
call_unsafe_wdf_function_binding!(WdfRequestSetInformation, self.0, src.len() as u64)
};
0 // STATUS_SUCCESS
}
/// The request's input buffer: up to `cap` bytes copied out, plus the buffer's TRUE length.
/// `Err(status)` if the input memory can't be retrieved (propagate as the completion status).
pub fn input_bytes(&self, cap: usize) -> Result<(Vec<u8>, usize), NTSTATUS> {
let mut inmem: WDFMEMORY = core::ptr::null_mut();
// SAFETY: `self.0` is the live callback request; `inmem` receives the memory handle.
let st = unsafe {
call_unsafe_wdf_function_binding!(WdfRequestRetrieveInputMemory, self.0, &mut inmem)
};
if !nt_success(st) {
return Err(st);
}
let mut len: usize = 0;
// SAFETY: `inmem` is the valid memory object just retrieved; `len` receives its size.
let p = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, inmem, &mut len) }
as *const u8;
if p.is_null() {
return Ok((Vec::new(), 0));
}
let n = len.min(cap);
// SAFETY: `p` is valid for `len` bytes per `WdfMemoryGetBuffer`; we read `n <= len`.
let bytes = unsafe { core::slice::from_raw_parts(p, n) }.to_vec();
Ok((bytes, len))
}
/// The request's output-buffer LENGTH (0 if unavailable) — UMDF HID marshalling carries the
/// output-report id in it.
pub fn output_buffer_len(&self) -> usize {
let mut outmem: WDFMEMORY = core::ptr::null_mut();
// SAFETY: `self.0` is the live callback request; output memory is optional here.
if !nt_success(unsafe {
call_unsafe_wdf_function_binding!(WdfRequestRetrieveOutputMemory, self.0, &mut outmem)
}) {
return 0;
}
let mut outlen: usize = 0;
// SAFETY: `outmem` is the valid memory object just retrieved; `outlen` receives its size.
let _ =
unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, outmem, &mut outlen) };
outlen
}
/// Set the completed-bytes information field (for paths that complete with a length but no
/// output copy, e.g. echoing an output report's length).
pub fn set_information(&self, info: u64) {
// SAFETY: `self.0` is the live callback request.
unsafe { call_unsafe_wdf_function_binding!(WdfRequestSetInformation, self.0, info) };
}
/// Forward the request to a manual queue. On success the framework owns it (the token is
/// consumed by value — the caller cannot touch the request again); on failure the token is
/// handed back with the status so the caller completes it. (`Request` has no `Drop`, so the
/// consumed-on-success token simply falls out of scope — nothing to run.)
///
/// # Safety
/// `queue` must be a live manual `WDFQUEUE` of the same device (e.g. the one created in
/// `EvtDeviceAdd` and stashed in a static).
pub unsafe fn forward_to_queue(self, queue: WDFQUEUE) -> Result<(), (Request, NTSTATUS)> {
// SAFETY: `self.0` is the live callback request; `queue` is live per this fn's contract.
let st =
unsafe { call_unsafe_wdf_function_binding!(WdfRequestForwardToIoQueue, self.0, queue) };
if nt_success(st) {
Ok(())
} else {
Err((self, st))
}
}
}
/// Pop the next pended request off a manual queue (`None` when empty).
///
/// # Safety
/// `queue` must be a live manual `WDFQUEUE` (e.g. the timer's parent object).
pub unsafe fn retrieve_next_request(queue: WDFQUEUE) -> Option<Request> {
let mut request: WDFREQUEST = core::ptr::null_mut();
// SAFETY: `queue` is live per this fn's contract; `request` receives the next pended request.
let st = unsafe {
call_unsafe_wdf_function_binding!(WdfIoQueueRetrieveNextRequest, queue, &mut request)
};
// SAFETY: on success `request` is a live framework request this caller now services — the
// exact contract `Request::new` requires.
nt_success(st).then(|| unsafe { Request::new(request) })
}
/// Read the pad index the host stamped into the device Location (`pszDeviceLocation`), a
/// NUL-terminated UTF-16 decimal string. Defaults to 0 (single-pad) if absent. (The WDFMEMORY is
/// device-parented and freed by the framework at device teardown — one small alloc per device add.)
///
/// # Safety
/// `device` must be the live `WDFDEVICE` created in the current `EvtDeviceAdd`.
pub unsafe fn query_location_index(device: WDFDEVICE) -> u32 {
let mut mem: wdk_sys::WDFMEMORY = core::ptr::null_mut();
// SAFETY: `device` is live per this fn's contract; property = LocationInformation; pool ignored
// in UMDF; `mem` receives the handle.
let st = unsafe {
call_unsafe_wdf_function_binding!(
WdfDeviceAllocAndQueryProperty,
device,
DEVICE_PROPERTY_LOCATION_INFORMATION,
0,
WDF_NO_OBJECT_ATTRIBUTES,
&mut mem
)
};
if !nt_success(st) || mem.is_null() {
return 0;
}
let mut len: usize = 0;
// SAFETY: `mem` is the valid memory object just allocated; `len` receives its size.
let buf = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, mem, &mut len) }
as *const u16;
if buf.is_null() {
return 0;
}
let units = (len / 2).min(8);
// SAFETY: `buf` is valid for `len` bytes per `WdfMemoryGetBuffer`; we read `units * 2 <= len`.
let chars = unsafe { core::slice::from_raw_parts(buf, units) };
let mut idx: u32 = 0;
let mut any = false;
for &c in chars {
if c == 0 {
break;
}
if (0x30..=0x39).contains(&c) {
idx = idx.wrapping_mul(10).wrapping_add((c - 0x30) as u32);
any = true;
}
}
if any { idx } else { 0 }
}
@@ -42,8 +42,10 @@ AddReg=pf_vdisplay_HardwareDeviceSettings
[pf_vdisplay_HardwareDeviceSettings] [pf_vdisplay_HardwareDeviceSettings]
HKR, , "UpperFilters", %REG_MULTI_SZ%, "IndirectKmd" HKR, , "UpperFilters", %REG_MULTI_SZ%, "IndirectKmd"
HKR, "WUDF", "DeviceGroupId", %REG_SZ%, "pfVDisplayGroup" HKR, "WUDF", "DeviceGroupId", %REG_SZ%, "pfVDisplayGroup"
; Let the host (LocalSystem service) + admins open the control device for the ADD/REMOVE/PING IOCTLs. ; Only the host (LocalSystem service) + admins may open the control device. Deliberately NO Everyone
HKR, , "Security", , "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;WD)" ; ACE (SudoVDA ships one for its user-mode host): the control plane creates/removes monitors and
; bootstraps the sealed frame channel (IOCTL_SET_FRAME_CHANNEL), so it is not for unprivileged callers.
HKR, , "Security", , "D:P(A;;GA;;;SY)(A;;GA;;;BA)"
[pf_vdisplay_Install.NT.Services] [pf_vdisplay_Install.NT.Services]
AddService=WUDFRd,0x000001fa,WUDFRD_ServiceInstall AddService=WUDFRd,0x000001fa,WUDFRD_ServiceInstall
@@ -36,6 +36,8 @@ struct SendAdapter(iddcx::IDDCX_ADAPTER);
// SAFETY: an opaque IddCx handle, used only as an argument to IddCx DDIs (themselves the synchronisation // SAFETY: an opaque IddCx handle, used only as an argument to IddCx DDIs (themselves the synchronisation
// point) — never dereferenced in Rust. Storing it across threads in a OnceLock is sound. // point) — never dereferenced in Rust. Storing it across threads in a OnceLock is sound.
unsafe impl Send for SendAdapter {} unsafe impl Send for SendAdapter {}
// SAFETY: as above — the handle is only ever passed by value to IddCx DDIs, never dereferenced, so
// shared `&SendAdapter` access across threads is sound.
unsafe impl Sync for SendAdapter {} unsafe impl Sync for SendAdapter {}
static ADAPTER: OnceLock<SendAdapter> = OnceLock::new(); static ADAPTER: OnceLock<SendAdapter> = OnceLock::new();
@@ -51,8 +51,9 @@ pub unsafe extern "C" fn parse_monitor_description(
p_in: *const iddcx::IDARG_IN_PARSEMONITORDESCRIPTION, p_in: *const iddcx::IDARG_IN_PARSEMONITORDESCRIPTION,
p_out: *mut iddcx::IDARG_OUT_PARSEMONITORDESCRIPTION, p_out: *mut iddcx::IDARG_OUT_PARSEMONITORDESCRIPTION,
) -> NTSTATUS { ) -> NTSTATUS {
// SAFETY: framework-provided in/out args, valid for the call. // SAFETY: the framework supplies a valid, live input-args pointer for the call.
let in_args = unsafe { &*p_in }; let in_args = unsafe { &*p_in };
// SAFETY: the framework supplies a valid, live output-args pointer for the call.
let out_args = unsafe { &mut *p_out }; let out_args = unsafe { &mut *p_out };
// SAFETY: the framework supplies a valid EDID buffer of `DataSize` bytes. // SAFETY: the framework supplies a valid EDID buffer of `DataSize` bytes.
let edid = unsafe { let edid = unsafe {
@@ -100,8 +101,9 @@ pub unsafe extern "C" fn parse_monitor_description2(
p_in: *const iddcx::IDARG_IN_PARSEMONITORDESCRIPTION2, p_in: *const iddcx::IDARG_IN_PARSEMONITORDESCRIPTION2,
p_out: *mut iddcx::IDARG_OUT_PARSEMONITORDESCRIPTION, p_out: *mut iddcx::IDARG_OUT_PARSEMONITORDESCRIPTION,
) -> NTSTATUS { ) -> NTSTATUS {
// SAFETY: framework-provided in/out args, valid for the call. // SAFETY: the framework supplies a valid, live input-args pointer for the call.
let in_args = unsafe { &*p_in }; let in_args = unsafe { &*p_in };
// SAFETY: the framework supplies a valid, live output-args pointer for the call.
let out_args = unsafe { &mut *p_out }; let out_args = unsafe { &mut *p_out };
// SAFETY: the framework supplies a valid EDID buffer of `DataSize` bytes. // SAFETY: the framework supplies a valid EDID buffer of `DataSize` bytes.
let edid = unsafe { let edid = unsafe {
@@ -156,8 +158,9 @@ pub unsafe extern "C" fn monitor_query_modes(
p_in: *const iddcx::IDARG_IN_QUERYTARGETMODES, p_in: *const iddcx::IDARG_IN_QUERYTARGETMODES,
p_out: *mut iddcx::IDARG_OUT_QUERYTARGETMODES, p_out: *mut iddcx::IDARG_OUT_QUERYTARGETMODES,
) -> NTSTATUS { ) -> NTSTATUS {
// SAFETY: framework-provided in/out args, valid for the call. // SAFETY: the framework supplies a valid, live input-args pointer for the call.
let in_args = unsafe { &*p_in }; let in_args = unsafe { &*p_in };
// SAFETY: the framework supplies a valid, live output-args pointer for the call.
let out_args = unsafe { &mut *p_out }; let out_args = unsafe { &mut *p_out };
let Some(modes) = crate::monitor::modes_for_object(monitor) else { let Some(modes) = crate::monitor::modes_for_object(monitor) else {
return STATUS_NOT_FOUND; return STATUS_NOT_FOUND;
@@ -183,8 +186,9 @@ pub unsafe extern "C" fn monitor_query_modes2(
p_in: *const iddcx::IDARG_IN_QUERYTARGETMODES2, p_in: *const iddcx::IDARG_IN_QUERYTARGETMODES2,
p_out: *mut iddcx::IDARG_OUT_QUERYTARGETMODES, p_out: *mut iddcx::IDARG_OUT_QUERYTARGETMODES,
) -> NTSTATUS { ) -> NTSTATUS {
// SAFETY: framework-provided in/out args, valid for the call. // SAFETY: the framework supplies a valid, live input-args pointer for the call.
let in_args = unsafe { &*p_in }; let in_args = unsafe { &*p_in };
// SAFETY: the framework supplies a valid, live output-args pointer for the call.
let out_args = unsafe { &mut *p_out }; let out_args = unsafe { &mut *p_out };
let Some(modes) = crate::monitor::modes_for_object(monitor) else { let Some(modes) = crate::monitor::modes_for_object(monitor) else {
return STATUS_NOT_FOUND; return STATUS_NOT_FOUND;
@@ -279,7 +283,8 @@ pub unsafe extern "C" fn assign_swap_chain(
drop(crate::monitor::take_swap_chain_processor(monitor)); drop(crate::monitor::take_swap_chain_processor(monitor));
// The OS target id (stamped on the monitor at creation, after IddCxMonitorArrival) keys the // The OS target id (stamped on the monitor at creation, after IddCxMonitorArrival) keys the
// per-monitor objects STEP 6's host opens. 0 (default) if the monitor isn't found. // frame-channel stash STEP 6's worker attaches from (the host addresses its IOCTL_SET_FRAME_CHANNEL
// delivery by this id). 0 (default) if the monitor isn't found — the worker then never attaches.
let target_id = crate::monitor::target_id_for_object(monitor).unwrap_or(0); let target_id = crate::monitor::target_id_for_object(monitor).unwrap_or(0);
if let Some(device) = crate::direct_3d_device::pooled_device(luid) { if let Some(device) = crate::direct_3d_device::pooled_device(luid) {
@@ -93,6 +93,8 @@ pub unsafe fn dispatch(request: WDFREQUEST, ioctl_code: u32) {
} }
// SAFETY: `request` is the framework WDFREQUEST. // SAFETY: `request` is the framework WDFREQUEST.
control::IOCTL_SET_RENDER_ADAPTER => unsafe { set_render_adapter(request) }, control::IOCTL_SET_RENDER_ADAPTER => unsafe { set_render_adapter(request) },
// SAFETY: `request` is the framework WDFREQUEST.
control::IOCTL_SET_FRAME_CHANNEL => unsafe { set_frame_channel(request) },
_ => complete(request, STATUS_NOT_FOUND), _ => complete(request, STATUS_NOT_FOUND),
} }
} }
@@ -148,11 +150,49 @@ unsafe fn add(request: WDFREQUEST) {
adapter_luid_high: luid_high, adapter_luid_high: luid_high,
target_id, target_id,
resolved_monitor_id: monitor_id, resolved_monitor_id: monitor_id,
// This WUDFHost's pid — where the host duplicates the sealed frame channel's handles INTO
// (`ProcessSharingDisabled`: this process is exclusively ours and dies with the device).
wudf_pid: std::process::id(),
}; };
// SAFETY: `request` is the framework WDFREQUEST. // SAFETY: `request` is the framework WDFREQUEST.
unsafe { write_output_complete(request, &reply) }; unsafe { write_output_complete(request, &reply) };
} }
/// `IOCTL_SET_FRAME_CHANNEL`: adopt the handle values the host duplicated into this process and stash
/// them on the target monitor for the swap-chain worker to attach with. The ownership contract with
/// the host is **adopt-on-success only**: this driver owns (and eventually closes) the handles iff the
/// IOCTL completes successfully; on ANY error completion it leaves them untouched, because the host
/// reaps its remote duplicates whenever the IOCTL fails — a close on both sides would double-close
/// values the OS may already have reused for unrelated handles.
///
/// # Safety
/// `request` is the framework `WDFREQUEST`.
unsafe fn set_frame_channel(request: WDFREQUEST) {
// SAFETY: `request` is the framework WDFREQUEST.
let Some(req) = (unsafe { read_input::<control::SetFrameChannelRequest>(request) }) else {
complete(request, STATUS_INVALID_PARAMETER);
return;
};
// A malformed request adopts nothing (no FrameChannel is built, so no Drop can close anything).
let Some(ch) = crate::frame_transport::FrameChannel::from_request(&req) else {
complete(request, STATUS_INVALID_PARAMETER);
return;
};
match crate::monitor::set_frame_channel(req.target_id, ch) {
Ok(()) => complete(request, STATUS_SUCCESS),
Err(ch) => {
dbglog!(
"[pf-vd] SET_FRAME_CHANNEL: no monitor with target_id {} — rejecting (host reaps the handles)",
req.target_id
);
// NOT adopted: disarm the channel so its Drop does NOT close the handles (see the contract
// above — the host's error path reaps them remotely).
ch.into_unowned();
complete(request, STATUS_NOT_FOUND);
}
}
}
/// `IOCTL_REMOVE`: depart + drop the monitor for the given session id. /// `IOCTL_REMOVE`: depart + drop the monitor for the given session id.
/// ///
/// # Safety /// # Safety
@@ -123,11 +123,11 @@ static DEVICE_POOL: Mutex<Option<(i64, Arc<Direct3DDevice>)>> = Mutex::new(None)
pub fn pooled_device(luid: LUID) -> Option<Arc<Direct3DDevice>> { pub fn pooled_device(luid: LUID) -> Option<Arc<Direct3DDevice>> {
let key = (i64::from(luid.HighPart) << 32) | i64::from(luid.LowPart); let key = (i64::from(luid.HighPart) << 32) | i64::from(luid.LowPart);
let mut pool = DEVICE_POOL.lock().ok()?; let mut pool = DEVICE_POOL.lock().ok()?;
if let Some((k, dev)) = pool.as_ref() { if let Some((k, dev)) = pool.as_ref()
if *k == key { && *k == key
{
return Some(dev.clone()); return Some(dev.clone());
} }
}
match Direct3DDevice::init(luid) { match Direct3DDevice::init(luid) {
Ok(d) => { Ok(d) => {
let a = Arc::new(d); let a = Arc::new(d);
@@ -1,32 +1,30 @@
//! STEP 6 — IDD-push frame publisher (DRIVER side). //! STEP 6 — IDD-push frame publisher (DRIVER side), attached over the **sealed channel**.
//! //!
//! The restricted WUDFHost token canNOT create named kernel objects (proven on the RTX box: it can't //! The restricted WUDFHost token canNOT create named kernel objects — and since the frame channel
//! even write a world-writable file), so — exactly like the gamepad UMDF drivers //! carries whole-desktop pixels, the objects are not merely host-created but **unnamed**: nothing to
//! (`crates/punktfunk-host/src/inject/dualsense_windows.rs`: *"the host creates the section, privileged, //! enumerate, open by name, or pre-create ("squat"). The **host** creates the shared header +
//! with a permissive SDDL so the WUDFHost can open it; the driver maps it"*) — the **host** creates the //! frame-ready event + ring of keyed-mutex textures with no names, duplicates the handles INTO this
//! shared header + frame-ready event + ring of keyed-mutex textures, and the driver only **OPENS** them. //! WUDFHost process (`DuplicateHandle` — SYSTEM can, we can't reciprocate, which is why the host is the
//! The driver writes its actual render-adapter LUID + a status code back into the host-created header (our //! broker), and delivers the handle VALUES over `IOCTL_SET_FRAME_CHANNEL` ([`crate::control`] stashes
//! only driver-visibility channel: UMDF hides OutputDebugString in ETW and the token can't write files), //! them per monitor as a [`FrameChannel`]). The swap-chain worker picks the stash up and attaches with
//! then copies each acquired swap-chain surface into the next ring slot and signals the host. //! [`FramePublisher::from_channel`]. Only the two endpoint processes ever hold a handle to any frame
//! object — see `design/idd-push-security.md`.
//! //!
//! Host counterpart: `crates/punktfunk-host/src/capture/idd_push.rs`. The shared `SharedHeader` layout, //! The driver writes its actual render-adapter LUID + a status code back into the host-created header
//! the [`FrameToken`] packing, the `Global\` object-name scheme, the `MAGIC`/`RING_LEN` and the //! (our only driver-visibility channel: UMDF hides OutputDebugString in ETW and the token can't write
//! `DRV_STATUS_*` codes are NOT hand-duplicated here: both sides `use pf_driver_proto::frame::*`, which //! files), then copies each acquired swap-chain surface into the next ring slot and signals the host.
//! 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`). //! Host counterpart: `crates/punktfunk-host/src/capture/windows/idd_push.rs`. The shared `SharedHeader`
//! Differences from the oracle: //! layout, the [`FrameToken`] packing, the `MAGIC`/`RING_LEN`, the `DRV_STATUS_*` codes and the
//! * the layout/consts/names/token come from `pf_driver_proto::frame` instead of being re-declared; //! channel-delivery struct are NOT hand-duplicated here: both sides `use pf_driver_proto::{control,
//! * `dbglog!` replaces `log::info!`; //! frame}`, which OWNS the contract (with `const` size asserts so any drift is a compile error).
//! * 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`
//! here too (it is owned by `idd_push.rs`, not the proto).
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use pf_driver_proto::control::SetFrameChannelRequest;
use pf_driver_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,
}; };
use windows::Win32::Foundation::{CloseHandle, HANDLE}; use windows::Win32::Foundation::{CloseHandle, HANDLE};
use windows::Win32::Graphics::Direct3D11::{ use windows::Win32::Graphics::Direct3D11::{
@@ -34,28 +32,95 @@ use windows::Win32::Graphics::Direct3D11::{
}; };
use windows::Win32::Graphics::Dxgi::IDXGIKeyedMutex; use windows::Win32::Graphics::Dxgi::IDXGIKeyedMutex;
use windows::Win32::System::Memory::{ use windows::Win32::System::Memory::{
FILE_MAP_ALL_ACCESS, MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, OpenFileMappingW, FILE_MAP_READ, FILE_MAP_WRITE, MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, UnmapViewOfFile,
UnmapViewOfFile,
}; };
use windows::Win32::System::Threading::{OpenEventW, SYNCHRONIZATION_ACCESS_RIGHTS, SetEvent}; use windows::Win32::System::Threading::SetEvent;
use windows::core::{HSTRING, Interface}; use windows::core::Interface;
/// `DXGI_SHARED_RESOURCE_READ | _WRITE` — passed to `OpenSharedResourceByName` (matches the host's
/// `CreateSharedHandle` access). Kept local: it is a `OpenSharedResourceByName` arg, not part of the
/// proto contract. (Same value the host uses in `idd_push.rs`.)
const DXGI_SHARED_RESOURCE_RW: u32 = 0x8000_0000 | 0x1;
/// SYNCHRONIZE | EVENT_MODIFY_STATE — the driver does not wait on the event, only SIGNALS it.
const EVENT_ACCESS: u32 = 0x0010_0000 | 0x0002;
/// `WAIT_TIMEOUT` as an HRESULT — `AcquireSync` returns this when the slot is held by the consumer. /// `WAIT_TIMEOUT` as an HRESULT — `AcquireSync` returns this when the slot is held by the consumer.
const WAIT_TIMEOUT_HRESULT: i32 = 0x0000_0102; const WAIT_TIMEOUT_HRESULT: i32 = 0x0000_0102;
/// One monitor's sealed-channel bootstrap: the handle VALUES the host duplicated into THIS process
/// (`IOCTL_SET_FRAME_CHANNEL`). Owning a `FrameChannel` means owning those handles — exactly one of
/// {the monitor stash ([`crate::monitor`]), a [`FramePublisher`] under construction} holds it at any
/// time, and `Drop` closes every entry not consumed, so a replaced/unmatched/failed delivery can never
/// leak entries in the WUDFHost handle table. A `0` field means "taken" (or never valid) and is skipped.
pub struct FrameChannel {
/// The ring generation these textures belong to (checked against the header at attach).
generation: u32,
ring_len: u32,
header: u64,
event: u64,
textures: [u64; RING_LEN as usize],
}
impl FrameChannel {
/// Validate + adopt the handle values from the host's IOCTL. `None` on a malformed request (bad
/// `ring_len`, zero handles) — the caller completes with `STATUS_INVALID_PARAMETER` and nothing is
/// adopted (a zero value is never treated as a handle).
pub fn from_request(req: &SetFrameChannelRequest) -> Option<Self> {
if req.ring_len == 0 || req.ring_len > RING_LEN {
return None;
}
if req.header_handle == 0
|| req.event_handle == 0
|| req.texture_handles[..req.ring_len as usize].contains(&0)
{
return None;
}
Some(Self {
generation: req.generation,
ring_len: req.ring_len,
header: req.header_handle,
event: req.event_handle,
textures: req.texture_handles,
})
}
/// Move a handle value out of the channel: the caller now owns it; `Drop` skips the zeroed slot.
fn take(v: &mut u64) -> HANDLE {
HANDLE(core::mem::take(v) as usize as *mut core::ffi::c_void)
}
/// Disarm without closing anything — for the adopt-on-success-only contract: a delivery rejected
/// with an error completion was never adopted, and the HOST reaps its remote duplicates on that
/// error, so closing here too would double-close (see `crate::control::set_frame_channel`).
pub fn into_unowned(mut self) {
self.header = 0;
self.event = 0;
self.textures = [0; RING_LEN as usize];
}
}
impl Drop for FrameChannel {
fn drop(&mut self) {
for v in [&mut self.header, &mut self.event]
.into_iter()
.chain(self.textures.iter_mut())
{
if *v != 0 {
let h = Self::take(v);
// SAFETY: `h` is a live handle the host duplicated into this process for us to own; it
// was not consumed (non-zero), so this is its sole close.
unsafe {
let _ = CloseHandle(h);
}
}
}
}
}
// NB: `FrameChannel` is plain integers, so it is auto-`Send` — it crosses from the control-plane
// dispatch thread (stash) to the swap-chain worker (attach) with `MONITOR_MODES` serializing the
// hand-off; no manual impl needed (handle values are process-global tokens, not thread-affine).
struct Slot { struct Slot {
tex: ID3D11Texture2D, tex: ID3D11Texture2D,
mutex: IDXGIKeyedMutex, mutex: IDXGIKeyedMutex,
} }
/// Publishes acquired swap-chain surfaces into the HOST-created ring. Owned by the swap-chain processor /// Publishes acquired swap-chain surfaces into the HOST-created ring. Owned by the swap-chain processor
/// thread; attached lazily once the host has created the shared objects. /// thread; attached lazily once the host's channel delivery lands in the monitor stash.
pub struct FramePublisher { pub struct FramePublisher {
context: ID3D11DeviceContext, context: ID3D11DeviceContext,
map: HANDLE, map: HANDLE,
@@ -70,7 +135,8 @@ pub struct FramePublisher {
ring_format: u32, ring_format: u32,
/// The ring generation this publisher attached to. The host BUMPS the header generation when it /// The ring generation this publisher attached to. The host BUMPS the header generation when it
/// 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 ring (whose channel the host re-delivers)
/// 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 /// 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). /// matched publish — throttles the drop log to once per mismatch episode (game-capture bug GB1).
@@ -81,102 +147,99 @@ pub struct FramePublisher {
unsafe impl Send for FramePublisher {} unsafe impl Send for FramePublisher {}
impl FramePublisher { impl FramePublisher {
/// Try ONCE to attach to the host-created shared objects. Returns `Err` cheaply if the host hasn't /// Attach to the host ring from a delivered [`FrameChannel`]. Consumes the channel: on ANY failure
/// created/published them yet — the drain loop retries periodically, so a non-IDD-push session just /// every handle is closed (taken ones explicitly, the rest by the channel's `Drop`) and the host
/// keeps draining with no stall. All early-return paths clean up the handles/mapping they opened /// re-delivers on the next recreate — there is nothing to poll, so failure is terminal for THIS
/// explicitly (raw-handle style, no RAII — matches the rest of this driver). /// delivery (the host's `wait_for_attach` sees the status code and fails the session open). All
pub fn try_open( /// early-return paths clean up explicitly (raw-handle style, no RAII — matches the rest of this
target_id: u32, /// driver).
pub fn from_channel(
mut channel: FrameChannel,
render_luid_low: u32, render_luid_low: u32,
render_luid_high: i32, render_luid_high: i32,
device: &ID3D11Device, device: &ID3D11Device,
context: &ID3D11DeviceContext, context: &ID3D11DeviceContext,
) -> windows::core::Result<Self> { ) -> windows::core::Result<Self> {
// 1. Open the host-created header (RW). Err if the host hasn't created it yet. let ring_len = channel.ring_len;
// SAFETY: a plain Win32 call; the name HSTRING is valid for the call (`?` returns on failure).
let map = unsafe { // 1. Map the header from the duplicated section handle (ours from here on).
OpenFileMappingW( let map = FrameChannel::take(&mut channel.header);
FILE_MAP_ALL_ACCESS.0, // SAFETY: `map` is the live section handle the host duplicated into this process; mapping
false, // size_of::<SharedHeader>() bytes of it (the host created the mapping at >= that size). The null
&HSTRING::from(header_name(target_id)), // `view.Value` is checked below.
)?
};
// SAFETY: `map` is the just-opened file mapping; mapping size_of::<SharedHeader>() bytes of it
// (the host created the mapping at >= that size). The null `view.Value` is checked below.
let view = unsafe { let view = unsafe {
// Read/write only — the host now duplicates the header handle with least access
// (`SECTION_MAP_READ | SECTION_MAP_WRITE`), so `FILE_MAP_ALL_ACCESS` would exceed the
// granted rights and fail. We read the layout + write status/publish-token fields; RW covers it.
MapViewOfFile( MapViewOfFile(
map, map,
FILE_MAP_ALL_ACCESS, FILE_MAP_READ | FILE_MAP_WRITE,
0, 0,
0, 0,
core::mem::size_of::<SharedHeader>(), core::mem::size_of::<SharedHeader>(),
) )
}; };
if view.Value.is_null() { if view.Value.is_null() {
// SAFETY: `map` is the just-opened mapping handle, closed once here on the error path. let err = windows::core::Error::from_win32();
// SAFETY: `map` is the taken section handle, closed once here on the error path (the rest of
// `channel` closes via its Drop).
unsafe { unsafe {
let _ = CloseHandle(map); let _ = CloseHandle(map);
} }
return Err(windows::core::Error::from_win32()); return Err(err);
} }
let header = view.Value.cast::<SharedHeader>(); let header = view.Value.cast::<SharedHeader>();
// 2. Report our render adapter to the host immediately (lets it detect a mismatch). // 2. Report our render adapter to the host immediately (lets it detect a mismatch).
// SAFETY: `header` points to the mapped, non-null host header (>= size_of::<SharedHeader>() bytes); // SAFETY: `header` points to the mapped, non-null host header (>= size_of::<SharedHeader>()
// these scalar writes are within it. The host opened the section with a permissive SDDL for us. // bytes); these scalar writes are within it.
unsafe { unsafe {
(*header).driver_render_luid_low = render_luid_low; (*header).driver_render_luid_low = render_luid_low;
(*header).driver_render_luid_high = render_luid_high; (*header).driver_render_luid_high = render_luid_high;
} }
// 3. The host sets magic==MAGIC only once the ring textures exist. Not ready → retry later. // 3. The host stamps magic==MAGIC BEFORE delivering the channel, and this channel's generation
// SAFETY: `header` is the mapped host header; `magic` lives within it and is read atomically // must match the header's CURRENT generation — a mismatch means the host recreated the ring
// (Acquire) to pair with the host's Release store once the ring textures are published. // again before we attached (a fresh delivery is on its way); drop this stale one.
let magic = unsafe { // SAFETY: `header` is the mapped host header; `magic`/`generation` live within it and are read
(*(core::ptr::addr_of!((*header).magic) as *const AtomicU32)).load(Ordering::Acquire) // atomically (Acquire) to pair with the host's Release publishes.
}; let (magic, header_gen) = unsafe {
if magic != MAGIC { (
// SAFETY: `header`/`map` are the live mapped view + handle; unmapped + closed once on this path. (*(core::ptr::addr_of!((*header).magic) as *const AtomicU32))
unsafe { .load(Ordering::Acquire),
let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS { (*(core::ptr::addr_of!((*header).generation) as *const AtomicU32))
Value: header.cast(), .load(Ordering::Acquire),
});
let _ = CloseHandle(map);
}
return Err(windows::core::Error::from_win32());
}
// SAFETY: `header` is the mapped host header; these scalar fields live within it.
let (generation, ring_len) =
unsafe { ((*header).generation, (*header).ring_len.min(RING_LEN)) };
// 4. Open the event (SYNCHRONIZE | EVENT_MODIFY_STATE so we can SetEvent).
// SAFETY: a plain Win32 call; the name HSTRING is valid for the call.
let event = match unsafe {
OpenEventW(
SYNCHRONIZATION_ACCESS_RIGHTS(EVENT_ACCESS),
false,
&HSTRING::from(event_name(target_id)),
) )
} { };
Ok(e) => e, if magic != MAGIC || header_gen != channel.generation {
Err(e) => { dbglog!(
// SAFETY: `header`/`map` are the live mapped view + handle; unmapped + closed once here. "[pf-vd] frame-push(driver): dropping channel delivery (magic ok: {}, channel gen {} vs header gen {header_gen})",
magic == MAGIC,
channel.generation
);
// SAFETY: `header`/`map` are the live mapped view + taken handle; unmapped + closed once on
// this path.
unsafe { unsafe {
let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS { let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS {
Value: header.cast(), Value: header.cast(),
}); });
let _ = CloseHandle(map); let _ = CloseHandle(map);
} }
return Err(e); // E_BOUNDS — stand-in for "stale delivery"; the caller only drops the attempt.
return Err(windows::core::HRESULT(0x8000_000Bu32 as i32).into());
} }
};
// 5. Open device1 + the ring textures the host created (same render adapter required). // 4. The frame-ready event (duplicated with the host handle's full access, so SetEvent works).
let event = FrameChannel::take(&mut channel.event);
// 5. Open device1 + the ring textures from their duplicated shared handles (same render adapter
// required). Each NT handle is closed right after the open — the COM object holds its own
// reference, and the HOST keeps the resource alive with its own handle.
let device1: ID3D11Device1 = match device.cast() { let device1: ID3D11Device1 = match device.cast() {
Ok(d) => d, Ok(d) => d,
Err(e) => { Err(e) => {
// SAFETY: `header` is the mapped host header (status write within it); `event`/`map` are the // SAFETY: `header` is the mapped host header (status write within it); `event`/`map` are
// live handles, all released once on this error path. // the taken live handles, all released once on this error path.
unsafe { unsafe {
(*header).driver_status = DRV_STATUS_NO_DEVICE1; (*header).driver_status = DRV_STATUS_NO_DEVICE1;
let _ = CloseHandle(event); let _ = CloseHandle(event);
@@ -189,34 +252,35 @@ impl FramePublisher {
} }
}; };
let mut slots = Vec::new(); let mut slots = Vec::new();
for k in 0..ring_len { // Take each texture handle one at a time (NOT the whole array up front), so an error return
let name = HSTRING::from(texture_name(target_id, generation, k)); // mid-loop still lets `channel`'s Drop close every not-yet-taken handle.
// SAFETY: `device1` is a live ID3D11Device1; the name HSTRING is valid for the call. for value in channel.textures.iter_mut().take(ring_len as usize) {
let tex_handle = FrameChannel::take(value);
// SAFETY: `device1` is a live ID3D11Device1; `tex_handle` is the duplicated shared NT handle
// for this ring texture.
let opened: windows::core::Result<ID3D11Texture2D> = let opened: windows::core::Result<ID3D11Texture2D> =
unsafe { device1.OpenSharedResourceByName(&name, DXGI_SHARED_RESOURCE_RW) }; unsafe { device1.OpenSharedResource1(tex_handle) };
match opened { // SAFETY: `tex_handle` is ours (taken above) and no longer needed whether the open succeeded
// (the COM object holds the resource) or failed — close it exactly once here.
unsafe {
let _ = CloseHandle(tex_handle);
}
let failed = match opened {
Ok(tex) => match tex.cast::<IDXGIKeyedMutex>() { Ok(tex) => match tex.cast::<IDXGIKeyedMutex>() {
Ok(mutex) => slots.push(Slot { tex, mutex }), Ok(mutex) => {
Err(e) => { slots.push(Slot { tex, mutex });
// SAFETY: `header` is the mapped host header (status writes within it); `event`/`map` None
// are the live handles, all released once on this error path.
unsafe {
(*header).driver_status = DRV_STATUS_TEX_FAIL;
(*header).driver_status_detail = e.code().0 as u32;
let _ = CloseHandle(event);
let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS {
Value: header.cast(),
});
let _ = CloseHandle(map);
}
return Err(e);
} }
Err(e) => Some(e),
}, },
Err(e) => { // Most likely a render-adapter mismatch (the host made the textures on a different GPU
// Most likely a render-adapter mismatch (the host made the textures on a different // than the swap-chain renders on). Tell the host so it can report it.
// GPU than the swap-chain renders on). Tell the host so it can report it. Err(e) => Some(e),
};
if let Some(e) = failed {
// SAFETY: `header` is the mapped host header (status writes within it); `event`/`map` // SAFETY: `header` is the mapped host header (status writes within it); `event`/`map`
// are the live handles, all released once on this error path. // are the taken live handles, all released once on this error path (the not-yet-taken
// texture handles close via `channel`'s Drop).
unsafe { unsafe {
(*header).driver_status = DRV_STATUS_TEX_FAIL; (*header).driver_status = DRV_STATUS_TEX_FAIL;
(*header).driver_status_detail = e.code().0 as u32; (*header).driver_status_detail = e.code().0 as u32;
@@ -229,14 +293,13 @@ impl FramePublisher {
return Err(e); return Err(e);
} }
} }
}
// SAFETY: `header` is the mapped host header; the status field lives within it. // SAFETY: `header` is the mapped host header; the status field lives within it.
unsafe { unsafe {
(*header).driver_status = DRV_STATUS_OPENED; (*header).driver_status = DRV_STATUS_OPENED;
} }
dbglog!( dbglog!(
"[pf-vd] frame-push(driver): attached to host ring gen {generation} ({ring_len} slots)" "[pf-vd] frame-push(driver): attached to host ring gen {header_gen} ({ring_len} slots, sealed channel)"
); );
Ok(Self { Ok(Self {
context: context.clone(), context: context.clone(),
@@ -248,7 +311,7 @@ impl FramePublisher {
seq: 0, seq: 0,
// 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: header_gen,
mismatch_logged: false, mismatch_logged: false,
}) })
} }
@@ -261,8 +324,8 @@ impl FramePublisher {
} }
/// True once the host has recreated the ring (bumped the header generation) — e.g. the display's HDR /// True once the host has recreated the ring (bumped the header generation) — e.g. the display's HDR
/// mode flipped, so the ring format changed (FP16 ⇄ BGRA) and the texture names now carry a new /// mode flipped, so the ring format changed (FP16 ⇄ BGRA) and a fresh channel delivery is coming.
/// generation. `run_core` drops the publisher on this so it re-attaches to the new ring. /// `run_core` drops the publisher on this so it re-attaches to the new ring.
pub fn is_stale(&self) -> bool { pub fn is_stale(&self) -> bool {
// SAFETY: `self.header` stays mapped for the publisher's lifetime; `generation` lives within it and // SAFETY: `self.header` stays mapped for the publisher's lifetime; `generation` lives within it and
// is read atomically (Acquire) to pair with the host's Release bump on a mid-session ring recreate. // is read atomically (Acquire) to pair with the host's Release bump on a mid-session ring recreate.
@@ -338,8 +401,8 @@ impl FramePublisher {
} }
.pack(); .pack();
self.latest_cell().store(latest, Ordering::Release); self.latest_cell().store(latest, Ordering::Release);
// SAFETY: `self.event` is the live host-created frame-ready event we opened with // SAFETY: `self.event` is the live host-created frame-ready event, duplicated into
// EVENT_MODIFY_STATE; signalling it wakes the host consumer. // this process with the creator's access; signalling it wakes the host consumer.
unsafe { unsafe {
let _ = SetEvent(self.event); let _ = SetEvent(self.event);
} }
@@ -357,10 +420,11 @@ impl FramePublisher {
impl Drop for FramePublisher { impl Drop for FramePublisher {
fn drop(&mut self) { fn drop(&mut self) {
// Slots FIRST (release the shared textures + keyed mutexes), THEN unmap the header, THEN the // Slots FIRST (release the shared textures + keyed mutexes), THEN unmap the header, THEN the
// handles. // handles — nothing of the channel outlives the publisher (teardown invariant,
// `design/idd-push-security.md`).
self.slots.clear(); self.slots.clear();
// SAFETY: drop runs once; `self.header` (if non-null) is the live mapped view and `self.event`/ // SAFETY: drop runs once; `self.header` (if non-null) is the live mapped view and `self.event`/
// `self.map` are the live handles this publisher opened — each unmapped/closed exactly once here. // `self.map` are the live handles this publisher owns — each unmapped/closed exactly once here.
unsafe { unsafe {
if !self.header.is_null() { if !self.header.is_null() {
let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS { let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS {
@@ -10,9 +10,12 @@
#![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 // 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 // fn-level `unsafe` never silently blesses the whole body, AND every `unsafe {}` must carry a `// SAFETY:`
// landed in STEP 8.) // proof. An IddCx display driver is inherently FFI-bound (D3D11 / IddCx DDIs / cross-process shared
// textures), so it can't be unsafe-FREE the way the gamepad drivers now are (their logic moved onto the
// safe `pf_umdf_util` layer); these gates make it unsafe-AUDITED instead, and stop it regressing.
#![deny(unsafe_op_in_unsafe_fn)] #![deny(unsafe_op_in_unsafe_fn)]
#![deny(clippy::undocumented_unsafe_blocks)]
#[macro_use] #[macro_use]
mod log; mod log;
@@ -45,13 +45,13 @@ pub fn log(s: &str) {
unsafe { OutputDebugStringA(c.as_ptr().cast()) }; unsafe { OutputDebugStringA(c.as_ptr().cast()) };
} }
use std::io::Write; use std::io::Write;
if let Some(m) = file_appender() { if let Some(m) = file_appender()
if let Ok(mut f) = m.lock() { && let Ok(mut f) = m.lock()
{
let _ = writeln!(f, "{s}"); let _ = writeln!(f, "{s}");
let _ = f.flush(); 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)*)) };
@@ -53,6 +53,11 @@ pub struct MonitorObject {
/// The live swap-chain drain worker, set by `assign_swap_chain` and dropped (RAII-joins the worker /// The live swap-chain drain worker, set by `assign_swap_chain` and dropped (RAII-joins the worker
/// thread) by `unassign_swap_chain` / departure (STEP 5). /// thread) by `unassign_swap_chain` / departure (STEP 5).
pub swap_chain_processor: Option<crate::swap_chain_processor::SwapChainProcessor>, pub swap_chain_processor: Option<crate::swap_chain_processor::SwapChainProcessor>,
/// The host's sealed-channel delivery (`IOCTL_SET_FRAME_CHANNEL`) awaiting pickup by the swap-chain
/// worker ([`take_frame_channel`]). Exactly one owner per delivery: replacing or dropping the entry
/// closes an unconsumed channel's handles via [`FrameChannel`]'s `Drop`, so no delivery can leak
/// handles in the WUDFHost table whatever the monitor's fate.
pub frame_channel: Option<crate::frame_transport::FrameChannel>,
/// When the entry was created — the watchdog skips still-initializing monitors. /// When the entry was created — the watchdog skips still-initializing monitors.
pub created_at: Instant, pub created_at: Instant,
} }
@@ -256,8 +261,8 @@ pub fn modes_for_object(object: iddcx::IDDCX_MONITOR) -> Option<Vec<Mode>> {
.map(|m| m.modes.clone()) .map(|m| m.modes.clone())
} }
/// The OS target id stamped on the monitor whose handle matches (used by `assign_swap_chain` to name the /// The OS target id stamped on the monitor whose handle matches (used by `assign_swap_chain` to key the
/// shared-ring objects). `None` if the monitor isn't found. /// frame-channel stash for its worker). `None` if the monitor isn't found.
pub fn target_id_for_object(object: iddcx::IDDCX_MONITOR) -> Option<u32> { pub fn target_id_for_object(object: iddcx::IDDCX_MONITOR) -> Option<u32> {
MONITOR_MODES MONITOR_MODES
.lock() .lock()
@@ -267,6 +272,52 @@ pub fn target_id_for_object(object: iddcx::IDDCX_MONITOR) -> Option<u32> {
.map(|m| m.target_id) .map(|m| m.target_id)
} }
/// Stash a host frame-channel delivery on the monitor with `target_id` (an ARRIVED monitor — a pending
/// entry's `target_id` is still 0, which the host can never send since OS target ids are non-zero).
/// Replacing an unconsumed delivery drops it → its handles close (it WAS adopted by a prior success).
/// `Err(ch)` if no such monitor exists — the caller must NOT close those handles (the host only sees
/// the error status and reaps its remote duplicates itself; closing here too would double-close values
/// the OS may have reused).
pub fn set_frame_channel(
target_id: u32,
ch: crate::frame_transport::FrameChannel,
) -> Result<(), crate::frame_transport::FrameChannel> {
if target_id == 0 {
return Err(ch);
}
let mut lock = lock_monitors();
if let Some(m) = lock.iter_mut().find(|m| m.target_id == target_id) {
m.frame_channel = Some(ch);
Ok(())
} else {
Err(ch)
}
}
/// Take (remove) the pending frame-channel delivery for `target_id`, transferring handle ownership to
/// the caller (the swap-chain worker's attach). `None` until the host delivers one.
pub fn take_frame_channel(target_id: u32) -> Option<crate::frame_transport::FrameChannel> {
if target_id == 0 {
return None;
}
lock_monitors()
.iter_mut()
.find(|m| m.target_id == target_id)?
.frame_channel
.take()
}
/// Is a frame-channel delivery pending for `target_id`? The swap-chain worker treats a pending
/// delivery as NEWEST-WINS: it supersedes an attached publisher, because the host only re-delivers
/// after (re)creating the ring — and a retry-created ring is a DIFFERENT header mapping, whose
/// generation bump an old publisher (mapped to the previous header) can never observe.
pub fn has_frame_channel(target_id: u32) -> bool {
target_id != 0
&& lock_monitors()
.iter()
.any(|m| m.target_id == target_id && m.frame_channel.is_some())
}
/// Install a swap-chain processor on the monitor whose handle matches, returning any PREVIOUS processor /// Install a swap-chain processor on the monitor whose handle matches, returning any PREVIOUS processor
/// for the caller to drop OUTSIDE the lock. Dropping a processor RAII-joins its worker thread, so it must /// for the caller to drop OUTSIDE the lock. Dropping a processor RAII-joins its worker thread, so it must
/// never happen while holding `MONITOR_MODES` (the worker would block the whole control plane / risk a /// never happen while holding `MONITOR_MODES` (the worker would block the whole control plane / risk a
@@ -351,6 +402,7 @@ pub fn create_monitor(
adapter_luid_low: 0, adapter_luid_low: 0,
adapter_luid_high: 0, adapter_luid_high: 0,
swap_chain_processor: None, swap_chain_processor: None,
frame_channel: None,
created_at: Instant::now(), created_at: Instant::now(),
}); });
id id
@@ -78,6 +78,8 @@ pub struct SwapChainProcessor {
// SAFETY: Raw ptr is managed by external library; access is serialised by the worker thread + the // SAFETY: Raw ptr is managed by external library; access is serialised by the worker thread + the
// terminate flag. // terminate flag.
unsafe impl Send for SwapChainProcessor {} unsafe impl Send for SwapChainProcessor {}
// SAFETY: as above — the raw pointer is only touched by the serialised worker, so a shared
// `&SwapChainProcessor` reference exposes no unsynchronised access.
unsafe impl Sync for SwapChainProcessor {} unsafe impl Sync for SwapChainProcessor {}
impl SwapChainProcessor { impl SwapChainProcessor {
@@ -223,10 +225,11 @@ impl SwapChainProcessor {
return; return;
} }
// STEP 6 IDD-push: lazily ATTACH to the HOST-created shared ring. The restricted UMDF token can't // STEP 6 IDD-push: lazily ATTACH to the HOST-created shared ring over the SEALED channel. The
// create named objects, so the host creates the header + event + textures and we only OPEN them // frame objects are unnamed — the host duplicates their handles into this process and delivers
// once they appear (`try_open`). Until then we just drain — exactly the STEP-5 behaviour — so a // the values via IOCTL_SET_FRAME_CHANNEL, which the control plane stashes on our monitor
// non-IDD-push session never stalls. Retried every ~30 loop iterations. // (`monitor::take_frame_channel`). Until a delivery lands we just drain — exactly the STEP-5
// behaviour — so a non-IDD-push session never stalls. The stash is polled every ~30 iterations.
let mut publisher: Option<FramePublisher> = None; let mut publisher: Option<FramePublisher> = None;
let mut frames_since_try: u32 = u32::MAX; // attach attempt on the first loop iteration let mut frames_since_try: u32 = u32::MAX; // attach attempt on the first loop iteration
@@ -243,25 +246,34 @@ impl SwapChainProcessor {
break; break;
} }
// The host recreates the shared ring (new format) mid-session when the display's HDR mode // Re-attach triggers, either of:
// flips — it bumps the header generation. Detect that and drop the publisher so we re-attach to // * `is_stale` — the host recreated the ring mid-session (HDR flip): it bumps OUR header's
// the new-format textures below; otherwise we'd keep CopyResource'ing into the stale ring, whose // generation and re-delivers; without dropping here we'd keep CopyResource'ing into the
// format now mismatches the surface → the publish() format-guard drops every frame and the // stale ring, whose format now mismatches the surface → the publish() format-guard drops
// stream freezes until the next swap-chain recreate. // every frame and the stream freezes until the next swap-chain recreate.
if publisher.as_ref().is_some_and(FramePublisher::is_stale) { // * a PENDING delivery (newest-wins) — a host build-retry creates a whole NEW ring with a
// DIFFERENT header mapping; the old publisher's header never changes, so `is_stale` can't
// fire. The host only delivers after fully (re)creating a ring, so a pending delivery
// always supersedes whatever we're attached to.
if publisher.as_ref().is_some_and(FramePublisher::is_stale)
|| (publisher.is_some() && crate::monitor::has_frame_channel(target_id))
{
publisher = None; publisher = None;
frames_since_try = u32::MAX; // re-attach immediately frames_since_try = u32::MAX; // re-attach immediately
} }
// Lazy-attach (rate-limited) at the loop TOP so we keep trying even while the display is idle // Lazy-attach (rate-limited) at the loop TOP so we keep trying even while the display is idle
// (E_PENDING / no frames presented yet), not only when a frame is acquired. `try_open` is a // (E_PENDING / no frames presented yet), not only when a frame is acquired. Checking the
// cheap OpenFileMapping that fails fast until the host has created the ring. // stash is a cheap mutex peek that stays empty until the host's channel delivery lands; a
// taken delivery is consumed whether the attach succeeds or not (on failure its handles are
// closed, the host's wait-for-attach reads the status code, and any retry is a NEW delivery).
if publisher.is_none() { if publisher.is_none() {
if frames_since_try >= 30 { if frames_since_try >= 30 {
frames_since_try = 0; frames_since_try = 0;
if let Some(channel) = crate::monitor::take_frame_channel(target_id) {
// `if let Ok` (not a `match` with an empty `Err` arm) keeps clippy's `single_match` // `if let Ok` (not a `match` with an empty `Err` arm) keeps clippy's `single_match`
// happy under `-D warnings`; semantics are identical — attach on success, retry on Err. // happy under `-D warnings`; attach on success, drop the delivery on Err.
if let Ok(p) = FramePublisher::try_open( if let Ok(p) = FramePublisher::from_channel(
target_id, channel,
render_luid_low, render_luid_low,
render_luid_high, render_luid_high,
&device.device, &device.device,
@@ -269,6 +281,7 @@ impl SwapChainProcessor {
) { ) {
publisher = Some(p); publisher = Some(p);
} }
}
} else { } else {
frames_since_try += 1; frames_since_try += 1;
} }
@@ -337,13 +350,13 @@ impl SwapChainProcessor {
if !raw.is_null() { if !raw.is_null() {
// SAFETY: `raw` is IddCx's live surface pointer (valid until the next // SAFETY: `raw` is IddCx's live surface pointer (valid until the next
// ReleaseAndAcquire); `from_raw_borrowed` does not consume the refcount. // ReleaseAndAcquire); `from_raw_borrowed` does not consume the refcount.
if let Some(res) = unsafe { IDXGIResource::from_raw_borrowed(&raw) } { if let Some(res) = unsafe { IDXGIResource::from_raw_borrowed(&raw) }
if let Ok(tex) = res.cast::<ID3D11Texture2D>() { && let Ok(tex) = res.cast::<ID3D11Texture2D>()
{
p.publish(&tex); p.publish(&tex);
} }
} }
} }
}
// SAFETY: driver is loaded; `swap_chain` is valid. // SAFETY: driver is loaded; `swap_chain` is valid.
let hr = unsafe { wdk_iddcx::IddCxSwapChainFinishedProcessingFrame(swap_chain) }; let hr = unsafe { wdk_iddcx::IddCxSwapChainFinishedProcessingFrame(swap_chain) };
@@ -23,6 +23,8 @@ wdk-build.workspace = true
[dependencies] [dependencies]
wdk.workspace = true wdk.workspace = true
wdk-sys.workspace = true wdk-sys.workspace = true
pf-driver-proto.workspace = true
pf-umdf-util.workspace = true
[features] [features]
default = [] default = []
+13 -7
View File
@@ -14,8 +14,11 @@ instance (= player slot 03) with `CreateFile`, and polls it with buffered IOC
**System** setup class; **System** setup class;
- registers the XUSB interface with `WdfDeviceCreateDeviceInterface(device, &XUSB_GUID, NULL)`; - registers the XUSB interface with `WdfDeviceCreateDeviceInterface(device, &XUSB_GUID, NULL)`;
- answers the XUSB IOCTLs (all `METHOD_BUFFERED`, delivered to user mode by the reflector) from - answers the XUSB IOCTLs (all `METHOD_BUFFERED`, delivered to user mode by the reflector) from
controller state the host publishes into a shared section `Global\pfxusb-shm-0`; a game's rumble controller state the host publishes into an **unnamed** shared DATA section reached over the
(`SET_STATE`) is published back for the host to forward to the client. **sealed pad channel** (`design/gamepad-channel-sealing.md`): the host duplicates the section
handle into this driver's WUDFHost, bootstrapped via the named `Global\pfxusb-boot-<index>`
mailbox (`pf_driver_proto::gamepad::PadBootstrap`); a game's rumble (`SET_STATE`) is published
back for the host to forward to the client.
The WAIT_* IOCTLs return `STATUS_INVALID_DEVICE_REQUEST`, which makes `xinput1_4` fall back to The WAIT_* IOCTLs return `STATUS_INVALID_DEVICE_REQUEST`, which makes `xinput1_4` fall back to
synchronous `GET_STATE` polling — so no manual queue / timer is needed for classic XInput. (WGI/ synchronous `GET_STATE` polling — so no manual queue / timer is needed for classic XInput. (WGI/
@@ -37,11 +40,13 @@ GameInput admission additionally needs a `xinputhid` `UpperFilters` registry tri
`wButtons` is the `XINPUT_GAMEPAD_*` bitmap (DPAD_UP `0x0001` … A `0x1000` B `0x2000` X `0x4000` `wButtons` is the `XINPUT_GAMEPAD_*` bitmap (DPAD_UP `0x0001` … A `0x1000` B `0x2000` X `0x4000`
Y `0x8000`). `dwPacketNumber` (GET_STATE `[5]`) must increment whenever the payload changes. Y `0x8000`). `dwPacketNumber` (GET_STATE `[5]`) must increment whenever the payload changes.
## Shared-memory layout `Global\pfxusb-shm-0` (64 B) — host writes state, driver writes rumble ## Shared-memory layout (unnamed DATA section, 64 B) — host writes state, driver writes rumble
`pf_driver_proto::gamepad::XusbShm` (the crate owns the offsets; both sides compile against it):
`magic u32 @0` (`"PFXU"` `0x55584650`) · `packet u32 @4` (host bumps → dwPacketNumber) · `wButtons u16 `magic u32 @0` (`"PFXU"` `0x55584650`) · `packet u32 @4` (host bumps → dwPacketNumber) · `wButtons u16
@8` · `LT @10` · `RT @11` · `LX/LY/RX/RY i16 @12/@14/@16/@18` · `rumble_seq u32 @24` (driver bumps) · @8` · `LT @10` · `RT @11` · `LX/LY/RX/RY i16 @12/@14/@16/@18` · `rumble_seq u32 @24` (driver bumps) ·
`large @28` · `small @29`. `large @28` · `small @29` · health marks `@32/@36` · `pad_index u32 @40` (validated against the
devnode's Location index when the delivered handle is mapped).
## Validated live (2026-06-22, maintainer's RTX test box) ## Validated live (2026-06-22, maintainer's RTX test box)
@@ -66,7 +71,8 @@ the whole build/sign/stage flow in CI. The manual steps:
## Host integration (done) ## Host integration (done)
`crates/punktfunk-host/src/inject/windows/gamepad_windows.rs` is the Windows `GamepadManager` (used by `crates/punktfunk-host/src/inject/windows/gamepad_windows.rs` is the Windows `GamepadManager` (used by
`PadBackend::Xbox360`): it SwDeviceCreate's the `pf_xusb` companion, maps `pfxusb-shm-<index>`, writes `PadBackend::Xbox360`): it SwDeviceCreate's the `pf_xusb` companion, delivers the unnamed DATA
section over the sealed channel (`PadChannel`), writes
the XInput state from the client's gamepad frame (already XInput-convention) and forwards rumble. There the XInput state from the client's gamepad frame (already XInput-convention) and forwards rumble. There
is **no ViGEmBus dependency** anymore. The driver is built + signed from source in CI is **no ViGEmBus dependency** anymore. The driver is built + signed from source in CI
(`build-gamepad-drivers.ps1`) and installed by the Inno Setup installer via (`build-gamepad-drivers.ps1`) and installed by the Inno Setup installer via
@@ -75,8 +81,8 @@ is **no ViGEmBus dependency** anymore. The driver is built + signed from source
## Multi-pad ## Multi-pad
The host stamps each pad's index into the device Location (`pszDeviceLocation`); the driver reads it The host stamps each pad's index into the device Location (`pszDeviceLocation`); the driver reads it
via `WdfDeviceAllocAndQueryProperty(DevicePropertyLocationInformation)` in EvtDeviceAdd and maps its own via `WdfDeviceAllocAndQueryProperty(DevicePropertyLocationInformation)` in EvtDeviceAdd and polls its own
`pfxusb-shm-<index>`. `UmdfHostProcessSharing=ProcessSharingDisabled` (the INF) gives each pad its own `pfxusb-boot-<index>` bootstrap mailbox (the delivered DATA section's `pad_index` is validated against it). `UmdfHostProcessSharing=ProcessSharingDisabled` (the INF) gives each pad its own
WUDFHost, so the per-pad `SHM_INDEX` static doesn't collide. Validated live: two pads → two distinct WUDFHost, so the per-pad `SHM_INDEX` static doesn't collide. Validated live: two pads → two distinct
XInput slots. (XInput assigns the player slot 0-3 by interface-enumeration order, independent of this XInput slots. (XInput assigns the player slot 0-3 by interface-enumeration order, independent of this
index — which only routes shared memory.) index — which only routes shared memory.)
+144 -237
View File
@@ -3,42 +3,39 @@
// //
// xinput1_4.dll enumerates GUID_DEVINTERFACE_XUSB, opens the Nth instance (= player slot), and polls // xinput1_4.dll enumerates GUID_DEVINTERFACE_XUSB, opens the Nth instance (= player slot), and polls
// it with buffered IOCTLs. We register the interface and answer those IOCTLs from controller state the // it with buffered IOCTLs. We register the interface and answer those IOCTLs from controller state the
// host publishes into a shared-memory section (`Global\pfxusb-shm-0`); a game's rumble (SET_STATE) is // host publishes into a shared DATA section; a game's rumble (SET_STATE) is published back for the
// published back for the host to forward. Byte formats are the source-verified xusb22 wire layout // host to forward. Byte formats are the source-verified xusb22 wire layout (HIDMaestro
// (HIDMaestro driver/companion.c + nefarius/XInputHooker XUSB.h + ViGEm XUSB_REPORT). // driver/companion.c + nefarius/XInputHooker XUSB.h + ViGEm XUSB_REPORT).
//
// The host channel is the **sealed pad channel** (design/gamepad-channel-sealing.md, proto v2): the
// DATA section (`pf_driver_proto::gamepad::XusbShm`) is UNNAMED — we reach it only through a handle
// the SYSTEM host duplicated into this WUDFHost, bootstrapped over the named `Global\pfxusb-boot-<i>`
// mailbox. The whole handshake + all shared-memory access lives in `pf_umdf_util` (audited unsafe
// layer): this crate's channel/IOCTL/state logic is 100% SAFE Rust. The only `unsafe` here is the
// unavoidable WDF setup FFI in DriverEntry/EvtDeviceAdd, each with a `// SAFETY:` proof.
// //
// We answer the WAIT_* IOCTLs with STATUS_INVALID_DEVICE_REQUEST, which makes xinput1_4 fall back to // We answer the WAIT_* IOCTLs with STATUS_INVALID_DEVICE_REQUEST, which makes xinput1_4 fall back to
// synchronous GET_STATE polling — so no manual queue / timer is needed for classic XInput. // synchronous GET_STATE polling — so no manual queue / timer is needed for classic XInput.
#![allow(non_snake_case, non_upper_case_globals, clippy::missing_safety_doc)] #![allow(non_snake_case, non_upper_case_globals, clippy::missing_safety_doc)]
// Every remaining `unsafe {}` (all WDF setup FFI) must carry a `// SAFETY:` proof.
#![deny(unsafe_op_in_unsafe_fn)]
#![deny(clippy::undocumented_unsafe_blocks)]
use core::ffi::c_void; use pf_driver_proto::gamepad::XusbShm;
use core::sync::atomic::{AtomicU32, Ordering}; use pf_umdf_util::channel::{ChannelClient, ChannelConfig};
use pf_umdf_util::nt_success;
use pf_umdf_util::section::MappedView;
use pf_umdf_util::wdf::{self, Request};
use wdk_sys::{ use wdk_sys::{
call_unsafe_wdf_function_binding, windows::OutputDebugStringA, GUID, NTSTATUS, PCUNICODE_STRING, GUID, NTSTATUS, PCUNICODE_STRING, PDRIVER_OBJECT, PWDFDEVICE_INIT, ULONG, WDF_DRIVER_CONFIG,
PDRIVER_OBJECT, PWDFDEVICE_INIT, ULONG, WDFDEVICE, WDFDRIVER, WDFMEMORY, WDFQUEUE, WDFREQUEST, WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, WDF_NO_OBJECT_ATTRIBUTES, WDFDEVICE, WDFDRIVER, WDFQUEUE,
WDF_DRIVER_CONFIG, WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, WDF_NO_OBJECT_ATTRIBUTES, WDFREQUEST, call_unsafe_wdf_function_binding, windows::OutputDebugStringA,
}; };
// DEVICE_REGISTRY_PROPERTY: DevicePropertyLocationInformation (the const isn't re-exported at the
// wdk_sys root; the value is stable WDM).
const DEVICE_PROPERTY_LOCATION_INFORMATION: i32 = 10;
/// The pad index this device serves (which `pfxusb-shm-<index>` section to map). The host stamps it
/// into the device Location (`pszDeviceLocation`); the driver reads it in EvtDeviceAdd. With
/// `UmdfHostProcessSharing=ProcessSharingDisabled` (the INF) each pad gets its own WUDFHost, so this
/// static is per-pad — the basis for multi-pad.
static SHM_INDEX: AtomicU32 = AtomicU32::new(0);
// ---- NTSTATUS ---- // ---- NTSTATUS ----
const STATUS_SUCCESS: NTSTATUS = 0; const STATUS_SUCCESS: NTSTATUS = 0;
const STATUS_INVALID_DEVICE_REQUEST: NTSTATUS = 0xC000_0010u32 as NTSTATUS; const STATUS_INVALID_DEVICE_REQUEST: NTSTATUS = 0xC000_0010u32 as NTSTATUS;
const STATUS_INVALID_BUFFER_SIZE: NTSTATUS = 0xC000_0206u32 as NTSTATUS;
#[inline]
fn nt_success(s: NTSTATUS) -> bool {
s >= 0
}
// GUID_DEVINTERFACE_XUSB {EC87F1E3-C13B-4100-B5F7-8B84D54260CB} — what xinput1_4 enumerates + opens. // GUID_DEVINTERFACE_XUSB {EC87F1E3-C13B-4100-B5F7-8B84D54260CB} — what xinput1_4 enumerates + opens.
const GUID_DEVINTERFACE_XUSB: GUID = GUID { const GUID_DEVINTERFACE_XUSB: GUID = GUID {
@@ -70,27 +67,46 @@ const XUSB_VERSION: u16 = 0x0103;
const WdfIoQueueDispatchParallel: i32 = 2; const WdfIoQueueDispatchParallel: i32 = 2;
const WdfUseDefault: i32 = 2; // WDF_TRI_STATE const WdfUseDefault: i32 = 2; // WDF_TRI_STATE
// ---- shared-memory layout (host ↔ driver), must match pf_driver_proto::gamepad::XusbShm ---- // ---- the sealed host channel: layouts + offsets from pf_driver_proto (drift = compile error) ----
// magic u32 @0 ("PFXU"); packet u32 @4 (host bumps on state change → dwPacketNumber); the XUSB_REPORT const SHM_MAGIC: u32 = pf_driver_proto::gamepad::XUSB_MAGIC; // "PFXU"
// payload @8: wButtons u16 @8, bLeftTrigger @10, bRightTrigger @11, sThumbLX i16 @12, LY @14, RX @16, const SHM_SIZE: usize = core::mem::size_of::<XusbShm>();
// RY @18; rumble_seq u32 @24 (driver bumps on SET_STATE); rumble large @28, small @29; const GAMEPAD_PROTO_VERSION: u32 = pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION;
// driver_proto u32 @32 (we stamp GAMEPAD_PROTO_VERSION = attach signal for the host's health check);
// driver_heartbeat u32 @36 (we bump per serviced IOCTL = the game-visible polling path moves).
const FILE_MAP_RW: u32 = 0x0002 | 0x0004;
const SHM_MAGIC: u32 = 0x5558_4650; // "PFXU" little-endian
const SHM_SIZE: usize = 64;
const GAMEPAD_PROTO_VERSION: u32 = 1; // must match pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION
unsafe extern "system" { // XusbShm field offsets (host writes state, we answer XInput; we write rumble + health marks).
fn OpenFileMappingW(access: u32, inherit: i32, name: *const u16) -> *mut c_void; const OFF_PACKET: usize = core::mem::offset_of!(XusbShm, packet);
fn MapViewOfFile(h: *mut c_void, access: u32, hi: u32, lo: u32, len: usize) -> *mut c_void; const OFF_BUTTONS: usize = core::mem::offset_of!(XusbShm, buttons);
fn UnmapViewOfFile(addr: *const c_void) -> i32; const OFF_LT: usize = core::mem::offset_of!(XusbShm, left_trigger);
fn CloseHandle(h: *mut c_void) -> i32; const OFF_RT: usize = core::mem::offset_of!(XusbShm, right_trigger);
const OFF_LX: usize = core::mem::offset_of!(XusbShm, thumb_lx);
const OFF_LY: usize = core::mem::offset_of!(XusbShm, thumb_ly);
const OFF_RX: usize = core::mem::offset_of!(XusbShm, thumb_rx);
const OFF_RY: usize = core::mem::offset_of!(XusbShm, thumb_ry);
const OFF_RUMBLE_SEQ: usize = core::mem::offset_of!(XusbShm, rumble_seq);
const OFF_RUMBLE_LARGE: usize = core::mem::offset_of!(XusbShm, rumble_large);
const OFF_RUMBLE_SMALL: usize = core::mem::offset_of!(XusbShm, rumble_small);
const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(XusbShm, driver_proto);
const OFF_DRIVER_HEARTBEAT: usize = core::mem::offset_of!(XusbShm, driver_heartbeat);
const OFF_PAD_INDEX: usize = core::mem::offset_of!(XusbShm, pad_index);
/// The sealed-channel client (per-pad: `ProcessSharingDisabled` gives each pad its own WUDFHost, so
/// this static is per-pad). All shared-memory access + the bootstrap handshake live in `pf_umdf_util`.
static CHANNEL: ChannelClient = ChannelClient::new();
/// This pad's channel config (magic/size/pad_index offset + our logger).
fn channel_cfg() -> ChannelConfig {
ChannelConfig {
tag: "pf-xusb",
boot_name_prefix: "Global\\pfxusb-boot-",
data_magic: SHM_MAGIC,
data_size: SHM_SIZE,
pad_index_off: OFF_PAD_INDEX,
log,
}
} }
fn log(s: &str) { 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 null-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;
@@ -110,11 +126,11 @@ pub unsafe extern "system" fn driver_entry(
registry_path: PCUNICODE_STRING, registry_path: PCUNICODE_STRING,
) -> NTSTATUS { ) -> NTSTATUS {
log("[pf-xusb] DriverEntry"); log("[pf-xusb] DriverEntry");
// SAFETY: zeroed config then Size + callback set. // SAFETY: a zeroed WDF_DRIVER_CONFIG is a valid all-null config; we then set Size + the callback.
let mut config: WDF_DRIVER_CONFIG = unsafe { core::mem::zeroed() }; 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(evt_device_add); config.EvtDriverDeviceAdd = Some(evt_device_add);
// SAFETY: all pointers valid; provided by the loader. // SAFETY: `driver`/`registry_path` are the loader-provided pointers; the config is valid.
unsafe { unsafe {
call_unsafe_wdf_function_binding!( call_unsafe_wdf_function_binding!(
WdfDriverCreate, WdfDriverCreate,
@@ -127,56 +143,11 @@ pub unsafe extern "system" fn driver_entry(
} }
} }
/// Read the pad index the host stamped into the device Location (`pszDeviceLocation`), a NUL-terminated
/// UTF-16 decimal string. Defaults to 0 (single-pad) if absent.
fn query_shm_index(device: WDFDEVICE) -> u32 {
let mut mem: WDFMEMORY = core::ptr::null_mut();
// SAFETY: device valid; property = LocationInformation; pool ignored in UMDF; mem receives the handle.
let st = unsafe {
call_unsafe_wdf_function_binding!(
WdfDeviceAllocAndQueryProperty,
device,
DEVICE_PROPERTY_LOCATION_INFORMATION,
0,
WDF_NO_OBJECT_ATTRIBUTES,
&mut mem
)
};
if !nt_success(st) || mem.is_null() {
return 0;
}
let mut len: usize = 0;
// SAFETY: mem valid.
let buf = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, mem, &mut len) }
as *const u16;
if buf.is_null() {
return 0;
}
let mut idx: u32 = 0;
let mut any = false;
for i in 0..(len / 2).min(8) {
// SAFETY: buf valid for len bytes; i < len/2.
let c = unsafe { *buf.add(i) };
if c == 0 {
break;
}
if (0x30..=0x39).contains(&c) {
idx = idx.wrapping_mul(10).wrapping_add((c - 0x30) as u32);
any = true;
}
}
if any {
idx
} else {
0
}
}
extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INIT) -> NTSTATUS { extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INIT) -> NTSTATUS {
log("[pf-xusb] EvtDeviceAdd"); log("[pf-xusb] EvtDeviceAdd");
let mut device: WDFDEVICE = core::ptr::null_mut(); let mut device: WDFDEVICE = core::ptr::null_mut();
// SAFETY: device_init valid; attributes null; device receives the handle. // SAFETY: `device_init` is the framework-provided init; attributes null; `device` receives it.
let st = unsafe { let st = unsafe {
call_unsafe_wdf_function_binding!( call_unsafe_wdf_function_binding!(
WdfDeviceCreate, WdfDeviceCreate,
@@ -190,12 +161,14 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
return st; return st;
} }
let idx = query_shm_index(device); // SAFETY: `device` is the live device just created — the exact contract `query_location_index`
SHM_INDEX.store(idx, Ordering::Relaxed); // requires.
let idx = unsafe { wdf::query_location_index(device) };
CHANNEL.set_index(idx);
dbglog!("[pf-xusb] shm index = {idx}"); dbglog!("[pf-xusb] shm index = {idx}");
// Register the XUSB device interface (no reference string) — what xinput1_4 enumerates + opens. // Register the XUSB device interface (no reference string) — what xinput1_4 enumerates + opens.
// SAFETY: device valid; GUID static; null reference string. // SAFETY: `device` is live; the GUID is a static; null reference string.
let st = unsafe { let st = unsafe {
call_unsafe_wdf_function_binding!( call_unsafe_wdf_function_binding!(
WdfDeviceCreateDeviceInterface, WdfDeviceCreateDeviceInterface,
@@ -213,7 +186,7 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
} }
// Default parallel queue: all the XUSB IOCTLs land here. // Default parallel queue: all the XUSB IOCTLs land here.
// SAFETY: zeroed config then fields set; Size matches the struct. // SAFETY: a zeroed WDF_IO_QUEUE_CONFIG is valid; we then set Size + the fields we use.
let mut qcfg: WDF_IO_QUEUE_CONFIG = unsafe { core::mem::zeroed() }; let mut qcfg: WDF_IO_QUEUE_CONFIG = unsafe { core::mem::zeroed() };
qcfg.Size = core::mem::size_of::<WDF_IO_QUEUE_CONFIG>() as ULONG; qcfg.Size = core::mem::size_of::<WDF_IO_QUEUE_CONFIG>() as ULONG;
qcfg.DispatchType = WdfIoQueueDispatchParallel; qcfg.DispatchType = WdfIoQueueDispatchParallel;
@@ -222,7 +195,7 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
qcfg.EvtIoDeviceControl = Some(evt_io_device_control); qcfg.EvtIoDeviceControl = Some(evt_io_device_control);
qcfg.Settings.Parallel.NumberOfPresentedRequests = u32::MAX; qcfg.Settings.Parallel.NumberOfPresentedRequests = u32::MAX;
let mut queue: WDFQUEUE = core::ptr::null_mut(); let mut queue: WDFQUEUE = core::ptr::null_mut();
// SAFETY: device + config valid; attributes null; queue receives the handle. // SAFETY: `device` + `qcfg` are valid; attributes null; `queue` receives the handle.
let st = unsafe { let st = unsafe {
call_unsafe_wdf_function_binding!( call_unsafe_wdf_function_binding!(
WdfIoQueueCreate, WdfIoQueueCreate,
@@ -237,93 +210,69 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
return st; return st;
} }
// Tell the host we're alive on the section (its driver-attach health check keys off this). // Run the sealed-channel handshake on a worker (must NOT block EvtDeviceAdd): publish our pid in
touch_driver_marks(); // the bootstrap mailbox and poll for the host's delivered DATA handle, so the pad attaches (and
// the host's driver-attach health check goes green) even before any game polls XInput. Bounded;
// a later host (or a re-delivery) is still picked up by the per-IOCTL pump. This closure is 100%
// safe — the whole channel state machine lives in pf_umdf_util.
std::thread::spawn(|| {
let cfg = channel_cfg();
for _ in 0..500 {
if let Some(v) = CHANNEL.pump(&cfg) {
touch_driver_marks(v);
return;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
log(
"[pf-xusb] no sealed-channel delivery within 10s (host absent, or host/driver version mismatch — see above)",
);
});
log("[pf-xusb] device ready (XUSB interface registered)"); log("[pf-xusb] device ready (XUSB interface registered)");
STATUS_SUCCESS STATUS_SUCCESS
} }
// Open + map the host's shared section and run `f` against the mapped base if magic is valid, then /// The current controller state from the attached DATA section (zeros / neutral when unattached).
// unmap. Re-mapped per access (the host may recreate the section across restarts).
fn with_shm<F: FnOnce(*mut u8)>(f: F) {
let name: Vec<u16> = format!("Global\\pfxusb-shm-{}", SHM_INDEX.load(Ordering::Relaxed))
.encode_utf16()
.chain(std::iter::once(0))
.collect();
// SAFETY: name is a valid NUL-terminated UTF-16 string.
let h = unsafe { OpenFileMappingW(FILE_MAP_RW, 0, name.as_ptr()) };
if h.is_null() {
return;
}
// SAFETY: h is a valid mapping handle; map the whole section; the view keeps it alive.
let view = unsafe { MapViewOfFile(h, FILE_MAP_RW, 0, 0, SHM_SIZE) } as *mut u8;
unsafe { CloseHandle(h) };
if view.is_null() {
return;
}
// SAFETY: view points at >= 4 mapped bytes.
let magic = unsafe { core::ptr::read_unaligned(view as *const u32) };
if magic == SHM_MAGIC {
f(view);
}
// SAFETY: view came from MapViewOfFile.
unsafe { UnmapViewOfFile(view as *const c_void) };
}
/// The current controller state from shared memory (zeros / neutral if the host hasn't connected).
/// Returns `(dwPacketNumber, wButtons, lt, rt, lx, ly, rx, ry)`. /// Returns `(dwPacketNumber, wButtons, lt, rt, lx, ly, rx, ry)`.
fn read_state() -> (u32, u16, u8, u8, i16, i16, i16, i16) { fn read_state(data: Option<&MappedView>) -> (u32, u16, u8, u8, i16, i16, i16, i16) {
let mut out = (0u32, 0u16, 0u8, 0u8, 0i16, 0i16, 0i16, 0i16); match data {
with_shm(|v| { Some(v) => (
// SAFETY: v points at a mapped SHM_SIZE section with valid magic. v.read_u32(OFF_PACKET),
unsafe { v.read_u16(OFF_BUTTONS),
out.0 = core::ptr::read_unaligned(v.add(4) as *const u32); v.read_u8(OFF_LT),
out.1 = core::ptr::read_unaligned(v.add(8) as *const u16); v.read_u8(OFF_RT),
out.2 = *v.add(10); v.read_i16(OFF_LX),
out.3 = *v.add(11); v.read_i16(OFF_LY),
out.4 = core::ptr::read_unaligned(v.add(12) as *const i16); v.read_i16(OFF_RX),
out.5 = core::ptr::read_unaligned(v.add(14) as *const i16); v.read_i16(OFF_RY),
out.6 = core::ptr::read_unaligned(v.add(16) as *const i16); ),
out.7 = core::ptr::read_unaligned(v.add(18) as *const i16); None => (0, 0, 0, 0, 0, 0, 0, 0),
} }
});
out
} }
/// Stamp the driver health marks the host watches: `driver_proto` @32 (the attach signal, /// Stamp the driver health marks the host watches: `driver_proto` (the attach signal, idempotent)
/// idempotent) and `driver_heartbeat` @36 (+1). Called at device add and on every serviced IOCTL, /// and `driver_heartbeat` (+1). Called once the channel attaches and on every serviced IOCTL, so the
/// so the host can tell "driver bound and alive" apart from "driver package missing/failed to /// host can tell "driver bound and alive" apart from "driver package missing/failed to bind" and see
/// bind" and see the game-visible polling path advance. No-op until the host's section exists /// the game-visible polling path advance.
/// (with_shm re-opens per access, so a section created after we started still gets marked). fn touch_driver_marks(data: &MappedView) {
fn touch_driver_marks() { data.write_u32(OFF_DRIVER_PROTO, GAMEPAD_PROTO_VERSION);
with_shm(|v| { let hb = data.read_u32(OFF_DRIVER_HEARTBEAT).wrapping_add(1);
// SAFETY: v points at a mapped SHM_SIZE section with valid magic; proto @32, heartbeat @36. data.write_u32(OFF_DRIVER_HEARTBEAT, hb);
unsafe {
core::ptr::write_unaligned(v.add(32) as *mut u32, GAMEPAD_PROTO_VERSION);
let hb = v.add(36) as *mut u32;
core::ptr::write_unaligned(hb, core::ptr::read_unaligned(hb).wrapping_add(1));
}
});
} }
/// Publish a game's rumble (from SET_STATE) into shared memory for the host to forward. /// Publish a game's rumble (from SET_STATE) into the DATA section for the host to forward.
fn publish_rumble(large: u8, small: u8) { fn publish_rumble(data: Option<&MappedView>, large: u8, small: u8) {
with_shm(|v| { let Some(v) = data else { return };
// SAFETY: v points at a mapped SHM_SIZE section; rumble_seq @24, large @28, small @29. v.write_u8(OFF_RUMBLE_LARGE, large);
unsafe { v.write_u8(OFF_RUMBLE_SMALL, small);
*v.add(28) = large; let seq = v.read_u32(OFF_RUMBLE_SEQ).wrapping_add(1);
*v.add(29) = small; v.write_u32(OFF_RUMBLE_SEQ, seq);
let seqp = v.add(24) as *mut u32;
let seq = core::ptr::read_unaligned(seqp).wrapping_add(1);
core::ptr::write_unaligned(seqp, seq);
}
});
} }
// Build the 29-byte GET_STATE buffer (the layout xinput1_4 parses). // Build the 29-byte GET_STATE buffer (the layout xinput1_4 parses).
fn build_get_state() -> [u8; 29] { fn build_get_state(data: Option<&MappedView>) -> [u8; 29] {
let (packet, buttons, lt, rt, lx, ly, rx, ry) = read_state(); let (packet, buttons, lt, rt, lx, ly, rx, ry) = read_state(data);
let mut s = [0u8; 29]; let mut s = [0u8; 29];
s[0..2].copy_from_slice(&XUSB_VERSION.to_le_bytes()); s[0..2].copy_from_slice(&XUSB_VERSION.to_le_bytes());
s[2] = 0x01; // device count s[2] = 0x01; // device count
@@ -374,11 +323,20 @@ extern "C" fn evt_io_device_control(
input_len: usize, input_len: usize,
ioctl: ULONG, ioctl: ULONG,
) { ) {
// Health marks first: attach signal + heartbeat (also covers a section the host created after // SAFETY: `request` is the live request for THIS EvtIoDeviceControl invocation — exactly the
// this device started — the marks land on the next XInput poll). // contract `Request::new` requires. From here everything is safe (the token owns completion).
touch_driver_marks(); let request = unsafe { Request::new(request) };
// Sealed-channel pump + health marks first: adopt a (late) delivery, detach when the host's
// mailbox is gone, and stamp the attach/heartbeat marks the host watches (also covers a host
// started after this device — the pump attaches on the next XInput poll).
let data = CHANNEL.pump(&channel_cfg());
if let Some(v) = data {
touch_driver_marks(v);
}
let status: NTSTATUS = match ioctl { let status: NTSTATUS = match ioctl {
IOCTL_XUSB_GET_INFORMATION => copy_to_output(request, &build_information()), IOCTL_XUSB_GET_INFORMATION => request.copy_to_output(&build_information()),
IOCTL_XUSB_GET_INFORMATION_EX => { IOCTL_XUSB_GET_INFORMATION_EX => {
let mut ex = [0u8; 64]; let mut ex = [0u8; 64];
ex[0..2].copy_from_slice(&XUSB_VERSION.to_le_bytes()); ex[0..2].copy_from_slice(&XUSB_VERSION.to_le_bytes());
@@ -387,21 +345,19 @@ extern "C" fn evt_io_device_control(
ex[8..10].copy_from_slice(&XUSB_VID.to_le_bytes()); ex[8..10].copy_from_slice(&XUSB_VID.to_le_bytes());
ex[10..12].copy_from_slice(&XUSB_PID.to_le_bytes()); ex[10..12].copy_from_slice(&XUSB_PID.to_le_bytes());
let n = output_len.min(64); let n = output_len.min(64);
copy_to_output(request, &ex[..n]) request.copy_to_output(&ex[..n])
} }
IOCTL_XUSB_GET_CAPABILITIES => { IOCTL_XUSB_GET_CAPABILITIES => {
if output_len >= 36 { if output_len >= 36 {
copy_to_output(request, &build_caps_v2()) request.copy_to_output(&build_caps_v2())
} else { } else {
copy_to_output(request, &CAPS_V1) request.copy_to_output(&CAPS_V1)
} }
} }
IOCTL_XUSB_GET_STATE => copy_to_output(request, &build_get_state()), IOCTL_XUSB_GET_STATE => request.copy_to_output(&build_get_state(data)),
IOCTL_XUSB_GET_LED_STATE => copy_to_output(request, &[0x00, 0x00, 0x06]), IOCTL_XUSB_GET_LED_STATE => request.copy_to_output(&[0x00, 0x00, 0x06]),
IOCTL_XUSB_GET_BATTERY_INFORMATION => { IOCTL_XUSB_GET_BATTERY_INFORMATION => request.copy_to_output(&[0x00, 0x01, 0x03, 0x00]),
copy_to_output(request, &[0x00, 0x01, 0x03, 0x00]) IOCTL_XUSB_SET_STATE => on_set_state(&request, data),
}
IOCTL_XUSB_SET_STATE => on_set_state(request),
IOCTL_XUSB_POWER_DOWN | IOCTL_XUSB_GET_XINPUT_MANAGEMENT_DRIVER => STATUS_SUCCESS, IOCTL_XUSB_POWER_DOWN | IOCTL_XUSB_GET_XINPUT_MANAGEMENT_DRIVER => STATUS_SUCCESS,
// Decline the async waits → xinput1_4 falls back to synchronous GET_STATE polling. // Decline the async waits → xinput1_4 falls back to synchronous GET_STATE polling.
IOCTL_XUSB_WAIT_GUIDE_BUTTON | IOCTL_XUSB_WAIT_FOR_INPUT => STATUS_INVALID_DEVICE_REQUEST, IOCTL_XUSB_WAIT_GUIDE_BUTTON | IOCTL_XUSB_WAIT_FOR_INPUT => STATUS_INVALID_DEVICE_REQUEST,
@@ -410,30 +366,18 @@ extern "C" fn evt_io_device_control(
STATUS_INVALID_DEVICE_REQUEST STATUS_INVALID_DEVICE_REQUEST
} }
}; };
// SAFETY: request valid and not forwarded. request.complete(status);
unsafe { call_unsafe_wdf_function_binding!(WdfRequestComplete, request, status) };
} }
// SET_STATE: the rumble packet. Classic xusb22 layout is small; the motor bytes sit near the end. // SET_STATE: the rumble packet. Classic xusb22 layout is small; the motor bytes sit near the end.
// We publish a best-effort (large = byte 3, small = byte 4 for the 5-byte form) and log the raw bytes // We publish a best-effort (large = byte 2, small = byte 3 for the 5-byte form) and log the raw bytes
// so the exact offsets can be confirmed against a real pad. // so the exact offsets can be confirmed against a real pad.
fn on_set_state(request: WDFREQUEST) -> NTSTATUS { fn on_set_state(request: &Request, data: Option<&MappedView>) -> NTSTATUS {
let mut inmem: WDFMEMORY = core::ptr::null_mut(); if let Ok((bytes, len)) = request.input_bytes(8)
// SAFETY: request valid. && len >= 2
let st = unsafe { {
call_unsafe_wdf_function_binding!(WdfRequestRetrieveInputMemory, request, &mut inmem)
};
if nt_success(st) {
let mut len: usize = 0;
// SAFETY: inmem valid.
let p = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, inmem, &mut len) }
as *const u8;
if !p.is_null() && len >= 2 {
let n = len.min(8);
// SAFETY: p valid for len bytes; read at most n.
let bytes = unsafe { core::slice::from_raw_parts(p, n) };
let mut hex = String::new(); let mut hex = String::new();
for b in bytes { for b in &bytes {
hex.push_str(&format!("{b:02x} ")); hex.push_str(&format!("{b:02x} "));
} }
dbglog!("[pf-xusb] SET_STATE len={len} data: {hex}"); dbglog!("[pf-xusb] SET_STATE len={len} data: {hex}");
@@ -441,47 +385,10 @@ fn on_set_state(request: WDFREQUEST) -> NTSTATUS {
// (large/low-freq at [2], small/high-freq at [3]); 0x01 = player-LED set (ignored). // (large/low-freq at [2], small/high-freq at [3]); 0x01 = player-LED set (ignored).
// 4-byte = raw XINPUT_VIBRATION → the two motor hi bytes. // 4-byte = raw XINPUT_VIBRATION → the two motor hi bytes.
if len >= 5 && bytes[4] == 0x02 { if len >= 5 && bytes[4] == 0x02 {
publish_rumble(bytes[2], bytes[3]); publish_rumble(data, bytes[2], bytes[3]);
} else if len == 4 { } else if len == 4 {
publish_rumble(bytes[1], bytes[3]); publish_rumble(data, bytes[1], bytes[3]);
}
} }
} }
STATUS_SUCCESS STATUS_SUCCESS
} }
// Copy `src` into the request's (buffered) output buffer and set the completed byte count.
fn copy_to_output(request: WDFREQUEST, src: &[u8]) -> NTSTATUS {
let mut mem: WDFMEMORY = core::ptr::null_mut();
// SAFETY: request valid; mem receives the memory handle.
let st = unsafe {
call_unsafe_wdf_function_binding!(WdfRequestRetrieveOutputMemory, request, &mut mem)
};
if !nt_success(st) {
return st;
}
let mut outlen: usize = 0;
// SAFETY: mem valid; outlen receives the buffer size.
let _ = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, mem, &mut outlen) };
if outlen < src.len() {
return STATUS_INVALID_BUFFER_SIZE;
}
// SAFETY: mem valid; src is a valid buffer of src.len() bytes.
let st = unsafe {
call_unsafe_wdf_function_binding!(
WdfMemoryCopyFromBuffer,
mem,
0usize,
src.as_ptr() as *mut c_void,
src.len()
)
};
if !nt_success(st) {
return st;
}
// SAFETY: request valid.
unsafe {
call_unsafe_wdf_function_binding!(WdfRequestSetInformation, request, src.len() as u64)
};
STATUS_SUCCESS
}
+10 -1
View File
@@ -10,8 +10,10 @@
//! 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. // P0 lint (audit §8): require explicit `unsafe {}` blocks inside `unsafe fn`s + a `// SAFETY:` proof on
// each (this crate is the IddCx DDI dispatch layer — inherently unsafe, so audited, not unsafe-free).
#![deny(unsafe_op_in_unsafe_fn)] #![deny(unsafe_op_in_unsafe_fn)]
#![deny(clippy::undocumented_unsafe_blocks)]
pub use wdk_sys::iddcx; pub use wdk_sys::iddcx;
@@ -36,6 +38,7 @@ unsafe fn ddi<T: Copy>(index: i32) -> T {
let table = (&raw const iddcx::IddFunctions).cast::<iddcx::PFN_IDD_CX>(); let table = (&raw const iddcx::IddFunctions).cast::<iddcx::PFN_IDD_CX>();
// SAFETY: `index` is a valid IddCx table slot; the slot holds a `PFN_*` whose layout is `T`. // SAFETY: `index` is a valid IddCx table slot; the slot holds a `PFN_*` whose layout is `T`.
let slot = unsafe { table.add(index as usize) }; let slot = unsafe { table.add(index as usize) };
// SAFETY: `slot` points at the `index`th (in-bounds) populated table entry, a `PFN_*` of layout `T`.
unsafe { slot.cast::<T>().read() } unsafe { slot.cast::<T>().read() }
} }
@@ -62,7 +65,10 @@ macro_rules! iddcx_ddi {
/// Call only after the driver is loaded by IddCx; pointers must satisfy the IddCx contract. /// Call only after the driver is loaded by IddCx; pointers must satisfy the IddCx contract.
#[inline] #[inline]
pub unsafe fn $name( $( $arg: $aty ),* ) -> NTSTATUS { pub unsafe fn $name( $( $arg: $aty ),* ) -> NTSTATUS {
// SAFETY: `$idx`/`$pfn` are the matched IddCx table index + PFN type (pinned by this macro
// invocation), and the table is populated once the driver is loaded (this fn's contract).
let f: iddcx::$pfn = unsafe { ddi(iddcx::_IDDFUNCENUM::$idx) }; let f: iddcx::$pfn = unsafe { ddi(iddcx::_IDDFUNCENUM::$idx) };
// SAFETY: only reads the stub-provided globals pointer; valid post-load per the contract.
let g = unsafe { globals() }; let g = unsafe { globals() };
// SAFETY: dispatching a populated DDI with the stub globals and caller-valid args. // SAFETY: dispatching a populated DDI with the stub globals and caller-valid args.
unsafe { (f.unwrap())(g, $( $arg ),* ) } unsafe { (f.unwrap())(g, $( $arg ),* ) }
@@ -79,7 +85,10 @@ macro_rules! iddcx_ddi {
/// Call only after the driver is loaded by IddCx; pointers must satisfy the IddCx contract. /// Call only after the driver is loaded by IddCx; pointers must satisfy the IddCx contract.
#[inline] #[inline]
pub unsafe fn $name( $( $arg: $aty ),* ) { pub unsafe fn $name( $( $arg: $aty ),* ) {
// SAFETY: `$idx`/`$pfn` are the matched IddCx table index + PFN type (pinned by this macro
// invocation), and the table is populated once the driver is loaded (this fn's contract).
let f: iddcx::$pfn = unsafe { ddi(iddcx::_IDDFUNCENUM::$idx) }; let f: iddcx::$pfn = unsafe { ddi(iddcx::_IDDFUNCENUM::$idx) };
// SAFETY: only reads the stub-provided globals pointer; valid post-load per the contract.
let g = unsafe { globals() }; let g = unsafe { globals() };
// SAFETY: dispatching a populated DDI with the stub globals and caller-valid args. // SAFETY: dispatching a populated DDI with the stub globals and caller-valid args.
unsafe { (f.unwrap())(g, $( $arg ),* ) } unsafe { (f.unwrap())(g, $( $arg ),* ) }
@@ -1,57 +0,0 @@
<#
.SYNOPSIS
Generate the NVENC import library (nvencodeapi.lib) into -OutDir, so the host links with
`--features nvenc` on a box that has no NVIDIA Video Codec SDK and no GPU.
.DESCRIPTION
The host links against nvencodeapi.lib (crates/punktfunk-host/build.rs). That import lib is just
a link-time stub for two exports of nvEncodeAPI64.dll (the real DLL ships with the NVIDIA driver
and resolves at runtime). We synthesise it from nvenc.def:
1. llvm-dlltool — preferred; LLVM is on the CI runner PATH (C:\Program Files\LLVM\bin) and this
works without a Visual Studio developer shell.
2. MSVC lib.exe — fallback; located via vswhere (no vcvars needed).
Point PUNKTFUNK_NVENC_LIB_DIR at -OutDir before `cargo build --features nvenc`.
.EXAMPLE
pwsh -File gen-nvenc-importlib.ps1 -OutDir C:\t\nvenc
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$OutDir,
[string]$DefPath = (Join-Path $PSScriptRoot 'nvenc.def')
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$PSNativeCommandUseErrorActionPreference = $false # check $LASTEXITCODE ourselves (pwsh 7.4 safe)
if (-not (Test-Path $DefPath)) { throw "module-definition file not found: $DefPath" }
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
$out = Join-Path $OutDir 'nvencodeapi.lib'
# 1) llvm-dlltool (preferred) ------------------------------------------------------------------
$dlltool = Get-Command llvm-dlltool -ErrorAction SilentlyContinue
if ($dlltool) {
Write-Host "==> llvm-dlltool -> $out"
& $dlltool.Source -m i386:x86-64 -d $DefPath -D nvEncodeAPI64.dll -l $out
if ($LASTEXITCODE -ne 0) { throw "llvm-dlltool failed ($LASTEXITCODE)" }
Write-Host " ok ($((Get-Item $out).Length) bytes)"
return
}
# 2) MSVC lib.exe via vswhere (fallback) -------------------------------------------------------
$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
if (Test-Path $vswhere) {
$lib = & $vswhere -latest -prerelease -products * -find 'VC\Tools\MSVC\**\bin\Hostx64\x64\lib.exe' |
Select-Object -First 1
if ($lib -and (Test-Path $lib)) {
Write-Host "==> lib.exe -> $out"
& $lib "/def:$DefPath" /machine:x64 "/out:$out"
if ($LASTEXITCODE -ne 0) { throw "lib.exe failed ($LASTEXITCODE)" }
Write-Host " ok ($((Get-Item $out).Length) bytes)"
return
}
}
throw "neither llvm-dlltool (LLVM bin on PATH) nor MSVC lib.exe (via vswhere) was found to build $out"
-14
View File
@@ -1,14 +0,0 @@
; Module-definition file for the NVENC import library the host links against with `--features nvenc`.
;
; The real entry points live in nvEncodeAPI64.dll, which ships with the NVIDIA driver. At LINK time
; the host only needs an import library exporting these two symbols (see crates/punktfunk-host/build.rs:
; it emits `cargo:rustc-link-lib=dylib=nvencodeapi` and searches PUNKTFUNK_NVENC_LIB_DIR). No GPU,
; driver, or NVIDIA Video Codec SDK is required to BUILD only to run, where the DLL resolves from
; the installed driver. Generate nvencodeapi.lib from this file with gen-nvenc-importlib.ps1.
;
; The LIBRARY line names the DLL the import records point at required for MSVC `lib.exe /def`
; (without it the import name would default to "nvenc.dll"). llvm-dlltool takes the name from `-D`.
LIBRARY nvEncodeAPI64.dll
EXPORTS
NvEncodeAPICreateInstance
NvEncodeAPIGetMaxSupportedVersion
+5 -2
View File
@@ -42,6 +42,8 @@ $here = Split-Path -Parent $MyInvocation.MyCommand.Path
$iss = Join-Path $here 'punktfunk-host.iss' $iss = Join-Path $here 'punktfunk-host.iss'
$exe = Join-Path $TargetDir 'punktfunk-host.exe' $exe = Join-Path $TargetDir 'punktfunk-host.exe'
if (-not (Test-Path $exe)) { throw "missing build artifact 'punktfunk-host.exe' in $TargetDir (did 'cargo build --release -p punktfunk-host --features nvenc' run?)" } if (-not (Test-Path $exe)) { throw "missing build artifact 'punktfunk-host.exe' in $TargetDir (did 'cargo build --release -p punktfunk-host --features nvenc' run?)" }
$trayExe = Join-Path $TargetDir 'punktfunk-tray.exe'
if (-not (Test-Path $trayExe)) { throw "missing build artifact 'punktfunk-tray.exe' in $TargetDir (did 'cargo build --release -p punktfunk-tray' run?)" }
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
# --- locate ISCC (Inno Setup) + signtool (Windows SDK) --------------------------------------- # --- locate ISCC (Inno Setup) + signtool (Windows SDK) ---------------------------------------
@@ -110,14 +112,15 @@ function Sign-File([string]$Path) {
} }
} }
# --- sign the inner exe before it's packed ---------------------------------------------------- # --- sign the inner exes before they're packed -------------------------------------------------
Sign-File $exe Sign-File $exe
Sign-File $trayExe
# --- resolve + validate the installer's source files ------------------------------------------ # --- resolve + validate the installer's source files ------------------------------------------
$repoRoot = (Resolve-Path (Join-Path $here '..\..')).Path $repoRoot = (Resolve-Path (Join-Path $here '..\..')).Path
$hostEnvSrc = Join-Path $repoRoot 'scripts\windows\host.env.example' $hostEnvSrc = Join-Path $repoRoot 'scripts\windows\host.env.example'
$readmeSrc = Join-Path $here 'README.md' $readmeSrc = Join-Path $here 'README.md'
foreach ($p in @($exe, $hostEnvSrc, $readmeSrc, $iss)) { foreach ($p in @($exe, $trayExe, $hostEnvSrc, $readmeSrc, $iss)) {
if (-not (Test-Path -LiteralPath $p)) { throw "installer source file missing: $p" } if (-not (Test-Path -LiteralPath $p)) { throw "installer source file missing: $p" }
} }
+55 -10
View File
@@ -85,7 +85,12 @@ DefaultGroupName=punktfunk
DisableProgramGroupPage=yes DisableProgramGroupPage=yes
UsePreviousAppDir=yes UsePreviousAppDir=yes
PrivilegesRequired=admin PrivilegesRequired=admin
MinVersion=10.0 ; HARD floor: Windows 11 22H2 (build 22621). The pf-vdisplay driver is built against IddCx 1.10
; (HDR *2 DDIs + FP16 caps, no runtime downgrade) — on anything older (all of Windows 10 incl.
; LTSC, Windows 11 21H2) the driver package installs but the device fails to start with Code 10
; STATUS_DEVICE_POWER_FAILURE, and the host can't stream. Gate the install instead; the message
; is customized in [Messages] below.
MinVersion=10.0.22621
ArchitecturesAllowed=x64 ArchitecturesAllowed=x64
ArchitecturesInstallIn64BitMode=x64 ArchitecturesInstallIn64BitMode=x64
OutputDir={#OutputDir} OutputDir={#OutputDir}
@@ -113,6 +118,12 @@ UninstallDisplayIcon={app}\punktfunk.ico
[Languages] [Languages]
Name: "english"; MessagesFile: "compiler:Default.isl" Name: "english"; MessagesFile: "compiler:Default.isl"
[Messages]
; Shown when MinVersion rejects the OS — name the actual requirement instead of Inno's generic
; "requires Windows version 10.0.22621" (users on Windows 10 LTSC hit this; see the pf-vdisplay
; IddCx 1.10 note at MinVersion above).
WinVersionTooLowError=punktfunk host requires Windows 11 22H2 (build 22621) or newer.%n%nIts virtual display driver needs the IddCx 1.10 framework, which is not available on older Windows — including all editions of Windows 10 (LTSC too) and Windows 11 21H2.
[Tasks] [Tasks]
#ifdef WithDriver #ifdef WithDriver
Name: "installdriver"; Description: "Install the pf-vdisplay virtual display driver (required for native-resolution streaming)" Name: "installdriver"; Description: "Install the pf-vdisplay virtual display driver (required for native-resolution streaming)"
@@ -134,9 +145,16 @@ Name: "installhdrlayer"; Description: "Install the HDR Vulkan layer (lets Vulkan
; host (the common Windows setup); unchecked = the secure native-only host (punktfunk clients only). ; host (the common Windows setup); unchecked = the secure native-only host (punktfunk clients only).
Name: "gamestream"; Description: "Enable GameStream (Moonlight) compatibility - lets stock Moonlight clients connect (uses legacy plain-HTTP pairing; for trusted LANs)" Name: "gamestream"; Description: "Enable GameStream (Moonlight) compatibility - lets stock Moonlight clients connect (uses legacy plain-HTTP pairing; for trusted LANs)"
Name: "startservice"; Description: "Start the punktfunk host service now (also starts on every boot)" Name: "startservice"; Description: "Start the punktfunk host service now (also starts on every boot)"
; The per-user status tray (punktfunk-tray.exe): shows running/stopped/failed at a glance and
; offers open-console / start / stop / restart without a terminal. HKLM Run = every user who signs
; in to this host box gets one (each session keeps exactly one via a Local\ mutex).
Name: "trayicon"; Description: "Show the punktfunk status icon in the notification area at sign-in"
[Files] [Files]
Source: "{#BinDir}\punktfunk-host.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "{#BinDir}\punktfunk-host.exe"; DestDir: "{app}"; Flags: ignoreversion
; The status tray companion (windows-subsystem, embeds its own icons). Installed unconditionally
; (small); only STARTED/registered when the trayicon task is selected.
Source: "{#BinDir}\punktfunk-tray.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#HostEnv}"; DestDir: "{app}"; Flags: ignoreversion Source: "{#HostEnv}"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#Readme}"; DestDir: "{app}"; DestName: "README.txt"; Flags: ignoreversion Source: "{#Readme}"; DestDir: "{app}"; DestName: "README.txt"; Flags: ignoreversion
; The branded icon, referenced by UninstallDisplayIcon (Apps & features shows it for the entry). ; The branded icon, referenced by UninstallDisplayIcon (Apps & features shows it for the entry).
@@ -184,6 +202,10 @@ Source: "{#VkLayerDir}\pf_vkhdr_layer.json"; DestDir: "{app}\vklayer"; Flags: ig
#endif #endif
[Registry] [Registry]
; Auto-start the status tray at sign-in (all users of this host box; uninsdeletevalue removes it
; with the app). Operators who moved --mgmt-bind can append --mgmt-addr/--mgmt-port here.
Root: HKLM64; Subkey: "SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; \
ValueName: "PunktfunkTray"; ValueData: """{app}\punktfunk-tray.exe"""; Flags: uninsdeletevalue; Tasks: trayicon
#ifdef WithVkLayer #ifdef WithVkLayer
; Register the HDR Vulkan implicit layer system-wide. The 64-bit Vulkan loader reads ; Register the HDR Vulkan implicit layer system-wide. The 64-bit Vulkan loader reads
; HKLM64\SOFTWARE\Khronos\Vulkan\ImplicitLayers; the value NAME is the manifest path and the DWORD ; HKLM64\SOFTWARE\Khronos\Vulkan\ImplicitLayers; the value NAME is the manifest path and the DWORD
@@ -222,12 +244,22 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "service start"; WorkingDir: "
#ifdef WithWeb #ifdef WithWeb
; Provision the console AFTER the host service is up (so the mgmt token exists): write the ACL'd ; Provision the console AFTER the host service is up (so the mgmt token exists): write the ACL'd
; login password, register the PunktfunkWeb scheduled task (boot, SYSTEM, restart-on-failure), ; login password, register the PunktfunkWeb scheduled task (boot, SYSTEM, restart-on-failure),
; open TCP 3000, and start it. {code:WebSetupParams} appends -PasswordFile only on a fresh install. ; open TCP 47992, and start it. {code:WebSetupParams} appends -PasswordFile only on a fresh install.
Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParams}"; WorkingDir: "{app}"; \ Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParams}"; WorkingDir: "{app}"; \
StatusMsg: "Setting up the punktfunk web console..."; Flags: runhidden waituntilterminated StatusMsg: "Setting up the punktfunk web console..."; Flags: runhidden waituntilterminated
#endif #endif
; Launch the status tray as the SIGNED-IN user (not the elevated install user) right away, so the
; icon appears without waiting for the next sign-in.
Filename: "{app}\punktfunk-tray.exe"; Flags: runasoriginaluser nowait skipifsilent; Tasks: trayicon
[UninstallRun] [UninstallRun]
; Quit the tray FIRST - it is this exe being deleted, so it must not be running. --quit closes the
; current session's instance (an elevated caller may message a medium-IL window; UIPI only blocks
; low->high); the taskkill then reaps instances in OTHER signed-in sessions. [UninstallRun] runs
; before file deletion, so a raced survivor only means a delete-on-reboot leftover, nothing worse.
; (runasoriginaluser is not valid in [UninstallRun] - both entries run elevated, which is fine.)
Filename: "{app}\punktfunk-tray.exe"; Parameters: "--quit"; Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkTrayQuit"
Filename: "{sys}\taskkill.exe"; Parameters: "/F /IM punktfunk-tray.exe"; Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkTrayKill"
Filename: "{app}\punktfunk-host.exe"; Parameters: "service uninstall"; Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkHostServiceUninstall" Filename: "{app}\punktfunk-host.exe"; Parameters: "service uninstall"; Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkHostServiceUninstall"
; Remove the punktfunk drivers we installed (pf-vdisplay devnode + driver package, then the gamepad ; Remove the punktfunk drivers we installed (pf-vdisplay devnode + driver package, then the gamepad
; driver packages). AFTER service uninstall so the host no longer holds the devices. Unconditional ; driver packages). AFTER service uninstall so the host no longer holds the devices. Unconditional
@@ -241,7 +273,7 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "driver uninstall --gamepad";
; Stop + remove the PunktfunkWeb task and its firewall rule (leaves %ProgramData%\punktfunk config, ; Stop + remove the PunktfunkWeb task and its firewall rule (leaves %ProgramData%\punktfunk config,
; like the host uninstall does). ; like the host uninstall does).
Filename: "powershell.exe"; \ Filename: "powershell.exe"; \
Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Stop-ScheduledTask -TaskName PunktfunkWeb -ErrorAction SilentlyContinue; Get-NetTCPConnection -LocalPort 3000 -State Listen -ErrorAction SilentlyContinue | ForEach-Object {{ Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Unregister-ScheduledTask -TaskName PunktfunkWeb -Confirm:$false -ErrorAction SilentlyContinue; Get-NetFirewallRule -DisplayName 'punktfunk web console (*' -ErrorAction SilentlyContinue | Remove-NetFirewallRule"""; \ Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Stop-ScheduledTask -TaskName PunktfunkWeb -ErrorAction SilentlyContinue; Get-NetTCPConnection -LocalPort 47992,3000 -State Listen -ErrorAction SilentlyContinue | ForEach-Object {{ Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Unregister-ScheduledTask -TaskName PunktfunkWeb -Confirm:$false -ErrorAction SilentlyContinue; Get-NetFirewallRule -DisplayName 'punktfunk web console (*' -ErrorAction SilentlyContinue | Remove-NetFirewallRule"""; \
Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkWebCleanup" Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkWebCleanup"
#endif #endif
@@ -300,7 +332,7 @@ begin
FreshWebInstall := not FileExists(WebPasswordPath); FreshWebInstall := not FileExists(WebPasswordPath);
WebPwPage := CreateInputQueryPage(wpSelectTasks, WebPwPage := CreateInputQueryPage(wpSelectTasks,
'Web console', 'Set the punktfunk web console login password', 'Web console', 'Set the punktfunk web console login password',
'The management console is served on http://this-computer:3000 and is login-gated. Keep the ' + 'The management console is served on https://this-computer:47992 and is login-gated. Keep the ' +
'secure password generated below (it is shown again on the final page) or enter your own - you ' + 'secure password generated below (it is shown again on the final page) or enter your own - you ' +
'can change it later in %ProgramData%\punktfunk\web-password.'); 'can change it later in %ProgramData%\punktfunk\web-password.');
WebPwPage.Add('Console password:', False); { visible, so the admin can read the generated default } WebPwPage.Add('Console password:', False); { visible, so the admin can read the generated default }
@@ -329,7 +361,7 @@ procedure CurPageChanged(CurPageID: Integer);
begin begin
if (CurPageID = wpFinished) and FreshWebInstall then if (CurPageID = wpFinished) and FreshWebInstall then
WizardForm.FinishedLabel.Caption := WizardForm.FinishedLabel.Caption + #13#10#13#10 + WizardForm.FinishedLabel.Caption := WizardForm.FinishedLabel.Caption + #13#10#13#10 +
'Web console: http://<this-PC-IP>:3000' + #13#10 + 'Web console: https://<this-PC-IP>:47992' + #13#10 +
'Login password: ' + Trim(WebPwPage.Values[0]); 'Login password: ' + Trim(WebPwPage.Values[0]);
end; end;
@@ -344,6 +376,17 @@ begin
end; end;
#endif #endif
{ On upgrade a running tray locks punktfunk-tray.exe - kill every session's instance so the copy
can overwrite it (the [Run] entry / next sign-in relaunches the new build). Best-effort; a fresh
install is a no-op. }
procedure StopTrays;
var
ResultCode: Integer;
begin
Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM punktfunk-tray.exe', '',
SW_HIDE, ewWaitUntilTerminated, ResultCode);
end;
{ On upgrade the running service locks punktfunk-host.exe (and the supervisor would respawn it from { On upgrade the running service locks punktfunk-host.exe (and the supervisor would respawn it from
the OLD binary), so stop it and WAIT for STOPPED before files are copied. Best-effort; a fresh the OLD binary), so stop it and WAIT for STOPPED before files are copied. Best-effort; a fresh
install is a no-op (the service doesn't exist yet). } install is a no-op (the service doesn't exist yet). }
@@ -361,10 +404,11 @@ begin
end; end;
#ifdef WithWeb #ifdef WithWeb
{ Stop a running web console + free :3000 BEFORE the file copy, so the old server doesn't lock { Stop a running web console + free its port BEFORE the file copy, so the old server doesn't lock
.output / web-run.cmd / bun.exe and the new task can bind. Killing the :3000 listener owner is .output / web-run.cmd / bun.exe and the new task can bind. Killing the listener owner is
runtime-agnostic (an early install may have run node, the current one runs bun). `web setup` runtime-agnostic (an early install may have run node on :3000, the current one runs bun on
repeats this idempotently after the copy. Best-effort; a fresh install is a no-op. } :47992 - sweep both). `web setup` repeats this idempotently after the copy. Best-effort; a
fresh install is a no-op. }
procedure StopWebConsole; procedure StopWebConsole;
var var
ResultCode: Integer; ResultCode: Integer;
@@ -373,7 +417,7 @@ begin
'-NoProfile -ExecutionPolicy Bypass -Command "' + '-NoProfile -ExecutionPolicy Bypass -Command "' +
'$ErrorActionPreference=''SilentlyContinue''; ' + '$ErrorActionPreference=''SilentlyContinue''; ' +
'Stop-ScheduledTask -TaskName PunktfunkWeb; ' + 'Stop-ScheduledTask -TaskName PunktfunkWeb; ' +
'Get-NetTCPConnection -LocalPort 3000 -State Listen | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }"', 'Get-NetTCPConnection -LocalPort 47992,3000 -State Listen | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }"',
'', SW_HIDE, ewWaitUntilTerminated, ResultCode); '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
end; end;
#endif #endif
@@ -383,6 +427,7 @@ begin
if CurStep = ssInstall then if CurStep = ssInstall then
begin begin
StopHostServiceAndWait; StopHostServiceAndWait;
StopTrays; { upgrade-safe: unlock punktfunk-tray.exe before the copy }
#ifdef WithWeb #ifdef WithWeb
StopWebConsole; { upgrade-safe: free :3000 + unlock the web files before the copy } StopWebConsole; { upgrade-safe: free :3000 + unlock the web files before the copy }
{ Stash the chosen password for `web setup` (fresh install only); the temp copy is auto-cleaned. } { Stash the chosen password for `web setup` (fresh install only); the temp copy is auto-cleaned. }
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Generate the punktfunk-tray status icons (committed, like the other branding assets).
Renders the brand mark — the two overlapping circles ("lens") from web's brand-mark.tsx, the
same geometry gen-branding.ps1 uses — with a status dot in the lower-right corner:
running colored mark + green dot
stopped grayscale mark + gray dot
error colored mark + red dot
degraded colored mark + amber dot (starting / running-but-status-unreachable)
streaming colored mark + bright-violet dot
Outputs (all checked in; re-run only when the brand or the palette changes):
packaging/windows/branding/punktfunk-tray-<state>.ico 16/20/24/32/48 px PNG-entry icos
(Vista+ format, same as punktfunk.ico)
packaging/linux/icons/hicolor/{22x22,48x48}/apps/punktfunk-tray[-<state>].png
(running is the unsuffixed base name)
Pure stdlib (zlib PNG writer, analytic 4x-supersampled rasterizer) so it runs on any dev box —
no PIL/ImageMagick/librsvg needed.
"""
import math
import struct
import zlib
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
# Brand-mark geometry in its 1000-unit viewbox (brand-mark.tsx; mirrors gen-branding.ps1).
R = 194.41
C1 = (403.037, 597.262) # light circle, behind
C2 = (597.8075, 402.8525) # deep circle, in front
BB_MIN = (C1[0] - R, C2[1] - R)
BB_MAX = (C2[0] + R, C1[1] + R)
MARK_CENTER = ((BB_MIN[0] + BB_MAX[0]) / 2, (BB_MIN[1] + BB_MAX[1]) / 2)
MARK_SPAN = BB_MAX[0] - BB_MIN[0] # the bbox is square
COL_LIGHT = (0xA7, 0x9F, 0xF8)
COL_DEEP = (0x6C, 0x5B, 0xF3)
COL_HI = (0xD2, 0xC9, 0xFB)
RING = (0x1C, 0x15, 0x30) # dot outline, the brand tile background
STATES = {
"running": {"dot": (0x2E, 0xCC, 0x71), "gray": False},
"stopped": {"dot": (0x8A, 0x8A, 0x8A), "gray": True},
"error": {"dot": (0xE7, 0x4C, 0x3C), "gray": False},
"degraded": {"dot": (0xF0, 0xA0, 0x30), "gray": False},
"streaming": {"dot": (0xB4, 0x4C, 0xF0), "gray": False},
}
def luma(c):
y = round(0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2])
return (y, y, y)
def render(size, dot_rgb, gray, ss=4):
"""RGBA rows, 4x supersampled: mark centered upper-left-ish, dot lower-right."""
n = size * ss
mark_c = (0.44 * n, 0.44 * n)
scale = (0.82 * n) / MARK_SPAN
dot_c = (0.76 * n, 0.76 * n)
dot_r = 0.21 * n
ring_r = dot_r + max(0.055 * n, 1.0 * ss)
c_light = luma(COL_LIGHT) if gray else COL_LIGHT
c_deep = luma(COL_DEEP) if gray else COL_DEEP
c_hi = luma(COL_HI) if gray else COL_HI
c1 = (mark_c[0] + (C1[0] - MARK_CENTER[0]) * scale, mark_c[1] + (C1[1] - MARK_CENTER[1]) * scale)
c2 = (mark_c[0] + (C2[0] - MARK_CENTER[0]) * scale, mark_c[1] + (C2[1] - MARK_CENTER[1]) * scale)
r = R * scale
rows = []
for y in range(size):
row = bytearray()
for x in range(size):
# Premultiplied accumulation over the ss×ss sample grid (no fringe on the rim).
ar = ag = ab = aa = 0.0
for sy in range(ss):
for sx in range(ss):
px = x * ss + sx + 0.5
py = y * ss + sy + 0.5
d1 = math.hypot(px - c1[0], py - c1[1])
d2 = math.hypot(px - c2[0], py - c2[1])
dd = math.hypot(px - dot_c[0], py - dot_c[1])
col = None
if dd < dot_r:
col = dot_rgb
elif dd < ring_r:
col = RING
elif d1 < r and d2 < r:
col = c_hi
elif d2 < r:
col = c_deep
elif d1 < r:
col = c_light
if col is not None:
ar += col[0]
ag += col[1]
ab += col[2]
aa += 255.0
samples = ss * ss
a = aa / samples
if a < 1.0:
row += b"\x00\x00\x00\x00"
else:
row += bytes(
(round(ar / aa * 255), round(ag / aa * 255), round(ab / aa * 255), round(a))
)
rows.append(bytes(row))
return rows
def png_bytes(size, rows):
def chunk(tag, data):
return (
struct.pack(">I", len(data))
+ tag
+ data
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
)
ihdr = struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0)
idat = zlib.compress(b"".join(b"\x00" + r for r in rows), 9)
return (
b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"")
)
def ico_bytes(pngs):
"""PNG-entry .ico (Vista+; the format punktfunk.ico already uses)."""
header = struct.pack("<HHH", 0, 1, len(pngs))
entries = b""
blobs = b""
offset = len(header) + 16 * len(pngs)
for size, png in pngs:
entries += struct.pack(
"<BBBBHHII", size if size < 256 else 0, size if size < 256 else 0, 0, 0, 1, 32, len(png), offset
)
blobs += png
offset += len(png)
return header + entries + blobs
def main():
ico_dir = REPO / "packaging/windows/branding"
for state, spec in STATES.items():
pngs = [
(s, png_bytes(s, render(s, spec["dot"], spec["gray"])))
for s in (16, 20, 24, 32, 48)
]
out = ico_dir / f"punktfunk-tray-{state}.ico"
out.write_bytes(ico_bytes(pngs))
print(f"wrote {out.relative_to(REPO)}")
for s in (22, 48):
name = "punktfunk-tray" if state == "running" else f"punktfunk-tray-{state}"
png_dir = REPO / f"packaging/linux/icons/hicolor/{s}x{s}/apps"
png_dir.mkdir(parents=True, exist_ok=True)
out = png_dir / f"{name}.png"
out.write_bytes(png_bytes(s, render(s, spec["dot"], spec["gray"])))
print(f"wrote {out.relative_to(REPO)}")
if __name__ == "__main__":
main()
+2 -2
View File
@@ -9,11 +9,11 @@ Helper scripts for the Windows host box (the RTX `.173` lab box, repo at
powershell -ExecutionPolicy Bypass -File scripts\windows\setup-build-env.ps1 powershell -ExecutionPolicy Bypass -File scripts\windows\setup-build-env.ps1
``` ```
Persists (Machine scope) the three vars the NVENC build needs: Persists (Machine scope) the vars the host build needs (NVENC itself needs none — its entry
points are runtime-loaded from the driver's `nvEncodeAPI64.dll`):
| var | value | why | | var | value | why |
| --- | --- | --- | | --- | --- | --- |
| `PUNKTFUNK_NVENC_LIB_DIR` | `C:\Users\Public\nvenc` | NVENC import lib (`nvencodeapi.lib`) |
| `LIBCLANG_PATH` | `C:\Program Files\LLVM\bin` | bindgen (`libclang.dll`) | | `LIBCLANG_PATH` | `C:\Program Files\LLVM\bin` | bindgen (`libclang.dll`) |
| `CMAKE_POLICY_VERSION_MINIMUM` | `3.5` | `audiopus_sys` / cmake crates | | `CMAKE_POLICY_VERSION_MINIMUM` | `3.5` | `audiopus_sys` / cmake crates |
+1 -1
View File
@@ -35,7 +35,7 @@ Set-Location $repo
# Load the persisted build env (Machine scope) into THIS process, so the build sees it even # Load the persisted build env (Machine scope) into THIS process, so the build sees it even
# if this shell was started before setup-build-env.ps1 ran (env is inherited at spawn time). # if this shell was started before setup-build-env.ps1 ran (env is inherited at spawn time).
foreach ($k in 'PUNKTFUNK_NVENC_LIB_DIR','LIBCLANG_PATH','CMAKE_POLICY_VERSION_MINIMUM') { foreach ($k in 'LIBCLANG_PATH','CMAKE_POLICY_VERSION_MINIMUM') {
$v = [Environment]::GetEnvironmentVariable($k, 'Machine') $v = [Environment]::GetEnvironmentVariable($k, 'Machine')
if ($v) { [Environment]::SetEnvironmentVariable($k, $v, 'Process'); Write-Host "env : $k=$v" } if ($v) { [Environment]::SetEnvironmentVariable($k, $v, 'Process'); Write-Host "env : $k=$v" }
else { Write-Warning "env $k not set (run setup-build-env.ps1)" } else { Write-Warning "env $k not set (run setup-build-env.ps1)" }

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