Compare commits
6 Commits
983adc5347
...
6a501f484a
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a501f484a | |||
| 72eeedc4da | |||
| fde438a1ed | |||
| 01dc0b616c | |||
| 4a73102d48 | |||
| aa159df33f |
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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")]
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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 ~1181–1272) |
|
||||||
| 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` ~1181–1272), flipping `pick_gamepad`/`resolve_gamepad`
|
||||||
|
(~1558–1606) from `#[cfg(target_os = "linux")]` to `#[cfg(any(target_os = "linux", target_os =
|
||||||
|
"windows"))]`, plus the `inject.rs` module gating (~424–451). `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) | small–medium | 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 250–560/yr;
|
||||||
|
FIPS hardware token/HSM mandatory; 1–7 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 M1–M6** 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 (M0–M6)
|
||||||
|
|
||||||
|
| # | 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 | low–med | 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user