Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9.5 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 hypothesis (the thing to confirm/fix)
The device is software-enumerated: SWD\PUNKTFUNK\PF_PAD_0 → child HID\VID_054C&PID_0CE6. It is NOT
a real USB or Bluetooth device. SDL/HIDAPI enumerate any HID by VID/PID (incl. SWD) — so they see it. A
game's native DualSense path is pickier. Two likely causes:
- Windows.Gaming.Input (WGI) / GameInput exclude SWD (software) HID devices that raw-HID enumeration includes. Many modern titles use these.
- USB-vs-Bluetooth detection by device-path prefix. Native DS5 code picks the report format (64-byte
USB report
0x01vs 78-byte BT report0x31) from the connection type. If it keys off the device path (USB\…vsBTHENUM\…) rather than the report length, ourSWD\…path matches neither and it mis-detects. (SDL keys off the report length = 64 → USB → works.)
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.