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 130 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>
|
b42597a How do I reproduce this issue? I do have 2x SCv1 controllers. 387c45c Can be dropped. Remaining Appimage commits can be dropped - let's keep attaching all builds, easier for users to find issues with them, we can add a nice release table with explanations instead or something if the idea was to have users prefer the oldest build. |
The USBErrorBusy handler in handle_new_device called the registered failure callback as fail_cb(*tp) -- just (vid, pid) -- but the callbacks (ds4drv, ds5drv) are fail_cb(syspath, vid, pid), and the open-failure path above already passes all three. The mismatch raised TypeError instead of running the evdev fallback whenever a device was already claimed by another daemon. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Turn on the complementary filter (_ACCEL_ALPHA = 0.98) with the accel<->gyro correspondence measured on a real DS4 by logging the gravity unit vector at held orientations: at rest gravity sits on accel +Y, nose-down swings it to +Z, a roll swings it to X (scale ~8200 LSB/g). The previous (disabled) formulas had pitch reading the roll axis -- why they dragged pitch/roll to a mis-derived "level". Now pitch = elevation of gravity out of the X-Y plane and roll = elevation out of the Y-Z plane (decoupled: a pure roll reads ~0 pitch and vice versa), with roll sign-flipped to match the gyro direction. Verified on hardware: a held tilt stays pinned with no drift on pitch and roll; yaw remains gyro-only (no gravity reference; drifts slowly, inherent without a magnetometer). Also add the calibration instrument used for the measurement, kept for the same pass on SC2/Deck/DS5: SCC_GYRO_CALIB=1 logs the accel gravity unit vector plus the integrated gyro angles at ~4 Hz (and opts the DS4 logger into INFO so it is visible under the daemon's default WARNING). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s map) TiltAction.gyro decoded q1-q4 as a quaternion unconditionally; it never got the EUREL_GYROS branch GyroAbsAction has. On euler controllers (DS4) q1-q3 hold euler angles in 2**15/PI fixed point with q4 always 0, so quat2euler computed atan2 of near-zero noise products -- arbitrary full-range angles that constantly crossed the +-0.75 rad threshold and fired tilt actions (continuous yaw output) with the pad at rest on the table. Read q1-q3 directly as euler when the controller has EUREL_GYROS, mapped to the slot wiring: slots are (front down/up, TILTED left/right, ROTATED left/right) = (pitch, roll, yaw), so yaw/roll are swapped into that order, and pitch/yaw are negated to match the slot firing directions. All six motions plus rest-silence verified on DS4 hardware. The quaternion path for other controllers is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Archaeology: introduced by c3dd3ad ("Fixed dongle connections", May 2020) during the Python 3 port — the original py2 ord(data[4]) started raising TypeError on py3 (int, not str), and wrapping it in str() silenced the exception while inverting the logic. Your current main still has it (sc_dongle.py:101). Hardware repro (2× SCv1 + 1 wireless dongle), on current main:
Expected with the fix ( Caveat for your test: our branch also fixes a separate v1 multi-controller issue (5c898df, GET_SERIAL stall tearing down the dongle) — on main that one can also make a second v1 misbehave, so if you test there you may see compounded symptoms. Step 3 (power on, don't touch, watch it not appear) isolates this commit's bug cleanly.
Why? This is a serious bug that caused a crash in OSD when in Wayland. Has it been fixed elsewhere?
|
Cause it's now in master |
… = lean-to-turn The gyro->mouse mapping had the semantics backwards and half-dead: with "Absolute" checked, the held angle was fed to mouse_move every frame, making cursor VELOCITY proportional to tilt (a joystick semantic on a delta device: 90 deg scrolled faster than 45 and never stopped); with it unchecked, mouse axes did nothing at all (GyroAction skipped Rels by design). New semantics, matched against Steam's gyro-mouse: - Absolute checked (GyroAbsAction): laser pointer. The angular rate is the move delta, exactly like MouseAction.gyro (the dedicated Mouse gyro editor); the rate integrates to the rotation angle, so the cursor tracks the controller's absolute orientation and stops when the rotation stops. Per-gyro-axis signs (pitch +, yaw -, roll -) hw-verified on the DS4. - Unchecked (GyroAction): lean-to-turn. Cursor velocity is proportional to the held tilt angle (saturating at +-90 deg): lean and it keeps moving, return to level and it stops. Uses the fused absolute angle (EUREL or quat2euler), so "level = stop" is anchored to gravity for pitch/roll. Useful where holding a leaned position should keep turning. Stick axes are unchanged in both modes (absolute = deflection follows the held angle; relative = deflection follows the rate). The now-unused GyroAbsAction.MOUSE_FACTOR velocity constant moves to GyroAction, and the gyro editor comment is updated. All verified on DS4 hardware. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The report-0x42 IMU boundary was reverse-engineered wrong: the protocol notes placed an x/y/z/w quaternion at offsets 40-47 and gyro rates at 48-53. Held- pose captures prove the real split -- the four i16 at 46/48/50/52 have norm exactly 32768 in EVERY orientation (a unit quaternion, laid out w@46 x@48 y@50 z@52), while 40-45 read ~0 whenever the controller is still (the actual angular rates). We had been feeding orientation components to the mapper as "rates" (so relative mode followed the angle and saturated at ~30 deg) and rates+w into quat2euler (so absolute mode was dead or axis-scrambled). IMU axes, verified from seven held poses: x = pitch (nose-up +), y = roll (roll-right +), z = yaw (yaw-left +); accel gravity reads +Z flat, -Y nose-down, -X roll-right. The quaternion is firmware-fused: pitch/roll are gravity-referenced (drift free), yaw is enable-time-referenced and drifts. parse_input now converts the quaternion to euler host-side and hands the mapper DS4-convention EUREL angles in q1-q3 (SC2Controller gains EUREL_GYROS), with the raw rates sign-mapped to match: angles + at nose-down / yaw-right / roll-right, rates + the opposite way, the per-axis opposition the DS4 established. Every gyro path -- absolute, relative, tilt, laser-pointer mouse, lean-to-turn -- thus shares the code and conventions already hardware-verified on the DS4, and all of them plus the six tilt slots are verified on real SC2 hardware (across two puck firmware updates). Known limitation: near +-90 deg roll the euler decomposition hits its gimbal-lock pole and pitch/yaw become noise-degenerate (inherent to any euler projection; the quaternion itself is fine). Also add the calibration instrument used for the measurement (and kept for future firmware changes, e.g. the 0x45 NoQuat stream): SCC_GYRO_CALIB=1 logs the accel gravity unit vector, the parsed rates and the derived euler angles at ~4 Hz. Tests gain real held-pose captures as ground-truth fixtures; the protocol notes are corrected accordingly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AREA_LT / AREA_RT sat outboard of the bumpers at bumper height, so hovering Left/Right Trigger brightened a region BESIDE the shoulder buttons. The trigger art itself is drawn above the bumpers (hand-added root-space paths; the committed viewBox is hand-extended to y=-45 to show them). Move the two area rects onto the art's actual bounding boxes, and sync the generator's REGION table so a regeneration keeps them there. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Gemini 3.1 Pro <gemini-code-assist@google.com> Co-authored-by: Claude <noreply@anthropic.com>
The v0.6.0.4 jammy AppImage build broke: the wheel now declares the
requires-python >=3.11 source-install floor, and jammy bundles Python 3.10,
so pip refuses it ("requires a different Python: 3.10.12 not in '>=3.11'").
The runtime is deliberately kept 3.10-compatible for exactly this AppImage
(functional IntEnum construction), so pass --ignore-requires-python.
Known-remaining (pre-existing, separate): the "tests need 3.11" skip guard
compares $UBUNTU_CODENAME, which is never set in that RUN (sourced only in
the previous, separate shell), so it errors and silently skips the tests on
EVERY base. Fixing it requires first fixing the three long-failing tests
(the 'inverted' modifier has no docs/tests entries), or every non-jammy
build starts failing -- follow-up, not part of this hotfix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The InvertedButtonModifier ("act on release") shipped without a docs anchor
in docs/actions.md or test_inverted methods, so the suite's completeness
meta-tests (test_every_action_has_docs and both TestModifiers.test_tests)
have been failing since the feature landed. Nobody noticed because the
AppImage build's test step silently never ran (see the next commit).
Add the actions.md entry and parser + profile round-trip tests. The full
suite is green, unfiltered: 160 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Dockerfile's "tests need Python 3.11+" guard compared an UNSET, unquoted
${UBUNTU_CODENAME}: /etc/os-release was sourced only in the previous RUN,
which is a separate shell, so the test `[ != 'jammy' ]` was a syntax error
("unexpected operator") whose non-zero status the `if` swallowed -- the test
suite has silently never run on ANY base since the step was added.
Source /etc/os-release in the same RUN and quote the expansion. Tests now
run on noble/bookworm/trixie (Python >= 3.11) and are skipped on jammy
(3.10), as always intended. Verified with a local container build on noble:
160 tests collected and passed in-container.
Requires the previous commit (the 'inverted' coverage): with the gate
working, its three failing meta-tests would have broken every non-jammy
build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…kept) Brings in upstream through d472d3d: our C0rn3j#101 (minimal-host AppImage fix, squash-merged as 15a2561), the Python 3.11 floor done upstream (e28d007, identical to ours - clean merge), CI action bumps, fedora 43/44 test bases, the new resolute base, and a gamecontrollerdb refresh. Fork-deliberate divergences preserved in conflict/auto-merge resolution: - jammy STAYS: upstream replaced jammy with resolute; jammy is this fork's release base (glibc 2.35 = Ubuntu 22.04 LTS onwards), so it is re-added to the build and test matrices alongside upstream's new bases, the release keeps attaching the jammy pair, and the Dockerfile keeps the jammy-proposed python-build workaround plus the tests-skip guard and pip --ignore-requires-python (jammy bundles Python 3.10). - Release checksums STAY: upstream dropped the per-asset .sha256 step (3584f6e); this fork ships them. - scc/osd/keyboard.py: upstream's Wayland crash fix (4ebb635) is equivalent to the one this fork already had (387c45c); ours is kept verbatim. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Code-verified feasibility notes: the emulated pad's X360 identity is pure config (config["output"]), a keyboard+mouse-only mode already exists as the undocumented SCC_NOGAMEPAD env var, and DS4/DS5 output splits into a cheap evdev identity preset (glyphs, no gyro) vs a real /dev/uhid emulation (kernel hid-playstation binds -> native in-game gyro). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MouseAction.gyro (the "Joystick or Mouse" tab's Mouse (Desktop) output) still hardcoded its pre-calibration negation of all three rates, which inverted pitch (screen Y) once the DS4/SC2 drivers were normalized to the shared rate convention -- while its yaw/roll negation happened to match. Use GyroAction.MOUSE_RATE_SIGN (hw-verified on DS4 + SC2) as the single source of truth. Verified on SC2 hardware: pitch up now moves the cursor up. Note for the Steam Controller v1 (whose driver predates the normalization): if its rates run opposite, the correction belongs in sc_dongle.py -- drivers normalize to the one convention. Queued in the v1 regression pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…labels) The button lists were static and Steam-Controller-v1-shaped, which broke down on the SC2 (reported by a user): - The gyro-enable dropdowns offered no way to gate the gyro on the SC2's capacitive handles -- the natural "aim while gripping" enabler. "Left/Right Grip" there is the rear upper paddle, which users read as the handle sensor. Add "Left/Right Grip Touched" (LGRIPTOUCH/RGRIPTOUCH) and the lower paddles (LGRIP2/RGRIP2) to both gyro editors' enabler lists. - On paddle controllers (sc2, deck) the four rear paddles are physically labeled L4/L5/R4/R5; calling them "Left Grip (2)" is confusing there, while the v1's squeeze grips must KEEP the grip naming. New button_label() helper renames just the paddles per controller type, applied to the gyro enabler lists, the modeshift/chord editor and the action editor header. - The reverse problem: the v1 (and anything else without them) must not see "Grip Touched" / "Grip 2" / stick-touch entries at all. New button_available() filters list entries against the controller's gui config "buttons" capability list -- restricted to an OPTIONAL_BUTTONS set whose presence that list records reliably, so configless controllers (e.g. DS4) never lose valid entries. Labels are display-only: profiles still store LGRIP/RGRIP2/... internally, so saved profiles and cross-controller reuse are unaffected. Verified on SC2 (L4/R4/L5/R5 + Touched entries, all functional as enablers) and v1 (classic grip naming, no phantom entries) hardware. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reported: resetgyro (Special Action "recenter gyro sensor") did nothing. Two real causes and one by-design case: - Lean-to-turn (relative gyro -> mouse) had NO neutral reference at all: it read the driver's fused absolute angle directly, so its zero was gravity level for pitch/roll and an arbitrary power-on orientation for yaw -- unrecenterable and making yaw-lean unusable. GyroAction now captures a neutral pose on the first event after (re)activation and measures the lean against it (anglediff), with reset() re-capturing. The ModeModifier deactivation hook already calls reset(), so a gated lean re-references on every activation, Steam-style. - mapper.reset_gyros only reset GyroAbsAction; broaden to GyroAction (the parent class), covering absolute (ir) and relative (lean neutral) alike, through ModeModifier wrapping. - The laser-pointer mouse (absolute -> mouse) is rate-based and has no center by nature; recentering rightly leaves it alone (now documented). Verified on hardware: recenter-while-leaned stops the lean cursor and moves the neutral; an absolute stick recenters its deflection at the current pose. 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
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.