Skip to content

Latest commit

 

History

History
544 lines (422 loc) · 22.2 KB

File metadata and controls

544 lines (422 loc) · 22.2 KB

SCUF Envision Pro — protocol notes

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.


0. MEASURED FROM HARDWARE (2026-08-28, milestone 2)

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.

0.1 Identity — not what the brief says

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.

0.2 The dongle mode-switches

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.

0.3 Composite mode — 2E95:434E, 419 bytes, 5 interfaces

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.

0.4 HID-only mode — 2E95:5046, 41 bytes

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

0.5 The HID interfaces are vendor-defined, not a standard gamepad

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.

0.6 Two firmware fixes this required

Both are non-obvious and worth remembering:

  1. tusb_time_millis_api() is only implemented in TinyUSB's BSP (hw/bsp/board.c), which this project does not link. Without it the weak tusb_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 in src/usb_host.c.
  2. CFG_TUH_ENUMERATION_BUFSIZE must be ≥ 419. The default 256 makes TinyUSB fail TU_ASSERT(total_len <= CFG_TUH_ENUMERATION_BUFSIZE) in process_enumeration, again silently. Set to 1024.

0.65 Resolved in play testing

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.

0.66 The idle failure, root-caused (2026-09-02)

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.

0.7 Open questions, revised

  1. What triggers the mode switch, and which mode should we drive?
  2. What do the 63-byte vendor reports contain? Which report ID carries buttons, sticks, triggers? → milestone 3, with T on the console to trace raw bytes.
  3. 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 stray 11 01 / 11 02 / 11 04 reports were: media keys, for the headset.
  4. Is an init/handshake needed on the OUT endpoint before reports become meaningful? The vendor OUT reports make this plausible again, despite §2.
  5. Keepalive: resolved - none needed. See 0.65.

0A. REPORT 0x06 DECODED (milestone 3, measured 2026-08-28)

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

Sticks

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.

The trigger / hat word — bytes 9..11 are NOT byte-aligned

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.

Hat (bits 20–23)

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.

byte 12 — face, shoulders, menu

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.

byte 13 — sticks, home, G-keys

Bit Button
0 L3
1 R3
2 Home / Guide
3 G1
4 G2
5 G3
6 G4
7 G5

bytes 14–15 — the six rear buttons

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:

  1. 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.
  2. 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.c now assumes.

The G-keys never do this - they only ever emit their own bit.

Still unknown

  • 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 0x11 with a single byte (01, 02, 04 seen during G-key presses). Possibly profile state.

1. Provenance — the brief's source repo is gone

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.


2. The finding that matters most

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.


3. Device identity

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

4. USB interface layout

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.


5. HID report 0x06 — the right trigger

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.


6. Axis map — with an unresolved conflict

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 ⚠️ "noisy/unused", explicitly ignored ⚠️ Right trigger
ABS_HAT0X/Y Hat switch D-pad D-pad

⚠️ Conflict — 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.


7. Button map — derived, with an unresolved conflict

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 ⚠️ Y ⚠️ X
3 4 BTN_NORTH ⚠️ X ⚠️ Y
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 —

⚠️ Conflict — X and Y are swapped between the two sources. Trivial to settle on the bench at milestone 3: press X, read the bit.

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.


8. Rumble

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.


9. Open questions for the bench

Ordered by how much they block progress.

  1. 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.
  2. Is 0x3A08 even the right PID? Unverified. → milestone 2 descriptor dump.
  3. How many interfaces, and which carries the gamepad? → milestone 2.
  4. 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.
  5. ABS_RY/Ry — right trigger or noise? → milestone 3.
  6. X/Y bit order. → milestone 3, one button press.
  7. Where do the paddles and G-keys appear? Probably the unaccounted bits 11/13/14. → milestone 3.
  8. Is report 0x06 present on the dongle, same 12-bit layout at bytes 10–11? → milestone 3.

10. Revision

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.