Steam Controller 2 (2026) driver, Steam Deck support, multi-controller UX, and OSD/GUI fixes - #100
Steam Controller 2 (2026) driver, Steam Deck support, multi-controller UX, and OSD/GUI fixes#100Patola wants to merge 145 commits into
Conversation
…ness Reverse-engineered the new Steam Controller's main gamepad HID report (0x42) from live captures of real hardware via its wireless Puck (28de:1304). Documents the full byte layout: 4-byte button bitfield (incl. capacitive stick/pad/grip touch and analog+digital triggers), two analog sticks, two trackpads with pressure, and 16-bit triggers. Notes that the IMU is disabled by default and the controller defaults to lizard mode; both the command channel (lizard-off, gyro-on) and the IMU stream remain to be reverse-engineered. Adds tools/sc2-probe/, the read-only hidraw capture harness used to produce these findings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sniffed Steam's USB traffic while it configured the controller and decoded the host->device command protocol: SET_REPORT (0x21/0x09) with wValue=0x03<id> (feature) / 0x02<id> (output), wIndex=interface (per slot), 64-byte [reportID, packetType, length, params] payloads. Opcodes match sc_dongle.py's SCPacketType: 0x81 CLEAR_MAPPINGS (lizard disable, resent as heartbeat), 0x8E LIZARD_MODE, 0x87 CONFIGURE/LED, 0xAE GET_SERIAL, 0xC1 SET_AUDIO_INDICES, plus v2-only key/value config (0xED "user/wireless_transport", "esb/bond"). LED level confirmed as 87 03 2d <level>. Gyro-enable register still TBD. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New scc/drivers/sc2.py implementing the reverse-engineered v2 protocol: report 0x42 parsing (buttons, two sticks, two pads with pressure, analog + digital triggers, d-pad, grips/paddles, capacitive touch), mapped to SCButtons; the wireless Puck (0x1304) as a 4-slot dongle; and the v2 command transport (SET_REPORT to feature report 0x01 per interface) with CLEAR_MAPPINGS unlizard heartbeat + replayed CONFIGURE/LED blocks. Modeled on steamdeck.py (parsing/mapping) and sc_dongle.py (multi-slot + commands). Gyro enable, haptics, real GET_SERIAL read-back, the wired (0x1302)/Bluetooth (0x1303) transports, GUI assets and live testing are still TODO (marked inline). tests/test_sc2.py locks the 0x42 layout with synthetic frames (no hardware needed); 11 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Live bring-up validated the protocol (lizard-off via CLEAR_MAPPINGS, buttons/sticks/triggers/pads all decode correctly on real hardware), but exposed an integration bug: the puck's interrupt-IN endpoint multiplexes reports of several sizes (0x42=54B, plus shorter 0x43/0x44/0x7b). The shared USBDevice.set_input_interrupt drops and stops resubmitting any report whose length != the requested size, which would freeze input on the first short report. Replace it with a per-driver lenient transfer that requests the full 64-byte max packet, accepts any length, filters by report ID in parse_input, and always resubmits. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
End-to-end bring-up in scc-daemon on real hardware now works: the puck is detected, the controller registers, lizard mode is disabled, and button / stick / pad / trigger input reaches uinput (verified digital -> BTN_* and analog -> ABS_X/Y). Fixes found during bring-up: - enable the driver by default (config.py "drivers": add "sc2": True); it was skipped as a disabled driver. - SET_REPORT length: command builders no longer pre-pad to 64; send_control prepends the 0x01 report-ID byte and clamps to exactly 64 bytes. A 65-byte transfer was stalling the device (LIBUSB_ERROR_PIPE) on the first command. - override disconnected() as a no-op (the inherited SCController version touches a dongle-only _available_serials attribute and crashed on unplug). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Live experiment (rotate controller, toggle gyro): the byte after `87 0f 30` is the gyro/accel enable -- 0x18 on, 0x00 off -- and once enabled the IMU streams in report 0x42 at offsets ~31-53 (bytes 31-53 go from static to 60-256 distinct values when moving). Matches what Steam sends. The driver's configure() already emits 0x18; parse_input still zeroes the gyro fields pending decode of the accel/gyro/quaternion sub-layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Captured isolated rotations with the gyro enabled and decoded report 0x42's IMU block (offsets 30-53): 30-33 timestamp, 34-39 accelerometer (Z holds ~1g at rest), 40-47 orientation quaternion (w~32767 at rest), 48-53 gyro pitch/roll/yaw. Verified each gyro axis dominates only its own motion (pitch->@48, roll->@50, yaw->@52) and accel_z tracks gravity. parse_input now fills accel_x/y/z, gpitch/groll/gyaw and q1..q4 from these offsets instead of zeroing them; configure() already enables the gyro. Accel X/Y labels and IMU signs remain provisional (polarity TBD). Adds IMU assertions to the parser test (12 tests pass). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The controller has four system buttons, not three: the View button (⧉, top-left) was untested and unmapped. Found at off3 bit 0x40 (it also emits a lizard keyboard report). Mapped View -> BACK, and moved QuickAccess (…) from BACK to DOTS so the four map cleanly to C / START / BACK / DOTS (Steam / Menu / View / QuickAccess). off3 is now fully mapped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Loaded a gyro->mouse profile in scc-daemon and checked cursor direction: yaw is natural (right->right) but pitch was inverted (up->down). Negated gpitch in parse_input so pitch-up aims up; re-verified live (up->up, right->right). Gyro roll sign remains untested/provisional. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Feedback:" handler did position.strip(" \t\r") on a bytes object
(TypeError) and the error path wrote b"Fail: %s\n" % (e,) (bytes %% str),
so the command always failed and dropped the connection. Decode the
position to ASCII before getattr(HapticPos, ...), and build the Fail
message with str(e).encode(). Affects all controllers, not just v2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Captured Steam's trackpad haptic feedback and decoded the rumble command: output report 0x82 = [0x82, side, effect, amplitude] on the interrupt-OUT endpoint (number == interface). side 0/1/2 = left/right/both, effect 0x01 = click (0x02 longer), amplitude 0x00(medium)..0xff(strong). The device stalls this report over SET_REPORT control, so feedback() submits an interrupt-OUT transfer instead. Verified live via the daemon's Feedback command: right/left/both clicks land on the correct side. It's a per-call click (fits pad/scroll detents); continuous variable rumble, if supported, would use a yet-uncaptured report. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The cabled controller enumerates as a single HID interface 0 (interrupt IN 0x81 / OUT 0x01, no CDC) with the same report descriptor and 0x42 report as the puck, so everything reuses. Refactor the USB device into a shared SC2Device base (lenient interrupt-IN, SET_REPORT/feature-0x01 commands, interrupt-OUT haptics, controller bookkeeping) with SC2Puck (4 slots) and SC2Wired (interface 0) subclasses, and give SC2Controller an explicit out-endpoint (puck OUT ep == interface; wired OUT ep == 1). Register 0x1302. Verified live over USB-C: detection, registration, buttons/sticks input, and L/R/both haptics all work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
get_gui_config_file() now returns "sc2.config.json" so the GUI renders the controller with its real buttons/axes/gyro. The v2's controls match the Steam Deck, so the config mirrors deck.config.json and reuses the "deck" background image for now (a dedicated controller-images/sc2.svg is TODO). Verified the daemon advertises it: "Controller: <id> sc2 19 sc2.config.json". The core SC/Deck drivers have no GUI enable/disable toggle (always on), so sc2 follows suit -- no global_settings change needed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mapper assumed HAS_RSTICK / d-pad controllers were always IS_DECK, which broke the new Steam Controller (HAS_RSTICK | HAS_DPAD, not IS_DECK): - MouseAction.whole force-treated the right *pad* (what == RIGHT) as a stick (velocity-from-position) whenever HAS_RSTICK was set, so mouse() on the right pad became a joystick. The right *stick* already arrives as RSTICK, so drop the RIGHT clause -> the right pad is a relative trackball again. (Verified live on the v2; also restores trackball behaviour for the Deck's right pad.) - Gate right-stick processing on HAS_RSTICK and the d-pad on HAS_DPAD instead of IS_DECK, so controllers with those flags but without IS_DECK get their right stick and d-pad. The Deck sets all three flags, so it is unaffected. Full test suite (156) passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The modeshift "combination" list only had Left/Right Grip -- the original SC's two back buttons. The Deck and the new Steam Controller have four back buttons (L4/R4 -> LGRIP/RGRIP, L5/R5 -> LGRIP2/RGRIP2) and a right stick (R3 -> RSTICKPRESS). Add LGRIP2, RGRIP2 and RSTICKPRESS to the chooser so those can be used as modeshift combinations. Benefits the Deck too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Same gap as the modeshift chooser: the d-pad-emulation source picker (ae/dpad.glade), the special-action button picker (ae/special_action.glade) and the controller-settings picker (controller_settings.glade) only listed Left/Right Grip and Stick Press. Add Left/Right Grip 2 (LGRIP2/RGRIP2 = the L5/R5 back buttons) and Right Stick Press (RSTICKPRESS) so every binding dialog offers the full Deck / new-Steam-Controller button set. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two gaps the original SC's button set didn't cover: - The capacitive stick-touch sensors had no SCButtons constants, so the decoded bits (LStick = off5 0x01, RStick = off4 0x10) were unmapped (the Deck leaves them out for the same reason). Add SCButtons.LSTICKTOUCH / RSTICKTOUCH (free bits 16/17), map them in the v2 driver, and add "Left/Right Stick Touched" to all four button choosers (modeshift + ae/dpad, ae/special_action, controller_settings). - Now that there's a "Right Stick Pressed", relabel the old "Stick Pressed" / "Stick Press" to "Left Stick Pressed" / "Left Stick Press". Driver mapping unit-tested; full suite (157) passes. (The Steam Deck driver could now map its stick-touch bits too, via the same constants.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Deck reports stick-touch (DeckButton.LSTICKTOUCH/RSTICKTOUCH) but they were left unmapped because SCButtons had no equivalent. Now that SCButtons.LSTICKTOUCH/RSTICKTOUCH exist (added for the new controller), map the Deck's bits too, so "Left/Right Stick Touched" works on the Deck as well. The shared mapper/action and GUI-chooser fixes already cover the Deck. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The new Steam Controller (like the v1) has capacitive sensors on the handles -- distinct from the L4/L5/R4/R5 grip buttons -- which the Steam Deck lacks. Decoded as off5 0x20 (left) / 0x10 (right). Add SCButtons.LGRIPTOUCH/RGRIPTOUCH (free bits 18/19), map them in the v2 driver, and add "Left/Right Grip Sensing" to all four button choosers (modeshift + ae/dpad, ae/special_action, controller_settings). These read "on" whenever the handles are held, which suits grip-activated modeshifts. Driver mapping unit-tested; full suite (158) passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Grip sensing reads "on" most of the time while holding the controller, so note a future option to invert it -- fire when the grip is released -- as a general "inverted button" condition usable for any always-on sensor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Match the thumbstick-sensor labels ("Left/Right Stick Touched"): the
capacitive handle grips are now "Left/Right Grip Touched" in all four
button choosers. Label only; the LGRIPTOUCH/RGRIPTOUCH constants are
unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The new controller's type is "sc2", but there were no sc2-* entries in controller-icons/, so the GUI's auto-assignment fell through to a random icon (a red player-colour) and saved it. Add sc2-0..sc2-6 (copies of the Steam Controller icons; sc2-0 is the blue one) so an sc2 controller gets a proper Steam-Controller icon. A dedicated v2 icon is future polish. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Record the agreed interface decisions: stick-touch belongs in a new "Touch" tab of the stick/pad editor (not the main image); grip-touch stays exposed on the main controller image (no parent control to nest under); and the dedicated v2 controller artwork with AREA_* anchors + matching config is still to come. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nsor bindings Replace the borrowed Steam Deck GUI image with dedicated v2 artwork and add first-class support for the controller's capacitive sensors. Controller image & assets (generated by tools/gen_sc2_image.py from tools/sc2-source.svg + tools/sc2-assets/): - controller-images/sc2.svg: traced v2 body, blank face buttons, control-name ids so sticks/pads/dpad/bumpers/grips highlight on hover, darker body. - button-images/sc2_*.svg: v2 face-button overlay glyphs lifted from the art (monochrome ABXY, round Steam, single dots, view/menu) - no duplication. - images/sc2/*.svg: v2-specific side-panel icons (leaned-square pads, real view/menu, oval L4/R4/L5/R5 paddles, grip-touch silhouettes). - sc2.config.json points at all of the above. Capacitive sensors: - Stick-touch: new "Touch" tab in the stick's pressed-action editor (ModeshiftEditor) binds LSTICKTOUCH/RSTICKTOUCH; shown only for the stick press, hidden elsewhere. - Grip-touch: exposed on the controller face (curved handle overlay, green on hover) and as buttons in the side-panel grid. - Both usable as conditions in mode-shift combinations. Fixes: - Per-controller side-panel icon override (images/<background>/<name>.svg), leaving v1/Deck untouched. - Right-stick (and center-pad) "pressed action" now opens the editor (RSTICK->RSTICKPRESS, CPAD->CPADPRESS). - set_action no longer throws when saving a button with no on-screen widget (the touch sensors). README: note v2 support + the stick-touch/grip-sensor binding & combinations. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Several independent fixes to the OSD menu:
- Register menu generators in the OSD process. menu.py now imports
scc.osd.menu_generators and scc.x11.autoswitcher so MENU_GENERATORS is
populated; previously it was empty in the OSD process, so every
{"generator": ...} entry was silently dropped (MenuData.from_json_data) -
"recent profiles" never appeared and the "All Profiles" / "Autoswitch
Options" submenus showed only their separator.
- Long vertical menus now scroll. The item list is wrapped in a
ScrolledWindow capped to the monitor height (sized after the items are
packed, since the box is empty when it's wrapped and a GtkFixed won't
re-expand it), and the viewport scrolls to keep the selection visible
(incl. layer-shell/Wayland). Grid/radial menus opt out via scroll_wrap().
- Autoswitch Options no longer crashes the OSD daemon. It identifies the
focused window through X, which can't see native Wayland windows and can
hard-abort the process under XWayland; on a Wayland session it now skips
those queries and shows "Autoswitch needs an X11 session". On X11 it works
fully. Also fixed a None-title TypeError in Condition.matches and a
wrong-variable crash (self.title vs display_title) in the generator.
- Install a no-op Xlib error handler (xwrappers) so a stray X protocol error
(e.g. a window that vanishes mid-query) no longer aborts the process.
- MenuData.generate now logs and skips a failing generator instead of letting
it take down the whole menu.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two related hot-plug fixes: - GUI: when the last controller is turned off, keep showing its image instead of reverting to the default (v1) Steam Controller image. A _controller_shown flag gates the revert so the default is only loaded at startup, before any controller has been connected. (scc/gui/app.py) - sc2: turning a Puck-slot controller off makes the next control flush raise a USB pipe error, which the shared USB layer treated as the whole dongle disconnecting - it closed the device, so the controller never came back when turned on again. SC2Device.flush() now catches the pipe error and drops only that slot's controller (_slot_gone), keeping the dongle open and listening so the controller re-attaches on the next input. A genuinely unplugged dongle raises USBErrorNoDevice instead, which still propagates and closes normally. This also resolves a libusb mutex-assertion coredump seen when unplugging the dongle after it had been left half-torn-down by the old close-on-flush path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two OSD-keyboard robustness fixes: - Wayland crash: the keyboard's label rendering reads the active X keyboard layout group through XKB (X.Display(GdkX11.x11_get_default_xdisplay()) + X.get_xkb_state). That only works on GTK's X11 backend; under a native Wayland session GdkX11.x11_get_default_xdisplay() returns no usable Display and the XKB call aborts the whole OSD daemon at the C level (uncatchable in Python), so "Display Keyboard" did nothing. The X display is now opened only on an actual X11 backend and the layout group defaults to 0 on Wayland, with the two get_xkb_state reads guarded. The keyboard renders and types normally on Wayland; only live multi-layout-group switching of the labels is lost there. - redraw_background: scc-osd-daemon's _check_colorconfig_change() calls Keyboard.redraw_background() when the OSD colours change while the keyboard is visible, but the method did not exist (AttributeError -> daemon crash). Add it as a guarded queue_draw() of the background image. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… layout "Display Current Bindings" always rendered the fixed v1 binding-display.svg template and a hardcoded 5-box layout built for the v1 control set, so it showed the v1 controller regardless of which one was connected, and its boxes overflowed the screen on busier profiles. - binding_display.py now resolves a per-controller image: an explicit gui.binding_display, else binding-display-<gui background>.svg (e.g. binding-display-sc2.svg), else the generic template. The window is built once the connected controller is known (on_daemon_connected) so it can pick the right image, and it draws that controller's current profile right away. - The Generator box layout is per-controller now. The original 5-box layout is kept verbatim as the v1 fallback (_build_v1); a LAYOUTS table drives others. LAYOUTS["sc2"] is the Steam Deck-style v2 set: six boxes (system, left/right shoulder, left/right thumb, face) covering two sticks, a D-pad, two pads, four system buttons and the back paddles + grip-squeeze. Every control is listed but only bound ones draw a line, and a box with no bound controls is hidden - so grip-squeeze and the touch/press variants show up only when actually bound. - Boxes auto-fit: a per-box max_height plus font auto-scaling shrinks a crowded box (e.g. a stick bound to a big radial) so all its lines stay inside it, fixing the overflow. - tools/gen_binding_display.py generates images/binding-display-sc2.svg from the restyled controller art (tools/binding-display-sc2-art.svg) inlined verbatim, plus the AREA_* anchors of the GUI image, placing the six markers_<box> connector groups. Edit the art asset in Inkscape and re-run to regenerate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…oller --osd) The OSD menu's "Edit Bindings" runs `sc-controller --osd`, which used the controller-driven "OSD mode" (osd_mode): it reused the full main window and drove it by injecting X11-style GDK events and matching windows by XID. That only works on the X11 backend, and even there it was fragile (a mispositioned, black- rendering hint overlay); on Wayland it just spawned a duplicate main window. --osd now opens only the standalone OSD-keyboard bindings editor instead - the same dialog as Settings > Menus & Keyboard > Advanced - on both X11 and Wayland. It is a plain GTK window with no backend dependency, so it behaves consistently everywhere: - no main window is shown (so it cannot pile up duplicate main windows) and no tray icon; - the OSK.* actions are registered first so the OSD-keyboard profile parses; - closing the editor quits the process; - an flock-based single-instance guard makes a repeat launch a no-op instead of stacking a second editor window. osd_mode is left in place but is now unreachable (osk_edit_mode replaces it); it is removed in the next commit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This is the single, isolated removal of osd_mode, kept separate from v0.4 so the v0.4..v0.5 diff is the complete record of the feature should it ever be wanted back. What osd_mode was: launching `sc-controller --osd` opened the main window in a special mode you navigated with the controller itself - the pad drove focus and a floating hint overlay (OSDModeMappings) showed the button legend - so bindings could be edited from the couch without a keyboard or mouse. Why it is abandoned: - X11 only. It drives the GUI by synthesising X11-style GDK input events (OSDModeKeyboard/OSDModeMouse via Gtk.main_do_event) and matches windows by XID. Under a native Wayland GDK backend none of that works: focus cannot move (GTK_IS_WIDGET warnings) and there is no XID to match. - Even on X11 it is fragile: the hint overlay latches onto the wrong active window and renders black (it is an override-redirect window), and editing happens in the full main window rather than a focused dialog. - As of v0.4 "Edit Bindings" (`sc-controller --osd`) opens the standalone OSD-keyboard bindings editor instead, on both X11 and Wayland - a plain GTK dialog that is consistent and reliable - which made osd_mode unreachable dead code (osk_edit_mode replaced it). Removed: scc/gui/osd_mode.py (OSDModeMapper/Keyboard/Mouse/Mappings); App.osd_mode, App.osd_mode_mapper and all their conditionals; App.enable_osd_mode and OSD_MODE_PROF_NAME; the OsdmodeMappings window in glade/app.glade; the on_Dialog_key_press_event handler and its glade signal (action_editor); the osd_mode button-grab/name-entry guards (action_editor, ae/buttons); and the now-unused default profile .scc-osd.profile_editor.sccprofile. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
late edit - I've done almost all of the needed rebasing work myself and now keep it in the I would highly suggest to just hard reset your branch against the upstream Note the branch drops all the DS4/DS5 commits, as main diverged too much from your fork there. baa394e Can be dropped. I can't reproduce the hotplug bug - I did once get SCC into a state where the controller was eventually dropped while leaving them idle and I had to use it for input to get it to re-appear, but could not do it twice... I'll keep trying I suppose. EDIT: I could repro it again but your change does not seem to fix that, restarting First instance log: EDIT2: I did read the history and it does indeed to seem to be wrongly converted code though, shipped the fix. Running |
f7dce8c to
77949e9
Compare
…race) handle_new_device crashed with an unhandled FileNotFoundError when the /dev/input/eventN node disappeared between udev enumeration and open() -- a normal hotplug race, seen live with a Bluetooth Xbox pad reconnecting. Treat ENOENT like the already-tolerated EACCES: log at debug and skip. Also use errno constants instead of the bare magic 13. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CONTROLLER_TYPE_NAMES was missing the "ds4bt_hidraw" type (the DS4's
Bluetooth hidraw driver), so a DS4 connected over Bluetooth showed as the
generic "Controller" in the multi-controller list. USB ("ds4") and evdev
("ds4evdev") already mapped to "DualShock 4"; add the third.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…path) The Steam Controller v1 was the last controller feeding its quaternion straight to quat2euler, whose axis convention does not match the calibrated EUREL one -- so the relative-mouse (lean-to-turn) path moved wrong (pitch inverted, roll/yaw drifting left), while absolute happened to work. Held-pose captures show the v1 quaternion (q1=w q2=x q3=y q4=z, norm 32767, identity at rest, steady when still) uses the IDENTICAL axis convention as the SC2: x = pitch (nose-up +), y = roll (roll-right +), z = yaw (yaw-left +). Reuse the SC2's verified quat->euler mapping in the driver, hand the mapper DS4-convention EUREL angles in q1-q3, and set EUREL_GYROS on SCController: every controller now shares the single hardware-verified gyro code path (absolute, relative, tilt, lean-to-turn, laser mouse). Also add the same SCC_GYRO_CALIB=1 calibration instrument the DS4/SC2 have, which produced the measurement. All five gyro suite tests verified on v1 hardware. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
With a gated absolute gyro, the reference was effectively captured at the RELEASE pose: ModeModifier's deactivation fix reset the reference and then called the neutralizing gyro(0,0,0,*q), whose first-event capture stamped it right back at wherever the controller was on release. Rotating while disengaged then carried a stale offset into the next engage (stick starts deflected), while cycling the enable button appeared to "move the origin". Found on the SC1 but general: the bug lived in the shared modifier/action layer and affected every controller. Rules now, deterministic and Steam-like: - the reference is captured at each ENGAGE (ModeModifier resets again after the neutralizing call, so it cannot poison the next activation); - the very first activation behaves like every other one (GyroAbsAction.ir starts all-None / capture-at-first-event, instead of pitch/yaw pinned to the driver zero); - a legitimate 0.0 reference sticks (explicit None test instead of the falsy `or` capture). Also benefits lean-to-turn, which shared the release-pose poisoning. Verified on SC1 hardware: engage anywhere reads centered, tilt deflects, release zeroes, rotating while off never carries over. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ult) The v0.6.0.5 release run built all 10 AppImages fine, but the test job failed on the three newest bases upstream's matrix bump added -- resolute, fedora 43 and 44 -- which blocked the release job. In all three the same artifact dies: the TRIXIE AppImage segfaults (exit 245 = SIGSEGV) in AppRun dependency-check on those newest-glibc userspaces, while passing everywhere else. It is a CI artifact only; releases ship the jammy pair, which passed every base it was tested on. Mark those three bases experimental (continue-on-error) so a known cross-distro issue in a non-shipped artifact cannot block releases, and log the segfault in TODO.md for a proper fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Version and hash update provided by Thomas Eric (@ThomasEricB), matching the package.nix attached to the v0.6.0.5 release -- keeps the in-tree file and the release asset in sync, as with v0.6.0.4.
The OSD was placed on the layer-shell TOP layer, but compositors stack an active FULLSCREEN window above it (KWin puts it in its ActiveLayer, between AboveLayer and OverlayLayer), so menus, messages and the on-screen keyboard were hidden behind fullscreen games -- most of the OSD's purpose. Default to the OVERLAY layer, which sits above them. Verified on KDE Plasma Wayland: circular and regular OSD menus now appear over fullscreen games. menu.py already promoted submenus to OVERLAY, so this only makes the normal case match. SCC_OSD_LAYER=TOP|OVERLAY|BOTTOM|BACKGROUND overrides it, for comparing behaviour between compositors without a rebuild. Two related fixes while here: - The y layer-shell anchor defaulted to BOTTOM while x defaulted to LEFT and flipped to RIGHT for negative values. A positive y position -- which the X11 path counts from the TOP border -- was therefore silently measured from the bottom edge. Default it to TOP and flip to BOTTOM when negative, mirroring x. (The default OSD position has a negative y, so existing placement is unchanged.) - Log which path was taken -- layer-shell with its layer, or the X11 window hints -- since that distinction decides whether an overlay is possible at all: an OSD landing on XWayland, or a compositor without zwlr_layer_shell_v1 such as GNOME/Mutter, cannot cover a fullscreen window no matter which layer is asked for. Note keyboard focus is deliberately NOT requested (layer-shell keyboard mode stays NONE): no OSD window handles key input -- menus and the OSK are driven by the controller through the daemon socket -- so taking focus would only steal input from the game. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Audited scope and cost so the analysis is not lost. Headline: the port is NOT
urgent -- its main driver ("the OSD cannot draw over fullscreen games on
Wayland") was a layer-shell layer choice, now fixed in GTK3, and
gtk4-layer-shell would not have helped by itself since it speaks the same
protocol that GNOME/Mutter does not implement.
Records the measured surface (15.6k lines gui + 5.0k osd + 31 glade files /
18.5k lines XML), the work areas with their real counts, the two regression
risks (svg_widget's removed Gtk.EventBox; GTK4 dropping the X11 window hints
the OSD falls back to), the ~25-40 day estimate, the phased-port option the
7 separate OSD binaries allow, and the Xlib-coupled modules that stay broken
on Wayland regardless of toolkit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A gyro bound to a trigger was unusable. Both GyroAction and GyroAbsAction
compute their value in the stick range (STICK_PAD_MIN..STICK_PAD_MAX) and
handed it straight to AxisAction.clamp_axis, which for ABS_Z / ABS_RZ merely
clamps into the trigger's unipolar 0..255. Two symptoms fell out of that:
- saturation: in absolute mode 0.7 degrees of rotation pinned the trigger
at 255, so it read as a button rather than an axis. Relative mode was
roughly 64x over.
- a dead half: every negative value clamped to 0, so rotating one way did
nothing at all -- reported as "no input", although it is the same bug as
the sensitivity, just seen from the other direction.
Route both through a new GyroAction.emit_axis(), which rescales onto the
axis' own range. The neutral pose now rests at "released" (which is what a
trigger has to do) and full pull needs the same 90 degrees that fully
deflects a stick; the opposite rotation is available with a negative
sensitivity or the 'inverted' modifier.
Sticks are untouched -- the rescale applies to AxisAction.Z only.
Also moves GyroAbsAction's deadzone call ahead of the rescale, so the
deadzone keeps working in stick range where its bounds are expressed.
tests/test_gyro.py is new: first runtime coverage for the gyro actions,
using a fake mapper (the real one needs a daemon, a controller and uinput).
RangeOP compared the axis against its threshold with a bare float compare, so an analog axis parked near that threshold flipped the condition several times a second. Every flip runs ModeModifier's switch path, which releases held buttons and calls reset() on the outgoing action. For gyro bindings that is fatal rather than merely jittery. reset() drops the captured neutral reference -- GyroAbsAction.ir, GyroAction._lean_ref -- so the measured deflection never accumulates beyond a single frame's worth and the binding looks completely dead. A user reported exactly this on `mode(RT >= 0.7, ...)`, and the shape of the report is the fingerprint: absolute-gyro-to-stick and relative-gyro-to-mouse were dead while relative-gyro-to-stick and absolute-gyro-to-mouse worked. Those are precisely the two paths that carry a reference versus the two that read instantaneous angular rate and hold no state. Measured with the gate flipping every third frame: absolute gyro to stick fell from 7280 to 364, gyro to mouse from 72 px to 3.6 px. Once the condition holds, the value now has to travel HYSTERESIS (5% of the axis range) back past the threshold before it stops holding. Applied to all six operators, with the band widening in whichever direction satisfies the operator.
GyroAbsAction scaled all three gyro axes by self.speed[2] -- the Z sensitivity -- and then, down in the output loop, scaled the gamepad-axis branch by self.speed[i] as well. Sensitivity was therefore applied twice and one of the two was read from the wrong axis: a setting of 2.0 came out as 4.0 (measured: baseline peak 3640, sens 2.0 peak 14560). Use speed[i], once, and do it before the clamp rather than after. Applying it after meant sensitivity could not move the point at which the axis pegs: the value was already clamped to STICK_PAD_MAX at 90 degrees of rotation, so a higher sensitivity just scaled a saturated reading and got re-clamped. It now behaves as the setting reads -- sens 3.0 pegs the axis at 30 degrees. Scaling before the clamp also fixes the out-of-range haptic, which sits between the two multiplications and was testing a pre-sensitivity value: at sens > 1 the axis could sit pegged without ever firing the feedback pulse. Note for release notes: a sensitivity tuned by feel against the old squaring needs its number squared to match (a working 2.0 becomes 4.0).
The DualSense has no "powering off" report -- a long press on the PS button
simply drops the link -- so a disconnect surfaces as a failing read inside
the poller callback. That OSError was uncaught, propagated through
Poller.poll and out of SCCDaemon.run, and killed the whole daemon:
File ".../scc/drivers/ds5drv.py", line 682, in _input
tempdata = self._hidrawdev.read(78)
OSError: [Errno 5] Input/output error
Every other connected controller went down with it, and the OSD followed
("Daemon died"), sometimes leaving an orphaned scc-osd-daemon behind.
Catch EIO / ENODEV / ENXIO and close just that controller; anything else
still propagates, since an unexpected errno here is a real bug and should
not be swallowed. close() also has to become idempotent, because the
hotplug teardown registered via add_remove_callback fires straight after
and would otherwise close the hidraw device a second time.
All three DS5 classes declare ControllerFlags.EUREL_GYROS, which means q1-q3 carry pitch/yaw/roll as 2**15/PI fixed point. Nothing in the driver ever produced that. _calculate_quaternion stored raw quaternion components instead -- q1=w, q2=y, q3=x, q4=-z -- a leftover from before the flag meant what it means today; every other driver that sets it (sc2, sc_dongle, ds4) converts right next to the flag. So the mapper read the quaternion SCALAR as "pitch", the pitch term as "yaw", and never looked at q4 where the real yaw sat. Absolute gyro, the gyro-to-mouse lean path and every tilt binding were driven by the wrong quantity: on hardware the pitch-bound axis sat pegged (w spans the full +-180 deg once scaled as an angle, so anglediff wraps past saturation), the yaw-bound axis followed pitch, and real yaw did nothing at all. The conversion is the exact analytic inverse of the composition a few lines above, which builds the standard aerospace ZYX quaternion from (roll about x, pitch about y, yaw about z) -- so no convention had to be guessed. Checked against that composition offline: round-trip error ~3e-15 deg for single and mixed rotations, and 3000 accumulated increments stay normalized to 1.000000000000. Signs are hardware-verified rather than derived. _convert_input_data already flips gyaw and groll (but not gpitch) on the way in, so only pitch needs the negation that turns a rate sense into the EUREL angle sense; my first attempt negated all three and got two of them backwards. Known limitation, documented at the call site: ZYX euler angles gimbal-lock at pitch = +-90 deg, and degrade visibly from about 75 deg where the yaw and roll extraction is amplified by 1/cos(pitch). That is inherent to the representation EUREL_GYROS is defined in, so every controller on this path shares it, and it is well outside the range gyro aiming uses.
The DualSense reports a small, temperature-dependent offset on each gyro axis even sitting untouched on a table, and this driver integrated it straight into the orientation. Measured on hardware: roughly 24 LSB on roll, which is 1.5 deg/s -- invisible in a raw dump, and enough to walk an absolute gyro across its entire range in half a minute. Symptoms were an axis that climbed on its own with the controller flat and still, and axes that no longer returned to centre after a few rotations. Estimate the offset whenever the controller has been at rest for _GYRO_REST_SECONDS and subtract it. Rest is judged against the current estimate rather than against zero, so a large offset still reads as "at rest" and converges instead of locking itself out. Simulated against the measured 24 LSB offset at 250 Hz: converges in ~2 s and drift over the following 60 s falls from ~90 deg to 0.00 deg, while rotations of 2 deg/s and up are recorded faithfully (90 deg/s for 1 s -> 90.03 deg). Rotation slower than about 1 deg/s sits inside the rest window and is partly absorbed as offset; that is the trade the window width buys, and 1 deg/s is far below any aiming motion. Also names the raw-LSB scale constant, and records that it is UNVERIFIED for this path: the value was carried over from a driver reading a kernel-calibrated evdev/hiddrv stream, while this one reads the raw hidraw report, and the two are not in the same units. It survives a dimensional check (a +-2000 deg/s sensor in int16 gives 16.4 LSB per deg/s) but has not been measured.
Same env-gated instrument the DS4, SC1 and SC2 drivers carry, ported to the DS5 hidraw path. Throttled to ~4 Hz and opts this logger into INFO, since the daemon leaves the root logger at WARNING. Prints the raw rates, the current zero-rate offset estimate and how long the controller has been judged at rest, next to the angles those rates integrate to and the gravity unit vector. That is enough to identify which physical axis each rate belongs to, to watch the offset estimate converge, and to measure _GYRO_LSB_PER_DEG_SEC from a controlled rotation through a known angle. The angles are read back out of q1-q3, i.e. after the EUREL sign convention has been applied, so the dump shows what the mapper actually receives. An earlier version logged the internal euler angles instead and led directly to a wrong conclusion during bring-up, because pitch is negated on the way into q1: a correct axis read as inverted, and taking that at face value would have broken the one axis that was right.
|
Built both the sc2 branch in the parent repo and the steam-controller-v2 branch in your repo and noticed a few things. When switching from the Steam Controller V2 to another controller with a smaller window, the window will shrink itself as the system cursor is moved. There is no haptic feedback when pressing down on each trackpad, which is important to have on this specific controller, as there is no physical button. I cannot navigate the OSD with the DPad, only with the joystick and the left trackpad. Adjusting mouse settings on the right trackpad does nothing, including trying to adjust the sensitivity, feedback, and trackball friction. The settings still apply fine on my original Steam Controller V1. It would be nice to be able to disable Steam Controller support in the settings for use with Steam, and also have separate toggles for the V1 and V2. Also, is it really necessary for the V2 window size to be that much larger compared to other controllers? |
Thanks for your comments. Do you mind re-sending this as a bug report in my repository? It will be easier to track. |
Fixes #98 — yes, it works with the new Steam Controller. 🙂
Summary
This branch adds a full user-mode driver for the new Steam Controller 2 (2026), substantially improves Steam Deck support, introduces multi-controller UX (a controller selector plus per-controller profiles, images and icons), and fixes a range of Input-Test, OSD, GUI/mapper and packaging issues. It closes with an asset-optimization (svgo) and type-annotation pass.
Everything here has been hardware-tested on real devices — Steam Controller v1 (wired and dongle), the Steam Controller 2 wireless Puck, and the Steam Deck (via AppImage). Commits are kept focused (one thing each) for bisectability. This is an LLM-assisted contribution; the human author drove the hardware reverse-engineering and testing.
Steam Controller 2 (2026) driver —
scc/drivers/sc2.py0x42) from USB captures. Protocol notes and a capture/diff harness are included underdocs/andtools/sc2-probe/.0x1304, a 4-slot dongle) and the wired USB-C transport (0x1302); Bluetooth (0x1303) is scaffolded as a follow-up.0x82); lizard-mode kept disabled; controllers survive turn-off/on (the Puck re-attaches without tearing down the dongle).images/sc2.config.json. The capacitive stick-touch and grip sensors are bindable directly, or usable as conditions in mode-shift combinations.Steam Deck
images/deck/.Multi-controller UX
Input Test mode
OSD
GUI / mapper
HAS_RSTICKcontrollers (right pad / stick / D-pad) and a v2 right-stick crash under a mode modifier.v1 / general fixes
GET_SERIALstalls; spawn helpers with a valid interpreter whensys.executableis bogus (AppImage); build theuinputenums via the functionalIntEnumAPI; and repair "register new controller" and "Restart emulation".Packaging / AppImage
sc-controller-cc; ship a single, most-compatible jammy build; and document the one-time udev-rules step (noting the Steam Deck doesn't need it).Asset optimization (svgo)
The 50 shipped SC1/SC2/Deck SVGs (plus 7 source assets) are run through
svgo— ~30% smaller with no visible change. These SVGs aren't only artwork; the GTK GUI reads them in ways a naive minifier silently breaks, sotools/svgo.config.jsdisables exactly the passes that would:cleanupIdsoff — the GUI looks elements up by id (AREA_*regions,controller/root/background/label_template, per-control ids).convertShapeToPathoff — hover / Input-Test areas are read as<rect x/y/width/height>.removeViewBox/removeHiddenElemsoff — coordinate math needs the viewBox; the Input-Test overlay lives in adisplay:nonelayer.removeUnknownsAndDefaultsoff — custom attributes (e.g.scc-button-scale) must survive.collapseGroups/moveElemsAttrsToGroup/moveGroupAttrsToElemsoff — button glyphs are<g id="button"><g transform=…><path/></g></g>, andcontroller_image._fill_button_imagesoverwrites thebuttongroup's transform when placing a glyph; flattening landsid="button"on the path with the normalisation transform, which then gets clobbered.convertTransformoff —SVGEditor.parse_transformuses a comma-only regex; svgo'stranslate(a,b)→translate(a b)rewrite makes the GUI read the element as untransformed, dropping thesc/deckcontroller-group offset.tools/gen_sc2_image.pyandtools/gen_binding_display.pynow pipe their output throughsvgo(helpertools/_svgo.py) so regenerated assets stay optimized, with a source-preserving config variant for the generator-parsed art.Type annotations
Every function, class and parameter this branch introduces now carries type annotations (driver, GUI, OSD, mapper,
tools/, tests) —ruff's flake8-annotations (ANN) rules pass on the added lines. Forward references usefrom __future__ import annotations+TYPE_CHECKING, matching the existing style. No runtime behaviour change.Testing
<g id="button">structure, comma-separated transforms andAREAgeometry byte-for-byte; all SVGs render (rsvg-convert); the generators still produce valid output.ruff --select ANNclean on the added lines; unit tests pass; full import of the touched modules succeeds.