6 Commits

Author SHA1 Message Date
enricobuehler 6a501f484a ci(audit): ignore RUSTSEC-2023-0071 (rsa Marvin timing sidechannel)
ci / rust (push) Failing after 30s
apple / swift (push) Successful in 57s
ci / web (push) Successful in 38s
ci / docs-site (push) Successful in 1m11s
android / android (push) Successful in 3m34s
deb / build-publish (push) Successful in 2m18s
decky / build-publish (push) Successful in 21s
ci / bench (push) Successful in 4m37s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 44s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 22s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 48s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 45s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m17s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m28s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m12s
docker / deploy-docs (push) Successful in 22s
windows-host / package (push) Successful in 3m12s
cargo audit fails on the rsa "Marvin Attack" advisory, which has NO fixed release
(the constant-time rewrite is still unreleased upstream) and rsa is required for
GameStream/Moonlight pairing. The attack targets RSA *decryption* (PKCS#1 v1.5
padding oracle); the host uses rsa ONLY for PKCS#1 v1.5 signing/verifying
(gamestream/cert.rs + pairing.rs), never for decryption, so the vulnerable path is
not exercised. Add the documented .cargo/audit.toml ignore with the justification.

The 3 unmaintained warnings (audiopus_sys / paste / rustls-pemfile) are left visible
on purpose — `cargo audit` does not fail on them, and they carry a maintenance signal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 10:32:04 +00:00
enricobuehler 72eeedc4da feat(windows): AMD (AMF) + Intel (QSV) hardware encode on the Windows host
The Windows host was NVIDIA-only (NVENC) with an openh264 software fallback. Add
AMD AMF and Intel QSV via libavcodec — the Windows analogue of the Linux VAAPI
backend — so one installer serves all three GPU vendors.

- encode/ffmpeg_win.rs: new WinVendor{Amf,Qsv} encoder. System-memory NV12/P010
  readback (default, robust) + opt-in zero-copy D3D11 (PUNKTFUNK_ZEROCOPY: shares
  the capturer's ID3D11Device; AMF takes AV_PIX_FMT_D3D11, QSV derives a QSV frames
  ctx and maps) with a system fallback for the format-group mismatch the capturer's
  video-processor fallback can produce. HDR Main10 (P010 + BT.2020/PQ VUI; an
  Rgb10a2->P010 swscale covers the shader fallback).
- encode.rs: Codec::amf_name/qsv_name; open_video + windows_resolved_backend()
  resolve PUNKTFUNK_ENCODER=auto|nvenc|amf|qsv|sw via a DXGI adapter VendorId probe.
- capture/dxgi.rs: gpu_mode mirrors the resolved backend (D3D11 NV12/P010 for AMF/QSV).
- gamestream/serverinfo.rs: GPU-aware codec advertisement (windows_codec_support;
  AV1 gated to RDNA3+/Arc, like the VAAPI path).
- Cargo.toml: amf-qsv feature (optional ffmpeg-next in the windows target block).
- CI/installer: windows-host.yml sets FFMPEG_DIR + builds --features nvenc,amf-qsv;
  the Inno installer bundles the FFmpeg DLLs; host.env default nvenc -> auto.

CI-green target; AMF/QSV not yet on-glass validated (no AMD/Intel Windows box in the
lab) — NVENC stays live-validated. An adversarial-review pass caught + fixed real
FFI bugs (AV_PIX_FMT_P010 is a macro -> P010LE; windows-rs 0.62 GetImmediateContext/
GetDesc1 return Result; AV_HWFRAME_MAP_* is a bindgen enum with no BitOr).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 10:31:54 +00:00
enricobuehler fde438a1ed feat(gamepad): SwDeviceCreate per-session devnode (best-effort) + windows self-test
DualSenseWindowsManager now SwDeviceCreate's the pf_dualsense devnode per session
(SwDeviceClose on drop), matching the Linux UHID pad's lifecycle. It's best-effort:
SwDeviceCreate currently hits an unresolved E_INVALIDARG when a completion callback is
passed (an underscore in the enumerator name was a second cause, fixed by using
"punktfunk"), so on failure the host keeps the section + data plane and falls back to
an out-of-band devnode (installer/devgen) — see docs/windows-dualsense-scoping.md.

Add a `dualsense-windows-test` host CLI that drives the manager (create devnode + push
a frame + hold), used to validate the path. Live on the RTX box: the manager creates
the section + pushes report 0x01 and a devnode serves it to a HID read (b1=0xC0,
b8=0x28) — the host-side data plane works end to end.

cargo check + clippy -D warnings clean on x86_64-pc-windows-msvc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 21:34:00 +00:00
enricobuehler 01dc0b616c refactor(windows): trim the inert IOCTL channel from the DualSense driver
The host<->driver channel is the shared-memory section (hidclass blocks the device
stack and UMDF has no control device), so the first-attempt in-driver IOCTL channel
never fired. Remove it: the custom device interface, IOCTL_PFDS_SET_INPUT/GET_OUTPUT,
the output queue, and the on_set_input/complete_one_read/deliver_output helpers. The
driver keeps the HID handshake, the 8ms read timer fed from the shared section, and
on_output_report publishing the game's 0x02 to the section. Rebuilt + reloaded + the
channel still verifies both directions live on the RTX box.

Also list `pf_dualsense` as a second hardware id (alongside `root\pf_dualsense`) so the
host's SwDeviceCreate'd software device binds the same driver as a devgen one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 21:34:00 +00:00
enricobuehler 4a73102d48 feat(gamepad): virtual DualSense on the Windows host (UMDF shm channel)
Wire the Windows UMDF DualSense driver into the host as a real pad backend, so a
client that requests a DualSense gets a genuine one on a Windows host (instead of
folding to Xbox 360).

- Extract the transport-independent DualSense contract (DsState + from_gamepad,
  serialize_state, parse_ds_output, DUALSENSE_RDESC, feature blobs, DS_* consts)
  out of the Linux-only UHID backend into inject/dualsense_proto.rs, shared by both
  platforms; dualsense.rs is now just the /dev/uhid plumbing.
- Add inject/dualsense_windows.rs: DualSenseWindowsManager mirroring the Linux
  DualSenseManager (same new/handle/apply_rich/pump/heartbeat surface) over a
  DsWinPad that creates the Global\pfds-shm-<idx> section (CreateFileMappingW +
  SDDL D:(A;;GA;;;WD) so WUDFHost can open it), writes serialize_state -> input
  slot, polls output_seq -> parse_ds_output -> rumble/hidout callbacks.
- Un-gate the seam: PadBackend::DualSenseWindows arm; pick_gamepad gains a
  windows flag (DualSense honored on linux||windows; DS4/Xbox One stay Linux-only).

Verified: Linux cargo test gamepad_resolution_precedence + clippy clean; Windows
cargo check + clippy -D warnings clean (on the RTX box). Device lifecycle still
uses an out-of-band devnode (devgen/installer); SwDeviceCreate per session is next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:36:53 +00:00
enricobuehler aa159df33f feat(windows): Rust UMDF virtual DualSense driver + shared-memory host channel
A self-authored UMDF2 HID minidriver (packaging/windows/dualsense-driver) that
presents a virtual Sony DualSense (VID 054C/PID 0CE6) on Windows — adaptive
triggers / lightbar / rumble that ViGEm structurally cannot deliver.

Validated live on an RTX box (Win11 25H2, Secure Boot ON): the self-signed driver
loads, Steam recognizes it as a genuine DualSense, and a game's 0x02 output report
reaches the driver. The host<->driver channel is a named shared-memory section
(Global\pfds-shm-<idx>) the host creates and the driver maps from its timer: input
report 0x01 host->driver, output report 0x02 driver->host — input and output proven
both directions live. This bypasses hidclass, which gates both a custom device
interface and custom IOCTLs on the HID node, and UMDF has no control device.

Built in Rust on microsoft/windows-drivers-rs. The load wall was the PE
FORCE_INTEGRITY bit that wdk-build sets via /INTEGRITYCHECK (forces a CI-trusted
page-hash signature a self-signed cert cannot satisfy) — cleared post-build. See
packaging/windows/dualsense-driver/README.md for the build/sign/install recipe.

Deferred: SwDeviceCreate per-session device lifecycle; removing the inert in-driver
IOCTL-channel code; full on-glass session test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:36:39 +00:00
27 changed files with 3719 additions and 653 deletions
+20
View File
@@ -0,0 +1,20 @@
# cargo-audit configuration — consumed by `.gitea/workflows/audit.yml` (`cargo audit`).
#
# Silence only advisories that are KNOWN-UNFIXABLE and either not applicable to how we use the crate
# or an accepted, documented risk. Keep this list TIGHT and justify every entry — an ignore here
# means the audit job stops flagging it, so the reasoning must hold up.
#
# NOTE: `cargo audit` (no `--deny warnings`) fails only on *vulnerabilities*, not on the
# `unmaintained` warnings (audiopus_sys / paste / rustls-pemfile). Those are left visible on purpose
# so we keep getting the maintenance signal — they do not fail CI.
[advisories]
ignore = [
# rsa "Marvin Attack" — a timing sidechannel in RSA *decryption* (PKCS#1 v1.5 padding oracle).
# There is NO fixed rsa release (the constant-time rewrite is still unreleased upstream), and rsa
# is required for GameStream/Moonlight pairing. Crucially, the host uses rsa ONLY for PKCS#1 v1.5
# SIGNING / VERIFYING (gamestream/cert.rs + gamestream/pairing.rs: SigningKey / VerifyingKey /
# Signer / Verifier) — it never performs RSA decryption, which is the operation Marvin targets.
# So the vulnerable code path is not exercised. Revisit if a fixed rsa ships or we add RSA decrypt.
"RUSTSEC-2023-0071",
]
+17 -6
View File
@@ -20,9 +20,12 @@
# an ephemeral self-signed cert is generated and its public .cer published next to the installer # an ephemeral self-signed cert is generated and its public .cer published next to the installer
# (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1. # (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1.
# #
# NVENC: the host builds with --features nvenc; the only link need is nvencodeapi.lib, synthesised # GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer.
# from a 2-export .def with llvm-dlltool (no GPU/SDK at build time). The resulting exe is NVIDIA-only # - NVENC (NVIDIA, direct SDK): the only link need is nvencodeapi.lib, synthesised from a 2-export
# by design — CI never launches it, so no GPU is needed here. # .def with llvm-dlltool (no GPU/SDK at build time).
# - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN gpl-shared
# tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer.
# CI never launches the exe, so no GPU is needed here — this is build + Windows clippy coverage only.
name: windows-host name: windows-host
on: on:
@@ -59,6 +62,13 @@ jobs:
# (pwsh Out-File utf8 = no BOM, unlike Windows PowerShell 5.1 — keeps the first line clean). # (pwsh Out-File utf8 = no BOM, unlike Windows PowerShell 5.1 — keeps the first line clean).
"CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# FFMPEG_DIR: the same BtbN gpl-shared x64 tree the Windows CLIENT links against (provisioned
# by scripts/ci/setup-windows-runner.ps1). The host's AMD/Intel AMF/QSV encode backend
# (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1
# then bundles its bin\*.dll into the installer. LIBCLANG_PATH is in the runner daemon env.
if (-not $env:FFMPEG_DIR) {
"FFMPEG_DIR=C:\Users\Public\ffmpeg" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
}
$v = if ($env:GITHUB_REF -like 'refs/tags/v*') { $v = if ($env:GITHUB_REF -like 'refs/tags/v*') {
$env:GITHUB_REF_NAME -replace '^v', '' $env:GITHUB_REF_NAME -replace '^v', ''
} else { } else {
@@ -74,14 +84,15 @@ jobs:
& packaging/windows/nvenc/gen-nvenc-importlib.ps1 -OutDir C:\t\nvenc & 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 "PUNKTFUNK_NVENC_LIB_DIR=C:\t\nvenc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Build (release, nvenc) - name: Build (release, nvenc + amf-qsv)
shell: pwsh shell: pwsh
run: cargo build --release -p punktfunk-host --features nvenc # 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
- name: Clippy (host, Windows) - name: Clippy (host, 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 -- -D warnings run: cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings
- name: Ensure Inno Setup - name: Ensure Inno Setup
shell: pwsh shell: pwsh
+21 -11
View File
@@ -74,17 +74,26 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
Clients auto-resolve the type from the physical controller (DS5→DualSense, DS4→DualShock 4, Clients auto-resolve the type from the physical controller (DS5→DualSense, DS4→DualShock 4,
Xbox One→Xbox One). Windows-host DualShock 4 (ViGEm) is not yet wired — Windows clients asking for Xbox One→Xbox One). Windows-host DualShock 4 (ViGEm) is not yet wired — Windows clients asking for
DS4 get Xbox 360 for now. DS4 get Xbox 360 for now.
- **Windows host: implemented and shipping (NVIDIA-only, x64-only).** `#[cfg(windows)]` backends - **Windows host: implemented and shipping (all-vendor, x64-only).** `#[cfg(windows)]` backends
behind the same traits as Linux — DXGI Desktop Duplication capture (`capture/dxgi.rs`), **SudoVDA** behind the same traits as Linux — DXGI Desktop Duplication capture (`capture/dxgi.rs`), **SudoVDA**
virtual display per session (`vdisplay/sudovda.rs`), NVENC encode (`--features nvenc`), SendInput + virtual display per session (`vdisplay/sudovda.rs`), GPU encode (NVENC `--features nvenc`; AMD/Intel
**ViGEm** gamepads (`inject/gamepad_windows.rs`), WASAPI loopback + virtual mic (`audio/wasapi_*`). `--features amf-qsv`), SendInput + **ViGEm** gamepads (`inject/gamepad_windows.rs`), WASAPI loopback
Ships as a **signed Inno Setup installer** that registers a `LocalSystem` SCM service launching into + virtual mic (`audio/wasapi_*`). Ships as a **signed Inno Setup installer** that registers a
the interactive session for secure-desktop (UAC/lock-screen) capture (`service.rs`), bundles the `LocalSystem` SCM service launching into the interactive session for secure-desktop (UAC/lock-screen)
SudoVDA driver, and is published by `windows-host.yml`. **HDR (10-bit)**: WGC captures the HDR capture (`service.rs`), bundles the SudoVDA driver + the FFmpeg DLLs, and is published by
desktop as FP16/Rgb10a2 (DDA FP16 for the secure desktop), NVENC forces HEVC Main10 + BT.2020 PQ, `windows-host.yml`. **Encoder is GPU-aware** (`encode.rs` `open_video` + `windows_resolved_backend`):
the client auto-detects PQ from the HEVC VUI — gated by `PUNKTFUNK_10BIT` + client `VIDEO_CAP_10BIT`; `PUNKTFUNK_ENCODER=auto` (the host.env default) detects the DXGI adapter vendor → **NVENC** (NVIDIA,
**Windows host only** (the Linux host stays 8-bit, blocked upstream). Newer/less battle-tested than direct SDK, `encode/nvenc.rs`), **AMF** (AMD) / **QSV** (Intel) via libavcodec
the Linux host; no AMD/Intel/software encode path. Packaging: `packaging/windows/`. (`encode/ffmpeg_win.rs`, the Windows analogue of the Linux VAAPI backend — `WinVendor{Amf,Qsv}`,
system-memory NV12/P010 readback default + opt-in zero-copy D3D11 behind `PUNKTFUNK_ZEROCOPY` with a
system fallback), or software H.264 (`encode/sw.rs`, GPU-less). GameStream codec advertisement is
probed per-GPU on AMF/QSV (`windows_codec_support``serverinfo`, AV1 gated). **HDR (10-bit)**: WGC
captures the HDR desktop as FP16/Rgb10a2 (DDA FP16 for the secure desktop), the encoder forces HEVC
Main10 + BT.2020 PQ (NVENC ABGR10/P010; AMF/QSV P010 + a swscale Rgb10a2→P010 fallback), the client
auto-detects PQ from the HEVC VUI — gated by `PUNKTFUNK_10BIT` + client `VIDEO_CAP_10BIT`; **Windows
host only** (the Linux host stays 8-bit, blocked upstream). **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/`.
## What's left ## What's left
@@ -243,6 +252,7 @@ crates/punktfunk-host/
vdisplay/{kwin,gamescope,mutter,wlroots}.rs per-compositor client-sized virtual outputs vdisplay/{kwin,gamescope,mutter,wlroots}.rs per-compositor client-sized virtual outputs
zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan) zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan)
inject/{libei,wlr,gamepad,dualsense}.rs input backends (uinput xpad + UHID DualSense) inject/{libei,wlr,gamepad,dualsense}.rs input backends (uinput xpad + UHID DualSense)
encode/{nvenc,linux,vaapi,ffmpeg_win,sw}.rs per-GPU encoders (NVENC · Linux NVENC/CUDA · VAAPI · AMF/QSV · openh264)
capture.rs · encode.rs · audio.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs capture.rs · encode.rs · audio.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs
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)
@@ -250,7 +260,7 @@ clients/windows/ native Windows client (WinUI 3 via windows-reactor · D3D11 ·
clients/apple/ native macOS/iOS/tvOS client (Swift · VideoToolbox · GameController) clients/apple/ native macOS/iOS/tvOS client (Swift · VideoToolbox · GameController)
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
crates/punktfunk-host/src/{capture/dxgi,vdisplay/sudovda,inject/gamepad_windows,audio/wasapi_*,service}.rs Windows host backends crates/punktfunk-host/src/{capture/dxgi,vdisplay/sudovda,encode/ffmpeg_win,inject/gamepad_windows,audio/wasapi_*,service}.rs Windows host backends
web/ TanStack web console over the mgmt API (status · devices · pairing) web/ TanStack web console over the mgmt API (status · devices · pairing)
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)
+16
View File
@@ -116,7 +116,13 @@ libloading = "0.8"
windows = { version = "0.62", features = [ windows = { version = "0.62", features = [
"Win32_Foundation", "Win32_Foundation",
"Win32_Security", "Win32_Security",
# ConvertStringSecurityDescriptorToSecurityDescriptorW — the SDDL on the virtual-DualSense
# shared-memory section (inject/dualsense_windows.rs) so the UMDF host can open it.
"Win32_Security_Authorization",
"Win32_Devices_DeviceAndDriverInstallation", "Win32_Devices_DeviceAndDriverInstallation",
# SwDeviceCreate/SwDeviceClose — the per-session virtual-DualSense devnode
# (inject/dualsense_windows.rs).
"Win32_Devices_Enumeration_Pnp",
"Win32_Devices_Display", "Win32_Devices_Display",
"Win32_Storage_FileSystem", "Win32_Storage_FileSystem",
"Win32_System_IO", "Win32_System_IO",
@@ -178,6 +184,12 @@ vigem-client = { version = "0.1", features = ["unstable_xtarget_notification"] }
# the crate builds on docs.rs/CI. We enable it so the GPU-less VM/CI compiles; the DirectX NVENC path # the crate builds on docs.rs/CI. We enable it so the GPU-less VM/CI compiles; the DirectX NVENC path
# never calls CUDA at runtime, so the pinned CUDA bindings version is irrelevant. # never calls CUDA at runtime, so the pinned CUDA bindings version is irrelevant.
nvidia-video-codec-sdk = { version = "0.4", features = ["ci-check"], optional = true } nvidia-video-codec-sdk = { version = "0.4", features = ["ci-check"], optional = true }
# AMD (AMF) + Intel (QSV) hardware encode on Windows via libavcodec — the analogue of the Linux
# VAAPI backend (`src/encode/ffmpeg_win.rs`). Optional + behind the `amf-qsv` feature because it
# link-imports the FFmpeg libs at build time (needs a `FFMPEG_DIR` with the AMF/QSV encoders — the
# same BtbN gpl-shared tree the Windows client uses) and pulls the shared `avcodec/avutil/...` DLLs
# at runtime. `ffmpeg-sys-next` auto-detects the FFmpeg version (7.x/avcodec-61 or 8.x/62).
ffmpeg-next = { version = "8", optional = true }
[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, and the host then needs
@@ -185,3 +197,7 @@ nvidia-video-codec-sdk = { version = "0.4", features = ["ci-check"], optional =
# time — i.e. `nvencodeapi.lib` from the NVIDIA Video Codec SDK (or an import lib generated from # time — i.e. `nvencodeapi.lib` from the NVIDIA Video Codec SDK (or an import lib generated from
# nvEncodeAPI64.dll) on the linker path. Build the GPU host with `--features nvenc`. # nvEncodeAPI64.dll) on the linker path. 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
# `FFMPEG_DIR` (BtbN gpl-shared, includes `*_amf`/`*_qsv`) at build time and bundles the FFmpeg
# DLLs at runtime. Build the all-vendor GPU host with `--features nvenc,amf-qsv`.
amf-qsv = ["dep:ffmpeg-next"]
+8 -3
View File
@@ -2157,9 +2157,14 @@ impl DuplCapturer {
.ok() .ok()
.and_then(|s| s.parse().ok()) .and_then(|s| s.parse().ok())
.unwrap_or((2000 / refresh_hz.max(1)).max(100)); .unwrap_or((2000 / refresh_hz.max(1)).max(100));
let gpu_mode = std::env::var("PUNKTFUNK_ENCODER") // Produce GPU-resident D3D11 frames (zero-copy NVENC, or the NV12/P010 the AMF/QSV
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "nvenc" | "hw" | "nvidia")) // backends read back / import) whenever the resolved encode backend is a GPU one — so the
.unwrap_or(false); // capturer's output format matches the encoder's input. Only the software (GPU-less) path
// takes CPU staging. Mirrors `encode::open_video`'s dispatch exactly.
let gpu_mode = !matches!(
crate::encode::windows_resolved_backend(),
crate::encode::WindowsBackend::Software
);
// Read the source display's HDR mastering metadata while we still hold `output` (it is // Read the source display's HDR mastering metadata while we still hold `output` (it is
// moved into the struct below). Only meaningful for an HDR (FP16) duplication. // moved into the struct below). Only meaningful for an HDR (FP16) duplication.
let is_hdr_init = dd.ModeDesc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT; let is_hdr_init = dd.ModeDesc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT;
+187 -16
View File
@@ -49,6 +49,26 @@ impl Codec {
Codec::Av1 => "av1_vaapi", Codec::Av1 => "av1_vaapi",
} }
} }
/// The FFmpeg AMD **AMF** encoder name (the Windows AMD backend). Selected by name (the codec id
/// would pick the software encoder). AV1 (`av1_amf`) is RDNA3+/RX 7000+ — probe, never assume.
pub fn amf_name(self) -> &'static str {
match self {
Codec::H264 => "h264_amf",
Codec::H265 => "hevc_amf",
Codec::Av1 => "av1_amf",
}
}
/// The FFmpeg Intel **QSV** encoder name (the Windows Intel backend). Selected by name. AV1
/// (`av1_qsv`) is Arc/Xe2+; HEVC Main10 is Gen9.5+ — probe, never assume.
pub fn qsv_name(self) -> &'static str {
match self {
Codec::H264 => "h264_qsv",
Codec::H265 => "hevc_qsv",
Codec::Av1 => "av1_qsv",
}
}
} }
/// A hardware encoder. One per session; runs on the encode thread. /// A hardware encoder. One per session; runs on the encode thread.
@@ -198,16 +218,15 @@ pub fn open_video(
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
let _ = cuda; // always false on Windows (no Cuda payload) let _ = cuda; // always false on Windows (no Cuda payload)
let _ = bit_depth; // used by the NVENC path below; the software H.264 path is 8-bit only // NVIDIA → NVENC (direct SDK), AMD → AMF, Intel → QSV (both libavcodec), else → software
let pref = std::env::var("PUNKTFUNK_ENCODER") // H.264. `auto` (the default) resolves from the DXGI adapter vendor.
.unwrap_or_default() match windows_resolved_backend() {
.to_ascii_lowercase(); WindowsBackend::Nvenc => {
if matches!(pref.as_str(), "nvenc" | "hw" | "nvidia") {
// Hardware path: NVENC over D3D11. The DXGI capturer switches to its zero-copy // Hardware path: NVENC over D3D11. The DXGI capturer switches to its zero-copy
// FramePayload::D3d11 output under the same env var so capture + encode share textures. // FramePayload::D3d11 output under the same env var so capture + encode share textures.
#[cfg(feature = "nvenc")] #[cfg(feature = "nvenc")]
{ {
let enc = nvenc::NvencD3d11Encoder::open( nvenc::NvencD3d11Encoder::open(
codec, codec,
format, format,
width, width,
@@ -215,32 +234,67 @@ pub fn open_video(
fps, fps,
bitrate_bps, bitrate_bps,
bit_depth, bit_depth,
)?; )
return Ok(Box::new(enc) as Box<dyn Encoder>); .map(|e| Box::new(e) as Box<dyn Encoder>)
} }
#[cfg(not(feature = "nvenc"))] #[cfg(not(feature = "nvenc"))]
{ {
anyhow::bail!( anyhow::bail!(
"NVENC requested but this host was built without it — rebuild with \ "NVENC requested/detected but this host was built without it — rebuild \
`--features nvenc` (needs the NVENC SDK's nvencodeapi.lib at link time)" with `--features nvenc` (needs the NVENC SDK's nvencodeapi.lib at link time)"
); )
} }
} }
backend @ (WindowsBackend::Amf | WindowsBackend::Qsv) => {
// AMD AMF / Intel QSV via libavcodec (the Windows analogue of the Linux VAAPI path).
#[cfg(feature = "amf-qsv")]
{
let vendor = if matches!(backend, WindowsBackend::Amf) {
ffmpeg_win::WinVendor::Amf
} else {
ffmpeg_win::WinVendor::Qsv
};
ffmpeg_win::FfmpegWinEncoder::open(
vendor,
codec,
format,
width,
height,
fps,
bitrate_bps,
bit_depth,
)
.map(|e| Box::new(e) as Box<dyn Encoder>)
}
#[cfg(not(feature = "amf-qsv"))]
{
let _ = backend;
anyhow::bail!(
"AMD/Intel (AMF/QSV) encode requested/detected but this host was built \
without it — rebuild with `--features amf-qsv` (needs ffmpeg-next + a \
FFMPEG_DIR with the AMF/QSV encoders at build time)"
)
}
}
WindowsBackend::Software => {
anyhow::ensure!( anyhow::ensure!(
codec == Codec::H264, codec == Codec::H264,
"the Windows software encoder supports H.264 only; client negotiated {codec:?} \ "the Windows software encoder supports H.264 only; client negotiated {codec:?} \
(set PUNKTFUNK_ENCODER=nvenc for a GPU host, or request H264)" (build a GPU backend: --features nvenc or amf-qsv, or request H264)"
); );
let _ = bit_depth; // the software H.264 path is 8-bit only
// Software H.264 realistically caps far below the negotiated hardware rates. // Software H.264 realistically caps far below the negotiated hardware rates.
const SW_BITRATE_CEIL: u64 = 100_000_000; const SW_BITRATE_CEIL: u64 = 100_000_000;
let enc = sw::OpenH264Encoder::open( sw::OpenH264Encoder::open(
format, format,
width, width,
height, height,
fps, fps,
bitrate_bps.min(SW_BITRATE_CEIL), bitrate_bps.min(SW_BITRATE_CEIL),
)?; )
Ok(Box::new(enc) as Box<dyn Encoder>) .map(|e| Box::new(e) as Box<dyn Encoder>)
}
}
} }
#[cfg(not(any(target_os = "linux", target_os = "windows")))] #[cfg(not(any(target_os = "linux", target_os = "windows")))]
{ {
@@ -339,7 +393,7 @@ pub fn linux_zero_copy_is_vaapi() -> bool {
/// Which codecs the active GPU can actually ENCODE. Used to build the GameStream codec /// Which codecs the active GPU can actually ENCODE. Used to build the GameStream codec
/// advertisement so a client never negotiates a codec the GPU can't do (AV1 encode is narrow — /// advertisement so a client never negotiates a codec the GPU can't do (AV1 encode is narrow —
/// Intel Arc/Xe2+, AMD RDNA3+/RDNA4 — so it must be probed, not assumed). /// Intel Arc/Xe2+, AMD RDNA3+/RDNA4 — so it must be probed, not assumed).
#[cfg(target_os = "linux")] #[cfg(any(target_os = "linux", target_os = "windows"))]
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub struct CodecSupport { pub struct CodecSupport {
pub h264: bool, pub h264: bool,
@@ -370,6 +424,123 @@ pub fn vaapi_codec_support() -> CodecSupport {
}) })
} }
// ---------------------------------------------------------------------------------------------
// Windows backend selection (the analogue of the Linux nvidia_present / linux_zero_copy_is_vaapi
// logic). NVIDIA → NVENC, AMD → AMF, Intel → QSV; `auto` (default) reads the DXGI adapter vendor.
// ---------------------------------------------------------------------------------------------
#[cfg(target_os = "windows")]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum WindowsBackend {
Nvenc,
Amf,
Qsv,
Software,
}
#[cfg(target_os = "windows")]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum GpuVendor {
Nvidia,
Amd,
Intel,
}
/// Resolve the active Windows encode backend from `PUNKTFUNK_ENCODER` (`auto` → the DXGI adapter
/// vendor). Shared by [`open_video`] and the GameStream codec advertisement so both agree.
#[cfg(target_os = "windows")]
pub(crate) fn windows_resolved_backend() -> WindowsBackend {
let pref = std::env::var("PUNKTFUNK_ENCODER")
.unwrap_or_default()
.to_ascii_lowercase();
match pref.as_str() {
"nvenc" | "hw" | "nvidia" | "cuda" => WindowsBackend::Nvenc,
"amf" | "amd" => WindowsBackend::Amf,
"qsv" | "intel" => WindowsBackend::Qsv,
"sw" | "software" | "openh264" => WindowsBackend::Software,
_ => match windows_gpu_vendor() {
Some(GpuVendor::Nvidia) => WindowsBackend::Nvenc,
Some(GpuVendor::Amd) => WindowsBackend::Amf,
Some(GpuVendor::Intel) => WindowsBackend::Qsv,
None => WindowsBackend::Software,
},
}
}
/// True if the active Windows backend is the libavcodec AMF/QSV path (so the codec advertisement
/// consults a real GPU probe rather than the NVENC static superset). Always false when the
/// `amf-qsv` feature is off — there's then no ffmpeg backend to probe.
#[cfg(target_os = "windows")]
pub fn windows_backend_is_ffmpeg() -> bool {
cfg!(feature = "amf-qsv")
&& matches!(
windows_resolved_backend(),
WindowsBackend::Amf | WindowsBackend::Qsv
)
}
/// Detect the host GPU vendor from the first hardware DXGI adapter (Windows has no `/dev/nvidia*`
/// probe). Cached. NVIDIA=0x10DE, AMD=0x1002, Intel=0x8086; the software/WARP adapter is skipped.
#[cfg(target_os = "windows")]
fn windows_gpu_vendor() -> Option<GpuVendor> {
use std::sync::OnceLock;
use windows::Win32::Graphics::Dxgi::{
CreateDXGIFactory1, IDXGIFactory1, DXGI_ADAPTER_FLAG_SOFTWARE,
};
static CACHE: OnceLock<Option<GpuVendor>> = OnceLock::new();
*CACHE.get_or_init(|| unsafe {
let factory: IDXGIFactory1 = CreateDXGIFactory1().ok()?;
let mut i = 0u32;
while let Ok(adapter) = factory.EnumAdapters1(i) {
i += 1;
// windows-rs 0.62: GetDesc1 returns the desc by value (no out-param).
let Ok(desc) = adapter.GetDesc1() else {
continue;
};
if (desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE.0 as u32) != 0 {
continue; // skip the Microsoft Basic Render / WARP adapter
}
match desc.VendorId {
0x10DE => return Some(GpuVendor::Nvidia),
0x1002 => return Some(GpuVendor::Amd),
0x8086 => return Some(GpuVendor::Intel),
_ => continue,
}
}
None
})
}
/// Probe the active Windows AMF/QSV backend for its encodable codecs (cached; opens a tiny encoder
/// per codec, once). Mirrors [`vaapi_codec_support`]; called only when [`windows_backend_is_ffmpeg`]
/// is true. AV1 is narrow (AMD RDNA3+, Intel Arc/Xe2+), so it must be probed, not assumed.
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
pub fn windows_codec_support() -> CodecSupport {
use std::sync::OnceLock;
static CACHE: OnceLock<CodecSupport> = OnceLock::new();
*CACHE.get_or_init(|| {
let vendor = match windows_resolved_backend() {
WindowsBackend::Qsv => ffmpeg_win::WinVendor::Qsv,
_ => ffmpeg_win::WinVendor::Amf,
};
let caps = CodecSupport {
h264: ffmpeg_win::probe_can_encode(vendor, Codec::H264),
h265: ffmpeg_win::probe_can_encode(vendor, Codec::H265),
av1: ffmpeg_win::probe_can_encode(vendor, Codec::Av1),
};
tracing::info!(
backend = ?vendor,
h264 = caps.h264,
h265 = caps.h265,
av1 = caps.av1,
"Windows AMF/QSV encode capabilities probed"
);
caps
})
}
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
mod ffmpeg_win;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
mod linux; mod linux;
#[cfg(all(target_os = "windows", feature = "nvenc"))] #[cfg(all(target_os = "windows", feature = "nvenc"))]
File diff suppressed because it is too large Load Diff
@@ -50,8 +50,28 @@ pub fn serverinfo_xml(host: &Host, https: bool, paired: bool) -> String {
fn codec_mode_support() -> u32 { fn codec_mode_support() -> u32 {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
if crate::encode::linux_zero_copy_is_vaapi() { if crate::encode::linux_zero_copy_is_vaapi() {
if let Some(m) = probed_mask(crate::encode::vaapi_codec_support()) {
return m;
}
}
// Windows AMD/Intel (AMF/QSV): advertise only what the GPU actually encodes (AV1 is narrow, an
// old iGPU might lack HEVC). NVENC and the GPU-less software path keep the static superset.
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
if crate::encode::windows_backend_is_ffmpeg() {
if let Some(m) = probed_mask(crate::encode::windows_codec_support()) {
return m;
}
}
SERVER_CODEC_MODE_SUPPORT
}
/// Turn a probed [`CodecSupport`](crate::encode::CodecSupport) into a `ServerCodecModeSupport` mask,
/// or `None` if the probe found nothing — meaning the GPU wasn't usable at probe time (GPU-less CI,
/// a misconfigured/wrong-vendor host), NOT that it encodes zero codecs; the caller then advertises
/// the static superset (pre-probe behaviour) rather than claiming nothing.
#[cfg(any(target_os = "linux", all(target_os = "windows", feature = "amf-qsv")))]
fn probed_mask(caps: crate::encode::CodecSupport) -> Option<u32> {
use super::{SCM_AV1_MAIN8, SCM_H264, SCM_HEVC}; use super::{SCM_AV1_MAIN8, SCM_H264, SCM_HEVC};
let caps = crate::encode::vaapi_codec_support();
let mut m = 0; let mut m = 0;
if caps.h264 { if caps.h264 {
m |= SCM_H264; m |= SCM_H264;
@@ -62,15 +82,7 @@ fn codec_mode_support() -> u32 {
if caps.av1 { if caps.av1 {
m |= SCM_AV1_MAIN8; m |= SCM_AV1_MAIN8;
} }
// Only trust a probe that actually found an encoder. An empty result means VAAPI wasn't (m != 0).then_some(m)
// usable at probe time (no VA display — a GPU-less CI box, or a misconfigured host), NOT
// that the GPU encodes nothing; advertise the static superset (pre-probe behaviour) rather
// than claiming zero codecs.
if m != 0 {
return m;
}
}
SERVER_CODEC_MODE_SUPPORT
} }
#[cfg(test)] #[cfg(test)]
+7
View File
@@ -423,6 +423,13 @@ fn gs_button_to_evdev(b: u32) -> Option<u32> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub mod dualsense; pub mod dualsense;
/// Transport-independent DualSense HID contract, shared by the Linux UHID backend ([`dualsense`])
/// and the Windows UMDF-driver backend ([`dualsense_windows`]).
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub mod dualsense_proto;
/// Windows: virtual DualSense via the UMDF minidriver + a shared-memory host channel.
#[cfg(target_os = "windows")]
pub mod dualsense_windows;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub mod dualshock4; pub mod dualshock4;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
+12 -461
View File
@@ -8,9 +8,15 @@
//! **output** reports (report `0x02`, a game's rumble/LED/trigger feedback) back, which it //! **output** reports (report `0x02`, a game's rumble/LED/trigger feedback) back, which it
//! forwards to the client as [`punktfunk_core::quic::HidOutput`]. //! forwards to the client as [`punktfunk_core::quic::HidOutput`].
//! //!
//! The report descriptor + field layout are the canonical inputtino ones (games-on-whales/ //! The transport-independent contract (report descriptor, feature blobs, [`DsState`], the `0x01`
//! inputtino `src/uhid/include/uhid/ps5.hpp`), so `hid-playstation` binds the same as a USB pad. //! serializer and `0x02` parser) lives in [`super::dualsense_proto`], shared with the Windows
//! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it.
use super::dualsense_proto::{
parse_ds_output, serialize_state, DsFeedback, DsState, DS_FEATURE_CALIBRATION,
DS_FEATURE_FIRMWARE, DS_FEATURE_PAIRING, DS_INPUT_REPORT_LEN, DS_PRODUCT, DS_TOUCH_H,
DS_TOUCH_W, DS_VENDOR, DUALSENSE_RDESC,
};
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use punktfunk_core::quic::{HidOutput, RichInput}; use punktfunk_core::quic::{HidOutput, RichInput};
@@ -32,275 +38,10 @@ const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2) const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
const BUS_USB: u16 = 0x03; const BUS_USB: u16 = 0x03;
// Feature reports `hid-playstation` GET_REPORTs during init — without these replies it never /// Copy a NUL-padded C string field into the event buffer.
// finishes calibration and creates no input devices. Verbatim from inputtino (each array's fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
// first byte is the report id). The pairing report carries a fixed virtual MAC. let n = s.len().min(cap - 1);
#[rustfmt::skip] ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
const DS_FEATURE_CALIBRATION: &[u8] = &[ // report 0x05 (motion calibration)
0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x27, 0xF0, 0xD8, 0x10, 0x27, 0xF0, 0xD8, 0x10,
0x27, 0xF0, 0xD8, 0xF4, 0x01, 0xF4, 0x01, 0x10, 0x27, 0xF0, 0xD8, 0x10, 0x27, 0xF0, 0xD8, 0x10,
0x27, 0xF0, 0xD8, 0x0B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
#[rustfmt::skip]
const DS_FEATURE_PAIRING: &[u8] = &[ // report 0x09 (pairing info: MAC at bytes 1..7)
0x09, 0x74, 0xE7, 0xD6, 0x3A, 0x53, 0x35, 0x08, 0x25, 0x00, 0x1E, 0x00, 0xEE, 0x74, 0xD0, 0xBC,
0x00, 0x00, 0x00, 0x00,
];
#[rustfmt::skip]
const DS_FEATURE_FIRMWARE: &[u8] = &[ // report 0x20 (firmware info / build date)
0x20, 0x4A, 0x75, 0x6E, 0x20, 0x31, 0x39, 0x20, 0x32, 0x30, 0x32, 0x33, 0x31, 0x34, 0x3A, 0x34,
0x37, 0x3A, 0x33, 0x34, 0x03, 0x00, 0x44, 0x00, 0x08, 0x02, 0x00, 0x01, 0x36, 0x00, 0x00, 0x01,
0xC1, 0xC8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x54, 0x01, 0x00, 0x00,
0x14, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
/// Sony DualSense USB HID report descriptor (232 bytes), verbatim from inputtino — the exact
/// descriptor `hid-playstation` parses to bind a UHID device as a DualSense.
#[rustfmt::skip]
const DUALSENSE_RDESC: &[u8] = &[
0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x35,
0x09, 0x33, 0x09, 0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x06, 0x81, 0x02, 0x06,
0x00, 0xFF, 0x09, 0x20, 0x95, 0x01, 0x81, 0x02, 0x05, 0x01, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07,
0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x65, 0x00, 0x05,
0x09, 0x19, 0x01, 0x29, 0x0F, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0F, 0x81, 0x02, 0x06,
0x00, 0xFF, 0x09, 0x21, 0x95, 0x0D, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x22, 0x15, 0x00, 0x26,
0xFF, 0x00, 0x75, 0x08, 0x95, 0x34, 0x81, 0x02, 0x85, 0x02, 0x09, 0x23, 0x95, 0x2F, 0x91, 0x02,
0x85, 0x05, 0x09, 0x33, 0x95, 0x28, 0xB1, 0x02, 0x85, 0x08, 0x09, 0x34, 0x95, 0x2F, 0xB1, 0x02,
0x85, 0x09, 0x09, 0x24, 0x95, 0x13, 0xB1, 0x02, 0x85, 0x0A, 0x09, 0x25, 0x95, 0x1A, 0xB1, 0x02,
0x85, 0x20, 0x09, 0x26, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x21, 0x09, 0x27, 0x95, 0x04, 0xB1, 0x02,
0x85, 0x22, 0x09, 0x40, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x80, 0x09, 0x28, 0x95, 0x3F, 0xB1, 0x02,
0x85, 0x81, 0x09, 0x29, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x82, 0x09, 0x2A, 0x95, 0x09, 0xB1, 0x02,
0x85, 0x83, 0x09, 0x2B, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x84, 0x09, 0x2C, 0x95, 0x3F, 0xB1, 0x02,
0x85, 0x85, 0x09, 0x2D, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xA0, 0x09, 0x2E, 0x95, 0x01, 0xB1, 0x02,
0x85, 0xE0, 0x09, 0x2F, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x30, 0x95, 0x3F, 0xB1, 0x02,
0x85, 0xF1, 0x09, 0x31, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2, 0x09, 0x32, 0x95, 0x0F, 0xB1, 0x02,
0x85, 0xF4, 0x09, 0x35, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF5, 0x09, 0x36, 0x95, 0x03, 0xB1, 0x02,
0xC0,
];
const DS_VENDOR: u32 = 0x054C; // Sony Interactive Entertainment
const DS_PRODUCT: u32 = 0x0CE6; // DualSense Wireless Controller
/// USB input report `0x01` is 64 bytes total (report id + 63-byte body).
const DS_INPUT_REPORT_LEN: usize = 64;
/// The DualSense touchpad's reported resolution (the kernel exposes it as ABS_MT 0..1920/1080).
pub const DS_TOUCH_W: u16 = 1920;
pub const DS_TOUCH_H: u16 = 1080;
/// Bit positions inside the DualSense face/dpad button byte (`buttons[0]`, low nibble = hat).
mod btn0 {
pub const SQUARE: u8 = 0x10;
pub const CROSS: u8 = 0x20;
pub const CIRCLE: u8 = 0x40;
pub const TRIANGLE: u8 = 0x80;
}
/// `buttons[1]`: shoulders, triggers-as-buttons, create/options, stick clicks.
mod btn1 {
pub const L1: u8 = 0x01;
pub const R1: u8 = 0x02;
pub const L2: u8 = 0x04;
pub const R2: u8 = 0x08;
pub const CREATE: u8 = 0x10; // "Share"
pub const OPTIONS: u8 = 0x20;
pub const L3: u8 = 0x40;
pub const R3: u8 = 0x80;
}
/// `buttons[2]`: PS, touchpad click, mute (+ a rolling counter in the high bits).
mod btn2 {
pub const PS: u8 = 0x01;
pub const TOUCHPAD: u8 = 0x02;
#[allow(dead_code)]
pub const MUTE: u8 = 0x04;
}
/// One touchpad contact for the report.
#[derive(Clone, Copy, Default)]
pub struct Touch {
pub active: bool,
pub id: u8,
pub x: u16, // 0..DS_TOUCH_W
pub y: u16, // 0..DS_TOUCH_H
}
/// Full DualSense controller state to serialize into report `0x01`. Sticks/triggers are 8-bit
/// (`0x80` neutral for sticks, `0x00` released for triggers); `dpad` is the 8-way hat (`8` =
/// centered); `buttons[0..3]` are the packed DualSense button bytes; gyro/accel are raw i16.
#[derive(Clone, Copy, Default)]
pub struct DsState {
pub lx: u8,
pub ly: u8,
pub rx: u8,
pub ry: u8,
pub l2: u8,
pub r2: u8,
pub dpad: u8, // 0..7 direction, 8 = neutral
pub buttons: [u8; 4],
pub gyro: [i16; 3],
pub accel: [i16; 3],
pub touch: [Touch; 2],
}
impl DsState {
/// A centered, nothing-pressed state (sticks 0x80, dpad neutral).
pub fn neutral() -> DsState {
DsState {
lx: 0x80,
ly: 0x80,
rx: 0x80,
ry: 0x80,
dpad: 8,
..Default::default()
}
}
/// Map a GameStream/XInput pad frame (button bitmask + i16 sticks + u8 triggers) into the
/// DualSense report fields. Sticks are recentred to `0x80`; the Y axes are inverted (XInput
/// `+y = up`, DualSense `0 = up`). Triggers double as the L2/R2 buttons when pressed. Touchpad
/// + motion are filled separately from rich-input events.
pub fn from_gamepad(
buttons: u32,
lx: i16,
ly: i16,
rx: i16,
ry: i16,
lt: u8,
rt: u8,
) -> DsState {
use punktfunk_core::input::gamepad as gs;
let to_u8 = |v: i16| (((v as i32) + 32768) >> 8) as u8;
let on = |bit: u32| buttons & bit != 0;
let mut s = DsState {
lx: to_u8(lx),
ly: 255 - to_u8(ly),
rx: to_u8(rx),
ry: 255 - to_u8(ry),
l2: lt,
r2: rt,
..DsState::neutral()
};
s.set_dpad(
on(gs::BTN_DPAD_UP),
on(gs::BTN_DPAD_DOWN),
on(gs::BTN_DPAD_LEFT),
on(gs::BTN_DPAD_RIGHT),
);
let mut b0 = 0;
if on(gs::BTN_A) {
b0 |= btn0::CROSS;
}
if on(gs::BTN_B) {
b0 |= btn0::CIRCLE;
}
if on(gs::BTN_X) {
b0 |= btn0::SQUARE;
}
if on(gs::BTN_Y) {
b0 |= btn0::TRIANGLE;
}
s.buttons[0] = b0; // face buttons (high nibble); dpad merged in write_state
let mut b1 = 0;
if on(gs::BTN_LB) {
b1 |= btn1::L1;
}
if on(gs::BTN_RB) {
b1 |= btn1::R1;
}
if lt > 0 {
b1 |= btn1::L2;
}
if rt > 0 {
b1 |= btn1::R2;
}
if on(gs::BTN_BACK) {
b1 |= btn1::CREATE;
}
if on(gs::BTN_START) {
b1 |= btn1::OPTIONS;
}
if on(gs::BTN_LS_CLICK) {
b1 |= btn1::L3;
}
if on(gs::BTN_RS_CLICK) {
b1 |= btn1::R3;
}
s.buttons[1] = b1;
if on(gs::BTN_GUIDE) {
s.buttons[2] |= btn2::PS;
}
if on(gs::BTN_TOUCHPAD) {
s.buttons[2] |= btn2::TOUCHPAD;
}
s
}
/// Set the dpad hat from the four GameStream dpad booleans (up/down/left/right).
pub fn set_dpad(&mut self, up: bool, down: bool, left: bool, right: bool) {
// DualSense hat: 0=N,1=NE,2=E,3=SE,4=S,5=SW,6=W,7=NW,8=neutral.
self.dpad = match (up, right, down, left) {
(true, false, false, false) => 0,
(true, true, false, false) => 1,
(false, true, false, false) => 2,
(false, true, true, false) => 3,
(false, false, true, false) => 4,
(false, false, true, true) => 5,
(false, false, false, true) => 6,
(true, false, false, true) => 7,
_ => 8,
};
}
}
/// Serialize a full input report `0x01` (pure — unit-testable without `/dev/uhid`). Field
/// offsets per the kernel's `struct dualsense_input_report`, this report's one consumer:
/// x..rz 0-5, seq 6, buttons[4] 7-10, reserved[4] 11-14, gyro[3] 15-20, accel[3] 21-26,
/// sensor_timestamp 27-30, reserved2 31, points[2] 32-39 (static_assert(sizeof == 63)).
/// The report id occupies r[0], so struct offset N = r[N + 1].
fn serialize_state(r: &mut [u8; DS_INPUT_REPORT_LEN], st: &DsState, seq: u8, ts: u32) {
r[0] = 0x01; // report id; the struct fields follow (struct offset 0 == r[1])
r[1] = st.lx;
r[2] = st.ly;
r[3] = st.rx;
r[4] = st.ry;
r[5] = st.l2;
r[6] = st.r2;
r[7] = seq; // seq_number (struct off 6)
r[8] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // off 7: dpad + face buttons
r[9] = st.buttons[1]; // off 8
r[10] = st.buttons[2]; // off 9
r[11] = st.buttons[3]; // off 10
for (i, v) in st.gyro.iter().enumerate() {
r[16 + i * 2..18 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 15
}
for (i, v) in st.accel.iter().enumerate() {
r[22 + i * 2..24 + i * 2].copy_from_slice(&v.to_le_bytes()); // accel at struct off 21
}
r[28..32].copy_from_slice(&ts.to_le_bytes()); // sensor_timestamp (struct off 27)
pack_touch(&mut r[33..37], &st.touch[0]); // touch point 1 (struct off 32)
pack_touch(&mut r[37..41], &st.touch[1]); // touch point 2
// status byte (struct off 52 → r[53]) — hid-playstation reads battery here: low nibble =
// capacity (×10+5 %), high nibble = charging state (0 = discharging). A virtual pad has no
// real cell, so report "discharging, full" (0x0A → 100 %); leaving it 0 makes SteamOS / the
// kernel see ~5 % and warn "low battery". (We don't forward the client pad's real charge yet.)
r[53] = 0x0A;
}
fn pack_touch(dst: &mut [u8], t: &Touch) {
// byte0: bit7 = NOT active (1 = no contact), bits0-6 = contact id.
dst[0] = (t.id & 0x7F) | if t.active { 0 } else { 0x80 };
// The kernel advertises ABS_MT ranges 0..=W-1 / 0..=H-1 — never emit the size itself.
let (x, y) = (t.x.min(DS_TOUCH_W - 1), t.y.min(DS_TOUCH_H - 1));
dst[1] = (x & 0xFF) as u8;
dst[2] = (((x >> 8) & 0x0F) as u8) | (((y & 0x0F) as u8) << 4);
dst[3] = ((y >> 4) & 0xFF) as u8;
}
/// What one [`DualSensePad::service`] pass extracted from the device's HID output reports.
/// Rich feedback (lightbar / player LEDs / adaptive triggers) rides the HID-output plane (0xCD);
/// motor rumble rides the universal rumble plane (0xCA) so non-DualSense clients still feel it.
#[derive(Default)]
pub struct DsFeedback {
pub hidout: Vec<HidOutput>,
/// `(low, high)` motor levels (0..=0xFFFF), if a report carried them.
pub rumble: Option<(u16, u16)>,
} }
/// A virtual DualSense backed by `/dev/uhid` (hand-rolled codec — no bindgen, mirroring the /// A virtual DualSense backed by `/dev/uhid` (hand-rolled codec — no bindgen, mirroring the
@@ -312,12 +53,6 @@ pub struct DualSensePad {
ts: u32, ts: u32,
} }
/// Copy a NUL-padded C string field into the event buffer.
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
let n = s.len().min(cap - 1);
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
}
impl DualSensePad { impl DualSensePad {
/// Create the UHID DualSense for pad `index` (used only to make the device name/uniq unique). /// Create the UHID DualSense for pad `index` (used only to make the device name/uniq unique).
pub fn open(index: u8) -> Result<DualSensePad> { pub fn open(index: u8) -> Result<DualSensePad> {
@@ -427,61 +162,6 @@ impl Drop for DualSensePad {
} }
} }
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`]. The byte layout below is
/// the USB DualSense common report; only the well-understood fields (motor rumble, lightbar RGB,
/// player LEDs) are surfaced — adaptive-trigger blocks are forwarded raw for the client.
///
/// Every field is gated on the report's valid-flags (`valid_flag0` at data[1], `valid_flag1`
/// at data[2]) — writers only set the bits for fields they mean to change (the kernel zeroes
/// the rest), so an ungated parse would turn every plain rumble write into a lightbar-off +
/// triggers-off broadcast.
fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
// data[0] is the report id (0x02). Be defensive about short reports.
if data.first() != Some(&0x02) || data.len() < 48 {
return;
}
let flag0 = data[1]; // BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2
let flag1 = data[2]; // BIT2 lightbar, BIT4 player indicators
// Motor rumble: high-frequency (small/right) motor at data[3], low-frequency (big/left) at
// data[4]. Scale 0..255 → 0..0xFFFF, same (low, high) convention as the uinput pad's mixer,
// and route to the universal rumble plane (0xCA).
if flag0 & 0x03 != 0 {
let high = (data[3] as u16) << 8;
let low = (data[4] as u16) << 8;
fb.rumble = Some((low, high));
}
// Lightbar RGB (USB common report: bytes 45..48). Player LEDs at byte 44.
if flag1 & 0x04 != 0 {
let (r, g, b) = (data[45], data[46], data[47]);
fb.hidout.push(HidOutput::Led { pad, r, g, b });
}
if flag1 & 0x10 != 0 {
fb.hidout.push(HidOutput::PlayerLeds {
pad,
bits: data[44] & 0x1F,
});
}
// Adaptive-trigger parameter blocks, 11 bytes each: the RIGHT trigger comes FIRST in the
// report (bytes 11..22), the left at 22..33 — per SDL's DS5EffectsState_t / inputtino's
// ps5.hpp. Wire convention: which 0 = L2, 1 = R2.
if data.len() >= 33 {
if flag0 & 0x04 != 0 {
fb.hidout.push(HidOutput::Trigger {
pad,
which: 1,
effect: data[11..22].to_vec(),
});
}
if flag0 & 0x08 != 0 {
fb.hidout.push(HidOutput::Trigger {
pad,
which: 0,
effect: data[22..33].to_vec(),
});
}
}
}
/// All virtual DualSense pads of a session — the rich-controller analog of /// All virtual DualSense pads of a session — the rich-controller analog of
/// [`GamepadManager`](super::gamepad::GamepadManager), selected with `PUNKTFUNK_GAMEPAD=dualsense`. /// [`GamepadManager`](super::gamepad::GamepadManager), selected with `PUNKTFUNK_GAMEPAD=dualsense`.
/// ///
@@ -678,132 +358,3 @@ impl DualSenseManager {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
/// A DualSense USB output report (`0x02`) with all valid-flags set parses into motor
/// rumble (0xCA), lightbar, player LEDs, and both adaptive-trigger blocks (0xCD) — with
/// the report's right-trigger-first layout mapped onto the wire's `which` (0 = L2).
#[test]
fn parse_output_report() {
let mut data = vec![0u8; 48];
data[0] = 0x02; // report id
data[1] = 0x0F; // valid_flag0: vibration + haptics + R2 + L2 triggers
data[2] = 0x14; // valid_flag1: lightbar + player indicators
data[3] = 0x80; // right (high-freq) motor
data[4] = 0x40; // left (low-freq) motor
data[11] = 0x21; // right-trigger block mode byte (report bytes 11..22)
data[22] = 0x26; // left-trigger block mode byte (report bytes 22..33)
data[44] = 0x03; // player LEDs (low 5 bits)
data[45] = 10; // R
data[46] = 20; // G
data[47] = 30; // B
let mut fb = DsFeedback::default();
parse_ds_output(0, &data, &mut fb);
// (low, high) = (left<<8, right<<8).
assert_eq!(fb.rumble, Some((0x4000, 0x8000)));
assert!(fb.hidout.contains(&HidOutput::Led {
pad: 0,
r: 10,
g: 20,
b: 30
}));
assert!(fb
.hidout
.contains(&HidOutput::PlayerLeds { pad: 0, bits: 3 }));
// The report's FIRST block (bytes 11..22) is the RIGHT trigger → wire which = 1.
let triggers: Vec<_> = fb
.hidout
.iter()
.filter_map(|h| match h {
HidOutput::Trigger { which, effect, .. } => Some((*which, effect[0])),
_ => None,
})
.collect();
assert_eq!(triggers, vec![(1, 0x21), (0, 0x26)]);
}
/// Writers set only the valid-flag bits for the fields they mean to change (the kernel
/// zeroes the rest of the report) — a plain rumble write must NOT blank the lightbar /
/// player LEDs / triggers, and an LED-only write must not stop the motors.
#[test]
fn parse_output_respects_valid_flags() {
// Kernel-style rumble write: only the vibration flags set, everything else zero.
let mut data = vec![0u8; 48];
data[0] = 0x02;
data[1] = 0x03; // compatible vibration + haptics select
data[3] = 0xFF;
data[4] = 0xFF;
let mut fb = DsFeedback::default();
parse_ds_output(0, &data, &mut fb);
assert_eq!(fb.rumble, Some((0xFF00, 0xFF00)));
assert!(fb.hidout.is_empty(), "rumble write must not emit hidout");
// Lightbar-only write: no rumble surfaced (would otherwise spam rumble-stops).
let mut data = vec![0u8; 48];
data[0] = 0x02;
data[2] = 0x04; // lightbar control enable
data[45] = 1;
let mut fb = DsFeedback::default();
parse_ds_output(0, &data, &mut fb);
assert!(fb.rumble.is_none());
assert_eq!(fb.hidout.len(), 1);
assert!(matches!(fb.hidout[0], HidOutput::Led { r: 1, .. }));
}
/// The input report's sensor/touch bytes must land exactly where the kernel's
/// `struct dualsense_input_report` reads them (gyro at struct offset 15, accel 21,
/// timestamp 27, touch points 32 — report byte = struct offset + 1). A one-byte slip
/// here turns client motion into noise and conjures phantom touch contacts.
#[test]
fn input_report_layout_matches_hid_playstation() {
let mut st = DsState::neutral();
st.gyro = [0x1122, 0x3344, 0x5566];
st.accel = [0x778, 0x99A, 0xBBC];
st.touch[0] = Touch {
active: true,
id: 5,
x: 0x123,
y: 0x356,
};
// touch[1] stays inactive — its NOT-active bit must be set.
let mut r = [0u8; DS_INPUT_REPORT_LEN];
serialize_state(&mut r, &st, 7, 0xAABBCCDD);
assert_eq!(r[0], 0x01);
assert_eq!(r[7], 7); // seq_number (struct off 6)
assert_eq!(&r[16..22], &[0x22, 0x11, 0x44, 0x33, 0x66, 0x55]); // gyro LE
assert_eq!(&r[22..28], &[0x78, 0x07, 0x9A, 0x09, 0xBC, 0x0B]); // accel LE
assert_eq!(&r[28..32], &[0xDD, 0xCC, 0xBB, 0xAA]); // sensor_timestamp LE
// Touch point 1 at struct off 32 = r[33..37]: contact byte (active → bit7 clear),
// then 12-bit x / 12-bit y packed.
assert_eq!(r[33], 5);
assert_eq!(r[34], 0x23);
assert_eq!(r[35], 0x61); // x_hi nibble 0x1 | (y & 0xF) << 4 (y=0x356 → 0x6 << 4)
assert_eq!(r[36], 0x35); // y >> 4
assert_eq!(r[37] & 0x80, 0x80); // touch point 2 inactive
// status byte (struct off 52): discharging (high nibble 0) + full capacity (low nibble
// 0xA → 100 %), so SteamOS/hid-playstation never reports a false "low battery".
assert_eq!(r[53], 0x0A);
}
/// The wire touchpad-click bit (Moonlight's extended position) lands in `buttons[2]`.
#[test]
fn from_gamepad_maps_touchpad_click() {
use punktfunk_core::input::gamepad as gs;
let s = DsState::from_gamepad(gs::BTN_TOUCHPAD | gs::BTN_GUIDE, 0, 0, 0, 0, 0, 0);
assert_eq!(s.buttons[2], btn2::PS | btn2::TOUCHPAD);
let s = DsState::from_gamepad(gs::BTN_A, 0, 0, 0, 0, 0, 0);
assert_eq!(s.buttons[2], 0);
}
/// A short / wrong-id report yields nothing.
#[test]
fn parse_output_rejects_garbage() {
let mut fb = DsFeedback::default();
parse_ds_output(0, &[0x01, 0, 0], &mut fb); // wrong report id, too short
assert!(fb.rumble.is_none());
assert!(fb.hidout.is_empty());
}
}
@@ -0,0 +1,476 @@
//! Transport-independent DualSense HID contract — shared by the Linux UHID backend
//! ([`super::dualsense`]) and the Windows UMDF-driver backend ([`super::dualsense_windows`]).
//!
//! This is the pure logic: the report descriptor, feature blobs, the [`DsState`] controller model
//! and its `GameStream`/XInput mapper, the input-report serializer (report `0x01`) and the
//! output-report parser (report `0x02`, a game's rumble / lightbar / player-LED / adaptive-trigger
//! feedback). Neither half depends on a transport — the Linux backend writes `0x01` to `/dev/uhid`
//! and reads `0x02` via `UHID_OUTPUT`; the Windows backend pushes `0x01` to the UMDF driver and
//! pulls `0x02` back over its control channel — but both build/parse the exact same bytes.
//!
//! The descriptor + field layout are the canonical inputtino ones (games-on-whales/inputtino
//! `src/uhid/include/uhid/ps5.hpp`), so `hid-playstation` (Linux) and `hidclass` (Windows) bind the
//! same as a real USB DualSense.
use punktfunk_core::quic::HidOutput;
// Feature reports the host stack GET_REPORTs during init — without these replies the kernel
// (`hid-playstation`) never finishes calibration and creates no input devices. Verbatim from
// inputtino (each array's first byte is the report id). The pairing report carries a fixed
// virtual MAC.
#[rustfmt::skip]
// FIXME(cal-len): the descriptor declares report 0x05 as a 40-byte feature (id + 40 = 41 total),
// but this blob is 42 bytes (one trailing pad byte too many). Linux `hid-playstation` tolerates it
// (the backend is live-validated), and `hidclass` truncates to the declared length, so it is not
// currently blocking; trim the trailing 0x00 to 41 once a physical DualSense is available to
// re-verify motion calibration on both backends.
pub const DS_FEATURE_CALIBRATION: &[u8] = &[ // report 0x05 (motion calibration)
0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x27, 0xF0, 0xD8, 0x10, 0x27, 0xF0, 0xD8, 0x10,
0x27, 0xF0, 0xD8, 0xF4, 0x01, 0xF4, 0x01, 0x10, 0x27, 0xF0, 0xD8, 0x10, 0x27, 0xF0, 0xD8, 0x10,
0x27, 0xF0, 0xD8, 0x0B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
#[rustfmt::skip]
pub const DS_FEATURE_PAIRING: &[u8] = &[ // report 0x09 (pairing info: MAC at bytes 1..7)
0x09, 0x74, 0xE7, 0xD6, 0x3A, 0x53, 0x35, 0x08, 0x25, 0x00, 0x1E, 0x00, 0xEE, 0x74, 0xD0, 0xBC,
0x00, 0x00, 0x00, 0x00,
];
#[rustfmt::skip]
pub const DS_FEATURE_FIRMWARE: &[u8] = &[ // report 0x20 (firmware info / build date)
0x20, 0x4A, 0x75, 0x6E, 0x20, 0x31, 0x39, 0x20, 0x32, 0x30, 0x32, 0x33, 0x31, 0x34, 0x3A, 0x34,
0x37, 0x3A, 0x33, 0x34, 0x03, 0x00, 0x44, 0x00, 0x08, 0x02, 0x00, 0x01, 0x36, 0x00, 0x00, 0x01,
0xC1, 0xC8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x54, 0x01, 0x00, 0x00,
0x14, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
/// Sony DualSense USB HID report descriptor (273 bytes), verbatim from inputtino — the exact
/// descriptor `hid-playstation` (Linux) / `hidclass` (Windows) parses to bind a DualSense.
#[rustfmt::skip]
pub const DUALSENSE_RDESC: &[u8] = &[
0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x35,
0x09, 0x33, 0x09, 0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x06, 0x81, 0x02, 0x06,
0x00, 0xFF, 0x09, 0x20, 0x95, 0x01, 0x81, 0x02, 0x05, 0x01, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07,
0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x65, 0x00, 0x05,
0x09, 0x19, 0x01, 0x29, 0x0F, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0F, 0x81, 0x02, 0x06,
0x00, 0xFF, 0x09, 0x21, 0x95, 0x0D, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x22, 0x15, 0x00, 0x26,
0xFF, 0x00, 0x75, 0x08, 0x95, 0x34, 0x81, 0x02, 0x85, 0x02, 0x09, 0x23, 0x95, 0x2F, 0x91, 0x02,
0x85, 0x05, 0x09, 0x33, 0x95, 0x28, 0xB1, 0x02, 0x85, 0x08, 0x09, 0x34, 0x95, 0x2F, 0xB1, 0x02,
0x85, 0x09, 0x09, 0x24, 0x95, 0x13, 0xB1, 0x02, 0x85, 0x0A, 0x09, 0x25, 0x95, 0x1A, 0xB1, 0x02,
0x85, 0x20, 0x09, 0x26, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x21, 0x09, 0x27, 0x95, 0x04, 0xB1, 0x02,
0x85, 0x22, 0x09, 0x40, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x80, 0x09, 0x28, 0x95, 0x3F, 0xB1, 0x02,
0x85, 0x81, 0x09, 0x29, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x82, 0x09, 0x2A, 0x95, 0x09, 0xB1, 0x02,
0x85, 0x83, 0x09, 0x2B, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x84, 0x09, 0x2C, 0x95, 0x3F, 0xB1, 0x02,
0x85, 0x85, 0x09, 0x2D, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xA0, 0x09, 0x2E, 0x95, 0x01, 0xB1, 0x02,
0x85, 0xE0, 0x09, 0x2F, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x30, 0x95, 0x3F, 0xB1, 0x02,
0x85, 0xF1, 0x09, 0x31, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2, 0x09, 0x32, 0x95, 0x0F, 0xB1, 0x02,
0x85, 0xF4, 0x09, 0x35, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF5, 0x09, 0x36, 0x95, 0x03, 0xB1, 0x02,
0xC0,
];
pub const DS_VENDOR: u32 = 0x054C; // Sony Interactive Entertainment
pub const DS_PRODUCT: u32 = 0x0CE6; // DualSense Wireless Controller
/// USB input report `0x01` is 64 bytes total (report id + 63-byte body).
pub const DS_INPUT_REPORT_LEN: usize = 64;
/// The DualSense touchpad's reported resolution (the kernel exposes it as ABS_MT 0..1920/1080).
pub const DS_TOUCH_W: u16 = 1920;
pub const DS_TOUCH_H: u16 = 1080;
/// Bit positions inside the DualSense face/dpad button byte (`buttons[0]`, low nibble = hat).
pub mod btn0 {
pub const SQUARE: u8 = 0x10;
pub const CROSS: u8 = 0x20;
pub const CIRCLE: u8 = 0x40;
pub const TRIANGLE: u8 = 0x80;
}
/// `buttons[1]`: shoulders, triggers-as-buttons, create/options, stick clicks.
pub mod btn1 {
pub const L1: u8 = 0x01;
pub const R1: u8 = 0x02;
pub const L2: u8 = 0x04;
pub const R2: u8 = 0x08;
pub const CREATE: u8 = 0x10; // "Share"
pub const OPTIONS: u8 = 0x20;
pub const L3: u8 = 0x40;
pub const R3: u8 = 0x80;
}
/// `buttons[2]`: PS, touchpad click, mute (+ a rolling counter in the high bits).
pub mod btn2 {
pub const PS: u8 = 0x01;
pub const TOUCHPAD: u8 = 0x02;
#[allow(dead_code)]
pub const MUTE: u8 = 0x04;
}
/// One touchpad contact for the report.
#[derive(Clone, Copy, Default)]
pub struct Touch {
pub active: bool,
pub id: u8,
pub x: u16, // 0..DS_TOUCH_W
pub y: u16, // 0..DS_TOUCH_H
}
/// Full DualSense controller state to serialize into report `0x01`. Sticks/triggers are 8-bit
/// (`0x80` neutral for sticks, `0x00` released for triggers); `dpad` is the 8-way hat (`8` =
/// centered); `buttons[0..3]` are the packed DualSense button bytes; gyro/accel are raw i16.
#[derive(Clone, Copy, Default)]
pub struct DsState {
pub lx: u8,
pub ly: u8,
pub rx: u8,
pub ry: u8,
pub l2: u8,
pub r2: u8,
pub dpad: u8, // 0..7 direction, 8 = neutral
pub buttons: [u8; 4],
pub gyro: [i16; 3],
pub accel: [i16; 3],
pub touch: [Touch; 2],
}
impl DsState {
/// A centered, nothing-pressed state (sticks 0x80, dpad neutral).
pub fn neutral() -> DsState {
DsState {
lx: 0x80,
ly: 0x80,
rx: 0x80,
ry: 0x80,
dpad: 8,
..Default::default()
}
}
/// Map a GameStream/XInput pad frame (button bitmask + i16 sticks + u8 triggers) into the
/// DualSense report fields. Sticks are recentred to `0x80`; the Y axes are inverted (XInput
/// `+y = up`, DualSense `0 = up`). Triggers double as the L2/R2 buttons when pressed. Touchpad
/// + motion are filled separately from rich-input events.
pub fn from_gamepad(
buttons: u32,
lx: i16,
ly: i16,
rx: i16,
ry: i16,
lt: u8,
rt: u8,
) -> DsState {
use punktfunk_core::input::gamepad as gs;
let to_u8 = |v: i16| (((v as i32) + 32768) >> 8) as u8;
let on = |bit: u32| buttons & bit != 0;
let mut s = DsState {
lx: to_u8(lx),
ly: 255 - to_u8(ly),
rx: to_u8(rx),
ry: 255 - to_u8(ry),
l2: lt,
r2: rt,
..DsState::neutral()
};
s.set_dpad(
on(gs::BTN_DPAD_UP),
on(gs::BTN_DPAD_DOWN),
on(gs::BTN_DPAD_LEFT),
on(gs::BTN_DPAD_RIGHT),
);
let mut b0 = 0;
if on(gs::BTN_A) {
b0 |= btn0::CROSS;
}
if on(gs::BTN_B) {
b0 |= btn0::CIRCLE;
}
if on(gs::BTN_X) {
b0 |= btn0::SQUARE;
}
if on(gs::BTN_Y) {
b0 |= btn0::TRIANGLE;
}
s.buttons[0] = b0; // face buttons (high nibble); dpad merged in write_state
let mut b1 = 0;
if on(gs::BTN_LB) {
b1 |= btn1::L1;
}
if on(gs::BTN_RB) {
b1 |= btn1::R1;
}
if lt > 0 {
b1 |= btn1::L2;
}
if rt > 0 {
b1 |= btn1::R2;
}
if on(gs::BTN_BACK) {
b1 |= btn1::CREATE;
}
if on(gs::BTN_START) {
b1 |= btn1::OPTIONS;
}
if on(gs::BTN_LS_CLICK) {
b1 |= btn1::L3;
}
if on(gs::BTN_RS_CLICK) {
b1 |= btn1::R3;
}
s.buttons[1] = b1;
if on(gs::BTN_GUIDE) {
s.buttons[2] |= btn2::PS;
}
if on(gs::BTN_TOUCHPAD) {
s.buttons[2] |= btn2::TOUCHPAD;
}
s
}
/// Set the dpad hat from the four GameStream dpad booleans (up/down/left/right).
pub fn set_dpad(&mut self, up: bool, down: bool, left: bool, right: bool) {
// DualSense hat: 0=N,1=NE,2=E,3=SE,4=S,5=SW,6=W,7=NW,8=neutral.
self.dpad = match (up, right, down, left) {
(true, false, false, false) => 0,
(true, true, false, false) => 1,
(false, true, false, false) => 2,
(false, true, true, false) => 3,
(false, false, true, false) => 4,
(false, false, true, true) => 5,
(false, false, false, true) => 6,
(true, false, false, true) => 7,
_ => 8,
};
}
}
/// Serialize a full input report `0x01` (pure — unit-testable without a transport). Field
/// offsets per the kernel's `struct dualsense_input_report`, this report's one consumer:
/// x..rz 0-5, seq 6, buttons[4] 7-10, reserved[4] 11-14, gyro[3] 15-20, accel[3] 21-26,
/// sensor_timestamp 27-30, reserved2 31, points[2] 32-39 (static_assert(sizeof == 63)).
/// The report id occupies r[0], so struct offset N = r[N + 1].
pub fn serialize_state(r: &mut [u8; DS_INPUT_REPORT_LEN], st: &DsState, seq: u8, ts: u32) {
r[0] = 0x01; // report id; the struct fields follow (struct offset 0 == r[1])
r[1] = st.lx;
r[2] = st.ly;
r[3] = st.rx;
r[4] = st.ry;
r[5] = st.l2;
r[6] = st.r2;
r[7] = seq; // seq_number (struct off 6)
r[8] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // off 7: dpad + face buttons
r[9] = st.buttons[1]; // off 8
r[10] = st.buttons[2]; // off 9
r[11] = st.buttons[3]; // off 10
for (i, v) in st.gyro.iter().enumerate() {
r[16 + i * 2..18 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 15
}
for (i, v) in st.accel.iter().enumerate() {
r[22 + i * 2..24 + i * 2].copy_from_slice(&v.to_le_bytes()); // accel at struct off 21
}
r[28..32].copy_from_slice(&ts.to_le_bytes()); // sensor_timestamp (struct off 27)
pack_touch(&mut r[33..37], &st.touch[0]); // touch point 1 (struct off 32)
pack_touch(&mut r[37..41], &st.touch[1]); // touch point 2
// status byte (struct off 52 → r[53]) — hid-playstation reads battery here: low nibble =
// capacity (×10+5 %), high nibble = charging state (0 = discharging). A virtual pad has no
// real cell, so report "discharging, full" (0x0A → 100 %); leaving it 0 makes SteamOS / the
// kernel see ~5 % and warn "low battery". (We don't forward the client pad's real charge yet.)
r[53] = 0x0A;
}
fn pack_touch(dst: &mut [u8], t: &Touch) {
// byte0: bit7 = NOT active (1 = no contact), bits0-6 = contact id.
dst[0] = (t.id & 0x7F) | if t.active { 0 } else { 0x80 };
// The kernel advertises ABS_MT ranges 0..=W-1 / 0..=H-1 — never emit the size itself.
let (x, y) = (t.x.min(DS_TOUCH_W - 1), t.y.min(DS_TOUCH_H - 1));
dst[1] = (x & 0xFF) as u8;
dst[2] = (((x >> 8) & 0x0F) as u8) | (((y & 0x0F) as u8) << 4);
dst[3] = ((y >> 4) & 0xFF) as u8;
}
/// What one service pass extracted from the device's HID output reports.
/// Rich feedback (lightbar / player LEDs / adaptive triggers) rides the HID-output plane (0xCD);
/// motor rumble rides the universal rumble plane (0xCA) so non-DualSense clients still feel it.
#[derive(Default)]
pub struct DsFeedback {
pub hidout: Vec<HidOutput>,
/// `(low, high)` motor levels (0..=0xFFFF), if a report carried them.
pub rumble: Option<(u16, u16)>,
}
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`]. The byte layout below is
/// the USB DualSense common report; only the well-understood fields (motor rumble, lightbar RGB,
/// player LEDs) are surfaced — adaptive-trigger blocks are forwarded raw for the client.
///
/// Every field is gated on the report's valid-flags (`valid_flag0` at data[1], `valid_flag1`
/// at data[2]) — writers only set the bits for fields they mean to change (the rest is zeroed),
/// so an ungated parse would turn every plain rumble write into a lightbar-off + triggers-off
/// broadcast.
pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
// data[0] is the report id (0x02). Be defensive about short reports.
if data.first() != Some(&0x02) || data.len() < 48 {
return;
}
let flag0 = data[1]; // BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2
let flag1 = data[2]; // BIT2 lightbar, BIT4 player indicators
// Motor rumble: high-frequency (small/right) motor at data[3], low-frequency (big/left) at
// data[4]. Scale 0..255 → 0..0xFFFF, same (low, high) convention as the uinput pad's mixer,
// and route to the universal rumble plane (0xCA).
if flag0 & 0x03 != 0 {
let high = (data[3] as u16) << 8;
let low = (data[4] as u16) << 8;
fb.rumble = Some((low, high));
}
// Lightbar RGB (USB common report: bytes 45..48). Player LEDs at byte 44.
if flag1 & 0x04 != 0 {
let (r, g, b) = (data[45], data[46], data[47]);
fb.hidout.push(HidOutput::Led { pad, r, g, b });
}
if flag1 & 0x10 != 0 {
fb.hidout.push(HidOutput::PlayerLeds {
pad,
bits: data[44] & 0x1F,
});
}
// Adaptive-trigger parameter blocks, 11 bytes each: the RIGHT trigger comes FIRST in the
// report (bytes 11..22), the left at 22..33 — per SDL's DS5EffectsState_t / inputtino's
// ps5.hpp. Wire convention: which 0 = L2, 1 = R2.
if data.len() >= 33 {
if flag0 & 0x04 != 0 {
fb.hidout.push(HidOutput::Trigger {
pad,
which: 1,
effect: data[11..22].to_vec(),
});
}
if flag0 & 0x08 != 0 {
fb.hidout.push(HidOutput::Trigger {
pad,
which: 0,
effect: data[22..33].to_vec(),
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A DualSense USB output report (`0x02`) with all valid-flags set parses into motor
/// rumble (0xCA), lightbar, player LEDs, and both adaptive-trigger blocks (0xCD) — with
/// the report's right-trigger-first layout mapped onto the wire's `which` (0 = L2).
#[test]
fn parse_output_report() {
let mut data = vec![0u8; 48];
data[0] = 0x02; // report id
data[1] = 0x0F; // valid_flag0: vibration + haptics + R2 + L2 triggers
data[2] = 0x14; // valid_flag1: lightbar + player indicators
data[3] = 0x80; // right (high-freq) motor
data[4] = 0x40; // left (low-freq) motor
data[11] = 0x21; // right-trigger block mode byte (report bytes 11..22)
data[22] = 0x26; // left-trigger block mode byte (report bytes 22..33)
data[44] = 0x03; // player LEDs (low 5 bits)
data[45] = 10; // R
data[46] = 20; // G
data[47] = 30; // B
let mut fb = DsFeedback::default();
parse_ds_output(0, &data, &mut fb);
// (low, high) = (left<<8, right<<8).
assert_eq!(fb.rumble, Some((0x4000, 0x8000)));
assert!(fb.hidout.contains(&HidOutput::Led {
pad: 0,
r: 10,
g: 20,
b: 30
}));
assert!(fb
.hidout
.contains(&HidOutput::PlayerLeds { pad: 0, bits: 3 }));
// The report's FIRST block (bytes 11..22) is the RIGHT trigger → wire which = 1.
let triggers: Vec<_> = fb
.hidout
.iter()
.filter_map(|h| match h {
HidOutput::Trigger { which, effect, .. } => Some((*which, effect[0])),
_ => None,
})
.collect();
assert_eq!(triggers, vec![(1, 0x21), (0, 0x26)]);
}
/// Writers set only the valid-flag bits for the fields they mean to change (the rest of the
/// report is zeroed) — a plain rumble write must NOT blank the lightbar / player LEDs /
/// triggers, and an LED-only write must not stop the motors.
#[test]
fn parse_output_respects_valid_flags() {
// Rumble write: only the vibration flags set, everything else zero.
let mut data = vec![0u8; 48];
data[0] = 0x02;
data[1] = 0x03; // compatible vibration + haptics select
data[3] = 0xFF;
data[4] = 0xFF;
let mut fb = DsFeedback::default();
parse_ds_output(0, &data, &mut fb);
assert_eq!(fb.rumble, Some((0xFF00, 0xFF00)));
assert!(fb.hidout.is_empty(), "rumble write must not emit hidout");
// Lightbar-only write: no rumble surfaced (would otherwise spam rumble-stops).
let mut data = vec![0u8; 48];
data[0] = 0x02;
data[2] = 0x04; // lightbar control enable
data[45] = 1;
let mut fb = DsFeedback::default();
parse_ds_output(0, &data, &mut fb);
assert!(fb.rumble.is_none());
assert_eq!(fb.hidout.len(), 1);
assert!(matches!(fb.hidout[0], HidOutput::Led { r: 1, .. }));
}
/// The input report's sensor/touch bytes must land exactly where the kernel's
/// `struct dualsense_input_report` reads them (gyro at struct offset 15, accel 21,
/// timestamp 27, touch points 32 — report byte = struct offset + 1). A one-byte slip
/// here turns client motion into noise and conjures phantom touch contacts.
#[test]
fn input_report_layout_matches_hid_playstation() {
let mut st = DsState::neutral();
st.gyro = [0x1122, 0x3344, 0x5566];
st.accel = [0x778, 0x99A, 0xBBC];
st.touch[0] = Touch {
active: true,
id: 5,
x: 0x123,
y: 0x356,
};
// touch[1] stays inactive — its NOT-active bit must be set.
let mut r = [0u8; DS_INPUT_REPORT_LEN];
serialize_state(&mut r, &st, 7, 0xAABBCCDD);
assert_eq!(r[0], 0x01);
assert_eq!(r[7], 7); // seq_number (struct off 6)
assert_eq!(&r[16..22], &[0x22, 0x11, 0x44, 0x33, 0x66, 0x55]); // gyro LE
assert_eq!(&r[22..28], &[0x78, 0x07, 0x9A, 0x09, 0xBC, 0x0B]); // accel LE
assert_eq!(&r[28..32], &[0xDD, 0xCC, 0xBB, 0xAA]); // sensor_timestamp LE
// Touch point 1 at struct off 32 = r[33..37]: contact byte (active → bit7 clear),
// then 12-bit x / 12-bit y packed.
assert_eq!(r[33], 5);
assert_eq!(r[34], 0x23);
assert_eq!(r[35], 0x61); // x_hi nibble 0x1 | (y & 0xF) << 4 (y=0x356 → 0x6 << 4)
assert_eq!(r[36], 0x35); // y >> 4
assert_eq!(r[37] & 0x80, 0x80); // touch point 2 inactive
// status byte (struct off 52): discharging (high nibble 0) + full capacity (low nibble
// 0xA → 100 %), so SteamOS/hid-playstation never reports a false "low battery".
assert_eq!(r[53], 0x0A);
}
/// The wire touchpad-click bit (Moonlight's extended position) lands in `buttons[2]`.
#[test]
fn from_gamepad_maps_touchpad_click() {
use punktfunk_core::input::gamepad as gs;
let s = DsState::from_gamepad(gs::BTN_TOUCHPAD | gs::BTN_GUIDE, 0, 0, 0, 0, 0, 0);
assert_eq!(s.buttons[2], btn2::PS | btn2::TOUCHPAD);
let s = DsState::from_gamepad(gs::BTN_A, 0, 0, 0, 0, 0, 0);
assert_eq!(s.buttons[2], 0);
}
/// A short / wrong-id report yields nothing.
#[test]
fn parse_output_rejects_garbage() {
let mut fb = DsFeedback::default();
parse_ds_output(0, &[0x01, 0, 0], &mut fb); // wrong report id, too short
assert!(fb.rumble.is_none());
assert!(fb.hidout.is_empty());
}
}
@@ -0,0 +1,442 @@
//! Virtual Sony DualSense on Windows via the UMDF minidriver (`packaging/windows/dualsense-driver`).
//!
//! 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 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**
//! `Global\pfds-shm-<idx>` (256 B: magic `u32@0`, input report `@8`, output seq `u32@72`, output
//! report `@76`). The host creates the section (privileged → a permissive SDDL so the WUDFHost can
//! open it); the driver maps it from its timer, feeds game `READ_REPORT`s from the input bytes, and
//! publishes a game's `0x02` (rumble / lightbar / player-LEDs / adaptive triggers) into the output
//! bytes. `hidclass` gates the 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 the `root\pf_dualsense` devnode on open and
//! `SwDeviceClose`s it on drop, so the virtual DualSense appears/disappears with the session —
//! matching the Linux UHID pad. (The driver itself must already be installed; the installer stages it.)
use super::dualsense_proto::{
parse_ds_output, serialize_state, DsFeedback, DsState, DS_INPUT_REPORT_LEN, DS_TOUCH_H,
DS_TOUCH_W,
};
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use anyhow::{anyhow, Result};
use punktfunk_core::quic::{HidOutput, RichInput};
use std::ffi::c_void;
use std::time::{Duration, Instant};
use windows::core::{w, HRESULT, HSTRING, PCWSTR};
use windows::Win32::Devices::Enumeration::Pnp::{
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
};
use windows::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE};
use windows::Win32::Security::Authorization::{
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
};
use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
use windows::Win32::System::Memory::{
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
};
use windows::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject};
/// Shared-section layout — must match `packaging/windows/dualsense-driver/src/lib.rs`.
const SHM_SIZE: usize = 256;
const SHM_MAGIC: u32 = 0x5046_4453; // "PFDS"
const OFF_INPUT: usize = 8;
const OFF_OUT_SEQ: usize = 72;
const OFF_OUTPUT: usize = 76;
/// A single virtual DualSense: the `root\pf_dualsense` software devnode (the driver loads on it and
/// the HID DualSense appears to games) plus the shared-memory section the driver maps. Dropping it
/// removes the devnode (`SwDeviceClose`) and unmaps + closes the section.
struct DsWinPad {
/// Per-session devnode from SwDeviceCreate, when it succeeds. `None` falls back to an out-of-band
/// `pf_dualsense` devnode (installer/devgen).
hsw: Option<HSWDEVICE>,
map: HANDLE,
view: *mut u8,
seq: u8,
ts: u32,
last_out_seq: u32,
}
/// Context for the async `SwDeviceCreate` completion callback: the event to signal + the result.
#[repr(C)]
struct SwCreateCtx {
event: HANDLE,
result: HRESULT,
}
/// `SwDeviceCreate` fires this on a worker thread once the device is created. We stash the result and
/// wake the waiting [`create_swdevice`]; the creator blocks on the event, so there's no concurrent
/// access to `*ctx`.
unsafe extern "system" fn sw_create_cb(
_dev: HSWDEVICE,
result: HRESULT,
ctx: *const c_void,
_id: PCWSTR,
) {
if !ctx.is_null() {
let c = ctx as *mut SwCreateCtx;
// SAFETY: c is the &mut SwCreateCtx the creator passed; it outlives this callback (the
// creator waits on the event before dropping it).
unsafe {
(*c).result = result;
let _ = SetEvent((*c).event);
}
}
}
/// Spawn the virtual DualSense software device under enumerator `punktfunk` (hardware id
/// `pf_dualsense`, which the INF matches). The returned `HSWDEVICE` owns the devnode for the session
/// — `SwDeviceClose` removes it.
///
/// NB: enumerator names with an underscore (`pf_dualsense`) get E_INVALIDARG — hence `punktfunk`.
/// TODO: a SECOND E_INVALIDARG remains — passing the completion callback is rejected (callback-absent
/// is accepted but then the devnode doesn't materialize). Until that's resolved [`DsWinPad::open`]
/// treats a failure as non-fatal and relies on an out-of-band `pf_dualsense` devnode (installer /
/// dev-box `devgen`); see `docs/windows-dualsense-scoping.md`.
fn create_swdevice() -> Result<HSWDEVICE> {
let hwids: Vec<u16> = "pf_dualsense".encode_utf16().chain([0u16, 0u16]).collect();
let desc: Vec<u16> = "punktfunk Virtual DualSense"
.encode_utf16()
.chain(std::iter::once(0))
.collect();
// SAFETY: zeroed then the fields we use are set; cbSize identifies the struct version.
let mut info: SW_DEVICE_CREATE_INFO = unsafe { std::mem::zeroed() };
info.cbSize = std::mem::size_of::<SW_DEVICE_CREATE_INFO>() as u32;
info.pszzHardwareIds = PCWSTR(hwids.as_ptr());
info.pszDeviceDescription = PCWSTR(desc.as_ptr());
// SWDeviceCapabilities: DriverRequired (8) | SilentInstall (2) | Removable (1).
info.CapabilityFlags = 0x0000_000B;
// SAFETY: a manual-reset, initially-unsignaled, unnamed event.
let event = unsafe { CreateEventW(None, true, false, PCWSTR::null())? };
let mut ctx = SwCreateCtx {
event,
result: HRESULT(0),
};
// SAFETY: info + hwids/desc outlive the call; ctx outlives the callback (we wait below).
// windows-rs returns the HSWDEVICE (the C out-param) as the Result value.
let hsw = match unsafe {
SwDeviceCreate(
w!("punktfunk"),
w!("HTREE\\ROOT\\0"),
&info,
None,
Some(sw_create_cb),
Some(&mut ctx as *mut SwCreateCtx as *const c_void),
)
} {
Ok(h) => h,
Err(e) => {
// SAFETY: event is valid.
unsafe {
let _ = CloseHandle(event);
}
return Err(anyhow!("SwDeviceCreate failed: {e}"));
}
};
// SAFETY: event is valid; block up to 10s for the creation callback.
unsafe {
WaitForSingleObject(event, 10_000);
let _ = CloseHandle(event);
}
if ctx.result.is_err() {
// SAFETY: hsw is the handle SwDeviceCreate returned.
unsafe { SwDeviceClose(hsw) };
return Err(anyhow!("SwDeviceCreate callback reported {:?}", ctx.result));
}
Ok(hsw)
}
impl DsWinPad {
/// Create + map the section `Global\pfds-shm-<index>`, stamp the magic, then spawn the
/// `root\pf_dualsense` devnode (the driver loads on it and maps the section). The devnode lives
/// for the pad's lifetime — dropping the pad removes it (`SwDeviceClose`).
fn open(index: u8) -> Result<DsWinPad> {
let name = HSTRING::from(format!("Global\\pfds-shm-{index}"));
// A permissive DACL so the WUDFHost (whatever account it runs as) can open the section.
let mut psd = PSECURITY_DESCRIPTOR::default();
// SAFETY: the SDDL literal is valid; psd receives an allocated descriptor (freed by the OS
// when the process exits — acceptable for a host-lifetime object).
unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
w!("D:(A;;GA;;;WD)"),
SDDL_REVISION_1,
&mut psd,
None,
)?;
}
let sa = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: psd.0,
bInheritHandle: false.into(),
};
// SAFETY: anonymous (pagefile-backed) section of SHM_SIZE bytes with the SDDL above.
let map = unsafe {
CreateFileMappingW(
INVALID_HANDLE_VALUE,
Some(&sa),
PAGE_READWRITE,
0,
SHM_SIZE as u32,
PCWSTR(name.as_ptr()),
)?
};
// SAFETY: map is a valid section handle; map the whole thing.
let view = unsafe { MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, SHM_SIZE) };
if view.Value.is_null() {
// SAFETY: map is valid.
unsafe {
let _ = CloseHandle(map);
}
return Err(anyhow!("MapViewOfFile failed for {name}"));
}
let base = view.Value as *mut u8;
// Zero the section then stamp the magic LAST (the driver only accepts it once magic is set).
// SAFETY: base points at SHM_SIZE writable bytes.
unsafe {
std::ptr::write_bytes(base, 0, SHM_SIZE);
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS_INPUT_REPORT_LEN], {
let mut r = [0u8; DS_INPUT_REPORT_LEN];
serialize_state(&mut r, &DsState::neutral(), 0, 0);
r
});
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
}
// Best-effort: spawn a per-session devnode via SwDeviceCreate. It currently fails with a
// SwDevice quirk (see create_swdevice), so on failure we keep the section + data plane and
// rely on an out-of-band `pf_dualsense` devnode (installer / dev-box devgen).
let hsw = match create_swdevice() {
Ok(h) => Some(h),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; using an out-of-band pf_dualsense devnode");
None
}
};
Ok(DsWinPad {
hsw,
map,
view: base,
seq: 0,
ts: 0,
last_out_seq: 0,
})
}
/// Serialize `st` into report `0x01` and publish it to the section's input slot.
fn write_state(&mut self, st: &DsState) {
self.seq = self.seq.wrapping_add(1);
self.ts = self.ts.wrapping_add(1);
let mut r = [0u8; DS_INPUT_REPORT_LEN];
serialize_state(&mut r, st, self.seq, self.ts);
// SAFETY: view points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
unsafe { std::ptr::copy_nonoverlapping(r.as_ptr(), self.view.add(OFF_INPUT), r.len()) };
}
/// Poll the section's output slot; parse a new `0x02` report (rumble / LEDs / triggers) into a
/// [`DsFeedback`] for pad `pad`. Returns empty feedback if the driver hasn't published anything new.
fn service(&mut self, pad: u8) -> DsFeedback {
let mut fb = DsFeedback::default();
// SAFETY: view points at SHM_SIZE bytes.
let seq = unsafe { std::ptr::read_unaligned(self.view.add(OFF_OUT_SEQ) as *const u32) };
if seq != self.last_out_seq {
self.last_out_seq = seq;
let mut out = [0u8; 64];
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
unsafe {
std::ptr::copy_nonoverlapping(self.view.add(OFF_OUTPUT), out.as_mut_ptr(), 64)
};
parse_ds_output(pad, &out, &mut fb);
}
fb
}
}
impl Drop for DsWinPad {
fn drop(&mut self) {
// SAFETY: hsw (if any) owns the devnode; view/map from MapViewOfFile/CreateFileMappingW.
unsafe {
if let Some(h) = self.hsw {
SwDeviceClose(h);
}
let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS {
Value: self.view as *mut c_void,
});
let _ = CloseHandle(self.map);
}
}
}
/// All virtual DualSense pads of a session — the Windows analogue of
/// [`DualSenseManager`](super::dualsense::DualSenseManager). Same method surface so the session input
/// thread drives either backend identically.
pub struct DualSenseWindowsManager {
pads: Vec<Option<DsWinPad>>,
state: Vec<DsState>,
last_rumble: Vec<(u16, u16)>,
last_write: Vec<Instant>,
broken: bool,
}
impl Default for DualSenseWindowsManager {
fn default() -> DualSenseWindowsManager {
DualSenseWindowsManager::new()
}
}
impl DualSenseWindowsManager {
pub fn new() -> DualSenseWindowsManager {
DualSenseWindowsManager {
pads: (0..MAX_PADS).map(|_| None).collect(),
state: vec![DsState::neutral(); MAX_PADS],
last_rumble: vec![(0, 0); MAX_PADS],
last_write: vec![Instant::now(); MAX_PADS],
broken: false,
}
}
/// Handle one decoded controller event (create/destroy by mask, then merge button/stick state).
pub fn handle(&mut self, ev: &GamepadEvent) {
match ev {
GamepadEvent::Arrival { index, kind, .. } => {
tracing::info!(index, kind, "controller arrival (DualSense/Windows)");
self.ensure(*index as usize);
}
GamepadEvent::State(f) => {
let idx = f.index as usize;
if idx >= MAX_PADS {
return;
}
for (i, slot) in self.pads.iter_mut().enumerate() {
if slot.is_some() && f.active_mask & (1 << i) == 0 {
tracing::info!(index = i, "controller unplugged (DualSense/Windows)");
*slot = None;
self.state[i] = DsState::neutral();
self.last_rumble[i] = (0, 0);
}
}
if f.active_mask & (1 << idx) == 0 {
return;
}
self.ensure(idx);
let prev = self.state[idx];
let mut s = DsState::from_gamepad(
f.buttons,
f.ls_x,
f.ls_y,
f.rs_x,
f.rs_y,
f.left_trigger,
f.right_trigger,
);
s.touch = prev.touch;
s.gyro = prev.gyro;
s.accel = prev.accel;
self.state[idx] = s;
self.write(idx);
}
}
}
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad.
pub fn apply_rich(&mut self, rich: RichInput) {
let idx = match rich {
RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize,
};
if idx >= MAX_PADS || self.pads[idx].is_none() {
return;
}
match rich {
RichInput::Touchpad {
finger,
active,
x,
y,
..
} => {
let slot = (finger as usize).min(1);
let t = &mut self.state[idx].touch[slot];
t.active = active;
t.id = slot as u8;
t.x = ((x as u32 * (DS_TOUCH_W - 1) as u32) / u16::MAX as u32) as u16;
t.y = ((y as u32 * (DS_TOUCH_H - 1) as u32) / u16::MAX as u32) as u16;
}
RichInput::Motion { gyro, accel, .. } => {
self.state[idx].gyro = gyro;
self.state[idx].accel = accel;
}
}
self.write(idx);
}
fn write(&mut self, idx: usize) {
let st = self.state[idx];
if let Some(pad) = self.pads[idx].as_mut() {
pad.write_state(&st);
}
self.last_write[idx] = Instant::now();
}
/// Re-emit each live pad's current report if it's been silent for `max_gap` (the driver's timer
/// streams whatever's in the section, so this just keeps the section fresh / future-proofs parity
/// with the UHID backend's heartbeat).
pub fn heartbeat(&mut self, max_gap: Duration) {
let now = Instant::now();
for i in 0..self.pads.len() {
if self.pads[i].is_some() && now.duration_since(self.last_write[i]) >= max_gap {
self.write(i);
}
}
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
return;
}
match DsWinPad::open(idx as u8) {
Ok(p) => {
tracing::info!(
index = idx,
"virtual DualSense created (Windows UMDF shm channel)"
);
self.pads[idx] = Some(p);
self.state[idx] = DsState::neutral();
self.last_rumble[idx] = (0, 0);
self.last_write[idx] = Instant::now();
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — controller input disabled");
self.broken = true;
}
}
}
/// Service every pad: poll the section for a game's feedback. `rumble` fires `(index, low, high)`
/// only on change (universal 0xCA plane); `hidout` fires for each rich DualSense feedback event
/// (lightbar / player LEDs / adaptive triggers — 0xCD plane).
pub fn pump(
&mut self,
mut rumble: impl FnMut(u16, u16, u16),
mut hidout: impl FnMut(HidOutput),
) {
for i in 0..self.pads.len() {
let Some(pad) = self.pads[i].as_mut() else {
continue;
};
let fb = pad.service(i as u8);
if let Some(r) = fb.rumble {
if self.last_rumble[i] != r {
self.last_rumble[i] = r;
rumble(i as u16, r.0, r.1);
}
}
for h in fb.hidout {
hidout(h);
}
}
}
}
@@ -13,7 +13,7 @@
//! resolution differ. The report descriptor + struct offsets are the canonical real-DS4-USB layout //! resolution differ. The report descriptor + struct offsets are the canonical real-DS4-USB layout
//! the kernel `struct dualshock4_input_report_usb` / `_output_report_common` parse. //! the kernel `struct dualshock4_input_report_usb` / `_output_report_common` parse.
use super::dualsense::{DsState, Touch}; use super::dualsense_proto::{DsState, Touch};
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use punktfunk_core::quic::{HidOutput, RichInput}; use punktfunk_core::quic::{HidOutput, RichInput};
+49 -1
View File
@@ -152,7 +152,8 @@ fn real_main() -> Result<()> {
// sends back. Verify with `evtest` / `ls /dev/input/by-id/*Punktfunk*` / `wpctl status`. // sends back. Verify with `evtest` / `ls /dev/input/by-id/*Punktfunk*` / `wpctl status`.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
Some("dualsense-test") => { Some("dualsense-test") => {
use inject::dualsense::{DsState, DualSensePad}; use inject::dualsense::DualSensePad;
use inject::dualsense_proto::DsState;
let secs: u64 = args let secs: u64 = args
.iter() .iter()
.skip_while(|a| *a != "--seconds") .skip_while(|a| *a != "--seconds")
@@ -200,6 +201,53 @@ fn real_main() -> Result<()> {
println!("dualsense-test: done"); println!("dualsense-test: done");
Ok(()) Ok(())
} }
// Windows: create a virtual DualSense via the UMDF driver (SwDeviceCreate per-session devnode
// + the shared-memory channel) and hold it, pushing one fixed frame (Cross + LS-right). Drives
// the real DualSenseWindowsManager, so it validates the device lifecycle end to end. Verify
// while it holds: `Get-PnpDevice` shows a VID_054C device, and a HID read returns the pushed
// report (byte1=0xC0, byte8=0x28). On exit the pad drops → SwDeviceClose removes the devnode.
#[cfg(target_os = "windows")]
Some("dualsense-windows-test") => {
use crate::gamestream::gamepad::{GamepadEvent, GamepadFrame};
use inject::dualsense_windows::DualSenseWindowsManager;
use std::time::{Duration, Instant};
let secs: u64 = args
.iter()
.skip_while(|a| *a != "--seconds")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(20);
let mut mgr = DualSenseWindowsManager::new();
// Arrival creates the pad (SwDeviceCreate + section); State pushes the report.
mgr.handle(&GamepadEvent::Arrival {
index: 0,
kind: 2,
capabilities: 0,
});
// ls_x 16384 → report byte1 0xC0; BTN_A (Cross) → report byte8 0x28.
mgr.handle(&GamepadEvent::State(GamepadFrame {
index: 0,
active_mask: 1,
buttons: punktfunk_core::input::gamepad::BTN_A,
left_trigger: 0,
right_trigger: 0,
ls_x: 16384,
ls_y: 0,
rs_x: 0,
rs_y: 0,
}));
println!(
"virtual DualSense created via SwDeviceCreate (VID 054C/PID 0CE6). Holding {secs}s — \
verify Get-PnpDevice VID_054C + a HID read (expect byte1=0xC0, byte8=0x28)."
);
let deadline = Instant::now() + Duration::from_secs(secs);
while Instant::now() < deadline {
mgr.pump(|_, _, _| {}, |_| {});
std::thread::sleep(Duration::from_millis(50));
}
println!("dualsense-windows-test: done (devnode removed)");
Ok(())
}
// Capture→encode→file pipeline spike (dev tool). // Capture→encode→file pipeline spike (dev tool).
Some("spike") => spike::run(parse_spike(&args[1..])?), Some("spike") => spike::run(parse_spike(&args[1..])?),
// Native punktfunk/1 host (QUIC control plane + UDP data plane). // Native punktfunk/1 host (QUIC control plane + UDP data plane).
+64 -24
View File
@@ -1176,14 +1176,17 @@ fn mic_service_thread(rx: std::sync::mpsc::Receiver<Vec<u8>>) {
/// - `DualShock4` (`PUNKTFUNK_GAMEPAD=ps4`) — virtual DualShock 4 via the same UHID path: lightbar, /// - `DualShock4` (`PUNKTFUNK_GAMEPAD=ps4`) — virtual DualShock 4 via the same UHID path: lightbar,
/// touchpad, motion, rumble (DualSense minus adaptive triggers / player LEDs / mute). /// touchpad, motion, rumble (DualSense minus adaptive triggers / player LEDs / mute).
/// ///
/// The two UHID pads are Linux-only; off Linux the resolver already folds them (and One/Series) /// DualShock 4 + One/Series are Linux-only; DualSense has both a Linux (UHID) and a Windows (UMDF
/// into `Xbox360`, so a non-Linux build never constructs them. /// minidriver) backend. The resolver folds any type a platform can't build into `Xbox360`, so a
/// build never constructs a variant it lacks.
enum PadBackend { enum PadBackend {
Xbox360(crate::inject::gamepad::GamepadManager), Xbox360(crate::inject::gamepad::GamepadManager),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
DualSense(crate::inject::dualsense::DualSenseManager), DualSense(crate::inject::dualsense::DualSenseManager),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
DualShock4(crate::inject::dualshock4::DualShock4Manager), DualShock4(crate::inject::dualshock4::DualShock4Manager),
#[cfg(target_os = "windows")]
DualSenseWindows(crate::inject::dualsense_windows::DualSenseWindowsManager),
} }
impl PadBackend { impl PadBackend {
@@ -1209,6 +1212,13 @@ impl PadBackend {
} }
_ => {} _ => {}
} }
#[cfg(target_os = "windows")]
if kind == GamepadPref::DualSense {
tracing::info!("gamepad backend: virtual DualSense (Windows UMDF shm channel)");
return PadBackend::DualSenseWindows(
crate::inject::dualsense_windows::DualSenseWindowsManager::new(),
);
}
let _ = kind; let _ = kind;
PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::new()) PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::new())
} }
@@ -1220,17 +1230,22 @@ impl PadBackend {
PadBackend::DualSense(m) => m.handle(ev), PadBackend::DualSense(m) => m.handle(ev),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
PadBackend::DualShock4(m) => m.handle(ev), PadBackend::DualShock4(m) => m.handle(ev),
#[cfg(target_os = "windows")]
PadBackend::DualSenseWindows(m) => m.handle(ev),
} }
} }
/// Apply a rich client→host event (touchpad / motion). A no-op for the X-Box pad, which has no /// Apply a rich client→host event (touchpad / motion). A no-op for the X-Box pad, which has no
/// equivalent; the DualSense and DualShock 4 pads both carry a touchpad + motion sensors. /// equivalent; the DualSense and DualShock 4 pads both carry a touchpad + motion sensors.
fn apply_rich(&mut self, _rich: punktfunk_core::quic::RichInput) { fn apply_rich(&mut self, _rich: punktfunk_core::quic::RichInput) {
#[cfg(target_os = "linux")]
match self { match self {
PadBackend::DualSense(m) => m.apply_rich(_rich),
PadBackend::DualShock4(m) => m.apply_rich(_rich),
PadBackend::Xbox360(_) => {} PadBackend::Xbox360(_) => {}
#[cfg(target_os = "linux")]
PadBackend::DualSense(m) => m.apply_rich(_rich),
#[cfg(target_os = "linux")]
PadBackend::DualShock4(m) => m.apply_rich(_rich),
#[cfg(target_os = "windows")]
PadBackend::DualSenseWindows(m) => m.apply_rich(_rich),
} }
} }
@@ -1252,6 +1267,8 @@ impl PadBackend {
PadBackend::DualSense(m) => m.pump(rumble, hidout), PadBackend::DualSense(m) => m.pump(rumble, hidout),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
PadBackend::DualShock4(m) => m.pump(rumble, hidout), PadBackend::DualShock4(m) => m.pump(rumble, hidout),
#[cfg(target_os = "windows")]
PadBackend::DualSenseWindows(m) => m.pump(rumble, hidout),
} }
} }
@@ -1267,6 +1284,8 @@ impl PadBackend {
PadBackend::DualSense(m) => m.heartbeat(std::time::Duration::from_millis(8)), PadBackend::DualSense(m) => m.heartbeat(std::time::Duration::from_millis(8)),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
PadBackend::DualShock4(m) => m.heartbeat(std::time::Duration::from_millis(8)), PadBackend::DualShock4(m) => m.heartbeat(std::time::Duration::from_millis(8)),
#[cfg(target_os = "windows")]
PadBackend::DualSenseWindows(m) => m.heartbeat(std::time::Duration::from_millis(8)),
} }
} }
} }
@@ -1555,7 +1574,7 @@ fn synthetic_stream(
/// 4) need it — off Linux any such wish degrades to X-Box 360 (never an error: a session without rich /// 4) need it — off Linux any such wish degrades to X-Box 360 (never an error: a session without rich
/// pads still streams). X-Box One/Series is a distinct uinput *identity* on Linux, but XInput-identical /// pads still streams). X-Box One/Series is a distinct uinput *identity* on Linux, but XInput-identical
/// to the 360 pad on Windows (ViGEm has no One target), so it degrades to `Xbox360` there. /// to the 360 pad on Windows (ViGEm has no One target), so it degrades to `Xbox360` there.
fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool) -> GamepadPref { fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool) -> GamepadPref {
let want = match pref { let want = match pref {
GamepadPref::Auto => env GamepadPref::Auto => env
.and_then(GamepadPref::from_name) .and_then(GamepadPref::from_name)
@@ -1563,7 +1582,8 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool) -> GamepadPre
explicit => explicit, explicit => explicit,
}; };
match want { match want {
GamepadPref::DualSense if linux => GamepadPref::DualSense, // DualSense: Linux UHID hid-playstation, or the Windows UMDF minidriver backend.
GamepadPref::DualSense if linux || windows => GamepadPref::DualSense,
GamepadPref::DualShock4 if linux => GamepadPref::DualShock4, GamepadPref::DualShock4 if linux => GamepadPref::DualShock4,
// One/Series: a real, distinct uinput identity on Linux; folded into the 360 backend on // One/Series: a real, distinct uinput identity on Linux; folded into the 360 backend on
// Windows (XInput can't tell them apart anyway). // Windows (XInput can't tell them apart anyway).
@@ -1576,7 +1596,12 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool) -> GamepadPre
/// [`pick_gamepad`]). Always concrete — the `Welcome` reports what the session will drive. /// [`pick_gamepad`]). Always concrete — the `Welcome` reports what the session will drive.
fn resolve_gamepad(pref: GamepadPref) -> GamepadPref { fn resolve_gamepad(pref: GamepadPref) -> GamepadPref {
let env = std::env::var("PUNKTFUNK_GAMEPAD").ok(); let env = std::env::var("PUNKTFUNK_GAMEPAD").ok();
let chosen = pick_gamepad(pref, env.as_deref(), cfg!(target_os = "linux")); let chosen = pick_gamepad(
pref,
env.as_deref(),
cfg!(target_os = "linux"),
cfg!(target_os = "windows"),
);
match pref { match pref {
GamepadPref::Auto => { GamepadPref::Auto => {
// The operator's env knob deserves a diagnostic when it didn't drive the // The operator's env knob deserves a diagnostic when it didn't drive the
@@ -3040,26 +3065,41 @@ mod tests {
#[test] #[test]
fn gamepad_resolution_precedence() { fn gamepad_resolution_precedence() {
use GamepadPref::*; use GamepadPref::*;
// Trailing args are (linux, windows).
// An explicit client choice wins over the env var. // An explicit client choice wins over the env var.
assert_eq!(pick_gamepad(DualSense, Some("xbox360"), true), DualSense); assert_eq!(
assert_eq!(pick_gamepad(Xbox360, Some("dualsense"), true), Xbox360); pick_gamepad(DualSense, Some("xbox360"), true, false),
DualSense
);
assert_eq!(
pick_gamepad(Xbox360, Some("dualsense"), true, false),
Xbox360
);
// Client Auto defers to the env var. // Client Auto defers to the env var.
assert_eq!(pick_gamepad(Auto, Some("dualsense"), true), DualSense); assert_eq!(
assert_eq!(pick_gamepad(Auto, Some("xbox360"), true), Xbox360); pick_gamepad(Auto, Some("dualsense"), true, false),
DualSense
);
assert_eq!(pick_gamepad(Auto, Some("xbox360"), true, false), Xbox360);
// Auto + no env (or an unparseable one) → X-Box 360. // Auto + no env (or an unparseable one) → X-Box 360.
assert_eq!(pick_gamepad(Auto, None, true), Xbox360); assert_eq!(pick_gamepad(Auto, None, true, false), Xbox360);
assert_eq!(pick_gamepad(Auto, Some("bogus"), true), Xbox360); assert_eq!(pick_gamepad(Auto, Some("bogus"), true, false), Xbox360);
// DualSense degrades to X-Box 360 where the backend doesn't exist (non-Linux). // DualSense: honored on Linux (UHID) AND Windows (UMDF minidriver); degrades elsewhere.
assert_eq!(pick_gamepad(DualSense, None, false), Xbox360); assert_eq!(pick_gamepad(DualSense, None, false, true), DualSense);
assert_eq!(pick_gamepad(Auto, Some("dualsense"), false), Xbox360); assert_eq!(
// DualShock 4: honored on Linux (UHID), degrades to X-Box 360 off it. pick_gamepad(Auto, Some("dualsense"), false, true),
assert_eq!(pick_gamepad(DualShock4, None, true), DualShock4); DualSense
assert_eq!(pick_gamepad(Auto, Some("ps4"), true), DualShock4); );
assert_eq!(pick_gamepad(DualShock4, None, false), Xbox360); assert_eq!(pick_gamepad(DualSense, None, false, false), Xbox360);
assert_eq!(pick_gamepad(Auto, Some("dualsense"), false, false), Xbox360);
// DualShock 4: Linux-only (UHID); degrades to X-Box 360 off it (including Windows).
assert_eq!(pick_gamepad(DualShock4, None, true, false), DualShock4);
assert_eq!(pick_gamepad(Auto, Some("ps4"), true, false), DualShock4);
assert_eq!(pick_gamepad(DualShock4, None, false, true), Xbox360);
// X-Box One: a distinct uinput identity on Linux, folded into the 360 pad on Windows. // X-Box One: a distinct uinput identity on Linux, folded into the 360 pad on Windows.
assert_eq!(pick_gamepad(XboxOne, None, true), XboxOne); assert_eq!(pick_gamepad(XboxOne, None, true, false), XboxOne);
assert_eq!(pick_gamepad(Auto, Some("series"), true), XboxOne); assert_eq!(pick_gamepad(Auto, Some("series"), true, false), XboxOne);
assert_eq!(pick_gamepad(XboxOne, None, false), Xbox360); assert_eq!(pick_gamepad(XboxOne, None, false, true), Xbox360);
} }
#[test] #[test]
+6 -3
View File
@@ -603,7 +603,8 @@ fn uninstall() -> Result<()> {
Ok(()) Ok(())
} }
/// Write a default `host.env` if none exists, so a fresh install streams with NVENC out of the box. /// Write a default `host.env` if none exists, so a fresh install streams out of the box. The encoder
/// defaults to `auto` — the host picks NVENC (NVIDIA) / AMF (AMD) / QSV (Intel) from the GPU vendor.
fn ensure_default_host_env() -> Result<()> { fn ensure_default_host_env() -> Result<()> {
let path = host_env_path(); let path = host_env_path();
if path.exists() { if path.exists() {
@@ -616,7 +617,9 @@ fn ensure_default_host_env() -> Result<()> {
# KEY=VALUE per line; '#' comments. Restart the service after editing:\n\ # KEY=VALUE per line; '#' comments. Restart the service after editing:\n\
# punktfunk-host service stop && punktfunk-host service start\n\ # punktfunk-host service stop && punktfunk-host service start\n\
\n\ \n\
PUNKTFUNK_ENCODER=nvenc\n\ # Encode backend: auto (default) detects the GPU vendor — NVIDIA->nvenc, AMD->amf, Intel->qsv.\n\
# Force one with nvenc | amf | qsv | sw (software H.264). amf/qsv need an FFmpeg-built host.\n\
PUNKTFUNK_ENCODER=auto\n\
PUNKTFUNK_VIDEO_SOURCE=virtual\n\ PUNKTFUNK_VIDEO_SOURCE=virtual\n\
PUNKTFUNK_SECURE_DDA=1\n\ PUNKTFUNK_SECURE_DDA=1\n\
RUST_LOG=info\n\ RUST_LOG=info\n\
@@ -625,7 +628,7 @@ fn ensure_default_host_env() -> Result<()> {
# compat). Use `serve` for a SECURE native-only host (no GameStream #5/#9 surface).\n\ # compat). Use `serve` for a SECURE native-only host (no GameStream #5/#9 surface).\n\
# PUNKTFUNK_HOST_CMD=serve --gamestream\n\ # PUNKTFUNK_HOST_CMD=serve --gamestream\n\
\n\ \n\
# Force a specific NVENC render GPU by name substring (multi-GPU boxes only):\n\ # Force a specific render GPU by name substring (multi-GPU boxes only):\n\
# PUNKTFUNK_RENDER_ADAPTER=4090\n"; # PUNKTFUNK_RENDER_ADAPTER=4090\n";
std::fs::write(&path, default).with_context(|| format!("write {}", path.display()))?; std::fs::write(&path, default).with_context(|| format!("write {}", path.display()))?;
println!("Wrote default config: {}", path.display()); println!("Wrote default config: {}", path.display());
+362 -80
View File
@@ -1,35 +1,64 @@
# Windows host — virtual DualSense scoping # Windows host — virtual DualSense scoping
**Status:** scoping (2026-06-20). Decision pending the web-research pass (see *Open questions* — web **Status:** **M0 feasibility gate PASSED (2026-06-21)** — a self-authored **Rust** UMDF virtual DualSense
search was unavailable when this was written, so the VHF API/signing specifics and the loads self-signed under Secure Boot, is recognized as a genuine DualSense by Steam, and receives `0x02`
"existing-driver-to-vendor" survey are marked TO-CONFIRM). output reports at its write callback. Driver source: `packaging/windows/dualsense-driver/`. (Earlier in
this doc's history the gate looked blocked by Secure Boot / driver code-integrity — that was wrong; the
real blocker was the PE FORCE_INTEGRITY bit that `wdk-build` sets via `/INTEGRITYCHECK`, cleared post-build.)
Web-research pass complete; the mechanism conclusion is **reversed**
from the 2026-06-20 draft. This doc **supersedes the 2026-06-20 VHF scoping** — VHF was the wrong
answer (it is kernel-only and cannot host a user-mode HID source), and the correct mechanism is a
**UMDF2 user-mode HID minidriver**, the same driver tier punktfunk already vendors/signs/installs for
SudoVDA. Two product decisions are now fixed and drive this plan: **(1)** the driver is for **public
end-user distribution** (so: EV cert + Microsoft attestation signing, not just the fleet self-signed
recipe), and **(2)** the strong preference is a **self-authored Rust driver**, with a thin C/C++ shim
as the realistic fallback and forking HIDMaestro as the last resort.
## TL;DR ## TL;DR
Apollo's backlog item #23/#89 ("DS4 ViGEm target on Windows") is the **wrong target** if the goal is Apollo's backlog item #23/#89 ("DS4 ViGEm target on Windows") is the **wrong target** if the goal is
*actual DualSense*. ViGEmBus emulates only **Xbox 360 (XUSB)** and **DualShock 4 (DS4)** — never a *actual DualSense*. ViGEmBus emulates only **Xbox 360 (XUSB)** and **DualShock 4 (DS4)** — never a
DualSense. Because this is a *host-side* virtual pad, the DualSense-defining features (adaptive DualSense. Because this is a *host-side* virtual pad, the DualSense-defining features (adaptive
triggers, the fine haptic actuators, DS5 identity) can only work end-to-end if the **game sees a real triggers, the fine haptic actuators, DS5 identity) only work end-to-end if the **game sees a real
DualSense** and therefore drives them; a DS4 virtual pad means the game uses its DS4 code path and DualSense** and therefore drives them; a DS4 virtual pad means the game takes its DS4 code path and
never emits those commands, so the client's adaptive-trigger rendering is never exercised. ViGEm DS4 never emits those commands, so the client's adaptive-trigger rendering is never exercised. ViGEm DS4
structurally **cannot** deliver adaptive triggers. structurally **cannot** deliver adaptive triggers.
The right path is the Windows analog of what the Linux host already does: present a **real virtual The right path is the Windows analog of what the Linux host already does over `/dev/uhid`: present a
DualSense HID device** (Sony VID `054C` / PID `0CE6`, the inputtino PS5 report descriptor). On Windows **real virtual DualSense HID device** (Sony VID `054C` / PID `0CE6`, the inputtino PS5 report
that means a kernel-mode virtual-HID device via the **Virtual HID Framework (VHF)** — the UHID analog descriptor punktfunk already ships). On Windows that is a **UMDF2 (user-mode) HID minidriver**
which is a SudoVDA-class driver effort (vendored + signed, installed by the existing Inno installer). created/torn-down per session from the host via `SwDeviceCreate`, sitting as a lower filter under
the OS pass-through driver `mshidumdf.sys`. It is the **same driver tier as SudoVDA** (UMDF, not
kernel), so the existing vendor → sign → Inno-installer machinery applies almost unchanged.
> **Supersedes the 2026-06-20 VHF scoping.** That draft concluded "a kernel-mode virtual-HID device
> via the Virtual HID Framework (VHF) — a SudoVDA-class driver effort." The decisive correction:
> **VHF supports a HID *source* driver only in kernel mode** (Microsoft "Virtual HID Framework
> (VHF)"). A user-mode (UMDF) HID source is **not** a VHF use case — it is a UMDF2 HID minidriver
> built from the `vhidmini2` sample (or DMF's `Dmf_VirtualHidMini`). The earlier "KMDF is a higher
> bar than SudoVDA's UMDF/IddCx" framing is therefore wrong: the correct mechanism is **the same
> UMDF tier as SudoVDA**, not above it.
Everything except the host backend is already platform-agnostic and DualSense-complete (verified
against live code), so this is a well-bounded host-side addition. **The whole effort is gated by an
on-glass feasibility spike (M0)** that no prior art settles: whether a virtual `054C:0CE6` device is
accepted as a genuine DualSense by `Windows.Gaming.Input` / GameInput / Steam **and** whether the
game's output report `0x02` (the adaptive-trigger block) actually reaches the driver's write callback.
## Why this is the wrong place to copy Apollo ## Why this is the wrong place to copy Apollo
Apollo (and all of Sunshine's lineage) **does DualSense only on Linux** (`inputtino`, Apollo (and all of Sunshine's lineage) **does DualSense only on Linux** (`inputtino`,
`DualSenseWired`). Its Windows input path (`src/platform/windows/input.cpp`) is ViGEm `DualSenseWired`). Its Windows input path (`src/platform/windows/input.cpp`) is ViGEm
`XUSB_REPORT` + `DS4_REPORT_EX` only — `MPS2_TO_DS4_ACCEL` motion conversion, inverse-ViGEmBus gyro `XUSB_REPORT` + `DS4_REPORT_EX` only — `MPS2_TO_DS4_ACCEL` motion conversion, inverse-ViGEmBus gyro
calibration, DS4 touchpad packing. There is **zero** VHF / virtual-HID / DualSense code on Apollo's calibration, DS4 touchpad packing. There is **zero** virtual-HID / DualSense code on Apollo's Windows
Windows side. So: side. So:
- Copying Apollo on Windows gets us a **DS4**, with the adaptive-trigger ceiling baked in. - Copying Apollo on Windows gets us a **DS4**, with the adaptive-trigger ceiling baked in.
- There is **no in-ecosystem upstream** (Sunshine/Apollo/Wolf) that already solved virtual DualSense - There is **no in-ecosystem upstream** (Sunshine/Apollo/Wolf) that already solved a virtual
on Windows to vendor from. This would be novel work for the streaming-host space. *DualSense* on Windows to vendor from. The closest prior art is in the **virtual-HID-controller**
space, not the streaming-host space: HIDMaestro and Nefarius DsHidMini (see *Mechanism*).
This is unchanged from the 2026-06-20 draft and remains correct.
## The parity target — and what's *already* done ## The parity target — and what's *already* done
@@ -39,96 +68,349 @@ DualSense — gamepad + motion + touchpad + lightbar/player-LEDs + adaptive trig
**input** report `0x01` (controller state) and reads HID **output** report `0x02` (the game's **input** report `0x01` (controller state) and reads HID **output** report `0x02` (the game's
rumble/LED/trigger feedback), which it forwards to the client as `punktfunk_core::quic::HidOutput`. rumble/LED/trigger feedback), which it forwards to the client as `punktfunk_core::quic::HidOutput`.
Crucially, **everything except the host backend is already platform-agnostic and DualSense-complete:** Crucially, **everything except the host backend is already platform-agnostic and DualSense-complete**
(verified against live source):
| Layer | State | Where | | Layer | State | Where |
|---|---|---| |---|---|---|
| Protocol planes (rich input `0xCC`, rumble `0xCA`, HID-output `0xCD`) | done | `punktfunk_core::quic` | | Protocol planes (rich input `0xCC`, rumble `0xCA`, HID-output `0xCD`) | done | `punktfunk_core::quic` |
| Feedback abstraction (`HidOutput::{Led,PlayerLeds,Trigger,…}`) | done | `punktfunk_core::quic` | | Feedback abstraction (`HidOutput::{Led,PlayerLeds,Trigger,…}`) | done | `punktfunk_core::quic` |
| Pad-type negotiation (client pref > env > default), `GamepadPref::DualSense` | done | `punktfunk1.rs::resolve_gamepad` | | Pad-type negotiation (client pref > env > default), `GamepadPref::DualSense` | done | `punktfunk1.rs` `resolve_gamepad` (~1577) |
| Backend dispatch (`enum PadBackend`) | done; `DualSense` arm is `#[cfg(target_os="linux")]` | `punktfunk1.rs:1229` | | Backend dispatch (`enum PadBackend`) | done; `DualSense`/`DualShock4` arms are `#[cfg(target_os="linux")]` | `punktfunk1.rs` (PadBackend ~11811272) |
| Clients (capture + adaptive-trigger/lightbar/haptic rendering) | done, all platforms | `clients/*` | | Clients (capture + adaptive-trigger/lightbar/haptic/touchpad/motion rendering) | done, all platforms | `clients/*` |
| C-ABI (`next_hidout` / `send_rich_input`) | done | `abi.rs` | | C-ABI (`next_hidout` / `send_rich_input`) | done | `abi.rs` |
| **Host virtual-DualSense backend** | **Linux only (UHID)** | `inject/dualsense.rs` | | **Host virtual-DualSense backend** | **Linux only (UHID)** | `inject/dualsense.rs` |
So a Windows DualSense backend needs **no protocol, client, or C-ABI change**. It must only: create a So a Windows DualSense backend needs **no protocol, client, or C-ABI change**. The whole DualSense
virtual DualSense HID device, translate our pad state → HID input report `0x01`, and surface the game's **HID contract already exists as pure, transport-independent Rust + const data**, kernel-verified
HID output report `0x02` as the same `HidOutput` events the Linux path already emits. That is a byte-for-byte against `hid-playstation.c` / inputtino / SDL, in `inject/dualsense.rs`:
well-bounded host-side addition (driver + a `DualSenseManager`-shaped userspace bridge + a
`PadBackend::DualSense` Windows arm).
## The Windows mechanism — VHF (primary candidate) - `DUALSENSE_RDESC` — the 232-byte USB report descriptor.
- `serialize_state` — the input report `0x01` packer (controller state → bytes).
- `parse_ds_output` — the output report `0x02` parser (game's rumble/LED/trigger block → `HidOutput`),
valid-flag gated.
- Feature blobs `0x05` calibration, `0x09` pairing, `0x20` firmware. **USB framing (no CRC).**
**No hardware capture is needed** — the bytes are already correct and proven. The *only* Linux
coupling is the `/dev/uhid` event framing (`UHID_CREATE2`/`INPUT2`/`OUTPUT`/`GET_REPORT`) in
`DualSensePad::open`/`write_state`/`service`. A Windows backend swaps that framing for the
`SwDeviceCreate` + IOCTL channel to the UMDF driver; **the report bytes are identical.**
> **One in-repo bug to fix in passing:** `DS_FEATURE_CALIBRATION` (`0x05`) is currently **42 bytes**;
> the spec is **41**. Trim it for strict Windows consumers as part of M1 (`42 → 41`).
`dualshock4.rs` (committed `3e6c9f6`) is a worked **second** example of the multi-pad-type
`PadBackend` pattern, reusing the DualSense state — a template for how the Windows arm slots in.
The host integration seam is small and already mapped: ~1 enum arm + 5 match arms in the
`PadBackend` block (`punktfunk1.rs` ~11811272), flipping `pick_gamepad`/`resolve_gamepad`
(~15581606) from `#[cfg(target_os = "linux")]` to `#[cfg(any(target_os = "linux", target_os =
"windows"))]`, plus the `inject.rs` module gating (~424451). `gamepad_windows.rs` is today
ViGEm-Xbox360-only (138 LOC); the new `inject/dualsense_windows.rs` sits beside it, and ViGEm stays
for Xbox 360 / Xbox One.
## The Windows mechanism — UMDF2 HID minidriver (not VHF)
Windows has **no userspace HID-device creation** (unlike Linux UHID), so a real virtual DualSense Windows has **no userspace HID-device creation** (unlike Linux UHID), so a real virtual DualSense
requires a kernel component. The Microsoft-sanctioned one is the **Virtual HID Framework (VHF)**: a needs a driver component. The decisive correction over the prior draft:
small KMDF driver creates a virtual HID device from an arbitrary report descriptor, submits **input**
reports to the OS, and receives **output/feature** reports written by applications (our feedback hook).
This is the structural twin of `/dev/uhid`.
Sketch of the integration (TO-CONFIRM details in *Open questions*): - **VHF (Virtual HID Framework) supports a HID *source* driver only in kernel mode.** It is not the
mechanism for a user-mode virtual pad. (Microsoft, "Virtual HID Framework (VHF)".)
- The user-mode mechanism is a **UMDF2 HID minidriver**: a small lower-filter driver under the
OS-supplied pass-through driver **`mshidumdf.sys`** (which calls `HidRegisterMinidriver` on the
minidriver's behalf). This is the **same UMDF tier as SudoVDA***below* kernel work, not above it.
A second prior-research correction that matters for the language choice: **UMDF 2.0 is NOT
COM-based.** COM / `IDriverEntry` / `IWDFDriver` belong to legacy **UMDF 1.x**. UMDF 2.0 uses the
same **C-style WDF object model as KMDF** — a `DriverEntry` symbol plus C function pointers
(`EvtDriverDeviceAdd`, `EvtIoDeviceControl`) stored in config structs. There is no vtable to
implement. (Microsoft, "Porting a Driver from UMDF 1 to UMDF 2", "Getting Started with UMDF v2".)
This is precisely why a Rust FFI implementation is even conceivable (see *Driver language*).
### What the driver actually does (small, well-bounded)
A UMDF2 HID minidriver holds **no device logic** — it shuttles bytes. Its entire job is one
`EvtIoDeviceControl` callback branching on ~10 HID IOCTLs (Microsoft, "Creating WDF HID
Minidrivers"; reference source `vhidmini2`):
- In `EvtDriverDeviceAdd`: call `WdfFdoInitSetFilter`, then create the I/O queue(s).
- **Descriptor IOCTLs** (`GET_DEVICE_DESCRIPTOR` / `GET_REPORT_DESCRIPTOR` / `GET_DEVICE_ATTRIBUTES`)
— trivial: `RequestCopyFromBuffer` a static blob. For punktfunk these blobs are the **existing
`DUALSENSE_RDESC` (232 B)** + a `HID_DEVICE_ATTRIBUTES` filled `054C`/`0CE6`.
- **Output / feature IOCTLs** (`WRITE_REPORT` / `SET_OUTPUT_REPORT` / `GET_FEATURE` / `SET_FEATURE`)
— pull the `HID_XFER_PACKET` (report id + buffer) and hand the bytes to the host. These carry the
game's `0x02` output report (rumble / lightbar / **adaptive-trigger** block) — exactly what
`parse_ds_output` already decodes.
- **Input path** (`READ_REPORT`, pad → game) — the only non-trivial mechanic, an **inverted call**:
each `READ_REPORT` request is pended into a manual `WDFQUEUE`
(`WdfIoQueueDispatchManual` + `WdfRequestForwardToIoQueue`) and later popped
(`WdfIoQueueRetrieveNextRequest`), filled, and completed (`WdfRequestComplete`) whenever the host
has a fresh `0x01` input report. `vhidmini2` drives this from a periodic timer; punktfunk drives
it from each new `0x01` report arriving over the host channel — **structurally identical to the
existing Linux `/dev/uhid` loop.**
Because UMDF can't marshal embedded pointers, `mshidumdf.sys` converts `IOCTL_HID_*` into
`IOCTL_UMDF_HID_*` (e.g. `IOCTL_UMDF_HID_GET_INPUT_REPORT`, `IOCTL_UMDF_HID_SET_FEATURE`), passing
`reportBuffer` / `reportId` as separate buffers — the driver branches on those.
### Integration sketch
``` ```
host process (Rust) <--IOCTL/named-pipe--> punktfunk-ds5.sys (KMDF + VHF) <--HID--> game / Steam / GameInput host process (Rust) <-- SwDeviceCreate + IOCTL channel --> UMDF2 HID minidriver <-- HID --> game / Steam / GameInput
PadState ----------- input report 0x01 -----------> VhfReadReportSubmit PadState -------------- input report 0x01 -------------> inverted READ_REPORT queue
HidOutput <-- output report 0x02 (write callback) --- EvtVhf*WriteReport HidOutput <----- output report 0x02 (WriteReport cb) ----- EvtIoDeviceControl
``` ```
- **Descriptor reuse:** the exact inputtino PS5 descriptor + feature-report replies we already ship for - **Descriptor reuse:** the exact inputtino PS5 descriptor + feature-report replies we already ship
Linux (`dualsense.rs` `DS_*` constants) — same bytes, same VID/PID, so Windows + games recognize it for Linux (`dualsense.rs` `DS_*` constants) — same bytes, same VID/PID, so Windows + games
as a DualSense. recognize it as a DualSense.
- **Userspace bridge:** a `DualSenseManager`-shaped struct mirroring the Linux one (same `RichInput` - **Host-side device creation:** `windows::Win32::Devices::Enumeration::Pnp::SwDeviceCreate`
report `0x01` packing, same `HidOutput` parsing from report `0x02`), talking to the driver over an `Result<HSWDEVICE>` (pure Win32, in the `windows` crate, **no WDK needed**), enumerating a
IOCTL/pipe instead of `/dev/uhid`. root device whose hardware IDs match the pre-staged INF. Requires Administrator. **The device
- **Packaging:** vendor + sign the `.sys`/`.inf`/`.cat` and install via the existing exists only while the `HSWDEVICE` handle (i.e. the host process) is open** — `SwDeviceClose`
`packaging/windows/sudovda` machinery (`nefconc.exe` + an `install-*.ps1`, bundled in the Inno removes it — so the pad is created/destroyed with the session, exactly like the Linux UHID fd.
`setup.exe`). The precedent is already in the repo. The INF is pre-staged once (`pnputil /add-driver`).
- **Userspace bridge:** a `DualSenseManager`-shaped struct mirroring the Linux one (same `RichInput`
→ report `0x01` packing via `serialize_state`, same `HidOutput` parsing via `parse_ds_output`),
talking to the driver over an IOCTL channel instead of `/dev/uhid`.
- **Packaging:** vendor + sign the `.dll`/`.inf`/`.cat` and install via the existing
`packaging/windows` machinery (`pnputil` + an `install-*.ps1`, bundled in the Inno `setup.exe`).
The precedent — SudoVDA, a UMDF/IddCx driver — is already in the repo.
## Effort & risk ## Driver language — recommendation
| Piece | Rough size | Notes / risk | The user strongly prefers a **self-authored Rust driver**. Verified verdict: **a Rust UMDF2 HID
|---|---|---| minidriver is technically viable but unproven and pioneering** — it does not clear the bar for a
| KMDF + VHF virtual-HID driver | large | KMDF (kernel) is a higher bar than SudoVDA's UMDF/IddCx; bulk of the work | *low-risk* M2. Honest ranking of the three options:
| Driver signing + distribution | medium | EV cert + Microsoft attestation for production; test-signing for dev; SudoVDA precedent but it's pre-signed/vendored, not built here |
| Userspace `DualSenseManager` (Windows) | smallmedium | Mostly a port of the Linux report packing/parsing; reuses descriptors |
| `PadBackend::DualSense` Windows arm + negotiation | small | Un-gate the existing dispatch for Windows |
| HidHide-style hiding of a physical pad | small (maybe unneeded) | Headless host usually has no physical pad; only matters if one is attached |
**Top risks:** (1) a KMDF/VHF driver is real kernel work + signing logistics; (2) whether VHF's ### Option R — fully self-authored Rust driver (preferred; viable, but pioneering)
output-report callback cleanly surfaces the DualSense `0x02` effect report we need for adaptive
triggers; (3) whether games/Steam/`Windows.Gaming.Input`/GameInput accept a VHF-sourced DualSense the - **What's real today:** `microsoft/windows-drivers-rs` (`wdk`, `wdk-sys`, `wdk-build`,
same as a physical one (descriptor + VID/PID should suffice, but unverified on Windows). `wdk-macros`) officially targets WDM + KMDF + **UMDF** (tested UMDF 2.33). It ships a *real* Rust
UMDF sample, `examples/sample-umdf-driver/src/lib.rs`, that `#[unsafe(export_name = "DriverEntry")]`,
builds a `WDF_DRIVER_CONFIG` with `EvtDriverDeviceAdd: Some(...)`, and calls `WdfDriverCreate` +
`WdfDeviceCreate` via `call_unsafe_wdf_function_binding!` over raw `wdk-sys` FFI. Because UMDF 2.0
is the C function-pointer model (no COM vtable), the FFI maps cleanly.
- **The gap:** that sample is a **bare stub** — no I/O queue, no IOCTL dispatch, no HID. The entire
HID-minidriver layer (`WdfFdoInitSetFilter`, the manual inverted-call queue, `IOCTL_UMDF_HID_*`
dispatch, `HID_XFER_PACKET`, `METHOD_NEITHER`) would be **hand-written `unsafe` FFI with no safe
wrappers**, against `vhidmini2`/GazeHid-scale glue (a few hundred lines). The heavy domain logic is
*not* in the driver — it already exists in `dualsense.rs`.
- **The honest blockers:** **zero precedent** — every shipping virtual-HID controller driver
(`vhidmini2`, HIDMaestro, DsHidMini, EmuController, GazeHid) is **C**. Microsoft labels
`windows-drivers-rs` "not yet recommended for production use" (Sept 2025) and has **not settled the
WHCP/attestation submission path for Rust drivers** — directly relevant given the public-distribution
requirement (though attestation re-signs the `.cat` and treats the `.dll` opaquely, so signing
*should* be language-agnostic — unverified). Whether all needed WDF symbols (`WdfIoQueueCreate`,
`WdfFdoInitSetFilter`, `WdfRequestRetrieveOutputMemory`, manual-queue APIs,
`WDF_IO_QUEUE_CONFIG_INIT`) are generated/usable for the UMDF target is **unverified against the
bindings — this is exactly what the M0 build spike must answer.** Note the Dec 2025
`windows-drivers-rs` build break (Discussion #591) is a transient LLVM-22-tip bindgen issue, fixed
by pinning LLVM 21.1.2 — not a fundamental defect.
Do **not** C-FFI-bind DMF's `Dmf_VirtualHidMini` from Rust (large, awkward C surface) — reimplement
the modest `vhidmini2` queue/IOCTL glue directly.
### Option C — thin C/C++ UMDF2 shim + all logic in the Rust host (realistic fallback / lowest-risk M2)
Clone `vhidmini2` (`WdfFdoInitSetFilter` + `EvtIoDeviceControl` + manual inverted-call queue, a few
hundred LOC of generic byte-shuttling); keep **all** DualSense logic in the existing Rust host
(`dualsense.rs` descriptors/packers/parsers fed over the IOCTL channel); the `SwDeviceCreate` host
bridge stays pure Rust in the `windows` crate (no WDK). This **mirrors HIDMaestro's split** (generic
C/C++ UMDF2 HID minidriver under `mshidumdf.sys`, all profile/DualSense logic in the user-mode
service) **and punktfunk's own Linux design.** It is the user's pre-ranked middle option and the
fastest way to reach the M0 on-glass gate.
### Option H — fork/reuse HIDMaestro (last resort)
HIDMaestro is a proven, pure-UMDF2 virtual controller (self-signed, no EV/test-signing/reboot)
recognized by DirectInput/XInput/SDL3/WGI/GameInput/RawInput + Steam, with a **DualSense profile**
(byte-exact VID/PID + descriptor). Use only if even the C shim stalls **and** adaptive-trigger
fidelity is not required — **HIDMaestro omits adaptive triggers from its DS5 feature list**, so it
cannot prove the very thing that makes a virtual DualSense worth building. Its driver is C; its
service is C#.
### Recommendation
**Lead with Option R for the long-term codebase, but de-risk the on-glass gate with Option C in M2.**
Concretely: run the **M0 spike in two halves** — (a) a `windows-drivers-rs` UMDF *build* spike to
confirm the WDF queue/IOCTL symbols are usable from Rust at all, and (b) the on-glass recognition gate
using whichever driver is fastest to stand up (the C `vhidmini2` shim is the safe bet). If (a) passes
**and** the on-glass gate passes, author the M2 driver in **Rust** (it would be the first Rust UMDF
HID driver, accepted as pioneering risk per the user's explicit preference). If (a) is shaky, ship M2
as the **C shim** and migrate the driver to Rust later, once `windows-drivers-rs` ships safe WDF/HID
abstractions. Either way the DualSense *logic* stays in Rust where it already lives. Forking HIDMaestro
is the fallback-of-fallbacks and is acceptable only if adaptive triggers are dropped from scope.
## Signing
Two recipes coexist in the Inno installer, selected by the bundled payload — the same pattern already
proven for SudoVDA.
### Fleet / self-signed (dev + internal boxes)
The in-repo precedent is `packaging/windows/install-sudovda.ps1`: import the bundled `.cer` into the
machine **Root** *and* **TrustedPublisher** stores (`certutil -addstore -f`), then `pnputil
/add-driver /install`. This installs silently **only** because the publisher is pre-trusted on that
machine. Microsoft is explicit that this auto-import-into-Root practice "should never be followed for
any driver package distributed outside your organization" — so it is the **fleet** path, never the
public one.
### Public end-user distribution — EV cert + Microsoft attestation
For arms-length public users, the correct tier is **Microsoft attestation signing** via Partner
Center (verified: "Attestation signing supports Windows Desktop kernel mode **and user mode**
drivers"; processable types include `.cab`/`.dll`). Pipeline:
1. **Prerequisites:** a registered **Windows Hardware Developer Program** (Partner Center) account
(free to register; sign in with an Entra ID global-admin work account; accept the agreements,
provide org/D-U-N-S info, respond to the legal-contact verification email) and an **EV
code-signing certificate** (mandatory to register *and* to sign the submission CAB; ~USD 250560/yr;
FIPS hardware token/HSM mandatory; 17 business-day identity vetting). Windows ADK (`MakeCab`).
2. **Build the submission:** `MakeCab` the `.dll` + `.inf` (+ `.pdb`/symbols) into per-driver
subfolders (folder names < 40 chars, no special chars, no UNC); `SignTool sign` the CAB with the
EV cert (`/fd sha256` + RFC3161 timestamp `/tr … /td sha256`).
3. **Submit:** Partner Center → *Submit new hardware*, **leave test-signing unchecked**, request the
desired signatures.
4. **Microsoft re-signs:** it appends a Microsoft SHA-2 signature and **regenerates + signs a new
`.cat` with a Microsoft cert** (your `.cat` is replaced). Because the catalog signer is then
Microsoft (already trusted), **PnP installs silently — no publisher prompt, no test-signing, no
reboot, and no shipping our cert into users' Root store.** Validation: `devcon`/`pnputil` install
must not show "Windows can't verify the publisher of this driver software."
**Important nuance — is attestation even *required* for UMDF?** UMDF is user-mode, so it is **exempt
from kernel-mode code-integrity *load* enforcement** — the driver `.dll` will *load* without a
Microsoft signature. But **PnP *installation* still requires a signed catalog whose publisher is
trusted.** A driver signed only with a plain publicly-trusted (OV/EV) Authenticode cert that is *not*
already in TrustedPublisher will **install, but with the blocking "Windows Security / would you like
to install this device software?" prompt** (setupapi warning `0x800b0109`, error `0xe0000242`
"publisher … not yet established as trusted"). So a bare Authenticode signature is **not** sufficient
for a prompt-free public install — **attestation is the minimal correct public path.** The April 2026
kernel-trust change (removing trust for legacy cross-signed *kernel* drivers) **does not affect**
attestation/WHQL or user-mode UMDF drivers.
What attestation does **not** do: attestation-signed drivers are **not** distributed via Windows
Update — irrelevant here, since punktfunk bundles the driver in its Inno installer exactly like
SudoVDA. (Azure Trusted Signing is **not** an option for the driver `.cat` at all — it signs only
user-mode PE / `/INTEGRITYCHECK` / SmartScreen, and cannot substitute for the EV cert in Partner
Center; it could only improve SmartScreen reputation on the installer `.exe`.) Note attestation does
**not** require HLK/WHQL testing. The heavier fallback, only if attestation's "testing scenarios"
positioning ever hardens into a block, is full **WHQL/HLK** submission (also yields a Microsoft-signed
catalog, plus Windows Update eligibility).
### Coexistence in the Inno installer
`packaging/windows/punktfunk-host.iss` already gates the SudoVDA driver payload behind
`#ifdef WithDriver` + the `installdriver` task + a `[Run]` call to `install-sudovda.ps1`. Add an
analogous gated payload + `install-dualsense.ps1` for the virtual DualSense driver, switching the
bundled `.cat` per build:
- **fleet build** → self-signed `.cat` + `install-dualsense.ps1` keeps the
`certutil -addstore Root/TrustedPublisher` step (cloned from `install-sudovda.ps1`).
- **public build** → Microsoft-attestation-re-signed `.cat`, and `install-dualsense.ps1`
**drops** the `certutil` import (just `pnputil /add-driver /install`).
Operationally, the EV key lives on a non-exportable FIPS token, so the **CAB signing + Partner Center
submission is a manual offline step**, not a CI secret (cloud-HSM/Azure Key Vault EV options exist but
need per-CA confirmation). The Microsoft-resigned `.cat` is then committed as the vendored public
payload, the way SudoVDA's signed driver is vendored in `packaging/windows/sudovda/`.
## Feasibility gate (BLOCKING — M0, on-glass only)
No prior art settles the two questions that decide whether this whole effort is worth building. **This
gate blocks M1M6** and can only be answered on the **RTX box (`192.168.1.173`)** — the dev VM is
headless/WARP and cannot validate game-facing HID recognition:
1. **Recognition:** is a virtual `054C:0CE6` UMDF2 device accepted as a *genuine DualSense* by
`Windows.Gaming.Input` / GameInput / Steam (and native-DS5 games)? HIDMaestro proves DualSense
*recognition* is possible, but…
2. **Adaptive-trigger fidelity:** does the game's output report `0x02` (the adaptive-trigger block)
actually reach the driver's `WriteReport`/`SetOutputReport` callback? **HIDMaestro omits adaptive
triggers**, so no prior art proves this — it must be **measured on glass**.
If (2) fails, the realistic product is a DualSense *identity* without adaptive triggers — at which
point the value over ViGEm DS4 collapses and the project should likely **defer** rather than ship.
**M0 RESULT (2026-06-21): GATE PASSED.** Both answered YES on the RTX box with a self-authored **Rust**
UMDF minidriver (`packaging/windows/dualsense-driver/`). (1) **Recognition:** Steam recognized the virtual
`054C:0CE6` device as a genuine DualSense and drove its DualSense-specific LEDs. (2) **`0x02` reaches the
write callback:** captured two Steam-Input output reports (`validFlag1=0x14` = LIGHTBAR|PLAYER_INDICATOR).
Adaptive-trigger-specific bytes ride the same `0x02` path (Cyberpunk confirmation is gravy, not a gate).
Three bugs had to be fixed to get there — the load wall was the PE **FORCE_INTEGRITY** bit (`wdk-build`'s
`/INTEGRITYCHECK`; clear bit `0x80` at PE+0x5e + re-sign), then `WdfTimerCreate` exec-level, then a parallel
queue's zeroed `NumberOfPresentedRequests`. **Option R (Rust) confirmed for M2; no C shim needed.**
**Host integration status (2026-06-21): M1/M3/M4 landed; data plane runtime-proven.** The Linux
DualSense logic is shared via `inject/dualsense_proto.rs`; the Windows backend
`inject/dualsense_windows.rs` (`DualSenseWindowsManager`) drives the driver over the
`Global\pfds-shm-<idx>` section, and the `PadBackend`/`pick_gamepad` seam now resolves DualSense on
Windows. Live-verified on the RTX box: the manager creates the section + pushes report `0x01` and a
devnode serves it to a HID read (manager data plane works). **Open item — `SwDeviceCreate`
per-session devnode:** two `E_INVALIDARG` causes found — (1) an underscore in the enumerator name
(`pf_dualsense` → use `punktfunk`), (2) passing the completion callback is still rejected (cause
unresolved; needs a known-good C reference). So per-session auto-creation is **best-effort/non-fatal**:
the host falls back to an out-of-band `pf_dualsense` devnode (the INF lists both `root\pf_dualsense`
for devgen and `pf_dualsense` for SwDevice; the installer would create it, as SudoVDA does). Remaining:
fix the SwDeviceCreate callback E_INVALIDARG, then the M5 on-glass game test.
## Milestone plan (M0M6)
| # | Milestone | Output | Gate / risk |
|---|---|---|---|
| **M0 ✅ DONE** | **Feasibility spike — PASSED (2026-06-21)** | (a) Rust `windows-drivers-rs` UMDF build spike — symbols usable, driver authored in Rust; (b) on-glass on the RTX box: self-signed Rust `054C:0CE6` UMDF minidriver loads under Secure Boot, Steam recognizes it as a DualSense, `0x02` output reaches the write callback. Source: `packaging/windows/dualsense-driver/` | **PASSED.** Option R (Rust) chosen for M2. Load needed clearing the PE FORCE_INTEGRITY bit |
| **M1** | Linux codec refactor | Extract the transport-independent contract from `dualsense.rs` into `inject/dualsense_proto.rs` (`DUALSENSE_RDESC`, `serialize_state`, `parse_ds_output`, feature blobs); **fix `DS_FEATURE_CALIBRATION` 42 → 41**; Linux backend keeps passing | Pure refactor; keep Linux loopback green |
| **M2** | UMDF2 driver | The HID minidriver + INF + signed `.cat` (test-signed for dev). **Language per M0(a):** Rust if the build spike is solid, else the `vhidmini2`-derived C shim. INF carries the required UMDF directives (`UmdfKernelModeClientPolicy=AllowKernelModeClients`, `UmdfMethodNeitherAction=Copy`, `UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects`, `UmdfFsContextUsePolicy=CanUseFsContext2`), root-enumerated `HIDClass`, filter under `mshidumdf.sys` | Pioneering if Rust; manual inverted-call queue is the hard part |
| **M3** | Rust host bridge | `inject/dualsense_windows.rs`: `SwDeviceCreate` per-session device (hold `HSWDEVICE` for the session) + the inverted-call IOCTL channel, feeding `0x01` and surfacing `0x02` as `HidOutput` — reusing `dualsense_proto.rs` | Channel design (single control device + inverted-call IOCTL vs shared-memory) |
| **M4** | Un-gate the seam + negotiation | New `PadBackend::DualSense` Windows arm; relax the `#[cfg(target_os="linux")]` guards on DualSense/DualShock4 in `pick_gamepad`/`resolve_gamepad` to `any(linux, windows)`; wire `GamepadPref::DualSense` resolution | Small; `dualshock4.rs` is the template |
| **M5** | On-glass E2E | Client → host → virtual DualSense → game, with adaptive triggers / lightbar / touchpad / motion / rumble round-tripping; latency check | RTX box; the real proof |
| **M6** | Packaging / installer | Vendor + sign the driver; `install-dualsense.ps1` (fleet vs public variant); gate the payload in `punktfunk-host.iss`; complete the **EV cert + attestation** submission for the public build | EV-cert procurement + Partner Center turnaround are lead-time items — start early |
## Decision matrix ## Decision matrix
| Option | Adaptive triggers / DS5 identity | Effort | When it's right | | Option | Adaptive triggers / DS5 identity | Effort | When it's right |
|---|---|---|---| |---|---|---|---|
| **A. VHF virtual DualSense** (parity) | ✅ full | large (kernel driver) | the goal — matches the Linux host | | **A. UMDF2 virtual DualSense** (parity) | ✅ full (pending the M0 gate) | medium — **UMDF, same tier as SudoVDA** (was mis-scoped as "kernel/large" in the 2026-06-20 draft) | the goal — matches the Linux host |
| **B. ViGEm DS4** (interim) | ❌ never (DS4 ceiling) | small | quick PS-pad-on-Windows w/ touchpad/motion/lightbar/rumble, no adaptive triggers | | **B. ViGEm DS4** (interim) | ❌ never (DS4 ceiling) | small | quick PS-pad on Windows w/ touchpad/motion/lightbar/rumble, no adaptive triggers |
| **C. Hybrid** | A for DS5 clients, B/Xbox360 fallback | A + small | belt-and-suspenders once A exists | | **C. Hybrid** | A for DS5 clients, B/Xbox 360 fallback | A + small | belt-and-suspenders once A exists |
| **D. Defer** | — | — | if a higher-ROI item (#9 launch, #7/#18 audio) wins the slot | | **D. Defer** | — | — | if the M0 gate fails (esp. output `0x02` fidelity), or a higher-ROI item wins the slot |
Xbox 360 (XInput) is already implemented and covers most Windows games regardless. Xbox 360 (XInput, via ViGEm) is already implemented and covers most Windows games regardless; Xbox
One/Series fold into it on Windows. Windows-host DualShock 4 (ViGEm) remains separately deferred.
## Open questions — REQUIRES the web-research pass (search was down) ## Risk register
1. **VHF specifics:** confirm VHF is the right/current mechanism (vs. a newer HID-injection API); | Risk | Likelihood | Impact | Mitigation |
exact API (`VhfCreate`/`VhfStart`/`VhfReadReportSubmit`/the output-report `EvtVhf…WriteReport` |---|---|---|---|
callback); KMDF-only or UMDF-capable; minimum Windows version; the MS `vhidmini`/VHF sample. | Output report `0x02` (adaptive triggers) never reaches the driver write callback | medium | **fatal** to the value prop | M0(b) measures it directly; if it fails → Option D |
2. **Existing driver to vendor:** is there a maintained virtual-HID / virtual-DualSense Windows driver | `054C:0CE6` UMDF2 device not accepted as a real DualSense by WGI/GameInput/Steam | lowmed | fatal | M0(b); HIDMaestro suggests recognition works, but confirm |
(Nefarius/community) we can vendor like SudoVDA, instead of writing a KMDF driver from scratch? | Rust UMDF driver pioneering risk (first of its kind; no safe WDF/HID wrappers; symbol coverage unproven) | medium | schedule | M0(a) build spike; **Option C (C shim) as the de-risked M2 fallback** |
3. **Recognition:** does a VHF device with VID `054C`/PID `0CE6` + the DualSense descriptor get | EV cert + Partner Center attestation lead time / friction | medium | schedule | Start procurement at M0; lean on the SudoVDA UMDF submission precedent |
recognized as a DualSense by Windows.Gaming.Input / GameInput / Steam Input / native-DS5 games — | EV key non-exportable → can't sign in CI | high | low | Accept a manual offline sign+submit step; vendor the Microsoft-resigned `.cat` |
including adaptive triggers via the `0x02` output report? | `SwDeviceCreate` device lifetime tied to the host process handle | known | low | Hold `HSWDEVICE` for the session lifetime (matches Linux UHID fd semantics) |
4. **Signing/distribution:** attestation vs. WHQL for a KMDF driver; can we test-sign for dev and ship | `windows-drivers-rs` transient toolchain breaks (e.g. LLVM-22 bindgen, Disc. #591) | low | low | Pin LLVM 21.1.2; not a fundamental defect |
an attestation-signed driver via the Inno installer like SudoVDA? | `DS_FEATURE_CALIBRATION` 42-byte blob rejected by strict Windows consumers | low | low | Trim to 41 bytes in M1 |
5. **HidHide:** needed at all on a (usually headless) host, or only when a physical pad is present?
## Recommended plan ## Open questions
1. **Web-research pass** (when search is back) to close the five questions above — especially #2 1. **Driver channel design** (unknown): punktfunk's own driver↔host protocol — simplest is a private
(vendor vs. build) and #1 (VHF feasibility + output-report support), which gate the whole effort. control device with an inverted-call IOCTL for input + IOCTLs for output/feature, vs HIDMaestro's
2. If VHF (or a vendorable driver) is confirmed feasible: build **Option A** — driver + Windows shared-memory section. `vhidmini2` has *no* service channel (it self-generates via a timer), so this
`DualSenseManager` + un-gate `PadBackend::DualSense`, reusing the inputtino descriptor and the must be designed fresh (or read out of HIDMaestro/DsHidMini source). **Resolve in M3.**
existing `HidOutput` plane (no protocol/client/ABI change), packaged via the SudoVDA path. 2. **Rust UMDF symbol coverage** (unknown — the M0(a) gate): are all needed WDF symbols
3. Keep **Xbox 360** as-is and treat **ViGEm DS4** only as an optional fallback (Option C), never as (`WdfIoQueueCreate`, `WdfFdoInitSetFilter`, `WdfRequestRetrieveOutputMemory`, manual-queue APIs,
the DualSense answer. `WDF_IO_QUEUE_CONFIG_INIT`) generated/usable from `wdk-sys` for the UMDF target?
3. **Attestation for a Rust-authored `.dll`** (likely fine, unverified): attestation re-signs the
`.cat` and treats the `.dll` opaquely (allowed type), so language *should* be irrelevant to
signing — but Microsoft has not explicitly settled the WHCP path for Rust drivers. Confirm via a
Partner Center dry-run.
4. **Single multi-driver CAB** (unknown, operationally useful): can one Partner Center submission carry
*both* the existing SudoVDA driver and the new DualSense driver? Multi-driver CABs are supported in
general; unverified for this account.
5. **EV cert + Partner Center mechanics** (unknown): exact cost/turnaround; whether a cloud-HSM EV
option lets CI sign, or whether it must be a manual offline step (most likely the latter).
6. **HidHide** (carried over): needed at all on a usually-headless host, or only when a physical pad is
attached?
7. **Min-OS / UMDFVERSION target** (unknown): which `UmdfLibraryVersion` / WDK to target for the widest
Win10/11 install base, consistent with punktfunk's existing host support matrix.
8. **DsHidMini end-user signing tier** (unknown): self-signed vs attestation in its WixSharp MSI —
useful as a second public-distribution data point.
+9 -6
View File
@@ -143,12 +143,15 @@ rustc 1.96 clippy is stricter than the Linux CI image on shared code, e.g. `need
driver DLL — `lib /def:nvenc.def /machine:x64 /out:nvencodeapi.lib` where `nvenc.def` lists driver DLL — `lib /def:nvenc.def /machine:x64 /out:nvencodeapi.lib` where `nvenc.def` lists
`NvEncodeAPICreateInstance` and `NvEncodeAPIGetMaxSupportedVersion` — and set `NvEncodeAPICreateInstance` and `NvEncodeAPIGetMaxSupportedVersion` — and set
`PUNKTFUNK_NVENC_LIB_DIR` to its directory. `PUNKTFUNK_NVENC_LIB_DIR` to its directory.
3. `cargo build -p punktfunk-host --features nvenc` (needs NASM + CMake for aws-lc-rs; libclang for 3. `cargo build -p punktfunk-host --features nvenc,amf-qsv` for the all-vendor GPU host (NVENC for
any ffmpeg-using client). Default build (no feature) uses the openh264 software encoder. NVIDIA; AMD AMF + Intel QSV via libavcodec — `amf-qsv` needs `FFMPEG_DIR` with the `*_amf`/`*_qsv`
encoders at build, e.g. the BtbN gpl-shared tree, and the FFmpeg DLLs on PATH at run; NASM + CMake
for aws-lc-rs; libclang for ffmpeg-sys-next). Default build (no feature) = openh264 software encoder.
4. Run in the **interactive session** (not a Session-0 service / not over SSH — SendInput + DXGI 4. Run in the **interactive session** (not a Session-0 service / not over SSH — SendInput + DXGI
Desktop Duplication need a desktop): `serve` or `punktfunk1-host --source virtual`. Set Desktop Duplication need a desktop): `serve` or `punktfunk1-host --source virtual`.
`PUNKTFUNK_ENCODER=nvenc` to select NVENC (the DXGI capturer switches to zero-copy D3D11 output to `PUNKTFUNK_ENCODER=auto` (default) picks the backend from the GPU vendor — `nvenc`/`amf`/`qsv`/`sw`
match). The SudoVDA monitor activates once a real GPU drives WDDM, so capture + NVENC then work. force one. The DXGI capturer emits zero-copy D3D11 NV12/P010 to match any GPU backend; the SudoVDA
monitor activates once a real GPU drives WDDM, so capture + encode then work.
### Dev loop (this repo → the Windows VM) ### Dev loop (this repo → the Windows VM)
@@ -224,7 +227,7 @@ This is the highest-value first move and is **fully doable GPU-less**.
| **VirtualDisplay** | KWin/gamescope/Mutter/Sway | **SudoVDA** IOCTLs (below) + `SetDisplayConfig` mode-set | ✅ likely (WARP) — *spike* | | **VirtualDisplay** | KWin/gamescope/Mutter/Sway | **SudoVDA** IOCTLs (below) + `SetDisplayConfig` mode-set | ✅ likely (WARP) — *spike* |
| **Capture** | PipeWire/dmabuf | **DXGI Desktop Duplication** primary, **WGC** fallback → `ID3D11Texture2D`; add `FramePayload::D3d11` | ⚠️ DDA-on-WARP unreliable; WGC-on-WARP unverified — *spike* | | **Capture** | PipeWire/dmabuf | **DXGI Desktop Duplication** primary, **WGC** fallback → `ID3D11Texture2D`; add `FramePayload::D3d11` | ⚠️ DDA-on-WARP unreliable; WGC-on-WARP unverified — *spike* |
| **Zero-copy** | dmabuf→EGL/Vulkan→CUDA | register `ID3D11Texture2D` with NVENC (`NV_ENC_DEVICE_TYPE_DIRECTX`) — no CUDA bridge | ❌ needs real GPU | | **Zero-copy** | dmabuf→EGL/Vulkan→CUDA | register `ID3D11Texture2D` with NVENC (`NV_ENC_DEVICE_TYPE_DIRECTX`) — no CUDA bridge | ❌ needs real GPU |
| **Encode** | ffmpeg `*_nvenc` | `openh264` SW (default on VM) + `nvidia-video-codec-sdk` HW (real GPU); behind `PUNKTFUNK_ENCODER` | SW ✅ / HW ❌ | | **Encode** | ffmpeg `*_nvenc`/`*_vaapi` | `nvidia-video-codec-sdk` (NVIDIA) + libavcodec `*_amf`/`*_qsv` (AMD/Intel, `encode/ffmpeg_win.rs`, `--features amf-qsv`) + `openh264` SW fallback; vendor-auto via `PUNKTFUNK_ENCODER` | NVENC ✅ live / AMF/QSV CI-only |
| **Input kbd/mouse** | libei / wlr | **SendInput** with `MOUSEEVENTF_VIRTUALDESK` absolute mapping onto the virtual desktop rect (skip the VK→evdev table — client sends Win VKs; use `KEYEVENTF_SCANCODE`+`EXTENDEDKEY`) | ✅ | | **Input kbd/mouse** | libei / wlr | **SendInput** with `MOUSEEVENTF_VIRTUALDESK` absolute mapping onto the virtual desktop rect (skip the VK→evdev table — client sends Win VKs; use `KEYEVENTF_SCANCODE`+`EXTENDEDKEY`) | ✅ |
| **Gamepad** | uinput xpad + FF | **ViGEmBus** via `vigem-client` (`Xbox360Wired`); rumble via `request_notification()``XNotification{large,small}` | ✅ (install driver) | | **Gamepad** | uinput xpad + FF | **ViGEmBus** via `vigem-client` (`Xbox360Wired`); rumble via `request_notification()``XNotification{large,small}` | ✅ (install driver) |
| **Audio capture** | PipeWire sink monitor | **WASAPI loopback** via the `wasapi` crate (48 kHz stereo f32 → existing Opus) | ⚠️ needs an audio endpoint | | **Audio capture** | PipeWire sink monitor | **WASAPI loopback** via the `wasapi` crate (48 kHz stereo f32 → existing Opus) | ⚠️ needs an audio endpoint |
@@ -0,0 +1,36 @@
[package]
edition = "2024"
name = "pf-dualsense"
version = "0.1.0"
publish = false
license = "MIT OR Apache-2.0"
description = "punktfunk virtual DualSense UMDF2 HID minidriver (M0 spike)"
[package.metadata.wdk.driver-model]
driver-type = "UMDF"
target-umdf-version-minor = 31
umdf-version-major = 2
[lib]
crate-type = ["cdylib"]
[build-dependencies]
wdk-build.path = "../../crates/wdk-build"
[dependencies]
wdk.path = "../../crates/wdk"
wdk-sys.path = "../../crates/wdk-sys"
[features]
default = ["hid"]
hid = ["wdk-sys/hid"]
nightly = ["wdk-sys/nightly", "wdk/nightly"]
[profile.dev]
lto = true
[profile.release]
lto = true
# Standalone package (not part of the windows-drivers-rs root workspace).
[workspace]
@@ -0,0 +1,4 @@
extend = [
{ path = "../../crates/wdk-build/rust-driver-makefile.toml" },
{ path = "../../crates/wdk-build/rust-driver-sample-makefile.toml" },
]
@@ -0,0 +1,83 @@
# pf-dualsense — virtual DualSense UMDF2 HID minidriver (M0 spike)
A self-authored **Rust UMDF2 HID minidriver** that presents a virtual Sony **DualSense**
(VID `054C` / PID `0CE6`) to Windows, so games drive adaptive triggers / lightbar / rumble —
capabilities ViGEm structurally cannot deliver. This is the M0 feasibility spike for rich
controller support in the punktfunk Windows host.
## Status (2026-06-21)
**Load + recognition: DONE.** A self-signed build **loads under Secure Boot ON** and enumerates as a
genuine DualSense HID game controller (`Status: OK`, VID `054C`, 273-byte DualSense report descriptor,
PID `0CE6` via `GET_DEVICE_ATTRIBUTES`). Validated live on the RTX box (`192.168.1.173`, Win11 25H2).
**Remaining:** the real-game `0x02` adaptive-trigger gate (Cyberpunk 2077 on the interactive desktop →
confirm `[pf-ds] *** OUTPUT ...` in the driver log), then wire into the host (M1+).
## This is a reference snapshot
The crate's `Cargo.toml` uses path-deps into `microsoft/windows-drivers-rs`
(`../../crates/wdk{,-sys,-build}`), so it builds **inside a `windows-drivers-rs` checkout's
`examples/` dir**, not standalone in this repo. On the dev box it lives at
`C:\Users\Public\m0\windows-drivers-rs\examples\pf-dualsense`. These files are checked in for
version control / portability of the spike.
## Build / sign / install recipe (the one that actually loads)
Prereqs on the Windows box: **WDK 26100**, **LLVM 21.1.2** (pinned — newer bindgen breaks),
`cargo-make`, Rust MSVC. A self-signed CodeSigning cert in `CurrentUser\My` + `LocalMachine\Root` +
`TrustedPublisher`.
Every build needs:
```powershell
$env:LIBCLANG_PATH = 'C:\Program Files\LLVM\bin'
$env:Version_Number = '10.0.26100.0' # else wdk-build picks 10.0.28000.0 (no km/crt) and bindgen fails
```
Then, in the example dir:
```powershell
cargo make # -> target\debug\pf_dualsense_package\ (.inf/.cat/.dll)
# *** CRITICAL: clear the PE FORCE_INTEGRITY bit ***
# windows-drivers-rs links the DLL with /INTEGRITYCHECK, which forces a CI-trusted page-hash
# signature a self-signed cert cannot satisfy (CodeIntegrity 3004 "hash not found" /
# 3089 VerificationError 7). SudoVDA.dll has this bit OFF. Clear bit 0x80 at PE-header offset +0x5e:
$f = 'target\debug\pf_dualsense_package\pf_dualsense.dll'
$b = [IO.File]::ReadAllBytes($f); $pe = [BitConverter]::ToInt32($b,0x3c); $off = $pe + 0x5e
$dc = [BitConverter]::ToUInt16($b,$off); $bb = [BitConverter]::GetBytes([uint16]($dc -band 0xFF7F))
$b[$off]=$bb[0]; $b[$off+1]=$bb[1]; [IO.File]::WriteAllBytes($f,$b)
signtool sign /fd SHA256 /sha1 <cert-thumbprint> $f
Remove-Item target\debug\pf_dualsense_package\pf_dualsense.cat
Inf2Cat /driver:target\debug\pf_dualsense_package /os:10_x64
signtool sign /fd SHA256 /sha1 <cert-thumbprint> target\debug\pf_dualsense_package\pf_dualsense.cat
pnputil /add-driver target\debug\pf_dualsense_package\pf_dualsense.inf /install
devgen /add /hardwareid "root\pf_dualsense" # creates the (transient, SWD) device node
```
`devgen` is at `...\Windows Kits\10\Tools\10.0.26100.0\x64\devgen.exe`. SWD devgen devices clear on
reboot (recreate after each boot). TODO: drop the post-build PE patch by stopping wdk-build emitting
`/INTEGRITYCHECK`.
## The three bugs that made it work (porting a WDK C sample to Rust)
`WDF_*_CONFIG_INIT` / `WDF_OBJECT_ATTRIBUTES_INIT` macros set **non-zero** defaults — `mem::zeroed()`
silently breaks them:
1. **FORCE_INTEGRITY** (above) — the load wall.
2. **Timer `ExecutionLevel`** — zeroed = Invalid → `WdfTimerCreate` 0xC0200209. Set
`ExecutionLevel/SynchronizationScope = InheritFromParent` + `AutomaticSerialization = TRUE`
(the working vhidmini2 shape).
3. **Queue `Settings.Parallel.NumberOfPresentedRequests`** — zeroed = 0 → a parallel queue presents
zero requests → `EvtIoDeviceControl` never fires → no HID handshake → ~5 s timeout →
`CM_PROB_FAILED_START`. Set to `u32::MAX`.
## Known limitations
- Uses **statics, not per-device WDF contexts** → only one device instance per WUDFHost works.
Multi-instance needs proper device contexts.
- Port of the WDK `vhidmini2` UMDF2 sample; DualSense identity + 273-byte descriptor + feature blobs
`0x05`/`0x09`/`0x20` from `crates/punktfunk-host/src/inject/dualsense.rs`.
@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// License: MIT OR Apache-2.0
//! Build script for the `sample-umdf-driver` crate.
//!
//! Based on the [`wdk_build::Config`] parsed from the build tree, this build
//! script will provide `Cargo` with the necessary information to build the
//! driver binary (ex. linker flags)
fn main() -> Result<(), wdk_build::ConfigError> {
wdk_build::configure_wdk_binary_build()
}
@@ -0,0 +1,76 @@
;/*++
; punktfunk virtual DualSense — UMDF2 HID minidriver INF (M0 spike).
; Adapted from the WDK vhidmini2 UMDF2 sample (VhidminiUm.inx).
; Depends on MsHidUmdf.inf (build >= 22000).
; Install: devgen /add /hardwareid "root\pf_dualsense" (after pnputil /add-driver /install)
;--*/
[Version]
Signature="$WINDOWS NT$"
Class=HIDClass
ClassGuid={745a17a0-74d3-11d0-b6fe-00a0c90f57da}
Provider=%ProviderString%
CatalogFile=pf_dualsense.cat
PnpLockdown=1
[DestinationDirs]
DefaultDestDir = 13
[SourceDisksNames]
1=%Disk_Description%,,,
[SourceDisksFiles]
pf_dualsense.dll=1
[Manufacturer]
%ManufacturerString%=pf, NT$ARCH$.10.0...22000
[pf.NT$ARCH$.10.0...22000]
; Two hardware ids: `root\pf_dualsense` for a root-enumerated devnode (devgen/devcon tests) and
; `pf_dualsense` for the host's SwDeviceCreate'd software device (the `root\` prefix is reserved for
; root enumeration, so SwDeviceCreate rejects it with E_INVALIDARG).
%DeviceDesc%=pfDualSense, root\pf_dualsense, pf_dualsense
[pfDualSense.NT]
CopyFiles=UMDriverCopy
Include=MsHidUmdf.inf
Needs=MsHidUmdf.NT
Include=WUDFRD.inf
Needs=WUDFRD_LowerFilter.NT
[pfDualSense.NT.hw]
Include=MsHidUmdf.inf
Needs=MsHidUmdf.NT.hw
Include=WUDFRD.inf
Needs=WUDFRD_LowerFilter.NT.hw
[pfDualSense.NT.Services]
Include=MsHidUmdf.inf
Needs=MsHidUmdf.NT.Services
Include=WUDFRD.inf
Needs=WUDFRD_LowerFilter.NT.Services
[pfDualSense.NT.Filters]
Include=WUDFRD.inf
Needs=WUDFRD_LowerFilter.NT.Filters
[pfDualSense.NT.Wdf]
UmdfService="pf_dualsense", pf_dualsense_Install
UmdfServiceOrder=pf_dualsense
UmdfKernelModeClientPolicy=AllowKernelModeClients
UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects
UmdfMethodNeitherAction=Copy
UmdfFsContextUsePolicy=CanUseFsContext2
[pf_dualsense_Install]
UmdfLibraryVersion=$UMDFVERSION$
ServiceBinary="%13%\pf_dualsense.dll"
[UMDriverCopy]
pf_dualsense.dll
[Strings]
ProviderString ="punktfunk"
ManufacturerString ="punktfunk"
ClassName ="HID device"
Disk_Description ="punktfunk DualSense Installation Disk"
DeviceDesc ="punktfunk Virtual DualSense"
@@ -0,0 +1,554 @@
// punktfunk virtual DualSense — UMDF2 HID minidriver (M0 spike).
//
// 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
// ships in `inject/dualsense.rs`. Its purpose for M0(b) is to (1) enumerate as a genuine DualSense
// and (2) LOG every output report the game writes — the adaptive-trigger `0x02` gate.
//
// 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.
#![allow(non_snake_case, non_upper_case_globals, clippy::missing_safety_doc)]
use core::ffi::c_void;
use core::sync::atomic::{AtomicPtr, Ordering};
use wdk_sys::{
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_TIMER_CONFIG, WDFDEVICE, WDFDRIVER, WDFMEMORY, WDFQUEUE, WDFQUEUE__, WDFREQUEST, WDFTIMER,
call_unsafe_wdf_function_binding, windows::OutputDebugStringA,
};
// ---- NTSTATUS values ----
const STATUS_SUCCESS: NTSTATUS = 0;
const STATUS_UNSUCCESSFUL: NTSTATUS = 0xC000_0001u32 as NTSTATUS;
const STATUS_NOT_IMPLEMENTED: NTSTATUS = 0xC000_0002u32 as NTSTATUS;
const STATUS_INVALID_PARAMETER: NTSTATUS = 0xC000_000Du32 as NTSTATUS;
const STATUS_INVALID_BUFFER_SIZE: NTSTATUS = 0xC000_0206u32 as NTSTATUS;
#[inline]
fn nt_success(s: NTSTATUS) -> bool {
s >= 0
}
// ---- HID minidriver IOCTLs: CTL_CODE(FILE_DEVICE_KEYBOARD=0x0b, id, METHOD_NEITHER=3, ANY) ----
const fn hid_ctl(id: u32) -> u32 {
(0x0000_000b << 16) | (id << 2) | 3
}
const IOCTL_HID_GET_DEVICE_DESCRIPTOR: u32 = hid_ctl(0);
const IOCTL_HID_GET_REPORT_DESCRIPTOR: u32 = hid_ctl(1);
const IOCTL_HID_READ_REPORT: u32 = hid_ctl(2);
const IOCTL_HID_WRITE_REPORT: u32 = hid_ctl(3);
const IOCTL_HID_GET_DEVICE_ATTRIBUTES: u32 = hid_ctl(9);
const IOCTL_UMDF_HID_SET_FEATURE: u32 = hid_ctl(20);
const IOCTL_UMDF_HID_GET_FEATURE: u32 = hid_ctl(21);
const IOCTL_UMDF_HID_SET_OUTPUT_REPORT: u32 = hid_ctl(22);
const IOCTL_UMDF_HID_GET_INPUT_REPORT: u32 = hid_ctl(23);
// ---- WDF enum values ----
const WdfIoQueueDispatchParallel: i32 = 2;
const WdfIoQueueDispatchManual: i32 = 3;
const WdfUseDefault: i32 = 2; // WDF_TRI_STATE
const WdfExecutionLevelInheritFromParent: i32 = 1; // WDF_EXECUTION_LEVEL
const WdfSynchronizationScopeInheritFromParent: i32 = 1; // WDF_SYNCHRONIZATION_SCOPE
// ---- DualSense identity ----
const DS_VID: u16 = 0x054C;
const DS_PID: u16 = 0x0CE6;
const DS_VER: u16 = 0x0100;
// Sony DualSense USB HID report descriptor (273 bytes), verbatim from inputtino (== inject/dualsense.rs).
// NOTE: inject/dualsense.rs comments this as "232 bytes" — that comment is wrong; it is 273.
#[rustfmt::skip]
static DUALSENSE_RDESC: [u8; 273] = [
0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x35,
0x09, 0x33, 0x09, 0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x06, 0x81, 0x02, 0x06,
0x00, 0xFF, 0x09, 0x20, 0x95, 0x01, 0x81, 0x02, 0x05, 0x01, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07,
0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x65, 0x00, 0x05,
0x09, 0x19, 0x01, 0x29, 0x0F, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0F, 0x81, 0x02, 0x06,
0x00, 0xFF, 0x09, 0x21, 0x95, 0x0D, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x22, 0x15, 0x00, 0x26,
0xFF, 0x00, 0x75, 0x08, 0x95, 0x34, 0x81, 0x02, 0x85, 0x02, 0x09, 0x23, 0x95, 0x2F, 0x91, 0x02,
0x85, 0x05, 0x09, 0x33, 0x95, 0x28, 0xB1, 0x02, 0x85, 0x08, 0x09, 0x34, 0x95, 0x2F, 0xB1, 0x02,
0x85, 0x09, 0x09, 0x24, 0x95, 0x13, 0xB1, 0x02, 0x85, 0x0A, 0x09, 0x25, 0x95, 0x1A, 0xB1, 0x02,
0x85, 0x20, 0x09, 0x26, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x21, 0x09, 0x27, 0x95, 0x04, 0xB1, 0x02,
0x85, 0x22, 0x09, 0x40, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x80, 0x09, 0x28, 0x95, 0x3F, 0xB1, 0x02,
0x85, 0x81, 0x09, 0x29, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x82, 0x09, 0x2A, 0x95, 0x09, 0xB1, 0x02,
0x85, 0x83, 0x09, 0x2B, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x84, 0x09, 0x2C, 0x95, 0x3F, 0xB1, 0x02,
0x85, 0x85, 0x09, 0x2D, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xA0, 0x09, 0x2E, 0x95, 0x01, 0xB1, 0x02,
0x85, 0xE0, 0x09, 0x2F, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x30, 0x95, 0x3F, 0xB1, 0x02,
0x85, 0xF1, 0x09, 0x31, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2, 0x09, 0x32, 0x95, 0x0F, 0xB1, 0x02,
0x85, 0xF4, 0x09, 0x35, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF5, 0x09, 0x36, 0x95, 0x03, 0xB1, 0x02,
0xC0,
];
// Feature reports hid-playstation / Steam read during init (each array's first byte is the report id).
#[rustfmt::skip]
static DS_FEATURE_CALIBRATION: [u8; 42] = [ // 0x05 motion calibration
0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x27, 0xF0, 0xD8, 0x10, 0x27, 0xF0, 0xD8, 0x10,
0x27, 0xF0, 0xD8, 0xF4, 0x01, 0xF4, 0x01, 0x10, 0x27, 0xF0, 0xD8, 0x10, 0x27, 0xF0, 0xD8, 0x10,
0x27, 0xF0, 0xD8, 0x0B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
#[rustfmt::skip]
static DS_FEATURE_PAIRING: [u8; 20] = [ // 0x09 pairing info (MAC at 1..7)
0x09, 0x74, 0xE7, 0xD6, 0x3A, 0x53, 0x35, 0x08, 0x25, 0x00, 0x1E, 0x00, 0xEE, 0x74, 0xD0, 0xBC,
0x00, 0x00, 0x00, 0x00,
];
#[rustfmt::skip]
static DS_FEATURE_FIRMWARE: [u8; 64] = [ // 0x20 firmware info
0x20, 0x4A, 0x75, 0x6E, 0x20, 0x31, 0x39, 0x20, 0x32, 0x30, 0x32, 0x33, 0x31, 0x34, 0x3A, 0x34,
0x37, 0x3A, 0x33, 0x34, 0x03, 0x00, 0x44, 0x00, 0x08, 0x02, 0x00, 0x01, 0x36, 0x00, 0x00, 0x01,
0xC1, 0xC8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x54, 0x01, 0x00, 0x00,
0x14, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
// HID descriptor (9 bytes, packed): len, type=0x21, bcdHID=0x0100, country=0, numDesc=1,
// then {reportType=0x22, wReportLength=273 (0x0111)}.
static HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x11, 0x01];
// HID_DEVICE_ATTRIBUTES (32 bytes): Size(u32)=32, VendorID, ProductID, VersionNumber, Reserved[11].
fn hid_attrs() -> [u8; 32] {
let mut a = [0u8; 32];
a[0..4].copy_from_slice(&32u32.to_le_bytes());
a[4..6].copy_from_slice(&DS_VID.to_le_bytes());
a[6..8].copy_from_slice(&DS_PID.to_le_bytes());
a[8..10].copy_from_slice(&DS_VER.to_le_bytes());
a
}
// Neutral DualSense input report 0x01 (64 bytes): sticks centered (0x80), triggers 0, dpad neutral (8).
const NEUTRAL_REPORT: [u8; 64] = {
let mut r = [0u8; 64];
r[0] = 0x01; // report id
r[1] = 0x80; // LX
r[2] = 0x80; // LY
r[3] = 0x80; // RX
r[4] = 0x80; // RY
// r[5]=L2, r[6]=R2 = 0; r[7] = seq counter = 0
r[8] = 0x08; // buttons[0]: low nibble = dpad hat (8 = neutral), high nibble = face buttons (0)
r
};
fn neutral_report() -> [u8; 64] {
NEUTRAL_REPORT
}
static MANUAL_QUEUE: AtomicPtr<WDFQUEUE__> = AtomicPtr::new(core::ptr::null_mut());
/// The latest input report the host pushed (report `0x01`) via shared memory; the timer delivers it
/// 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);
// ---- user-mode shared-memory IPC with the punktfunk host ----
// 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
// control device, so the host channel is a named section the (privileged) host CREATES and the driver
// OPENS. Layout (256 B): magic u32 @0 ("PFDS"), input_seq u32 @4, input_report[64] @8,
// output_seq u32 @72, output_report[64] @76.
const FILE_MAP_RW: u32 = 0x0002 | 0x0004; // FILE_MAP_WRITE | FILE_MAP_READ
const SHM_MAGIC: u32 = 0x5046_4453; // "PFDS" little-endian
const SHM_SIZE: usize = 256;
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).
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;
}
fn log(s: &str) {
if let Ok(c) = std::ffi::CString::new(s) {
// SAFETY: c is a valid null-terminated string for the duration of the call.
unsafe { OutputDebugStringA(c.as_ptr().cast()) };
}
// Also append to a world-writable file — DebugView can't capture the UMDF host's output
// across session 0, so this is how we read driver-start diagnostics.
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open("C:\\Users\\Public\\pfds-driver.log")
{
let _ = writeln!(f, "{s}");
}
}
macro_rules! dbglog { ($($a:tt)*) => { log(&format!($($a)*)) } }
#[unsafe(export_name = "DriverEntry")]
pub unsafe extern "system" fn driver_entry(
driver: PDRIVER_OBJECT,
registry_path: PCUNICODE_STRING,
) -> NTSTATUS {
log("[pf-ds] DriverEntry");
// SAFETY: 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() };
config.Size = core::mem::size_of::<WDF_DRIVER_CONFIG>() as ULONG;
config.EvtDriverDeviceAdd = Some(evt_device_add);
// SAFETY: all pointers valid; driver/registry_path provided by the loader.
unsafe {
call_unsafe_wdf_function_binding!(
WdfDriverCreate,
driver,
registry_path,
WDF_NO_OBJECT_ATTRIBUTES,
&mut config,
WDF_NO_HANDLE.cast::<WDFDRIVER>()
)
}
}
extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INIT) -> NTSTATUS {
log("[pf-ds] EvtDeviceAdd");
// Mark as a filter (HID minidriver sits below mshidumdf.sys).
// SAFETY: device_init is provided by the framework and non-null.
unsafe { call_unsafe_wdf_function_binding!(WdfFdoInitSetFilter, device_init) };
let mut device: WDFDEVICE = core::ptr::null_mut();
// SAFETY: device_init valid; attributes allowed null; device receives the handle.
let st = unsafe {
call_unsafe_wdf_function_binding!(
WdfDeviceCreate,
&mut device_init,
WDF_NO_OBJECT_ATTRIBUTES,
&mut device
)
};
if !nt_success(st) {
dbglog!("[pf-ds] WdfDeviceCreate failed 0x{:08x}", st as u32);
return st;
}
// Default parallel queue handling all IOCTLs.
// SAFETY: zeroed config then fields set; Size matches the struct.
let mut qcfg: WDF_IO_QUEUE_CONFIG = unsafe { core::mem::zeroed() };
qcfg.Size = core::mem::size_of::<WDF_IO_QUEUE_CONFIG>() as ULONG;
qcfg.DispatchType = WdfIoQueueDispatchParallel;
qcfg.PowerManaged = WdfUseDefault;
qcfg.DefaultQueue = 1;
qcfg.EvtIoDeviceControl = Some(evt_io_device_control);
// WDF_IO_QUEUE_CONFIG_INIT sets this to (ULONG)-1 (unlimited); mem::zeroed left it 0,
// which on a parallel queue means present ZERO requests → EvtIoDeviceControl never fires.
qcfg.Settings.Parallel.NumberOfPresentedRequests = u32::MAX;
let mut default_queue: WDFQUEUE = core::ptr::null_mut();
// SAFETY: device + config valid; attributes null; queue receives the handle.
let st = unsafe {
call_unsafe_wdf_function_binding!(
WdfIoQueueCreate,
device,
&mut qcfg,
WDF_NO_OBJECT_ATTRIBUTES,
&mut default_queue
)
};
if !nt_success(st) {
dbglog!(
"[pf-ds] default WdfIoQueueCreate failed 0x{:08x}",
st as u32
);
return st;
}
// Manual queue: pended READ_REPORT requests are completed by the timer.
// SAFETY: zeroed config then fields set.
let mut mcfg: WDF_IO_QUEUE_CONFIG = unsafe { core::mem::zeroed() };
mcfg.Size = core::mem::size_of::<WDF_IO_QUEUE_CONFIG>() as ULONG;
mcfg.DispatchType = WdfIoQueueDispatchManual;
mcfg.PowerManaged = WdfUseDefault;
let mut manual_queue: WDFQUEUE = core::ptr::null_mut();
// SAFETY: device + config valid; attributes null; queue receives the handle.
let st = unsafe {
call_unsafe_wdf_function_binding!(
WdfIoQueueCreate,
device,
&mut mcfg,
WDF_NO_OBJECT_ATTRIBUTES,
&mut manual_queue
)
};
if !nt_success(st) {
dbglog!("[pf-ds] manual WdfIoQueueCreate failed 0x{:08x}", st as u32);
return st;
}
MANUAL_QUEUE.store(manual_queue, Ordering::SeqCst);
// Periodic timer (parent = manual queue) completes pended reads with the neutral report.
// SAFETY: zeroed config then fields set.
let mut tcfg: WDF_TIMER_CONFIG = unsafe { core::mem::zeroed() };
tcfg.Size = core::mem::size_of::<WDF_TIMER_CONFIG>() as ULONG;
tcfg.EvtTimerFunc = Some(evt_timer);
tcfg.Period = 8; // ms
tcfg.AutomaticSerialization = 1; // TRUE — UMDF requires a serialized timer (vhidmini2 pattern)
let mut tattr: WDF_OBJECT_ATTRIBUTES = unsafe { core::mem::zeroed() };
tattr.Size = core::mem::size_of::<WDF_OBJECT_ATTRIBUTES>() as ULONG;
tattr.ParentObject = manual_queue.cast();
// mem::zeroed leaves these at 0 (Invalid) → set them like WDF_OBJECT_ATTRIBUTES_INIT
// (matches the working vhidmini2 UMDF timer setup; avoids 0xc0200209 / 0xc00000bb).
tattr.ExecutionLevel = WdfExecutionLevelInheritFromParent;
tattr.SynchronizationScope = WdfSynchronizationScopeInheritFromParent;
let mut timer: WDFTIMER = core::ptr::null_mut();
// SAFETY: config + attributes valid; timer receives the handle.
let st = unsafe {
call_unsafe_wdf_function_binding!(WdfTimerCreate, &mut tcfg, &mut tattr, &mut timer)
};
if !nt_success(st) {
dbglog!("[pf-ds] WdfTimerCreate failed 0x{:08x}", st as u32);
return st;
}
// SAFETY: timer valid; -80000 == 8ms relative due time (100ns units, negative = relative).
let _started = unsafe { call_unsafe_wdf_function_binding!(WdfTimerStart, timer, -80000i64) };
log("[pf-ds] device ready (DualSense 054C:0CE6)");
STATUS_SUCCESS
}
extern "C" fn evt_io_device_control(
_queue: WDFQUEUE,
request: WDFREQUEST,
_output_len: usize,
_input_len: usize,
ioctl: ULONG,
) {
let mut complete = true;
// 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.
if ioctl != IOCTL_HID_READ_REPORT {
dbglog!("[pf-ds] ioctl 0x{ioctl:08x} out={_output_len} in={_input_len}");
}
let status: NTSTATUS = match ioctl {
IOCTL_HID_GET_DEVICE_DESCRIPTOR => copy_to_output(request, &HID_DESC),
IOCTL_HID_GET_DEVICE_ATTRIBUTES => copy_to_output(request, &hid_attrs()),
IOCTL_HID_GET_REPORT_DESCRIPTOR => copy_to_output(request, &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 => {
on_output_report(request, ioctl)
}
IOCTL_UMDF_HID_SET_FEATURE => {
log("[pf-ds] SET_FEATURE (stub ok)");
STATUS_SUCCESS
}
IOCTL_UMDF_HID_GET_FEATURE => on_get_feature(request),
IOCTL_UMDF_HID_GET_INPUT_REPORT => copy_to_output(request, &neutral_report()),
_ => STATUS_NOT_IMPLEMENTED,
};
if ioctl != IOCTL_HID_READ_REPORT {
dbglog!(
"[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
// UMDF marshalling convention the report data is the *input* buffer and the report id is carried in
// the *output* buffer length. We log it.
fn on_output_report(request: WDFREQUEST, ioctl: ULONG) -> NTSTATUS {
let mut inmem: WDFMEMORY = core::ptr::null_mut();
// SAFETY: request valid.
let st = unsafe {
call_unsafe_wdf_function_binding!(WdfRequestRetrieveInputMemory, request, &mut inmem)
};
if !nt_success(st) {
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();
if !inbuf.is_null() {
// 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} "));
}
}
let kind = if ioctl == IOCTL_HID_WRITE_REPORT {
"WRITE_REPORT"
} else {
"SET_OUTPUT_REPORT"
};
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 /
// player-LEDs / adaptive triggers). output_report @76, output_seq @72.
if !inbuf.is_null() && inlen > 0 {
let n = inlen.min(64);
with_shm(|view| {
// SAFETY: view is a mapped 256-byte section; write the report then bump the host-polled seq.
unsafe {
core::ptr::copy_nonoverlapping(inbuf, view.add(76), n);
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.
unsafe { call_unsafe_wdf_function_binding!(WdfRequestSetInformation, request, inlen as u64) };
STATUS_SUCCESS
}
// GET_FEATURE: report id from the input buffer; reply with the matching DualSense feature blob.
fn on_get_feature(request: WDFREQUEST) -> NTSTATUS {
let mut inmem: WDFMEMORY = core::ptr::null_mut();
// SAFETY: request valid.
let st = unsafe {
call_unsafe_wdf_function_binding!(WdfRequestRetrieveInputMemory, request, &mut inmem)
};
if !nt_success(st) {
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;
}
// SAFETY: inbuf valid for >=1 byte.
let report_id = unsafe { *inbuf };
let blob: &[u8] = match report_id {
0x05 => &DS_FEATURE_CALIBRATION,
0x09 => &DS_FEATURE_PAIRING,
0x20 => &DS_FEATURE_FIRMWARE,
other => {
dbglog!("[pf-ds] GET_FEATURE unknown report id 0x{other:02x}");
return STATUS_INVALID_PARAMETER;
}
};
copy_to_output(request, blob)
}
// Open + map the host's shared-memory section (Global\pfds-shm-0) and run `f` against the mapped base
// if it exists with a valid magic, then unmap. NOT cached: re-mapped per access so the driver always
// sees the current section (UMDF groups all devices in one WUDFHost, and the host may recreate the
// section across restarts — a cached view would go stale). ~125 maps/s from the timer = negligible.
fn with_shm<F: FnOnce(*mut u8)>(f: F) {
let name: Vec<u16> = "Global\\pfds-shm-0"
.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 the section alive,
// so the handle can be closed right away.
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 {
if !LOGGED_SHM.swap(true, Ordering::Relaxed) {
dbglog!("[pf-ds] control: shared memory mapped (Global\\pfds-shm-0)");
}
f(view);
}
// SAFETY: view came from MapViewOfFile.
unsafe { UnmapViewOfFile(view as *const c_void) };
}
extern "C" fn evt_timer(timer: WDFTIMER) {
// Pull the latest host input report from shared memory (if the host has connected).
with_shm(|view| {
let mut buf = [0u8; 64];
// SAFETY: view points at a mapped 256-byte section; input lives at offset 8..72.
unsafe { core::ptr::copy_nonoverlapping(view.add(8), buf.as_mut_ptr(), 64) };
if buf[0] == 0x01 {
if let Ok(mut g) = INPUT_REPORT.lock() {
*g = buf;
}
}
});
// SAFETY: timer valid; parent is the manual queue.
let queue =
unsafe { call_unsafe_wdf_function_binding!(WdfTimerGetParentObject, timer) } as WDFQUEUE;
let mut request: WDFREQUEST = core::ptr::null_mut();
// SAFETY: queue valid; request receives the next pended request if any.
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 s = copy_to_output(request, &report);
// SAFETY: request valid and dequeued.
unsafe { call_unsafe_wdf_function_binding!(WdfRequestComplete, request, s) };
}
let _ = STATUS_UNSUCCESSFUL; // keep the const referenced
}
+19
View File
@@ -25,6 +25,7 @@ param(
[string]$Publisher = 'CN=unom', [string]$Publisher = 'CN=unom',
[string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # reuse the client's signing secret [string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # reuse the client's signing secret
[string]$PfxPassword = $env:MSIX_CERT_PASSWORD, [string]$PfxPassword = $env:MSIX_CERT_PASSWORD,
[string]$FfmpegDir = $env:FFMPEG_DIR, # bundle its bin\*.dll (amf-qsv build)
[switch]$NoDriver, # build without the bundled SudoVDA driver [switch]$NoDriver, # build without the bundled SudoVDA driver
[switch]$NoSign # skip signing (local debug) [switch]$NoSign # skip signing (local debug)
) )
@@ -146,6 +147,24 @@ if (-not $NoDriver) {
} }
else { Write-Host "-NoDriver: building installer WITHOUT the bundled SudoVDA driver" } else { Write-Host "-NoDriver: building installer WITHOUT the bundled SudoVDA driver" }
# --- stage the FFmpeg shared DLLs (AMD/Intel AMF/QSV build) ------------------------------------
# A host built with --features amf-qsv link-imports avcodec/avutil/swscale/... so the shared DLLs
# MUST sit next to the exe (it won't start otherwise). Bundle them from $FfmpegDir\bin — the same
# BtbN gpl-shared tree the build linked against. A nvenc/software-only build doesn't import them, so
# this is a harmless extra there; skipped entirely when $FfmpegDir is unset.
$ffmpegBinSrc = if ($FfmpegDir) { Join-Path $FfmpegDir 'bin' } else { $null }
if ($ffmpegBinSrc -and (Test-Path $ffmpegBinSrc)) {
$dlls = Get-ChildItem -Path $ffmpegBinSrc -Filter '*.dll' -ErrorAction SilentlyContinue
if ($dlls) {
$ffmpegStage = Join-Path $OutDir 'ffmpeg'
New-Item -ItemType Directory -Force -Path $ffmpegStage | Out-Null
$dlls | ForEach-Object { Copy-Item $_.FullName -Destination $ffmpegStage -Force }
$defines += "/DFfmpegBin=$ffmpegStage"
Write-Host "bundling $($dlls.Count) FFmpeg DLL(s) from $ffmpegBinSrc"
}
}
else { Write-Host "no FFMPEG_DIR\bin -> installer built WITHOUT FFmpeg DLLs (nvenc/software-only host)" }
# --- build the installer (from the non-redirected copy under C:\t) ----------------------------- # --- build the installer (from the non-redirected copy under C:\t) -----------------------------
Write-Host "==> ISCC $($defines -join ' ') $issLocal" Write-Host "==> ISCC $($defines -join ' ') $issLocal"
& $iscc @defines $issLocal & $iscc @defines $issLocal
+11
View File
@@ -32,6 +32,11 @@
#ifdef StageDir #ifdef StageDir
#define WithDriver #define WithDriver
#endif #endif
; FfmpegBin (a dir of FFmpeg shared DLLs) is optional — present when the host is built with
; --features amf-qsv (the AMD/Intel AMF/QSV encode backend link-imports the FFmpeg libs).
#ifdef FfmpegBin
#define WithFfmpeg
#endif
[Setup] [Setup]
AppId={{7C9E6A52-1F4B-4E8D-A3C7-2B5D8F1E0A93} AppId={{7C9E6A52-1F4B-4E8D-A3C7-2B5D8F1E0A93}
@@ -68,6 +73,12 @@ Name: "startservice"; Description: "Start the punktfunk host service now (also s
Source: "{#BinDir}\punktfunk-host.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "{#BinDir}\punktfunk-host.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
#ifdef WithFfmpeg
; FFmpeg shared DLLs (avcodec/avutil/swscale/...) laid down next to the exe — the AMD/Intel
; (AMF/QSV) encode backend link-imports them, so the exe won't start without them. NVENC/software-
; only builds simply omit this block.
Source: "{#FfmpegBin}\*.dll"; DestDir: "{app}"; Flags: ignoreversion
#endif
#ifdef WithDriver #ifdef WithDriver
; The driver payload + nefconc.exe + install-sudovda.ps1, extracted to {tmp} and removed after install. ; The driver payload + nefconc.exe + install-sudovda.ps1, extracted to {tmp} and removed after install.
Source: "{#StageDir}\*"; DestDir: "{tmp}\sudovda"; Flags: deleteafterinstall recursesubdirs createallsubdirs; Tasks: installdriver Source: "{#StageDir}\*"; DestDir: "{tmp}\sudovda"; Flags: deleteafterinstall recursesubdirs createallsubdirs; Tasks: installdriver
+5 -3
View File
@@ -9,9 +9,11 @@
# Format: KEY=VALUE per line; '#' starts a comment. The service loads these into its environment # Format: KEY=VALUE per line; '#' starts a comment. The service loads these into its environment
# and passes PUNKTFUNK_* and RUST_LOG through to the host it launches into the active session. # and passes PUNKTFUNK_* and RUST_LOG through to the host it launches into the active session.
# Hardware encode via NVENC (NVIDIA). The host must be the `--features nvenc` build. Falls back to # Hardware encode backend. `auto` (default) detects the GPU vendor: NVIDIA->nvenc (direct SDK),
# the software encoder automatically if NVENC is unavailable. # AMD->amf, Intel->qsv (both libavcodec). Force one with: nvenc | amf | qsv | sw (software H.264).
PUNKTFUNK_ENCODER=nvenc # nvenc needs the `--features nvenc` build; amf/qsv need the `--features amf-qsv` build (FFmpeg DLLs
# ship in the installer). The published installer is built with all three.
PUNKTFUNK_ENCODER=auto
# Video source: `virtual` creates a per-client virtual display (SudoVDA) at the client's exact # Video source: `virtual` creates a per-client virtual display (SudoVDA) at the client's exact
# resolution + refresh — the flagship mode. Requires the SudoVDA indirect display driver installed. # resolution + refresh — the flagship mode. Requires the SudoVDA indirect display driver installed.