Skip to content

Repository files navigation

Scuf2Switch

RP2040 firmware that lets a SCUF Envision Pro play on a Nintendo Switch, by bridging the controller's Corsair Slipstream wireless dongle to a Switch-compatible USB gamepad.

The Switch only accepts a small set of wired USB controllers. This adapter hosts the dongle on one USB port, decodes its vendor-specific protocol, and presents itself on the other port as a HORIPAD — a profile the console already trusts.

Status: working. Tested on a real Switch through a dock; gameplay is smooth with no dropouts.

 SCUF Envision Pro
        │  (Slipstream 2.4 GHz)
        ▼
 Slipstream dongle ──USB──► RP2040 ──USB──► Nintendo Switch dock
                        PIO-USB host    native USB device
                          (core 1)         (core 0)

Why this was harder than it looks

The dongle does not speak a documented protocol, and the public information about it is wrong in several specific ways. Everything in docs/protocol.md was measured on hardware. Highlights:

  • The dongle is 2E95:434E, not Corsair's 1B1C:3A08. Every Linux driver in the wild targets 1B1C:3A05, which is the wired controller — a different device with a different protocol.
  • It changes USB identity depending on whether the controller is awake. Awake, it is a 5-interface composite device with headset audio; asleep, it re-enumerates as a single HID interface with a different PID. The adapter has to survive a full unmount/remount as a routine event.
  • The gamepad data is vendor-defined HID, not a standard gamepad descriptor, and lives on interfaces 3 and 4 — not interface 0.
  • The triggers and D-pad share a packed 24-bit field that is not byte-aligned. This is why an existing Linux driver reports the right trigger as "12-bit, 0–4092": it is really 10-bit 0–1023, read two bits too low, with the left trigger's high bits underneath. 4092 = 1023 × 4.

Hardware

Board Waveshare RP2040-Zero, RP2040, 2 MB flash
System clock 120 MHz — not the 125 MHz default; Pico-PIO-USB requires it
Dongle port D+ = GP9, D− = GP10, 22 Ω series on each, VBUS from the 5 V pad
Status LED WS2812 on GP16, driven by PIO1
Debug UART UART0, GP0 = TX, GP1 = RX, 115200 8N1

Every board-specific number lives in include/board_config.h — nothing else hardcodes a GPIO. PIO0 is left free for Pico-PIO-USB; the LED uses PIO1.

Wiring the dongle port

Salvage a USB-A socket (a cut-up extension cable works). Only four wires matter:

USB wire Signal To
Red VBUS 5 V pad
White D− GP10
Green D+ GP9
Black GND GND

Put 22 Ω in series on D+ and D− only. D+ must be the lower numbered pin — Pico-PIO-USB's default PIO_USB_PINOUT_DPDM means D− = D+ + 1. If you wire it the other way round, PIO_USB_PINOUT_DMDP flips it in one line.

Do not add the 1.5 kΩ pull-up mentioned in the Pico-PIO-USB docs; that is for its device mode. As a host we must not pull D+ up — the dongle does that itself, and detecting it is how the firmware knows something is attached.

Status LED

Readable at a glance with no serial adapter attached:

Colour Meaning
red → green → blue boot self-test
amber, breathing powered, not enumerated by the host
cyan host enumerated us, but no dongle/controller
green both links up, inputs flowing
dim blue host suspended us
white flash a byte arrived on the debug UART

Building

Requires the pico-sdk 2.3.0, an arm-none-eabi GCC 10+, CMake 3.13+, and Ninja.

git clone --recurse-submodules <this repo>
cd Scuf2Switch

The Pico-PIO-USB submodule is required — CMake fails with a clear message if it is missing.

Linux / macOS

export PICO_SDK_PATH=~/pico/pico-sdk
cmake -S . -B build -G Ninja
cmake --build build

Windowstools/env.ps1 sets up one shell without touching your system PATH. It defaults to ~/pico/pico-sdk and will borrow STM32CubeIDE's bundled ARM toolchain if you happen to have it; override PICO_SDK_PATH / PICO_TOOLCHAIN_PATH to use your own.

. .\tools\env.ps1
cmake -S . -B build -G Ninja
cmake --build build

On Windows you may also want prebuilt pioasm and picotool from pico-sdk-tools, since the SDK otherwise builds them from source and needs a native C++ compiler.

Options

cmake -S . -B build -G Ninja -DLOG_LEVEL=4
Option Default Meaning
LOG_LEVEL 3 0=silent 1=err 2=warn 3=info 4=debug. Higher levels compile away entirely.
M1_DEMO_MOTION 0 Synthetic stick circle when no dongle is attached, for testing the device side alone.
PICO_BOARD waveshare_rp2040_zero

Flashing

No BOOT button needed. The firmware runs a console on the debug UART; sending it B calls reset_usb_boot():

.\tools\flash.ps1 -Port COM11 -Build

That matters because the Switch descriptor set is a byte-exact copy of GP2040-CE's and deliberately has no picotool reset interface — without the console trick, every reflash would need physical access to the board.

There is also a USB fallback for when the UART is unavailable: the HID OUT endpoint accepts A5 5A C3 3C <cmd>, where 01 reboots to BOOTSEL. Writing nine bytes to /dev/hidrawN on Linux is enough. This exists because it was genuinely needed — the board had to be recovered while its serial link was still broken.

Otherwise: hold BOOT, tap RESET, drag build/scuf2switch.uf2 onto RPI-RP2.

tools/remote.sh builds and flashes over SSH on a Linux box; set export REMOTE=you@yourbox first.


Using it on a Switch

  1. System Settings → Controllers and Sensors → Pro Controller Wired Communication → ON. Without this the console ignores wired USB pads.
  2. Plug the adapter into a USB-A port on the dock — not the console's USB-C.
  3. Wake the controller. The LED goes green when both links are up.

Debug console

115200 8N1 on the UART pins. Single keystrokes:

Key Action
B reboot into BOOTSEL for reflashing
R reboot
S toggle decoded controller state + translated report, 10 Hz
T toggle raw host report trace (changed bytes only)
C / D start / dump an on-device capture of changed reports
? status and help

The capture buffer exists because reports arrive at ~1 kHz — far faster than 115200 baud can stream — and because it decouples exercising the controller from any live capture window. It records only reports that changed, rate-limiting analog fields while never dropping a button edge.


Changing the button mapping

src/mapping.c is the only file you need. One table, one translate function, no configuration system.

Mapping is by physical position, not label. The SCUF uses the Xbox diamond, so its bottom face button ("A") maps to the Switch's bottom face button ("B"):

   SCUF (Xbox layout)        Switch
          Y                     X
       X     B      ->       Y     A
          A                     B

Notes on the defaults:

  • Analog triggers become digital ZL/ZR at 307 of 1023 (~30 %).
  • Minus and Plus are on G1 and G5, not View and Menu. View and Menu sit close enough to the D-pad and face buttons to be pressed by accident mid-game, so they are deliberately mapped to nothing.
  • The six rear buttons are unmapped, though they are fully decoded and assigning one is a one-line change.

If you do map a rear button, unassign it in the controller's profiles first. A bound rear button emits its own bit and whatever it is bound to, so it would fire two inputs at once. The bindings are per profile and the controller has three — writing to only the profile you think is active looks like it did nothing. The G-keys never behave this way; they only ever emit their own bit, which is why Minus and Plus live there.


Layout

CMakeLists.txt            build, options, Pico-PIO-USB wiring
include/board_config.h    every board-specific pin and constant
include/scuf_report.h     SCUF report layout and decoded state
include/switch_report.h   Switch report format
include/tusb_config.h     TinyUSB device + PIO-USB host config
src/main.c                core 0: device stack, console, LED
src/usb_host.c            core 1: PIO-USB host, descriptor dumps, capture
src/scuf_parse.c          report 0x06 -> controller state
src/mapping.c             controller state -> Switch report   <- edit this
src/usb_descriptors.c     Switch descriptors, verbatim from GP2040-CE
src/ws2812.pio            WS2812 driver (pico-examples)
docs/protocol.md          everything measured about the dongle
tools/                    build environment and flashing helpers

Two traps worth knowing about

Both cost real debugging time and both fail silently:

  1. tusb_time_millis_api() is only implemented in TinyUSB's BSP (hw/bsp/board.c). Use TinyUSB host without linking that BSP and the weak tusb_time_delay_ms_api() spins on a clock that never advances — enumeration stalls immediately after "Device Attach" with no error at all. This project implements both in src/usb_host.c.

  2. CFG_TUH_ENUMERATION_BUFSIZE defaults to 256, and this dongle's configuration descriptor is 419 bytes. TinyUSB trips an internal assert in process_enumeration with no visible diagnosis. Set it to 1024.

  3. Pico-PIO-USB can wedge its root port permanently. On detecting a device it sets connected = true and suspended = true, expecting the host to issue a bus reset that clears suspended. If enumeration never completes, the port is left in a state where neither branch of its 1 ms task can run:

    disconnect check needs  connected && !suspended   // blocked by suspended
    reconnect  check needs  !connected                // blocked by connected

    The port then never notices the device leave and never re-detects it arriving — the device is dead until reboot. We watched a dongle sit attached and ignored for three minutes with its pull-up asserted the whole time. src/usb_host.c recovers by clearing the flags itself after a few seconds of no device and no data.

The pico-sdk also forces -DCFG_TUSB_DEBUG=0, so an #ifndef guard in tusb_config.h never fires — you have to #undef it to get any TinyUSB tracing at all.

Do not gate your output on a mount flag. Gate it on whether reports are actually arriving. A mode-switching device can leave mount bookkeeping disagreeing with reality — we caught the adapter reporting mounted=0 while reports streamed in at ~1 kHz, which silently sent a neutral report to the console while the controller was in use.


Third-party code

Component Licence Use
GP2040-CE MIT, © 2021 Jason Skuby Switch USB descriptors, copied byte-for-byte
Pico-PIO-USB MIT USB host on PIO (submodule)
pico-examples BSD-3-Clause WS2812 PIO program
pico-sdk / TinyUSB BSD-3-Clause / MIT

The descriptor arrays in src/usb_descriptors.c are byte-identical to GP2040-CE's, verified by diffing the parsed arrays rather than by eye. Please don't tidy them.

This project is licensed under the MIT Licence — see LICENSE.

Not affiliated with SCUF, Corsair, HORI or Nintendo.

About

RP2040 firmware bridging a SCUF Envision Pro's Slipstream dongle to a Nintendo Switch. Includes the reverse-engineered dongle protocol.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages