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>
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_outputextracts the motors, host forwards0xCA— log:rumble: forwarding to client (0xCA) low=16128 high=16128). The break is the client (macOS) not rendering0xCAonto 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, ioctl0x000b0013) returnsSTATUS_NOT_IMPLEMENTED— a game polls it repeatedly (seen live inpfds-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_CALIBRATIONis 42 bytes but the report descriptor declares feature0x05as 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 appendsSWD\Generic). HIDAPI/SDL/libScePad walk HID-child →CM_Get_Parent→ this parent's CompatibleIds and string-match"USB"→bus_typenow resolves to USB (was UNKNOWN).pszzHardwareIds=pf_dualsensefirst (so the INF still binds our UMDF driver), thenUSB\VID_054C&PID_0CE6&REV_0100,USB\VID_054C&PID_0CE6. hidclass then derives the real-DS5 child idsHID\VID_054C&PID_0CE6[&REV_0100](previously onlyHID\VID_054C&UP:0001_U:0005).pContainerId= a deterministic per-pad GUID{50464453-0000-0000-0000-00000000000<idx>}("PFDS") (avoids the null-sentinel-ContainerIdxinput1_4slot-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 thePunktfunkHostservice (release rebuilt + restarted) and the driver fixes are installed + signed (oem74.inf,punktfunk-ds-testcert). The box is ready for the decisive on-glass test. A rollback copy of the prior driver is atC:\Users\Public\giprobe\driver-backup-oem74.
- 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 withscripts\windows\deploy-host.ps1. - Driver-side correctness — DONE & installed (2026-06-22). Rebuilt/resigned/reinstalled per the recipe
below; validated live (
hidstringsprobe +pfds-driver.log):IOCTL_HID_GET_STRINGnow implemented (wasSTATUS_NOT_IMPLEMENTED). Discovery: Windows polls this device's string slots with low-word ids0x0E/0x0F/0x10(lang0x0409) cyclically — NOT the0/1/2HID_STRING_ID_*constants. The handler maps them (+0/1/2as fallbacks):0x0E→manufacturer "Sony Interactive Entertainment",0x0F→product "DualSense Wireless Controller",0x10→serial "35533AD6E774" (the0x09pairing-report MAC). Verified:HidD_GetManufacturer/Product/ SerialNumberStringnow return those three distinct strings.DS_FEATURE_CALIBRATIONtrimmed 42 → 41 bytes (1 id + 40 data) to match the descriptor's feature0x05(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).
- 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 viaLoadLibrary("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) — standaloneSwDeviceCreateidentity experiment: variations forpszzCompatibleIds(struct field) vsDEVPKEY_Device_CompatibleIds(pProperties — ignored),pszzHardwareIdsUSB ids,pContainerId. Create at a spare instance id, hold, inspect. Built with the VS18 MSVC toolchain viavcvars64.bat.- WGI probe: Windows PowerShell 5.1 WinRT projection of
RawGameController/Gamepad(pump the message loop; subscribeRawGameControllerAddedto kick enumeration). - Parent-walk bus check: from the HID child,
DEVPKEY_Device_Parent→ that node'sDEVPKEY_Device_CompatibleIds, match^USB/^BTH— mirrors HIDAPI'shid_internal_detect_bus_type(). - NOTE: the agent shell's PowerShell tool chokes on inline
@'…'@here-strings feedingAdd-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:
- 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 (
GameInputCreate→EnumerateDevices) —GameInputRedistServiceis installed on.173. Compare which list ourVID_054C&PID_0CE6appears in. The one(s) it's missing from point at the API Cyberpunk uses.
- If WGI/GameInput exclude it: make the SwDeviceCreate device enumerate more like a real USB device.
SwDeviceCreatetakes apProperties(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. - Rule out an XInput device taking priority (a leftover ViGEm pad, etc.).
- Correctness (not the detection blocker):
DS_FEATURE_CALIBRATIONin the driver is 42 bytes but the report descriptor declares feature0x05as 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-native→cargo build -p punktfunk-host(debug fordualsense-windows-test;--release --features nvencis what the service runs). The build env is persisted Machine-scope (PUNKTFUNK_NVENC_LIB_DIR,LIBCLANG_PATH,CMAKE_POLICY_VERSION_MINIMUM) — seescripts\windows\. One-call rebuild+redeploy of the service:scripts\windows\deploy-host.ps1(stop → build → restart,.bakrollback); 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
PunktfunkHostruns…\target\release\punktfunk-host.exe service run→ spawnsserve(currently native-only,PUNKTFUNK_HOST_CMD=serveinC:\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.rslive here; the canonical copies are in the repo underpackaging/windows/dualsense-driver/— keep them in sync). Rebuild + reinstall recipe (e.g. after the calibration fix), all from that dir, envLIBCLANG_PATH=C:\Program Files\LLVM\bin,Version_Number=10.0.26100.0:cargo make→target\debug\pf_dualsense_package\- Clear the FORCE_INTEGRITY PE bit (wdk-build sets
/INTEGRITYCHECK, which blocks self-signed load): clear bit 0x80 atPE_header_offset+0x5eofpf_dualsense.dll, then re-sign. signtool sign /fd SHA256 /sha1 6A52984E54376C45A1C236B1A2C8A746C5AB6131 pf_dualsense.dllInf2Cat /driver:<pkg> /os:10_x64→ re-sign the.catwith the same thumbprint.pnputil /delete-driver <old oemNN.inf> /uninstall /forcethenpnputil /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 Ncreates oneSWD\PUNKTFUNK\PF_PAD_0and 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)
SwDeviceCreaterequirements: 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
0xCAis the open rumble bug.