What the adapter's host side needs to know about the controller. Written before the parser, per the project brief.
Read the status column before trusting anything here. Much of this is derived from third-party Linux drivers rather than measured, and several sources contradict each other. Milestones 2–3 replace the guesses with capture from our own hardware.
Our own PIO-USB host enumerated the dongle. This supersedes everything derived from third-party drivers below. Several of the brief's stated facts turn out to be wrong.
| Brief said | Actually measured | |
|---|---|---|
| VID | 0x1B1C (Corsair) |
0x2E95 |
| PID | 0x3A08 |
0x434E, then 0x5046 after a mode switch |
| Manufacturer string | — | Scuf Gaming |
| Product string | — | SCUF PC Controller Dongle |
| bcdUSB | — | 2.00, bMaxPacketSize0 = 8 |
0x1B1C:0x3A08 never appeared. The Linux drivers in §1 all target the wired
controller 1B1C:3A05, which is a different device entirely.
Observed once during bring-up: it enumerates as a composite audio + HID
device (2E95:434E, 419-byte config, 5 interfaces), then detaches and comes
back as a HID-only device (2E95:5046, 41-byte config, 1 interface).
The adapter must tolerate this: a tuh_umount_cb shortly after mount is
normal, not a fault. Which mode it settles in, and what triggers the switch,
is not yet established — it did not switch on every boot.
IFACE 0 class 0x01/0x01 Audio Control
IFACE 1 alt 0/1/2 class 0x01/0x02 Audio Streaming IN EP 0x83 iso, 98 / 147 B
IFACE 2 alt 0/1/2 class 0x01/0x02 Audio Streaming OUT EP 0x03 iso, 196 / 294 B
IFACE 3 class 0x03 HID report desc 156 B
EP 0x81 IN interrupt 64 B bInterval 1
EP 0x01 OUT interrupt 64 B bInterval 1
IFACE 4 class 0x03 HID report desc 99 B
EP 0x82 IN interrupt 64 B bInterval 1
EP 0x02 OUT interrupt 64 B bInterval 1
Interfaces 0–2 are the Envision's headset audio — irrelevant to us, and a
good reason to leave CFG_TUH_ audio support off so TinyUSB ignores them.
The brief's "buttons on interface 0, sticks on interface 3" does not match: interface 0 is audio, and the gamepad data is on interfaces 3 and 4.
IFACE 0 class 0x03 HID report desc 100 B
EP 0x81 IN interrupt 64 B bInterval 1
EP 0x01 OUT interrupt 64 B bInterval 1
Start of the 100-byte report descriptor from HID-only mode:
06 58 FF Usage Page (Vendor 0xFF58)
09 01 Usage (0x01)
A1 01 Collection (Application)
85 58 Report ID 0x58
15 00 26 FF 00 Logical Min 0, Max 255
95 3F 75 08 Report Count 63, Report Size 8 <- 63-byte payload
81 02 Input (Data,Var,Abs)
85 58 ... 95 3F 75 08 91 02 Report ID 0x58, 63-byte Output
C0 End Collection
06 42 FF Usage Page (Vendor 0xFF42)
09 01 Usage (0x01)
A1 01 Collection (Application)
85 01 Report ID 0x01, 63 bytes In
85 02 Report ID 0x02, 63 bytes Out
85 0C Report ID 0x0C ...
So: two vendor usage pages (0xFF58, 0xFF42), report IDs 0x58, 0x01,
0x02, 0x0C, all 63-byte payloads. There is no Generic Desktop gamepad
collection, no X/Y/Z/Rx/Ry/Rz, no Button usage page.
This kills the §2 hypothesis that the dongle is a plain HID gamepad the kernel parses generically. It is a vendor protocol, and the §6/§7 axis and button maps — derived from the wired controller's evdev view — almost certainly do not apply. Treat them as a starting hypothesis only.
Reports are flowing: 16-byte transfers observed on EP 0x81. Decoding them
is milestone 3.
Both are non-obvious and worth remembering:
tusb_time_millis_api()is only implemented in TinyUSB's BSP (hw/bsp/board.c), which this project does not link. Without it the weaktusb_time_delay_ms_api()spins on a symbol that never advances and host enumeration stalls immediately after "Device Attach" with no error. We implement both insrc/usb_host.c.CFG_TUH_ENUMERATION_BUFSIZEmust be ≥ 419. The default 256 makes TinyUSB failTU_ASSERT(total_len <= CFG_TUH_ENUMERATION_BUFSIZE)inprocess_enumeration, again silently. Set to 1024.
Confirmed on a real Nintendo Switch through a dock, several minutes of gameplay, no interruptions:
- No keepalive is needed. The brief claimed a packet roughly every 20 s was required to hold the wireless link. Nothing in any driver implemented one, and sustained play with no keepalive shows no dropouts. Treat that claim as wrong unless a long idle later proves otherwise.
- The mode switch is the controller sleeping.
2E95:434E(composite, with headset audio) means the controller is awake and connected;2E95:5046(HID only) means it is asleep or absent. The switch is a full USB unmount and re-enumeration, and it is routine, not an error. The firmware clears its decoded state on unmount so a button held as the controller sleeps cannot latch on. - Report rate is ample. ~1 kHz in from the dongle, 250 Hz out to the Switch, well past the brief's 125 Hz requirement.
Symptom: after sitting idle the controller stops working until the adapter is rebooted. Two independent faults, which partly masked each other.
1. Pico-PIO-USB wedges its root port. On connect it sets
connected = true and suspended = true, expecting a bus reset to clear
suspended. If enumeration aborts - which happens here, because the
configuration descriptor occasionally arrives corrupted - the port is stuck:
the library's disconnect check requires connected && !suspended, and its
reconnect check requires !connected, so neither can ever run again.
Confirmed by observing a dongle sit attached, pull-up asserted, ignored for
three minutes, then enumerate instantly on reboot. Recovered in
src/usb_host.c by clearing the flags after a few seconds of no device and
no data.
2. Gating on the mount flag. The adapter decided whether to forward
controller state based on tuh_mount_cb/tuh_umount_cb bookkeeping. A
mode-switch can leave that inconsistent - observed as mounted=0 while
reports arrived at ~1 kHz - so a perfectly healthy controller was translated
into a neutral report. Now gated on report freshness instead, which cannot
disagree with reality.
These masked each other: the separate floating-UART-RX bug was rebooting the board on line noise, which happened to clear fault 1. Fixing the RX pull-up removed the accidental cure and exposed the real fault.
- What triggers the mode switch, and which mode should we drive?
- What do the 63-byte vendor reports contain? Which report ID carries buttons,
sticks, triggers? → milestone 3, with
Ton the console to trace raw bytes. - Interfaces 3 and 4: resolved. Interface 3 is the gamepad (report 0x06).
Interface 4 is Consumer Control - its report descriptor begins
05 0C 09 01 A1 01 85 11 ... 09 E9 09 EA, i.e. Usage Page (Consumer), Report ID 0x11, Volume Increment / Decrement. That is what the stray11 01/11 02/11 04reports were: media keys, for the headset. - Is an init/handshake needed on the OUT endpoint before reports become meaningful? The vendor OUT reports make this plausible again, despite §2.
- Keepalive: resolved - none needed. See 0.65.
Interface 3 / HID instance 0, 16-byte input report, ~1 kHz. This is the one that carries controller state. Decoded by pressing known inputs in a known order and correlating; every field below is measured, not inferred.
byte 0 report ID, always 0x06
byte 1..2 left stick X int16 LE
byte 3..4 left stick Y int16 LE
byte 5..6 right stick X int16 LE
byte 7..8 right stick Y int16 LE
byte 9..11 packed 24-bit LE word - see below
byte 12 buttons A
byte 13 buttons B
byte 14 rear buttons
byte 15 bit0 = S2
int16 little-endian per axis, centred at 0. Up and left are negative.
| Measured | |
|---|---|
| Range | full int16: -32768 … +32767, all four axes |
| At rest | exactly 0 on all four axes - no drift at all |
| Gate | round, not square |
The controller does its own centring and deadzone, so the adapter needs
neither. (Contrast the wired controller, where the Linux driver needed
STICK_DEADZONE = 800 to clear a ~420 count resting offset. Not needed here.)
Gate shape, from a slow rim lap: peak magnitude is ~34,600 on the cardinals and ~37,400 on the diagonals, a ratio of 1.08. A square gate would give sqrt(2) = 1.41, a perfect circle 1.00. So each axis saturates at +/-32767 alone, but the physical gate limits diagonal travel to about +/-26,400 per axis.
Scale each axis independently and linearly; do not radially renormalise. A Switch Pro Controller has a round gate too, so a diagonal reaching ~0.81 of full scale per axis is what the console expects.
Read bytes 9,10,11 as one 24-bit little-endian word, then:
| Bits | Field | Range |
|---|---|---|
| 0–9 | L2 | 0–1023 |
| 10–19 | R2 | 0–1023 |
| 20–23 | D-pad hat | see below |
uint32_t w = r[9] | (r[10] << 8) | (r[11] << 16);
uint16_t l2 = w & 0x3FF; /* 0..1023 */
uint16_t r2 = (w >> 10) & 0x3FF; /* 0..1023 */
uint8_t hat = (w >> 20) & 0x0F;Verified by sweeping each trigger alone: L2 rose 1→1023 monotonically with R2 pinned at 0, and R2 rose 2→1023 with L2 pinned at 0, zero inversions in either. At rest L2 = 0 and R2 ≤ 3.
This corrects §5. The Linux driver's "12-bit R2 at bytes 10–11, max 4092"
is that same field read two bits too low: its <H at offset 10 masked 0x0FFF
spans bits 8–19, i.e. R2 << 2 with L2's top two bits in the bottom. It only
works because L2 reads 0 whenever R2 is pressed. 4092 = 1023 × 4, its
deadzone 48 is really 12, and R2_RAW_FULL 4080 is 1020. Their "byte 11 high
nibble is a constant flag" is the D-pad hat, constant only because they never
pressed the D-pad.
The ~30 % ZL/ZR threshold the brief asks for is therefore 307 of 1023.
0 = Up, 2 = Right, 4 = Down, 6 = Left, 8 = centre — the standard
8-way encoding, and byte-identical to the Switch's own HAT values, so it
passes through untranslated. Diagonals (1,3,5,7) not yet observed but implied.
| Bit | Button |
|---|---|
| 0 | A |
| 1 | B |
| 2 | X |
| 3 | Y |
| 4 | L1 |
| 5 | R1 |
| 6 | View / Select |
| 7 | Menu / Start |
Physical labels as printed on the controller. This settles the §7 conflict:
ChaseDRedmon was right (bit2 = X, bit3 = Y); mozoii had X and Y swapped.
| Bit | Button |
|---|---|
| 0 | L3 |
| 1 | R3 |
| 2 | Home / Guide |
| 3 | G1 |
| 4 | G2 |
| 5 | G3 |
| 6 | G4 |
| 7 | G5 |
Physical order across the back is S1, P4, P3, P2, P1, S2. Note P1–P4 run downward in bit order — do not "tidy" this into ascending order.
| Bit | Button |
|---|---|
| byte14 bit7 | S1 |
| byte14 bit6 | P4 |
| byte14 bit5 | P3 |
| byte14 bit4 | P2 |
| byte14 bit3 | P1 |
| byte15 bit0 | S2 |
The rear buttons' own bits are independent of the profile. They report here whether or not the button is bound to anything, and the bit does not change when the binding changes. That makes them safe to map.
But a bound rear button ALSO emits its binding, so it fires twice. Measured across three profile states on the same hardware:
| Button | Shipped default | After a partial edit | After clearing all 3 profiles |
|---|---|---|---|
| S1 | + A | + X | (nothing) |
| P4 | + D-pad Left | + A | (nothing) |
| P3 | + D-pad Up | + Left | (nothing) |
| P2 | + D-pad Down | + Right | (nothing) |
| P1 | + D-pad Right | + B | (nothing) |
| S2 | + B | + Y | (nothing) |
Two practical consequences:
- Bindings are per profile, and the controller has three. Editing only the profile you think is active is not enough - the middle column above is what a partial edit looks like, and it is easy to mistake for "it did nothing". Write the change to every profile.
- Unassign the rear buttons before mapping them in firmware, or every
press sends two inputs. With all profiles cleared the rear bits arrive
alone, which is what
src/mapping.cnow assumes.
The G-keys never do this - they only ever emit their own bit.
- byte 14 bits 0–2, byte 15 bits 1–7 — unused so far.
- Diagonal hat values.
- Right stick full range (capture buffer filled first).
- Interface 4 / instance 1: emits report ID
0x11with a single byte (01,02,04seen during G-key presses). Possibly profile state.
The brief names github.com/Tealdragon204/scuf-envision-pro-V2-Linux as the
protocol source of truth. That repository no longer exists (404 — deleted
or renamed). Substitutes, found by search and all cloned 2026-08-28:
| Repo | Language | Notes |
|---|---|---|
mozoii/scuf-envision-pro-linux |
Python | Most complete. Credits "Julian Cotto" for the original bridge — probably the lineage of the missing repo. |
Gicotto/cacique-envision-pro-linux |
Python | Julian Cotto's own; earlier/shorter version of the same file. |
ChaseDRedmon/Scufpad |
C# | Independent reimplementation. Disagrees with the above — see §6. |
thesimpleinterface/scuf-desktop |
Python | Desktop/keyboard utility. No gamepad protocol content. |
sirmodok/scuf-envision |
Rust | Remapper. No low-level protocol content. |
Every one of these targets PID 0x3A05 — the wired controller. None of
them touch the 0x3A08 wireless dongle, which is what this project uses.
None of these drivers speak a vendor-specific protocol to the controller.
They are all evdev/hidraw consumers sitting on top of Linux's generic HID
driver. Grepping every repo for device writes, feature reports, keepalives or
handshakes returns nothing — the only writes are to uinput, i.e. to the
virtual pad they publish, never to the controller.
This directly contradicts three of the brief's stated "known facts":
| Brief claims | Evidence found |
|---|---|
Vendor reports on interface 0, data[2]==0x02 buttons, 0x0A triggers |
None. No such parsing exists in any surviving source. |
| Keepalive packet roughly every 20 s | None. No timer, no periodic write, anywhere. |
| Init/handshake writes before reports flow | None. Drivers just open and read. |
Most likely the controller enumerates as a standard HID gamepad and the kernel's generic driver handles it, which is why no driver needs a protocol. If that also holds for the dongle, our host side gets much simpler: TinyUSB's HID host class can drive it directly, with no custom driver.
Do not act on that yet. A wireless dongle plausibly does need a keepalive to hold the RF link even when the wired controller does not, and no source covers the dongle. Milestone 2 settles it.
| Field | Value | Status |
|---|---|---|
| Vendor (Corsair) | 0x1B1C |
Confirmed — consistent across all repos |
| Wired controller | 0x3A05 |
Confirmed — every repo targets it |
| Slipstream dongle | 0x3A08 |
Unverified — brief only; no source corroborates |
From device-selection heuristics in mozoii/scuf_virtual_pad.py:
score_hidraw_by_id_name: 1000 if "if03" else 200 if "if04" else 100
detect_hidraw_by_sysfs: 1000 if ":1.3" else 200 if ":1.4" else 0| Interface | Role | Status |
|---|---|---|
| 3 | Gamepad HID — buttons, sticks, D-pad, and report 0x06 |
Likely (strongly preferred by both selectors) |
| 4 | Secondary/fallback, scored lower | Unclear |
| 0 | The brief says buttons/triggers live here | Contradicted — no source uses interface 0 |
The brief's "sticks arrive on a separate interface 3" is partly consistent: interface 3 is indeed the interesting one — but as the main gamepad interface carrying everything, not a stick-only stream.
The one piece of genuine raw-HID parsing in any of these drivers, and the most directly reusable finding:
def parse_report6(report: bytes) -> int | None:
if len(report) < 12 or report[0] != 0x06:
return None
return struct.unpack_from("<H", report, 10)[0] & 0x0FFF| Property | Value |
|---|---|
| Report ID | 0x06 (byte 0) |
| Minimum length | 12 bytes |
| R2 location | bytes 10–11, little-endian uint16 |
| Mask | 0x0FFF — 12 significant bits |
| Byte 11 high nibble | constant flag, not part of the value |
| Rest value | 0 |
| Full-scale | ~4092; driver treats 4080 as full |
| Practical deadzone | 48 |
Status: Confirmed for the wired controller; identical code in two independent repos. Unverified for the dongle.
Note this is a 12-bit trigger. Our Switch report gives ZL/ZR one bit each,
so the brief's ~30 % threshold lands at roughly 1230 of 4095.
These drivers read evdev axes, which are the kernel's translation of HID Generic Desktop usages. Since the kernel maps usage→axis one-for-one, the evdev codes tell us the raw HID usages our own parser will meet.
| evdev axis | HID usage | mozoii (Python) | ChaseDRedmon (C#) |
|---|---|---|---|
ABS_X |
X | Left stick X | Left stick X |
ABS_Y |
Y | Left stick Y | Left stick Y |
ABS_Z |
Z | Right stick X | Right stick X |
ABS_RX |
Rx | L2 (left trigger) | L2 (left trigger) |
ABS_RZ |
Rz | Right stick Y | Right stick Y |
ABS_RY |
Ry | ||
ABS_HAT0X/Y |
Hat switch | D-pad | D-pad |
ABS_RY. mozoii ignores it as noise and instead reads R2
from hidraw report 0x06; ChaseDRedmon uses it directly as the right
trigger. Both cannot be right. Possibilities: a firmware revision changed it,
or ABS_RY carries a low-resolution R2 that mozoii rejected in favour of the
12-bit value. Resolve at milestone 3 by capturing raw reports while pulling
R2 slowly.
Note the layout is unusual and worth not "fixing" by intuition: the right stick is on Z/Rz, and the left trigger is on Rx.
For a Game Pad application collection, Linux maps HID button N to
BTN_SOUTH + (N-1) (hid-input.c). Inverting that gives the raw button index,
and hence the likely bit position in the input report:
| Bit | HID btn | evdev code | Physical (mozoii) | Physical (ChaseDRedmon) |
|---|---|---|---|---|
| 0 | 1 | BTN_SOUTH |
A | A |
| 1 | 2 | BTN_EAST |
B | B |
| 2 | 3 | BTN_C |
||
| 3 | 4 | BTN_NORTH |
||
| 4 | 5 | BTN_WEST |
L1 / LB | L1 / LB |
| 5 | 6 | BTN_Z |
R1 / RB | — |
| 6 | 7 | BTN_TL |
View / Select | — |
| 7 | 8 | BTN_TR |
Menu / Start | — |
| 8 | 9 | BTN_TL2 |
L3 | — |
| 9 | 10 | BTN_TR2 |
R3 | — |
| 10 | 11 | BTN_SELECT |
Centre power → Guide | — |
| 12 | 13 | BTN_MODE |
Mode | — |
Both agree the layout is non-standard — that's the entire reason these projects exist. Bits 11, 13, 14 are unaccounted for; the Envision's rear paddles and G-keys are candidates, which matters because the brief wants them mapped to Home and Capture.
Nothing found. No force-feedback, no output reports, in any repo.
(scuf-desktop's scuf_vibe_map.py is a keyboard-mapping file — "vibe", not
vibration.) The brief's "rumble is a HID motor packet" has no support in any
surviving source. Stretch goal; capture from the vendor software would be
needed, or the dongle's descriptor may declare an output report we can probe.
Ordered by how much they block progress.
- Does the dongle (
0x3A08) enumerate as standard HID? If yes, TinyUSB's HID host class does the job and no custom driver is needed. → milestone 2. - Is
0x3A08even the right PID? Unverified. → milestone 2 descriptor dump. - How many interfaces, and which carries the gamepad? → milestone 2.
- Does the wireless link need a keepalive? No evidence for one, but no source covers the dongle. Test: idle > 60 s, confirm reports still flow.
ABS_RY/Ry — right trigger or noise? → milestone 3.- X/Y bit order. → milestone 3, one button press.
- Where do the paddles and G-keys appear? Probably the unaccounted bits 11/13/14. → milestone 3.
- Is report
0x06present on the dongle, same 12-bit layout at bytes 10–11? → milestone 3.
| Date | Change |
|---|---|
| 2026-09-02 | Root-caused the idle failure: wedged PIO-USB root port plus gating on the mount flag. Both fixed. |
| 2026-09-02 | Rear buttons: own bits confirmed independent of profile bindings; bindings are per profile across all three. Interface 4 identified as Consumer Control. |
| 2026-08-28 | §0.65: confirmed working on a real Switch. No keepalive needed; mode switch is the controller sleeping. |
| 2026-08-28 | §0A added: report 0x06 fully decoded on hardware. Triggers are a packed 24-bit field, which corrects the Linux driver's R2 reading. |
| 2026-08-28 | §0 added: measured from our own PIO-USB host. Identity, interface layout and vendor-HID nature all differ from the brief. |
| 2026-08-28 | First draft. Source repo from the brief found missing; reconstructed from four substitutes. No vendor protocol found in any of them. |