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 %).
  • G-keys carry Capture and Home, not the rear paddles. Each rear button also fires whatever the controller's own profile maps it to, so mapping a paddle would fire two things at once. The G-keys emit only their own bit. Clear the paddle mappings in SCUF's profile editor first if you want to use them.

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.

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.


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