An 8BitDo Switch controller adventure

Recently I wanted to play Splatoon on PC using a gyro-based game controller rather than the large and awkward Wii U gamepad. Additionally I needed a replacement for my assortment of controllers in various states of jank and wear. After asking around on Fedi, I ended up ordering an 8BitDo Ultimate 2C BT controller. This decision set me down a rabbit hole of firmware research and hardware modding.

Sidenote: it's bewildering that the Ultimate 2C BT costs $30, while the Switch 1 Pro controller is listed at $80 (minus sales). Does the Pro Controller really work more than twice as well for gaming as a third-party option? (Rumble in Switch games does work significantly better with linear resonant actuators than 8BitDo's traditional motors, as the 8BitDo firmware runs the rumble motors so hard it throws off my aim in Metroid Prime Remastered. I ended up disabling rumble on my console.) I do think that the average third-party controller is dreadful; I had the misfortune of testing SmartTube with a PowerA wired Switch controller, and it's built and feels like a plastic Happy Meal toy.

Using the controller on PC

There's an interesting compatibility matrix for using the controller's functionality and/or gyro input on different OSes.

On Windows, controller support is patchy and depends on OS and app version.

Win32 trusts the HID descriptors. It shouldn't.

On Linux, the controller is better-supported, but gyro input requires raw access that's limited due to "security".

Β«There are no games on MacΒ», so I tested the wired controller in the Gamepad Tester webapp. Firefox gave a seizure (much like Win32 above), Safari properly detected the controller after I pressed a button, and Vivaldi worked properly from the get-go.


For Splatoon, I tried setting up the controller in Cemu, mapping the controls straight through (minus the touchscreen, though Cemu has a "graphic pack" that binds super jumping to a button combination). I had a bit of trouble getting gyro input to be mapped in Cemu, and had to switch input backends, play with udev on Linux, and reset my keybinds a few times to get it working.

How accurate is the controller? The buttons feel fine overall, and the D-Pad is clicky (opinions will differ, I experience slight thumb-strain but that's the case on many controllers).

While testing the analog sticks in the online Gamepad Tester, I found that both analog sticks produce values slightly beyond a unit vector in the corners, which get clipped at the cardinals.

Using the controller on phones

Android devices allow you to control the UI and apps using game controllers. The input stack assumes the Xbox controller layout, using the bottom button as Accept and the right button as Back, even on Switch Pro controllers which label the right button as A and bottom as B.

Using the controller on Switch

The controller works more or less as you'd expect on a Nintendo Switch.

Debugging switch bounce in Ultrahand

While using my homebrewed Switch, I started noticing instances where Ultrahand Overlay would often send extraneous button presses when loading between different overlays or games. When opening or closing overlays with A or B, I'd have to quickly release the button so it wouldn't be interpreted as a button press in the game or next overlay. After reporting this bug to the Ultrahand developer, I was told the bug was impossible and nobody else had reported it, but the issue persisted, disappearing and reappearing on its own.

I decided to attach gdb to Ultrahand-based overlays, single-step through the process of loading and unloading an overlay, and look for the point the active game received a double-input. After a series of "oops I returned from the function call" and "should I trace a different event?", I managed to isolate the trigger to Ultrahand calling libnx's hidInitializeVibrationDevices(). But did vibration trigger a button press in the OS or the controller itself?

Capturing USB traffic

Joy-Cons communicate by sending packets over wired UART or wireless Bluetooth. Pro Controllers tunnel the same protocol over USB or Bluetooth. I don't know how to wiretap Bluetooth, and decided that USB tracing was more practical (even if still tricky).

Photo of a Nintendo Switch dock with a USB passthrough board daisy-chained to a controller cable. The USB board has a Pi Pico glued on, reading data off the data lines and sending it to a computer. The Pico's SWD debug pins are wired to another Pi Pico acting as a debug probe.
I used one Pi Pico to capture the Switch controller's USB packets. It would sometimes hang and stop logging packets, so I attached a second Pi Pico to debug the first.

I did run into some problems. pico_usb_sniffer has a known bug where sometimes "the sniffer itself (Raspberry Pi Pico) is not recognized by a PC. If the serial port of the Pico does not appear, plug the Pico into your PC again." I attempted forking and debugging the sniffer firmware, but could not reliably reproduce and diagnose the bug, and lost interest when I finished debugging the controller. It may have been caused by a missing multicore_reset_core1() (which I added in my fork), outdated Pico SDK, or debug/release differences.

Similarly I found that passing -i to filter out packets early (reducing the size of .pcap files) would often hang the USB stream. I tried debugging the Pico firmware but ran into mysterious crashes, until I updated my debugger to openocd[-raspberrypi]-git (the release shipped by Arch Linux is too old and doesn't support dual-core mode). So it seems the debugging crashes were caused by my debugger, and I still have no clue what caused the -i hangs (I finished the project before hitting the issue again).

Parsing USB traffic

In Wireshark, I saw the controller transmit data at around 120-125 Hz (~2 replies per frame). But the console actually polled at a much higher USB poll rate (my PC reports the IN endpoint has bInterval=4 or 250 Hz), while the controller NAKs half of polls non-uniformly, leading to heavy response time jitter.

While learning to read the USBLL packets in Wireshark and pyshark, I came across several websites for the USB protocol2. These websites helped explain the types (PID, Packet ID) of packet, ranging from SOF (background radiation) to IN/OUT (attempted transmissions), ACK/NAK (responses), and DATA0/1 (alternating on each endpoint).

Afterwards I started looking for documentation specific to the Switch Pro controller protocol3. One "project" that saddens me is the vibe-coded steam-pro-controller, and its author suggesting to "point Claude at it". Programming is about building skills, while the usual AI usage patterns build dependence and erode your ability to write code independently.

At this point I started writing a program to parse the communications.

I built a TUI using Python and Textual to show OUT and IN packets in two panels side by side. I used arrow keys to scroll through packets in the capture, and the TUI showed the most recent data packet from both the console and controller, while highlighting the current packet in a green outline.

Screenshot of a TUI app, Pro Controller USB Viewer. To the left is OUT packet 78, where the console sends command hex 01 (Rumble + Subcommand) with subcommand 48 (ENABLE_VIBRATION) set to False. To the right is IN packet 80, where the controller replies with no buttons pressed, both sticks off-center, and acknowledges the previous subcommand 48 (ENABLE_VIBRATION).
After writing a USB packet capture analyzer app, I found that 8BitDo Pro Controllers send incorrect button/stick states when acknowledging subcommands.

I've published my packet viewing tool at Codeberg (nyanpasu64/procon-usb-viewer). Note that it does not work on PowerA Switch controllers, which use Report 0x00 with an abbreviated packet format.

Analyzing controller traffic

Loading the .pcap files, I found that when the controller sends a DATA0/1 replying to the host's IN poll, it normally contains report 0x30 with buttons, stick positions, and 3 sixaxis motion entries. The Switch sends commands to the controller using OUT + DATA0/1 packets.

When the console sends command 0x01 followed by a subcommand (e.g. enable or disable rumble), it expects the controller to reply with report 0x21 with buttons and stick positions, but replacing motion data with the same subcommand ID and response bytes.

The bug was that when the controller replies with report 0x21, it fails to include button state! Instead it outputs a fixed byte buffer with no buttons pressed, a hard-coded battery level, and both sticks off-center.

I found that when I entered or exited Ultrahand overlays (which call hidInitializeVibrationDevices upon entry), the Switch would sometimes send command 0x01 to enable/disable rumble. This caused the controller to reply with report 0x21 (dropping all button presses), then return to report 0x30 with the usual buttons pressed.

After completing the analysis, I reported the bug to 8BitDo using their support form and email, giving details of the controller protocol and screenshots of the erroneous packets. Over a month later they haven't gotten back to me. To this day the controller double-clicks when switching between Ultrahand menus.

Fixing the jamming sticks

Homebrew aside, after several months of using the controller, I noticed the analog sticks would occasionally get jammed at neutral, and require excessive force to unstick with a click. I'm not sure if this was caused by its factory condition or a previous botched greasing attempt.

It took several attempts of adding and removing grease before I got it into a consistently smooth state, and I was unsuccessful in improving the analog stick's feel until I desoldered and disassembled the module. Desoldering the stickbox was an involved process:

Line art diagram of a cross section through a Xbox-style game controller analog stick. There's a spring between the bottom of the joystick and a plastic plunger pressed into the white plastic base. To the right there's the metal wall of the controller, and the base has a plastic hook holding the metal in place. If the metal moves outwards (as indicated by a rightwards arrow) the hook breaks off. I've drawn potential cut lines through the hook, either horizontally (to remove the hook only) or flush with the metal (removing everything that sticks out).
Diagram of how the stickbox plastic breaks.
My god GIMP's path tool is a joke for diagramming.

If you used an appropriate grease, and knew how much to add (e.g. with a blunt-needle syringe), it may be possible to resolve sticky control sticks without desoldering and disassembling to clean the contact surfaces directly. I don't know how to measure or remove grease without disassembly; perhaps fill the assembly with isopropyl alcohol, move the stick in circles, and watch solvent and dissolved oils leak over the PCB?

Sidenote: The GC/Wii's T3 stickbox was a blessing the industry will never make the mistake of repeating. The plastic stickbox can be removed by unscrewing it from the PCB with zero desoldering needed, and can be disassembled by popping its plastic base out. In Smash Bros spaces, this analog stick design has a reputation for being more robust than previous metal designs. Moreover GC sticks are known to produce minimal sensor inputs when the stick wobbles (due to wear), since the stick wobbles inside the spring-loaded sensor arms, whereas in modern Xbox-style sticks the spring-loaded stick wobbles inside freely moving sensor arms.

Footnotes

1

There are a number of guides to writing udev rules. I haven't researched them in depth, but here are some links I've collected:

3

Switch Pro controller protocol resources: