# 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_DEVICE` — **note 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}` ("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 PunktfunkHost` → `target\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)]`): ```powershell $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 /os:10_x64` → re-sign the `.cat` with the same thumbprint. 5. `pnputil /delete-driver /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-` 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.