Files
punktfunk/docs/windows-dualsense-game-detection.md
enricobuehler b0c82333d2
audit / cargo-audit (push) Successful in 17s
apple / swift (push) Successful in 57s
android / android (push) Successful in 4m36s
ci / web (push) Successful in 34s
ci / docs-site (push) Successful in 52s
release / apple (push) Successful in 7m31s
ci / rust (push) Successful in 8m37s
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
deb / build-publish (push) Successful in 2m35s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m18s
flatpak / build-publish (push) Successful in 4m0s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m31s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m22s
windows-host / package (push) Successful in 2m56s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m13s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m15s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 59s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m3s
feat(gamepad): pure-user-mode Windows DualShock 4 + Xbox 360 (drop ViGEm) + installer + multi-pad
Windows virtual gamepads now have zero external dependencies - ViGEmBus is removed.

- DualShock 4: Windows UMDF backend (inject/dualshock4_windows.rs + dualshock4_proto.rs),
  reusing the DualSense SwDeviceCreate game-detection identity fix. The one UMDF driver serves
  the DS5 or DS4 identity/descriptor/features/strings per a device_type byte the host stamps into
  shared memory. Driver also gains IOCTL_HID_GET_STRING and a 41-byte calibration feature.
- Xbox 360: a new UMDF2 XUSB companion driver (packaging/windows/xusb-driver/) that registers
  GUID_DEVINTERFACE_XUSB and answers the buffered XInput IOCTLs from a shared section, so classic
  XInputGetState/SetState work with no kernel bus driver. inject/gamepad_windows.rs is rewritten
  to drive it and the vigem-client dependency is removed. Xbox One folds to the 360 XInput path.
- Installer: vendor + pnputil-install the three UMDF drivers (packaging/windows/gamepad-drivers/
  + install-gamepad-drivers.ps1, wired into pack-host-installer.ps1 + punktfunk-host.iss).
- Multi-pad: the host stamps each pad index into the device Location (pszDeviceLocation); the
  driver reads it via WdfDeviceAllocAndQueryProperty to map its own *-shm-<index>, with
  UmdfHostProcessSharing=ProcessSharingDisabled giving each pad its own host (per-pad statics).

Validated live on the Windows host: Cyberpunk native DualSense detection, DS4 identity + descriptor,
XInputGetState + rumble round-trip, two pads -> two distinct XInput slots, and a full installer build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:35:03 +02:00

18 KiB

Windows virtual DualSense — game detection handoff

Goal: get the host's virtual DualSense detected and usable in games (Cyberpunk's native PS5 path + others) on the Windows host. This doc is the portable handoff (the investigation lives here, not in any one agent's memory). Run the experiments on the Windows host (.173, repo at C:\Users\Public\punktfunk-native).

Status (2026-06-22)

  • Input works. Client → host → virtual DualSense → games read input. Verified in Steam's controller test (buttons/sticks).
  • The HID is a CORRECT, COMPLETE DualSense. An SDL3 probe reports our live device as name='DualSense Wireless Controller' vid=0x054C pid=0x0CE6 isGamepad=True gamepadType=PS5. SDL = HIDAPI = what Steam (and many games) build on → that's why Steam works. So the report descriptor, feature reports, and identity are right; this is not a descriptor/feature-report problem.
  • Cyberpunk's native DualSense path does NOT detect it at all. (Steam Input was off — Cyberpunk was reading the raw HID.)
  • Rumble: host-side is proven working (driver captures the game's 0x02, parse_ds_output extracts the motors, host forwards 0xCA — log: rumble: forwarding to client (0xCA) low=16128 high=16128). The break is the client (macOS) not rendering 0xCA onto the physical pad. Separate task/agent.

Root cause — CONFIRMED (2026-06-22, run live on the interactive desktop, console session 3)

The break is the device's PnP identity / device-interface path, not the HID descriptor or feature reports. hidclass derives the HID child's path token and its HID\VID_054C&PID_0CE6 hardware-ids from the parent bus device's hardware-id. Our parent is the software (SWD) devnode SWD\PUNKTFUNK\PF_PAD_0 whose hardware-id is pf_dualsense (no VID/PID), so hidclass emits only the VendorID+usage fallback and no PID. Measured on this box (one virtual pad live + one real 8BitDo present):

HID-child hardware-ids (DEVPKEY_Device_HardwareIds, CompatibleIds empty): HID\pf_dualsense · HID\VID_054C&UP:0001_U:0005 · HID_DEVICE_SYSTEM_GAME · HID_DEVICE_UP:0001_U:0005 · HID_DEVICEnote the absent HID\VID_054C&PID_0CE6. HIDD_ATTRIBUTES itself is correct (VID 054C / PID 0CE6), which is why attribute-readers work.

Device-interface paths (from HKLM\SYSTEM\CurrentControlSet\Control\DeviceClasses\{4d1e55b2-…}):

Device HID interface path
Ours (virtual) \\?\HID#punktfunk#1&ca418da&0&0000#{…}no VID_/PID_ token
Real DualShock 4 (USB, registry remnant) \\?\HID#VID_054C&PID_05C4&REV_0100#…
Real DualSense (BT, registry remnant) \\?\HID#{00001124-…}_VID&0002054c_PID&0ce6#…

Cross-API enumeration (the decisive experiment — impossible over SSH, run live in the console session):

API Sees our virtual DS5? Identity reported Reads from
SDL3 / HIDAPI 054C:0CE6, type=PS5 HIDD_ATTRIBUTES → Steam works
RawInput 054C:0CE6 HIDD_ATTRIBUTES
WGI RawGameController 054C:0CE6 HIDD_ATTRIBUTES
WGI Gamepad empty (empty for all pads on this box — no Xbox-profile pad; not DS-specific)
MS GameInput enumerates it vid=0x0000 pid=0x0000 PnP path / hardware-ids
Cyberpunk native PS5 needs the DS5 VID/PID identity

The GameInput result is the clincher: it does enumerate our pad — descriptor fingerprint matches exactly (15 buttons, 6 axes, 1 hat, usage Game Pad 0x05) — but reports vid/pid = 0, while it reads the real 8BitDo's vid=0x3434 correctly. So GameInput (and, by the same logic, a native PS5 path) takes VID/PID from the PnP device path / hardware-ids, NOT from HIDD_ATTRIBUTES, and ours carry no VID_054C&PID_0CE6. Everything that reads attributes directly (SDL / RawInput / WGI-raw) is fine; everything that keys off the device identity/path (GameInput, native DualSense detection) sees a generic, unidentified gamepad → no PS5 path.

⇒ The fix must put VID_054C&PID_0CE6 into the device-interface path and the HID\VID&PID hardware-ids (give the device a real-USB-like PnP identity), not merely correct HIDD_ATTRIBUTES. See "Fix options".

Secondary driver gaps found (not the detection blocker, but fix while here):

  • IOCTL_HID_GET_STRING (id 4, ioctl 0x000b0013) returns STATUS_NOT_IMPLEMENTED — a game polls it repeatedly (seen live in pfds-driver.log). Implement manufacturer / product / serial strings ("DualSense Wireless Controller", a serial). Native PS5 code can read the serial to tell USB from BT.
  • DS_FEATURE_CALIBRATION is 42 bytes but the report descriptor declares feature 0x05 as 41 (0x95 0x28 = 40 data + 1 id). Trim to 41 (motion-only; SDL accepts it regardless).

Fix — implemented & validated at the identity layer (2026-06-22)

create_swdevice (inject/dualsense_windows.rs) now sets, via SW_DEVICE_CREATE_INFO struct fields (NOT pProperties — empirically a DEVPROPERTY write of these PnP-owned identity keys is ignored; the create-time struct fields are the supported lever, confirmed on .173):

  • pszzCompatibleIds = USB\VID_054C&PID_0CE6, USB\Class_03&SubClass_00&Prot_00, USB\Class_03 (Windows appends SWD\Generic). HIDAPI/SDL/libScePad walk HID-child → CM_Get_Parent → this parent's CompatibleIds and string-match "USB"bus_type now resolves to USB (was UNKNOWN).
  • pszzHardwareIds = pf_dualsense first (so the INF still binds our UMDF driver), then USB\VID_054C&PID_0CE6&REV_0100, USB\VID_054C&PID_0CE6. hidclass then derives the real-DS5 child ids HID\VID_054C&PID_0CE6[&REV_0100] (previously only HID\VID_054C&UP:0001_U:0005).
  • pContainerId = a deterministic per-pad GUID {50464453-0000-0000-0000-00000000000<idx>} ("PFDS") (avoids the null-sentinel-ContainerId xinput1_4 slot-skip bug; groups the pad's devnodes).

Validated live (real shipping path, dualsense-windows-test --index 1 alongside the running service's pad 0): INF still binds (Service=MsHidUmdf), parent CompatibleIds/HardwareIds + per-pad ContainerId set, the HID child gains HID\VID_054C&PID_0CE6, and the HIDAPI parent-walk reports bus_type=USB. SDL / RawInput / WGI RawGameController identity stays correct (054C:0CE6).

Remaining gap (NOT fixed by the above): GameInput VID/PID still reads 0. GameInput parses VID/PID from the HID child's instance path (HID\punktfunk\1&…), which carries no VID_…&PID_… token; neither CompatibleIds nor HardwareIds change the instance path. Only a real USB-bus instance path (HID\VID_054C&PID_0CE6\…) does — i.e. a ViGEm-style KMDF USB-emulating bus driver (the rank-3, last resort). Pursue only if a target title uses GameInput AND the identity fix above doesn't satisfy it; prior art (HIDMaestro) shows pure user-mode pads ARE accepted by WGI/GameInput, so other parity (descriptor / strings / mapping) may matter more than a genuine USB bus.

Next steps

Deployed to .173 (2026-06-22): the host identity fix is live in the PunktfunkHost service (release rebuilt + restarted) and the driver fixes are installed + signed (oem74.inf, punktfunk-ds-test cert). The box is ready for the decisive on-glass test. A rollback copy of the prior driver is at C:\Users\Public\giprobe\driver-backup-oem74.

  1. Decisive on-glass test (only the user can run): launch Cyberpunk 2077 with Steam Input OFF against a virtual DS5 carrying the new identity; check the in-game glyphs/prompt switch to DualSense. Cleanest single-pad test (frees the service's pad 0 so only the new-identity pad is present): sc stop PunktfunkHosttarget\debug\punktfunk-host.exe dualsense-windows-test --index 0 --seconds 600 (new identity + live cycling Cross/stick), launch the game; then deploy the release + restart with scripts\windows\deploy-host.ps1.
  2. Driver-side correctness — DONE & installed (2026-06-22). Rebuilt/resigned/reinstalled per the recipe below; validated live (hidstrings probe + pfds-driver.log):
    • IOCTL_HID_GET_STRING now implemented (was STATUS_NOT_IMPLEMENTED). Discovery: Windows polls this device's string slots with low-word ids 0x0E/0x0F/0x10 (lang 0x0409) cyclically — NOT the 0/1/2 HID_STRING_ID_* constants. The handler maps them (+ 0/1/2 as fallbacks): 0x0E→manufacturer "Sony Interactive Entertainment", 0x0F→product "DualSense Wireless Controller", 0x10→serial "35533AD6E774" (the 0x09 pairing-report MAC). Verified: HidD_GetManufacturer/Product/ SerialNumberString now return those three distinct strings.
    • DS_FEATURE_CALIBRATION trimmed 42 → 41 bytes (1 id + 40 data) to match the descriptor's feature 0x05 (0x95 0x28).
    • The repo source (packaging/windows/dualsense-driver/src/lib.rs) and the m0 build copy were diverged by formatting only; they are now back in sync (the repo file was copied to m0 before building).
  3. If a GameInput-only title needs the real VID/PID → the rank-3 KMDF USB-emulating bus driver.

On-box experiment tooling (built 2026-06-22, C:\Users\Public\giprobe\)

  • probe.cpp (+build.bat) — GameInput enumeration/fingerprint via LoadLibrary("GameInput.dll") + GameInputCreate/RegisterDeviceCallback (GDK header). Prints each device's vid/pid/usage/counts — this is what proved GameInput reads our pad as vid=0.
  • swexp.cpp (+build-swexp.bat) — standalone SwDeviceCreate identity experiment: variations for pszzCompatibleIds (struct field) vs DEVPKEY_Device_CompatibleIds (pProperties — ignored), pszzHardwareIds USB ids, pContainerId. Create at a spare instance id, hold, inspect. Built with the VS18 MSVC toolchain via vcvars64.bat.
  • WGI probe: Windows PowerShell 5.1 WinRT projection of RawGameController/Gamepad (pump the message loop; subscribe RawGameControllerAdded to kick enumeration).
  • Parent-walk bus check: from the HID child, DEVPKEY_Device_Parent → that node's DEVPKEY_Device_CompatibleIds, match ^USB/^BTH — mirrors HIDAPI's hid_internal_detect_bus_type().
  • NOTE: the agent shell's PowerShell tool chokes on inline @'…'@ here-strings feeding Add-Type (throws a spurious "Remove-Item on system path '/' is blocked"); write C#/scripts to a file and run them instead.

How to reproduce / iterate (on .173)

1. Spawn a live virtual DualSense to test against

C:\Users\Public\punktfunk-native\target\debug\punktfunk-host.exe dualsense-windows-test --seconds 60

Creates SWD\PUNKTFUNK\PF_PAD_0 (+ its HID child) and holds it, pushing a cycling input. Or just connect a client — the real session creates the identical device. (Build with the env CMAKE_POLICY_VERSION_MINIMUM=3.5.)

2. SDL3 detection oracle (already set up: C:\Users\Public\sdltest\SDL3.dll)

Confirms HID-level recognition (HIDAPI). Run while a device from step 1 is live. PowerShell + C# (note: PS 5.1's Add-Type is C# 5 — no interpolated strings, no inline out vars, no Marshal.PtrToStringUTF8; SDL3 bools are 1 byte → [return: MarshalAs(UnmanagedType.I1)]):

$cs = @'
using System; using System.Runtime.InteropServices; using System.Text;
public static class S {
  const string D = @"C:\Users\Public\sdltest\SDL3.dll";
  [DllImport(D)][return: MarshalAs(UnmanagedType.I1)] public static extern bool SDL_Init(uint f);
  [DllImport(D)] public static extern IntPtr SDL_GetJoysticks(out int c);
  [DllImport(D)] public static extern IntPtr SDL_GetJoystickNameForID(uint id);
  [DllImport(D)] public static extern ushort SDL_GetJoystickVendorForID(uint id);
  [DllImport(D)] public static extern ushort SDL_GetJoystickProductForID(uint id);
  [DllImport(D)][return: MarshalAs(UnmanagedType.I1)] public static extern bool SDL_IsGamepad(uint id);
  [DllImport(D)] public static extern IntPtr SDL_OpenGamepad(uint id);
  [DllImport(D)] public static extern int SDL_GetGamepadType(IntPtr g);
  static string U(IntPtr p){ if(p==IntPtr.Zero)return""; int n=0; while(Marshal.ReadByte(p,n)!=0)n++; byte[] b=new byte[n]; Marshal.Copy(p,b,0,n); return Encoding.UTF8.GetString(b); }
  public static string Run(){ if(!SDL_Init(0x2000))return"init fail"; System.Threading.Thread.Sleep(1500);
    int n=0; IntPtr a=SDL_GetJoysticks(out n); StringBuilder sb=new StringBuilder("joysticks: "+n+"\n");
    for(int i=0;i<n;i++){ uint id=(uint)Marshal.ReadInt32(a,i*4); bool ig=SDL_IsGamepad(id); int t=ig?SDL_GetGamepadType(SDL_OpenGamepad(id)):-1;
      sb.AppendLine("  '"+U(SDL_GetJoystickNameForID(id))+"' vid=0x"+SDL_GetJoystickVendorForID(id).ToString("x4")+" pid=0x"+SDL_GetJoystickProductForID(id).ToString("x4")+" isGamepad="+ig+" type="+t+" (PS5=6)"); }
    return sb.ToString(); }
}
'@
Add-Type -TypeDefinition $cs; [S]::Run()

Expected today: it lists our device with type=6 (PS5). That's the baseline "HID is correct".

Next experiments — MUST run ON THE INTERACTIVE DESKTOP, not over SSH

WGI / RawInput / GameInput enumeration returns empty from a headless SSH session (no window/message pump) — only HIDAPI works headless. So these must run in the logged-in desktop session (RDP in, or run locally) while a DualSense session is live:

  1. Determine which API Cyberpunk uses and whether it sees the SWD device. Enumerate via, separately:
    • Windows.Gaming.Input (RawGameController.RawGameControllers, Gamepad.Gamepads),
    • RawInput (GetRawInputDeviceList → filter HID gamepad usage 01/05),
    • GameInput (GameInputCreateEnumerateDevices) — GameInputRedistService is installed on .173. Compare which list our VID_054C&PID_0CE6 appears in. The one(s) it's missing from point at the API Cyberpunk uses.
  2. If WGI/GameInput exclude it: make the SwDeviceCreate device enumerate more like a real USB device. SwDeviceCreate takes a pProperties (DEVPROPERTY[]) array — try setting bus-type / container-id / compatible-IDs so the newer APIs accept it. If that's insufficient, the heavyweight option is a USB-emulating bus driver (the way ViGEmBus presents a real-looking device) instead of SwDeviceCreate + UMDF-HID.
  3. Rule out an XInput device taking priority (a leftover ViGEm pad, etc.).
  4. Correctness (not the detection blocker): DS_FEATURE_CALIBRATION in the driver is 42 bytes but the report descriptor declares feature 0x05 as 41 (1 id + 40 data, 0x95 0x28). Trim to 41; wrong calibration only affects motion, and SDL accepts the device regardless.

On-box layout (.173, builds + tools)

  • Host repo / build: C:\Users\Public\punktfunk-nativecargo build -p punktfunk-host (debug for dualsense-windows-test; --release --features nvenc is what the service runs). The build env is persisted Machine-scope (PUNKTFUNK_NVENC_LIB_DIR, LIBCLANG_PATH, CMAKE_POLICY_VERSION_MINIMUM) — see scripts\windows\. One-call rebuild+redeploy of the service: scripts\windows\deploy-host.ps1 (stop → build → restart, .bak rollback); web: scripts\windows\build-web.ps1. bun=C:\Users\Public\bun, node=C:\Users\Public\node-v22.11.0-win-x64.
  • Host service: scheduled task / SCM PunktfunkHost runs …\target\release\punktfunk-host.exe service run → spawns serve (currently native-only, PUNKTFUNK_HOST_CMD=serve in C:\ProgramData\punktfunk\host.env). Restart: sc stop/start PunktfunkHost. Native port 9777, mgmt 47990. (NB: Sunshine/Apollo conflicts on the GameStream ports — keep it stopped, or run native-only.)
  • UMDF driver build project: C:\Users\Public\m0\windows-drivers-rs\examples\pf-dualsense (pf_dualsense.inx + src\lib.rs live here; the canonical copies are in the repo under packaging/windows/dualsense-driver/ — keep them in sync). Rebuild + reinstall recipe (e.g. after the calibration fix), all from that dir, env LIBCLANG_PATH=C:\Program Files\LLVM\bin, Version_Number=10.0.26100.0:
    1. cargo maketarget\debug\pf_dualsense_package\
    2. Clear the FORCE_INTEGRITY PE bit (wdk-build sets /INTEGRITYCHECK, which blocks self-signed load): clear bit 0x80 at PE_header_offset+0x5e of pf_dualsense.dll, then re-sign.
    3. signtool sign /fd SHA256 /sha1 6A52984E54376C45A1C236B1A2C8A746C5AB6131 pf_dualsense.dll
    4. Inf2Cat /driver:<pkg> /os:10_x64 → re-sign the .cat with the same thumbprint.
    5. pnputil /delete-driver <old oemNN.inf> /uninstall /force then pnputil /add-driver pf_dualsense.inf /install. (Self-signed cert is already trusted on .173; Secure Boot ON, HVCI off.)
  • SDL oracle: C:\Users\Public\sdltest\SDL3.dll. Test device: punktfunk-host.exe dualsense-windows-test --seconds N creates one SWD\PUNKTFUNK\PF_PAD_0 and holds it.

Key code

What File
Host backend (create_swdevice, the Global\pfds-shm-<idx> section, write_state/service/pump) crates/punktfunk-host/src/inject/dualsense_windows.rs
UMDF driver (HID descriptor, feature reports, on_output_report) packaging/windows/dualsense-driver/src/lib.rs
Shared report codec (serialize_state input, parse_ds_output feedback) crates/punktfunk-host/src/inject/dualsense_proto.rs
Pad seam (PadBackend, pump → rumble 0xCA / hidout 0xCD) crates/punktfunk-host/src/punktfunk1.rs

Facts proven (don't re-litigate)

  • SwDeviceCreate requirements: enumerator must have no underscore (punktfunk); the completion callback is mandatory (NULL → E_INVALIDARG). Per-session device works; auto-removed on disconnect.
  • HID descriptor + feature reports are DS5-accurate enough that SDL identifies it as PS5.
  • Host-side rumble works end to end; the client (macOS) rendering of 0xCA is the open rumble bug.