Files
H4zeyaf--Ghostcontrol-PS5-U…/ProControllerResearch.md
T

8.4 KiB
Raw Blame History

Nintendo Switch Pro Controller USB Protocol Research

This document captures the complete research into making the 8BitDo Ultimate 2 (Nintendo Switch Pro Controller mode) work on PS5 via raw USB FS access.


Device Identification

Property Value
VID:PID (Nintendo mode) 0x057e:0x2009
VID:PID (Native mode) 0x2dc8:0x310b
PS5 device node /dev/ugen2.2
USB bus usbus2, depth=1 port=2 speed=FS
Kernel driver (pre-detach) usb_hid0
IN endpoint ep=0x81, wMaxPacketSize=64, bInterval=1ms
OUT endpoint ep=0x02, wMaxPacketSize=64 (NOTE: 0x02 not 0x01 like genuine Nintendo)

PS5 USB Access Method

Direct kernel HID access via usb_hid0 is blocked (EPERM). The working path:

USB_IFACE_DRIVER_DETACH(iface=0)   // detach usb_hid0
USB_FS_INIT(ep_index_max=N)        // allocate N endpoint slots
USB_FS_OPEN(ep_no=0x81, ep_index=0)  // IN endpoint
USB_FS_OPEN(ep_no=0x02, ep_index=1)  // OUT endpoint
USB_FS_START(ep_index=0)           // start interrupt IN transfer
USB_FS_COMPLETE(ep_index=0)        // block until data

Why Two-Pass Init

The IN endpoint (0x81) cannot be opened on the first try in some states. A "pass 1" is required:

// Pass 1: unlock endpoint
FS_INIT(1 slot) → DETACH → FS_OPEN(ep=0x81) → FS_UNINIT → close()

// Pass 2: actual session
open() → DETACH → FS_INIT(2 slots) → FS_OPEN IN(0x81) + OUT(0x02)

Pass 1 does nothing functionally — it just exercises the endpoint to unlock it for pass 2.


Critical Finding: Subcmd 0x03 Must Be Sent LAST

Wrong order (pipe breaks):

[80 02] → [80 04] → subcmd 0x03 0x30   ← USB pipe break, controller disconnects

Correct order (works):

[80 02] → [80 04]            ← USB handshake only
respond to 0x81 handshake    ← controller responds with 0x81 0x01 / 0x81 0x02
[80 04] again                ← handshake ack
subcmd 0x40 0x01             ← enable IMU
subcmd 0x48 0x01             ← enable vibration
subcmd 0x30 0x01             ← set player LED
subcmd 0x03 0x30             ← set input report mode (LAST)

Sending subcmd 0x03 first causes a USB pipe break (EBUSY on USB_FS_COMPLETE) and the controller enters a reconnect loop that requires 2–3 additional handshake rounds to recover. With the correct order, subcmd 0x03 is accepted cleanly and streaming starts immediately.


Complete Initialization Sequence

Phase 1: Two-Pass USB FS Setup

// Pass 1 (unlock)
fd1 = open("/dev/ugen2.2", O_RDWR);
USB_FS_INIT(fd1, ep_index_max=1);
USB_IFACE_DRIVER_DETACH(fd1, iface=0);
USB_FS_OPEN(fd1, ep_index=0, ep_no=0x81, max_bufsize=64);
USB_FS_UNINIT(fd1);
close(fd1);
usleep(30000);

// Pass 2 (working session)
fd = open("/dev/ugen2.2", O_RDWR);
USB_IFACE_DRIVER_DETACH(fd, iface=0);
USB_FS_INIT(fd, ep_index_max=2);
USB_FS_OPEN(fd, ep_index=0, ep_no=0x81, max_bufsize=64);   // IN
USB_FS_OPEN(fd, ep_index=1, ep_no=0x02, max_bufsize=64);   // OUT

Phase 2: Initial USB Handshake (Blind)

send_out([0x80, 0x02]);  // handshake start
usleep(30000);
send_out([0x80, 0x04]);  // disable USB timeout
usleep(50000);

Phase 3: Controller-Initiated Handshake Response

The controller sends 0x81 handshake packets. Respond within ~200ms or the controller gives up:

Controller sends: 0x81 0x01 → Host responds: [0x80, 0x02]
Controller sends: 0x81 0x02 → Host responds: [0x80, 0x04]

Phase 4: Subcmd Init Sequence (MUST be in this order)

send_subcmd(timer=1, subcmd=0x40, data=0x01);  // enable IMU
usleep(50000);
send_subcmd(timer=2, subcmd=0x48, data=0x01);  // enable vibration
usleep(50000);
send_subcmd(timer=3, subcmd=0x30, data=0x01);  // set player LED
usleep(50000);
send_subcmd(timer=4, subcmd=0x03, data=0x30);  // input mode = full push (LAST)

Subcmd packet format (12 bytes):

uint8_t sc[12] = {0};
sc[0] = 0x01;       // output report ID
sc[1] = timer;      // 4-bit counter, must increment each packet
// sc[2..9] = neutral rumble = {0,1,0x40,0x40,0,1,0x40,0x40}
sc[10] = subcmd;
sc[11] = data;

Phase 5: Read Loop

After subcmd 0x03, the controller immediately sends streaming data:

while (1) {
    USB_FS_START(ep_index=0);
    USB_FS_COMPLETE(ep_index=0, timeout=200ms);
    // process pkt[0..63]
}

Streaming Packet Format

After the init sequence, the controller sends 0x00-prefixed packets at ~60Hz:

Byte  0: 0x00         — always 0x00 (report ID stripped by 8BitDo firmware)
Byte  1: timer        — 8-bit counter, increments ~6-8 units per frame
Byte  2: 0x70         — battery/connection (0x70 = USB, full charge)
Byte  3: right_btns   — Y X B A SR SL R ZR (bits 0-7)
Byte  4: misc_btns    — Minus Plus R3 L3 Home Capture (bits 0-5)
Byte  5: left_btns    — Down Up Right Left SR SL L ZL (bits 0-7)
Bytes 6-8:  left stick  (12-bit packed, center ~0x800)
Bytes 9-11: right stick (12-bit packed, center ~0x7FF)
Byte 12: vibration status (0x0b = idle)
Bytes 13-47: IMU data (3 × 6-axis samples, when IMU enabled)

Note: Genuine Nintendo Switch Pro Controllers send 0x30 as byte 0 (the report ID). The 8BitDo sends 0x00. The rest of the layout is identical.

Stick Decoding (12-bit packed)

uint16_t lx = buf[6] | ((buf[7] & 0x0F) << 8);   // 0–4095, center ~2048
uint16_t ly = (buf[7] >> 4) | ((uint16_t)buf[8] << 4); // 0–4095, center ~2047
uint16_t rx = buf[9] | ((buf[10] & 0x0F) << 8);
uint16_t ry = (buf[10] >> 4) | ((uint16_t)buf[11] << 4);

// Map to 0–255 for ScePadData:
uint8_t x = (uint8_t)((v * 255u) / 4095u);

Button Bit Mapping

buf[3] — right face buttons:
  bit 0: Y     → PS5 Square
  bit 1: X     → PS5 Triangle
  bit 2: B     → PS5 Cross
  bit 3: A     → PS5 Circle
  bit 6: R     → PS5 R1
  bit 7: ZR    → PS5 R2

buf[4] — shared buttons:
  bit 0: Minus    → PS5 Create/Share
  bit 1: Plus     → PS5 Options
  bit 2: R3       → PS5 R3
  bit 3: L3       → PS5 L3
  bit 4: Home     → PS5 PS button

buf[5] — left buttons:
  bit 0: Down  → PS5 Down
  bit 1: Up    → PS5 Up
  bit 2: Right → PS5 Right
  bit 3: Left  → PS5 Left
  bit 6: L     → PS5 L1
  bit 7: ZL    → PS5 L2

Reconnect Handling

On controller unplug/replug:

  • USB_FS_START or USB_FS_COMPLETE returns ENXIO or ENOTTY
  • Close all endpoints, USB_FS_UNINIT, close(fd)
  • Re-scan ugen paths for the device (20s timeout)
  • Run the full two-pass init + handshake sequence again

On reconnect without full power cycle:

  • First packet may be all-zeros (00 00 00 00...) — discard it
  • Then 0x81 handshake packets appear — run Phase 3 and 4 again

USB FS COMPLETE Polling

USB_FS_COMPLETE is non-blocking. Poll at 50ms intervals for up to timeout_ms + 300ms:

int max_polls = (int)((timeout_ms + 300) / 50) + 1;
for (int w = 0; w < max_polls; w++) {
    if (ioctl(fd, USB_FS_COMPLETE, &co) == 0) break;
    if (errno != EBUSY) { /* error */ break; }
    usleep(50000);
}

Do not use 5-retry × 10ms — the USB transfer timeout is 200ms so you'll stop polling before the transfer can complete, leaving the endpoint stuck in EBUSY.


Things That Do Not Work

Approach Result
USB_GET_REPORT_DESC errno=25 ENOTTY
read(fd, buf, 64) without DETACH errno=1 EPERM
libSceHidControl Controller not auto-registered; ShellCore not initialized
libSceUsbd Not loaded in SceShellCore or SceShellUI
Soft-reinit after [80 04] Controller goes silent (wrong approach)
Sending subcmd 0x03 first USB pipe break → reconnect loop
Responding to 0x81 0x02 without subcmds Controller silent after [80 04]

Research Probe History

Probes run on PS5 (usb_handshake_probe v1 through v12) at port 6986, reports fetched from FTP /data/ghostpad/:

Version Key Discovery
v1 Confirmed soft-reinit after subcmd 0x03 pipe-break works
v2–v3 Fixed COMPLETE polling (50ms intervals needed, not 10ms)
v4–v5 Identified full USB bus-reset after [80 04] in second handshake
v6 Second subcmd 0x03 also causes bus-reset — infinite loop
v7–v8 Discovered controller gives up if host takes >200ms to respond to 0x81
v9 Tight 200ms loop — controller responds within timing window
v10 Added IMU/vibration subcmds — still no streaming
v11 BREAKTHROUGH: subcmd 0x03 sent LAST → immediate 0x30 streaming
v12 Full 64-byte packet dump — confirmed format, verified stick decode