Files
punktfunk/design/windows-dualsense-game-detection.md
T
enricobuehler d01a8fd17a
ci / web (push) Failing after 22s
windows-host / package (push) Failing after 4m16s
ci / rust (push) Failing after 4m56s
ci / docs-site (push) Successful in 1m7s
android / android (push) Successful in 9m19s
ci / bench (push) Successful in 4m47s
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 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Failing after 3s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
docker / deploy-docs (push) Has been skipped
deb / build-publish (push) Failing after 6m29s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 7m4s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m17s
apple / swift (push) Successful in 1m13s
apple / screenshots (push) Successful in 5m27s
feat(host): HDR Vulkan layer so Vulkan games get HDR on the virtual display
NVIDIA/AMD Vulkan ICDs refuse to *advertise* an HDR color space for a surface on an
IddCx indirect/virtual display, so Vulkan games (Doom: The Dark Ages, id Tech, Indiana
Jones, …) report "device does not support HDR" — even though Windows HDR, DWM compose,
and the client PQ stream all work, and the ICD happily *accepts + presents* a forced HDR
swapchain there. The whole gap is enumeration; the community (Apollo/Sunshine/VDD) wrote
this off as kernel-side / unfixable.

Add VK_LAYER_PUNKTFUNK_hdr_inject (packaging/windows/pf-vkhdr-layer/): a standalone
cdylib Vulkan implicit layer that appends {A2B10G10R10, HDR10_ST2084} + {RGBA16F, scRGB}
to vkGetPhysicalDeviceSurfaceFormats[2]KHR (no need to hook vkCreateSwapchainKHR — the
ICD doesn't validate the color space there). Self-gated on the surface monitor's actual
advanced-color state (DisplayConfig GET_ADVANCED_COLOR_INFO), so it is a complete no-op
on SDR sessions and real monitors (dedup). Always-on (registry-discovered) so it works
regardless of how a game is launched — env-scoping silently fails for already-running
Steam. Escape hatches: DISABLE_PF_VKHDR, PF_VKHDR_EXCLUDE, and a built-in kernel-anti-
cheat denylist.

The installer builds/signs/stages it and registers it under
HKLM64\SOFTWARE\Khronos\Vulkan\ImplicitLayers (opt-out "Install the HDR Vulkan layer"
task); windows-host CI fmt+clippy-gates it (msvc-only FFI).

Live-validated on the RTX box: Doom: The Dark Ages enables HDR over the pf-vdisplay
virtual display.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:33:20 +00: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.