Steam Controller 2 (2026) driver, Steam Deck support, multi-controller UX, and OSD/GUI fixes#100
Open
Patola wants to merge 104 commits into
Open
Steam Controller 2 (2026) driver, Steam Deck support, multi-controller UX, and OSD/GUI fixes#100Patola wants to merge 104 commits into
Patola wants to merge 104 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>
The OSD entries aren't dropped - they ship disabled in the menu settings and do nothing when enabled/selected. Also note the Deck status (tray) icon doesn't appear even when enabled (works on desktop now that libdbusmenu is bundled). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Set app_info.name to sc-controller-cc so AppImages build natively as sc-controller-cc-<ver>-<distro>-<arch>.AppImage, and point update-information at this fork (Patola/sc-controller-cc, sc-controller-cc-* glob) so the bundled zsync self-update pulls from our releases instead of upstream C0rn3j/sc-controller. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ntroller-cc Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
creg/dialog.py imported BUTTON_ORDER from scc.gui.creg.constants, where it does not exist - it lives in scc.gui (as daemon_manager already imports it). Clicking "register new controller" therefore raised ImportError. Import it from scc.gui. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Highlights are added/removed per observed press/release. Turning Input Test off (sniffing disabled) stops the events, so a control held at that moment (e.g. a grip sensor) never receives its release and stays highlighted. Clear the observe highlights in on_daemon_reconfigured when sniffing is off. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…mage udev steps
Trim releases to one most-compatible AppImage per arch: matrix reduced to Ubuntu
22.04 jammy (glibc 2.35 - runs on everything newer, incl. Debian 12+ and Ubuntu
22.04+) x {amd64, arm64}, and stop attaching the .zsync and per-asset .sha256
files. Document how AppImage users install the udev rules, since the AppImage
cannot install them itself.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Deck maps its lower paddles to LGRIP/RGRIP (labelled L5/R5) and the upper ones to LGRIP2/RGRIP2 (L4/R4) - the reverse of the SC2 (LGRIP=L4). Both share the "deck" UI layout and the .glade grip order (LGRIP above LGRIP2), so the Deck's side panel showed L5/R5 above L4/R4, upside-down vs the device. Reorder the paddle buttons per-controller (keyed on gui.background): on the Deck put L4/R4 above L5/R5; the SC2 and everything else keep the .glade order. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nify A GUI-safe svgo config (tools/svgo.config.js), a source-preserving variant for the generator-parsed art (tools/svgo.config.source.js) and a helper (tools/_svgo.py) that gen_sc2_image.py and gen_binding_display.py now call so regenerated SVGs stay optimized. The config disables the svgo passes that break the GUI's naive SVG parsing: it keeps element ids, the viewBox, <rect> hover areas, display:none layers, custom attributes, comma-separated transforms (SVGEditor.parse_transform is comma-only) and the <g id="button"> glyph structure (_fill_button_images overwrites that group's transform). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Runs the 50 shipped controller/button/icon SVGs and 7 source assets through svgo (tools/svgo.config.js): ~30% smaller with byte-identical AREA geometry and rendering. Every element id, the <g id="button"> glyph group and every comma-separated transform is preserved. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Type annotations for every function, class and parameter this branch introduced (driver, GUI, OSD, mapper, tools and tests), so ruff's flake8-annotations (ANN) rules pass on the added lines. No behaviour change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ButtonInRevealer builds its button with Gtk.Button.new_from_icon_name(), a literal icon-name lookup, but it was handed the deprecated GTK stock IDs "gtk-save" and "gtk-edit". Themes without those legacy aliases (and leaner AppImage icon sets) render the "Save changes" button blank. Use the standard freedesktop names document-save / document-edit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
macro_editor.py and modeshift_editor.py still use the deprecated Gtk.Image.new_from_stock() API for their up/down/delete/clear buttons. They render (stock->icon fallback) but should move to new_from_icon_name with freedesktop names, mirroring the profile_switcher.py save/edit fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- per-controller profile memory: implemented (persisted + restored by controller id); moved to the Done list - action-editor "Touch" tab: superseded - the capacitive stick-touch is bound via the controller image instead - LT/RT/GYRO side-panel icon note: status-only (the shared defaults look fine); the v2 side-panel icons are already recorded under Done Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… Off - "Run Program", "Display Current Bindings" and "Edit Bindings" launch scc-* / sc-controller helpers via shell(), which trusts PATH and the binary's shebang - both unreliable in the AppImage, so they failed silently. on_sa_shell now runs those helpers via find_python() + find_binary(), the same shebang-bypassing path the daemon uses for its own OSD helpers; arbitrary shell commands are unchanged. - "Turn Controller OFF" is hidden from the OSD menu for the Deck's built-in controls (they can't be powered off). The daemon passes the controller type via --controller-type; the menu drops turnoff items for type "deck". Since the OSD menu normally loads without an action parser (the daemon runs actions by id), the Deck menu now parses its actions so the filter can see them, and the filter is shared so QuickMenu drops the item too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The OSD "Display Current Bindings" template was a hand-drawn per-controller asset (tools/binding-display-sc2-art.svg) that had to be maintained by hand and kept in sync with the controller drawing. Replace that with a generator that builds the template straight from the existing GUI controller drawing. tools/gen_binding_display.py is now controller-agnostic: driven by a CONTROLLERS table, for each entry it scales that controller's images/controller-images drawing into the OSD canvas, recolours it into the binding-display palette (green outlines over two greys on a dark backdrop so it recedes behind the binding boxes), strips the AREA_* hotspots and drops a marker ring at each control anchor. Adding a controller is now just a table entry plus a box layout. Also relocate the output out of the images/ root into an images/binding-display/ subdir (picked up by setup.py's images/*/ glob), so _resolve_image now looks for binding-display/<gui-background>.svg. Drop the hand-art asset, regenerate images/binding-display/sc2.svg, and document the art-generation tools under the README build section for future contributors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Deck had no Display Current Bindings template, so the OSD fell back to the generic layout and drew the wrong (Steam Controller v1) picture. Give it a real one: add a "deck" entry to gen_binding_display.py's CONTROLLERS table (the Deck's AREA naming differs -- segmented pads/bumpers, no grip-touch) and generate images/binding-display/deck.svg from the Deck GUI drawing. The Deck's built-in controller shares the v2's physical control set, so it reuses the v2 box layout (LAYOUTS["deck"] = LAYOUTS["sc2"]); controls the Deck lacks stay unbound and their boxes simply render nothing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Display Current Bindings" menu entry runs shell("scc-osd-show-bindings"),
which was launched with no --controller. OSDWindow.choose_controller then falls
back to the first connected controller, so with several controllers connected it
showed the wrong controller's bindings -- and because the same controller is the
one the OSD locks its cancel button on, the window couldn't be dismissed at all
(the cancel press landed on a different controller).
on_sa_shell now appends --controller <id> for scc-osd-show-bindings, targeting
the controller that actually invoked the action (mirroring on_sa_menu). Arbitrary
user shell commands, and any command already passing --controller, are untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Display Bindings window was scaled to fit only the screen *width*, and did nothing at all when the active screen couldn't be determined -- as on the Steam Deck under gamescope, which reports no active window. There the 1280x720 image was shown at full size and overflowed the Deck's 1280x800 screen, pushing the edge-anchored binding boxes off-screen so their connector lines appeared to shoot outside the window. compute_position now caps the image to 80% of the screen in BOTH dimensions and falls back to the primary monitor when no active screen is reported, so it always fits with a margin. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The per-controller (sc2/deck) layout caps each box's height, so Box.calculate() auto-scales the label font to keep a crowded box's lines inside the frame. The original v1 layout (_build_v1) never set max_height, so that auto-shrink never triggered and a busy box's labels spilled out of the frame and off the screen. Give the v1 boxes the same max_height caps so they shrink to fit like the sc2/ deck layout. Boxes that already fit keep scale 1.0, so uncrowded v1 displays are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
read_area_centers read each AREA_* rect's raw x/y, assuming the anchors live in the drawing's user space. That holds for sc2 (identity anchor layer) but not the Deck, whose anchors are nested in a separate layer under translated groups. Their raw coordinates land hundreds of units outside the 446x345 viewBox, so the Deck's shoulder (and pad) markers were placed below the canvas -- the binding boxes then drew their connector lines shooting off the bottom of the window. Accumulate the full ancestor transform chain (translate/scale/matrix/rotate, space- or comma-separated) down to each anchor, so a marker lands on its control regardless of how the source drawing nests it. sc2's identity layer is unchanged; the Deck's markers now sit on the sticks, pads, triggers and buttons. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DualShock 4, DualSense and Xbox 360 fell back to the generic (Steam Controller v1) binding-display image. Give them their own, generated the same way as sc2/ deck: a CONTROLLERS entry (source drawing + per-box AREA anchors) plus a LAYOUTS entry. The three are physically alike and their drawings share the same anchor names, so they share one marker set (gen_binding_display.py _GAMEPAD_MARKERS) and one box layout (_GAMEPAD_LAYOUT). That layout reflects the gamepad control model, which differs from the Steam controllers: the right stick is the right pad (pads[RIGHT], not rstick) and the d-pad is the left pad (pads[LEFT]) -- verified against the bundled XBox default profile. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Input Test moves a test-cursor inside each control's invisible AREA_*TEST rect, scaling the motion by the rect's width and height. Several controller images ship those rects flattened to ~0-1px tall (ds4/ds5/x360/remotepad, and the unwired ps1/psx/snes), so the cursor could only move horizontally -- the left stick showed no vertical motion, and the pads barely moved. Square each degenerate rect (height = width, keep its centre, which already sits on the control). These are hotspot rects, not visible art, so the drawing is untouched. sc/sc2/deck already have proper squares and are left alone. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Input Test observe list never included the touchpad. Add a cpad test-cursor and observe CPAD -- mapped to the touchpad's AREA_CPAD rect (a real rectangle, so no *TEST square is needed) -- so the cursor tracks the finger; the daemon emits CPAD positionally only while touched, so it hides on release like the other pads. Also observe CPADPRESS so a touchpad click brightens the CPADPRESS element. Harmless on controllers without a touchpad (neither source ever fires). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DS4/DS5 set HAS_RSTICK and HAS_DPAD, but store the right stick as the right pad and the d-pad as a hatswitch, so their generic HID state (HIDControllerInput) has no rstick_* / dpad_* fields. The mapper read them unconditionally, so every input event raised AttributeError -- caught, but only after aborting the rest of input processing. That silently killed the right stick, triggers and touchpad on the DS4 (everything after the sticks block), while buttons and the left stick, handled earlier, still worked. Guard both accesses with hasattr(state, ...). Controllers with a real rstick/dpad in their state (Steam Controller 2, Deck) are unaffected; gamepads on the generic HID decoder skip the fields they don't have and process the rest normally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The DS4TOUCHPAD decoder leaves the touchpad coordinates raw (~0..1919 x, ~0..942 y on the DS4; the DualSense pad is 1920x1080), so a touchpad-bound mouse/pad action only jittered a few units instead of moving. Scale them into the STICK_PAD range in each HID driver's input() override (y flipped so finger-up is positive), as the evdev path already does. The DS5 mapping is by analogy and UNTESTED -- no DualSense hardware; the DS5HidRawController touchpad (unscaled, and cpad stored as unsigned c_uint16) is left for a follow-up noted in TODO. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Record the DS4/DS5 rough edges left after the HID driver was made functional: asymmetric stick highlighting, generic (non-DualShock) input icons, missing rumble and lightbar, and the unverified DS5 / unscaled DS5HidRawController touchpad. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The DS4 previously had only the invasive libusb path (USB, claims the interface) and a "far from optimal" evdev fallback for Bluetooth. Add a hidraw driver for Bluetooth, mirroring the DS5's DS5HidRawController: it opens /dev/hidraw, reads the full 0x11 report, and decodes it with the SAME C HID decoder DS4Controller uses over USB, with every byte offset shifted by the 2-byte BT report header. It reuses the touchpad coordinate scaling and CPADTOUCH handling, and the mapper rstick/dpad guards already cover it. init() prefers it over the evdev fallback for Bluetooth when hiddrv is enabled; the USB path is unchanged. Verified over Bluetooth on a real DS4: buttons, both sticks (+ clicks), triggers, d-pad and the touchpad (id=0x11, len=78 streaming after the 0x02 calibration feature-report read). Gyro is decoded but unverified; output reports (rumble / lightbar) are a follow-up (need the BT CRC32 wrapper) -- both noted in TODO. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Some Steam Controller (Triton) firmware switched the default motion stream from
0x42 (with on-controller quaternion) to 0x45 ("TritonMTUNoQuat"). We parse only
0x42 and silently ignore everything else, so such a firmware would present as a
mysteriously dead controller. Add a cheap, once-per-controller warning when a 0x45
report is seen, so the situation is diagnosable. Ref:
ValveSoftware/steam-for-linux#13255.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The DS4 gyro/IMU is decoded but never confirmed to work (USB or Bluetooth), and the new DS4HidRawController is input-only -- rumble and lightbar over Bluetooth need output reports with the BT CRC32 wrapper, like the DS5 driver. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.