From f66a26a6931064b17e075915d804340bbc529d1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Mon, 15 Jun 2026 04:33:18 +0200 Subject: [PATCH 01/98] gui: offer all 4 grips + right-stick press in modeshift combo chooser 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 --- scc/gui/modeshift_editor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scc/gui/modeshift_editor.py b/scc/gui/modeshift_editor.py index a3988128d..0fae0de7d 100644 --- a/scc/gui/modeshift_editor.py +++ b/scc/gui/modeshift_editor.py @@ -34,6 +34,8 @@ class ModeshiftEditor(Editor): (None, None), (SCButtons.LGRIP, _("Left Grip")), (SCButtons.RGRIP, _("Right Grip")), + (SCButtons.LGRIP2, _("Left Grip 2")), + (SCButtons.RGRIP2, _("Right Grip 2")), (SCButtons.LB, _("Left Bumper")), (SCButtons.RB, _("Right Bumper")), (None, None), From 6fbf0e6cb83978ef5669bbe4c081688a93749098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Mon, 15 Jun 2026 04:41:43 +0200 Subject: [PATCH 02/98] gui: add 2nd grips + right-stick press to remaining button choosers 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 --- ui/ae/dpad.ui | 8 ++++++++ ui/ae/special_action.ui | 8 ++++++++ ui/controller_settings.ui | 8 ++++++++ 3 files changed, 24 insertions(+) diff --git a/ui/ae/dpad.ui b/ui/ae/dpad.ui index 3fa2fc8bc..4983a72d9 100644 --- a/ui/ae/dpad.ui +++ b/ui/ae/dpad.ui @@ -85,6 +85,14 @@ Right Grip RGRIP + + Left Grip 2 + LGRIP2 + + + Right Grip 2 + RGRIP2 + - x diff --git a/ui/ae/special_action.ui b/ui/ae/special_action.ui index d2f8a92b9..82db62a1d 100644 --- a/ui/ae/special_action.ui +++ b/ui/ae/special_action.ui @@ -124,6 +124,14 @@ Right Grip RGRIP + + Left Grip 2 + LGRIP2 + + + Right Grip 2 + RGRIP2 + - x diff --git a/ui/controller_settings.ui b/ui/controller_settings.ui index 615322a86..d9b968cd3 100644 --- a/ui/controller_settings.ui +++ b/ui/controller_settings.ui @@ -76,6 +76,14 @@ Right Grip RGRIP + + Left Grip 2 + LGRIP2 + + + Right Grip 2 + RGRIP2 + - x From 0f54df0ec5282c09ac9d0c9ed9b7b75c82ccba74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Mon, 15 Jun 2026 05:32:37 +0200 Subject: [PATCH 03/98] sc2/gui: bind capacitive stick touch; label left stick 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 --- scc/gui/modeshift_editor.py | 2 ++ ui/ae/dpad.ui | 8 ++++++++ ui/ae/special_action.ui | 8 ++++++++ ui/controller_settings.ui | 8 ++++++++ 4 files changed, 26 insertions(+) diff --git a/scc/gui/modeshift_editor.py b/scc/gui/modeshift_editor.py index 0fae0de7d..470b19f8e 100644 --- a/scc/gui/modeshift_editor.py +++ b/scc/gui/modeshift_editor.py @@ -50,6 +50,8 @@ class ModeshiftEditor(Editor): (SCButtons.RPAD, _("Right Pad Pressed")), (SCButtons.LPADTOUCH, _("Left Pad Touched")), (SCButtons.RPADTOUCH, _("Right Pad Touched")), + (SCButtons.LSTICKTOUCH, _("Left Stick Touched")), + (SCButtons.RSTICKTOUCH, _("Right Stick Touched")), ) def __init__(self, app, callback): diff --git a/ui/ae/dpad.ui b/ui/ae/dpad.ui index 4983a72d9..7d6f12fb8 100644 --- a/ui/ae/dpad.ui +++ b/ui/ae/dpad.ui @@ -125,6 +125,14 @@ Right Stick Press RSTICKPRESS + + Left Stick Touched + LSTICKTOUCH + + + Right Stick Touched + RSTICKTOUCH + Left Pad Press LPADPRESS diff --git a/ui/ae/special_action.ui b/ui/ae/special_action.ui index 82db62a1d..954cac55c 100644 --- a/ui/ae/special_action.ui +++ b/ui/ae/special_action.ui @@ -164,6 +164,14 @@ Right Stick Press RSTICKPRESS + + Left Stick Touched + LSTICKTOUCH + + + Right Stick Touched + RSTICKTOUCH + Left Pad Press LPADPRESS diff --git a/ui/controller_settings.ui b/ui/controller_settings.ui index d9b968cd3..5c97d3129 100644 --- a/ui/controller_settings.ui +++ b/ui/controller_settings.ui @@ -116,6 +116,14 @@ Right Stick Press RSTICKPRESS + + Left Stick Touched + LSTICKTOUCH + + + Right Stick Touched + RSTICKTOUCH + Left Pad Press LPADPRESS From 16198312b5b5ce0530eb7da149282923db60dbca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Mon, 15 Jun 2026 05:41:23 +0200 Subject: [PATCH 04/98] sc2/gui: bind capacitive handle grip sensing (Steam Controller only) 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 gui: rename grip-sensing labels to "Grip Touched" 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 --- scc/gui/modeshift_editor.py | 2 ++ ui/ae/dpad.ui | 8 ++++++++ ui/ae/special_action.ui | 8 ++++++++ ui/controller_settings.ui | 8 ++++++++ 4 files changed, 26 insertions(+) diff --git a/scc/gui/modeshift_editor.py b/scc/gui/modeshift_editor.py index 470b19f8e..c56ef106a 100644 --- a/scc/gui/modeshift_editor.py +++ b/scc/gui/modeshift_editor.py @@ -52,6 +52,8 @@ class ModeshiftEditor(Editor): (SCButtons.RPADTOUCH, _("Right Pad Touched")), (SCButtons.LSTICKTOUCH, _("Left Stick Touched")), (SCButtons.RSTICKTOUCH, _("Right Stick Touched")), + (SCButtons.LGRIPTOUCH, _("Left Grip Touched")), + (SCButtons.RGRIPTOUCH, _("Right Grip Touched")), ) def __init__(self, app, callback): diff --git a/ui/ae/dpad.ui b/ui/ae/dpad.ui index 7d6f12fb8..14d9e304b 100644 --- a/ui/ae/dpad.ui +++ b/ui/ae/dpad.ui @@ -133,6 +133,14 @@ Right Stick Touched RSTICKTOUCH + + Left Grip Touched + LGRIPTOUCH + + + Right Grip Touched + RGRIPTOUCH + Left Pad Press LPADPRESS diff --git a/ui/ae/special_action.ui b/ui/ae/special_action.ui index 954cac55c..b55e6dbec 100644 --- a/ui/ae/special_action.ui +++ b/ui/ae/special_action.ui @@ -172,6 +172,14 @@ Right Stick Touched RSTICKTOUCH + + Left Grip Touched + LGRIPTOUCH + + + Right Grip Touched + RGRIPTOUCH + Left Pad Press LPADPRESS diff --git a/ui/controller_settings.ui b/ui/controller_settings.ui index 5c97d3129..3a9cfe118 100644 --- a/ui/controller_settings.ui +++ b/ui/controller_settings.ui @@ -124,6 +124,14 @@ Right Stick Touched RSTICKTOUCH + + Left Grip Touched + LGRIPTOUCH + + + Right Grip Touched + RGRIPTOUCH + Left Pad Press LPADPRESS From 785eb1afd5ea4afc58f8836c669bf878519322b5 Mon Sep 17 00:00:00 2001 From: Martin Rys Date: Sun, 2 Aug 2026 22:58:58 +0200 Subject: [PATCH 05/98] gui: dedicated v2 (Steam Controller 2025) image + stick-touch/grip-sensor 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//.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 docs: correct Steam Controller 2 release year to 2026 in README Matches the year correction already applied to the code comments/config. Co-Authored-By: Claude Opus 4.8 --- scc/gui/app.py | 15 +++++- scc/gui/binding_editor.py | 5 +- scc/gui/modeshift_editor.py | 53 ++++++++++++++++++++- ui/app.ui | 32 ++++++++++--- ui/modeshift_editor.ui | 95 +++++++++++++++++++++++++++++++++++++ 5 files changed, 189 insertions(+), 11 deletions(-) diff --git a/scc/gui/app.py b/scc/gui/app.py index bf70da88d..10847646a 100644 --- a/scc/gui/app.py +++ b/scc/gui/app.py @@ -286,6 +286,8 @@ def apply_gui_config_buttons(self, config) -> None: btDPAD = self.builder.get_object("btDPAD") btGYRO = self.builder.get_object("btGYRO") btC = self.builder.get_object("btC") + btLGRIPTOUCH = self.builder.get_object("btLGRIPTOUCH") + btRGRIPTOUCH = self.builder.get_object("btRGRIPTOUCH") buttons = ControllerImage.get_names(config.get("buttons", {})) axes = ControllerImage.get_names(config.get("axes", {})) @@ -297,10 +299,18 @@ def apply_gui_config_buttons(self, config) -> None: if w: w.set_sensitive(nameof(b) in buttons) # Buttons (as GTK Widgets) + # A controller may ship its own side-panel icons under + # images//.svg (e.g. images/sc2/); use them when + # present, else fall back to the shared default icon. + bg = config.get("gui", {}).get("background") for b in self.button_widgets: try: w = self.button_widgets[b] icon, trash = ControllerManager.get_button_icon(config, b, True) + if bg: + cand = os.path.join(self.imagepath, bg, nameof(b) + ".svg") + if os.path.exists(cand): + icon = cand w.icon.set_from_file(icon) except Exception: pass @@ -323,8 +333,9 @@ def apply_gui_config_buttons(self, config) -> None: # TODO: Maybe actual detection w.set_sensitive(gyros) - for w in (btC, btCPAD, btDPAD, btGYRO): - w.set_visible(w.get_sensitive()) + for w in (btC, btCPAD, btDPAD, btGYRO, btLGRIPTOUCH, btRGRIPTOUCH): + if w: + w.set_visible(w.get_sensitive()) # Re-layout if needed expected_layout = "default" diff --git a/scc/gui/binding_editor.py b/scc/gui/binding_editor.py index fa01d5243..98e44fa68 100644 --- a/scc/gui/binding_editor.py +++ b/scc/gui/binding_editor.py @@ -101,7 +101,10 @@ def set_action(self, profile: Profile, id: SCButtons | SCSticks | SCPads, action self.button_widgets[id.name].update() elif id in BUTTONS: before, profile.buttons[id] = profile.buttons[id], action - self.button_widgets[id].update() + # Some buttons (e.g. the stick-touch sensors, set via the Touch tab) + # have no on-screen widget; just store the action for those. + if id in self.button_widgets: + self.button_widgets[id].update() elif id in SCTriggers: side = id before, profile.triggers[side] = profile.triggers[side], action diff --git a/scc/gui/modeshift_editor.py b/scc/gui/modeshift_editor.py index c56ef106a..2e646c0e9 100644 --- a/scc/gui/modeshift_editor.py +++ b/scc/gui/modeshift_editor.py @@ -66,6 +66,10 @@ def __init__(self, app, callback): self.current_page = 0 self.actions = ([], [], []) self.nomods = [NoAction(), NoAction(), NoAction()] + # Touch tab: bound to the capacitive stick-touch sensor (a separate + # button), only shown when editing a stick-press action. + self.touch_id = None + self.touch_action = NoAction() self.setup_widgets() def setup_widgets(self): @@ -247,9 +251,16 @@ def on_chosen(id, action): def on_ntbMore_switch_page(self, ntb, box, index): self.current_page = index + cb = self.builder.get_object("cbButtonChooser") + add = self.builder.get_object("btAddAction") + if index >= len(self.actions): + # Touch tab: no mode-shift grid / button chooser here + cb.set_sensitive(False) + add.set_sensitive(False) + return self._fill_button_chooser() - self.builder.get_object("cbButtonChooser").set_sensitive(box.get_sensitive()) - self.builder.get_object("btAddAction").set_sensitive(box.get_sensitive()) + cb.set_sensitive(box.get_sensitive()) + add.set_sensitive(box.get_sensitive()) def on_nomodbt_clicked(self, button, *a): actionButton = self.action_widgets[self.current_page][1] @@ -267,6 +278,20 @@ def on_nomodclear_clicked(self, button, *a): actionButton = self.action_widgets[self.current_page][1] actionButton.set_label(self.nomods[self.current_page].describe(self.mode)) + def on_btTouch_clicked(self, *a): + """'Touch' tab: edit the action bound to the stick-touch sensor.""" + def on_chosen(id, action): + self.touch_action = action + self.builder.get_object("btTouch").set_label(action.describe(self.mode)) + + ae = self._choose_editor(self.touch_action, on_chosen) + ae.set_input(self.touch_id, self.touch_action, mode=Action.AC_BUTTON) + ae.show(self.window) + + def on_btClearTouch_clicked(self, *a): + self.touch_action = NoAction() + self.builder.get_object("btTouch").set_label(self.touch_action.describe(self.mode)) + def on_btAddAction_clicked(self, *a): cbButtonChooser = self.builder.get_object("cbButtonChooser") item = cbButtonChooser.get_model().get_value(cbButtonChooser.get_active_iter(), 0) @@ -310,6 +335,9 @@ def on_btOK_clicked(self, *a): """Handler for OK button""" if self.ac_callback is not None: self.ac_callback(self.id, self._make_action()) + if self.touch_id is not None: + # Touch tab maps to a separate input (the stick-touch sensor) + self.ac_callback(self.touch_id, self.touch_action) self.close() def _make_action(self): @@ -416,3 +444,24 @@ def set_input(self, id, action, mode=None): if mode != Action.AC_BUTTON: for w in ("vbHold", "vbDoubleClick", "lblHold", "lblDoubleClick"): self.builder.get_object(w).set_sensitive(False) + + # Touch tab: only for the stick-press inputs; binds the capacitive + # stick-touch sensor (a separate button). Hidden for everything else. + # (Editor.show() uses window.show(), not show_all, so hiding sticks.) + touch_for = {SCButtons.STICKPRESS: SCButtons.LSTICKTOUCH, + SCButtons.RSTICKPRESS: SCButtons.RSTICKTOUCH} + self.touch_id = touch_for.get(id) if id in SCButtons.__members__.values() else None + vbTouch = self.builder.get_object("vbTouch") + lblTouch = self.builder.get_object("lblTouch") + if self.touch_id is not None: + try: + self.touch_action = self.app.current.buttons[self.touch_id] or NoAction() + except (KeyError, TypeError): + self.touch_action = NoAction() + self.builder.get_object("btTouch").set_label(self.touch_action.describe(self.mode)) + vbTouch.set_visible(True) + lblTouch.set_visible(True) + else: + self.touch_action = NoAction() + vbTouch.set_visible(False) + lblTouch.set_visible(False) diff --git a/ui/app.ui b/ui/app.ui index f00b7335c..3b1cac4e7 100644 --- a/ui/app.ui +++ b/ui/app.ui @@ -400,15 +400,15 @@ - + 1 1 - 0 + 1 170 - + 1 1 170 @@ -446,15 +446,15 @@ - + 1 1 - 0 + 1 170 - + 1 1 170 @@ -486,6 +486,16 @@ bottom 12 + + + 220 + 1 + 1 + + + + + 1 @@ -535,6 +545,16 @@ 3 2 + + + 220 + 1 + 1 + + + + + diff --git a/ui/modeshift_editor.ui b/ui/modeshift_editor.ui index 76acc456d..8e89d7640 100644 --- a/ui/modeshift_editor.ui +++ b/ui/modeshift_editor.ui @@ -427,6 +427,101 @@ + + + True + False + vertical + + + True + False + 10 + 10 + 10 + 5 + 15 + + + 150 + True + False + When touched + 0 + + + 0 + 0 + + + + + True + True + True + True + + + + 1 + 0 + + + + + True + True + True + + + + True + False + gtk-clear + + + + + 2 + 0 + + + + + False + True + 0 + + + + + True + False + 10 + True + Action triggered when the capacitive sensor under the stick is touched (no press needed). + True + + + False + True + 1 + + + + + + + True + False + True + Touch + + + 3 + False + + 0 2 From 9445cea1c2b7e4e41c5ad026ecaf9c84855cbc16 Mon Sep 17 00:00:00 2001 From: Martin Rys Date: Sun, 19 Jul 2026 21:41:24 +0200 Subject: [PATCH 06/98] fix: Ignore X11 errors - 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. --- scc/lib/xwrappers.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/scc/lib/xwrappers.py b/scc/lib/xwrappers.py index 26107ec3d..fb559ef16 100644 --- a/scc/lib/xwrappers.py +++ b/scc/lib/xwrappers.py @@ -18,6 +18,7 @@ from ctypes import ( CDLL, + CFUNCTYPE, POINTER, Structure, byref, @@ -50,6 +51,21 @@ def _load_lib(*names): libX11 = _load_lib("libX11.so", "libX11.so.6") libXext = _load_lib("libXext.so", "libXext.so.6") +# By default Xlib aborts the whole process on an X protocol error (e.g. BadWindow +# when querying a stale/invalid window id - which happens under XWayland, where +# the focused window may not be an X window). Install a no-op error handler so the +# offending call fails quietly (callers already cope with missing data) instead of +# killing the process (this previously crashed scc-osd-daemon from Autoswitch). +_XErrorHandler = CFUNCTYPE(c_int, c_void_p, c_void_p) + + +def _ignore_x_error(display, error): + return 0 + + +_x_error_handler = _XErrorHandler(_ignore_x_error) # keep a reference so it isn't GC'd +libX11.XSetErrorHandler(_x_error_handler) + # Types XID = c_ulong From 9c8452c756219763953c53bfa43068cbc69be097 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Fri, 19 Jun 2026 00:07:43 +0200 Subject: [PATCH 07/98] osd: per-controller binding display with a v2 (Steam Controller 2025) 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-.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_ connector groups. Edit the art asset in Inkscape and re-run to regenerate. Co-Authored-By: Claude Opus 4.8 --- images/binding-display-sc2.svg | 38 ++++++ scc/osd/binding_display.py | 220 ++++++++++++++++++++++++++---- tools/binding-display-sc2-art.svg | 38 ++++++ tools/gen_binding_display.py | 150 ++++++++++++++++++++ 4 files changed, 422 insertions(+), 24 deletions(-) create mode 100644 images/binding-display-sc2.svg create mode 100644 tools/binding-display-sc2-art.svg create mode 100644 tools/gen_binding_display.py diff --git a/images/binding-display-sc2.svg b/images/binding-display-sc2.svg new file mode 100644 index 000000000..2baf69256 --- /dev/null +++ b/images/binding-display-sc2.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + X \ No newline at end of file diff --git a/scc/osd/binding_display.py b/scc/osd/binding_display.py index bc04c8003..f5c09d18a 100644 --- a/scc/osd/binding_display.py +++ b/scc/osd/binding_display.py @@ -9,6 +9,7 @@ import base64 import logging import os +import re import sys from enum import IntEnum from typing import Self @@ -17,7 +18,7 @@ from scc.actions import Action, AxisAction, DPadAction, MouseAction, MultiAction, XYAction from scc.config import Config -from scc.constants import SCButtons, SCTriggers, SCPads +from scc.constants import DPAD, LEFT, RIGHT, SCButtons, SCTriggers, SCPads from scc.gui.daemon_manager import DaemonManager from scc.gui.svg_widget import SVGEditor, SVGWidget from scc.modifiers import DoubleclickModifier, ModeModifier @@ -45,6 +46,7 @@ def __init__(self, config=None): self.group = None self.limits = {} self.background = None + self._layout_key = None # gui "background" name -> per-controller LAYOUTS self._eh_ids = [] self._stick = 0, 0 @@ -53,8 +55,22 @@ def __init__(self, config=None): self.c.set_name("osd-keyboard-container") def on_profile_changed(self, daemon: DaemonManager, filename: str): - profile = Profile(TalkingActionParser()).load(filename) - Generator(SVGEditor(self.background), profile) + self._draw_profile(filename) + + def _draw_profile(self, filename): + """(Re)draws the binding boxes for the given profile onto the current + background. No-op until the background image has been built, which + happens in on_daemon_connected() once the connected controller (and + thus which per-controller image to load) is known. + """ + if self.background is None or not filename: + return + try: + profile = Profile(TalkingActionParser()).load(filename) + except Exception: + log.exception("Failed to load profile %s", filename) + return + Generator(SVGEditor(self.background), profile, self._layout_key) def use_daemon(self, d): """Allows (re)using already existing DaemonManager instance in same process.""" @@ -114,6 +130,16 @@ def success(*a): self.on_failed_to_lock("Controller not connected") return + # The binding-display image is per-controller, so it can only be + # resolved now that we know which controller is connected. show() left + # the window unbuilt; build it here and draw the profile the controller + # already has (the daemon may have reported the profile before the + # window existed, so relying on the profile-changed signal alone would + # leave the boxes blank until the next profile change). + if self.background is None: + self._build_and_show(self._resolve_image(c)) + self._draw_profile(c.get_profile()) + self._eh_ids += [ (c, c.connect("event", self.on_event)), (c, c.connect("lost", self.on_controller_lost)), @@ -123,6 +149,53 @@ def success(*a): locks = ["RB", "LB", self.args.cancel_with] c.lock(success, self.on_failed_to_lock, *locks) + def _resolve_image(self, controller): + """Picks the binding-display SVG for the connected controller. + + Order of preference: + 1. an explicit image given on the command line + 2. "binding_display" filename set in the controller's gui config + 3. convention: binding-display-.svg + (looked up in ~/.config/scc first, then the bundled images dir) + 4. the generic binding-display.svg (user override, then bundled) + The generic fallback keeps controllers without a dedicated layout + working - they just render on the old template, as before. + """ + images_path = os.path.join(get_share_path(), "images") + config_path = get_config_path() + candidates = [] + # 1. explicit command-line image (default equals self.bdisplay) + cli = getattr(self.args, "image", None) + if cli and cli != self.bdisplay: + candidates.append(cli) + # 2./3. per-controller, from the controller's gui config + try: + config = controller.load_gui_config(images_path) + except Exception: + log.exception("Failed to load controller gui config") + config = None + gui = (config or {}).get("gui") or {} + explicit = gui.get("binding_display") + if explicit: + if "/" in explicit: + candidates.append(explicit) + else: + candidates.append(os.path.join(config_path, explicit)) + candidates.append(os.path.join(images_path, explicit)) + background = gui.get("background") + self._layout_key = background # selects the per-controller box layout + if background: + fname = "binding-display-%s.svg" % (background,) + candidates.append(os.path.join(config_path, fname)) + candidates.append(os.path.join(images_path, fname)) + # 4. generic fallback (already user-override-then-bundled) + candidates.append(self.bdisplay) + for path in candidates: + if path and os.path.exists(path): + log.debug("Using binding-display image: %s", path) + return path + return self.bdisplay + def quit(self, code=-1): if self.get_controller(): self.get_controller().unlock_all() @@ -132,14 +205,22 @@ def quit(self, code=-1): OSDWindow.quit(self, code) def show(self, *a): - if self.background is None: - self.realize() - self.background = SVGWidget(self.args.image, init_hilighted=True) - self.c.append(self.background) - self.set_child(self.c) - + # The background image is per-controller and only known once the daemon + # reports the connected controller, so the real show is deferred to + # on_daemon_connected() -> _build_and_show(). Until the background + # exists this is a no-op (run() calls show() before the daemon is up). + if self.background is not None: + OSDWindow.show(self, *a) + self.move(*self.compute_position()) + + def _build_and_show(self, image): + """Builds the window around the given background image and shows it.""" + self.realize() + self.background = SVGWidget(image, init_hilighted=True) + self.c.append(self.background) + self.set_child(self.c) self.position = tuple(int(value) for value in self.compute_position()) - OSDWindow.show(self, *a) + OSDWindow.show(self) def on_event(self, daemon, what, data): """Called when button press, button release or stick / pad update is @@ -199,8 +280,10 @@ class Box: SPACING = 2 MIN_WIDTH = 100 MIN_HEIGHT = 50 + MIN_SCALE = 0.4 # smallest font shrink before lines may overflow anyway - def __init__(self, anchor_x, anchor_y, align, name, min_width=MIN_WIDTH, min_height=MIN_HEIGHT, max_width=999999): + def __init__(self, anchor_x, anchor_y, align, name, min_width=MIN_WIDTH, min_height=MIN_HEIGHT, + max_width=999999, max_height=999999): self.name = name self.lines = [] self.anchor = anchor_x, anchor_y @@ -209,6 +292,7 @@ def __init__(self, anchor_x, anchor_y, align, name, min_width=MIN_WIDTH, min_hei self.x, self.y = 0, 0 self.min_width = min_width self.max_width = max_width + self.max_height = max_height self.min_height = min_height def to_string(self): @@ -237,7 +321,7 @@ def add(self, icon, context, action: Action): action = action.strip() if isinstance(action, MenuAction): - if self.name == "bcs" and action.menu_id == "Default.menu": + if self.name in ("bcs", "system") and action.menu_id == "Default.menu": # Special case, this action is expected in every profile, # so there is no need to draw it here return LineCollection() @@ -277,6 +361,17 @@ def calculate(self, gen): self.width += 2 * self.PADDING + self.icount * (gen.line_height + self.SPACING) self.width = min(self.width, self.max_width) self.height = max(self.height, self.min_height) + # Auto-scale the font for this box so all its lines fit within max_height. + # place() draws every line regardless of box height, so without this a + # crowded box (e.g. a stick bound to a big radial/dpad, or mode-heavy face + # buttons) overflows downward off the box and the screen. + content = self.height - 2 * self.PADDING + avail = self.max_height - 2 * self.PADDING + if content > avail and content > 0: + self.scale = max(self.MIN_SCALE, avail / content) + self.height = self.max_height + else: + self.scale = 1.0 anchor_x, anchor_y = self.anchor if (self.align & Align.TOP) != 0: @@ -307,9 +402,12 @@ def place(self, gen, root): y=self.y, ) + scale = getattr(self, "scale", 1.0) + lh = gen.line_height * scale + text_style = gen.label_style(scale) y = self.y + self.PADDING for line in self.lines: - h = gen.line_height + h = lh x = self.x + self.PADDING for icon in line.icons: image = find_image(icon) @@ -327,12 +425,12 @@ def place(self, gen, root): x += h + self.SPACING x = self.x + self.PADDING + self.icount * (h + self.SPACING) y += h - txt = SVGEditor.add_element(root, "text", x=x, y=y, style=gen.label_template.attrib["style"]) - max_line_width = self.max_width - gen.line_height - self.PADDING - while line.text and line.get_size(gen)[0] > max_line_width: + txt = SVGEditor.add_element(root, "text", x=x, y=y, style=text_style) + max_line_width = self.max_width - lh - self.PADDING + while line.text and line.get_size(gen)[0] * scale > max_line_width: line.text = line.text[:-1] SVGEditor.set_text(txt, line.text) - y += self.SPACING + y += self.SPACING * scale def place_marker(self, gen, root): x1, y1 = self.x, self.y @@ -380,17 +478,97 @@ def place_marker(self, gen, root): ) +# --- per-controller binding-display layouts -------------------------------- +# Which controls go in which box is semantic, so it lives here rather than in +# the template SVG (which only supplies marker positions + the canvas). Keyed by +# the controller's gui "background" name; controllers without an entry fall back +# to the v1 layout (Generator._build_v1). Box positions are auto-placed from the +# Align flags; size caps are fractions of the canvas. Only bound controls draw a +# line and a box with no bound controls is hidden (Generator._build_layout), so +# every variant (grip-squeeze, stick/pad touch & press, ...) can be listed and +# simply stays invisible until the user binds it. +_B, _T, _P, _S = Action.AC_BUTTON, Action.AC_TRIGGER, Action.AC_PAD, Action.AC_STICK + + +def _btn(name): + return lambda p: p.buttons.get(SCButtons[name]) + + +def _pad(side): + return lambda p: p.pads.get(side) + + +def _trig(side): + return lambda p: p.triggers.get(side) + + +def _stick(p): + return p.stick + + +def _rstick(p): + return getattr(p, "rstick", None) + + +LAYOUTS = { + # Steam Controller v2 (2026) -- Steam Deck control set: two sticks, a D-pad, + # two trackpads, four system buttons, back paddles + grip-squeeze sensors. + # Six boxes: four corners + top/bottom centre, controller art in the middle. + "sc2": [ + dict(name="system", align=Align.TOP, ax=0, max_width_f=0.4, max_height_f=0.22, + controls=[("BACK", _B, _btn("BACK")), ("C", _B, _btn("C")), + ("START", _B, _btn("START")), ("DOTS", _B, _btn("DOTS"))]), + dict(name="lshoulder", align=Align.LEFT | Align.TOP, + min_width_f=0.18, max_width_f=0.27, max_height_f=0.42, + controls=[("LT", _T, _trig(LEFT)), ("LB", _B, _btn("LB")), + ("LGRIP", _B, _btn("LGRIP")), ("LGRIP2", _B, _btn("LGRIP2")), + ("LGRIPTOUCH", _B, _btn("LGRIPTOUCH"))]), + dict(name="rshoulder", align=Align.RIGHT | Align.TOP, + min_width_f=0.18, max_width_f=0.27, max_height_f=0.42, + controls=[("RT", _T, _trig(RIGHT)), ("RB", _B, _btn("RB")), + ("RGRIP", _B, _btn("RGRIP")), ("RGRIP2", _B, _btn("RGRIP2")), + ("RGRIPTOUCH", _B, _btn("RGRIPTOUCH"))]), + dict(name="lthumb", align=Align.LEFT | Align.BOTTOM, + min_width_f=0.18, max_width_f=0.27, max_height_f=0.42, + controls=[("STICK", _S, _stick), ("DPAD", _P, _pad(DPAD)), + ("LPAD", _P, _pad(LEFT))]), + dict(name="rthumb", align=Align.RIGHT | Align.BOTTOM, + min_width_f=0.18, max_width_f=0.27, max_height_f=0.42, + controls=[("RSTICK", _S, _rstick), ("RPAD", _P, _pad(RIGHT))]), + dict(name="face", align=Align.BOTTOM, ax=0, max_width_f=0.4, max_height_f=0.22, + controls=[("A", _B, _btn("A")), ("B", _B, _btn("B")), + ("X", _B, _btn("X")), ("Y", _B, _btn("Y"))]), + ], +} + + class Generator: PADDING = 10 - def __init__(self, editor, profile: Profile): + def __init__(self, editor, profile: Profile, layout_key=None): background = SVGEditor.get_element(editor, "background") self.label_template = SVGEditor.get_element(editor, "label_template") self.line_height = int(float(self.label_template.attrib.get("height") or 8)) self.char_width = int(float(self.label_template.attrib.get("width") or 8)) self.full_width = int(float(background.attrib.get("width") or 800)) self.full_height = int(float(background.attrib.get("height") or 800)) + self._label_style = self.label_template.attrib.get("style", "") + m = re.search(r"font-size:\s*([\d.]+)", self._label_style) + self.font_size = float(m.group(1)) if m else self.line_height * 1.45 + root = SVGEditor.get_element(editor, "root") + layout = LAYOUTS.get(layout_key) + if layout is None: + self._build_v1(profile, root) + else: + self._build_layout(profile, root, layout) + + editor.commit() + + def _build_v1(self, profile, root): + """The original 5-box layout (Steam Controller v1: one stick, no D-pad, + three system buttons). Used for v1 and any controller without a dedicated + entry in LAYOUTS.""" boxes = [] box_bcs = Box(0, self.PADDING, Align.TOP, "bcs") box_bcs.add("BACK", Action.AC_BUTTON, profile.buttons.get(SCButtons.BACK)) @@ -443,10 +621,6 @@ def __init__(self, editor, profile: Profile): box_lstick.add("LSTICK", Action.AC_STICK, profile.lstick) boxes.append(box_lstick) - w = int(float(background.attrib.get("width") or 800)) - h = int(float(background.attrib.get("height") or 800)) - - root = SVGEditor.get_element(editor, "root") for b in boxes: b.calculate(self) @@ -465,8 +639,6 @@ def __init__(self, editor, profile: Profile): for b in boxes: b.place(self, root) - editor.commit() - def equal_width(self, *boxes) -> None: """Sets width of all passed boxes to width of widest box""" width = 0 diff --git a/tools/binding-display-sc2-art.svg b/tools/binding-display-sc2-art.svg new file mode 100644 index 000000000..cf4caccbe --- /dev/null +++ b/tools/binding-display-sc2-art.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tools/gen_binding_display.py b/tools/gen_binding_display.py new file mode 100644 index 000000000..b3c33ae76 --- /dev/null +++ b/tools/gen_binding_display.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Generate the binding-display layout SVG for the v2 Steam Controller. + +The OSD "Display Current Bindings" window (scc/osd/binding_display.py) draws each +control's binding into boxes laid out around a controller picture, using a +per-controller template SVG (binding-display-.svg). This tool +assembles that template for the v2 controller. + +What it emits (images/binding-display-sc2.svg), all required by Generator: + - a 1280x720 canvas with `background` (sizes the layout), `label_template` + (label font/metrics) and `root` (boxes are drawn into it) elements; + - the restyled controller drawing, inlined verbatim from the source asset + tools/binding-display-sc2-art.svg (edit that in Inkscape to change the + look -- it carries its own placement transform); + - the six `markers_` groups (system/lshoulder/rshoulder/lthumb/rthumb/ + face -- the v2 box set in binding_display.py LAYOUTS["sc2"]), each with + circles placed at the matching AREA_* anchor centres of sc2.svg, mapped + into canvas coordinates. Each box draws a connector line to up to two. + +Markers come from the GUI image's (sc2.svg) AREA_* anchors -- they live in an +untransformed layer in final display coords, so their centres map into the +canvas via the ART_MAX_*_FRAC transform below. The art asset was placed to +register with that mapping; if you change ART_MAX_*_FRAC, re-place the art. + +Run from repo root: python3 tools/gen_binding_display.py +""" +import os +import xml.etree.ElementTree as ET + +SVG = "http://www.w3.org/2000/svg" +SRC = "images/controller-images/sc2.svg" # AREA anchors for markers +ART = "tools/binding-display-sc2-art.svg" # restyled controller drawing +OUT = "images/binding-display-sc2.svg" + +CANVAS_W, CANVAS_H = 1280, 720 + +# Defines the controller's footprint in the canvas, i.e. the sc2.svg-coords -> +# canvas transform used to place the AREA-anchor markers. The art asset +# (tools/binding-display-sc2-art.svg) was drawn/placed to register with this. +ART_MAX_W_FRAC = 0.38 +ART_MAX_H_FRAC = 0.70 + +# Generator box name -> AREA_* anchors its connector lines point at. A box draws +# at most two lines; missing anchors are skipped (so a box may get 1 or 2). These +# match the v2 (sc2) box set in scc/osd/binding_display.py LAYOUTS["sc2"]. +MARKERS = { + "system": ["BACK", "START"], + "lshoulder": ["LB", "LGRIPTOUCH"], + "rshoulder": ["RB", "RGRIPTOUCH"], + "lthumb": ["STICK", "LPAD"], + "rthumb": ["RSTICK", "RPAD"], + "face": ["Y", "A"], +} + +ET.register_namespace("", SVG) + + +def q(tag): + return "{%s}%s" % (SVG, tag) + + +def parse_viewbox(svg): + vb = svg.get("viewBox") + if vb: + p = [float(x) for x in vb.replace(",", " ").split()] + return p[2], p[3] + return float(svg.get("width")), float(svg.get("height")) + + +def read_area_centers(root): + """AREA_ rects sit in an untransformed layer in display coords, so + their centres are read directly.""" + centers = {} + for rect in root.iter(q("rect")): + rid = rect.get("id") or "" + if rid.startswith("AREA_"): + x, y = float(rect.get("x")), float(rect.get("y")) + w, h = float(rect.get("width")), float(rect.get("height")) + centers[rid[5:]] = (x + w / 2.0, y + h / 2.0) + return centers + + +def main(): + if not os.path.exists(SRC): + raise SystemExit("run from repo root: %s not found" % SRC) + src = ET.parse(SRC).getroot() + cw, ch = parse_viewbox(src) # controller art size (685x493) + centers = read_area_centers(src) + + # Scale + centre the art in the free middle band. + s = min(CANVAS_W * ART_MAX_W_FRAC / cw, CANVAS_H * ART_MAX_H_FRAC / ch) + ox = (CANVAS_W - cw * s) / 2.0 + oy = (CANVAS_H - ch * s) / 2.0 + + def to_canvas(pt): + return ox + s * pt[0], oy + s * pt[1] + + out = ET.Element(q("svg"), { + "width": str(CANVAS_W), "height": str(CANVAS_H), + "viewBox": "0 0 %d %d" % (CANVAS_W, CANVAS_H), "version": "1.1"}) + ET.SubElement(out, q("defs"), {"id": "defs1"}) + + # background: drives the Generator's layout (it reads width/height) and gives + # the OSD a dark backdrop so the labels read. + ET.SubElement(out, q("rect"), { + "id": "background", "x": "0", "y": "0", + "width": str(CANVAS_W), "height": str(CANVAS_H), + "style": "fill:#000000;fill-opacity:0.85"}) + + # controller art: the hand-restyled drawing, inlined verbatim from the source + # asset (kept separate so this generator reproduces it and the look is edited + # in Inkscape). It carries its own placement transform, made to register with + # the AREA-anchor marker mapping above. + if not os.path.exists(ART): + raise SystemExit("%s not found" % ART) + for child in list(ET.parse(ART).getroot()): + if child.tag.split("}")[-1] == "defs": + continue + out.append(child) + + # foreground: label_template + root (boxes drawn here) + the marker groups. + root = ET.SubElement(out, q("g"), {"id": "root", "style": "display:inline"}) + lt = ET.SubElement(root, q("text"), { + "id": "label_template", + "style": "font-size:22px;font-family:'Ubuntu Mono';fill:#ffffff", + "width": "11", "height": "15", "x": "-100", "y": "-100"}) + lt.text = "X" + + missing = [] + for name, anchors in MARKERS.items(): + g = ET.SubElement(root, q("g"), {"id": "markers_%s" % name}) + for a in anchors: + if a not in centers: + missing.append(a) + continue + cx, cy = to_canvas(centers[a]) + ET.SubElement(g, q("circle"), { + "cx": "%g" % cx, "cy": "%g" % cy, "r": "5", + "style": "fill:#000000;fill-opacity:0;stroke:#06a400;stroke-width:1"}) + + ET.ElementTree(out).write(OUT, encoding="unicode", xml_declaration=True) + print("wrote", OUT) + print(" art inlined from %s; marker mapping scale %.3f at (%.1f, %.1f)" + % (ART, s, ox, oy)) + if missing: + print(" WARNING: AREA anchors not found in %s: %s" % (SRC, ", ".join(missing))) + + +if __name__ == "__main__": + main() From 0e3e6a92aba0cb252c362dc7bd44344c3a5644c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Fri, 19 Jun 2026 00:07:56 +0200 Subject: [PATCH 08/98] gui: "Edit Bindings" opens the OSD-keyboard bindings editor (sc-controller --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 --- scc/gui/app.py | 52 +++++++++++++++++++++++++++++++++++---- tests/test_app_startup.py | 20 +++++++++++---- 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/scc/gui/app.py b/scc/gui/app.py index 10847646a..11b1f4108 100644 --- a/scc/gui/app.py +++ b/scc/gui/app.py @@ -111,8 +111,9 @@ def __init__(self, gladepath: str = "/usr/share/scc", imagepath: str = "/usr/sha self.status = "unknown" self.context_menu_for = None self.daemon_changed_profile = False - self.osd_mode = False # In OSD mode, only active profile can be editted + self.osd_mode = False # legacy controller-driven OSD edit mode (retired; never enabled) self.osd_mode_mapper: OSDModeMapper | None = None + self.osk_edit_mode = False # --osd: open only the OSD-keyboard bindings editor self.background = None self.outdated_version = None self.profile_switchers: list[ProfileSwitcher] = [] @@ -1538,13 +1539,17 @@ def do_startup(self, *a) -> None: Gtk.IconTheme.get_for_display(display).add_search_path(self.imagepath) self.load_profile_list() self.setup_widgets() - if self.app.config["gui"]["enable_status_icon"]: + # No tray icon for the transient OSD-keyboard bindings editor launch. + if self.app.config["gui"]["enable_status_icon"] and not self.osk_edit_mode: self.setup_statusicon() self.set_daemon_status("unknown", True) def do_local_options(self, trash, lo): set_logging_level(lo.contains("verbose"), lo.contains("debug")) - self.osd_mode = lo.contains("osd") + # --osd opens the standalone OSD-keyboard bindings editor (do_activate) - + # the same dialog as Settings > Menus & Keyboard > Advanced. Used on both + # X11 and Wayland for a consistent, reliable single-window experience. + self.osk_edit_mode = lo.contains("osd") return -1 def do_command_line(self, cl: Gio.ApplicationCommandLine) -> int: @@ -1571,10 +1576,17 @@ def i_told_you_to_quit(*a) -> Never: return 0 def do_activate(self, *a) -> None: + if self.osk_edit_mode: + # "Edit Bindings" (OSD menu): show only the OSD-keyboard bindings + # editor, never the main window, and quit when it is closed - so it + # can't pile up duplicate main windows. + if not getattr(self, "_osk_editor", None): + self.open_osk_editor() + return first_activation = not self._activated self._activated = True self._cancel_startup_tray_wait() - if first_activation and not self.osd_mode and self.config["gui"]["minimize_on_start"] and self.statusicon: + if first_activation and self.config["gui"]["minimize_on_start"] and self.statusicon: self.window.set_visible(False) if not self.statusicon.get_property("active"): # Fall back to a window if the desktop cannot provide a tray within 3 seconds @@ -1597,6 +1609,36 @@ def _startup_tray_unavailable(self): self.window.present() return GLib.SOURCE_REMOVE + def open_osk_editor(self): + """Opens the standalone OSD-keyboard bindings editor (the same window + reachable from Settings > Menus & Keyboard > Advanced) as the only + window, quitting the app when it closes. Backs the OSD menu's + 'Edit Bindings' item.""" + import fcntl + + import scc.osd.osk_actions + from scc.actions import Action + from scc.gui.osk_binding_editor import OSKBindingEditor + # Single-instance: each "Edit Bindings" is its own process (the app is + # NON_UNIQUE), so without this a repeated launch would stack a second + # editor window. Hold an exclusive lock for our lifetime; if another + # launch already holds it, just quit instead of opening a duplicate. + try: + self._osk_lock = open(os.path.join(get_config_path(), ".osk-editor.lock"), "w") + fcntl.flock(self._osk_lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + log.info("OSD-keyboard bindings editor already open; not opening another") + self.quit() + return + except Exception: + log.exception("OSK editor single-instance lock failed; opening anyway") + # The OSD-keyboard profile uses OSK.* actions; register them so the + # editor can parse it (GlobalSettings does the same before opening it). + Action.register_all(scc.osd.osk_actions, prefix="OSK") + self._osk_editor = OSKBindingEditor(self) + self._osk_editor.window.connect("destroy", lambda *a: self.quit()) + self._osk_editor.show(None) + def remove_dot_profile(self) -> None: """Checks if first profile in list begins with dot and if yes, removes it. This is done to undo automatic addition that is done when daemon reports @@ -1679,7 +1721,7 @@ def aso(long_name, short_name, description, arg=None, flags=GLib.OptionFlags.IN_ aso("verbose", b"v", "Be verbose") aso("debug", b"d", "Be more verbose (debug mode)") - aso("osd", b"o", "OSD mode (OSD-controllable editor for current profile)") + aso("osd", b"o", "Open the OSD-keyboard bindings editor") def save_profile_selection(self, path) -> None: """Saves name of profile into config file""" diff --git a/tests/test_app_startup.py b/tests/test_app_startup.py index 3ff790c32..3958caa47 100644 --- a/tests/test_app_startup.py +++ b/tests/test_app_startup.py @@ -14,9 +14,9 @@ @pytest.fixture def app(monkeypatch): app = SimpleNamespace( - _activated=False, _startup_tray_timeout=None, osd_mode=False, + _activated=False, _startup_tray_timeout=None, osk_edit_mode=False, config={"gui": {"minimize_on_start": True}}, - window=Mock(), statusicon=Mock(), + window=Mock(), statusicon=Mock(), open_osk_editor=Mock(), ) app.statusicon.get_property.return_value = False for name in ("do_activate", "_cancel_startup_tray_wait", "on_startup_tray_active", @@ -52,19 +52,29 @@ def test_missing_tray_shows_fallback_window(app): assert app._startup_tray_timeout is None -@pytest.mark.parametrize("mode", ["disabled", "no_icon", "osd"]) +@pytest.mark.parametrize("mode", ["disabled", "no_icon"]) def test_startup_without_minimizing(app, mode): if mode == "disabled": app.config["gui"]["minimize_on_start"] = False elif mode == "no_icon": app.statusicon = None - else: - app.osd_mode = True app.do_activate() app.window.present.assert_called_once() assert app._startup_tray_timeout is None +def test_osd_mode_opens_only_keyboard_editor(app): + app.osk_edit_mode = True + app.do_activate() + app.open_osk_editor.assert_called_once() + app.window.present.assert_not_called() + app.window.set_visible.assert_not_called() + assert app._startup_tray_timeout is None + app._osk_editor = object() + app.do_activate() + app.open_osk_editor.assert_called_once() + + def test_reactivation_cancels_pending_startup_minimize(app): app.do_activate() app.do_activate() From 5ca89c9e9ccf4d2f9843b181e032497fa42d38c3 Mon Sep 17 00:00:00 2001 From: Martin Rys Date: Sun, 30 Aug 2026 07:13:18 +0200 Subject: [PATCH 09/98] gui: remove the abandoned controller-driven OSD edit mode (osd_mode) 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 --- scc/gui/action_editor.py | 7 -- scc/gui/ae/buttons.py | 3 - scc/gui/app.py | 96 +------------------ scc/gui/osd_mode.py | 193 --------------------------------------- 4 files changed, 5 insertions(+), 294 deletions(-) delete mode 100644 scc/gui/osd_mode.py diff --git a/scc/gui/action_editor.py b/scc/gui/action_editor.py index b03913a2e..749c834ba 100644 --- a/scc/gui/action_editor.py +++ b/scc/gui/action_editor.py @@ -185,9 +185,6 @@ def setup_widgets(self): ), ) - if self.app.osd_mode: - self.builder.get_object("entName").set_sensitive(False) - def load_components(self): """Loads list of editor components""" # Import and load components @@ -219,10 +216,6 @@ def on_Dialog_destroy(self, *a): if self._selected_component is not None: self._selected_component.hidden() - def on_Dialog_key_press_event(self, controller, keyval, keycode, state): - if self.app.osd_mode and keyval == 65471: - self.on_btOK_clicked() - def set_osd_enabled(self, value): """Sets value of OSD modifier checkbox, without firing any more events.""" self._recursing = True diff --git a/scc/gui/ae/buttons.py b/scc/gui/ae/buttons.py index 40f3db3db..0bd2b1204 100644 --- a/scc/gui/ae/buttons.py +++ b/scc/gui/ae/buttons.py @@ -50,9 +50,6 @@ def load(self): if not self.loaded: AEComponent.load(self) self.setup_image() - if self.app.osd_mode: - self.builder.get_object("btnGrabKey").set_sensitive(False) - self.builder.get_object("btnGrabAnother").set_sensitive(False) def area_action_selected(self, area, action): self.set_active_area(area) diff --git a/scc/gui/app.py b/scc/gui/app.py index 11b1f4108..ac6fc2600 100644 --- a/scc/gui/app.py +++ b/scc/gui/app.py @@ -77,7 +77,6 @@ class App(Gtk.Application, UserDataManager, BindingEditor): OBSERVE_COLOR = "#FF60A0FF" # ARGB CONFIG = "scc.config.json" RELEASE_URL = "https://github.com/C0rn3j/sc-controller/releases/tag/v%s" - OSD_MODE_PROF_NAME = ".scc-osd.profile_editor" def __init__(self, gladepath: str = "/usr/share/scc", imagepath: str = "/usr/share/scc/images"): Gtk.Application.__init__( @@ -111,8 +110,6 @@ def __init__(self, gladepath: str = "/usr/share/scc", imagepath: str = "/usr/sha self.status = "unknown" self.context_menu_for = None self.daemon_changed_profile = False - self.osd_mode = False # legacy controller-driven OSD edit mode (retired; never enabled) - self.osd_mode_mapper: OSDModeMapper | None = None self.osk_edit_mode = False # --osd: open only the OSD-keyboard bindings editor self.background = None self.outdated_version = None @@ -237,11 +234,6 @@ def _setup_popover_menus(self): self.main_area.put(self.dpad_test, 40, 90) self.main_area.put(self.cpad_test, 150, 90) - # OSD mode (if used) - if self.osd_mode: - self.builder.get_object("btDaemon").set_sensitive(False) - self.window.set_title(_("Edit Profile")) - # Headerbar headerbar(self.builder.get_object("hbWindow")) @@ -835,12 +827,6 @@ def on_switch_to_clicked(self, ps, *a) -> None: def on_profile_saved(self, giofile: Gio.File, send: bool = True) -> None: """Called when selected profile is saved to disk""" - if self.osd_mode: - # Special case, profile shouldn't be changed while in osd_mode - if not giofile.get_path().endswith(".mod"): - self.profile_switchers[0].set_profile_modified(False, self.current.is_template) - return - if giofile.get_path().endswith(".mod"): # Special case, this one is saved only to be sent to daemon # and user doesn't need to know about it @@ -983,7 +969,7 @@ def on_exiting_n_daemon_killed(self, *a): self.quit() def on_mnuExit_activate(self, *a): - if not self.osd_mode and self.app.config["gui"]["autokill_daemon"]: + if self.app.config["gui"]["autokill_daemon"]: log.debug("Terminating scc-daemon") for x in ("content", "mnuEmulationEnabled", "mnuEmulationEnabledTray"): w = self.builder.get_object(x) @@ -1011,9 +997,7 @@ def on_daemon_alive(self, *a) -> None: if not self.release_notes_visible(): self.hide_error() self.just_started = False - if self.osd_mode: - self.enable_osd_mode() - elif self.profile_switchers[0].get_file() is not None and not self.just_started: + if self.profile_switchers[0].get_file() is not None and not self.just_started: self.dm.set_profile(self.current_file.get_path()) GLib.timeout_add_seconds(1, self.check) self.enable_test_mode() @@ -1090,9 +1074,6 @@ def add_switcher(self, margin_start: int = 24, margin_end: int = 24) -> ProfileS sepSwitchers.set_visible(True) vbSwitchers.set_visible(True) - if self.osd_mode: - ps.set_allow_switch(False) - if len(self.profile_switchers) > 0: ps.set_profile_list(self.profile_switchers[0].get_profile_list()) ps.set_switch_to_enabled(True) @@ -1115,7 +1096,7 @@ def enable_test_mode(self, controller: ControllerManager | None = None) -> None: If sniffing is disabled in daemon configuration, 2nd call fails and logs error. """ - if self.dm.is_alive() and not self.osd_mode: + if self.dm.is_alive(): if self.test_mode_controller: self.test_mode_controller.unlock_all() if controller is None: @@ -1164,62 +1145,6 @@ def enable_test_mode(self, controller: ControllerManager | None = None) -> None: ) self.test_mode_controller = c - def enable_osd_mode(self): - # TODO: Support for multiple controllers here - self.osd_mode_controller = 0 - osd_mode_profile = Profile(GuiActionParser()) - osd_mode_profile.load(find_profile(App.OSD_MODE_PROF_NAME)) - try: - c = self.dm.get_controllers()[self.osd_mode_controller] - except IndexError: - log.error("osd_mode: Controller not connected") - self.quit() - return - - def on_lock_failed(*a): - log.error("osd_mode: Locking failed") - self.quit() - - def on_lock_success(*a): - log.debug("osd_mode: Locked everything") - from scc.gui.osd_mode import OSDModeMapper, OSDModeMappings - - self.osd_mode_mapper = OSDModeMapper(self, osd_mode_profile) - self.osd_mode_mapper.set_target_window(self.window.get_window()) - self.builder.get_object("btUndo").set_visible(False) - self.builder.get_object("btRedo").set_visible(False) - - m = OSDModeMappings(self, self.osd_mode_mapper, self.builder.get_object("OsdmodeMappings")) - m.set_controller(self.profile_switchers[0].get_controller()) - m.show() - - # Locks everything but pads. Pads are emulating mouse and this is - # better left in daemon - involving socket in mouse controls - # adds too much lags. - c.lock( - on_lock_success, - on_lock_failed, - "A", - "B", - "X", - "Y", - "START", - "BACK", - "LB", - "RB", - "C", - "LSTICK", - "LGRIP", - "RGRIP", - "LT", - "RT", - "LSTICKPRESS", - ) - - # Ask daemon to temporaly reconfigure pads for mouse emulation - c.replace(DaemonManager.nocallback, on_lock_failed, SCPads.LPAD, osd_mode_profile.pads[SCPads.LPAD]) - c.replace(DaemonManager.nocallback, on_lock_failed, SCPads.RPAD, osd_mode_profile.pads[SCPads.RPAD]) - def on_observe_failed(self, error) -> None: log.debug("Failed to enable test mode: %s", error) @@ -1240,8 +1165,7 @@ def on_daemon_version(self, daemon, version) -> None: # and we can check if there is anything new to inform user about elif self.app.config["gui"]["news"]["last_version"] != App.get_release(): if self.app.config["gui"]["news"]["enabled"]: - if not self.osd_mode: - self.check_release_notes() + self.check_release_notes() def on_daemon_error(self, daemon, error): log.debug("Daemon reported error '%s'", error) @@ -1274,16 +1198,11 @@ def on_daemon_error(self, daemon, error): return # If check() fails to find error reason, error message is displayed as it is - if self.osd_mode: - self.quit() - self.show_error(msg) self.set_daemon_status("error", True) def on_daemon_event_observer(self, daemon, c, what: str, data: list[int]) -> None: - if self.osd_mode_mapper: - self.osd_mode_mapper.handle_event(daemon, what, data) - elif what in (*SCPads, *SCSticks): + if what in (*SCPads, *SCSticks): widget, area = { SCPads.CPAD: (self.cpad_test, "CPADTEST"), SCPads.DPAD: (self.dpad_test, "DPADTEST"), @@ -1473,8 +1392,6 @@ def on_window_key_press_event(self, controller, keyval, keycode, state) -> None: if (state & Gdk.ModifierType.CONTROL_MASK) != 0: if keyval == 115: self.on_save_clicked() - elif self.osd_mode and keyval == 65471: - self.on_save_clicked() def show_error(self, message, ribar=None): if self.ribar is None or self.ribar.get_label() is None: @@ -1509,9 +1426,6 @@ def on_daemon_dead(self, *a): self.set_daemon_status("unknown", True) return - if self.osd_mode: - self.quit() - for ps in self.profile_switchers: ps.set_controller(None) ps.on_daemon_dead() diff --git a/scc/gui/osd_mode.py b/scc/gui/osd_mode.py deleted file mode 100644 index 63c06d2bf..000000000 --- a/scc/gui/osd_mode.py +++ /dev/null @@ -1,193 +0,0 @@ -"""SC Controller - OSD Mode Mapper - -Very special case of mapper used when main application is launched in "odd mode". -That means it's drawn in OSD layer, cannot be clicked and cannot react to -keyboard. This mapper emulates input events on it using GTK methods. - -Mouse movement (but not buttons) are passed to uinput as usuall. -""" - -import logging - -from gi.repository import Gdk, GLib, Gtk - -from scc.constants import SCButtons -from scc.gui.gdk_to_key import KEY_TO_GDK, KEY_TO_KEYCODE -from scc.osd.slave_mapper import SlaveMapper -from scc.uinput import Keys - -log = logging.getLogger("OSDModMapper") - - -class OSDModeMapper(SlaveMapper): - def __init__(self, app, profile): - SlaveMapper.__init__(self, profile, None, keyboard="osd", mouse="osd") - self.app = app - self.set_special_actions_handler(self) - self.target_window = None - - def on_sa_restart(self, *a): - """Restart / exit handler""" - self.app.quit() - - def set_target_window(self, w): - self.target_window = w - - def create_keyboard(self, name): - return OSDModeKeyboard(self) - - def create_mouse(self, name): - return OSDModeMouse(self) - - -class OSDModeKeyboard: - """Emulates uinput keyboard emulator""" - - def __init__(self, mapper): - self.mapper = mapper - self.display = Gdk.Display.get_default() - self.manager = self.display.get_device_manager() - self.device = [ - x for x in self.manager.list_devices(Gdk.DeviceType.MASTER) if x.get_source() == Gdk.InputSource.KEYBOARD - ][0] - - def pressEvent(self, keys): - for k in keys: - event = Gdk.Event.new(Gdk.EventType.KEY_PRESS) - event.time = Gtk.get_current_event_time() - event.hardware_keycode = KEY_TO_KEYCODE[k] - event.keyval = KEY_TO_GDK[k] - event.window = self.mapper.target_window - event.set_device(self.device) - Gtk.main_do_event(event) - - def releaseEvent(self, keys=[]): - for k in keys: - event = Gdk.Event.new(Gdk.EventType.KEY_RELEASE) - event.time = Gtk.get_current_event_time() - event.hardware_keycode = KEY_TO_KEYCODE[k] - event.keyval = KEY_TO_GDK[k] - event.window = self.mapper.target_window - event.set_device(self.device) - Gtk.main_do_event(event) - - -class OSDModeMouse: - """Emulates uinput keyboard emulator too""" - - def __init__(self, mapper): - self.mapper = mapper - self.display = Gdk.Display.get_default() - self.manager = self.display.get_device_manager() - self.device = [ - x for x in self.manager.list_devices(Gdk.DeviceType.MASTER) if x.get_source() == Gdk.InputSource.MOUSE - ][0] - - def synEvent(self, *a): - pass - - def keyEvent(self, key, val) -> None: - tp = Gdk.EventType.BUTTON_PRESS if val else Gdk.EventType.BUTTON_RELEASE - event = Gdk.Event.new(tp) - event.button = int(key) - Keys.BTN_LEFT + 1 - window, event.x, event.y = Gdk.Window.at_pointer() - screen, x, y, mask = Gdk.Display.get_default().get_pointer() - event.x_root, event.y_root = x, y - - gtk_window = None - for w in Gtk.Window.list_toplevels(): - if w.get_window(): - if window.get_toplevel().get_xid() == w.get_window().get_xid(): - gtk_window = w - break - if gtk_window: - if gtk_window.get_type_hint() == Gdk.WindowTypeHint.COMBO: - # Special case, clicking on combo does nothing, so - # pressing "space" is emulated instead. - if not val: - return - event = Gdk.Event.new(Gdk.EventType.KEY_PRESS) - event.time = Gtk.get_current_event_time() - event.hardware_keycode = 65 - event.keyval = Gdk.KEY_space - event.window = self.mapper.target_window - event.time = Gtk.get_current_event_time() - event.window = window - event.set_device(self.device) - Gtk.main_do_event(event) - - -class OSDModeMappings: - ICONS = { - "imgOsdmodeAct": SCButtons.A, - "imgOsdmodeClose": SCButtons.B, - "imgOsdmodeExit": SCButtons.C, - "imgOsdmodeSave": SCButtons.Y, - "imgOsdmodeOK": SCButtons.Y, - } - - MAIN_WINDOW_BUTTONS = {"vbOsdmodeExit", "vbOsdmodeSave"} - OTHER_WINDOW_BUTTONS = {"vbOsdmodeExit", "vbOsdmodeAct", "vbOsdmodeClose", "vbOsdmodeOK"} - - def __init__(self, app, mapper, window): - self.app = app - self.mapper = mapper - self.window = window - self.parent = app.window - self.first_window = None - GLib.timeout_add(10, self.move_around) - focus = Gtk.EventControllerFocus.new() - focus.connect("enter", self.on_main_window_focus_in_event) - focus.connect("leave", self.on_main_window_focus_out_event) - self.app.window.add_controller(focus) - self.on_main_window_focus_in_event() - - def set_controller(self, c): - config = c.load_gui_config(self.app.imagepath or {}) - for name in OSDModeMappings.ICONS: - w = self.app.builder.get_object(name) - icon, trash = c.get_button_icon(config, OSDModeMappings.ICONS[name]) - w.set_from_file(icon) - - def on_main_window_focus_in_event(self, *a): - for x in self.OTHER_WINDOW_BUTTONS: - self.app.builder.get_object(x).set_visible(False) - for x in self.MAIN_WINDOW_BUTTONS: - self.app.builder.get_object(x).set_visible(True) - - def on_main_window_focus_out_event(self, *a): - for x in self.MAIN_WINDOW_BUTTONS: - self.app.builder.get_object(x).set_visible(False) - for x in self.OTHER_WINDOW_BUTTONS: - self.app.builder.get_object(x).set_visible(True) - - def get_target_position(self): - pos = self.first_window.get_position() - size = self.first_window.get_geometry() - my_size = self.window.get_window().get_geometry() - tx = pos.x + 0.5 * (size.width - my_size.width) - ty = pos.y + size.height + 100 - return tx, ty - - def show(self): - self.window.set_visible(True) - self.window.get_window().set_override_redirect(True) - - def move_around(self, *a): - if self.first_window is None: - active = self.window.get_window().get_screen().get_active_window() - if active is None: - return None - self.first_window = active - - tx, ty = self.get_target_position() - self.window.get_window().move(tx, ty) - return True - - -def direction(x): - if x >= 1: - return 1 - if x <= -1: - return -1 - return 0 From a49cebac45c962bded42f60e184f96cceeac17e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Fri, 19 Jun 2026 17:39:40 +0200 Subject: [PATCH 10/98] gui: add "Act on release" (inverted button) for always-on sensors Adds InvertedButtonModifier (action-language COMMAND "inverted"): a held inversion that delivers the wrapped action's press on physical *release* and its release on physical press - so the binding is active while the button or sensor is NOT held. Meant for the Steam Controller's capacitive handle grips, which read "on" the whole time the controller is held. (Distinct from the existing pressed/released modifiers, which emit a momentary tap.) Exposed as an "Act on release" checkbox in the button binding pane, next to Toggle/Repeat, in the buttons action-editor component: apply_keys() and area_action_selected() wrap the action when ticked, set_action() detects and re-ticks it on load, and handles() looks through the wrapper - so it also round-trips with the Custom Action `inverted(...)` token. Label and tooltip are translatable. Co-Authored-By: Claude Opus 4.8 --- scc/gui/ae/buttons.py | 21 +++++++++++++++++++++ scc/modifiers.py | 33 +++++++++++++++++++++++++++++++++ ui/ae/buttons.ui | 21 +++++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/scc/gui/ae/buttons.py b/scc/gui/ae/buttons.py index 0bd2b1204..ab03254be 100644 --- a/scc/gui/ae/buttons.py +++ b/scc/gui/ae/buttons.py @@ -15,6 +15,7 @@ from scc.gui.key_grabber import KeyGrabber from scc.gui.parser import InvalidAction from scc.macros import Cycle, Macro, PressAction, ReleaseAction +from scc.modifiers import InvertedButtonModifier from scc.tools import _ from scc.uinput import Keys, Rels @@ -53,12 +54,19 @@ def load(self): def area_action_selected(self, area, action): self.set_active_area(area) + if self.builder.get_object("cbActOnRelease").get_active(): + action = InvertedButtonModifier(action) self.editor.set_action(action) def set_action(self, mode, action): cbToggle = self.builder.get_object("cbToggle") cbRepeat = self.builder.get_object("cbRepeat") + cbActOnRelease = self.builder.get_object("cbActOnRelease") if self.handles(mode, action): + # "Act on release" wraps the action in an InvertedButtonModifier + is_inverted = isinstance(action, InvertedButtonModifier) + if is_inverted: + action = action.action self.keys = set() is_togle, is_repeat = False, False if isinstance(action, MultiAction): @@ -77,6 +85,7 @@ def set_action(self, mode, action): is_togle = True cbToggle.set_active(is_togle) cbRepeat.set_active(is_repeat) + cbActOnRelease.set_active(is_inverted) area = action_to_area(action) if area is not None: self.set_active_area(area) @@ -87,6 +96,10 @@ def get_button_title(self): return _("Key or Button") def handles(self, mode, action): + # "Act on release" wraps the real action in an InvertedButtonModifier; + # look through it to the wrapped action. + if isinstance(action, InvertedButtonModifier): + return self.handles(mode, action.action) # Handles ButtonAction and MultiAction if all subactions are ButtonAction if isinstance(action, (ButtonAction, NoAction, InvalidAction)): return True @@ -124,8 +137,11 @@ def modifiers_first(key): def apply_keys(self, *a): """Common part of on_*key_grabbed""" + if not self.keys: + return cbToggle = self.builder.get_object("cbToggle") cbRepeat = self.builder.get_object("cbRepeat") + cbActOnRelease = self.builder.get_object("cbActOnRelease") keys = sorted(self.keys, key=ButtonsComponent.modifiers_first) action = ButtonAction(keys[0]) if len(keys) > 1: @@ -136,6 +152,8 @@ def apply_keys(self, *a): action.repeat = True elif cbToggle.get_active(): action = Cycle(PressAction(action), ReleaseAction(action)) + if cbActOnRelease.get_active(): + action = InvertedButtonModifier(action) self.editor.set_action(action) def on_btnGrabKey_clicked(self, *a): @@ -163,6 +181,9 @@ def on_cbRepeat_toggled(self, cbRepeat): cbToggle.set_active(False) self.apply_keys() + def on_cbActOnRelease_toggled(self, cb): + self.apply_keys() + def hide_toggle(self): """Hides 'set as toggle button' option""" cbToggle = self.builder.get_object("cbToggle") diff --git a/scc/modifiers.py b/scc/modifiers.py index 402226cab..c48c2be9e 100644 --- a/scc/modifiers.py +++ b/scc/modifiers.py @@ -345,6 +345,39 @@ def button_release(self, mapper) -> None: mapper.schedule(0.02, self._release) +class InvertedButtonModifier(Modifier): + """Acts on button release instead of press ("Act on release"). + + Swaps press and release, so the wrapped action is held while the physical + button is NOT pressed. Meant for always-on sensors such as the Steam + Controller's capacitive handle grips, which read "on" the whole time the + controller is held - inverting them lets the action fire when you let go. + Unlike PressedModifier/ReleasedModifier (which emit a momentary tap), this + is a true held inversion of the button state. + """ + COMMAND = "inverted" + + def describe(self, context): + if context in (Action.AC_STICK, Action.AC_PAD): + return _("(act on release)") + "\n" + self.action.describe(context) + return _("(act on release)") + " " + self.action.describe(context) + + def strip(self): + return self.action.strip() + + def compress(self): + self.action = self.action.compress() + return self + + def button_press(self, mapper): + # Physical press -> the wrapped action is released + self.action.button_release(mapper) + + def button_release(self, mapper): + # Physical release -> the wrapped action is pressed + self.action.button_press(mapper) + + class BallModifier(Modifier, WholeHapticAction): """Emulates ball-like movement with inertia and friction. diff --git a/ui/ae/buttons.ui b/ui/ae/buttons.ui index 1e6f5980a..ed67ebaf4 100644 --- a/ui/ae/buttons.ui +++ b/ui/ae/buttons.ui @@ -40,6 +40,27 @@ + + + Act on release + 150 + True + True + False + start + end + 0 + True + Fire when the button or sensor is released instead of pressed. Useful for always-on sensors like the capacitive handle grips, which read "on" while the controller is held. + + + + False + True + end + 2 + + From 54f69e15a94b76b159a95fafa9169ddce8bc2edd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Fri, 19 Jun 2026 22:50:18 +0200 Subject: [PATCH 11/98] daemon: remember each controller's profile across (re)connects Until now every controller loaded the global default profile (recent_profiles[0]) on connect; the per-controller config stored name/icon/LED/etc. but no profile, so with several controllers there was no way to keep a different profile per device. - config: add a "profile" key to CONTROLLER_DEFAULTS (None = global default); get_controller_config backfills it for existing configs. - daemon: add_controller now loads the controller's remembered profile if it has a valid one, else the global default - done explicitly after binding the mapper so a reused/pooled mapper never carries over another controller's profile. A deleted/missing remembered profile falls back to the default. - daemon: _remember_controller_profile persists the selection (by name) from the "Profile:" handler, but only for explicit user selections: the autoswitch daemon's contextual switches and transient .mod live-edits are skipped, and the config is written only when the value actually changes. Keyed by controller id, so the remembered profile follows the physical device with "Use Serial Numbers" enabled, or the connection slot otherwise. Single- controller behavior is unchanged (no remembered profile -> global default). Co-Authored-By: Claude Opus 4.8 --- scc/config.py | 4 ++++ scc/sccdaemon.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/scc/config.py b/scc/config.py index 97acf77e0..c7857c9bb 100644 --- a/scc/config.py +++ b/scc/config.py @@ -126,6 +126,10 @@ class Config: "menu_control": "LSTICK", "menu_confirm": "A", "menu_cancel": "B", + # Remembered profile name, restored when this controller (re)connects. + # None means "use the global default". See SCCDaemon.add_controller and + # SCCDaemon._remember_controller_profile. + "profile": None, } def __init__(self) -> None: diff --git a/scc/sccdaemon.py b/scc/sccdaemon.py index 039c56ef5..b74b1d2d9 100644 --- a/scc/sccdaemon.py +++ b/scc/sccdaemon.py @@ -44,7 +44,7 @@ from scc.poller import Poller from scc.profile import Profile from scc.scheduler import Scheduler -from scc.tools import clamp, find_binary, find_menu, find_profile, nameof, set_logging_level, shjoin, shsplit +from scc.tools import clamp, find_binary, find_menu, find_profile, get_profile_name, nameof, set_logging_level, shjoin, shsplit from scc.uinput import CannotCreateUInputException if TYPE_CHECKING: @@ -294,6 +294,31 @@ def _set_profile(self, mapper: Mapper, filename: str) -> None: else: self.send_profile_info(None, self._send_to_all, mapper=mapper) + def _remember_controller_profile(self, client, filename): + """Persists a controller's profile so it is restored on (re)connect. + + Only explicit, user-initiated selections are remembered: the autoswitch + daemon's contextual per-window switches and transient live-edit (.mod) + profiles are skipped. Stored by name under + config["controllers"][]["profile"]; with "Use Serial Numbers" on + that id is the physical device, otherwise it is the connection slot. + """ + if client is self.autoswitch_daemon: + # Autoswitcher changes are contextual, not the controller's choice + return + if filename.endswith(".mod"): + # Transient profile produced while live-editing in the GUI + return + c = client.mapper.get_controller() if client.mapper else None + if c is None: + return + name = get_profile_name(filename) + config = Config() + cc = config.get_controller_config(c.get_id()) + if cc.get("profile") != name: + cc["profile"] = name + config.save() + def _send_to_all(self, message_str: bytes) -> None: """Sends message to all connect clients. @@ -591,9 +616,27 @@ def add_controller(self, c: Controller) -> None: else: # New controller, but no mapper created mapper = self.init_mapper() - self.load_default_profile(mapper) mapper.set_controller(c) c.set_mapper(mapper) + + # Load this controller's remembered profile if it has a valid one, + # otherwise fall back to the global default. Done explicitly (rather + # than relying on the mapper's existing profile) so a reused/pooled + # mapper never carries over the profile of a previously-bound + # controller. With a single controller and no remembered profile this + # is exactly the old behavior (the global default is loaded). + remembered = Config().get_controller_config(c.get_id()).get("profile") + path = find_profile(remembered) if remembered else None + if path: + try: + mapper.profile.load(path).compress() + log.debug("Loaded remembered profile '%s' for %s", remembered, c.get_id()) + except Exception as e: + log.warning("Failed to load remembered profile '%s' for %s: %s", remembered, c.get_id(), e) + self.load_default_profile(mapper) + else: + self.load_default_profile(mapper) + if mapper == self.default_mapper: log.debug("Assigned default_mapper to %s", c) if mapper.profile.gyro: @@ -805,6 +848,7 @@ def _handle_message(self, client: Client, message: bytes) -> None: try: filename = message[8:].decode("utf-8").strip("\t ") self._set_profile(client.mapper, filename) + self._remember_controller_profile(client, filename) log.info("Loaded profile '%s'", filename) client.wfile.write(b"OK.\n") except Exception as e: From 57d9d5a5216cb09533660201c7d6d80fa7c67398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sat, 20 Jun 2026 19:57:45 +0200 Subject: [PATCH 12/98] gui: replace the per-controller profile bars with a controller selector With multiple controllers the window stacked one full profile bar per device (the same profile list repeated N times) plus a "switch-to" pen button. Replace that with a single controller selector above one profile picker: choose a controller to make it the active/edited one (with the same image transition the pen used) and its profile follows. This scales to any number of controllers as two dropdowns instead of N stacked bars. Also show friendly per-type names ("Steam Controller v2", "DualShock 4", ...) instead of the raw internal id ("sc1", "3:4"), numbering duplicates of the same type (#1/#2), with each controller's current profile as dim secondary text. Co-Authored-By: Claude Opus 4.8 --- scc/gui/app.py | 225 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 188 insertions(+), 37 deletions(-) diff --git a/scc/gui/app.py b/scc/gui/app.py index ac6fc2600..89d1af0d1 100644 --- a/scc/gui/app.py +++ b/scc/gui/app.py @@ -14,7 +14,7 @@ from typing import TYPE_CHECKING from urllib.parse import unquote -from gi.repository import Gdk, Gio, GLib, GObject, Gtk +from gi.repository import Gdk, GdkPixbuf, Gio, GLib, GObject, Gtk from scc.actions import NoAction from scc.config import Config @@ -37,6 +37,7 @@ from scc.tools import ( _, check_access, + find_controller_icon, find_gksudo, find_profile, get_profile_name, @@ -53,6 +54,44 @@ log = logging.getLogger("App") +# Human-friendly default names per controller type (controller.get_type()). +# Used when the user has not given the controller a custom name. Wrapped in +# _() at lookup time so they remain translatable. +CONTROLLER_TYPE_NAMES = { + "sc": "Steam Controller v1", + "scbt": "Steam Controller v1 (Bluetooth)", + "sc2": "Steam Controller v2", + "deck": "Steam Deck", + "ds4": "DualShock 4", + "ds4evdev": "DualShock 4", + "ds5": "DualSense", + "ds5evdev": "DualSense", + "ds5bt_hidraw": "DualSense", + "hid": "HID Controller", + "evdev": "Controller", + "rpad": "Remote Pad", + "fake": "Fake Controller", +} + +# Human-friendly default names per controller type (controller.get_type()). +# Used when the user has not given the controller a custom name. Wrapped in +# _() at lookup time so they remain translatable. +CONTROLLER_TYPE_NAMES = { + "sc": "Steam Controller v1", + "scbt": "Steam Controller v1 (Bluetooth)", + "sc2": "Steam Controller v2", + "deck": "Steam Deck", + "ds4": "DualShock 4", + "ds4evdev": "DualShock 4", + "ds5": "DualSense", + "ds5evdev": "DualSense", + "ds5bt_hidraw": "DualSense", + "hid": "HID Controller", + "evdev": "Controller", + "rpad": "Remote Pad", + "fake": "Fake Controller", +} + def reorder_box_child(box, child, position): """Move child to a zero-based position using GTK4 sibling ordering.""" @@ -185,6 +224,15 @@ def _setup_popover_menus(self): ps.connect("new-clicked", self.on_new_clicked) ps.connect("save-clicked", self.on_save_clicked) + # Controller selector: shown above the profile switcher only when more + # than one controller is connected. Lets the user pick which controller + # the editor shows (replacing the old stack of one profile bar per + # controller + the per-bar "switch-to" pen button). Each row is the + # controller's icon + name, with its current profile as dim secondary + # text; selecting a row makes that controller the active/edited one. + self._selector_recursing = False + self.controller_selector = self._build_controller_selector() + # Drag&drop target content = self.builder.get_object("content") for value_type in (Gdk.FileList, GObject.TYPE_STRING): @@ -840,8 +888,9 @@ def on_profile_saved(self, giofile: Gio.File, send: bool = True) -> None: self.profile_switchers[0].set_profile_modified(False, self.current.is_template) if send and self.dm.is_alive() and not self.daemon_changed_profile: - for ps in self.profile_switchers: - controller = ps.get_controller() + # Re-send to every controller currently running this profile (not + # just the active one), so a saved profile reloads on all of them. + for controller in self.dm.get_controllers(): if controller: active = controller.get_profile() if active.endswith(".mod"): @@ -1003,40 +1052,29 @@ def on_daemon_alive(self, *a) -> None: self.enable_test_mode() def on_daemon_ccunt_changed(self, daemon, count: int) -> None: - if self.controller_count == 0: - # First controller connected - # - # 'event' signal should be connected only on first controller, - # so this block is executed only when number of connected - # controllers changes from 0 to 1 - if len(self.dm.get_controllers()) > 0: - c = self.dm.get_controllers()[0] - self.load_gui_config_for_controller(c, first=True) - if count > self.controller_count: - # Controller added - while len(self.profile_switchers) < count: - s = self.add_switcher() - elif count < self.controller_count: - # Controller removed - while len(self.profile_switchers) > max(1, count): - s = self.profile_switchers.pop() - s.set_controller(None) - self.remove_switcher(s) - - # Assign controllers to widgets - for i in range(count): - c = self.dm.get_controllers()[i] - self.profile_switchers[i].set_controller(c) - - if count == 0: - # Special case, no controllers are connected, but one widget has to stay on screen - self.profile_switchers[0].set_controller(None) - # First load, default controller decided by _ensure_config() in controller_image.py - if not self._controller_shown: - self.load_gui_config_for_controller(None, first=True) + # A single profile switcher always shows the *active* controller; any + # others are reachable through the controller selector above it (built + # in setup_widgets). So here we only keep the active controller in the + # switcher and rebuild the selector. + controllers = list(self.dm.get_controllers()) + ps0 = self.profile_switchers[0] + first_connect = self.controller_count == 0 and count >= 1 + + if count >= 1: + active = ps0.get_controller() + if active not in controllers: + # No active controller yet (first connect) or the active one was + # disconnected: fall back to the first connected controller and + # switch the editor image to it. + active = controllers[0] + ps0.set_controller(active) + self.load_gui_config_for_controller(active, first=first_connect) else: - self.enable_test_mode(self.profile_switchers[0].get_controller()) + # No controllers connected, but one switcher has to stay on screen + ps0.set_controller(None) + self.load_gui_config_for_controller(None, first=True) + self.rebuild_controller_selector() self.controller_count = count def new_profile(self, profile: Profile, name: str) -> None: @@ -1091,6 +1129,112 @@ def remove_switcher(self, s): if len(vbSwitchers.observe_children()) == 2: sepSwitchers.set_visible(False) + def _build_controller_selector(self): + """Creates the 'which controller' combo and packs it above the profile + switcher. Hidden until 2+ controllers are connected.""" + # model columns: controller object, icon pixbuf, name, current profile + model = Gtk.ListStore(object, GdkPixbuf.Pixbuf, str, str) + combo = Gtk.ComboBox.new_with_model(model) + rPix, rName, rProf = Gtk.CellRendererPixbuf(), Gtk.CellRendererText(), Gtk.CellRendererText() + rProf.set_property("foreground", "#888888") + combo.pack_start(rPix, False) + combo.pack_start(rName, True) + combo.pack_start(rProf, False) + combo.add_attribute(rPix, "pixbuf", 1) + combo.add_attribute(rName, "text", 2) + combo.add_attribute(rProf, "text", 3) + combo.set_margin_start(12) + combo.set_margin_end(12) + combo.set_margin_top(4) + combo.connect("changed", self.on_controller_selected) + combo.connect("notify::popup-shown", self._refresh_selector_profiles) + vbSwitchers = self.builder.get_object("vbSwitchers") + vbSwitchers.append(combo) + # Layout top-to-bottom: [ selector ][ separator ][ profile switcher ] + vbSwitchers.reorder_child_after(combo, None) + vbSwitchers.reorder_child_after(self.builder.get_object("sepSwitchers"), vbSwitchers.get_first_child()) + combo.set_visible(False) + return combo + + def _load_controller_pixbuf(self, c): + """Loads the 24px icon for a controller, or None if unavailable.""" + try: + iconname = self.config.get_controller_config(c.get_id()).get("icon") + if iconname: + path = find_controller_icon(iconname) + if path and os.path.exists(path): + return GdkPixbuf.Pixbuf.new_from_file_at_size(path, 24, 24) + except Exception as e: + log.debug("No selector icon for %s: %s", c.get_id(), e) + return None + + def controller_display_name(self, c): + """Human-friendly controller name: the user's custom name if one was set + in controller settings, otherwise a per-type label (e.g. 'Steam + Controller v2') rather than the raw internal id (e.g. 'sc1' / '3:4').""" + name = self.config.get_controller_config(c.get_id())["name"] + if name and name != c.get_id(): + return name # user-customised + return _(CONTROLLER_TYPE_NAMES.get(c.get_type(), "Controller")) + + def rebuild_controller_selector(self): + """Refills the controller selector from the connected controllers and + shows it only when more than one is connected.""" + combo = self.controller_selector + controllers = list(self.dm.get_controllers()) + active = self.profile_switchers[0].get_controller() + # Friendly names, disambiguating duplicates of the same type with #N + # (e.g. two 'Steam Controller v1' become '... #1' and '... #2'). + names = [self.controller_display_name(c) for c in controllers] + dupes = {n for n in names if names.count(n) > 1} + seen = {} + labels = [] + for n in names: + if n in dupes: + seen[n] = seen.get(n, 0) + 1 + labels.append("%s #%d" % (n, seen[n])) + else: + labels.append(n) + self._selector_recursing = True + model = combo.get_model() + model.clear() + active_iter = None + for c, label in zip(controllers, labels): + prof = get_profile_name(c.get_profile() or "") or "" + it = model.append((c, self._load_controller_pixbuf(c), label, prof)) + if c is active: + active_iter = it + if active_iter is not None: + combo.set_active_iter(active_iter) + self._selector_recursing = False + multi = len(controllers) >= 2 + combo.set_visible(multi) + self.builder.get_object("sepSwitchers").set_visible(multi) + + def _refresh_selector_profiles(self, combo, *a): + """Refreshes each row's profile subtext when the dropdown is opened.""" + if not combo.get_property("popup-shown"): + return + for row in combo.get_model(): + row[3] = get_profile_name(row[0].get_profile() or "") or "" + + def on_controller_selected(self, combo): + """Makes the chosen controller the active (edited) one, with the same + image transition the old switch-to button used.""" + if self._selector_recursing: + return + it = combo.get_active_iter() + if it is None: + return + c = combo.get_model().get_value(it, 0) + ps0 = self.profile_switchers[0] + if c is None or c is ps0.get_controller(): + return + ps0.set_controller(c) + ps0.set_profile(c.get_profile()) + self.load_gui_config_for_controller(c, False) + self.enable_test_mode() + def enable_test_mode(self, controller: ControllerManager | None = None) -> None: """Disables and re-enables Input Test mode. @@ -1341,10 +1485,11 @@ def on_btRenameProfile_clicked(self, *a) -> None: controllers = list(self.dm.get_controllers()) for c in controllers: if get_profile_name(c.get_profile()) == old_name: - ps = self.profile_switchers[controllers.index(c)] - ps.set_profile(new_name, True) c.set_profile(new_fname) + if c is self.profile_switchers[0].get_controller(): + self.profile_switchers[0].set_profile(new_name, True) self.load_profile_list() + self.rebuild_controller_selector() dlg.set_visible(False) def on_mnuProfileDelete_activate(self, *a) -> None: @@ -1418,6 +1563,7 @@ def on_daemon_reconfigured(self, *a) -> None: self.config.reload() for ps in self.profile_switchers: ps.set_controller(ps.get_controller()) + self.rebuild_controller_selector() def on_daemon_dead(self, *a): if self.just_started: @@ -1429,6 +1575,11 @@ def on_daemon_dead(self, *a): for ps in self.profile_switchers: ps.set_controller(None) ps.on_daemon_dead() + self._selector_recursing = True + self.controller_selector.get_model().clear() + self._selector_recursing = False + self.controller_selector.set_visible(False) + self.builder.get_object("sepSwitchers").set_visible(False) self.set_daemon_status("dead", False) def on_mnuEmulationEnabled_toggled(self, cb): From d901a72472aad818880cee11627411e21675149f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sat, 20 Jun 2026 19:57:45 +0200 Subject: [PATCH 13/98] fix(gui): make "Restart emulation" wait for the old daemon to exit The Settings restart button did dm.stop() then dm.start() on a fixed 1-second timer. stop() is asynchronous and the daemon's real shutdown -- releasing every claimed USB device -- can take longer than that, so the new daemon started against a still-dying one: a stale pidfile or still-claimed devices left controllers undetected (and a half-claimed device showing as off). Use the purpose-built dm.restart(), which runs the daemon's stop-and-wait-then-start handoff and so cannot race the shutdown. Co-Authored-By: Claude Opus 4.8 --- scc/gui/global_settings.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scc/gui/global_settings.py b/scc/gui/global_settings.py index 5adbb1e2d..15a38c026 100644 --- a/scc/gui/global_settings.py +++ b/scc/gui/global_settings.py @@ -343,9 +343,13 @@ def on_cbShowOSD_toggled(self, cb): def on_btRestartEmulation_clicked(self, *a): rvRestartWarning = self.builder.get_object("rvRestartWarning") - self.app.dm.stop() rvRestartWarning.set_reveal_child(False) - GLib.timeout_add_seconds(1, self.app.dm.start) + # Use the daemon's built-in restart (stop-and-wait, *then* start) rather + # than a manual stop + fixed 1s delay + start. The old approach raced the + # daemon's own shutdown: with several USB devices the new daemon could + # start before the old one had died and released them, so it hit a stale + # pidfile / still-claimed devices and the controllers stayed undetected. + self.app.dm.restart() def on_restarting_checkbox_toggled(self, *a): if self._recursing: From c732e8622f2071a3aa569de0a41d4643e3d3765e Mon Sep 17 00:00:00 2001 From: Martin Rys Date: Sun, 12 Jul 2026 17:43:28 +0200 Subject: [PATCH 14/98] fix(usb/sc_dongle): keep the v1 dongle alive when GET_SERIAL stalls With "Use Serial Numbers" on and two wireless v1 dongles connected, only one v1 appeared: the flaky v1 GET_SERIAL control request stalls (USBErrorPipe) during flush, and the generic USBDevice.flush() let that propagate to the mainloop, which closed the whole dongle and dropped its not-yet-added controller. Serials off was unaffected because controllers are added immediately, with no pending serial window to lose. - usb.py: flush() now recovers from a control-endpoint stall instead of propagating it. A stalled request is retried on later flushes (a control protocol stall clears on the next SETUP) up to REQUEST_MAX_ATTEMPTS, after which an optional on_giveup hook fires; a stalled config command is dropped. The device is no longer torn down by a transient control stall -- mirroring the resilience the sc2 puck driver already implements for itself. - sc_dongle.py: passes on_giveup so a v1 whose serial never reads is still added with a generated id, and guards on_serial_got so a blank/duplicate id cannot collapse two controllers into one (the GUI keys controllers by id). Verified on hardware: two Steam Controller v1s plus a v2 are now detected consistently with serials on, across daemon restarts (dialog and manual) and repeated on/off toggles of the setting. Co-Authored-By: Claude Opus 4.8 --- scc/drivers/sc_dongle.py | 17 +++++++++++- scc/drivers/usb.py | 58 +++++++++++++++++++++++++++++++--------- 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/scc/drivers/sc_dongle.py b/scc/drivers/sc_dongle.py index 323007318..96125fd79 100644 --- a/scc/drivers/sc_dongle.py +++ b/scc/drivers/sc_dongle.py @@ -302,8 +302,17 @@ def cb(rawserial) -> None: self._driver.make_request( self._ccidx, cb, struct.pack(">BBB61x", SCPacketType.GET_SERIAL, SCPacketLength.GET_SERIAL, 0x01), + on_giveup=self._on_serial_giveup, ) + def _on_serial_giveup(self): + """Called when the GET_SERIAL request kept stalling. Add the controller + with a generated id anyway, so it still appears (it just won't have a + stable serial-based identity).""" + log.warning("GET_SERIAL kept stalling for SC on endpoint %s; using a generated id", self._endpoint) + self.generate_serial() + self.on_serial_got() + def generate_serial(self) -> None: """Called only if ignore_serials is enabled""" if len(self._driver._available_serials) > 0: @@ -318,7 +327,13 @@ def on_serial_got(self) -> None: except UnicodeDecodeError: log.debug("Failed to decode wireless SC serial") self._serial = self._driver._available_serials.pop() - self._id = str(self._serial) + serial = str(self._serial).strip() + if not serial or serial in self._driver.daemon.get_active_ids(): + # A blank or already-used id would make two controllers collapse into + # one in the GUI (it keys controllers by id). Keep them distinct by + # falling back to a generated positional id. + serial = self._generate_id() + self._id = serial self._driver.daemon.add_controller(self) def apply_config(self, config: dict) -> None: diff --git a/scc/drivers/usb.py b/scc/drivers/usb.py index 9a2728d4b..4aa01e91e 100644 --- a/scc/drivers/usb.py +++ b/scc/drivers/usb.py @@ -27,6 +27,11 @@ log = logging.getLogger("USB") +# How many times to retry a stalling control request (notably the flaky Steam +# Controller v1 GET_SERIAL) on successive flushes before giving up, instead of +# letting the stall propagate and tear down the whole device. +REQUEST_MAX_ATTEMPTS = 20 + class SCUSBDevice: """Base class for all handled usb devices.""" @@ -98,10 +103,12 @@ def overwrite_control(self, index, data) -> None: break self.send_control(index, data) - def make_request(self, index, callback, data, size=64) -> None: + def make_request(self, index, callback, data, size=64, on_giveup=None) -> None: """Schedule a synchronous request that requires response. Request is done ASAP and provided callback is called with received data. + If the control transfer keeps stalling it is retried on later flushes, + and 'on_giveup' (if given) is called once the retries are exhausted. """ self._rmsg.append( ( @@ -115,26 +122,53 @@ def make_request(self, index, callback, data, size=64) -> None: index, size, callback, + on_giveup, + 0, # stall-retry attempts so far ), ) def flush(self) -> None: - """Flush all prepared control messages to the device.""" + """Flush all prepared control messages to the device. + + A control-endpoint stall (USBErrorPipe) is recovered from rather than + propagated: letting it reach the mainloop would tear down the whole + device and drop its controllers. Notably the Steam Controller v1 + GET_SERIAL request is flaky and can stall (more so with several dongles + connected at once); such a request is retried on a later flush. + """ while len(self._cmsg): msg = self._cmsg.pop() - self.handle.controlWrite(*msg) + try: + self.handle.controlWrite(*msg) + except usb1.USBErrorPipe: + # Config command stalled; drop it (it is re-sent on the next + # configure) instead of tearing the whole device down. + pass + requeue = [] while len(self._rmsg): - msg, index, size, callback = self._rmsg.pop() - self.handle.controlWrite(*msg) - data = self.handle.controlRead( - 0xA1, # request_type - 0x01, # request - 0x0300, # value - index, - size, - ) + msg, index, size, callback, on_giveup, attempts = self._rmsg.pop() + try: + self.handle.controlWrite(*msg) + data = self.handle.controlRead( + 0xA1, # request_type + 0x01, # request + 0x0300, # value + index, + size, + ) + except usb1.USBErrorPipe: + # Control protocol stall; it clears on the next SETUP, so retry on + # a later flush rather than letting it close the whole device. + if attempts + 1 < REQUEST_MAX_ATTEMPTS: + requeue.append((msg, index, size, callback, on_giveup, attempts + 1)) + else: + log.warning("Control request to %s kept stalling; giving up after %d tries", self, attempts + 1) + if on_giveup: + on_giveup() + continue callback(data) + self._rmsg.extend(requeue) def force_restart(self) -> None: """Restart device, close handle and try to re-grab it again. From c41a7f1d6d508d112f0a6e5990e241ee27182b9b Mon Sep 17 00:00:00 2001 From: Martin Rys Date: Wed, 15 Jul 2026 19:42:13 +0200 Subject: [PATCH 15/98] fix(osd): Add input locking to prevent stuck keys --- scc/osd/menu.py | 59 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/scc/osd/menu.py b/scc/osd/menu.py index dbcda8976..b50a71dbb 100644 --- a/scc/osd/menu.py +++ b/scc/osd/menu.py @@ -83,6 +83,14 @@ def __init__(self, cls="osd-menu", layer=None): self._menuid = None self._use_cursor = False self._eh_ids = [] + # Reveal-after-lock state: the menu is not made visible until inputs are + # locked, so a button press can't reach the normal mapping before the + # (asynchronous) lock lands. A fallback reveals anyway if the lock is + # slow, so the menu can never get stuck invisible. + self._inputs_locked = False + self._show_pending = False + self._reveal_timer = None + self._quit_done = False self._control_with = SCSticks.LSTICK self._control_with_dpad: bool = False self._confirm_with = "A" @@ -447,9 +455,44 @@ def show(self, *a): if not self.select(0): self.next_item(1) self._fit_scroll() - OSDWindow.show(self, *a) + # Reveal only once inputs are locked, so a button press can never reach + # the normal mapping before the lock lands. That matters for more than a + # stray keystroke: if the press is handled by the old action (e.g. a key + # goes DOWN) and the release is then captured by the menu, the key never + # goes UP and gets stuck. So we must never show the menu while unlocked: + # if the lock does not land within the timeout, give up and CLOSE the + # menu rather than revealing it. + self._show_pending = True + if self._reveal_timer is None: + self._reveal_timer = GLib.timeout_add(2000, self._lock_timed_out) + self._reveal_if_locked() + + def _on_inputs_locked(self, *a): + """Called from the lock-success callback, once inputs are diverted to + this menu and it is safe to reveal it.""" + self._inputs_locked = True + self._reveal_if_locked() + + def _reveal_if_locked(self): + """Reveal the menu, but only once it is both requested and locked.""" + if not (self._show_pending and self._inputs_locked): + return + self._show_pending = False + if self._reveal_timer is not None: + GLib.source_remove(self._reveal_timer) + self._reveal_timer = None + OSDWindow.show(self) GLib.timeout_add(1, self._check_on_screen_position, True) + def _lock_timed_out(self, *a): + """The lock never landed; close the menu instead of revealing it while + unlocked (which could leak input or strand a key).""" + self._reveal_timer = None + if self._show_pending: + self._show_pending = False + self.quit(3) + return False + def on_daemon_connected(self, *a) -> None: if not self.config: self.config = Config() @@ -506,6 +549,7 @@ def use_controller(self, controller: ControllerManager) -> None: def lock_inputs(self) -> None: def success(*a) -> None: + self._on_inputs_locked() log.info("Successfully locked input") locks = [self._control_with, self._confirm_with, self._cancel_with] @@ -514,6 +558,19 @@ def success(*a) -> None: self.controller.lock(success, self.on_failed_to_lock, *locks) def quit(self, code=-2) -> None: + # A menu must unlock exactly once. quit() can be re-entered — most + # importantly by a *previous* menu's leftover timeout firing after this + # object should be gone — and unlock_all() is per-CLIENT: it clears every + # lock the OSD daemon holds, not just this menu's. Re-running it would + # strip the locks of whatever menu is open *now*, leaving it visible but + # dead (it stops responding and leaks input to the normal mapping). So + # make quit idempotent: a menu that already quit never unlocks again. + if self._quit_done: + return + self._quit_done = True + if self._reveal_timer is not None: + GLib.source_remove(self._reveal_timer) + self._reveal_timer = None if not self._is_submenu: if self.get_controller(): self.get_controller().unlock_all() From dc1772201b73a396c29c8841030c3d7267de9f4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Fri, 3 Jul 2026 15:34:08 +0200 Subject: [PATCH 16/98] fix(daemon): release a held action when locking so its key-up isn't lost --- scc/sccdaemon.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scc/sccdaemon.py b/scc/sccdaemon.py index b74b1d2d9..ce1e06e1d 100644 --- a/scc/sccdaemon.py +++ b/scc/sccdaemon.py @@ -1368,6 +1368,16 @@ class LockedAction(ReportingAction): def __init__(self, what, client: Client, original_action) -> None: ReportingAction.__init__(self, what, client) self.original_action = original_action + # If the button being locked is currently held, the original action's + # press already ran (a key it bound may be DOWN). Its release will now be + # captured by this lock instead of the original action, leaving the key + # stuck down (it has locked people's keyboards). Release the original + # action here so the matching key-UP is sent. + if what in SCButtons.__members__.values() and self.mapper and (self.mapper.buttons & what): + try: + original_action.button_release(self.mapper) + except Exception as e: + log.warning("Failed to release held action while locking %s: %s", what, e) original_action.cancel(self.mapper) self._store_lock() log.debug("%s locked by %s", nameof(self.what), self.client) From 44ff851e7e130015948bb9f89cec77fc44a1d1df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Fri, 3 Jul 2026 15:36:37 +0200 Subject: [PATCH 17/98] fix(osd): keep generic face-button icons for the v2 quick menu --- scc/osd/quick_menu.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scc/osd/quick_menu.py b/scc/osd/quick_menu.py index 93e83ca24..e67a775d3 100644 --- a/scc/osd/quick_menu.py +++ b/scc/osd/quick_menu.py @@ -115,10 +115,16 @@ def success(*a): icon = self._icons[i] name = buttons[self.BUTTON_INDEXES[i]] filename, trash = find_icon("buttons/%s" % name) - icon.set_filename(filename) - icon.queue_draw() + # Some controllers (e.g. the v2) name their face buttons + # sc2_A/B/X/Y, which have no icon under buttons/. Only + # override when a real icon is found, otherwise keep the + # generic A/B/X/Y/LB/RB icon already set in generate_widget. + if filename: + icon.set_filename(filename) + icon.queue_draw() except IndexError: pass + self._on_inputs_locked() locks = [x for x in self.BUTTONS] + [self._cancel_with] self.controller.lock(success, self.on_failed_to_lock, *locks) From cac8be0da783438171fb158409ec5236fdc3e291 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Fri, 3 Jul 2026 15:36:59 +0200 Subject: [PATCH 18/98] fix(osd): cancel the quick-menu auto-timeout on quit --- scc/osd/quick_menu.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scc/osd/quick_menu.py b/scc/osd/quick_menu.py index e67a775d3..7c0e171f4 100644 --- a/scc/osd/quick_menu.py +++ b/scc/osd/quick_menu.py @@ -106,7 +106,6 @@ def _check_on_screen_position(self, quick=False): def lock_inputs(self): def success(*a): - log.error("Sucessfully locked input") config = self.controller.load_gui_config(os.path.join(get_share_path(), "images")) if config and config["gui"] and config["gui"]["buttons"]: buttons = config["gui"]["buttons"] @@ -246,6 +245,13 @@ def cancel_timer(self): GLib.source_remove(self._timer) self._timer = None + def quit(self, code=-2): + # Cancel the auto-timeout before quitting; otherwise it lingers and fires + # after this menu is gone, calling quit() again -> unlock_all(), which is + # per-client and would clear the *next* menu's locks. See Menu.quit. + self.cancel_timer() + Menu.quit(self, code) + def on_event(self, daemon, what, data): if self._submenu: return self._submenu.on_event(daemon, what, data) From f03875e6e587f2f6a46800a86082e7256df0604f Mon Sep 17 00:00:00 2001 From: Martin Rys Date: Sun, 13 Sep 2026 21:23:31 +0200 Subject: [PATCH 19/98] =?UTF-8?q?fix(gui):=20Input=20Test=20mode=20?= =?UTF-8?q?=E2=80=94=20selected=20controller,=20viewBox=20cursor,=20re-arm?= =?UTF-8?q?=20on=20connect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - enable_test_mode observes the controller selected in the GUI (the one drawn on the big image) instead of always get_controllers()[0], so Input Test works with several controllers connected and on a non-first controller. Also observes the v2 lower paddles and right-stick click (LGRIP2, RGRIP2, RSTICKPRESS) plus RSTICK/DPAD positional sources. - on_daemon_event_observer ignores events from controllers other than the one being observed; skips a missing test area gracefully (e.g. an image without STICKTEST) instead of crashing with ValueError; and offsets the cursor by the SVG viewBox origin so a non-zero origin (sc2.svg's trigger headroom) no longer pushes every pad/stick cursor up and to the left. - Re-arm Input Test when a controller connects after the GUI has started. - svg_widget: add get_viewbox() to read the SVG viewBox. Co-Authored-By: Claude Opus 4.8 --- scc/gui/app.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scc/gui/app.py b/scc/gui/app.py index 89d1af0d1..1862abac1 100644 --- a/scc/gui/app.py +++ b/scc/gui/app.py @@ -1076,6 +1076,12 @@ def on_daemon_ccunt_changed(self, daemon, count: int) -> None: self.rebuild_controller_selector() self.controller_count = count + if count >= 1: + # Re-arm Input Test on the (now) active controller. Without this, a + # controller connected *after* startup is never observed -- the + # enable at 'alive' ran while no controller was present -- so Input + # Test stays blank until the user toggles it off and on again. + self.enable_test_mode() def new_profile(self, profile: Profile, name: str) -> None: filename = os.path.join(get_profiles_path(), name + ".sccprofile") @@ -1346,6 +1352,11 @@ def on_daemon_error(self, daemon, error): self.set_daemon_status("error", True) def on_daemon_event_observer(self, daemon, c, what: str, data: list[int]) -> None: + # Only react to the controller Input Test is observing. Other connected + # controllers also emit events; without this, their input would show on + # the selected controller's image. + if c is not self.test_mode_controller: + return if what in (*SCPads, *SCSticks): widget, area = { SCPads.CPAD: (self.cpad_test, "CPADTEST"), From 89701ab7e8a3e637ede9c1ce652abba510d666b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sun, 28 Jun 2026 03:44:15 +0200 Subject: [PATCH 20/98] fix(config): default "disable emulation on close" ON for Steam Deck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gui.autokill_daemon defaulted to False, so closing the GUI left scc-daemon running and still emulating. On a Steam Deck — which has no built-in keyboard — that locks the user out of the device's own controls (only the touchscreen keeps working), even after the lingering daemon is killed. Detect Deck hardware via DMI product/board name (Jupiter = LCD, Galileo = OLED) and default autokill_daemon to True there. Desktop machines are unaffected, and an explicit user setting still wins. Co-Authored-By: Claude Opus 4.8 --- scc/config.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/scc/config.py b/scc/config.py index c7857c9bb..364113f78 100644 --- a/scc/config.py +++ b/scc/config.py @@ -14,6 +14,25 @@ log = logging.getLogger("Config") +def _is_steam_deck(): + """Return True when running on Steam Deck hardware (LCD reports DMI product + name 'Jupiter', OLED 'Galileo'). + + Used to flip the 'autokill_daemon' default on: the Deck has no built-in + keyboard, so a daemon left running after the GUI closes keeps emulating and + locks the user out of the device's own controls (only the touchscreen still + works). Desktop machines are unaffected. + """ + for path in ("/sys/class/dmi/id/product_name", "/sys/class/dmi/id/board_name"): + try: + with open(path) as f: + if f.read().strip() in ("Jupiter", "Galileo"): + return True + except OSError: + pass + return False + + class Config: DEFAULTS = { "autoswitch_osd": True, # True to show OSD message when profile is autoswitched @@ -47,7 +66,7 @@ class Config: "enable_status_icon": False, "minimize_to_status_icon": True, "minimize_on_start": False, - "autokill_daemon": False, + "autokill_daemon": _is_steam_deck(), # default ON on the Steam Deck (no built-in keyboard) "news": { # Controls "new in this version" message "enabled": True, # if disabled, no querying is done From 91a831e1c49e4d5a7e110139aafb0e55066da570 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sun, 28 Jun 2026 15:30:43 +0200 Subject: [PATCH 21/98] feat(gui,images): per-controller button-image override + Deck back-button/pad icons The mapping-box icons resolve through find_button_image(), which was global, so the Deck reused the Steam Controller's long/short back-grip and round-pad art. - Add a per-controller override: find_button_image gains a controller_set arg and get_button_icon passes the controller's gui.background, so a controller can ship button-images//.svg that overrides the shared image, falling back to the shared set. SC v1/SC2 are unaffected (no override dirs). - images/button-images/deck/: Deck back-button (LGRIP/LGRIP2/RGRIP/RGRIP2) and trackpad (LPAD/RPAD) icons. Back-button shapes carry their physical L4/L5/ R4/R5 labels (note the swapped mapping: LGRIP = lower L5); pads are plain square trackpads. Co-Authored-By: Claude Opus 4.8 --- images/button-images/deck/LGRIP.svg | 53 ++++++++++++++++++++++++++++ images/button-images/deck/LGRIP2.svg | 53 ++++++++++++++++++++++++++++ images/button-images/deck/LPAD.svg | 48 +++++++++++++++++++++++++ images/button-images/deck/RGRIP.svg | 53 ++++++++++++++++++++++++++++ images/button-images/deck/RGRIP2.svg | 53 ++++++++++++++++++++++++++++ images/button-images/deck/RPAD.svg | 48 +++++++++++++++++++++++++ scc/gui/daemon_manager.py | 14 +++++++- scc/tools.py | 13 +++++-- 8 files changed, 331 insertions(+), 4 deletions(-) create mode 100644 images/button-images/deck/LGRIP.svg create mode 100644 images/button-images/deck/LGRIP2.svg create mode 100644 images/button-images/deck/LPAD.svg create mode 100644 images/button-images/deck/RGRIP.svg create mode 100644 images/button-images/deck/RGRIP2.svg create mode 100644 images/button-images/deck/RPAD.svg diff --git a/images/button-images/deck/LGRIP.svg b/images/button-images/deck/LGRIP.svg new file mode 100644 index 000000000..5c1a716d3 --- /dev/null +++ b/images/button-images/deck/LGRIP.svg @@ -0,0 +1,53 @@ + + + + + + + L5 + + diff --git a/images/button-images/deck/LGRIP2.svg b/images/button-images/deck/LGRIP2.svg new file mode 100644 index 000000000..fc8e7858b --- /dev/null +++ b/images/button-images/deck/LGRIP2.svg @@ -0,0 +1,53 @@ + + + + + + + L4 + + diff --git a/images/button-images/deck/LPAD.svg b/images/button-images/deck/LPAD.svg new file mode 100644 index 000000000..28df978f3 --- /dev/null +++ b/images/button-images/deck/LPAD.svg @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/images/button-images/deck/RGRIP.svg b/images/button-images/deck/RGRIP.svg new file mode 100644 index 000000000..adcea6e9d --- /dev/null +++ b/images/button-images/deck/RGRIP.svg @@ -0,0 +1,53 @@ + + + + + + + R5 + + diff --git a/images/button-images/deck/RGRIP2.svg b/images/button-images/deck/RGRIP2.svg new file mode 100644 index 000000000..39a8fede2 --- /dev/null +++ b/images/button-images/deck/RGRIP2.svg @@ -0,0 +1,53 @@ + + + + + + + R4 + + diff --git a/images/button-images/deck/RPAD.svg b/images/button-images/deck/RPAD.svg new file mode 100644 index 000000000..3240c1649 --- /dev/null +++ b/images/button-images/deck/RPAD.svg @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/scc/gui/daemon_manager.py b/scc/gui/daemon_manager.py index 04ef324c7..c880ce5b3 100644 --- a/scc/gui/daemon_manager.py +++ b/scc/gui/daemon_manager.py @@ -418,8 +418,20 @@ def get_button_icon(config, button, prefer_bw=False): returns icon filename assigned to that button in controller config or default if config is invalid or button unassigned. + + A controller whose gui.background names a button-images// subdir + (e.g. "deck") gets its own icons from there, falling back to the shared + set. This is how the Deck overrides the Steam Controller back-button and + trackpad art. """ - return find_button_image(ControllerManager.get_button_name(config, button), prefer_bw=prefer_bw) + controller_set = None + try: + controller_set = config["gui"]["background"] + except (KeyError, TypeError): + pass + return find_button_image( + ControllerManager.get_button_name(config, button), + prefer_bw=prefer_bw, controller_set=controller_set) @staticmethod def get_button_name(config, button): diff --git a/scc/tools.py b/scc/tools.py index b31918c14..7b407cd57 100644 --- a/scc/tools.py +++ b/scc/tools.py @@ -272,10 +272,17 @@ def find_icon( def find_button_image( - name: str | None, prefer_bw: bool = False, + name: str | None, prefer_bw: bool = False, controller_set: str | None = None, ) -> tuple[None, bool] | tuple[str, bool]: - """Similar to find_icon, but searches for button image""" - return find_icon(nameof(name), prefer_bw, paths=[get_button_images_path()], extensions=("svg",)) + """Similar to find_icon, but searches for a button image. + + controller_set (e.g. "deck") lets a controller override the shared images + with its own button-images//.svg, falling back to the + shared set when no override exists. + """ + base = get_button_images_path() + paths = (os.path.join(base, controller_set), base) if controller_set else (base,) + return find_icon(nameof(name), prefer_bw, paths=paths, extensions=("svg",)) def menu_is_default(name: str) -> bool: From 60ec6bb5aa5627c8b7b8254d846f61759d1852b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sun, 28 Jun 2026 16:14:39 +0200 Subject: [PATCH 22/98] fix(packaging,images): package per-controller icon dirs; Deck icons to images/deck/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The around-controller icons use a per-controller override, images// .svg (applied in app.py). It works in a source checkout, but setup.py never packaged the images// subdirs, so built AppImages dropped images/sc2/ — the new Steam Controller fell back to v1 back-button/pad/system icons — and would have dropped images/deck/ too. Hence "works via ./run.sh, broken in the AppImage". - setup.py: package every images//*.svg per-controller override dir (generic; currently sc2 + deck). This also restores the SC2's own icons in AppImages. - Move the Deck back-button/pad icons from button-images/deck/ to images/deck/, the directory the override actually reads. - Revert the find_button_image/get_button_icon controller_set override added in the previous commit — it targeted the wrong (button-images) mechanism; images// is the real one. Co-Authored-By: Claude Opus 4.8 --- images/{button-images => }/deck/LGRIP.svg | 0 images/{button-images => }/deck/LGRIP2.svg | 0 images/{button-images => }/deck/LPAD.svg | 0 images/{button-images => }/deck/RGRIP.svg | 0 images/{button-images => }/deck/RGRIP2.svg | 0 images/{button-images => }/deck/RPAD.svg | 0 scc/gui/daemon_manager.py | 14 +------------- scc/tools.py | 13 +++---------- setup.py | 7 +++++++ 9 files changed, 11 insertions(+), 23 deletions(-) rename images/{button-images => }/deck/LGRIP.svg (100%) rename images/{button-images => }/deck/LGRIP2.svg (100%) rename images/{button-images => }/deck/LPAD.svg (100%) rename images/{button-images => }/deck/RGRIP.svg (100%) rename images/{button-images => }/deck/RGRIP2.svg (100%) rename images/{button-images => }/deck/RPAD.svg (100%) diff --git a/images/button-images/deck/LGRIP.svg b/images/deck/LGRIP.svg similarity index 100% rename from images/button-images/deck/LGRIP.svg rename to images/deck/LGRIP.svg diff --git a/images/button-images/deck/LGRIP2.svg b/images/deck/LGRIP2.svg similarity index 100% rename from images/button-images/deck/LGRIP2.svg rename to images/deck/LGRIP2.svg diff --git a/images/button-images/deck/LPAD.svg b/images/deck/LPAD.svg similarity index 100% rename from images/button-images/deck/LPAD.svg rename to images/deck/LPAD.svg diff --git a/images/button-images/deck/RGRIP.svg b/images/deck/RGRIP.svg similarity index 100% rename from images/button-images/deck/RGRIP.svg rename to images/deck/RGRIP.svg diff --git a/images/button-images/deck/RGRIP2.svg b/images/deck/RGRIP2.svg similarity index 100% rename from images/button-images/deck/RGRIP2.svg rename to images/deck/RGRIP2.svg diff --git a/images/button-images/deck/RPAD.svg b/images/deck/RPAD.svg similarity index 100% rename from images/button-images/deck/RPAD.svg rename to images/deck/RPAD.svg diff --git a/scc/gui/daemon_manager.py b/scc/gui/daemon_manager.py index c880ce5b3..04ef324c7 100644 --- a/scc/gui/daemon_manager.py +++ b/scc/gui/daemon_manager.py @@ -418,20 +418,8 @@ def get_button_icon(config, button, prefer_bw=False): returns icon filename assigned to that button in controller config or default if config is invalid or button unassigned. - - A controller whose gui.background names a button-images// subdir - (e.g. "deck") gets its own icons from there, falling back to the shared - set. This is how the Deck overrides the Steam Controller back-button and - trackpad art. """ - controller_set = None - try: - controller_set = config["gui"]["background"] - except (KeyError, TypeError): - pass - return find_button_image( - ControllerManager.get_button_name(config, button), - prefer_bw=prefer_bw, controller_set=controller_set) + return find_button_image(ControllerManager.get_button_name(config, button), prefer_bw=prefer_bw) @staticmethod def get_button_name(config, button): diff --git a/scc/tools.py b/scc/tools.py index 7b407cd57..b31918c14 100644 --- a/scc/tools.py +++ b/scc/tools.py @@ -272,17 +272,10 @@ def find_icon( def find_button_image( - name: str | None, prefer_bw: bool = False, controller_set: str | None = None, + name: str | None, prefer_bw: bool = False, ) -> tuple[None, bool] | tuple[str, bool]: - """Similar to find_icon, but searches for a button image. - - controller_set (e.g. "deck") lets a controller override the shared images - with its own button-images//.svg, falling back to the - shared set when no override exists. - """ - base = get_button_images_path() - paths = (os.path.join(base, controller_set), base) if controller_set else (base,) - return find_icon(nameof(name), prefer_bw, paths=paths, extensions=("svg",)) + """Similar to find_icon, but searches for button image""" + return find_icon(nameof(name), prefer_bw, paths=[get_button_images_path()], extensions=("svg",)) def menu_is_default(name: str) -> bool: diff --git a/setup.py b/setup.py index c82e4f8a1..cc9fc0354 100755 --- a/setup.py +++ b/setup.py @@ -31,6 +31,13 @@ ] + [ # menu icons subfolders ("share/scc/images/menu-icons/" + x.split("/")[-1], [x + "/LICENSES"] + glob.glob(x + "/*.png")) for x in glob.glob("images/menu-icons/*") +] + [ # per-controller icon override dirs: images//*.svg (e.g. sc2, deck) + ("share/scc/images/" + d.split("/")[-1], glob.glob(d + "/*.svg")) + for d in (p.rstrip("/") for p in glob.glob("images/*/")) + if d.split("/")[-1] not in ( + "button-images", "controller-icons", "controller-images", + "24x24", "256x256", "menu-icons") + and glob.glob(d + "/*.svg") ] extensions = [ From 0448e16ec9cb7a903ca227796b740b4cbf0cc5f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sun, 28 Jun 2026 17:38:09 +0200 Subject: [PATCH 23/98] feat(images): consistent around-controller icons for Deck & SC2 (from controller art) Make each button's mapping-box icon match how the button looks on the controller drawing, fit to the existing icon footprint so the layout is unchanged. - button-images/ELIPSE{,.bw}.svg: add a centred "STEAM" label (the Deck C-slot / Steam button image). - images/deck/{BACK,C,START,DOTS}.svg: Deck system-button icons (View/Steam/Menu/ QAM) from the stamped VIEW/ELIPSE/MENU/DOTS images, standardised to 49x32 (Steam was an odd 32x32 circle; QAM had no icon at all). - images/sc2/{LT,RT,LB,RB}.svg: new trigger/bumper icons lifted from sc2.svg. - images/sc2/{LGRIP,LGRIP2,RGRIP,RGRIP2}.svg: replace the early placeholder paddle art with the paddle shapes from sc2.svg. Co-Authored-By: Claude Opus 4.8 --- images/button-images/ELIPSE.bw.svg | 81 +++++------------------------ images/button-images/ELIPSE.svg | 82 +++++------------------------- images/deck/BACK.svg | 15 ++++++ images/deck/C.svg | 11 ++++ images/deck/DOTS.svg | 8 +++ images/deck/START.svg | 16 ++++++ images/sc2/LB.svg | 7 +++ images/sc2/LT.svg | 7 +++ images/sc2/RB.svg | 7 +++ images/sc2/RT.svg | 7 +++ 10 files changed, 102 insertions(+), 139 deletions(-) create mode 100644 images/deck/BACK.svg create mode 100644 images/deck/C.svg create mode 100644 images/deck/DOTS.svg create mode 100644 images/deck/START.svg create mode 100644 images/sc2/LB.svg create mode 100644 images/sc2/LT.svg create mode 100644 images/sc2/RB.svg create mode 100644 images/sc2/RT.svg diff --git a/images/button-images/ELIPSE.bw.svg b/images/button-images/ELIPSE.bw.svg index 7762731ab..8130cadb4 100644 --- a/images/button-images/ELIPSE.bw.svg +++ b/images/button-images/ELIPSE.bw.svg @@ -1,75 +1,18 @@ - - - - - - - + + + + + - + image/svg+xml - + - - - - + + + + STEAM - + \ No newline at end of file diff --git a/images/button-images/ELIPSE.svg b/images/button-images/ELIPSE.svg index b421c8dac..e29084217 100644 --- a/images/button-images/ELIPSE.svg +++ b/images/button-images/ELIPSE.svg @@ -1,76 +1,18 @@ - - - - - - - + + + + + - + image/svg+xml - + - - - - + + + + STEAM - + \ No newline at end of file diff --git a/images/deck/BACK.svg b/images/deck/BACK.svg new file mode 100644 index 000000000..3d3b206c4 --- /dev/null +++ b/images/deck/BACK.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/images/deck/C.svg b/images/deck/C.svg new file mode 100644 index 000000000..75c4a44dc --- /dev/null +++ b/images/deck/C.svg @@ -0,0 +1,11 @@ + + + + + + + STEAM + + + + diff --git a/images/deck/DOTS.svg b/images/deck/DOTS.svg new file mode 100644 index 000000000..45865c45b --- /dev/null +++ b/images/deck/DOTS.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/images/deck/START.svg b/images/deck/START.svg new file mode 100644 index 000000000..ac566553d --- /dev/null +++ b/images/deck/START.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/images/sc2/LB.svg b/images/sc2/LB.svg new file mode 100644 index 000000000..9a08c5c4a --- /dev/null +++ b/images/sc2/LB.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/images/sc2/LT.svg b/images/sc2/LT.svg new file mode 100644 index 000000000..1b7c95f87 --- /dev/null +++ b/images/sc2/LT.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/images/sc2/RB.svg b/images/sc2/RB.svg new file mode 100644 index 000000000..62e4ed1b6 --- /dev/null +++ b/images/sc2/RB.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/images/sc2/RT.svg b/images/sc2/RT.svg new file mode 100644 index 000000000..ba7f9eb54 --- /dev/null +++ b/images/sc2/RT.svg @@ -0,0 +1,7 @@ + + + + + + + From 568a81aca1a45d3e54a58392dccb9f8ad9e5d877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Fri, 3 Jul 2026 15:39:06 +0200 Subject: [PATCH 24/98] fix(images): lighten the Deck Steam/QAM button infill --- images/button-images/DOTS.svg | 2 +- images/button-images/ELIPSE.svg | 4 ++-- images/deck/C.svg | 10 ++++------ images/deck/DOTS.svg | 8 +++----- 4 files changed, 10 insertions(+), 14 deletions(-) diff --git a/images/button-images/DOTS.svg b/images/button-images/DOTS.svg index 1aa42bda3..e084c592e 100644 --- a/images/button-images/DOTS.svg +++ b/images/button-images/DOTS.svg @@ -51,7 +51,7 @@ inkscape:groupmode="layer" id="layer1"> - - + + STEAM \ No newline at end of file diff --git a/images/deck/C.svg b/images/deck/C.svg index 75c4a44dc..ff50726ff 100644 --- a/images/deck/C.svg +++ b/images/deck/C.svg @@ -1,11 +1,9 @@ - - - - + + + STEAM - - + diff --git a/images/deck/DOTS.svg b/images/deck/DOTS.svg index 45865c45b..da6038235 100644 --- a/images/deck/DOTS.svg +++ b/images/deck/DOTS.svg @@ -1,8 +1,6 @@ - - - - - + + + From 76de62297d0fa9de5217d88eba5095d668907f8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sun, 28 Jun 2026 21:22:54 +0200 Subject: [PATCH 25/98] fix(gui): SC v1 mislayout - gui: SC v1 (8 default axes + a C button) wrongly tripped the Deck UI layout, which moves the GYRO button over the Steam logo on sc.svg and shoves the Steam button to the right column. Gate the "deck" layout on "rstick_x" in axes (SC2/Deck have a right stick, SC v1 does not). - gui: add btRSTICK to the hide-when-unavailable set, so the right-stick mapping button no longer shows (greyed) on controllers without a right stick. Co-Authored-By: Claude Opus 4.8 --- scc/gui/app.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scc/gui/app.py b/scc/gui/app.py index 1862abac1..c3fb3cec9 100644 --- a/scc/gui/app.py +++ b/scc/gui/app.py @@ -329,6 +329,7 @@ def apply_gui_config_buttons(self, config) -> None: btC = self.builder.get_object("btC") btLGRIPTOUCH = self.builder.get_object("btLGRIPTOUCH") btRGRIPTOUCH = self.builder.get_object("btRGRIPTOUCH") + btRSTICK = self.builder.get_object("btRSTICK") buttons = ControllerImage.get_names(config.get("buttons", {})) axes = ControllerImage.get_names(config.get("axes", {})) @@ -374,13 +375,13 @@ def apply_gui_config_buttons(self, config) -> None: # TODO: Maybe actual detection w.set_sensitive(gyros) - for w in (btC, btCPAD, btDPAD, btGYRO, btLGRIPTOUCH, btRGRIPTOUCH): + for w in (btC, btCPAD, btDPAD, btGYRO, btLGRIPTOUCH, btRGRIPTOUCH, btRSTICK): if w: w.set_visible(w.get_sensitive()) # Re-layout if needed expected_layout = "default" - if len(axes) >= 8 and btC.get_sensitive(): + if "rstick_x" in axes and btC.get_sensitive(): expected_layout = "deck" if expected_layout != self.current_ui_layout: From 66c1e7387443d2534acac935f43f167ad485147d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sun, 28 Jun 2026 21:26:53 +0200 Subject: [PATCH 26/98] =?UTF-8?q?fix(gui):=20Deck=20button=20layout=20?= =?UTF-8?q?=E2=80=94=20D-Pad=20to=20top,=20Steam=20to=20the=20left=20colum?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Deck layout put the Steam (C) button in the right column and the D-Pad at the bottom of the left column. Move the D-Pad to the top of the left column and Steam to its bottom, so the left column reads D-Pad, L4, L5, View, Steam — matching the Deck's physical arrangement. Co-Authored-By: Claude Opus 4.8 --- scc/gui/app.py | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/scc/gui/app.py b/scc/gui/app.py index c3fb3cec9..7bc230e26 100644 --- a/scc/gui/app.py +++ b/scc/gui/app.py @@ -393,28 +393,24 @@ def apply_gui_config_buttons(self, config) -> None: def apply_ui_layout(self, layout: str) -> None: """Changes layout of ui elements to fit additional buttons needed for Deck""" if layout == "deck": - # Move 'C' button bellow LGRIP - btRGRIP: Gtk.Button | None = self.builder.get_object("btRGRIP") + btLGRIP: Gtk.Button | None = self.builder.get_object("btLGRIP") + # Put 'DPAD' at the top of the left column (above the back paddles), + # to mirror the Deck's physical layout: D-Pad, L4, L5, View, Steam. + btDPAD: Gtk.Button | None = self.builder.get_object("btDPAD") + btDPAD.get_parent().remove(btDPAD) + btLGRIP.get_parent().pack_start(btDPAD, False, True, 6) + btLGRIP.get_parent().reorder_child(btDPAD, 2) + # Move 'C' (Steam) to the bottom of the LEFT column (was the right) btC: Gtk.Button | None = self.builder.get_object("btC") btC.get_parent().remove(btC) btC.set_margin_end(0) - btRGRIP.get_parent().append(btC) - reorder_box_child(btRGRIP.get_parent(), btC, 5) + btLGRIP.get_parent().pack_start(btC, False, True, 0) # Move 'GYRO' button to middle of image (where C was) btGYRO: Gtk.Button | None = self.builder.get_object("btGYRO") btGYRO.get_parent().remove(btGYRO) vbC = self.builder.get_object("vbC") vbC.append(btGYRO) btGYRO.set_margin_top(30) - # Resize buttons at bottom - # for w in ['btLSTICK', 'btRSTICK', 'btLPAD', 'btRPAD']: - # w.set_size_request(150, -1) - # Move 'DPAD' bellow 'LGRIP' - btLGRIP: Gtk.Button | None = self.builder.get_object("btLGRIP") - btDPAD: Gtk.Button | None = self.builder.get_object("btDPAD") - btDPAD.get_parent().remove(btDPAD) - btLGRIP.get_parent().append(btDPAD) - reorder_box_child(btLGRIP.get_parent(), btDPAD, 5) def setup_statusicon(self) -> None: if self.statusicon is None: From c28421bbe2c790e9c1323b5a8d89142001c01d03 Mon Sep 17 00:00:00 2001 From: Martin Rys Date: Mon, 17 Aug 2026 21:48:50 +0200 Subject: [PATCH 27/98] fix(gui,osd): invisible OSD submenus - osd/menu: a submenu reuses the parent menu's input lock (the parent forwards events), so it never requests its own lock and _on_inputs_locked() never fires. The reveal-when-locked gate added for the dead-menu fix therefore kept the submenu window permanently hidden, though it remained navigable via the forwarded input. Mark a submenu's inputs as already locked so it reveals. Fixes the invisible "All Profiles" and "Autoswitch Options" submenus. Co-Authored-By: Claude Opus 4.8 --- scc/osd/menu.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scc/osd/menu.py b/scc/osd/menu.py index b50a71dbb..ef1a41577 100644 --- a/scc/osd/menu.py +++ b/scc/osd/menu.py @@ -199,6 +199,14 @@ def use_daemon(self, d): if not self._is_submenu: self._connect_handlers() self.on_daemon_connected(self.daemon) + else: + # A submenu reuses the parent menu's input lock (the parent forwards + # events to it), so it never requests its own lock and + # _on_inputs_locked() is never called. Treat its inputs as already + # locked; otherwise the reveal-when-locked gate added for the + # dead-menu fix keeps the submenu window permanently hidden (it is + # navigable via forwarded input but never shown). + self._inputs_locked = True def use_config(self, c): """Allows reusing already existin Config instance in same process. From ad9819e9984d1e586875f00b67ed12be6b56e078 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Mon, 29 Jun 2026 06:35:13 +0200 Subject: [PATCH 28/98] feat(gui): show the Steam logo on the Steam Controller v1 C button The SC v1 fell back to GUI defaults (its drivers had no get_gui_config_file), so its Steam (C) button used the generic circle image while only the SC2 showed the logo. - SCController.get_gui_config_file() now returns sc-config.json (inherited by SCByCable / SCByBt), so the GUI loads it for the SC v1. - sc-config.json is now GUI-only (background + button slot images). Dropping its incomplete "buttons" dict keeps button sensitivity at the defaults (the default load goes through _ensure_config, which fills missing keys). The C slot now points at sc2_C, the Steam logo. - images/sc/C.svg added so the side mapping-box icon shows the logo too, via the per-controller images//.svg override. Co-Authored-By: Claude Opus 4.8 --- images/sc-config.json | 7 ++++--- images/sc/C.svg | 2 ++ scc/drivers/sc_dongle.py | 5 +++++ 3 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 images/sc/C.svg diff --git a/images/sc-config.json b/images/sc-config.json index cb069d7c6..aad58aede 100644 --- a/images/sc-config.json +++ b/images/sc-config.json @@ -1,12 +1,13 @@ { - "_ " : "Currently, this file is used only when changing controller image", - "__" : "manually from context menu", + "_ ": "Loaded for the Steam Controller (v1) via SCController.get_gui_config_file", + "__": "and on manual image change. GUI section only: button/axis sets stay at", + "___": "their defaults; this just points the Steam (C) button at the logo image.", "gui": { "background": "sc", "buttons": [ "A", "B", "X", "Y", "BACK", - "C", "START", "LB", "RB", "LT", "RT", + "sc2_C", "START", "LB", "RB", "LT", "RT", "LSTICK", "LPAD", "RPAD", "LG", "RG" ] }, diff --git a/images/sc/C.svg b/images/sc/C.svg new file mode 100644 index 000000000..aff696047 --- /dev/null +++ b/images/sc/C.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/scc/drivers/sc_dongle.py b/scc/drivers/sc_dongle.py index 96125fd79..d80749547 100644 --- a/scc/drivers/sc_dongle.py +++ b/scc/drivers/sc_dongle.py @@ -206,6 +206,11 @@ def __init__(self, driver: Deck | Dongle | SCByBt | SCByCable | SC2Device, ccidx def get_type(self) -> str: return "sc" + def get_gui_config_file(self) -> str: + # Steam Controller (v1): GUI-only config that puts the Steam logo on the + # C button (image + side icon). Inherited by SCByCable / SCByBt. + return "sc-config.json" + def __repr__(self) -> str: return f"" From 5d99cd79892e15364945febc708a19355e78eaba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Mon, 29 Jun 2026 06:57:01 +0200 Subject: [PATCH 29/98] fix(gui): size the SC v1 Steam logo to the generic C button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sc2_C.svg is sized for the SC2's larger AREA_C (~48.8 units); placed on the SC v1 at scale 1.0 it overflowed and overlapped the START button. Add button-images/sc_C.svg — the same Steam logo scaled to the generic C button's ~36.86 box (origin re-aligned to match) — and point sc-config.json at it. Co-Authored-By: Claude Opus 4.8 --- images/button-images/sc_C.svg | 2 ++ images/sc-config.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 images/button-images/sc_C.svg diff --git a/images/button-images/sc_C.svg b/images/button-images/sc_C.svg new file mode 100644 index 000000000..66a3b04ab --- /dev/null +++ b/images/button-images/sc_C.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/images/sc-config.json b/images/sc-config.json index aad58aede..537f80c8b 100644 --- a/images/sc-config.json +++ b/images/sc-config.json @@ -7,7 +7,7 @@ "background": "sc", "buttons": [ "A", "B", "X", "Y", "BACK", - "sc2_C", "START", "LB", "RB", "LT", "RT", + "sc_C", "START", "LB", "RB", "LT", "RT", "LSTICK", "LPAD", "RPAD", "LG", "RG" ] }, From c591ecb2a3a0bfe28371f302a6bd52cd667b3502 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Thu, 2 Jul 2026 01:12:25 +0200 Subject: [PATCH 30/98] fix(gui): clear Input Test highlights when it is turned off 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 --- scc/gui/app.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scc/gui/app.py b/scc/gui/app.py index 7bc230e26..ba23e3c62 100644 --- a/scc/gui/app.py +++ b/scc/gui/app.py @@ -1569,6 +1569,12 @@ def hide_error(self, *a): def on_daemon_reconfigured(self, *a) -> None: log.debug("Reloading config...") self.config.reload() + # If Input Test was just turned off, drop any highlights left over from + # the last observed press (e.g. a held grip sensor): with sniffing off no + # release event arrives to clear them, so they'd stay stuck on the image. + if not self.config["enable_sniffing"] and self.hilights[App.OBSERVE_COLOR]: + self.hilights[App.OBSERVE_COLOR].clear() + self._update_background() for ps in self.profile_switchers: ps.set_controller(ps.get_controller()) self.rebuild_controller_selector() From 6b9e98b1de6d301e977b822f9d352ab2143694e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Thu, 2 Jul 2026 10:12:52 +0200 Subject: [PATCH 31/98] fix(gui): Deck back-paddle order in side panel (L4/R4 above L5/R5) 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 --- scc/gui/app.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/scc/gui/app.py b/scc/gui/app.py index ba23e3c62..93e1d73a3 100644 --- a/scc/gui/app.py +++ b/scc/gui/app.py @@ -387,6 +387,26 @@ def apply_gui_config_buttons(self, config) -> None: if expected_layout != self.current_ui_layout: self.apply_ui_layout(expected_layout) + # The Steam Deck maps its lower back paddles to LGRIP/RGRIP (labelled + # L5/R5) and the upper ones to LGRIP2/RGRIP2 (L4/R4) - the reverse of the + # SC2. Left alone the side panel reads L5/R5 above L4/R4; reorder the + # paddle buttons so they match the device (L4/R4 above L5/R5). Every other + # controller (incl. the SC2, whose LGRIP=L4) keeps the .glade order. + if bg == "deck": + grip_pairs = (("btLGRIP2", "btLGRIP"), ("btRGRIP2", "btRGRIP")) + else: + grip_pairs = (("btLGRIP", "btLGRIP2"), ("btRGRIP", "btRGRIP2")) + for above, below in grip_pairs: + wa = self.builder.get_object(above) + wb = self.builder.get_object(below) + if wa and wb and wa.get_parent() is wb.get_parent(): + parent = wa.get_parent() + current = wa.get_next_sibling() + while current is not None and current is not wb: + current = current.get_next_sibling() + if current is None: + parent.reorder_child_after(wa, wb.get_prev_sibling()) + stckEditor.set_visible_child(grEditor) GLib.idle_add(self.on_c_size_allocate) @@ -398,13 +418,13 @@ def apply_ui_layout(self, layout: str) -> None: # to mirror the Deck's physical layout: D-Pad, L4, L5, View, Steam. btDPAD: Gtk.Button | None = self.builder.get_object("btDPAD") btDPAD.get_parent().remove(btDPAD) - btLGRIP.get_parent().pack_start(btDPAD, False, True, 6) - btLGRIP.get_parent().reorder_child(btDPAD, 2) + btLGRIP.get_parent().append(btDPAD) + reorder_box_child(btLGRIP.get_parent(), btDPAD, 2) # Move 'C' (Steam) to the bottom of the LEFT column (was the right) btC: Gtk.Button | None = self.builder.get_object("btC") btC.get_parent().remove(btC) btC.set_margin_end(0) - btLGRIP.get_parent().pack_start(btC, False, True, 0) + btLGRIP.get_parent().append(btC) # Move 'GYRO' button to middle of image (where C was) btGYRO: Gtk.Button | None = self.builder.get_object("btGYRO") btGYRO.get_parent().remove(btGYRO) From 6bf1819630b169b3462f692ff9a97923b438c455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Fri, 3 Jul 2026 15:14:24 +0200 Subject: [PATCH 32/98] build(images): add an svgo config and wire the asset generators to minify 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, hover areas, display:none layers, custom attributes, comma-separated transforms (SVGEditor.parse_transform is comma-only) and the glyph structure (_fill_button_images overwrites that group's transform). Co-Authored-By: Claude Opus 4.8 --- tools/gen_binding_display.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tools/gen_binding_display.py b/tools/gen_binding_display.py index b3c33ae76..b22ddabbf 100644 --- a/tools/gen_binding_display.py +++ b/tools/gen_binding_display.py @@ -25,8 +25,12 @@ Run from repo root: python3 tools/gen_binding_display.py """ import os +import sys import xml.etree.ElementTree as ET +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import _svgo # noqa: E402 + SVG = "http://www.w3.org/2000/svg" SRC = "images/controller-images/sc2.svg" # AREA anchors for markers ART = "tools/binding-display-sc2-art.svg" # restyled controller drawing @@ -55,11 +59,11 @@ ET.register_namespace("", SVG) -def q(tag): +def q(tag: str) -> str: return "{%s}%s" % (SVG, tag) -def parse_viewbox(svg): +def parse_viewbox(svg: ET.Element) -> tuple[float, float]: vb = svg.get("viewBox") if vb: p = [float(x) for x in vb.replace(",", " ").split()] @@ -67,7 +71,7 @@ def parse_viewbox(svg): return float(svg.get("width")), float(svg.get("height")) -def read_area_centers(root): +def read_area_centers(root: ET.Element) -> dict[str, tuple[float, float]]: """AREA_ rects sit in an untransformed layer in display coords, so their centres are read directly.""" centers = {} @@ -80,7 +84,7 @@ def read_area_centers(root): return centers -def main(): +def main() -> None: if not os.path.exists(SRC): raise SystemExit("run from repo root: %s not found" % SRC) src = ET.parse(SRC).getroot() @@ -92,7 +96,7 @@ def main(): ox = (CANVAS_W - cw * s) / 2.0 oy = (CANVAS_H - ch * s) / 2.0 - def to_canvas(pt): + def to_canvas(pt: tuple[float, float]) -> tuple[float, float]: return ox + s * pt[0], oy + s * pt[1] out = ET.Element(q("svg"), { @@ -139,6 +143,7 @@ def to_canvas(pt): "style": "fill:#000000;fill-opacity:0;stroke:#06a400;stroke-width:1"}) ET.ElementTree(out).write(OUT, encoding="unicode", xml_declaration=True) + _svgo.optimize(OUT) print("wrote", OUT) print(" art inlined from %s; marker mapping scale %.3f at (%.1f, %.1f)" % (ART, s, ox, oy)) From 048c2d749b0af931b7c46a7f8bcd7c02aadb2dba Mon Sep 17 00:00:00 2001 From: Martin Rys Date: Sun, 13 Sep 2026 21:35:28 +0200 Subject: [PATCH 33/98] perf(images): svgo-optimize the SC1/SC2/Deck SVGs 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 glyph group and every comma-separated transform is preserved. Co-Authored-By: Claude Opus 4.8 --- images/binding-display-sc2.svg | 39 +--------------------- images/button-images/sc_C.svg | 3 +- images/deck/BACK.svg | 16 +-------- images/deck/C.svg | 10 +----- images/deck/DOTS.svg | 7 +--- images/deck/LGRIP.svg | 54 +------------------------------ images/deck/LGRIP2.svg | 54 +------------------------------ images/deck/LPAD.svg | 49 +--------------------------- images/deck/RGRIP.svg | 54 +------------------------------ images/deck/RGRIP2.svg | 54 +------------------------------ images/deck/RPAD.svg | 49 +--------------------------- images/deck/START.svg | 17 +--------- images/sc/C.svg | 3 +- images/sc2/LB.svg | 8 +---- images/sc2/LT.svg | 8 +---- images/sc2/RB.svg | 8 +---- images/sc2/RT.svg | 8 +---- tools/binding-display-sc2-art.svg | 39 +--------------------- 18 files changed, 18 insertions(+), 462 deletions(-) diff --git a/images/binding-display-sc2.svg b/images/binding-display-sc2.svg index 2baf69256..63cbf55ce 100644 --- a/images/binding-display-sc2.svg +++ b/images/binding-display-sc2.svg @@ -1,38 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - X \ No newline at end of file +X \ No newline at end of file diff --git a/images/button-images/sc_C.svg b/images/button-images/sc_C.svg index 66a3b04ab..2253a099e 100644 --- a/images/button-images/sc_C.svg +++ b/images/button-images/sc_C.svg @@ -1,2 +1 @@ - - \ No newline at end of file + \ No newline at end of file diff --git a/images/deck/BACK.svg b/images/deck/BACK.svg index 3d3b206c4..d818d4ce4 100644 --- a/images/deck/BACK.svg +++ b/images/deck/BACK.svg @@ -1,15 +1 @@ - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/images/deck/C.svg b/images/deck/C.svg index ff50726ff..0e06f2248 100644 --- a/images/deck/C.svg +++ b/images/deck/C.svg @@ -1,9 +1 @@ - - - - - - STEAM - - - +STEAM \ No newline at end of file diff --git a/images/deck/DOTS.svg b/images/deck/DOTS.svg index da6038235..fc0f4b4cb 100644 --- a/images/deck/DOTS.svg +++ b/images/deck/DOTS.svg @@ -1,6 +1 @@ - - - - - - + \ No newline at end of file diff --git a/images/deck/LGRIP.svg b/images/deck/LGRIP.svg index 5c1a716d3..cff736a50 100644 --- a/images/deck/LGRIP.svg +++ b/images/deck/LGRIP.svg @@ -1,53 +1 @@ - - - - - - - L5 - - +L5 \ No newline at end of file diff --git a/images/deck/LGRIP2.svg b/images/deck/LGRIP2.svg index fc8e7858b..119da8e4c 100644 --- a/images/deck/LGRIP2.svg +++ b/images/deck/LGRIP2.svg @@ -1,53 +1 @@ - - - - - - - L4 - - +L4 \ No newline at end of file diff --git a/images/deck/LPAD.svg b/images/deck/LPAD.svg index 28df978f3..5cc901816 100644 --- a/images/deck/LPAD.svg +++ b/images/deck/LPAD.svg @@ -1,48 +1 @@ - - - - - - - - + \ No newline at end of file diff --git a/images/deck/RGRIP.svg b/images/deck/RGRIP.svg index adcea6e9d..0849c27e8 100644 --- a/images/deck/RGRIP.svg +++ b/images/deck/RGRIP.svg @@ -1,53 +1 @@ - - - - - - - R5 - - +R5 \ No newline at end of file diff --git a/images/deck/RGRIP2.svg b/images/deck/RGRIP2.svg index 39a8fede2..8c93744eb 100644 --- a/images/deck/RGRIP2.svg +++ b/images/deck/RGRIP2.svg @@ -1,53 +1 @@ - - - - - - - R4 - - +R4 \ No newline at end of file diff --git a/images/deck/RPAD.svg b/images/deck/RPAD.svg index 3240c1649..5cc901816 100644 --- a/images/deck/RPAD.svg +++ b/images/deck/RPAD.svg @@ -1,48 +1 @@ - - - - - - - - + \ No newline at end of file diff --git a/images/deck/START.svg b/images/deck/START.svg index ac566553d..252773da4 100644 --- a/images/deck/START.svg +++ b/images/deck/START.svg @@ -1,16 +1 @@ - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/images/sc/C.svg b/images/sc/C.svg index aff696047..5bb1a60a9 100644 --- a/images/sc/C.svg +++ b/images/sc/C.svg @@ -1,2 +1 @@ - - \ No newline at end of file + \ No newline at end of file diff --git a/images/sc2/LB.svg b/images/sc2/LB.svg index 9a08c5c4a..db34ce7ee 100644 --- a/images/sc2/LB.svg +++ b/images/sc2/LB.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/images/sc2/LT.svg b/images/sc2/LT.svg index 1b7c95f87..0c9322547 100644 --- a/images/sc2/LT.svg +++ b/images/sc2/LT.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/images/sc2/RB.svg b/images/sc2/RB.svg index 62e4ed1b6..d9a7fa85d 100644 --- a/images/sc2/RB.svg +++ b/images/sc2/RB.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/images/sc2/RT.svg b/images/sc2/RT.svg index ba7f9eb54..c18d7c04d 100644 --- a/images/sc2/RT.svg +++ b/images/sc2/RT.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/tools/binding-display-sc2-art.svg b/tools/binding-display-sc2-art.svg index cf4caccbe..7d86bfcc3 100644 --- a/tools/binding-display-sc2-art.svg +++ b/tools/binding-display-sc2-art.svg @@ -1,38 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + \ No newline at end of file From 80f7764800cd569b5693a829b952e73562e54014 Mon Sep 17 00:00:00 2001 From: Martin Rys Date: Sun, 2 Aug 2026 15:01:27 +0200 Subject: [PATCH 34/98] style(types): annotate the Steam Controller v2 / Deck changeset 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 --- scc/config.py | 2 +- scc/drivers/sc_dongle.py | 2 +- scc/drivers/usb.py | 5 ++- scc/gui/ae/buttons.py | 2 +- scc/gui/app.py | 14 +++---- scc/gui/modeshift_editor.py | 6 +-- scc/lib/xwrappers.py | 2 +- scc/modifiers.py | 12 +++--- scc/osd/binding_display.py | 74 ++++++++++++++++++++++++++++++------- scc/osd/grid_menu.py | 2 +- scc/osd/keyboard.py | 2 +- scc/osd/menu.py | 10 ++--- scc/osd/quick_menu.py | 2 +- scc/osd/radial_menu.py | 2 +- scc/sccdaemon.py | 2 +- 15 files changed, 95 insertions(+), 44 deletions(-) diff --git a/scc/config.py b/scc/config.py index 364113f78..72b33a8d4 100644 --- a/scc/config.py +++ b/scc/config.py @@ -14,7 +14,7 @@ log = logging.getLogger("Config") -def _is_steam_deck(): +def _is_steam_deck() -> bool: """Return True when running on Steam Deck hardware (LCD reports DMI product name 'Jupiter', OLED 'Galileo'). diff --git a/scc/drivers/sc_dongle.py b/scc/drivers/sc_dongle.py index d80749547..13b6d71d5 100644 --- a/scc/drivers/sc_dongle.py +++ b/scc/drivers/sc_dongle.py @@ -310,7 +310,7 @@ def cb(rawserial) -> None: on_giveup=self._on_serial_giveup, ) - def _on_serial_giveup(self): + def _on_serial_giveup(self) -> None: """Called when the GET_SERIAL request kept stalling. Add the controller with a generated id anyway, so it still appears (it just won't have a stable serial-based identity).""" diff --git a/scc/drivers/usb.py b/scc/drivers/usb.py index 4aa01e91e..bfa7578d2 100644 --- a/scc/drivers/usb.py +++ b/scc/drivers/usb.py @@ -20,6 +20,8 @@ import usb1 if TYPE_CHECKING: + from collections.abc import Callable + from usb1 import USBContext, USBDevice, USBDeviceHandle, USBTransfer from scc.drivers.hiddrv import HIDDrvFakeDaemon @@ -103,7 +105,8 @@ def overwrite_control(self, index, data) -> None: break self.send_control(index, data) - def make_request(self, index, callback, data, size=64, on_giveup=None) -> None: + def make_request(self, index: int, callback: Callable[[bytes], None], data: bytes, + size: int = 64, on_giveup: Callable[[], None] | None = None) -> None: """Schedule a synchronous request that requires response. Request is done ASAP and provided callback is called with received data. diff --git a/scc/gui/ae/buttons.py b/scc/gui/ae/buttons.py index ab03254be..b6042210a 100644 --- a/scc/gui/ae/buttons.py +++ b/scc/gui/ae/buttons.py @@ -181,7 +181,7 @@ def on_cbRepeat_toggled(self, cbRepeat): cbToggle.set_active(False) self.apply_keys() - def on_cbActOnRelease_toggled(self, cb): + def on_cbActOnRelease_toggled(self, cb: Gtk.CheckButton) -> None: self.apply_keys() def hide_toggle(self): diff --git a/scc/gui/app.py b/scc/gui/app.py index 93e1d73a3..adae46aa5 100644 --- a/scc/gui/app.py +++ b/scc/gui/app.py @@ -1152,7 +1152,7 @@ def remove_switcher(self, s): if len(vbSwitchers.observe_children()) == 2: sepSwitchers.set_visible(False) - def _build_controller_selector(self): + def _build_controller_selector(self) -> Gtk.ComboBox: """Creates the 'which controller' combo and packs it above the profile switcher. Hidden until 2+ controllers are connected.""" # model columns: controller object, icon pixbuf, name, current profile @@ -1179,7 +1179,7 @@ def _build_controller_selector(self): combo.set_visible(False) return combo - def _load_controller_pixbuf(self, c): + def _load_controller_pixbuf(self, c: ControllerManager) -> GdkPixbuf.Pixbuf | None: """Loads the 24px icon for a controller, or None if unavailable.""" try: iconname = self.config.get_controller_config(c.get_id()).get("icon") @@ -1191,7 +1191,7 @@ def _load_controller_pixbuf(self, c): log.debug("No selector icon for %s: %s", c.get_id(), e) return None - def controller_display_name(self, c): + def controller_display_name(self, c: ControllerManager) -> str: """Human-friendly controller name: the user's custom name if one was set in controller settings, otherwise a per-type label (e.g. 'Steam Controller v2') rather than the raw internal id (e.g. 'sc1' / '3:4').""" @@ -1200,7 +1200,7 @@ def controller_display_name(self, c): return name # user-customised return _(CONTROLLER_TYPE_NAMES.get(c.get_type(), "Controller")) - def rebuild_controller_selector(self): + def rebuild_controller_selector(self) -> None: """Refills the controller selector from the connected controllers and shows it only when more than one is connected.""" combo = self.controller_selector @@ -1234,14 +1234,14 @@ def rebuild_controller_selector(self): combo.set_visible(multi) self.builder.get_object("sepSwitchers").set_visible(multi) - def _refresh_selector_profiles(self, combo, *a): + def _refresh_selector_profiles(self, combo: Gtk.ComboBox, *a: object) -> None: """Refreshes each row's profile subtext when the dropdown is opened.""" if not combo.get_property("popup-shown"): return for row in combo.get_model(): row[3] = get_profile_name(row[0].get_profile() or "") or "" - def on_controller_selected(self, combo): + def on_controller_selected(self, combo: Gtk.ComboBox) -> None: """Makes the chosen controller the active (edited) one, with the same image transition the old switch-to button used.""" if self._selector_recursing: @@ -1708,7 +1708,7 @@ def _startup_tray_unavailable(self): self.window.present() return GLib.SOURCE_REMOVE - def open_osk_editor(self): + def open_osk_editor(self) -> None: """Opens the standalone OSD-keyboard bindings editor (the same window reachable from Settings > Menus & Keyboard > Advanced) as the only window, quitting the app when it closes. Backs the OSD menu's diff --git a/scc/gui/modeshift_editor.py b/scc/gui/modeshift_editor.py index 2e646c0e9..bf506cfbf 100644 --- a/scc/gui/modeshift_editor.py +++ b/scc/gui/modeshift_editor.py @@ -278,9 +278,9 @@ def on_nomodclear_clicked(self, button, *a): actionButton = self.action_widgets[self.current_page][1] actionButton.set_label(self.nomods[self.current_page].describe(self.mode)) - def on_btTouch_clicked(self, *a): + def on_btTouch_clicked(self, *a: object) -> None: """'Touch' tab: edit the action bound to the stick-touch sensor.""" - def on_chosen(id, action): + def on_chosen(id: str, action: Action) -> None: self.touch_action = action self.builder.get_object("btTouch").set_label(action.describe(self.mode)) @@ -288,7 +288,7 @@ def on_chosen(id, action): ae.set_input(self.touch_id, self.touch_action, mode=Action.AC_BUTTON) ae.show(self.window) - def on_btClearTouch_clicked(self, *a): + def on_btClearTouch_clicked(self, *a: object) -> None: self.touch_action = NoAction() self.builder.get_object("btTouch").set_label(self.touch_action.describe(self.mode)) diff --git a/scc/lib/xwrappers.py b/scc/lib/xwrappers.py index fb559ef16..8511c44bf 100644 --- a/scc/lib/xwrappers.py +++ b/scc/lib/xwrappers.py @@ -59,7 +59,7 @@ def _load_lib(*names): _XErrorHandler = CFUNCTYPE(c_int, c_void_p, c_void_p) -def _ignore_x_error(display, error): +def _ignore_x_error(display: int, error: int) -> int: return 0 diff --git a/scc/modifiers.py b/scc/modifiers.py index c48c2be9e..0533decf6 100644 --- a/scc/modifiers.py +++ b/scc/modifiers.py @@ -6,6 +6,8 @@ """ from __future__ import annotations +from __future__ import annotations + import inspect import itertools import logging @@ -357,23 +359,23 @@ class InvertedButtonModifier(Modifier): """ COMMAND = "inverted" - def describe(self, context): + def describe(self, context: int) -> str: if context in (Action.AC_STICK, Action.AC_PAD): return _("(act on release)") + "\n" + self.action.describe(context) return _("(act on release)") + " " + self.action.describe(context) - def strip(self): + def strip(self) -> Action: return self.action.strip() - def compress(self): + def compress(self) -> Action: self.action = self.action.compress() return self - def button_press(self, mapper): + def button_press(self, mapper: Mapper) -> None: # Physical press -> the wrapped action is released self.action.button_release(mapper) - def button_release(self, mapper): + def button_release(self, mapper: Mapper) -> None: # Physical release -> the wrapped action is pressed self.action.button_press(mapper) diff --git a/scc/osd/binding_display.py b/scc/osd/binding_display.py index f5c09d18a..1b9d4e8b0 100644 --- a/scc/osd/binding_display.py +++ b/scc/osd/binding_display.py @@ -5,21 +5,23 @@ Reuses styles from OSD Menu and OSD Dialog """ +from __future__ import annotations import base64 import logging import os import re import sys +from collections.abc import Callable from enum import IntEnum -from typing import Self +from typing import TYPE_CHECKING, Self from gi.repository import Gtk from scc.actions import Action, AxisAction, DPadAction, MouseAction, MultiAction, XYAction from scc.config import Config from scc.constants import DPAD, LEFT, RIGHT, SCButtons, SCTriggers, SCPads -from scc.gui.daemon_manager import DaemonManager +from scc.gui.daemon_manager import ControllerManager, DaemonManager from scc.gui.svg_widget import SVGEditor, SVGWidget from scc.modifiers import DoubleclickModifier, ModeModifier from scc.osd import OSDWindow @@ -30,6 +32,9 @@ from scc.tools import _, nameof from scc.uinput import Rels +if TYPE_CHECKING: + from xml.etree import ElementTree as ET + log = logging.getLogger("osd.binds") @@ -57,7 +62,7 @@ def __init__(self, config=None): def on_profile_changed(self, daemon: DaemonManager, filename: str): self._draw_profile(filename) - def _draw_profile(self, filename): + def _draw_profile(self, filename: str) -> None: """(Re)draws the binding boxes for the given profile onto the current background. No-op until the background image has been built, which happens in on_daemon_connected() once the connected controller (and @@ -149,7 +154,7 @@ def success(*a): locks = ["RB", "LB", self.args.cancel_with] c.lock(success, self.on_failed_to_lock, *locks) - def _resolve_image(self, controller): + def _resolve_image(self, controller: ControllerManager) -> str: """Picks the binding-display SVG for the connected controller. Order of preference: @@ -213,7 +218,7 @@ def show(self, *a): OSDWindow.show(self, *a) self.move(*self.compute_position()) - def _build_and_show(self, image): + def _build_and_show(self, image: str) -> None: """Builds the window around the given background image and shows it.""" self.realize() self.background = SVGWidget(image, init_hilighted=True) @@ -282,8 +287,8 @@ class Box: MIN_HEIGHT = 50 MIN_SCALE = 0.4 # smallest font shrink before lines may overflow anyway - def __init__(self, anchor_x, anchor_y, align, name, min_width=MIN_WIDTH, min_height=MIN_HEIGHT, - max_width=999999, max_height=999999): + def __init__(self, anchor_x: int, anchor_y: int, align: Align, name: str, min_width: int = MIN_WIDTH, + min_height: int = MIN_HEIGHT, max_width: int = 999999, max_height: int = 999999) -> None: self.name = name self.lines = [] self.anchor = anchor_x, anchor_y @@ -490,23 +495,23 @@ def place_marker(self, gen, root): _B, _T, _P, _S = Action.AC_BUTTON, Action.AC_TRIGGER, Action.AC_PAD, Action.AC_STICK -def _btn(name): +def _btn(name: str) -> Callable[[Profile], Action | None]: return lambda p: p.buttons.get(SCButtons[name]) -def _pad(side): +def _pad(side: str) -> Callable[[Profile], Action | None]: return lambda p: p.pads.get(side) -def _trig(side): +def _trig(side: str) -> Callable[[Profile], Action | None]: return lambda p: p.triggers.get(side) -def _stick(p): +def _stick(p: Profile) -> Action: return p.stick -def _rstick(p): +def _rstick(p: Profile) -> Action | None: return getattr(p, "rstick", None) @@ -545,7 +550,7 @@ def _rstick(p): class Generator: PADDING = 10 - def __init__(self, editor, profile: Profile, layout_key=None): + def __init__(self, editor: SVGEditor, profile: Profile, layout_key: str | None = None) -> None: background = SVGEditor.get_element(editor, "background") self.label_template = SVGEditor.get_element(editor, "label_template") self.line_height = int(float(self.label_template.attrib.get("height") or 8)) @@ -565,7 +570,7 @@ def __init__(self, editor, profile: Profile, layout_key=None): editor.commit() - def _build_v1(self, profile, root): + def _build_v1(self, profile: Profile, root: ET.Element) -> None: """The original 5-box layout (Steam Controller v1: one stick, no D-pad, three system buttons). Used for v1 and any controller without a dedicated entry in LAYOUTS.""" @@ -639,6 +644,47 @@ def _build_v1(self, profile, root): for b in boxes: b.place(self, root) + def _build_layout(self, profile: Profile, root: ET.Element, layout: list[dict]) -> None: + """Builds boxes from a per-controller LAYOUTS spec. Box positions are + auto-placed from the Align flags; size caps come from canvas fractions. + Boxes whose controls are all unbound draw nothing and are dropped.""" + boxes = [] + for spec in layout: + box = Box( + spec.get("ax", self.PADDING), + spec.get("ay", self.PADDING), + spec["align"], + spec["name"], + min_width=spec.get("min_width_f", 0) * self.full_width or Box.MIN_WIDTH, + min_height=spec.get("min_height_f", 0) * self.full_height or Box.MIN_HEIGHT, + max_width=spec.get("max_width_f", 1.0) * self.full_width, + max_height=spec.get("max_height_f", 1.0) * self.full_height, + ) + for icon, context, getter in spec["controls"]: + box.add(icon, context, getter(profile)) + boxes.append(box) + + for b in boxes: + b.calculate(self) + # Hide boxes that ended up with no bound controls. + boxes = [b for b in boxes if b.lines] + + for b in boxes: + b.place_marker(self, root) + for b in boxes: + b.place(self, root) + + def label_style(self, scale: float) -> str: + """Label text style with font-size scaled by `scale` (used to shrink a + crowded box so its lines fit). Returns the template style unchanged at + scale 1.0.""" + if scale >= 0.999: + return self._label_style + fs = self.font_size * scale + if re.search(r"font-size:\s*[\d.]+px", self._label_style): + return re.sub(r"font-size:\s*[\d.]+px", "font-size:%.1fpx" % (fs,), self._label_style) + return "font-size:%.1fpx;%s" % (fs, self._label_style) + def equal_width(self, *boxes) -> None: """Sets width of all passed boxes to width of widest box""" width = 0 diff --git a/scc/osd/grid_menu.py b/scc/osd/grid_menu.py index 8208b6906..a6bdced3e 100644 --- a/scc/osd/grid_menu.py +++ b/scc/osd/grid_menu.py @@ -25,7 +25,7 @@ def __init__(self, cls="osd-menu"): Menu.__init__(self, cls) self.ipr = 1 # items per row - def scroll_wrap(self, parent): + def scroll_wrap(self, parent: Gtk.Widget) -> Gtk.Widget: """Grid menus manage their own fixed layout - return unchanged""" return parent diff --git a/scc/osd/keyboard.py b/scc/osd/keyboard.py index 003318c4a..40b039bf7 100644 --- a/scc/osd/keyboard.py +++ b/scc/osd/keyboard.py @@ -395,7 +395,7 @@ def use_daemon(self, d: DaemonManager) -> None: self._cononect_handlers() self.on_daemon_connected(self.daemon) - def redraw_background(self, *a) -> None: + def redraw_background(self, *a: object) -> None: """Forces a repaint of the keyboard background image. Called by the OSD daemon after recolor()/update_labels() when the diff --git a/scc/osd/menu.py b/scc/osd/menu.py index ef1a41577..3805e1862 100644 --- a/scc/osd/menu.py +++ b/scc/osd/menu.py @@ -107,7 +107,7 @@ def create_parent(self) -> Gtk.Box: v.set_name("osd-menu") return v - def scroll_wrap(self, parent) -> Gtk.ScrolledWindow: + def scroll_wrap(self, parent: Gtk.Widget) -> Gtk.ScrolledWindow: """Wrap the vertical item list in a scrolled viewport capped to the screen height So very long menus (e.g. hundreds of profiles) don't run off-screen. @@ -150,7 +150,7 @@ def _fit_scroll(self) -> None: else: sw.set_size_request(natw, nath) - def _ensure_visible(self, widget) -> None: + def _ensure_visible(self, widget: Gtk.Widget) -> None: """Scroll the viewport so the selected item and its section heading stay visible.""" sw = getattr(self, "_scrollwindow", None) if sw is None or widget is None: @@ -475,13 +475,13 @@ def show(self, *a): self._reveal_timer = GLib.timeout_add(2000, self._lock_timed_out) self._reveal_if_locked() - def _on_inputs_locked(self, *a): + def _on_inputs_locked(self, *a: object) -> None: """Called from the lock-success callback, once inputs are diverted to this menu and it is safe to reveal it.""" self._inputs_locked = True self._reveal_if_locked() - def _reveal_if_locked(self): + def _reveal_if_locked(self) -> None: """Reveal the menu, but only once it is both requested and locked.""" if not (self._show_pending and self._inputs_locked): return @@ -492,7 +492,7 @@ def _reveal_if_locked(self): OSDWindow.show(self) GLib.timeout_add(1, self._check_on_screen_position, True) - def _lock_timed_out(self, *a): + def _lock_timed_out(self, *a: object) -> None: """The lock never landed; close the menu instead of revealing it while unlocked (which could leak input or strand a key).""" self._reveal_timer = None diff --git a/scc/osd/quick_menu.py b/scc/osd/quick_menu.py index 7c0e171f4..699324a59 100644 --- a/scc/osd/quick_menu.py +++ b/scc/osd/quick_menu.py @@ -245,7 +245,7 @@ def cancel_timer(self): GLib.source_remove(self._timer) self._timer = None - def quit(self, code=-2): + def quit(self, code: int = -2) -> None: # Cancel the auto-timeout before quitting; otherwise it lingers and fires # after this menu is gone, calling quit() again -> unlock_all(), which is # per-client and would clear the *next* menu's locks. See Menu.quit. diff --git a/scc/osd/radial_menu.py b/scc/osd/radial_menu.py index 18fe757a8..27c20259f 100644 --- a/scc/osd/radial_menu.py +++ b/scc/osd/radial_menu.py @@ -51,7 +51,7 @@ def __init__(self) -> None: self.set_app_paintable(True) self.connect("draw", self._on_draw_clip_circle) - def scroll_wrap(self, parent): + def scroll_wrap(self, parent: Gtk.Widget) -> Gtk.Widget: """Radial menus draw items on an SVG - no scroll viewport - return unchanged""" return parent diff --git a/scc/sccdaemon.py b/scc/sccdaemon.py index ce1e06e1d..9eeebc326 100644 --- a/scc/sccdaemon.py +++ b/scc/sccdaemon.py @@ -294,7 +294,7 @@ def _set_profile(self, mapper: Mapper, filename: str) -> None: else: self.send_profile_info(None, self._send_to_all, mapper=mapper) - def _remember_controller_profile(self, client, filename): + def _remember_controller_profile(self, client: "Client", filename: str) -> None: """Persists a controller's profile so it is restored on (re)connect. Only explicit, user-initiated selections are remembered: the autoswitch From 72e7893eb88ec6535e63b50bb8652b91e9b1dc83 Mon Sep 17 00:00:00 2001 From: Martin Rys Date: Sun, 12 Jul 2026 17:30:23 +0200 Subject: [PATCH 35/98] fix(osd): Steam Deck OSD menu - helpers work in the AppImage, no Turn 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 --- scc/osd/__init__.py | 1 + scc/osd/menu.py | 30 +++++++++++++++++++++++++++--- scc/osd/quick_menu.py | 2 +- scc/sccdaemon.py | 14 ++++++++++++-- 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/scc/osd/__init__.py b/scc/osd/__init__.py index df8f23f62..8e697fbfe 100644 --- a/scc/osd/__init__.py +++ b/scc/osd/__init__.py @@ -154,6 +154,7 @@ def _add_arguments(self) -> None: Use negative value to specify as distance from bottom side (default: -20)""", ) self.argparser.add_argument("--controller", type=str, help="""id of controller to use""") + self.argparser.add_argument("--controller-type", type=str, help="""type of controller to use""") self.argparser.add_argument("-d", action="store_true", help="""display debug messages""") def choose_controller(self, daemonmanager: DaemonManager) -> ControllerManager: diff --git a/scc/osd/menu.py b/scc/osd/menu.py index 3805e1862..ee9f0f017 100644 --- a/scc/osd/menu.py +++ b/scc/osd/menu.py @@ -29,6 +29,7 @@ from scc.gui.daemon_manager import DaemonManager from scc.menu_data import MenuData, Separator, Submenu from scc.osd import OSDWindow, StickController, menu_generators +from scc.parser import TalkingActionParser from scc.paths import get_share_path from scc.tools import _, circle_to_square, clamp, find_icon, find_menu from scc.x11 import autoswitcher, get_xdisplay @@ -279,10 +280,15 @@ def _get_on_screen_position(w): return a.x, a.y def parse_menu(self): + # Parse actions only when we may need to filter items by what they do + # (dropping "Turn Controller OFF" on the Steam Deck). Other controllers + # keep the lighter no-parse load: the daemon runs a menu action by id, so + # the OSD menu itself never needs the parsed action. + parser = TalkingActionParser() if getattr(self.args, "controller_type", None) == "deck" else None if self.args.from_profile: try: self._menuid = self.args.items[0] - self.items = MenuData.from_profile(self.args.from_profile, self._menuid) + self.items = MenuData.from_profile(self.args.from_profile, self._menuid, parser) except OSError: print("%s: error: profile file not found" % (sys.argv[0]), file=sys.stderr) return False @@ -292,7 +298,7 @@ def parse_menu(self): elif self.args.from_file: try: self._menuid = self.args.from_file - self.items = MenuData.from_file(self.args.from_file) + self.items = MenuData.from_file(self.args.from_file, parser) except: print("%s: error: failed to load menu file" % (sys.argv[0]), file=sys.stderr) return False @@ -305,6 +311,24 @@ def parse_menu(self): return False return True + def _drop_inapplicable_items(self, items): + """Removes generated menu items that don't apply to the connected + controller - currently "Turn Controller OFF" for the Steam Deck's + built-in controls, which can't be powered off. The live controller + isn't known when items are built (that happens before we connect to + the daemon), so the daemon passes its type via --controller-type. + """ + if getattr(self.args, "controller_type", None) != "deck": + return items + def is_turnoff(action): + # turnoff() directly, or wrapped e.g. as osd(turnoff()) - unwrap .action + while action is not None: + if getattr(action, "SA", "") == "turnoff": + return True + action = getattr(action, "action", None) + return False + return [i for i in items if not is_turnoff(getattr(i, "action", None))] + def parse_arguments(self, argv): if not OSDWindow.parse_arguments(self, argv): return False @@ -317,7 +341,7 @@ def parse_arguments(self, argv): self._size = self.args.size # Create buttons that are displayed on screen - items = self.items.generate(self) + items = self._drop_inapplicable_items(self.items.generate(self)) self.items = [] for item in items: item.widget = self.generate_widget(item) diff --git a/scc/osd/quick_menu.py b/scc/osd/quick_menu.py index 699324a59..e103e4052 100644 --- a/scc/osd/quick_menu.py +++ b/scc/osd/quick_menu.py @@ -142,7 +142,7 @@ def parse_arguments(self, argv): self._timeout = self.args.timeout # Create buttons that are displayed on screen - items = self.items.generate(self) + items = self._drop_inapplicable_items(self.items.generate(self)) self.items = [] self._button_index = 0 for item in items: diff --git a/scc/sccdaemon.py b/scc/sccdaemon.py index 9eeebc326..af48137ce 100644 --- a/scc/sccdaemon.py +++ b/scc/sccdaemon.py @@ -350,6 +350,15 @@ def on_sa_led(self, mapper: Mapper, action: LedAction) -> None: def on_sa_shell(self, mapper: Mapper, action: ShellCommandAction) -> Popen[bytes]: """Called when 'shell' action is used""" + cmd = shsplit(action.command) + # scc's own helpers (scc-osd-launcher, scc-osd-show-bindings, sc-controller + # ...) are Python entry points whose shebang and PATH can't be relied on in + # every environment - notably inside the AppImage, where a bare shell spawn + # silently fails (the same reason the daemon launches its other helpers via + # find_python() + find_binary()). Launch these the same way, bypassing the + # shebang; arbitrary user commands still go through the shell unchanged. + if cmd and (cmd[0].startswith("scc-") or cmd[0] == "sc-controller"): + return subprocess.Popen([find_python(), find_binary(cmd[0]), *cmd[1:]]) return subprocess.Popen(action.command, shell=True) def on_sa_gestures(self, mapper: Mapper, action: GesturesAction, x, y, what: str) -> None: @@ -436,8 +445,9 @@ def on_sa_keyboard(self, mapper: Mapper, action: KeyboardAction) -> None: def on_sa_menu(self, mapper: Mapper, action: MenuAction, *pars) -> None: """Called when 'menu' action is used""" p = [action.MENU_TYPE] - if mapper.get_controller(): - p += ["--controller", mapper.get_controller().get_id()] + c = mapper.get_controller() + if c: + p += ["--controller", c.get_id(), "--controller-type", c.get_type()] if "." in action.menu_id: path = find_menu(action.menu_id) if not path: From 1483f2f8ea947c5d50b795b77340c6a421648b71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sat, 4 Jul 2026 19:36:49 +0200 Subject: [PATCH 36/98] binding-display: auto-generate the template from controller art 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/.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 --- README.md | 29 +++++ images/binding-display-sc2.svg | 1 - images/binding-display/sc2.svg | 1 + scc/osd/binding_display.py | 6 +- tools/binding-display-sc2-art.svg | 1 - tools/gen_binding_display.py | 204 ++++++++++++++++++++---------- 6 files changed, 171 insertions(+), 71 deletions(-) delete mode 100644 images/binding-display-sc2.svg create mode 100644 images/binding-display/sc2.svg delete mode 100644 tools/binding-display-sc2-art.svg diff --git a/README.md b/README.md index 3881333db..60478b53d 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,35 @@ docker build -o build-output --build-arg BASE_CODENAME=noble . - Execute `./run.sh`, this automatically builds the project into a venv called `.venv`, activates it and runs sc-controller, which in turn runs scc-daemon if one does not run already - If you are debugging an issue, running `./run.sh daemon` first will launch the daemon in debug mode, allowing you to launch sc-controller in another terminal with `./run.sh` - note that sc-controller launched via `run.sh` always runs in debug mode too. +### Regenerating controller artwork (for contributors) + +Some SVG assets under `images/` are **generated** from source drawings by scripts +in `tools/`, so edit the source and rerun the script rather than hand-editing the +committed output. All scripts run from the repository root and optimise their +output with [`svgo`](https://github.com/svg/svgo) when it is on `PATH` (optional; +without it the SVGs are just left un-minified). The `svgo` config +(`tools/svgo.config.js`) deliberately preserves the element ids, `` +geometry, `viewBox` and `display:none` layers that the GUI relies on. + +- **`tools/gen_sc2_image.py`** — builds the Steam Controller v2 GUI artwork + (`images/controller-images/sc2.svg`, the face-button glyphs and side-panel + icons) from the traced sources in `tools/` (`sc2-source.svg`, `sc2-assets/`). + +- **`tools/gen_binding_display.py`** — builds the per-controller *Display Current + Bindings* templates in `images/binding-display/.svg`. Instead of + a hand-drawn asset per controller, it derives each template straight from that + controller's GUI drawing (`images/controller-images/.svg`): it scales the + drawing into the OSD canvas, recolours it into the binding-display palette + (green outlines over two greys on a dark backdrop) and drops a marker ring at + each control's `AREA_*` anchor so the binding boxes can draw connector lines to + them. The OSD then picks the file up automatically via the controller's gui + `background` name (see `scc/osd/binding_display.py`, `_resolve_image`). + + To add a controller: give it an entry in the script's `CONTROLLERS` table (its + source drawing + which `AREA_*` anchors each binding box points at) and a + matching box layout in `LAYOUTS` in `scc/osd/binding_display.py`, then rerun the + script. Controllers that share a physical control set can share a layout. + ## AI notice If you contribute AI-assisted work, please disclose it, ensure you have tested the changes, and do not use unreviewed AI-generated text to communicate with maintainers. diff --git a/images/binding-display-sc2.svg b/images/binding-display-sc2.svg deleted file mode 100644 index 63cbf55ce..000000000 --- a/images/binding-display-sc2.svg +++ /dev/null @@ -1 +0,0 @@ -X \ No newline at end of file diff --git a/images/binding-display/sc2.svg b/images/binding-display/sc2.svg new file mode 100644 index 000000000..95d5b91e8 --- /dev/null +++ b/images/binding-display/sc2.svg @@ -0,0 +1 @@ +R4R5L5L4X \ No newline at end of file diff --git a/scc/osd/binding_display.py b/scc/osd/binding_display.py index 1b9d4e8b0..ccffcc436 100644 --- a/scc/osd/binding_display.py +++ b/scc/osd/binding_display.py @@ -160,7 +160,7 @@ def _resolve_image(self, controller: ControllerManager) -> str: Order of preference: 1. an explicit image given on the command line 2. "binding_display" filename set in the controller's gui config - 3. convention: binding-display-.svg + 3. convention: binding-display/.svg (looked up in ~/.config/scc first, then the bundled images dir) 4. the generic binding-display.svg (user override, then bundled) The generic fallback keeps controllers without a dedicated layout @@ -190,7 +190,9 @@ def _resolve_image(self, controller: ControllerManager) -> str: background = gui.get("background") self._layout_key = background # selects the per-controller box layout if background: - fname = "binding-display-%s.svg" % (background,) + # per-controller templates live in the binding-display/ subdir, with + # an optional user override under ~/.config/scc/binding-display/. + fname = os.path.join("binding-display", "%s.svg" % (background,)) candidates.append(os.path.join(config_path, fname)) candidates.append(os.path.join(images_path, fname)) # 4. generic fallback (already user-override-then-bundled) diff --git a/tools/binding-display-sc2-art.svg b/tools/binding-display-sc2-art.svg deleted file mode 100644 index 7d86bfcc3..000000000 --- a/tools/binding-display-sc2-art.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/tools/gen_binding_display.py b/tools/gen_binding_display.py index b22ddabbf..0a1a3e3ed 100644 --- a/tools/gen_binding_display.py +++ b/tools/gen_binding_display.py @@ -1,30 +1,35 @@ #!/usr/bin/env python3 -"""Generate the binding-display layout SVG for the v2 Steam Controller. +"""Generate the per-controller binding-display templates from controller art. The OSD "Display Current Bindings" window (scc/osd/binding_display.py) draws each control's binding into boxes laid out around a controller picture, using a -per-controller template SVG (binding-display-.svg). This tool -assembles that template for the v2 controller. - -What it emits (images/binding-display-sc2.svg), all required by Generator: - - a 1280x720 canvas with `background` (sizes the layout), `label_template` - (label font/metrics) and `root` (boxes are drawn into it) elements; - - the restyled controller drawing, inlined verbatim from the source asset - tools/binding-display-sc2-art.svg (edit that in Inkscape to change the - look -- it carries its own placement transform); +per-controller template SVG. This tool builds those templates -- one per entry +in CONTROLLERS -- straight from the existing controller drawings +(images/controller-images/.svg), so there is no hand-drawn per-controller +art to maintain: change a controller image and rerun this. + +For each controller it emits images/binding-display/.svg, containing all the +elements Generator needs: + - a 1280x720 canvas with `background` (sizes the layout + dark backdrop), + `label_template` (label font/metrics) and `root` (boxes are drawn into it); + - the controller drawing, scaled + centred into the canvas and recoloured into + the "Matrix" binding-display palette (green outlines + two greys) so it reads + as a subdued filled silhouette behind the bright binding boxes/labels; - the six `markers_` groups (system/lshoulder/rshoulder/lthumb/rthumb/ - face -- the v2 box set in binding_display.py LAYOUTS["sc2"]), each with - circles placed at the matching AREA_* anchor centres of sc2.svg, mapped - into canvas coordinates. Each box draws a connector line to up to two. + face -- the box set in binding_display.py LAYOUTS[]), each a ring at the + matching AREA_* anchor centre of the source drawing, mapped into canvas + coords. Each box draws a connector line to up to two of them. -Markers come from the GUI image's (sc2.svg) AREA_* anchors -- they live in an -untransformed layer in final display coords, so their centres map into the -canvas via the ART_MAX_*_FRAC transform below. The art asset was placed to -register with that mapping; if you change ART_MAX_*_FRAC, re-place the art. +The AREA_* anchors live in an untransformed layer of the source drawing in its +own coords; the same drawing->canvas transform places both the drawing and the +markers, so they always register regardless of the source viewBox (sc2.svg has a +non-zero origin). If you change ART_MAX_*_FRAC, both move together. Run from repo root: python3 tools/gen_binding_display.py """ +import copy import os +import re import sys import xml.etree.ElementTree as ET @@ -32,72 +37,129 @@ import _svgo # noqa: E402 SVG = "http://www.w3.org/2000/svg" -SRC = "images/controller-images/sc2.svg" # AREA anchors for markers -ART = "tools/binding-display-sc2-art.svg" # restyled controller drawing -OUT = "images/binding-display-sc2.svg" +ET.register_namespace("", SVG) CANVAS_W, CANVAS_H = 1280, 720 +OUT_DIR = "images/binding-display" -# Defines the controller's footprint in the canvas, i.e. the sc2.svg-coords -> -# canvas transform used to place the AREA-anchor markers. The art asset -# (tools/binding-display-sc2-art.svg) was drawn/placed to register with this. +# The controller drawing's footprint in the canvas (leaves the margins free for +# the binding boxes). Both the drawing and its AREA-anchor markers are placed +# through this, so they stay registered. ART_MAX_W_FRAC = 0.38 ART_MAX_H_FRAC = 0.70 -# Generator box name -> AREA_* anchors its connector lines point at. A box draws -# at most two lines; missing anchors are skipped (so a box may get 1 or 2). These -# match the v2 (sc2) box set in scc/osd/binding_display.py LAYOUTS["sc2"]. -MARKERS = { - "system": ["BACK", "START"], - "lshoulder": ["LB", "LGRIPTOUCH"], - "rshoulder": ["RB", "RGRIPTOUCH"], - "lthumb": ["STICK", "LPAD"], - "rthumb": ["RSTICK", "RPAD"], - "face": ["Y", "A"], +# "Matrix" binding-display palette: the GUI controller art is full-colour, but +# here it is flattened to green outlines over two greys on a dark backdrop, so it +# recedes behind the bright binding boxes and labels while staying readable. +GREEN = "#047100" # every outline/stroke -> this green +GRAY_LIGHT = "#3d3d3d" # lighter fills (luminance >= GRAY_SPLIT) +GRAY_DARK = "#262626" # darker fills, and the default for un-filled shapes +GRAY_SPLIT = 80 # fill luminance split between GRAY_LIGHT and GRAY_DARK +MARKER_GREEN = "#06a400" # marker rings + connector lines (matches Generator) + +# Per-controller source drawing + the AREA_* anchors each binding box points at. +# The box names match scc/osd/binding_display.py LAYOUTS[]. A box draws at +# most two connector lines; anchor names differ per controller image. +CONTROLLERS = { + "sc2": { + "src": "images/controller-images/sc2.svg", + "markers": { + "system": ["BACK", "START"], "lshoulder": ["LB", "LGRIPTOUCH"], + "rshoulder": ["RB", "RGRIPTOUCH"], "lthumb": ["STICK", "LPAD"], + "rthumb": ["RSTICK", "RPAD"], "face": ["Y", "A"], + }, + }, } -ET.register_namespace("", SVG) +_FILL = re.compile(r"fill:\s*#([0-9a-fA-F]{3,6})") +_STROKE = re.compile(r"stroke:\s*#([0-9a-fA-F]{3,6})") def q(tag: str) -> str: return "{%s}%s" % (SVG, tag) -def parse_viewbox(svg: ET.Element) -> tuple[float, float]: +def parse_viewbox(svg: ET.Element) -> tuple[float, float, float, float]: + """(x, y, w, h) of the source viewBox -- x/y may be non-zero (e.g. sc2.svg).""" vb = svg.get("viewBox") if vb: p = [float(x) for x in vb.replace(",", " ").split()] - return p[2], p[3] - return float(svg.get("width")), float(svg.get("height")) + return p[0], p[1], p[2], p[3] + return 0.0, 0.0, float(svg.get("width")), float(svg.get("height")) def read_area_centers(root: ET.Element) -> dict[str, tuple[float, float]]: - """AREA_ rects sit in an untransformed layer in display coords, so - their centres are read directly.""" + """AREA_ rects sit in an untransformed layer in the drawing's coords, + so their centres are read directly (and later mapped into the canvas).""" centers = {} for rect in root.iter(q("rect")): rid = rect.get("id") or "" if rid.startswith("AREA_"): - x, y = float(rect.get("x")), float(rect.get("y")) - w, h = float(rect.get("width")), float(rect.get("height")) + try: + x, y, w, h = (float(rect.get(k)) for k in ("x", "y", "width", "height")) + except (TypeError, ValueError): + continue centers[rid[5:]] = (x + w / 2.0, y + h / 2.0) return centers -def main() -> None: - if not os.path.exists(SRC): - raise SystemExit("run from repo root: %s not found" % SRC) - src = ET.parse(SRC).getroot() - cw, ch = parse_viewbox(src) # controller art size (685x493) +def _luminance(hexcolor: str) -> float: + h = hexcolor.lstrip("#") + if len(h) == 3: + h = "".join(c * 2 for c in h) + r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) + return 0.299 * r + 0.587 * g + 0.114 * b + + +def _gray(hexcolor: str) -> str: + """Map a source fill to one of the two binding-display greys by luminance, so + light body panels stay lighter than dark detailing.""" + return GRAY_LIGHT if _luminance(hexcolor) >= GRAY_SPLIT else GRAY_DARK + + +def recolor(el: ET.Element) -> None: + """Recolour a drawing subtree into the Matrix palette in place: every stroke + becomes GREEN, every explicit fill becomes one of the two greys. Shapes with + no fill inherit the drawing group's GRAY_DARK default (so they read instead + of falling back to SVG black); stroke-only outlines keep their fill:none.""" + style = el.get("style") + if style: + style = _FILL.sub(lambda m: "fill:" + _gray(m.group(1)), style) + style = _STROKE.sub(lambda _m: "stroke:" + GREEN, style) + el.set("style", style) + if (el.get("fill") or "").startswith("#"): + el.set("fill", _gray(el.get("fill"))) + if (el.get("stroke") or "").startswith("#"): + el.set("stroke", GREEN) + for child in el: + recolor(child) + + +def strip_areas(el: ET.Element) -> None: + """Remove AREA_* hotspot rects recursively -- they are invisible in the GUI + but recolour() would give them a grey fill and cover the drawing.""" + for child in list(el): + if (child.get("id") or "").startswith("AREA_"): + el.remove(child) + else: + strip_areas(child) + + +def build(key: str, spec: dict) -> None: + src_path = spec["src"] + if not os.path.exists(src_path): + raise SystemExit("run from repo root: %s not found" % src_path) + src = ET.parse(src_path).getroot() + vx, vy, cw, ch = parse_viewbox(src) centers = read_area_centers(src) - # Scale + centre the art in the free middle band. + # Scale + centre the drawing in the canvas. s = min(CANVAS_W * ART_MAX_W_FRAC / cw, CANVAS_H * ART_MAX_H_FRAC / ch) ox = (CANVAS_W - cw * s) / 2.0 oy = (CANVAS_H - ch * s) / 2.0 def to_canvas(pt: tuple[float, float]) -> tuple[float, float]: - return ox + s * pt[0], oy + s * pt[1] + return ox + s * (pt[0] - vx), oy + s * (pt[1] - vy) out = ET.Element(q("svg"), { "width": str(CANVAS_W), "height": str(CANVAS_H), @@ -111,16 +173,18 @@ def to_canvas(pt: tuple[float, float]) -> tuple[float, float]: "width": str(CANVAS_W), "height": str(CANVAS_H), "style": "fill:#000000;fill-opacity:0.85"}) - # controller art: the hand-restyled drawing, inlined verbatim from the source - # asset (kept separate so this generator reproduces it and the look is edited - # in Inkscape). It carries its own placement transform, made to register with - # the AREA-anchor marker mapping above. - if not os.path.exists(ART): - raise SystemExit("%s not found" % ART) - for child in list(ET.parse(ART).getroot()): - if child.tag.split("}")[-1] == "defs": + # The controller drawing, scaled into the canvas so it registers with the + # markers, then recoloured. The group's GRAY_DARK default catches shapes with + # no explicit fill (which would otherwise render SVG-black on the dark bg). + g = ET.SubElement(out, q("g"), { + "fill": GRAY_DARK, + "transform": "translate(%g,%g) scale(%g) translate(%g,%g)" % (ox, oy, s, -vx, -vy)}) + for child in list(src): + if child.get("id") == "layerAreas": continue - out.append(child) + g.append(copy.deepcopy(child)) + strip_areas(g) + recolor(g) # foreground: label_template + root (boxes drawn here) + the marker groups. root = ET.SubElement(out, q("g"), {"id": "root", "style": "display:inline"}) @@ -131,24 +195,30 @@ def to_canvas(pt: tuple[float, float]) -> tuple[float, float]: lt.text = "X" missing = [] - for name, anchors in MARKERS.items(): - g = ET.SubElement(root, q("g"), {"id": "markers_%s" % name}) + for name, anchors in spec["markers"].items(): + mg = ET.SubElement(root, q("g"), {"id": "markers_%s" % name}) for a in anchors: if a not in centers: missing.append(a) continue cx, cy = to_canvas(centers[a]) - ET.SubElement(g, q("circle"), { + ET.SubElement(mg, q("circle"), { "cx": "%g" % cx, "cy": "%g" % cy, "r": "5", - "style": "fill:#000000;fill-opacity:0;stroke:#06a400;stroke-width:1"}) + "style": "fill:none;stroke:%s;stroke-width:1" % MARKER_GREEN}) - ET.ElementTree(out).write(OUT, encoding="unicode", xml_declaration=True) - _svgo.optimize(OUT) - print("wrote", OUT) - print(" art inlined from %s; marker mapping scale %.3f at (%.1f, %.1f)" - % (ART, s, ox, oy)) + out_path = os.path.join(OUT_DIR, "%s.svg" % key) + ET.ElementTree(out).write(out_path, encoding="unicode", xml_declaration=True) + _svgo.optimize(out_path) + print("wrote %s (from %s, scale %.3f at %.1f,%.1f)" % (out_path, src_path, s, ox, oy)) if missing: - print(" WARNING: AREA anchors not found in %s: %s" % (SRC, ", ".join(missing))) + print(" WARNING: AREA anchors not found in %s: %s" % (src_path, ", ".join(missing))) + + +def main() -> None: + """Build every controller's binding-display template into OUT_DIR.""" + os.makedirs(OUT_DIR, exist_ok=True) + for key, spec in CONTROLLERS.items(): + build(key, spec) if __name__ == "__main__": From 4874bf35fffe46a25afa211adb532bb8e0758aa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sat, 4 Jul 2026 19:40:09 +0200 Subject: [PATCH 37/98] binding-display: add Steam Deck support 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 --- images/binding-display/deck.svg | 1 + scc/osd/binding_display.py | 5 +++++ tools/gen_binding_display.py | 9 +++++++++ 3 files changed, 15 insertions(+) create mode 100644 images/binding-display/deck.svg diff --git a/images/binding-display/deck.svg b/images/binding-display/deck.svg new file mode 100644 index 000000000..440617688 --- /dev/null +++ b/images/binding-display/deck.svg @@ -0,0 +1 @@ +L4L5R5R4X \ No newline at end of file diff --git a/scc/osd/binding_display.py b/scc/osd/binding_display.py index ccffcc436..443a6d658 100644 --- a/scc/osd/binding_display.py +++ b/scc/osd/binding_display.py @@ -548,6 +548,11 @@ def _rstick(p: Profile) -> Action | None: ], } +# The Steam Deck's built-in controller shares the v2's physical control set (same +# boxes, same controls), so it reuses the v2 layout. Controls the Deck lacks stay +# unbound and their boxes render nothing, so the shared list is safe. +LAYOUTS["deck"] = LAYOUTS["sc2"] + class Generator: PADDING = 10 diff --git a/tools/gen_binding_display.py b/tools/gen_binding_display.py index 0a1a3e3ed..8edf04d79 100644 --- a/tools/gen_binding_display.py +++ b/tools/gen_binding_display.py @@ -69,6 +69,15 @@ "rthumb": ["RSTICK", "RPAD"], "face": ["Y", "A"], }, }, + "deck": { + "src": "images/controller-images/deck.svg", + # Deck AREA naming differs (segmented pads/bumpers, no grip-touch). + "markers": { + "system": ["BACK", "START"], "lshoulder": ["LB_2", "LT_2"], + "rshoulder": ["RB_2", "RT_2"], "lthumb": ["STICK", "LPAD_1"], + "rthumb": ["RSTICK", "RPAD_1"], "face": ["Y", "A"], + }, + }, } _FILL = re.compile(r"fill:\s*#([0-9a-fA-F]{3,6})") From 92e7d4c24d4593bfe49e27e29483dfa0a1cbee7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sat, 4 Jul 2026 20:55:59 +0200 Subject: [PATCH 38/98] sccdaemon: show/lock the invoking controller for Display Bindings 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 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 --- scc/sccdaemon.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scc/sccdaemon.py b/scc/sccdaemon.py index af48137ce..7479ab264 100644 --- a/scc/sccdaemon.py +++ b/scc/sccdaemon.py @@ -358,7 +358,17 @@ def on_sa_shell(self, mapper: Mapper, action: ShellCommandAction) -> Popen[bytes # find_python() + find_binary()). Launch these the same way, bypassing the # shebang; arbitrary user commands still go through the shell unchanged. if cmd and (cmd[0].startswith("scc-") or cmd[0] == "sc-controller"): - return subprocess.Popen([find_python(), find_binary(cmd[0]), *cmd[1:]]) + args = cmd[1:] + # scc-osd-show-bindings renders, and locks input on, one specific + # controller. Without --controller it falls back to the first + # connected controller (OSDWindow.choose_controller), so with several + # controllers it shows the wrong one's bindings AND its cancel button + # is locked on that other controller, so the window can't be + # dismissed. Target the controller that actually invoked the action. + c = mapper.get_controller() + if c and cmd[0] == "scc-osd-show-bindings" and "--controller" not in args: + args = ["--controller", c.get_id(), *args] + return subprocess.Popen([sys.executable, find_binary(cmd[0]), *args]) return subprocess.Popen(action.command, shell=True) def on_sa_gestures(self, mapper: Mapper, action: GesturesAction, x, y, what: str) -> None: From cd0fb18729cd9d5d133e22426e4909378ed1a23b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sat, 4 Jul 2026 20:59:21 +0200 Subject: [PATCH 39/98] osd/binding-display: fit the window to the screen (Steam Deck) 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 --- scc/osd/binding_display.py | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/scc/osd/binding_display.py b/scc/osd/binding_display.py index 443a6d658..e0c1b29c2 100644 --- a/scc/osd/binding_display.py +++ b/scc/osd/binding_display.py @@ -91,19 +91,30 @@ def _add_arguments(self): ) def compute_position(self): - """Unlike other OSD windows, this one is scaled to 80% of screen size and centered in on active screen.""" - x, y = 10, 10 + """Fit the (per-controller) binding image to the active screen and centre + it. Unlike other OSD windows this one is nearly screen-sized, so it is + capped to 80% of the screen in BOTH dimensions and scaled down to fit.""" iw, ih = self.background.image_width, self.background.image_height geometry = self.get_active_screen_geometry() - if geometry: - width, height = iw, ih - if width > geometry.width * 0.8: - width = geometry.width * 0.8 - height = int(float(ih) / float(iw) * float(width)) - self.background.resize(width, height) - self.background.hilight({}) - x = geometry.x + ((geometry.width - width) / 2) - y = geometry.y + ((geometry.height - height) / 2) + if geometry is None: + # The Steam Deck (gamescope) reports no active window; fall back to the + # primary monitor so the image is still fitted instead of shown at full + # 1280x720, which overflows the Deck's 1280x800 screen. + screen = self.get_window().get_screen() + geometry = screen.get_monitor_geometry(screen.get_primary_monitor()) + if geometry is None: + return 10, 10 + # Cap to 80% of the screen in BOTH dimensions: width-only scaling left the + # window overflowing on screens barely larger than the image (the Deck's + # 1280x800 vs the 1280x720 image), and off-screen boxes made their + # connector lines appear to shoot outside the window. + scale = min(1.0, geometry.width * 0.8 / iw, geometry.height * 0.8 / ih) + width, height = int(iw * scale), int(ih * scale) + if scale < 1.0: + self.background.resize(width, height) + self.background.hilight({}) + x = geometry.x + (geometry.width - width) // 2 + y = geometry.y + (geometry.height - height) // 2 return x, y def parse_arguments(self, argv): From 796909361b367807e263ee5ebdbbd09c8325cbb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sat, 4 Jul 2026 20:59:43 +0200 Subject: [PATCH 40/98] osd/binding-display: shrink v1 labels to fit their boxes 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 --- scc/osd/binding_display.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scc/osd/binding_display.py b/scc/osd/binding_display.py index e0c1b29c2..f8554fd41 100644 --- a/scc/osd/binding_display.py +++ b/scc/osd/binding_display.py @@ -593,7 +593,7 @@ def _build_v1(self, profile: Profile, root: ET.Element) -> None: three system buttons). Used for v1 and any controller without a dedicated entry in LAYOUTS.""" boxes = [] - box_bcs = Box(0, self.PADDING, Align.TOP, "bcs") + box_bcs = Box(0, self.PADDING, Align.TOP, "bcs", max_height=self.full_height * 0.25) box_bcs.add("BACK", Action.AC_BUTTON, profile.buttons.get(SCButtons.BACK)) box_bcs.add("C", Action.AC_BUTTON, profile.buttons.get(SCButtons.C)) box_bcs.add("START", Action.AC_BUTTON, profile.buttons.get(SCButtons.START)) @@ -607,6 +607,7 @@ def _build_v1(self, profile: Profile, root: ET.Element) -> None: min_height=self.full_height * 0.5, min_width=self.full_width * 0.2, max_width=self.full_width * 0.275, + max_height=self.full_height * 0.85, ) box_left.add("LT", Action.AC_TRIGGER, profile.triggers.get(SCTriggers.LT)) box_left.add("LB", Action.AC_BUTTON, profile.buttons.get(SCButtons.LB)) @@ -622,6 +623,7 @@ def _build_v1(self, profile: Profile, root: ET.Element) -> None: min_height=self.full_height * 0.5, min_width=self.full_width * 0.2, max_width=self.full_width * 0.275, + max_height=self.full_height * 0.85, ) box_right.add("RT", Action.AC_TRIGGER, profile.triggers.get(SCTriggers.RT)) box_right.add("RB", Action.AC_BUTTON, profile.buttons.get(SCButtons.RB)) @@ -630,7 +632,8 @@ def _build_v1(self, profile: Profile, root: ET.Element) -> None: boxes.append(box_right) box_abxy = Box( - 4 * self.PADDING, self.PADDING, Align.RIGHT | Align.BOTTOM, "abxy", max_width=self.full_width * 0.45, + 4 * self.PADDING, self.PADDING, Align.RIGHT | Align.BOTTOM, "abxy", + max_width=self.full_width * 0.45, max_height=self.full_height * 0.25, ) box_abxy.add("A", Action.AC_BUTTON, profile.buttons.get(SCButtons.A)) box_abxy.add("B", Action.AC_BUTTON, profile.buttons.get(SCButtons.B)) @@ -639,7 +642,8 @@ def _build_v1(self, profile: Profile, root: ET.Element) -> None: boxes.append(box_abxy) box_lstick = Box( - 4 * self.PADDING, self.PADDING, Align.LEFT | Align.BOTTOM, "lstick", max_width=self.full_width * 0.45, + 4 * self.PADDING, self.PADDING, Align.LEFT | Align.BOTTOM, "lstick", + max_width=self.full_width * 0.45, max_height=self.full_height * 0.25, ) box_lstick.add("LSTICK", Action.AC_STICK, profile.lstick) boxes.append(box_lstick) From 84a4c3c4c29b0a4e2b04837bcee64486733f477e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sat, 4 Jul 2026 21:26:15 +0200 Subject: [PATCH 41/98] binding-display: honor group transforms when placing markers (Deck) 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 --- images/binding-display/deck.svg | 2 +- tools/gen_binding_display.py | 76 ++++++++++++++++++++++++++++----- 2 files changed, 67 insertions(+), 11 deletions(-) diff --git a/images/binding-display/deck.svg b/images/binding-display/deck.svg index 440617688..27f17985a 100644 --- a/images/binding-display/deck.svg +++ b/images/binding-display/deck.svg @@ -1 +1 @@ -L4L5R5R4X \ No newline at end of file +L4L5R5R4X \ No newline at end of file diff --git a/tools/gen_binding_display.py b/tools/gen_binding_display.py index 8edf04d79..6473386fa 100644 --- a/tools/gen_binding_display.py +++ b/tools/gen_binding_display.py @@ -28,6 +28,7 @@ Run from repo root: python3 tools/gen_binding_display.py """ import copy +import math import os import re import sys @@ -97,18 +98,73 @@ def parse_viewbox(svg: ET.Element) -> tuple[float, float, float, float]: return 0.0, 0.0, float(svg.get("width")), float(svg.get("height")) +# --- affine transforms (a, b, c, d, e, f) mapping (x,y) -> (ax+cy+e, bx+dy+f) --- +IDENTITY = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0) +_TRANSFORM = re.compile(r"(matrix|translate|scale|rotate)\s*\(([^)]*)\)") + + +def _compose(a: tuple, b: tuple) -> tuple: + """Return a after b (apply b first, then a) -- SVG's nested-transform order.""" + a1, b1, c1, d1, e1, f1 = a + a2, b2, c2, d2, e2, f2 = b + return (a1 * a2 + c1 * b2, b1 * a2 + d1 * b2, + a1 * c2 + c1 * d2, b1 * c2 + d1 * d2, + a1 * e2 + c1 * f2 + e1, b1 * e2 + d1 * f2 + f1) + + +def _parse_transform(s: str | None) -> tuple: + """Parse an SVG transform attribute into one composed affine matrix. Handles + space- OR comma-separated args and a list of transforms (leftmost outermost).""" + m = IDENTITY + if not s: + return m + for name, argstr in _TRANSFORM.findall(s): + v = [float(x) for x in re.split(r"[\s,]+", argstr.strip()) if x] + if name == "translate": + t = (1.0, 0.0, 0.0, 1.0, v[0], v[1] if len(v) > 1 else 0.0) + elif name == "scale": + t = (v[0], 0.0, 0.0, v[1] if len(v) > 1 else v[0], 0.0, 0.0) + elif name == "matrix": + t = tuple(v[:6]) + elif name == "rotate": + rad = math.radians(v[0]) + cos, sin = math.cos(rad), math.sin(rad) + t = (cos, sin, -sin, cos, 0.0, 0.0) + if len(v) >= 3: # rotate about (cx, cy) + t = _compose(_compose((1.0, 0.0, 0.0, 1.0, v[1], v[2]), t), + (1.0, 0.0, 0.0, 1.0, -v[1], -v[2])) + else: + t = IDENTITY + m = _compose(m, t) + return m + + def read_area_centers(root: ET.Element) -> dict[str, tuple[float, float]]: - """AREA_ rects sit in an untransformed layer in the drawing's coords, - so their centres are read directly (and later mapped into the canvas).""" + """Centre of each AREA_ rect in the drawing's user (viewBox) space. + + The rects may sit inside groups with their own transforms (the Deck nests + them under translated groups in a separate layer), so the full ancestor + transform chain is accumulated -- reading raw x/y put the Deck's shoulder + anchors hundreds of units outside the viewBox. sc2's anchors are in an + identity layer, so its result is unchanged.""" centers = {} - for rect in root.iter(q("rect")): - rid = rect.get("id") or "" - if rid.startswith("AREA_"): - try: - x, y, w, h = (float(rect.get(k)) for k in ("x", "y", "width", "height")) - except (TypeError, ValueError): - continue - centers[rid[5:]] = (x + w / 2.0, y + h / 2.0) + + def walk(el: ET.Element, acc: tuple) -> None: + acc = _compose(acc, _parse_transform(el.get("transform"))) + for child in el: + rid = child.get("id") or "" + if child.tag == q("rect") and rid.startswith("AREA_"): + try: + x, y, w, h = (float(child.get(k)) for k in ("x", "y", "width", "height")) + except (TypeError, ValueError): + continue + a, b, c, d, e, f = acc + cx, cy = x + w / 2.0, y + h / 2.0 + centers[rid[5:]] = (a * cx + c * cy + e, b * cx + d * cy + f) + else: + walk(child, acc) + + walk(root, IDENTITY) return centers From ff83daa1afe8410516aa48e22b8d89ed1ac7359f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sun, 5 Jul 2026 00:10:53 +0200 Subject: [PATCH 42/98] binding-display: add ds4/ds5/x360 templates 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 --- images/binding-display/ds4.svg | 1 + images/binding-display/ds5.svg | 1 + images/binding-display/x360.svg | 1 + scc/osd/binding_display.py | 29 +++++++++++++++++++++++++++++ tools/gen_binding_display.py | 12 ++++++++++++ 5 files changed, 44 insertions(+) create mode 100644 images/binding-display/ds4.svg create mode 100644 images/binding-display/ds5.svg create mode 100644 images/binding-display/x360.svg diff --git a/images/binding-display/ds4.svg b/images/binding-display/ds4.svg new file mode 100644 index 000000000..5184b84fa --- /dev/null +++ b/images/binding-display/ds4.svg @@ -0,0 +1 @@ +X \ No newline at end of file diff --git a/images/binding-display/ds5.svg b/images/binding-display/ds5.svg new file mode 100644 index 000000000..2501e08e2 --- /dev/null +++ b/images/binding-display/ds5.svg @@ -0,0 +1 @@ +X \ No newline at end of file diff --git a/images/binding-display/x360.svg b/images/binding-display/x360.svg new file mode 100644 index 000000000..96821e2dd --- /dev/null +++ b/images/binding-display/x360.svg @@ -0,0 +1 @@ +X \ No newline at end of file diff --git a/scc/osd/binding_display.py b/scc/osd/binding_display.py index f8554fd41..57a47f85e 100644 --- a/scc/osd/binding_display.py +++ b/scc/osd/binding_display.py @@ -564,6 +564,35 @@ def _rstick(p: Profile) -> Action | None: # unbound and their boxes render nothing, so the shared list is safe. LAYOUTS["deck"] = LAYOUTS["sc2"] +# Standard gamepads (DualShock 4, DualSense, Xbox 360). Their control model +# 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]); there are +# no trackpads or grip-touch. Same six-box frame. ds4/ds5/x360 are physically +# alike, so they share one layout. +_GAMEPAD_LAYOUT = [ + dict(name="system", align=Align.TOP, ax=0, max_width_f=0.4, max_height_f=0.22, + controls=[("BACK", _B, _btn("BACK")), ("C", _B, _btn("C")), + ("START", _B, _btn("START"))]), + dict(name="lshoulder", align=Align.LEFT | Align.TOP, + min_width_f=0.18, max_width_f=0.27, max_height_f=0.42, + controls=[("LT", _T, _trig(LEFT)), ("LB", _B, _btn("LB")), + ("LGRIP", _B, _btn("LGRIP"))]), + dict(name="rshoulder", align=Align.RIGHT | Align.TOP, + min_width_f=0.18, max_width_f=0.27, max_height_f=0.42, + controls=[("RT", _T, _trig(RIGHT)), ("RB", _B, _btn("RB")), + ("RGRIP", _B, _btn("RGRIP"))]), + dict(name="lthumb", align=Align.LEFT | Align.BOTTOM, + min_width_f=0.18, max_width_f=0.27, max_height_f=0.42, + controls=[("STICK", _S, _stick), ("DPAD", _P, _pad(LEFT))]), + dict(name="rthumb", align=Align.RIGHT | Align.BOTTOM, + min_width_f=0.18, max_width_f=0.27, max_height_f=0.42, + controls=[("RSTICK", _P, _pad(RIGHT))]), + dict(name="face", align=Align.BOTTOM, ax=0, max_width_f=0.4, max_height_f=0.22, + controls=[("A", _B, _btn("A")), ("B", _B, _btn("B")), + ("X", _B, _btn("X")), ("Y", _B, _btn("Y"))]), +] +LAYOUTS["ds4"] = LAYOUTS["ds5"] = LAYOUTS["x360"] = _GAMEPAD_LAYOUT + class Generator: PADDING = 10 diff --git a/tools/gen_binding_display.py b/tools/gen_binding_display.py index 6473386fa..3ebffe9d5 100644 --- a/tools/gen_binding_display.py +++ b/tools/gen_binding_display.py @@ -58,6 +58,15 @@ GRAY_SPLIT = 80 # fill luminance split between GRAY_LIGHT and GRAY_DARK MARKER_GREEN = "#06a400" # marker rings + connector lines (matches Generator) +# Standard gamepads (ds4, ds5, x360) share one anchor set: left stick + d-pad +# (LPAD) on the left, right stick (RPAD) on the right, the ABXY face cluster, and +# the top bumpers/triggers. Their drawings use the same AREA_* names. +_GAMEPAD_MARKERS = { + "system": ["BACK", "START"], "lshoulder": ["LB_1", "LT_1"], + "rshoulder": ["RB_4", "RT_1"], "lthumb": ["STICK_1", "LPAD_1"], + "rthumb": ["RPAD_1"], "face": ["Y", "A"], +} + # Per-controller source drawing + the AREA_* anchors each binding box points at. # The box names match scc/osd/binding_display.py LAYOUTS[]. A box draws at # most two connector lines; anchor names differ per controller image. @@ -79,6 +88,9 @@ "rthumb": ["RSTICK", "RPAD_1"], "face": ["Y", "A"], }, }, + "ds4": {"src": "images/controller-images/ds4.svg", "markers": _GAMEPAD_MARKERS}, + "ds5": {"src": "images/controller-images/ds5.svg", "markers": _GAMEPAD_MARKERS}, + "x360": {"src": "images/controller-images/x360.svg", "markers": _GAMEPAD_MARKERS}, } _FILL = re.compile(r"fill:\s*#([0-9a-fA-F]{3,6})") From a5d6ac935da4938f55f38eed2582750e3cefaecc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sun, 5 Jul 2026 01:15:34 +0200 Subject: [PATCH 43/98] mapper: guard rstick/dpad state access for gamepads on the HID decoder 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 --- scc/mapper.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scc/mapper.py b/scc/mapper.py index a6cb42c56..ad2e87216 100644 --- a/scc/mapper.py +++ b/scc/mapper.py @@ -510,7 +510,14 @@ def input(self, controller: Controller, old_state: CInput, state: CInput) -> Non elif not self.buttons & SCButtons.LPADTOUCH: if FE_STICK in fe or self.old_state.lpad_x != state.lpad_x or self.old_state.lpad_y != state.lpad_y: self.profile.lstick.whole(self, state.lpad_x, state.lpad_y, LSTICK) - if controller.flags & ControllerFlags.HAS_RSTICK: + # HAS_RSTICK controllers store the right stick either as a real rstick + # (Steam Controller 2 / Deck) or, for gamepads on the generic HID decoder + # (DS4/DS5), as the right pad (pads[RIGHT]). The latter's state struct + # (HIDControllerInput) has no rstick_* fields, so guard the access: + # without it every event raised AttributeError here, which aborted the + # rest of input processing (right pad, triggers, touchpad) -- the reason + # those controls were dead on the DS4. + if self.controller.flags & ControllerFlags.HAS_RSTICK and hasattr(state, "rstick_x"): if ( FE_STICK in fe or self.old_state.rstick_x != state.rstick_x From bb7ca4371fc42aaca682643a2557ed56687f5a71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sun, 5 Jul 2026 23:20:16 +0200 Subject: [PATCH 44/98] modifiers: neutralize a released absolute-gyro action in ModeModifier When a mode-shift (e.g. a gyro-enable button) deselects a gyro action, ModeModifier zeroed it by calling gyro(0, 0, 0, ...). That neutralizes a relative GyroAction (its output is pitch/yaw/roll * speed), but a GyroAbsAction ignores those and reads q1-q4, so it kept emitting its last orientation and the output axis stayed stuck -- a held gamepad axis is continuous output, i.e. runaway. Reset each GyroAbsAction (re-taking its reference) before the zeroing call so it emits neutral, walking MultiAction children so mixed relative+absolute bindings are covered. Co-Authored-By: Claude Opus 4.8 --- scc/modifiers.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scc/modifiers.py b/scc/modifiers.py index 0533decf6..5b8abee51 100644 --- a/scc/modifiers.py +++ b/scc/modifiers.py @@ -1039,6 +1039,16 @@ def gyro(self, mapper, pitch, yaw, roll, *q): sel = self.select(mapper) if sel is not self.old_action: if self.old_action: + # Neutralize the outgoing gyro action so its output axis doesn't stay + # stuck when the enable button is released. Relative GyroAction zeroes + # on (0,0,0), but GyroAbsAction ignores pitch/yaw/roll (it tracks + # q1-q4), so it would emit its last orientation and leave the axis + # deflected. Reset each GyroAbsAction's reference first (the only gyro + # action with reset()) so the neutralizing call below emits 0. Covers + # MultiAction (mixed relative+absolute) via its .actions children. + for a in getattr(self.old_action, "actions", None) or (self.old_action,): + if hasattr(a, "reset"): + a.reset() self.old_action.gyro(mapper, 0, 0, 0, *q) self.old_action = sel return sel.gyro(mapper, pitch, yaw, roll, *q) From d5337de61cdcb1911dee97c26cedc177c6ebe608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sun, 5 Jul 2026 23:21:01 +0200 Subject: [PATCH 45/98] gui: fix gyro Per-Axis mouse-axis labels + add a Clear button The gyro Per-Axis editor forced every axis through AxisAction, so a mouse axis (REL_*) mislabeled as "LStick" and the "Select Axis" chooser highlighted the stick for it. Root cause: Axes and Rels are IntEnums with colliding values (ABS_X == REL_X), so value-based checks misfire; use isinstance in the label and in the chooser's display_action. Also stop hide_mouse() from greying out the mouse arrows (a GyroAbsAction validly maps to REL_X/REL_Y), and add a Clear button to the axis chooser so an axis can be unset. (The mouse axis still serializes/round-trips as a stick axis -- the same collision in the save/load path -- so gamepad axes remain the reliable gyro target; that fix is tracked in TODO.) Co-Authored-By: Claude Opus 4.8 --- scc/gui/ae/gyro.py | 27 ++++++++++++++++++++++----- scc/gui/simple_chooser.py | 9 +++++++++ ui/simple_chooser.ui | 13 ++++++++++++- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/scc/gui/ae/gyro.py b/scc/gui/ae/gyro.py index 5456a4df7..fb6cd9baa 100644 --- a/scc/gui/ae/gyro.py +++ b/scc/gui/ae/gyro.py @@ -5,8 +5,9 @@ import itertools import logging -from scc.actions import Action, AxisAction, GyroAbsAction, GyroAction, MultiAction, NoAction, RangeOP +from scc.actions import Action, AxisAction, GyroAbsAction, GyroAction, MouseAction, MultiAction, NoAction, RangeOP from scc.constants import LSTICK, SCButtons +from scc.uinput import Rels from scc.gui.ae import AEComponent, describe_action from scc.gui.ae.gyro_action import TRIGGERS, fill_buttons, is_gyro_enable from scc.gui.simple_chooser import SimpleChooser @@ -89,14 +90,24 @@ def on_select_axis(self, source, *a): i = self.buttons.index(source) def cb(action): - self.axes[i] = action.parameters[0] + self.axes[i] = None if isinstance(action, NoAction) else action.parameters[0] self.update() self.send() b = SimpleChooser(self.app, "axis", cb) b.set_title(_("Select Axis")) - b.hide_mouse() - b.display_action(Action.AC_STICK, AxisAction(self.axes[i])) + b.show_clear() + # Mouse is a valid target here: an axis marked "Absolute" becomes a + # GyroAbsAction, which maps orientation to REL_X/REL_Y (mapper.mouse_move). + # (Relative GyroAction ignores mouse -- use the dedicated Mouse gyro editor + # for that.) hide_mouse() wrongly blocked it, greying out the mouse arrows. + # + # Show the current axis with the matching action class, or the chooser + # highlights the stick for a mouse axis (Axes/Rels value collision) and + # re-confirming would silently retarget the binding to the stick. + axis = self.axes[i] + current = MouseAction(axis) if isinstance(axis, Rels) else AxisAction(axis) + b.display_action(Action.AC_STICK, current) b.show(self.editor.window) def on_abs_changed(self, source, *a): @@ -144,7 +155,13 @@ def on_sclSoftLevel_format_value(self, scale, value, *a): def update(self, *a): for i in range(3): - self.labels[i].set_label(describe_action(Action.AC_STICK, AxisAction, self.axes[i])) + # A mouse axis (REL_*) must be described as a MouseAction; forcing it + # through AxisAction mislabels "Mouse X/Y" as "LStick X/Y". Use an + # isinstance check, not "in Rels.values()" -- Axes and Rels are IntEnums + # with overlapping values, so ABS_X == REL_X and membership misfires. + axis = self.axes[i] + cls = MouseAction if isinstance(axis, Rels) else AxisAction + self.labels[i].set_label(describe_action(Action.AC_STICK, cls, axis)) def send(self, *a): if self._recursing: diff --git a/scc/gui/simple_chooser.py b/scc/gui/simple_chooser.py index 34509d210..f273b0f2c 100644 --- a/scc/gui/simple_chooser.py +++ b/scc/gui/simple_chooser.py @@ -8,6 +8,7 @@ import importlib import logging +from scc.actions import NoAction from scc.gui.ae import AEComponent from scc.gui.dwsnc import headerbar from scc.gui.editor import Editor @@ -60,3 +61,11 @@ def hide_axes(self): def hide_mouse(self): """Prevents user from selecting mouse-related stuff""" self.component.hide_mouse() + + def show_clear(self): + """Reveals a 'Clear' button. The caller's callback must accept a NoAction + (i.e. handle the unset case).""" + self.builder.get_object("btClear").set_visible(True) + + def on_btClear_clicked(self, *a): + self.set_action(NoAction()) diff --git a/ui/simple_chooser.ui b/ui/simple_chooser.ui index 80121f96e..d447b3938 100644 --- a/ui/simple_chooser.ui +++ b/ui/simple_chooser.ui @@ -6,7 +6,18 @@ 0 - + + + + Clear + True + True + True + Unset this axis + + + + From d91e4aac3aa31afd4d6b5e9bc1d3adfc366a28ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Tue, 7 Jul 2026 00:40:35 +0200 Subject: [PATCH 46/98] fix(gyro,gui): make gyro->mouse fully work (routing, labels, direction, highlight) Absolute gyro could target Mouse X/Y, but a family of Axes/Rels IntEnum collisions -- plus Rels.REL_X having integer value 0 (falsy) -- broke it: - GyroAbsAction.gyro routed REL_X/REL_Y into the gamepad branch, because `axis in Axes.__members__.values()` is True for them (REL_X == ABS_X == 0, REL_Y == ABS_Y == 1), so gyro->mouse moved the stick, not the cursor. Use isinstance(axis, Axes) so the mouse branches run. Same fix in GyroAction.gyro. - MouseAction.__init__ did `self._mouse_axis = axis or None`, collapsing REL_X (value 0, falsy) to None -- describing it as "Mouse" (not "Mouse X") and moving both axes. Assign directly. - GyroAction.describe used `if x:` (skipping REL_X) and a colliding `in Rels.__members__.values()` that returned a bare "Mouse". Rewrite it per-axis, `is not None`, isinstance-aware -> "Mouse X" / "Mouse Y". - Mouse Y was inverted: negate REL_Y (screen Y grows downward) so tilting up moves the cursor up. X needed no flip. - action_to_area never highlighted a configured Mouse X/Y/Wheel in the axis chooser: the whole-axis mouse entries carried a redundant trailing "1" (2-param, and a duplicate of MOUSE_RIGHT), so the matcher skipped them against the bare 1-param MouseAction the editors store. Make MOUSE_X/Y/WHEEL/HWHEEL bare, mirroring the bare stick entries (ABS_X etc.). Verified on DS4 hardware: gyro-absolute -> Mouse X/Y moves the cursor with the correct direction, labels read "Mouse X"/"Mouse Y", the axis dialog highlights them, and stick axes / relative mode are unaffected. Co-Authored-By: Claude Opus 4.8 --- scc/actions.py | 39 +++++++++++++++++++++++++++------------ scc/gui/area_to_action.py | 13 +++++++++---- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/scc/actions.py b/scc/actions.py index eb6b93f39..e6b6273c4 100644 --- a/scc/actions.py +++ b/scc/actions.py @@ -789,7 +789,10 @@ class MouseAction(WholeHapticAction, Action): def __init__(self, axis=None, speed: float | None = None) -> None: Action.__init__(self, *strip_none(axis, speed)) WholeHapticAction.__init__(self) - self._mouse_axis = axis or None + # NOT `axis or None`: Rels.REL_X has integer value 0, which is falsy, so + # `or None` would collapse REL_X to None -- describing it as "Mouse" (not + # "Mouse X") and moving both axes instead of just X. + self._mouse_axis = axis if axis is not None else None self._old_pos = None if speed: self.speed: tuple[float, float] = (speed, speed) @@ -1179,8 +1182,11 @@ def get_speed(self) -> tuple[float, float, float]: def gyro(self, mapper: Mapper, *pyr): for i in (0, 1, 2): axis = self.axes[i] - # 'gyro' cannot map to mouse, but 'mouse' does that. - if axis in Axes.__members__.values() or type(axis) is int: + # 'gyro' cannot map to mouse, but 'mouse' does that. isinstance, not + # `in Axes.__members__.values()`: Rels and Axes are IntEnums with + # overlapping values (REL_X == ABS_X == 0), so the membership test + # would misroute a mouse axis here as a gamepad axis. + if isinstance(axis, Axes) or type(axis) is int: mapper.gamepad.axisEvent(axis, AxisAction.clamp_axis(axis, pyr[i] * self.speed[i] * -10)) mapper.syn_list.add(mapper.gamepad) @@ -1188,15 +1194,18 @@ def describe(self, context): if self.name: return self.name rv = [] - - if self.axes[0] in Rels.__members__.values(): - return _("Mouse") - for x in self.axes: - if x: + # `is not None`, not truthiness: Rels.REL_X / Axes.ABS_X have value 0 + # (falsy) yet are valid axes. isinstance keeps mouse (Rels) apart from + # stick (Axes) -- their integer values collide (REL_X == ABS_X == 0). + if x is None: + continue + if isinstance(x, Rels): + s = MouseAction(x).describe(context) + else: s, trash, trash = AxisAction.get_axis_description(x) - if s not in rv: - rv.append(s) + if s not in rv: + rv.append(s) return "\n".join(rv) @@ -1257,7 +1266,11 @@ def gyro(self, mapper: Mapper, pitch, yaw, roll, q1, q2, q3, q4): pyr[i] = int(clamp(STICK_PAD_MIN, pyr[i], STICK_PAD_MAX)) for i in self.GYROAXES: axis = self.axes[i] - if axis in Axes.__members__.values() or type(axis) == int: + # isinstance, not `in Axes.__members__.values()`: REL_X == ABS_X == 0 + # and REL_Y == ABS_Y == 1 by IntEnum value, so the membership test + # swallowed the mouse axes into this gamepad branch (the elifs below + # never ran) -- gyro->mouse moved the stick instead of the cursor. + if isinstance(axis, Axes) or type(axis) == int: val = AxisAction.clamp_axis(axis, pyr[i] * self.speed[i]) if self._deadzone_fn: val, trash = self._deadzone_fn(val, 0, STICK_PAD_MAX) @@ -1267,7 +1280,9 @@ def gyro(self, mapper: Mapper, pitch, yaw, roll, q1, q2, q3, q4): elif axis == Rels.REL_X: mapper.mouse_move(AxisAction.clamp_axis(axis, pyr[i] * GyroAbsAction.MOUSE_FACTOR * self.speed[i]), 0) elif axis == Rels.REL_Y: - mapper.mouse_move(0, AxisAction.clamp_axis(axis, pyr[i] * GyroAbsAction.MOUSE_FACTOR * self.speed[i])) + # Screen Y grows downward, so negate: tilting/looking up moves the + # cursor up. (REL_X needs no flip -- verified on hardware.) + mapper.mouse_move(0, -AxisAction.clamp_axis(axis, pyr[i] * GyroAbsAction.MOUSE_FACTOR * self.speed[i])) class ResetGyroAction(Action): diff --git a/scc/gui/area_to_action.py b/scc/gui/area_to_action.py index 73b6740cd..4b4cb74c9 100644 --- a/scc/gui/area_to_action.py +++ b/scc/gui/area_to_action.py @@ -83,10 +83,15 @@ Rels.REL_Y, 1, ), - "MOUSE_X": (MouseAction, Rels.REL_X, 1), - "MOUSE_Y": (MouseAction, Rels.REL_Y, 1), - "MOUSE_WHEEL": (MouseAction, Rels.REL_WHEEL, 1), - "MOUSE_HWHEEL": (MouseAction, Rels.REL_HWHEEL, 1), + # Full-axis (whole) mouse entries are bare, mirroring the bare stick entries + # (ABS_X etc.) -- the whole X axis is MouseAction(REL_X), no direction. The + # trailing "1" made these 2-param, so action_to_area could never match the + # 1-param MouseAction the editors store (gyro Mouse X/Y went un-highlighted), + # and it duplicated the directional MOUSE_LEFT/RIGHT/UP/DOWN entries anyway. + "MOUSE_X": (MouseAction, Rels.REL_X), + "MOUSE_Y": (MouseAction, Rels.REL_Y), + "MOUSE_WHEEL": (MouseAction, Rels.REL_WHEEL), + "MOUSE_HWHEEL": (MouseAction, Rels.REL_HWHEEL), # Mouse buttons "MOUSE1": (ButtonAction, Keys.BTN_LEFT), "MOUSE2": (ButtonAction, Keys.BTN_MIDDLE), From 6a46a5cab7ece9160f654b5a2861250e3644830c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Thu, 9 Jul 2026 18:24:56 +0200 Subject: [PATCH 47/98] fix(actions): decode euler gyros in TiltAction (rest false-fires, axis 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 --- scc/actions.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/scc/actions.py b/scc/actions.py index e6b6273c4..7da223d87 100644 --- a/scc/actions.py +++ b/scc/actions.py @@ -1369,7 +1369,19 @@ def get_compatible_modifiers(self): def gyro(self, mapper: Mapper, *pyr): q1, q2, q3, q4 = pyr[-4:] - pyr = quat2euler(q1 / 32767.0, q2 / 32767.0, q3 / 32767.0, q4 / 32767.0) + if mapper.get_controller().flags & ControllerFlags.EUREL_GYROS: + # q1-q3 already hold euler angles in 2**15/PI fixed point (q4 unused). + # Feeding them into quat2euler as if they were a quaternion computes + # atan2 of near-zero noise products -> arbitrary full-range angles that + # constantly cross MIN and fire tilt actions with the pad at rest. + # + # Slot order below is (front down/up, TILTED left/right, ROTATED + # left/right) = (pitch, roll, yaw), so swap yaw/roll into that order; + # pitch and yaw are negated to match the slot firing directions against + # the DS4 integration conventions (all three axes hw-verified). + pyr = (-q1 / 10430.37, q3 / 10430.37, -q2 / 10430.37) + else: + pyr = quat2euler(q1 / 32767.0, q2 / 32767.0, q3 / 32767.0, q4 / 32767.0) for j in (0, 1, 2): i = j * 2 if self.actions[i]: From 89623b63171fefc4d2f17626162252778256fd59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Fri, 10 Jul 2026 04:13:12 +0200 Subject: [PATCH 48/98] gyro: intuitive mouse semantics -- Absolute = laser pointer, relative = 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 --- scc/actions.py | 45 ++++++++++++++++++++++++++++++++++++--------- scc/gui/ae/gyro.py | 9 +++++---- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/scc/actions.py b/scc/actions.py index 7da223d87..9f6920f22 100644 --- a/scc/actions.py +++ b/scc/actions.py @@ -1164,6 +1164,11 @@ class GyroAction(Action): """Uses *relative* gyroscope position as input for emulated axes""" COMMAND = "gyro" + # Mouse-path tuning, shared with GyroAbsAction. RATE_SIGN is per gyro axis + # (pitch, yaw, roll), hw-verified on the DS4. MOUSE_FACTOR scales the + # lean-to-turn cursor velocity into a sane default range. + MOUSE_RATE_SIGN = (1.0, -1.0, -1.0) + MOUSE_FACTOR = 0.01 def __init__(self, axis1, axis2=None, axis3=None): Action.__init__(self, axis1, *strip_none(axis2, axis3)) @@ -1180,15 +1185,34 @@ def get_speed(self) -> tuple[float, float, float]: return self.speed def gyro(self, mapper: Mapper, *pyr): + angles = None for i in (0, 1, 2): axis = self.axes[i] - # 'gyro' cannot map to mouse, but 'mouse' does that. isinstance, not - # `in Axes.__members__.values()`: Rels and Axes are IntEnums with - # overlapping values (REL_X == ABS_X == 0), so the membership test - # would misroute a mouse axis here as a gamepad axis. + # isinstance, not `in Axes.__members__.values()`: Rels and Axes are + # IntEnums with overlapping values (REL_X == ABS_X == 0), so the + # membership test would misroute a mouse axis here as a gamepad axis. if isinstance(axis, Axes) or type(axis) is int: mapper.gamepad.axisEvent(axis, AxisAction.clamp_axis(axis, pyr[i] * self.speed[i] * -10)) mapper.syn_list.add(mapper.gamepad) + # Relative mouse = lean-to-turn: cursor VELOCITY is proportional to + # the held tilt angle -- lean and the cursor keeps moving, return to + # level and it stops. (For laser-pointer tracking, where the cursor + # follows the rotation and stops with it, check Absolute.) + elif axis in (Rels.REL_X, Rels.REL_Y) and len(pyr) >= 7: + if angles is None: + q1, q2, q3, q4 = pyr[3:7] + if mapper.get_controller().flags & ControllerFlags.EUREL_GYROS: + angles = (q1 / 10430.37, q2 / 10430.37, q3 / 10430.37) # 2**15 / PI + else: + angles = quat2euler(q1 / 32767.0, q2 / 32767.0, q3 / 32767.0, q4 / 32767.0) + # saturate at +-90 deg, then scale to a sane default velocity + v = clamp(STICK_PAD_MIN, angles[i] * (2**15) * 2 / PI, STICK_PAD_MAX) + v = v * GyroAction.MOUSE_FACTOR * self.speed[i] + if axis == Rels.REL_X: + mapper.mouse_move(v, 0) + else: + # screen Y grows downward (sign hw-verified on the DS4) + mapper.mouse_move(0, -v) def describe(self, context): if self.name: @@ -1213,7 +1237,6 @@ class GyroAbsAction(HapticEnabledAction, GyroAction): """Uses *absolute* gyroscope position as input for emulated axes""" COMMAND = "gyroabs" - MOUSE_FACTOR = 0.01 # Just random number to put default sensitivity into sane range def __init__(self, *blah): GyroAction.__init__(self, *blah) @@ -1238,6 +1261,7 @@ def get_previewable(self) -> bool: GYROAXES = (0, 1, 2) def gyro(self, mapper: Mapper, pitch, yaw, roll, q1, q2, q3, q4): + rates = (pitch, yaw, roll) # raw angular rates, used for the mouse axes if mapper.get_controller().flags & ControllerFlags.EUREL_GYROS: pyr = [q1 / 10430.37, q2 / 10430.37, q3 / 10430.37] # 2**15 / PI else: @@ -1277,12 +1301,15 @@ def gyro(self, mapper: Mapper, pitch, yaw, roll, q1, q2, q3, q4): val = int(val) mapper.gamepad.axisEvent(axis, val) mapper.syn_list.add(mapper.gamepad) + # Absolute mouse = laser pointer: the angular RATE is the move delta + # (like MouseAction.gyro); the rate integrates to the rotation angle, + # so the cursor tracks the controller's absolute orientation and stops + # when the rotation stops (Steam's gyro-mouse behavior). For + # angle-proportional cursor velocity (lean-to-turn), uncheck Absolute. elif axis == Rels.REL_X: - mapper.mouse_move(AxisAction.clamp_axis(axis, pyr[i] * GyroAbsAction.MOUSE_FACTOR * self.speed[i]), 0) + mapper.mouse_move(rates[i] * GyroAction.MOUSE_RATE_SIGN[i] * self.speed[i], 0) elif axis == Rels.REL_Y: - # Screen Y grows downward, so negate: tilting/looking up moves the - # cursor up. (REL_X needs no flip -- verified on hardware.) - mapper.mouse_move(0, -AxisAction.clamp_axis(axis, pyr[i] * GyroAbsAction.MOUSE_FACTOR * self.speed[i])) + mapper.mouse_move(0, rates[i] * GyroAction.MOUSE_RATE_SIGN[i] * self.speed[i]) class ResetGyroAction(Action): diff --git a/scc/gui/ae/gyro.py b/scc/gui/ae/gyro.py index fb6cd9baa..0970ba794 100644 --- a/scc/gui/ae/gyro.py +++ b/scc/gui/ae/gyro.py @@ -97,10 +97,11 @@ def cb(action): b = SimpleChooser(self.app, "axis", cb) b.set_title(_("Select Axis")) b.show_clear() - # Mouse is a valid target here: an axis marked "Absolute" becomes a - # GyroAbsAction, which maps orientation to REL_X/REL_Y (mapper.mouse_move). - # (Relative GyroAction ignores mouse -- use the dedicated Mouse gyro editor - # for that.) hide_mouse() wrongly blocked it, greying out the mouse arrows. + # Mouse is a valid target here in both modes: Absolute checked gives + # laser-pointer tracking (cursor follows the rotation, Steam-style, + # rate-based); unchecked gives lean-to-turn (cursor velocity follows the + # held tilt angle). hide_mouse() wrongly blocked it, greying out the + # mouse arrows. # # Show the current axis with the matching action class, or the chooser # highlights the stick for a mouse axis (Axes/Rels value collision) and From 703b510da167d1f367c4dd2d23a23e8bebc4bd2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sat, 11 Jul 2026 09:07:42 +0200 Subject: [PATCH 49/98] tests,docs: cover the 'inverted' modifier (3 long-failing meta-tests) 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 --- docs/actions.md | 8 ++++++++ tests/test_parser/test_modifiers.py | 7 +++++++ tests/test_profile/test_modifiers.py | 7 +++++++ 3 files changed, 22 insertions(+) diff --git a/docs/actions.md b/docs/actions.md index 69bc4d33f..bf90c23ea 100755 --- a/docs/actions.md +++ b/docs/actions.md @@ -325,6 +325,14 @@ A button whenever physical button is pressed. #### released(action) Creates action that occurs for brief moment when button is released. +#### inverted(action) +Acts on release: swaps press and release, so the wrapped action is held while +the physical button is NOT pressed and released while it is. Meant for +always-on sensors such as the capacitive handle grips, which read "on" the +whole time the controller is held - inverting them fires the action when you +let go. Unlike `pressed`/`released`, which emit a momentary tap, this is a +true held inversion of the button state. + #### pressed(action) Creates action that occurs for brief moment when finger touches pad. diff --git a/tests/test_parser/test_modifiers.py b/tests/test_parser/test_modifiers.py index 303ba8029..133902910 100644 --- a/tests/test_parser/test_modifiers.py +++ b/tests/test_parser/test_modifiers.py @@ -37,6 +37,13 @@ def test_released(self): a = _parse_compressed("pressed(axis(KEY_A))") assert isinstance(a, PressedModifier) + def test_inverted(self): + """Tests if InvertedButtonModifier is parsed + """ + a = _parse_compressed("inverted(button(KEY_A))") + assert isinstance(a, InvertedButtonModifier) + assert isinstance(a.action, ButtonAction) + def test_touched(self): """Tests if TouchedModifier is parsed""" a = _parse_compressed("touched(button(KEY_A))") diff --git a/tests/test_profile/test_modifiers.py b/tests/test_profile/test_modifiers.py index 81fa99383..f2195074b 100644 --- a/tests/test_profile/test_modifiers.py +++ b/tests/test_profile/test_modifiers.py @@ -53,6 +53,13 @@ def test_released(self): assert isinstance(a, ReleasedModifier) assert _is_axis_with_value(a.action) + def test_inverted(self): + """Tests if InvertedButtonModifier is parsed correctly from json. + """ + a = parser.from_json_data({"action": "inverted(axis(ABS_X))"}) + assert isinstance(a, InvertedButtonModifier) + assert _is_axis_with_value(a.action) + def test_touched(self): """Tests if TouchedModifier is parsed correctly from json.""" a = parser.from_json_data({"action": "touched(button(KEY_A))"}) From c480cc2f3e8412e6a9d45adba342d2d7f08a6553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sun, 12 Jul 2026 03:12:01 +0200 Subject: [PATCH 50/98] fix(gyro): align "Mouse (Desktop)" signs with the calibrated rate table 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 --- scc/actions.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scc/actions.py b/scc/actions.py index 9f6920f22..ad05217a5 100644 --- a/scc/actions.py +++ b/scc/actions.py @@ -915,10 +915,16 @@ def whole(self, mapper: Mapper, x, y, what) -> None: self._old_pos = None def gyro(self, mapper: Mapper, pitch, yaw, roll, *a): + # Use the same per-gyro-axis rate signs as the Per-Axis mouse paths + # (GyroAction.MOUSE_RATE_SIGN, hw-verified on DS4 + SC2). The old + # hardcoded negation of ALL axes predates the drivers' rate-convention + # normalization; with normalized rates it inverted pitch (screen Y), + # while its yaw/roll negation happened to match the sign table. + sp, sy, sr = GyroAction.MOUSE_RATE_SIGN if self._mouse_axis == YAW: - mapper.mouse_move(yaw * -self.speed[0], pitch * -self.speed[1]) + mapper.mouse_move(yaw * sy * self.speed[0], pitch * sp * self.speed[1]) else: - mapper.mouse_move(roll * -self.speed[0], pitch * -self.speed[1]) + mapper.mouse_move(roll * sr * self.speed[0], pitch * sp * self.speed[1]) def trigger(self, mapper: Mapper, position, old_position): delta = position - old_position From 2560de9c5d0a8f5864612c271faa6462f458a2d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sun, 12 Jul 2026 03:12:44 +0200 Subject: [PATCH 51/98] gui: per-controller grip/paddle button lists (gyro enablers, chords, 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 --- scc/gui/ae/__init__.py | 57 +++++++++++++++++++++++++++++++++++++ scc/gui/ae/first_page.py | 11 +++++-- scc/gui/ae/gyro.py | 2 +- scc/gui/ae/gyro_action.py | 19 +++++++++++-- scc/gui/modeshift_editor.py | 10 +++++++ 5 files changed, 93 insertions(+), 6 deletions(-) diff --git a/scc/gui/ae/__init__.py b/scc/gui/ae/__init__.py index 941b2590d..422b9c714 100644 --- a/scc/gui/ae/__init__.py +++ b/scc/gui/ae/__init__.py @@ -10,11 +10,68 @@ from gi.repository import Gdk, GLib, Gtk from scc.actions import Action, NoAction, XYAction +from scc.constants import SCButtons from scc.gui.editor import ComboSetter from scc.tools import _, ensure_size log = logging.getLogger("AE") +# Rear-paddle display names. On controllers whose four rear paddles are +# physically labeled L4/L5/R4/R5 (Steam Controller 2, Steam Deck) the generic +# "Left Grip (2)" naming is confusing -- especially on the SC2, where "grip" +# reads as the capacitive handle sensor instead. The Steam Controller v1's +# LGRIP/RGRIP are its squeeze grips and keep the "Left/Right Grip" naming. +PADDLE_TYPES = ("sc2", "deck") +PADDLE_NAMES = { + SCButtons.LGRIP: "L4", + SCButtons.RGRIP: "R4", + SCButtons.LGRIP2: "L5", + SCButtons.RGRIP2: "R5", +} + + +def button_label(app, button, default): + """Per-controller display name for a button: the rear paddles are + L4/L5/R4/R5 on paddle controllers (sc2/deck), the default (grip) name + elsewhere. `app` may be None (falls back to the default label).""" + ctype = None + try: + c = app.profile_switchers[0].get_controller() if app else None + ctype = c.get_type() if c else None + except Exception: + pass + if ctype in PADDLE_TYPES and button in PADDLE_NAMES: + return PADDLE_NAMES[button] + return default + + +# Buttons that only SOME controllers have and whose presence is reliably +# recorded in the controller's gui config "buttons" capability list (sc2 and +# deck ship configs; everything else falls back to DEFAULT_BUTTONS, which +# correctly lacks these). Only these are ever filtered out of button lists -- +# filtering arbitrary buttons against the capability list would wrongly hide +# valid entries on configless controllers (e.g. the DS4's stick presses). +OPTIONAL_BUTTONS = { + SCButtons.LGRIP2, SCButtons.RGRIP2, + SCButtons.LGRIPTOUCH, SCButtons.RGRIPTOUCH, + SCButtons.LSTICKTOUCH, SCButtons.RSTICKTOUCH, +} + + +def button_available(app, button) -> bool: + """True unless `button` is an optional extra (OPTIONAL_BUTTONS) that the + currently displayed controller's capability list does not include.""" + if button not in OPTIONAL_BUTTONS: + return True + try: + available = app.background.get_config()["buttons"] if app else None + except Exception: + available = None + if not available: + return True + from scc.tools import nameof + return nameof(button) in available + class AEComponent(ComboSetter): GLADE = None diff --git a/scc/gui/ae/first_page.py b/scc/gui/ae/first_page.py index c943b27a2..5b7e5b438 100644 --- a/scc/gui/ae/first_page.py +++ b/scc/gui/ae/first_page.py @@ -6,7 +6,8 @@ import logging from scc.actions import Action -from scc.gui.ae import AEComponent +from scc.constants import SCButtons +from scc.gui.ae import AEComponent, button_label from scc.tools import _, nameof log = logging.getLogger("AE.1st") @@ -96,6 +97,8 @@ def load(self): "RPAD": _("Right Pad"), "LGRIP": _("Left Grip"), "RGRIP": _("Right Grip"), + "LGRIP2": _("Left Grip 2"), + "RGRIP2": _("Right Grip 2"), "LB": _("Left Bumper"), "RB": _("Right Bumper"), "LEFT": _("Left Trigger"), @@ -104,8 +107,12 @@ def load(self): "RSTICK": _("Right Stick"), } + name = nameof(self.editor.get_id()) + what = long_names.get(name, name.title()) + # Per-controller paddle naming (L4/L5/R4/R5 on sc2/deck) + what = button_label(self.app, getattr(SCButtons, name, None), what) markup = markup % { - "what": long_names.get(nameof(self.editor.get_id()), nameof(self.editor.get_id()).title()), + "what": what, } self.builder.get_object("lblMarkup").set_markup(markup.strip(" \r\n\t")) return True diff --git a/scc/gui/ae/gyro.py b/scc/gui/ae/gyro.py index 0970ba794..1e99afb9a 100644 --- a/scc/gui/ae/gyro.py +++ b/scc/gui/ae/gyro.py @@ -38,7 +38,7 @@ def load(self): cbGyroButton = self.builder.get_object("cbGyroButton") self._recursing = True cbGyroButton = self.builder.get_object("cbGyroButton") - fill_buttons(cbGyroButton) + fill_buttons(cbGyroButton, self.app) self._recursing = False self.buttons = [self.builder.get_object(x) for x in ("btPitch", "btYaw", "btRoll")] self.cbs = [self.builder.get_object(x) for x in ("cbPitchAbs", "cbYawAbs", "cbRollAbs")] diff --git a/scc/gui/ae/gyro_action.py b/scc/gui/ae/gyro_action.py index 8acb81a89..56df78ed3 100644 --- a/scc/gui/ae/gyro_action.py +++ b/scc/gui/ae/gyro_action.py @@ -6,7 +6,7 @@ from scc.actions import Action, GyroAbsAction, GyroAction, MouseAbsAction, MouseAction, MultiAction, NoAction, RangeOP from scc.constants import LSTICK, ROLL, YAW, SCButtons -from scc.gui.ae import AEComponent +from scc.gui.ae import AEComponent, button_available, button_label from scc.gui.parser import GuiActionParser from scc.modifiers import ModeModifier, SensitivityModifier from scc.special_actions import CemuHookAction @@ -38,8 +38,17 @@ class GyroActionComponent(AEComponent): (SCButtons.LPAD, _("Left Pad Pressed")), (SCButtons.RPAD, _("Right Pad Pressed")), (None, None), + # The rear paddles. Labels are per-controller (see button_label): + # L4/R4/L5/R5 on sc2/deck, Left/Right Grip (2) elsewhere. (SCButtons.LGRIP, _("Left Grip")), (SCButtons.RGRIP, _("Right Grip")), + (SCButtons.LGRIP2, _("Left Grip 2")), + (SCButtons.RGRIP2, _("Right Grip 2")), + # Capacitive handle sensors (Steam Controller 2). On the SC2 the + # entries above are the rear paddles; the handle TOUCH sensors are + # separate buttons -- and the natural "gyro while gripping" enabler. + (SCButtons.LGRIPTOUCH, _("Left Grip Touched")), + (SCButtons.RGRIPTOUCH, _("Right Grip Touched")), (LSTICK, _("Left Stick Tilted")), (None, None), (SCButtons.A, _("A")), @@ -67,7 +76,7 @@ def load(self) -> None: self.builder.get_object("sclSoftLevel").set_format_value_func(self.on_sclSoftLevel_format_value) self._recursing = True cbGyroButton = self.builder.get_object("cbGyroButton") - fill_buttons(cbGyroButton) + fill_buttons(cbGyroButton, self.app) self._recursing = False def set_action(self, mode: int, action) -> None: @@ -313,9 +322,13 @@ def is_gyro_enable(modemod) -> bool: return False -def fill_buttons(cb): +def fill_buttons(cb, app=None): cb.set_row_separator_func(lambda model, iter: model.get_value(iter, 1) is None) model = cb.get_model() for button, text in GyroActionComponent.BUTTONS: + if button is not None: + if not button_available(app, button): + continue # e.g. grip-touch / GRIP2 on controllers without them + text = button_label(app, button, text) model.append((None if button is None else nameof(button), text)) cb.set_active(0) diff --git a/scc/gui/modeshift_editor.py b/scc/gui/modeshift_editor.py index bf506cfbf..a77a57d7f 100644 --- a/scc/gui/modeshift_editor.py +++ b/scc/gui/modeshift_editor.py @@ -10,6 +10,7 @@ from scc.actions import Action, NoAction, RangeOP from scc.constants import HapticPos, SCButtons +from scc.gui.ae import button_available, button_label from scc.gui.controller_widget import PADS, STICKS from scc.gui.dwsnc import headerbar from scc.gui.editor import Editor @@ -103,6 +104,11 @@ def _fill_button_chooser(self, *a): if any([x[0] == item for x in self.actions[self.current_page]]): # Skip already added buttons continue + if isinstance(item, SCButtons): + if not button_available(self.app, item): + continue # optional button this controller doesn't have + # Per-controller paddle naming (L4/L5/R4/R5 on sc2/deck) + text = button_label(self.app, item, text) if type(item) is str: # Special case for soft pull items button = getattr(SCButtons, item.split(" ")[-1]) @@ -208,6 +214,10 @@ def on_clearb_clicked(self, trash, index, button): model.clear() # Fill it again for button, text in self.BUTTONS: + if isinstance(button, SCButtons): + if not button_available(self.app, button): + continue # optional button this controller doesn't have + text = button_label(self.app, button, text) model.append((None if button is None else nameof(button), text)) if button is not None: if nameof(button) == active: From 3cf48d514c4b40543ce4ad7af043074db74b70c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sun, 12 Jul 2026 03:12:44 +0200 Subject: [PATCH 52/98] fix(gyro): make the Recenter Gyro special action actually recenter 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 --- scc/actions.py | 18 +++++++++++++++++- scc/mapper.py | 8 ++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/scc/actions.py b/scc/actions.py index ad05217a5..58100bc81 100644 --- a/scc/actions.py +++ b/scc/actions.py @@ -1180,6 +1180,15 @@ def __init__(self, axis1, axis2=None, axis3=None): Action.__init__(self, axis1, *strip_none(axis2, axis3)) self.axes = [axis1, axis2, axis3] self.speed: tuple[float, float, float] = (1.0, 1.0, 1.0) + # lean-to-turn neutral reference (per gyro axis, radians); captured on + # the first gyro event after (re)activation or a Recenter Gyro action. + self._lean_ref = [None, None, None] + + def reset(self) -> None: + """Re-captures the lean-to-turn neutral pose on the next gyro event. + Called by mapper.reset_gyros (the Recenter Gyro special action) and by + ModeModifier when a gated gyro deactivates.""" + self._lean_ref = [None, None, None] def get_compatible_modifiers(self): return Action.MOD_SENSITIVITY | Action.MOD_SENS_Z @@ -1211,8 +1220,15 @@ def gyro(self, mapper: Mapper, *pyr): angles = (q1 / 10430.37, q2 / 10430.37, q3 / 10430.37) # 2**15 / PI else: angles = quat2euler(q1 / 32767.0, q2 / 32767.0, q3 / 32767.0, q4 / 32767.0) + # The lean is measured against the neutral reference captured on + # the first event after (re)activation or Recenter Gyro -- NOT + # against the driver's absolute zero, whose yaw is an arbitrary + # power-on orientation (and would make yaw-lean unusable). + if self._lean_ref[i] is None: + self._lean_ref[i] = angles[i] + lean = anglediff(self._lean_ref[i], angles[i]) # saturate at +-90 deg, then scale to a sane default velocity - v = clamp(STICK_PAD_MIN, angles[i] * (2**15) * 2 / PI, STICK_PAD_MAX) + v = clamp(STICK_PAD_MIN, lean * (2**15) * 2 / PI, STICK_PAD_MAX) v = v * GyroAction.MOUSE_FACTOR * self.speed[i] if axis == Rels.REL_X: mapper.mouse_move(v, 0) diff --git a/scc/mapper.py b/scc/mapper.py index ad2e87216..579d31cc0 100644 --- a/scc/mapper.py +++ b/scc/mapper.py @@ -6,7 +6,7 @@ import traceback from typing import TYPE_CHECKING -from scc.actions import Action, ButtonAction, GyroAbsAction +from scc.actions import Action, ButtonAction, GyroAction from scc.aliases import ALL_AXES, ALL_BUTTONS from scc.config import Config from scc.constants import ( @@ -465,8 +465,12 @@ def cancel_all(self) -> None: self.clear_bt_stick_mouse_velocity() def reset_gyros(self) -> None: + # GyroAction covers GyroAbsAction (subclass): absolute actions re-capture + # their orientation reference (ir), relative ones their lean-to-turn + # neutral pose. Rate-based outputs (laser-pointer mouse, relative stick) + # have no reference by nature, so recentering rightly leaves them alone. for a in self.profile.get_all_actions(): - if isinstance(a, GyroAbsAction): + if isinstance(a, GyroAction): a.reset() def input(self, controller: Controller, old_state: CInput, state: CInput) -> None: From ac4530ff23f212ebd5f7bd72aa2a492c5c485fde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Fri, 19 Jun 2026 22:50:18 +0200 Subject: [PATCH 53/98] docs: add a "Using multiple controllers" section to the README Covers the one-window / one-bar-per-controller model, per-controller remembered profiles, safe disconnect, and the "Use Serial Numbers to Identify Controllers" setting (connection-order vs per-device identity). Screenshots may follow. Co-Authored-By: Claude Opus 4.8 --- README.md | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 60478b53d..7fbb01497 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ User-mode driver, mapper, and GTK 4 based GUI for game controllers, including bu - Allows to setup, configure and use the Steam Controller (2015 & 2026) without ever launching Steam - Emulates the Xbox 360 controller, mouse, trackball and keyboard -- Connect multiple controllers at the same time +- Connect multiple controllers at the same time, each with its own remembered profile - Supports profiles, switchable in the GUI, or with a controller button - Joystick, Touchpad and Gyroscope input - Haptic Feedback and in-game Rumble support @@ -73,6 +73,41 @@ Beware that even when you do use automapper instead of manual assignment, you st *Note that there is no automatic discovery of evdev nodes for gyroscope/touchpad, so if your controller has one of those, it currently needs SCC to implement a custom driver for it.*
*That does mean that devices like Nintendo controllers do not have a working gyroscope, as I do not own any.* +## Using multiple controllers + +SC Controller can drive several controllers at once — Steam Controllers (v1 and +v2), a DualShock 4 and others can all be connected together. + +- **One window, one bar per controller.** Just connect them: each controller + gets its own profile selector stacked in the main window, there is no separate + window per device. The controller that connected *first* is the primary one — + it is the one drawn on the big controller image and the default target when a + command (a menu, the OSD) does not name a specific controller. +- **Each controller keeps its own profile.** Picking a profile from a + controller's own bar applies only to that controller. The choice is remembered + and restored automatically the next time that controller connects, so you do + not have to re-pick it every session. +- **Disconnecting is safe.** Turning one controller off (or letting it go idle) + leaves the window and the other controllers untouched; when it comes back it + returns to its remembered profile. + +### Telling controllers apart + +How a controller is identified — and therefore which remembered profile and +per-controller settings it gets — is governed by **Use Serial Numbers to +Identify Controllers** in *Settings*: + +- **Off (default):** controllers are identified by connection order (first + connected, second connected, …). This is simplest for a fixed setup, but if + you change which controller powers on first they will swap profiles. +- **On:** each controller is identified by its own hardware serial number, so + its profile and settings follow the physical device no matter what order + things connect in. + +Turn this **on** when you regularly use more than one controller — especially +two of the same model, such as two Steam Controllers — and want each to reliably +keep its own profile. + ## Like what I'm doing? You can check out the ways to donate on [my website](https://rys.rs/donate), or just go straight to my [Ko-Fi](https://ko-fi.com/martinrys). From 9ff1663cb81c9424e58310bb3e7e5c2d926c165c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sun, 21 Jun 2026 14:46:34 +0200 Subject: [PATCH 54/98] docs(README): document the controller selector and add a multi-controller screenshot The "Using multiple controllers" section still described the old layout (one stacked profile bar per controller). Rewrote it for the current design: a controller-selector bar that lists every connected controller (by type, numbered for duplicates) with its current profile, plus a separate bar that sets the selected controller's profile. Added a screenshot of three controllers (two v1s and a v2) connected at once. Co-Authored-By: Claude Opus 4.8 --- README.md | 27 ++++++++++++++++++--------- docs/multiple-controllers.jpg | Bin 0 -> 74459 bytes 2 files changed, 18 insertions(+), 9 deletions(-) create mode 100644 docs/multiple-controllers.jpg diff --git a/README.md b/README.md index 7fbb01497..11b43a4df 100644 --- a/README.md +++ b/README.md @@ -78,19 +78,28 @@ Beware that even when you do use automapper instead of manual assignment, you st SC Controller can drive several controllers at once — Steam Controllers (v1 and v2), a DualShock 4 and others can all be connected together. -- **One window, one bar per controller.** Just connect them: each controller - gets its own profile selector stacked in the main window, there is no separate - window per device. The controller that connected *first* is the primary one — - it is the one drawn on the big controller image and the default target when a - command (a menu, the OSD) does not name a specific controller. -- **Each controller keeps its own profile.** Picking a profile from a - controller's own bar applies only to that controller. The choice is remembered - and restored automatically the next time that controller connects, so you do - not have to re-pick it every session. +- **One window, two bars: pick the controller, then its profile.** Just connect + them: a controller-selector bar lists every connected controller (by type, + numbered when you have more than one of the same model) together with its + current profile; choosing one shows it on the big controller image, and a + second bar sets that controller's profile. There is no separate window per + device. The controller that connected *first* is the primary one — it is the + one drawn by default and the target when a command (a menu, the OSD) does not + name a specific controller. +- **Each controller keeps its own profile.** Selecting a controller and setting + a profile applies only to that controller. The choice is remembered and + restored automatically the next time that controller connects, so you do not + have to re-pick it every session. - **Disconnecting is safe.** Turning one controller off (or letting it go idle) leaves the window and the other controllers untouched; when it comes back it returns to its remembered profile. +![SC Controller with three controllers connected](docs/multiple-controllers.jpg?raw=true) + +*Two Steam Controller v1s and a Steam Controller v2 connected at once: the +selector at the top lists each controller — numbered when there are duplicates — +alongside its current profile.* + ### Telling controllers apart How a controller is identified — and therefore which remembered profile and diff --git a/docs/multiple-controllers.jpg b/docs/multiple-controllers.jpg new file mode 100644 index 0000000000000000000000000000000000000000..788ebfa1c3473447004f395f3291546d25262dee GIT binary patch literal 74459 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 B?dJdh literal 0 HcmV?d00001 From 9b8988c94d23265342b88e3ff66a1af2d6505789 Mon Sep 17 00:00:00 2001 From: Martin Rys Date: Sun, 12 Jul 2026 17:51:02 +0200 Subject: [PATCH 55/98] docs(appimage): document AppImage udev steps Co-Authored-By: Claude Opus 4.8 --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index 11b43a4df..8b5254fa5 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,23 @@ We also have a [Discord](https://discord.gg/Np7pgfTX6) available. +### AppImage: install the udev rules + +The AppImage is self-contained but **cannot install the udev rules** it needs (those live in a system directory). Without them your user can't access the controller and SC Controller can't create the virtual gamepad (`/dev/uinput`), so a detected controller appears to "do nothing". Distro packages install these rules for you; **AppImage users must do it once, by hand:** + +1. Download `69-sc-controller.rules` from the [latest release](https://github.com/Patola/sc-controller-cc/releases/latest). +2. Copy it into place — this needs `sudo`: + ```sh + sudo cp 69-sc-controller.rules /etc/udev/rules.d/69-sc-controller.rules + ``` +3. Reload and re-apply the rules: + ```sh + sudo udevadm control --reload-rules && sudo udevadm trigger + ``` +4. Unplug and replug the controller (or its wireless dongle) — or reboot. + +Only the AppImage needs this; the Arch and other distro packages already ship these rules. **The Steam Deck doesn't need it either** — SteamOS already ships udev rules for Steam devices, so the AppImage works out of the box there. + ## Building the package by yourself ### Dependencies From 8da75a6376f9afd396b136e27077fb7c13a64828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Mon, 15 Jun 2026 05:45:16 +0200 Subject: [PATCH 56/98] docs(TODO.md): Add changes 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 TODO: capture v2 GUI plan (stick "Touch" tab, grip on main, v2 artwork) 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 docs(todo): mark "Act on release" done; scope per-controller profile memory - Move "Act on release" (inverted button) to the Done list. - Scope a future feature: remember each controller's profile across (re)connects (persist by controller id from the daemon's "Profile:" handler, excluding autoswitch and temp profiles; load it in add_controller). Co-Authored-By: Claude Opus 4.8 docs(TODO): note per-controller icons and the serials-on multi-v1 limitation - Custom 24px icons per controller type (only sc/sc2 are bespoke today). - Steam Controller v1 with "Use Serial Numbers" on and multiple wireless dongles: only one v1 shows because one dongle throws a USBErrorPipe during flush and the daemon closes it. The serial read itself succeeds; the fix is to recover from the transient stall instead of tearing the dongle down. Workaround: use serials off. Co-Authored-By: Claude Opus 4.8 docs(TODO): scope continuous "HD rumble" (v2 haptics) as a future entry Records the baseline (single-pulse 0x8F / 0x82 clicks), the gap (the v2's continuous-rumble report is not yet identified), the capture-and-port approach (read SDL / hid-steam, check SDL3 v2 support first, else usbmon capture, replicate, map FF -> LRA) and the reference implementations. Co-Authored-By: Claude Opus 4.8 docs(todo): record Deck OSD menu fixes Deck OSD is missing "Display Current Bindings", "Run Program" and "Edit Bindings"; make the first two work there, and drop "Turn Controller OFF" on the Deck (its built-in controller can't be powered off). Co-Authored-By: Claude Opus 4.8 docs(todo): refine Deck OSD-menu item, add Deck tray-icon note 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 docs(todo): defer AppImage desktop app-id rebrand to org.patola.sc-controller-cc Co-Authored-By: Claude Opus 4.8 docs(todo): note the remaining deprecated new_from_stock icon calls 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 docs(todo): drop items that are now implemented or superseded - 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 docs(todo): note remaining DualShock 4 / DualSense issues 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 docs(todo): note DS4 gyro + Bluetooth rumble/lightbar follow-ups 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 docs(todo): DS4 gyro absolute works (drift + mouse-serialization follow-ups) Co-Authored-By: Claude Opus 4.8 docs(todo): evaluate selectable output device (Xbox / DS4-DS5 / none) 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 --- TODO.md | 143 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/TODO.md b/TODO.md index 7d5070afd..2882e92b1 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,135 @@ List of (possibly) planned features in no particular order: +- Selectable output device: virtual Xbox (today's default), virtual DS4/DS5, + or NO virtual controller at all. The current "Xbox 360 pad" is just a + generic uinput device wearing an X360 identity defined entirely in + config["output"] (scc/config.py: vendor/product/name/buttons/axes), so the + architecture already treats identity as data. Three tiers of work: + - no-controller mode (keyboard+mouse only) ALREADY EXISTS as the + undocumented SCC_NOGAMEPAD env var (scc/mapper.py create_gamepad); + promote it to a config key + GUI toggle (trivial), or per-profile + (moderate: create/destroy the uinput gamepad on profile switch). + Essential for games that refuse mouse/keyboard input while any + controller is detected; + - evdev-level DS4/DS5 identity presets (Sony VID/PID + the button/axis + layout SDL's gamecontrollerdb expects on the classic path): preset + table + GUI dropdown; gives PlayStation glyphs in most games, but no + gyro/touchpad/lightbar (those ride on hidraw, which uinput cannot + fake; SDL HIDAPI just falls back to evdev); + - faithful DS4/DS5 emulation via /dev/uhid: present a real HID device so + the kernel's hid-playstation binds and exposes motion/touchpad nodes + -> native in-game gyro from ANY supported controller. Big: a uhid + backend beside uinput, authentic report descriptors/streams, a udev + rule for /dev/uhid. Prior art exists (fake-DS4-over-uhid projects). + +- DualShock 4 / DualSense (ds4/ds5) polish. The HID driver is now functional + (mapper rstick/dpad guards, touchpad coordinate scaling + click highlight), but + rough edges remain: + - the two analog sticks are asymmetric in Input Test -- the left and right + stick brighten / behave differently from each other; + - the input icons drawn around the controller are the generic ones, not the + DualShock face symbols (cross / circle / square / triangle); + - the DS4 gyro works as relative and absolute (host-side euler integration in + _step_orientation, verified on hardware) but the absolute path is gyro-only, + so it DRIFTS and gimbals on large combined rotations. The clean next step is + accelerometer drift-correction: fuse gyro + accel (complementary filter) to + pin pitch/roll to the gravity vector -- the raw accel is still in q1-q3 right + after decode, before _step_orientation overwrites them (yaw has no absolute + reference without a magnetometer, so it drifts inherently); + - gyro -> MOUSE routes to the stick instead: an Axes/Rels IntEnum value + collision (ABS_X == REL_X) makes a mouse axis serialize/round-trip as a stick + axis. The gyro editor labels + chooser display are fixed, but the save/reload + path still needs a proper Axes-vs-Rels disambiguation; use gamepad axes for + gyro meanwhile; + - no rumble: neither DS4Controller nor DS4HidRawController drives the pad (the + DS5 driver does) -- port the DS5/kernel output report; over Bluetooth the + DS4HidRawController is input-only, so rumble + lightbar there need output + reports with the BT CRC32 wrapper (mirror DS5HidRawController); + - the lightbar LED is not driven (no DS4-specific set_led); + - DS5 is UNVERIFIED (no DualSense hardware here): its HID touchpad scaling was + added by analogy to the DS4 (DualSense pad assumed 1920x1080) and not tested; + the DS5HidRawController touchpad is still unscaled and stores cpad as unsigned + c_uint16, which can't hold the signed scaled range -- it needs a field type + change as well as scaling. - Multiple on-screen menus (and possibly keyboards) when using multiple controllers - Injecting emulated xbox controller into wine +- mnuImage right-click "change background" menu has no `sc2` entry (the v2 + image is selected automatically via sc2.config.json `gui.background`, but + it can't be picked manually from that menu yet). +- Custom small (24px) controller icons per supported controller. Today only the + Steam Controller v1 (sc-*) and v2 (sc2-*) have bespoke top-down glyphs; every + other type (deck, ds4, ds5, evdev, hid, scbt, fake) reuses the same generic + silhouette, just recolored. Draw a distinct glyph per type so each controller + is recognisable at a glance. The v2 glyph could also be refined further (its + trackpads are necessarily small at 24px). +- Steam Controller v1 GET_SERIAL reliability (nicety). The flaky v1 serial read + is now handled gracefully - usb.py retries a stalled control request instead of + tearing the dongle down, and sc_dongle falls back to a generated id if it never + reads - so multiple v1s with "Use Serial Numbers" on are detected reliably. + Remaining nicety: investigate *why* GET_SERIAL stalls, so a v1 always ends up + with its real serial (today a persistent stall yields a positional id instead). +- Continuous "HD rumble" for the Steam Controller v2 (and v1/Deck). The SC pads + are LRA voice-coil actuators, not ERM spin-motors. We already drive single + pulses (v1: FEEDBACK report 0x8F; v2: interrupt-OUT report 0x82, effect 0x01 = + one click) which suit pad/scroll detents but NOT sustained, amplitude/ + frequency-modulated game rumble. Gap: the v2's continuous-rumble report is + unknown (see sc2.py feedback(): "sustained game rumble may need another report, + not yet found"); the v2 uses its own report scheme (interrupt-OUT 0x82), + distinct from the Deck's feature-report commands, so it needs confirming for + the v2 specifically. Approach (do NOT brute-force the HID space by trial and + error - a wrong report just does nothing and gives no signal): + 1. Read the canonical implementations: SDL's hidapi Steam driver + (SDL_hidapi_steam.c / SDL_hidapi_steamdeck.c - ID_TRIGGER_RUMBLE_CMD plus + the left/right gain "magic numbers") and the Linux kernel + drivers/hid/hid-steam.c (FF play_effect, derived from SDL's Deck code). + 2. Check first whether SDL3 already rumbles the v2 by its VID/PID - if so, + its source *is* the v2 report format and no capture is needed. + 3. Otherwise capture ground truth: run Steam Input on the v2, trigger rumble + (Steam's controller rumble test, or a rumbling game) and capture the USB + OUTPUT reports with usbmon + Wireshark; decode the continuous-rumble + report Steam actually sends. + 4. Replicate it in sc2.py feedback() and diff the emitted bytes against the + capture to confirm. + 5. Map the emulated gamepad's FF_RUMBLE strong/weak magnitudes to the LRA's + amplitude/frequency/gain and tune for feel (LRA != ERM, so a curve is + needed). + Plumbing already exists (emulated gamepad FF -> controller.feedback()); the + missing piece is the v2 continuous-rumble report itself. Refs: SDL hidapi steam + driver, kernel hid-steam.c, and Alice Mikhaylenko's "Steam Deck, HID, and + libmanette adventures" writeup. +- Deck OSD menu fixes. (a) "Display Current Bindings..." and "Run Program..." + ship disabled in the menu settings; once enabled they appear in the OSD, but + selecting them does nothing - their shell() actions (scc-osd-show-bindings, + scc-osd-launcher) don't actually run/work on the Deck. Make them functional. + (b) Remove "Turn Controller OFF" from the Deck's OSD menu - the Deck's + built-in controller can't be powered off (today it shows and does nothing). + Entries defined in scc/gui/global_settings.py (~L45-58, e.g. + "Turn Controller OFF" -> osd(turnoff())); menu data in + default_menus/Default.menu. +- Generalize the OSD "Turn Controller OFF" hiding. It's currently hidden only + for the Deck's built-in controls (controller type == "deck", checked in + scc/osd/menu.py against the --controller-type the daemon passes). Replace that + hardcoded type check with a per-controller capability (a ControllerFlags bit or + a controller.can_turnoff()) so any controller that can't be powered off + remotely hides the entry, not just the Deck. +- Deck tray/status icon not visible. On the Steam Deck the status (tray) icon + doesn't appear even with the option enabled - works on desktop now that + libdbusmenu is bundled, so this is a Deck/gamescope SNI-tray-host issue to + investigate. +- Rebrand the AppImage desktop app-id. app_info.id in AppImageBuilder.yml / + AppImageBuilder.debian.yml is still org.c0rn3j.sc-controller (upstream), so the + installed .desktop carries the upstream id, and the after_bundle step symlinks + it as org.c0rn3j.sc-controller.desktop. Switch both to org.patola.sc-controller-cc + once the fork is stable and we have committed/PR'd to upstream. +- Replace the last deprecated GTK stock-icon calls. macro_editor.py (the + up/down/delete buttons) and modeshift_editor.py (the clear button) still call + `Gtk.Image.new_from_stock("gtk-go-up" / "gtk-go-down" / "gtk-delete", ...)`. + They render today (GTK maps the stock id to an icon internally) but the stock + API is deprecated; move them to `Gtk.Image.new_from_icon_name` with freedesktop + names (go-up / go-down / edit-delete, or the -symbolic variants - all present in + Adwaita/Breeze). Same class as the profile_switcher.py save/edit buttons, which + were actively blank because `new_from_icon_name` was handed the stock ids + "gtk-save"/"gtk-edit" (now document-save / document-edit). Hard stuff: - Injecting emulated xbox controller into PlayOnLinux @@ -10,7 +138,22 @@ Very hard stuff: - Visual feedback in binding editor ( [what this guy says](https://www.reddit.com/r/linux_gaming/comments/5pcdmr/sc_controller_use_steam_controller_without_steam/dcqpvf4/) ) **Done** stuff: +- "Act on release" (inverted button): a general InvertedButtonModifier plus a + checkbox in the button action editor (next to Toggle/Repeat) that fires a + binding on *release* instead of press - for always-on sensors like the + capacitive grips. Round-trips with the Custom Action `inverted(...)` token. +- Dedicated v2 controller artwork: traced SVG (tools/sc2-source.svg) wired by + tools/gen_sc2_image.py into controller-images/sc2.svg + v2 face-overlay + glyphs (button-images/sc2_*.svg, lifted from the drawn symbols so the face + buttons are blank in the art -> no duplication, monochrome ABXY, round Steam, + single dots) + v2 side-panel icons (images/sc2/*.svg, per-controller override + added in app.apply_gui_config_buttons). Control-name ids on sticks/pads/dpad/ + bumpers + grip-touch shapes so everything highlights on hover; darker body + (#b8b8b8). sc2.config.json points at it all. Replaces the borrowed Deck image. - Multicontroller support +- Per-controller profile memory: each controller's profile is remembered by id + (config["controllers"][id]["profile"]) and restored on (re)connect - follows + the physical device with "Use Serial Numbers" on, per-slot otherwise. - Configurable gamepad type (e.g. 4 axes and 16 buttons) - Steam Profile import - Radial Menu for the Joystick/Trackpad From 2d0447299f7c54a99a0ca93c019e17859701196a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sun, 26 Jul 2026 23:59:41 +0200 Subject: [PATCH 57/98] gui: name the Bluetooth hidraw DualShock 4 in the controller list 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 --- scc/gui/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scc/gui/app.py b/scc/gui/app.py index adae46aa5..850a05f76 100644 --- a/scc/gui/app.py +++ b/scc/gui/app.py @@ -64,6 +64,7 @@ "deck": "Steam Deck", "ds4": "DualShock 4", "ds4evdev": "DualShock 4", + "ds4bt_hidraw": "DualShock 4", "ds5": "DualSense", "ds5evdev": "DualSense", "ds5bt_hidraw": "DualSense", From ee28819e9a7ef46d15ebf232be902da43dceeceb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sun, 26 Jul 2026 23:59:41 +0200 Subject: [PATCH 58/98] sc1: convert the hardware quaternion to EUREL euler angles (one gyro 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 --- scc/drivers/sc_dongle.py | 73 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/scc/drivers/sc_dongle.py b/scc/drivers/sc_dongle.py index 13b6d71d5..bd53bf408 100644 --- a/scc/drivers/sc_dongle.py +++ b/scc/drivers/sc_dongle.py @@ -7,9 +7,11 @@ from __future__ import annotations import logging +import os import struct +import time from enum import IntEnum -from math import cos, sin +from math import asin, atan2, cos, sin, sqrt from math import pi as PI from typing import TYPE_CHECKING, NamedTuple @@ -17,6 +19,7 @@ from scc.constants import STICK_PAD_MAX, STICK_PAD_MIN, ControllerFlags, SCButtons from scc.controller import Controller from scc.drivers.usb import SCUSBDevice, register_hotplug_device +from scc.tools import quat2euler if TYPE_CHECKING: from usb1 import USBDevice, USBDeviceHandle @@ -27,6 +30,27 @@ from scc.drivers.steamdeck import Deck from scc.sccdaemon import SCCDaemon +_EUREL_SCALE = 32768.0 / PI # radians -> the 2**15/PI fixed point EUREL_GYROS wants + + +def _quat_to_eurel(q1: int, q2: int, q3: int, q4: int) -> tuple[int, int, int]: + """Convert the SC1 hardware quaternion (q1=w q2=x q3=y q4=z, unit * 32767) + to DS4/SC2-convention EUREL euler angles (pitch, yaw, roll) in 2**15/PI fixed + point. Measured from held poses: the SC1 and SC2 use the identical quaternion + convention -- x=pitch (nose-up +), y=roll (roll-right +), z=yaw (yaw-left +) -- + so this is byte-for-byte the sc2.parse_input mapping. Feeding these to the + mapper (with EUREL_GYROS set) puts SC1 on the same single gyro code path as + the DS4/SC2, where all the axis/sign conventions are hardware-verified.""" + w, x, y, z = q1 / 32767.0, q2 / 32767.0, q3 / 32767.0, q4 / 32767.0 + pitch = atan2(2.0 * (w * x + y * z), 1.0 - 2.0 * (x * x + y * y)) + roll = asin(max(-1.0, min(1.0, 2.0 * (w * y - z * x)))) + yaw = atan2(2.0 * (w * z + x * y), 1.0 - 2.0 * (y * y + z * z)) + return ( + int(-pitch * _EUREL_SCALE), + int(-yaw * _EUREL_SCALE), + int(roll * _EUREL_SCALE), + ) + class ControllerInput(NamedTuple): """Based on INPUT_FORMAT except anything starting with "ukn_""" @@ -92,7 +116,39 @@ class ControllerInput(NamedTuple): log = logging.getLogger("SCDongle") - +_CALIB = bool(os.environ.get("SCC_GYRO_CALIB")) +if _CALIB: + # The daemon leaves the root logger at WARNING; opt this logger into INFO so + # the IMU-calibration dump is visible. + log.setLevel(logging.INFO) +_calib_last_t = 0.0 + + +def _log_imu_calib(idata) -> None: + """Throttled IMU dump for Steam Controller v1 gyro calibration (gate: env + SCC_GYRO_CALIB=1). Logs the accel gravity vector, the raw hardware + quaternion, what quat2euler currently makes of it, and the raw rates -- so + held poses reveal the quaternion's real axis/sign convention vs the + EUREL convention the DS4/SC2 use.""" + global _calib_last_t + now = time.time() + if now - _calib_last_t < 0.25: + return + _calib_last_t = now + ax, ay, az = idata.accel_x, idata.accel_y, idata.accel_z + mag = sqrt(ax * ax + ay * ay + az * az) or 1.0 + q = (idata.q1 / 32767.0, idata.q2 / 32767.0, idata.q3 / 32767.0, idata.q4 / 32767.0) + qnorm = sqrt(sum(c * c for c in q)) * 32767.0 + e = quat2euler(*q) + deg = 180.0 / PI + log.info( + "IMU-CALIB accel unit=(% .2f % .2f % .2f) |a|=%6.0f | quat=(% 6d % 6d % 6d % 6d) |q|=%6.0f | " + "quat2euler deg=(% 6.1f % 6.1f % 6.1f) | rates(gpitch,groll,gyaw)=(% 5d % 5d % 5d)", + ax / mag, ay / mag, az / mag, mag, + idata.q1, idata.q2, idata.q3, idata.q4, qnorm, + e[0] * deg, e[1] * deg, e[2] * deg, + idata.gpitch, idata.groll, idata.gyaw, + ) class Dongle(SCUSBDevice): MAX_ENDPOINTS = 4 @@ -186,7 +242,9 @@ class SCConfigType(IntEnum): class SCController(Controller): - flags: int = ControllerFlags.LPAD_RPAD_IS_CIRCLE + # The SC1 quaternion is converted to euler host-side (input()) and handed to + # the mapper in q1-q3 as EUREL angles, so SC1 shares the DS4/SC2 gyro path. + flags: int = ControllerFlags.LPAD_RPAD_IS_CIRCLE | ControllerFlags.EUREL_GYROS def __init__(self, driver: Deck | Dongle | SCByBt | SCByCable | SC2Device, ccidx: int, endpoint: int) -> None: Controller.__init__(self) @@ -271,6 +329,15 @@ def input(self, idata: ControllerInput) -> None: q4=idata.q4, ) + if _CALIB: + _log_imu_calib(idata) # logs the RAW quaternion, before conversion + # Convert the hardware quaternion (q1-q4) to EUREL euler angles in + # q1-q3 (q4 unused) so the mapper's gyro paths -- absolute, tilt, + # lean-to-turn -- match the DS4/SC2 exactly. Only when the gyro is + # streaming (a nonzero quaternion); disabled, q1-q4 are 0. + if idata.q1 or idata.q2 or idata.q3 or idata.q4: + p, y, r = _quat_to_eurel(idata.q1, idata.q2, idata.q3, idata.q4) + idata = idata._replace(q1=p, q2=y, q3=r, q4=0) self.mapper.input(self, old_state, idata) def _generate_id(self) -> str: From aec81c88d197bace1ebef5da405c417a7e8d7158 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sun, 26 Jul 2026 23:59:41 +0200 Subject: [PATCH 59/98] fix(gyro): re-center absolute gyro at every activation, not at release 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 --- scc/actions.py | 11 +++++++++-- scc/modifiers.py | 13 ++++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/scc/actions.py b/scc/actions.py index 58100bc81..38883277e 100644 --- a/scc/actions.py +++ b/scc/actions.py @@ -1263,7 +1263,11 @@ class GyroAbsAction(HapticEnabledAction, GyroAction): def __init__(self, *blah): GyroAction.__init__(self, *blah) HapticEnabledAction.__init__(self) - self.ir = [0, 0, None, 0] # Initial rotation, last has to be determined + # Orientation reference: None = capture at the next gyro event. All + # axes capture-at-first-event, so the very first activation centers at + # the current pose exactly like every later one (re-centered by + # reset() on deactivation / Recenter Gyro). + self.ir = [None, None, None, None] self._was_oor = False self._deadzone_fn = None @@ -1289,7 +1293,10 @@ def gyro(self, mapper: Mapper, pitch, yaw, roll, q1, q2, q3, q4): else: pyr = list(quat2euler(q1 / 32767.0, q2 / 32767.0, q3 / 32767.0, q4 / 32767.0)) for i in self.GYROAXES: - self.ir[i] = self.ir[i] or pyr[i] + # explicit None test, not `or`: a legitimate 0.0 reference is falsy + # and would re-capture on every event + if self.ir[i] is None: + self.ir[i] = pyr[i] pyr[i] = anglediff(self.ir[i], pyr[i]) * (2**15) * self.speed[2] * 2 / PI if self.haptic: oor = False # oor - Out Of Range diff --git a/scc/modifiers.py b/scc/modifiers.py index 5b8abee51..a83b994ad 100644 --- a/scc/modifiers.py +++ b/scc/modifiers.py @@ -1043,13 +1043,20 @@ def gyro(self, mapper, pitch, yaw, roll, *q): # stuck when the enable button is released. Relative GyroAction zeroes # on (0,0,0), but GyroAbsAction ignores pitch/yaw/roll (it tracks # q1-q4), so it would emit its last orientation and leave the axis - # deflected. Reset each GyroAbsAction's reference first (the only gyro - # action with reset()) so the neutralizing call below emits 0. Covers - # MultiAction (mixed relative+absolute) via its .actions children. + # deflected. Reset the reference first so the neutralizing call below + # emits 0. Covers MultiAction (mixed relative+absolute) via .actions. for a in getattr(self.old_action, "actions", None) or (self.old_action,): if hasattr(a, "reset"): a.reset() self.old_action.gyro(mapper, 0, 0, 0, *q) + # That neutralizing call just re-captured the reference at the + # RELEASE pose (reset -> first-event capture). Reset once more so + # the next activation captures ITS OWN pose: the gyro re-centers + # on every engage (Steam-like), instead of carrying a stale + # offset accumulated while deactivated. + for a in getattr(self.old_action, "actions", None) or (self.old_action,): + if hasattr(a, "reset"): + a.reset() self.old_action = sel return sel.gyro(mapper, pitch, yaw, roll, *q) From a825beec1f00fc8b180f63937095b4ae6eb993e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Mon, 3 Aug 2026 01:33:20 +0200 Subject: [PATCH 60/98] fix(gyro): scale gyro output to the target axis' own range 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). --- scc/actions.py | 32 +++++++++++---- tests/test_gyro.py | 100 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 7 deletions(-) create mode 100644 tests/test_gyro.py diff --git a/scc/actions.py b/scc/actions.py index 38883277e..91d58a1b0 100644 --- a/scc/actions.py +++ b/scc/actions.py @@ -1193,6 +1193,26 @@ def reset(self) -> None: def get_compatible_modifiers(self): return Action.MOD_SENSITIVITY | Action.MOD_SENS_Z + @staticmethod + def emit_axis(mapper: Mapper, axis, value): + """Sends a stick-range gyro value (STICK_PAD_MIN..STICK_PAD_MAX) to a + gamepad axis, rescaled to that axis' own range. + + Triggers need the rescale: they are unipolar (TRIGGER_MIN..TRIGGER_MAX + == 0..255), so merely clamping a stick-range value into them -- what + this used to do -- discarded the whole negative half and saturated the + positive half within about 0.7 deg of rotation. A gyro-driven trigger + behaved like a button. Mapping the positive half across the full + trigger travel keeps the neutral pose at "released" (which is what a + trigger has to rest at) and full pull at the same rotation that would + fully deflect a stick; bind the opposite rotation with a negative + sensitivity or the 'inverted' modifier. + """ + if axis in AxisAction.Z: + value = value * TRIGGER_MAX / STICK_PAD_MAX + mapper.gamepad.axisEvent(axis, AxisAction.clamp_axis(axis, value)) + mapper.syn_list.add(mapper.gamepad) + def set_speed(self, x: float, y: float, z: float) -> None: self.speed = (x, y, z) @@ -1207,8 +1227,7 @@ def gyro(self, mapper: Mapper, *pyr): # IntEnums with overlapping values (REL_X == ABS_X == 0), so the # membership test would misroute a mouse axis here as a gamepad axis. if isinstance(axis, Axes) or type(axis) is int: - mapper.gamepad.axisEvent(axis, AxisAction.clamp_axis(axis, pyr[i] * self.speed[i] * -10)) - mapper.syn_list.add(mapper.gamepad) + GyroAction.emit_axis(mapper, axis, pyr[i] * self.speed[i] * -10) # Relative mouse = lean-to-turn: cursor VELOCITY is proportional to # the held tilt angle -- lean and the cursor keeps moving, return to # level and it stops. (For laser-pointer tracking, where the cursor @@ -1324,12 +1343,11 @@ def gyro(self, mapper: Mapper, pitch, yaw, roll, q1, q2, q3, q4): # swallowed the mouse axes into this gamepad branch (the elifs below # never ran) -- gyro->mouse moved the stick instead of the cursor. if isinstance(axis, Axes) or type(axis) == int: - val = AxisAction.clamp_axis(axis, pyr[i] * self.speed[i]) + val = pyr[i] * self.speed[i] if self._deadzone_fn: - val, trash = self._deadzone_fn(val, 0, STICK_PAD_MAX) - val = int(val) - mapper.gamepad.axisEvent(axis, val) - mapper.syn_list.add(mapper.gamepad) + # deadzone works in stick range, before the axis rescale + val, trash = self._deadzone_fn(clamp(STICK_PAD_MIN, val, STICK_PAD_MAX), 0, STICK_PAD_MAX) + GyroAction.emit_axis(mapper, axis, val) # Absolute mouse = laser pointer: the angular RATE is the move delta # (like MouseAction.gyro); the rate integrates to the rotation angle, # so the cursor tracks the controller's absolute orientation and stops diff --git a/tests/test_gyro.py b/tests/test_gyro.py new file mode 100644 index 000000000..29c1a5ea8 --- /dev/null +++ b/tests/test_gyro.py @@ -0,0 +1,100 @@ +"""Runtime behaviour of the gyro actions. + +Driven through a fake mapper: the real one needs a daemon, a controller and +uinput devices. +""" +import math + +from scc.actions import GyroAbsAction, GyroAction +from scc.constants import STICK_PAD_MAX, TRIGGER_MAX, ControllerFlags +from scc.uinput import Axes + +# driver-side euler encoding: 2**15 / PI fixed point, see ControllerFlags.EUREL_GYROS +EUREL = 32768.0 / math.pi + + +class FakeGamepad: + def __init__(self): + self.events = {} + + def axisEvent(self, axis, value): + self.events.setdefault(axis, []).append(value) + + +class FakeController: + flags = ControllerFlags.EUREL_GYROS + + +class FakeState: + rtrig = 0 + + +class FakeMapper: + def __init__(self): + self.gamepad = FakeGamepad() + self.syn_list = set() + self.mouse_moves = [] + self.state = FakeState() + self._controller = FakeController() + + def get_controller(self): + return self._controller + + def mouse_move(self, dx, dy): + self.mouse_moves.append((dx, dy)) + + def send_feedback(self, *a): + pass + + +def sweep(action, mapper, to_degrees, steps=20): + """Rotates all three gyro axes from neutral to 'to_degrees'.""" + prev = None + for n in range(steps + 1): + a = math.radians(to_degrees * n / steps) + rates = (0, 0, 0) if prev is None else tuple([(a - prev) * 3000.0] * 3) + prev = a + q = int(a * EUREL) + action.gyro(mapper, rates[0], rates[1], rates[2], q, q, q, 0) + + +def peak(mapper, axis): + vals = mapper.gamepad.events[axis] + return max(vals, key=abs) + + +class TestGyroAxisRange: + """A gyro bound to a trigger must use the trigger's own 0..255 range. + + Feeding it a stick-range value and clamping (what it used to do) threw + away the negative half and saturated within a fraction of a degree, so + the trigger behaved like a button. + """ + + def test_absolute_trigger_is_proportional(self): + m = FakeMapper() + sweep(GyroAbsAction(Axes.ABS_Z), m, 20) + # 20 of the 90 deg that deflect a stick fully -> ~22% of trigger travel + assert 0.15 * TRIGGER_MAX < peak(m, Axes.ABS_Z) < 0.30 * TRIGGER_MAX + + def test_absolute_trigger_reaches_full_pull(self): + m = FakeMapper() + sweep(GyroAbsAction(Axes.ABS_Z), m, 90) + assert peak(m, Axes.ABS_Z) == TRIGGER_MAX + + def test_absolute_trigger_rests_released(self): + """A trigger has to sit at 0 when the controller is not rotated.""" + m = FakeMapper() + sweep(GyroAbsAction(Axes.ABS_Z), m, 0) + assert set(m.gamepad.events[Axes.ABS_Z]) == {0} + + def test_relative_trigger_is_not_digital(self): + m = FakeMapper() + sweep(GyroAction(Axes.ABS_Z), m, -20) + assert 0 < peak(m, Axes.ABS_Z) < TRIGGER_MAX + + def test_stick_range_is_unchanged(self): + """The rescale must apply to triggers only.""" + m = FakeMapper() + sweep(GyroAbsAction(Axes.ABS_X), m, 90) + assert peak(m, Axes.ABS_X) == STICK_PAD_MAX From 6255ccf1f772dd4b23b79e48347adcaf2e5221e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Mon, 3 Aug 2026 01:34:14 +0200 Subject: [PATCH 61/98] fix(modeshift): give analog range conditions hysteresis 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. --- scc/actions.py | 61 ++++++++++++++++++++++++---------------- tests/test_gyro.py | 70 ++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 102 insertions(+), 29 deletions(-) diff --git a/scc/actions.py b/scc/actions.py index 91d58a1b0..92b6a4d7a 100644 --- a/scc/actions.py +++ b/scc/actions.py @@ -14,6 +14,7 @@ import inspect import logging +import operator import sys from enum import IntEnum from math import atan2, copysign, cos, sin, sqrt @@ -382,11 +383,21 @@ class RangeOP: OPS = ("<", ">", "<=", ">=") + # An analog axis parked near a modeshift threshold jitters across it, and + # every crossing runs ModeModifier's switch path -- which releases held + # buttons and recenters gyro references. With a hair-trigger comparison a + # trigger held at "70%" could re-center an absolute gyro several times a + # second, leaving it permanently at its neutral output. Once the condition + # holds, the value has to travel this far (as a fraction of the axis range) + # back past the threshold before it stops holding. + HYSTERESIS = 0.05 + def __init__(self, what, op, value): """Raises ValueError if 'what' or 'op' is not supported value""" self.what = what self.op = op self.value = value + self.held = False self.min = float(TRIGGER_MIN) self.max = float(TRIGGER_MAX) @@ -436,41 +447,43 @@ def __init__(self, what, op, value): def cmp_or(self, mapper: Mapper) -> bool: return any(x(mapper) for x in self.children) - def cmp_gt(self, mapper: Mapper) -> bool: + def _cmp(self, mapper: Mapper, op, state, rising: bool) -> bool: + """Compares 'state' against the threshold with HYSTERESIS applied, and + latches the result. 'rising' says which side of the threshold satisfies + the operator, so the band always widens in the holding direction. + """ + margin = -RangeOP.HYSTERESIS if self.held else RangeOP.HYSTERESIS + self.held = op(state, self.value + (margin if rising else -margin)) + return self.held + + def _state(self, mapper: Mapper) -> bool: if mapper.state is None: - return False - state = float(getattr(mapper.state, self.axis_name)) / self.max - return state > self.value + return None + return float(getattr(mapper.state, self.axis_name)) / self.max + + def cmp_gt(self, mapper: Mapper): + state = self._state(mapper) + return False if state is None else self._cmp(mapper, operator.gt, state, True) def cmp_lt(self, mapper: Mapper) -> bool: - if mapper.state is None: - return False - state = float(getattr(mapper.state, self.axis_name)) / self.max - return state < self.value + state = self._state(mapper) + return False if state is None else self._cmp(mapper, operator.lt, state, False) def cmp_ge(self, mapper: Mapper) -> bool: - if mapper.state is None: - return False - state = float(getattr(mapper.state, self.axis_name)) / self.max - return state >= self.value + state = self._state(mapper) + return False if state is None else self._cmp(mapper, operator.ge, state, True) def cmp_le(self, mapper: Mapper) -> bool: - if mapper.state is None: - return False - state = float(getattr(mapper.state, self.axis_name)) / self.max - return state <= self.value + state = self._state(mapper) + return False if state is None else self._cmp(mapper, operator.le, state, False) def cmp_labs(self, mapper: Mapper) -> bool: - if mapper.state is None: - return False - state = float(getattr(mapper.state, self.axis_name)) / self.max - return abs(state) < self.value + state = self._state(mapper) + return False if state is None else self._cmp(mapper, operator.lt, abs(state), False) def cmp_gabs(self, mapper: Mapper) -> bool: - if mapper.state is None: - return False - state = float(getattr(mapper.state, self.axis_name)) / self.max - return abs(state) > self.value + state = self._state(mapper) + return False if state is None else self._cmp(mapper, operator.gt, abs(state), True) def __call__(self, mapper: Mapper) -> bool: return self.op_method(mapper) diff --git a/tests/test_gyro.py b/tests/test_gyro.py index 29c1a5ea8..29cae7b4d 100644 --- a/tests/test_gyro.py +++ b/tests/test_gyro.py @@ -1,12 +1,13 @@ -"""Runtime behaviour of the gyro actions. +"""Runtime behaviour of the gyro actions and of the analog modeshift gate. -Driven through a fake mapper: the real one needs a daemon, a controller and -uinput devices. +Both are driven through a fake mapper: the real one needs a daemon, a +controller and uinput devices. """ import math -from scc.actions import GyroAbsAction, GyroAction -from scc.constants import STICK_PAD_MAX, TRIGGER_MAX, ControllerFlags +from scc.actions import GyroAbsAction, GyroAction, RangeOP +from scc.constants import STICK_PAD_MAX, TRIGGER_MAX, ControllerFlags, SCButtons +from scc.parser import ActionParser from scc.uinput import Axes # driver-side euler encoding: 2**15 / PI fixed point, see ControllerFlags.EUREL_GYROS @@ -98,3 +99,62 @@ def test_stick_range_is_unchanged(self): m = FakeMapper() sweep(GyroAbsAction(Axes.ABS_X), m, 90) assert peak(m, Axes.ABS_X) == STICK_PAD_MAX + + +class TestRangeOPHysteresis: + """`mode(RT >= 0.7, ...)` must not flip while the trigger is parked near + the threshold: every flip runs ModeModifier's switch path, which recenters + gyro references and releases held buttons. + """ + + def test_holds_through_jitter(self): + m = FakeMapper() + op = RangeOP(SCButtons.RT, ">=", 0.7) + m.state.rtrig = int(0.9 * TRIGGER_MAX) + assert op(m) + # dip just under the raw threshold, still inside the hysteresis band + m.state.rtrig = int((0.7 - RangeOP.HYSTERESIS / 2) * TRIGGER_MAX) + assert op(m) + + def test_still_releases(self): + m = FakeMapper() + op = RangeOP(SCButtons.RT, ">=", 0.7) + m.state.rtrig = int(0.9 * TRIGGER_MAX) + assert op(m) + m.state.rtrig = int((0.7 - 2 * RangeOP.HYSTERESIS) * TRIGGER_MAX) + assert not op(m) + + def test_needs_the_full_threshold_to_engage(self): + """Approaching from below, the band tightens rather than loosens.""" + m = FakeMapper() + op = RangeOP(SCButtons.RT, ">=", 0.7) + m.state.rtrig = int((0.7 + RangeOP.HYSTERESIS / 2) * TRIGGER_MAX) + assert not op(m) + m.state.rtrig = int((0.7 + 2 * RangeOP.HYSTERESIS) * TRIGGER_MAX) + assert op(m) + + def test_less_than_direction(self): + m = FakeMapper() + op = RangeOP(SCButtons.RT, "<", 0.3) + m.state.rtrig = 0 + assert op(m) + m.state.rtrig = int((0.3 + RangeOP.HYSTERESIS / 2) * TRIGGER_MAX) + assert op(m) + m.state.rtrig = int((0.3 + 2 * RangeOP.HYSTERESIS) * TRIGGER_MAX) + assert not op(m) + + def test_gated_absolute_gyro_survives_jitter(self): + """End to end: the reported symptom was an absolute gyro producing + nothing at all when gated behind a trigger held at 70%. + """ + action = ActionParser().restart("mode(RT >= 0.7, gyroabs(Axes.ABS_X), None)").parse().compress() + m = FakeMapper() + prev = None + for n in range(21): + m.state.rtrig = TRIGGER_MAX if n % 3 else int(0.69 * TRIGGER_MAX) + a = math.radians(20.0 * n / 20) + rate = 0 if prev is None else (a - prev) * 3000.0 + prev = a + q = int(a * EUREL) + action.gyro(m, rate, rate, rate, q, q, q, 0) + assert peak(m, Axes.ABS_X) > 0.15 * STICK_PAD_MAX From f0d8934d67911d0da30533007dfdc4c98b2f6cee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Mon, 3 Aug 2026 01:34:58 +0200 Subject: [PATCH 62/98] fix(gyro): apply per-axis sensitivity once, before clamping 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). --- scc/actions.py | 12 ++++++++++-- tests/test_gyro.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/scc/actions.py b/scc/actions.py index 92b6a4d7a..af063ebd1 100644 --- a/scc/actions.py +++ b/scc/actions.py @@ -1329,7 +1329,15 @@ def gyro(self, mapper: Mapper, pitch, yaw, roll, q1, q2, q3, q4): # and would re-capture on every event if self.ir[i] is None: self.ir[i] = pyr[i] - pyr[i] = anglediff(self.ir[i], pyr[i]) * (2**15) * self.speed[2] * 2 / PI + # speed[i], not speed[2]: sensitivity is per gyro axis, and the Z + # one used to be applied to all three -- and then applied a SECOND + # time, per-axis, down in the output loop, so a sensitivity of 2 + # came out as 4. Scaling here rather than there also means the + # clamp and the out-of-range haptic below see the value that is + # actually emitted: sensitivity now moves the rotation needed for + # full deflection, instead of merely scaling an already saturated + # reading. + pyr[i] = anglediff(self.ir[i], pyr[i]) * (2**15) * self.speed[i] * 2 / PI if self.haptic: oor = False # oor - Out Of Range for i in self.GYROAXES: @@ -1356,7 +1364,7 @@ def gyro(self, mapper: Mapper, pitch, yaw, roll, q1, q2, q3, q4): # swallowed the mouse axes into this gamepad branch (the elifs below # never ran) -- gyro->mouse moved the stick instead of the cursor. if isinstance(axis, Axes) or type(axis) == int: - val = pyr[i] * self.speed[i] + val = pyr[i] if self._deadzone_fn: # deadzone works in stick range, before the axis rescale val, trash = self._deadzone_fn(clamp(STICK_PAD_MIN, val, STICK_PAD_MAX), 0, STICK_PAD_MAX) diff --git a/tests/test_gyro.py b/tests/test_gyro.py index 29cae7b4d..8a2c5b3dd 100644 --- a/tests/test_gyro.py +++ b/tests/test_gyro.py @@ -101,6 +101,39 @@ def test_stick_range_is_unchanged(self): assert peak(m, Axes.ABS_X) == STICK_PAD_MAX +class TestGyroSensitivity: + """Per-axis sensitivity used to be read from index 2 for every axis and + then applied a second time per-axis, squaring it. + """ + + def test_applied_once(self): + plain, scaled = FakeMapper(), FakeMapper() + a = GyroAbsAction(Axes.ABS_X) + sweep(a, plain, 10) + b = GyroAbsAction(Axes.ABS_X) + b.set_speed(2.0, 2.0, 2.0) + sweep(b, scaled, 10) + assert peak(scaled, Axes.ABS_X) == 2 * peak(plain, Axes.ABS_X) + + def test_is_per_axis(self): + """Sensitivity of gyro axis 0 must not be taken from axis 2.""" + m = FakeMapper() + a = GyroAbsAction(Axes.ABS_X, Axes.ABS_Y, Axes.ABS_RX) + a.set_speed(1.0, 1.0, 3.0) + sweep(a, m, 10) + assert peak(m, Axes.ABS_RX) == 3 * peak(m, Axes.ABS_X) + + def test_moves_the_full_deflection_point(self): + """Sensitivity has to change the rotation needed to peg the axis, not + just scale a value that already saturated at 90 deg. + """ + m = FakeMapper() + a = GyroAbsAction(Axes.ABS_X) + a.set_speed(3.0, 3.0, 3.0) + sweep(a, m, 35) # 3x35 deg is past the 90 deg full-deflection point + assert peak(m, Axes.ABS_X) == STICK_PAD_MAX + + class TestRangeOPHysteresis: """`mode(RT >= 0.7, ...)` must not flip while the trigger is parked near the threshold: every flip runs ModeModifier's switch path, which recenters From 9d75f931a1fa20063685aeb49d0b1f8bc80db750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sat, 8 Aug 2026 07:35:16 +0200 Subject: [PATCH 63/98] fix(sc2): enable the IMU quaternion stream, not just rates and accel SC2Controller declares EUREL_GYROS, and parse_input derives the euler angles that promises from the controller's fused orientation quaternion. The config block replayed from Steam wrote 0x18 to CONFIGURE register 0x30 -- the IMU stream bitmask -- which is 0x10 (rates) | 0x08 (accel) and leaves 0x04 (quaternion) off. The v1 driver documents those bits and sends 0x1C; see sc_dongle.configure, where the same mask sits at the same position right after its own 0x30 byte. With the quaternion off, every field of it reads zero, and zero converts to exactly zero euler -- atan2(0, 1) and asin(0) -- with no error anywhere. So absolute gyro, lean-to-turn and tilt sat silently at their neutral output while the rate-driven bindings worked perfectly. A user's IMU-CALIB dump shows it directly: rates and accel tracking, euler columns flat 0.0. It worked for some people only by accident. set_gyro_enabled was a no-op (the register was an open TODO), so the controller kept whatever state Steam had last left it in -- which is why the same build behaved differently on two otherwise identical SC2s, and why this could not be reproduced locally. set_gyro_enabled now actually reconfigures. Rates and accel stay on unconditionally, exactly as before, so nothing that works today changes; only the quaternion bit is switched, on when a profile uses the gyro. Refs #3. Not closing it: this is the cause of the absolute-gyro, lean-to-turn and tilt items in that report, but the reporter has not confirmed on his own hardware yet -- it could not be reproduced here, because our controller had the quaternion left on by Steam. --- scc/drivers/sc2.py | 44 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/scc/drivers/sc2.py b/scc/drivers/sc2.py index f7f85c52e..4c9b00fdd 100644 --- a/scc/drivers/sc2.py +++ b/scc/drivers/sc2.py @@ -429,11 +429,16 @@ def clear_mappings(self) -> None: def configure( self, idle_timeout: int | None = None, enable_gyros: bool | None = None, led_level: int | None = None ) -> None: - # Replay the config blocks captured from Steam. These put the controller - # into gamepad mode; the exact gyro-enable register is still TODO. + # Replay the config blocks captured from Steam, which put the controller + # into gamepad mode. The blocks are (register, value16-LE) triplets, and + # register 0x30 is the IMU-stream bitmask -- the same one the v1 driver + # writes (see sc_dongle.configure, where it sits at the identical + # position, right after the 0x30 byte). if led_level is not None: self._led_level = led_level - # main config block: 87 0f 30 18 00 07 07 00 08 07 00 31 02 00 52 03 + if enable_gyros is not None: + self._enable_gyros = enable_gyros + # main config block: 87 0f 30 00 07 07 00 08 07 00 31 02 00 52 03 self._driver.overwrite_control( self._ccidx, bytes( @@ -441,7 +446,7 @@ def configure( SCPacketType.CONFIGURE, 0x0F, 0x30, - 0x18, + self._imu_mask(), 0x00, 0x07, 0x07, @@ -462,9 +467,36 @@ def configure( self._ccidx, struct.pack(">BBBB", SCPacketType.CONFIGURE, 0x03, 0x2D, int(self._led_level)) ) + # IMU-stream bits for CONFIGURE register 0x30, from the v1 protocol + IMU_GYRO = 0x10 # raw angular rates + IMU_ACCEL = 0x08 # accelerometer + IMU_QUAT = 0x04 # fused orientation quaternion + + def _imu_mask(self) -> int: + """IMU streams to turn on. + + The block captured from Steam carried 0x18 -- rates and accel, but NOT + the fused quaternion. Since parse_input derives the euler angles this + controller reports through EUREL_GYROS from that quaternion, and an + all-zero quaternion converts to exactly zero (atan2(0,1) and asin(0)), + every orientation-based binding silently did nothing: absolute gyro, + lean-to-turn and tilt all sat at their neutral output while the + rate-based ones worked. Confirmed by a user's IMU-CALIB dump, whose + euler columns read 0.0 while the rates moved. + + It only worked at all on controllers where Steam had already switched + the quaternion on, because nothing here ever wrote this register -- + set_gyro_enabled was a no-op, so we inherited whatever state the pad + was left in. That is why the same build behaved differently on two + otherwise identical SC2s. + + Rates and accel stay on unconditionally, exactly as before, so nothing + that works today can regress; only the quaternion bit is switched. + """ + return self.IMU_GYRO | self.IMU_ACCEL | (self.IMU_QUAT if self._enable_gyros else 0) + def set_gyro_enabled(self, enabled: bool) -> None: - self._enable_gyros = enabled - # TODO: which 0x87 CONFIGURE register enables the IMU? + self.configure(enable_gyros=enabled) def get_gyro_enabled(self) -> bool: return self._enable_gyros From 12c014bb58df15e33ccad9f258b4d58e8dbaf6fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Sat, 8 Aug 2026 07:36:58 +0200 Subject: [PATCH 64/98] fix(gui): make "Mouse (Emulate Stick)" actually emulate a stick The mode emitted gyroabs(Rels.REL_Y, Rels.REL_X), which under the current mouse semantics is the laser pointer: the cursor tracks the controller's rotation and stops when the rotation stops. That contradicts the label -- emulating a stick means a held deflection keeps producing movement -- and it left all three mouse modes in that dropdown doing the same rate-driven thing, since Desktop and Camera emit the identical mouse() action and differ only in default sensitivity. A user reported exactly that: no way to get angle-proportional cursor velocity from this tab at all. Point it at gyro(...) instead, which is the lean-to-turn path. No semantics change: gyroabs still means laser pointer everywhere, gyro still means lean-to-turn everywhere, and both actions already existed and worked. Only which of the two this row writes changes. Existing profiles are unaffected -- handles() and set_action() both match on isinstance(action, GyroAction), and GyroAbsAction subclasses it, so a gyroabs profile still loads and still displays as Emulate Stick. No shipped profile binds gyro to a mouse axis (every default profile has "gyro": {}). Refs #3, where the reporter noted that all three mouse modes in the "Joystick or Mouse" tab behave identically. --- ui/ae/gyro_action.ui | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/ae/gyro_action.ui b/ui/ae/gyro_action.ui index 31c31bc79..e69d9c529 100644 --- a/ui/ae/gyro_action.ui +++ b/ui/ae/gyro_action.ui @@ -39,7 +39,7 @@ mouse_cam - gyroabs(Rels.REL_Y, [Rels.REL_X|None, Rels.REL_X]) + gyro(Rels.REL_Y, [Rels.REL_X|None, Rels.REL_X]) Mouse (Emulate Stick) mouse_stick From f228eafa4c8811dd788d80b447ff8917328887aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Mon, 10 Aug 2026 18:12:32 +0200 Subject: [PATCH 65/98] fix(sc2): send the haptic gain as signed dB, not as an amplitude Refs #7. Report 0x82's last byte is a SIGNED gain in decibels, clamped by the firmware to -23..+24. We were packing a 0..255 amplitude into it (HapticData's amplitude >> 7), so read as dB the strength control was not merely ineffective, it was non-monotonic. Across the GUI slider's 16..32767 range: amp 512 -> byte 4 -> +4 dB amp 8192 -> byte 64 -> +64 dB (clamped to +24) amp 16384 -> byte 128 -> -128 dB (clamped to -23) amp 32767 -> byte 255 -> -1 dB It rose over the bottom tenth of the slider, sat clamped at maximum through the middle, then wrapped negative -- so turning strength UP past halfway made the pad quieter. That is the "feedback strength does not work" report. Map amplitude to dB logarithmically instead, with HapticData's own default (512) at 0 dB so existing profiles keep about the strength they had, and pack it as "b". Now monotonic across the whole slider: -23 dB at 16, 0 at 512, +24 from 8192 up. This also corrects the pad-click constants added in 6ab55132, which were inverted under the true encoding: press 0x6000 read as -64 -> clamped -23 dB (weakest) while release 0x3000 read as +96 -> +24 dB (strongest), so the release clicked harder than the press. Now +18 and +6 dB. The protocol doc gains the full haptic report family. Valve upstreamed this controller to SDL3 as "Triton", so SDL_hidapi_steam_triton.c is a reference implementation, and iczero's independent RE (collected in CouchTurtle/sc2-research) agrees with it. Continuous variable rumble is report 0x80, which the doc previously recorded as "not yet captured" -- the connect-time 0x80 traffic IS that report, sent with zero speeds, which is why nothing was felt. --- docs/steam-controller-v2-protocol.md | 31 ++++++++++++++++++++++------ scc/drivers/sc2.py | 29 ++++++++++++++++++++++---- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/docs/steam-controller-v2-protocol.md b/docs/steam-controller-v2-protocol.md index 5bb898509..643376101 100644 --- a/docs/steam-controller-v2-protocol.md +++ b/docs/steam-controller-v2-protocol.md @@ -170,12 +170,31 @@ same shape as the v1/Deck command packets. The `packetType` opcodes match Haptics: **Output report `0x82`** on the **interrupt-OUT** endpoint (its number equals the interface, e.g. EP `0x02` for slot 1) — the device **stalls it over SET_REPORT control**, so it must go to the interrupt endpoint. Layout -`82 `: `side` 0=left / 1=right / 2=both; `effect` -`0x01`=click (`0x02`=longer click); `amplitude` `0x00`(medium)…`0xff`(strong). -This is a single *click* per report (verified live). Continuous variable rumble, -if the controller supports it, likely uses a different report (not yet captured; -the connect-time `0x80` report is **not** felt). The `0x81` output reports seen -at connect are unrelated (not haptic). +`82 `: `side` 0=left / 1=right / 2=both; `command` +`0x01`=click, `0x02`=strong click; `gain` a **signed i8 in decibels**, clamped +by the firmware to -23…+24. Note the last byte is NOT a 0..255 amplitude -- we +sent it as one until v0.6.0.7, which made the strength control non-monotonic. + +The full haptic report family is documented by Valve themselves: they upstreamed +this controller ("Triton") to SDL3, so `src/joystick/hidapi/SDL_hidapi_steam_triton.c` +is a reference implementation. Corroborated independently by iczero's RE, collected +at (`docs/HAPTICS.md`): + +| report | name | payload | +| --- | --- | --- | +| `0x80` | `HAPTIC_RUMBLE` | `type u8, intensity u16, left{speed u16, gain i8}, right{speed u16, gain i8}` | +| `0x81` | `HAPTIC_PULSE` | `side u8, on_us u16, off_us u16, repeat u16` | +| `0x82` | `HAPTIC_COMMAND` | `side u8, command u8 (0=stop all, 1=click, 2=strong click), gain i8 dB` | +| `0x83` | `HAPTIC_LFO_TONE` | `side u8, gain i8, frequency u16 Hz, duration u16, lfo_freq u16 Hz, lfo_depth u8` | +| `0x84` | `HAPTIC_LOG_SWEEP` | `side u8, gain i8, duration u16, start_freq u16, end_freq u16` | +| `0x85` | `HAPTIC_SCRIPT` | `side u8, script_id u8, gain i8` | +| `0x86`–`0x89` | audio stream | actuator PCM / u-law streaming | + +So **continuous variable rumble is `0x80`**, and the earlier note here that it was +"not yet captured" was wrong -- the connect-time `0x80` traffic is that report, +sent with zero speeds, which is why nothing was felt. SDL sends the two SDL rumble +magnitudes straight through as `left.speed` / `right.speed` (u16) with `gain` 0. +Layouts for `0x83`-`0x89` are from RE and not verified here. **Implication:** the existing `sc_dongle.py` command builders port over; the differences are the **transport** (SET_REPORT/feature to a per-slot interface diff --git a/scc/drivers/sc2.py b/scc/drivers/sc2.py index 4c9b00fdd..7395db29d 100644 --- a/scc/drivers/sc2.py +++ b/scc/drivers/sc2.py @@ -120,8 +120,25 @@ def _log_imu_calib(idata) -> None: # for one), so this cannot be expressed as a profile and has to be built in. # Emitted on press and, more softly, on release, because that is what a real # dome switch does: it clicks going down and again springing back. -PAD_CLICK_PRESS = 0x6000 -PAD_CLICK_RELEASE = 0x3000 +PAD_CLICK_PRESS = 0x1000 +PAD_CLICK_RELEASE = 0x0400 + +# Report 0x82's last byte is a SIGNED gain in decibels, clamped by the firmware +# to this range -- not a 0..255 amplitude, which is what it was being fed. See +# the haptic notes in docs/steam-controller-v2-protocol.md. +HAPTIC_GAIN_MIN_DB = -23 +HAPTIC_GAIN_MAX_DB = 24 +# HapticData's own default amplitude, mapped to 0 dB (nominal) so existing +# profiles keep roughly the strength they had. +HAPTIC_NOMINAL_AMPLITUDE = 512 + + +def _gain_db(amplitude: int) -> int: + """HapticData amplitude -> report 0x82 gain, in dB relative to nominal.""" + if amplitude <= 0: + return HAPTIC_GAIN_MIN_DB + db = 20.0 * math.log10(amplitude / HAPTIC_NOMINAL_AMPLITUDE) + return int(round(max(HAPTIC_GAIN_MIN_DB, min(HAPTIC_GAIN_MAX_DB, db)))) TURNOFF_QUIET_PERIOD = 0.5 @@ -509,11 +526,15 @@ def feedback(self, data: HapticData) -> None: # it suits pad/scroll detents; sustained game rumble may need another # report (not yet found). data.data = (position, amplitude, period, count). pos = data.data[0] - amp = data.data[1] if len(data.data) > 1 else 0x4000 + amp = data.data[1] if len(data.data) > 1 else HAPTIC_NOMINAL_AMPLITUDE if amp <= 0: return side = {HapticPos.LEFT: 0, HapticPos.RIGHT: 1, HapticPos.BOTH: 2}.get(pos, 1) - report = bytes([0x82, side, 0x01, min(255, amp >> 7)]) + # "b" -- the gain is signed. Packing it as an unsigned 0..255 amplitude + # made the strength control non-monotonic: it rose over the bottom tenth + # of the slider, sat clamped at max through the middle, then wrapped + # negative so that turning it UP past halfway made the pad quieter. + report = struct.pack(" Date: Mon, 10 Aug 2026 18:19:08 +0200 Subject: [PATCH 66/98] feat(sc2): real continuous rumble via output report 0x80 Refs #7. Game rumble on the v2 was emulated the way it has to be on a v1 -- as a train of haptic clicks derived from the force-feedback magnitude -- because the driver had no continuous-rumble report. It does: output report 0x80, HAPTIC_RUMBLE, layout `80 ` little-endian. Valve upstreamed this controller to SDL3 as "Triton", and their driver passes SDL's two rumble magnitudes straight through as the two speeds with both gains at 0 dB, so that is what this does. The emulated pad reports a single magnitude, so both sides get it; splitting strong/weak needs the uinput shim to stop collapsing them, which is a separate change. The report carries no duration, so it runs until something changes it. A stop is scheduled for the effect's duration and cancelled if a new effect arrives first -- without that a single rumble runs forever. Controller gains a rumble() hook returning False by default, so anything without real motors (notably the v1, whose "motors" are the pad actuators) keeps the existing click-train emulation. Only the mapper's dispatch changed: it offers the effect to the controller first and falls through unchanged. Also drops a duplicated HapticData import and refreshes the module docstring, which still listed continuous rumble as TODO. --- scc/controller.py | 10 ++++++++++ scc/drivers/sc2.py | 39 +++++++++++++++++++++++++++++++++++---- scc/mapper.py | 6 ++++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/scc/controller.py b/scc/controller.py index 8f211a8e1..7af605b54 100644 --- a/scc/controller.py +++ b/scc/controller.py @@ -133,6 +133,16 @@ def feedback(self, data) -> None: 'data' is HapticData instance. """ + def rumble(self, level: int, duration_ms: int) -> bool: + """Plays continuous game rumble, if the hardware has real rumble motors. + + 'level' is 0..32767, 'duration_ms' how long it should run for. Returns + True if handled. The default returns False, which makes the mapper fall + back to emulating rumble as a train of haptic clicks -- the only option + on a Steam Controller v1, whose "motors" are the pad actuators. + """ + return False + def turnoff(self) -> None: """Turns off controller, if supported""" diff --git a/scc/drivers/sc2.py b/scc/drivers/sc2.py index 7395db29d..596e5a755 100644 --- a/scc/drivers/sc2.py +++ b/scc/drivers/sc2.py @@ -5,9 +5,10 @@ via the wireless Puck (0x1304): lizard-mode disable plus button / stick / pad / trigger / gyro input through scc-daemon to uinput, and click haptics. Wired (0x1302) is supported too (single HID interface 0), and the GUI gets -images/sc2-config.json. Still TODO: continuous variable rumble, the Bluetooth -(0x1303) transport, real serial read-back, and a dedicated v2 GUI background -image (currently reuses the Deck's). +images/sc2-config.json. Continuous rumble uses output report 0x80. Still TODO: +the Bluetooth (0x1303) transport, real serial read-back, the richer haptic +effects (tone/sweep/script, reports 0x83-0x85), actuator audio streaming +(0x86-0x89), and a dedicated v2 GUI background image (reuses the Deck's). Architecture mirrors the existing drivers: - the wireless "Controller Puck" (0x1304) is a multi-slot dongle, like @@ -37,7 +38,6 @@ from scc.drivers.usb import SCUSBDevice, register_hotplug_device if TYPE_CHECKING: - from scc.controller import HapticData from scc.sccdaemon import SCCDaemon log = logging.getLogger("SC2") @@ -391,6 +391,7 @@ def __init__(self, driver: SC2Device, ccidx: int, in_endpoint: int, out_endpoint super().__init__(driver, ccidx, in_endpoint) self._out_ep = out_endpoint # interrupt-OUT endpoint for haptics self._old_state = SC2_NULL + self._rumble_stop = None # pending 'stop rumble' task, see rumble() # pad-press button -> which actuator to pulse _PAD_CLICK_SIDES = ((SCButtons.LPAD, HapticPos.LEFT), (SCButtons.RPAD, HapticPos.RIGHT)) @@ -412,6 +413,36 @@ def queue_pad_click(self, idata) -> None: elif was and not now: self.mapper.send_feedback(HapticData(pos, amplitude=PAD_CLICK_RELEASE)) + def rumble(self, level: int, duration_ms: int) -> bool: + """Continuous game rumble via output report 0x80 (HAPTIC_RUMBLE). + + Layout `80 + `, all little-endian. Valve's own SDL3 driver + for this controller passes SDL's two rumble magnitudes straight through + as the two speeds with both gains at 0 dB, so that is what we do; the + emulated pad reports a single magnitude, so both sides get it. + + This is the report the driver could not find before, which is why game + rumble was emulated as a train of pad clicks. The connect-time 0x80 + traffic IS this report, sent with zero speeds -- so it was never felt, + and got recorded as "not haptic". + """ + speed = max(0, min(0xFFFF, int(level))) + self._send_rumble(speed) + # 0x80 carries no duration -- it runs until something changes it -- so + # the stop has to be scheduled here or a single effect rumbles forever. + if self._rumble_stop: + self.mapper.cancel_task(self._rumble_stop) + self._rumble_stop = None + if speed and duration_ms > 0: + self._rumble_stop = self.mapper.schedule( + duration_ms / 1000.0, lambda *a: self._send_rumble(0)) + return True + + def _send_rumble(self, speed: int) -> None: + self._driver.send_haptic(self._out_ep, struct.pack( + " str: return "sc2" diff --git a/scc/mapper.py b/scc/mapper.py index 579d31cc0..da2c4594f 100644 --- a/scc/mapper.py +++ b/scc/mapper.py @@ -172,6 +172,12 @@ def _rumble_ready(self, fd, event) -> None: self.generate_feedback() return + # Controllers with real rumble motors take the magnitude directly; + # only fall through to the click-train emulation below if they + # cannot, which on a v1 is always, its "motors" being pad actuators. + if self.controller and self.controller.rumble( + ef.level, int(ef.duration * max(1, ef.repetitions))): + return period_command = 0 amplitude = 0 if ef.level != 0 and ef.repetitions > 0: From f5f55ff3fd8b0fb52605fbe9b566244c5a2e640c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Mon, 10 Aug 2026 18:19:21 +0200 Subject: [PATCH 67/98] docs(todo): scope SC2 actuator audio streaming (haptic reports 0x86-0x89) Refs #7. The remaining piece of the v2 haptic family, deferred: unlike the tone/sweep/script effects it is not a wiring job, because sc-controller has no concept of a haptic *stream* -- every haptic today is a fire-and-forget event attached to an action, so a source, buffering, endpoint timing and a profile representation all have to be designed first. --- TODO.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 2882e92b1..2da08f1fe 100644 --- a/TODO.md +++ b/TODO.md @@ -175,4 +175,21 @@ Very hard stuff: - Trigger settings - DPAD that acts only when clicked - 8-way DPAD -- Selector for media keys \ No newline at end of file +- Selector for media keys + +## SC2 actuator audio streaming (haptic reports 0x86-0x89) + +The v2's haptic actuators can be driven as speakers: reports 0x86-0x89 carry +stream configuration plus PCM / u-law data (layouts from iczero's RE, collected +in CouchTurtle/sc2-research; not verified here, and not present in SDL3). + +This is the one piece of the haptic family that needs design rather than just +wiring, because nothing in sc-controller has a concept of a *stream*. Every +haptic today is a fire-and-forget event attached to an action. Streaming needs +a source (file? synthesised? routed from the system mixer?), buffering and +timing against the USB endpoint, and some answer to what a *profile* would even +store. Deferred until there is a reason to want it beyond completeness. + +Prerequisites: the simpler effects (0x83 tone / 0x84 sweep / 0x85 script) land +first, since they settle how per-effect parameters are represented in +HapticData and in the GUI. From 39d2e7279917d33ba2d68f035a352665a4ce90d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Mon, 10 Aug 2026 18:40:09 +0200 Subject: [PATCH 68/98] feat(sc2): use both click presets, and add SCC_HAPTIC_DEBUG Refs #7. Strength still felt flat on hardware after the gain fix, and there are two possible reasons: the gain byte may not reach the driver at all for whatever binding is under test, or the firmware may ignore gain for a preset effect. This addresses both. Report 0x82 offers two click presets and we only ever sent the quiet one. Reach for CLICK_STRONG above +6 dB, so the slider spans two physically distinct effects and has an audible range even if gain turns out to be ignored for presets. SCC_HAPTIC_DEBUG=1 logs every haptic and rumble report as it is sent, with the amplitude that produced it, so the first of those two possibilities can be ruled out from the daemon log rather than by feel: HAPTIC amplitude= 2048 -> CLICK_STRONG gain=+12 dB report=82 01 02 0c RUMBLE speed=32767 report=80 00 00 00 ff 7f 00 ff 7f 00 Note the built-in pad click deliberately does not consult the slider -- it uses its own constants, because it is not a profile binding -- so moving Strength is not expected to change how pressing a pad feels. --- scc/drivers/sc2.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/scc/drivers/sc2.py b/scc/drivers/sc2.py index 596e5a755..065c1c901 100644 --- a/scc/drivers/sc2.py +++ b/scc/drivers/sc2.py @@ -143,6 +143,20 @@ def _gain_db(amplitude: int) -> int: TURNOFF_QUIET_PERIOD = 0.5 +# Report 0x82 offers two click presets, and we only ever used the quiet one. +# Reach for the strong one past this gain, so strength still has an audible +# range even if the firmware turns out to ignore the gain byte for a preset +# effect -- which is the open question about it. +HAPTIC_STRONG_CLICK_ABOVE_DB = 6 +HAPTIC_CLICK = 0x01 +HAPTIC_CLICK_STRONG = 0x02 + +# Set SCC_HAPTIC_DEBUG=1 to log every haptic report as it goes out. +_HAPTIC_DEBUG = bool(os.environ.get("SCC_HAPTIC_DEBUG")) +if _HAPTIC_DEBUG: + log.setLevel(logging.INFO) + + # --- report 0x42 layout (see docs/steam-controller-v2-protocol.md) ---------- # 0 report id (0x42) # 1 packet counter @@ -440,8 +454,10 @@ def rumble(self, level: int, duration_ms: int) -> bool: return True def _send_rumble(self, speed: int) -> None: - self._driver.send_haptic(self._out_ep, struct.pack( - " str: return "sc2" @@ -561,11 +577,17 @@ def feedback(self, data: HapticData) -> None: if amp <= 0: return side = {HapticPos.LEFT: 0, HapticPos.RIGHT: 1, HapticPos.BOTH: 2}.get(pos, 1) + gain = _gain_db(amp) + command = HAPTIC_CLICK_STRONG if gain > HAPTIC_STRONG_CLICK_ABOVE_DB else HAPTIC_CLICK # "b" -- the gain is signed. Packing it as an unsigned 0..255 amplitude # made the strength control non-monotonic: it rose over the bottom tenth # of the slider, sat clamped at max through the middle, then wrapped # negative so that turning it UP past halfway made the pad quieter. - report = struct.pack(" %s gain=%+3d dB report=%s", + amp, "CLICK_STRONG" if command == HAPTIC_CLICK_STRONG else "CLICK", + gain, report.hex(" ")) self._driver.send_haptic(self._out_ep, report) From d3e1ff54b128e6ad6f03c37b853c2314071ca1e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Mon, 10 Aug 2026 18:55:51 +0200 Subject: [PATCH 69/98] fix(sc2): spread haptic gain over the whole slider, split rumble motors Refs #7. Both from hardware testing of the previous batch. Strength: the Strength slider runs 16..32767 but the gain mapping anchored its default (512) to 0 dB, so +24 dB -- the firmware's ceiling -- was reached at amplitude 8192 and everything above clamped. Measured on hardware: 16, 512 and 3194 felt distinct while 13960 and 32639 felt identical to each other and barely different from 3194, i.e. the top three quarters of the travel did nothing. Spread the -23..+24 dB range across the slider's own bounds instead, logarithmically since the slider is linear in amplitude and the device takes decibels. Now roughly 4-5 dB per 10% of travel end to end, and the five amplitudes tested above land on -23, -2, +10, +19 and +24 dB. The CLICK_STRONG switch moves from +6 to +18 dB, so it lands at the top where gain has run out of headroom rather than stepping in the middle of an otherwise smooth range. That threshold is a guess and wants tuning by feel. Rumble: fftest's "strong rumble (heavy motor)" and "weak rumble (light motor)" both worked but felt identical, because uinput.c collapsed the two FF_RUMBLE magnitudes into one averaged level: avg = strong_magnitude / 3 + weak_magnitude / 6; Carry them through unmerged, alongside that average -- which stays, since the v1 emulates rumble as a click train and can only act on one intensity. The v2 puts them on the two sides, matching Valve's SDL3 driver, where SDL's low_frequency_rumble and high_frequency_rumble (the same two motors Linux calls strong and weak) go to left.speed and right.speed. The C struct and its ctypes mirror were checked to agree byte for byte: size 24, strong@20, weak@22 on both sides. --- scc/controller.py | 11 +++++---- scc/drivers/sc2.py | 61 ++++++++++++++++++++++++++-------------------- scc/mapper.py | 2 +- scc/uinput.c | 8 ++++++ scc/uinput.py | 2 ++ 5 files changed, 51 insertions(+), 33 deletions(-) diff --git a/scc/controller.py b/scc/controller.py index 7af605b54..eeb856845 100644 --- a/scc/controller.py +++ b/scc/controller.py @@ -133,13 +133,14 @@ def feedback(self, data) -> None: 'data' is HapticData instance. """ - def rumble(self, level: int, duration_ms: int) -> bool: + def rumble(self, strong: int, weak: int, duration_ms: int) -> bool: """Plays continuous game rumble, if the hardware has real rumble motors. - 'level' is 0..32767, 'duration_ms' how long it should run for. Returns - True if handled. The default returns False, which makes the mapper fall - back to emulating rumble as a train of haptic clicks -- the only option - on a Steam Controller v1, whose "motors" are the pad actuators. + 'strong' and 'weak' are the two FF_RUMBLE magnitudes (0..65535): the + heavy low-frequency motor and the light high-frequency one. Returns True + if handled. The default returns False, which makes the mapper fall back + to emulating rumble as a train of haptic clicks -- the only option on a + Steam Controller v1, whose "motors" are the pad actuators. """ return False diff --git a/scc/drivers/sc2.py b/scc/drivers/sc2.py index 065c1c901..34cd56f87 100644 --- a/scc/drivers/sc2.py +++ b/scc/drivers/sc2.py @@ -128,17 +128,23 @@ def _log_imu_calib(idata) -> None: # the haptic notes in docs/steam-controller-v2-protocol.md. HAPTIC_GAIN_MIN_DB = -23 HAPTIC_GAIN_MAX_DB = 24 -# HapticData's own default amplitude, mapped to 0 dB (nominal) so existing -# profiles keep roughly the strength they had. -HAPTIC_NOMINAL_AMPLITUDE = 512 +# The Strength slider's own bounds (glade adjFAmplitude). The gain range is +# spread across exactly this, so every part of the slider does something: +# anchoring 512 to 0 dB instead put +24 dB at amplitude 8192, leaving the top +# three quarters of the travel clamped and indistinguishable on hardware. +HAPTIC_AMPLITUDE_MIN = 16 +HAPTIC_AMPLITUDE_MAX = 32767 def _gain_db(amplitude: int) -> int: - """HapticData amplitude -> report 0x82 gain, in dB relative to nominal.""" - if amplitude <= 0: - return HAPTIC_GAIN_MIN_DB - db = 20.0 * math.log10(amplitude / HAPTIC_NOMINAL_AMPLITUDE) - return int(round(max(HAPTIC_GAIN_MIN_DB, min(HAPTIC_GAIN_MAX_DB, db)))) + """HapticData amplitude -> report 0x82 gain in dB. + + Logarithmic, because the slider is linear in amplitude while the device + takes decibels; this keeps the perceived step per pixel roughly even. + """ + a = max(HAPTIC_AMPLITUDE_MIN, min(HAPTIC_AMPLITUDE_MAX, int(amplitude))) + t = math.log(a / HAPTIC_AMPLITUDE_MIN) / math.log(HAPTIC_AMPLITUDE_MAX / HAPTIC_AMPLITUDE_MIN) + return int(round(HAPTIC_GAIN_MIN_DB + t * (HAPTIC_GAIN_MAX_DB - HAPTIC_GAIN_MIN_DB))) TURNOFF_QUIET_PERIOD = 0.5 @@ -147,7 +153,7 @@ def _gain_db(amplitude: int) -> int: # Reach for the strong one past this gain, so strength still has an audible # range even if the firmware turns out to ignore the gain byte for a preset # effect -- which is the open question about it. -HAPTIC_STRONG_CLICK_ABOVE_DB = 6 +HAPTIC_STRONG_CLICK_ABOVE_DB = 18 HAPTIC_CLICK = 0x01 HAPTIC_CLICK_STRONG = 0x02 @@ -427,36 +433,37 @@ def queue_pad_click(self, idata) -> None: elif was and not now: self.mapper.send_feedback(HapticData(pos, amplitude=PAD_CLICK_RELEASE)) - def rumble(self, level: int, duration_ms: int) -> bool: + def rumble(self, strong: int, weak: int, duration_ms: int) -> bool: """Continuous game rumble via output report 0x80 (HAPTIC_RUMBLE). - Layout `80 - `, all little-endian. Valve's own SDL3 driver - for this controller passes SDL's two rumble magnitudes straight through - as the two speeds with both gains at 0 dB, so that is what we do; the - emulated pad reports a single magnitude, so both sides get it. + Layout `80 + `, all little-endian, both gains 0 dB -- which + is what Valve's own SDL3 driver for this controller sends. - This is the report the driver could not find before, which is why game - rumble was emulated as a train of pad clicks. The connect-time 0x80 - traffic IS this report, sent with zero speeds -- so it was never felt, - and got recorded as "not haptic". + SDL calls its two magnitudes low_frequency_rumble and + high_frequency_rumble and puts them on left and right respectively; + those are the same two motors Linux calls strong (heavy) and weak + (light), so they map straight across. Sending one averaged value to + both sides instead -- which is what the uinput shim used to hand us -- + made fftest's "strong rumble" and "weak rumble" feel identical. """ - speed = max(0, min(0xFFFF, int(level))) - self._send_rumble(speed) + left = max(0, min(0xFFFF, int(strong))) + right = max(0, min(0xFFFF, int(weak))) + self._send_rumble(left, right) # 0x80 carries no duration -- it runs until something changes it -- so # the stop has to be scheduled here or a single effect rumbles forever. if self._rumble_stop: self.mapper.cancel_task(self._rumble_stop) self._rumble_stop = None - if speed and duration_ms > 0: + if (left or right) and duration_ms > 0: self._rumble_stop = self.mapper.schedule( - duration_ms / 1000.0, lambda *a: self._send_rumble(0)) + duration_ms / 1000.0, lambda *a: self._send_rumble(0, 0)) return True - def _send_rumble(self, speed: int) -> None: - report = struct.pack(" None: + report = struct.pack(" str: @@ -573,7 +580,7 @@ def feedback(self, data: HapticData) -> None: # it suits pad/scroll detents; sustained game rumble may need another # report (not yet found). data.data = (position, amplitude, period, count). pos = data.data[0] - amp = data.data[1] if len(data.data) > 1 else HAPTIC_NOMINAL_AMPLITUDE + amp = data.data[1] if len(data.data) > 1 else 512 if amp <= 0: return side = {HapticPos.LEFT: 0, HapticPos.RIGHT: 1, HapticPos.BOTH: 2}.get(pos, 1) diff --git a/scc/mapper.py b/scc/mapper.py index da2c4594f..5d400c3e5 100644 --- a/scc/mapper.py +++ b/scc/mapper.py @@ -176,7 +176,7 @@ def _rumble_ready(self, fd, event) -> None: # only fall through to the click-train emulation below if they # cannot, which on a v1 is always, its "motors" being pad actuators. if self.controller and self.controller.rumble( - ef.level, int(ef.duration * max(1, ef.repetitions))): + ef.strong, ef.weak, int(ef.duration * max(1, ef.repetitions))): return period_command = 0 amplitude = 0 diff --git a/scc/uinput.c b/scc/uinput.c index 51f35b031..02d422537 100644 --- a/scc/uinput.c +++ b/scc/uinput.c @@ -47,6 +47,12 @@ struct feedback_effect { int32_t repetitions; uint16_t type; int16_t level; + // FF_RUMBLE's two magnitudes, kept unmerged. 'level' above averages them + // for drivers that can only do one intensity (the v1 emulates rumble as a + // click train); hardware with real motors wants them apart, or the heavy + // and light effects feel identical. + uint16_t strong; + uint16_t weak; }; int uinput_init( @@ -324,6 +330,8 @@ int uinput_ff_read(int fd, int ff_effects_max, struct feedback_effect** ff_effec avg = upload.effect.u.rumble.strong_magnitude / 3 + upload.effect.u.rumble.weak_magnitude / 6; ff_effects[eid]->level = (int32_t)MIN(avg, 0x7FFF); + ff_effects[eid]->strong = upload.effect.u.rumble.strong_magnitude; + ff_effects[eid]->weak = upload.effect.u.rumble.weak_magnitude; if (ff_effects[eid]->continuous_rumble) { // See comment in case EV_FF: block ff_effects[eid]->duration = INFINITE_RUMBLE; diff --git a/scc/uinput.py b/scc/uinput.py index e0f59c34f..62fcfd00a 100644 --- a/scc/uinput.py +++ b/scc/uinput.py @@ -218,6 +218,8 @@ class FeedbackEvent(ctypes.Structure): ("repetitions", c_int32), ("type", c_uint16), ("level", c_int16), + ("strong", c_uint16), # FF_RUMBLE magnitudes, unmerged; see uinput.c + ("weak", c_uint16), ] def __init__(self): From 1b75004fe175554f536e1b2f2697a6b4a9bb2238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Mon, 10 Aug 2026 19:14:44 +0200 Subject: [PATCH 70/98] fix(rumble): never swallow an effect that carries no magnitudes Refs #7. Defect introduced in 0645782a. Splitting FF_RUMBLE's two magnitudes out of the averaged level made the mapper hand controller.rumble() strong and weak directly -- but only FF_RUMBLE fills those. Every other effect type carries just 'level', so constant, periodic, spring and damper effects arrived as rumble(0, 0), which a controller with real motors answers True to. The effect was then swallowed: silent, and no fall-through to the click-train emulation either. Fall back to the averaged level whenever both magnitudes are zero and the level is not, which covers the other effect types and any libuinput built before the struct grew those fields. Also default strong/weak on upload, next to level's own default. They are written only in the FF_RUMBLE branch while the effect slots are pooled and reused, so a slot that previously held a rumble effect could leak its magnitudes into an effect of a different type. --- scc/mapper.py | 9 ++++++++- scc/uinput.c | 5 +++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/scc/mapper.py b/scc/mapper.py index 5d400c3e5..bd0757c93 100644 --- a/scc/mapper.py +++ b/scc/mapper.py @@ -175,8 +175,15 @@ def _rumble_ready(self, fd, event) -> None: # Controllers with real rumble motors take the magnitude directly; # only fall through to the click-train emulation below if they # cannot, which on a v1 is always, its "motors" being pad actuators. + strong, weak = ef.strong, ef.weak + if not strong and not weak and ef.level > 0: + # Only FF_RUMBLE carries the two magnitudes. Every other effect + # type -- and any older libuinput -- provides just the averaged + # level, so drive both motors from that instead of handing the + # controller a silent (0, 0) and returning as if it played. + strong = weak = ef.level if self.controller and self.controller.rumble( - ef.strong, ef.weak, int(ef.duration * max(1, ef.repetitions))): + strong, weak, int(ef.duration * max(1, ef.repetitions))): return period_command = 0 amplitude = 0 diff --git a/scc/uinput.c b/scc/uinput.c index 02d422537..ccfb79b8f 100644 --- a/scc/uinput.c +++ b/scc/uinput.c @@ -298,6 +298,11 @@ int uinput_ff_read(int fd, int ff_effects_max, struct feedback_effect** ff_effec ff_effects[eid]->repetitions = 0; ff_effects[eid]->type = upload.effect.type; ff_effects[eid]->level = 0x4FFF; + // Only FF_RUMBLE fills these; default them so a slot + // reused from a previous rumble cannot leak its + // magnitudes into an effect of another type. + ff_effects[eid]->strong = 0; + ff_effects[eid]->weak = 0; // This part converts all possible event types to one that // SCC and controller really supports. Only output level is used. switch (upload.effect.type) { From d8c77fbffc216a1bfbc2412bb5ad4de62649e652 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Mon, 10 Aug 2026 19:27:59 +0200 Subject: [PATCH 71/98] debug(rumble): dump the raw FF effect under SCC_HAPTIC_DEBUG The two FF_RUMBLE magnitudes are arriving as zero even though uinput.c stores them on upload, the object was recompiled, and both the C struct and its ctypes mirror agree at 24 bytes with strong@20 / weak@22. fftest #4 and #5 produce 10922 and 8192, which are exactly strong/3 and weak/6 -- the averaged level -- so the fallback added in d18e465f is being taken. Static inspection has run out, so log the effect exactly as the shim hands it over. This separates a shim-side problem (magnitudes never stored, or stored in a slot other than the one played) from a driver-side one. --- scc/mapper.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scc/mapper.py b/scc/mapper.py index bd0757c93..5f85d494a 100644 --- a/scc/mapper.py +++ b/scc/mapper.py @@ -45,6 +45,13 @@ log = logging.getLogger("Mapper") +# SCC_HAPTIC_DEBUG=1 also dumps the raw force-feedback effect as the uinput +# shim hands it over, which is the only way to tell a shim-side problem from a +# driver-side one. +_RUMBLE_DEBUG = bool(os.environ.get("SCC_HAPTIC_DEBUG")) +if _RUMBLE_DEBUG: + log.setLevel(logging.INFO) + class Mapper: DEBUG: bool = False @@ -175,6 +182,9 @@ def _rumble_ready(self, fd, event) -> None: # Controllers with real rumble motors take the magnitude directly; # only fall through to the click-train emulation below if they # cannot, which on a v1 is always, its "motors" being pad actuators. + if _RUMBLE_DEBUG: + log.info("FF-RAW type=%d level=%d strong=%d weak=%d dur=%d reps=%d", + ef.type, ef.level, ef.strong, ef.weak, ef.duration, ef.repetitions) strong, weak = ef.strong, ef.weak if not strong and not weak and ef.level > 0: # Only FF_RUMBLE carries the two magnitudes. Every other effect From ce62d7afb2dc8a7ec69e2897e80d3364085274a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Mon, 10 Aug 2026 19:59:50 +0200 Subject: [PATCH 72/98] docs(sc2): record report 0x80 as hardware-verified Rumble confirmed live with fftest on the emulated pad: type, intensity and both gains at 0, and the two speed fields driving their own actuator independently, with Linux's strong/weak FF_RUMBLE magnitudes landing on left and right as Valve's SDL3 driver arranges them. Also records an open question the test raised. The field is called speed, not amplitude, and gain is a separate i8 in dB. The weak-motor effect carries the larger value (49152 against 32768) yet is clearly weaker in the hand, so speed looks like a motor speed rather than a linear amplitude -- higher meaning a lighter, higher-frequency buzz. Both gains are 0 dB only because that is what SDL sends; per-side gain and the intensity field are untried and may be the better volume control. --- docs/steam-controller-v2-protocol.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/steam-controller-v2-protocol.md b/docs/steam-controller-v2-protocol.md index 643376101..b1b4cb5a7 100644 --- a/docs/steam-controller-v2-protocol.md +++ b/docs/steam-controller-v2-protocol.md @@ -182,7 +182,7 @@ at (`docs/HAPTICS.md`): | report | name | payload | | --- | --- | --- | -| `0x80` | `HAPTIC_RUMBLE` | `type u8, intensity u16, left{speed u16, gain i8}, right{speed u16, gain i8}` | +| `0x80` | `HAPTIC_RUMBLE` | `type u8, intensity u16, left{speed u16, gain i8}, right{speed u16, gain i8}` — **verified live** | | `0x81` | `HAPTIC_PULSE` | `side u8, on_us u16, off_us u16, repeat u16` | | `0x82` | `HAPTIC_COMMAND` | `side u8, command u8 (0=stop all, 1=click, 2=strong click), gain i8 dB` | | `0x83` | `HAPTIC_LFO_TONE` | `side u8, gain i8, frequency u16 Hz, duration u16, lfo_freq u16 Hz, lfo_depth u8` | @@ -190,6 +190,23 @@ at (`docs/HAPTICS.md`): | `0x85` | `HAPTIC_SCRIPT` | `side u8, script_id u8, gain i8` | | `0x86`–`0x89` | audio stream | actuator PCM / u-law streaming | +`0x80` is hardware-verified: `type`, `intensity` and both `gain` fields at 0, +the two `speed` fields driving their own actuator independently. Confirmed with +`fftest` on the emulated pad, whose "strong rumble (heavy motor)" and "weak +rumble (light motor)" arrive as the two Linux FF_RUMBLE magnitudes and come out +on the left and right actuators respectively: + + strong=32768 weak=0 -> 80 00 00 00 00 80 00 00 00 00 (left only) + strong=0 weak=49152 -> 80 00 00 00 00 00 00 00 c0 00 (right only) + +**Open question:** the field is named `speed`, not amplitude, and `gain` is a +separate i8 in dB. The second case above carries the LARGER number yet is +clearly weaker in the hand, so `speed` is probably not linear amplitude -- more +likely a motor speed, where higher means a lighter, higher-frequency buzz. Both +gains are 0 dB here because that is what Valve's driver sends; exposing gain +per side, and the `intensity` field, is untried and may be the better volume +control. + So **continuous variable rumble is `0x80`**, and the earlier note here that it was "not yet captured" was wrong -- the connect-time `0x80` traffic is that report, sent with zero speeds, which is why nothing was felt. SDL sends the two SDL rumble From e186237360d044975bb00f5a20195761632e1993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Mon, 10 Aug 2026 20:08:46 +0200 Subject: [PATCH 73/98] feat(sc2): tone, sweep and firmware-script haptic effects Refs #7. The synthesised effects from the v2's haptic family, output reports 0x83 (LFO tone), 0x84 (logarithmic sweep) and 0x85 (firmware script). Two design decisions, both about not breaking what exists: HapticData grows the effect and its parameters as defaulted keyword arguments, kept OUT of the 'data' tuple. That tuple is unpacked positionally by every existing driver, so growing it would break them; the new fields are plain attributes that drivers read only if they understand them. feedback() is left bit-for-bit compatible, because it appears in every profile already written and its arguments are positional -- adding to it would change the meaning of files on disk. The new effects are sibling commands instead: feedbacktone, feedbacksweep and feedbackscript, sharing a base class with feedback() so they attach to an action the same way. Only hardware that can synthesise waveforms plays them, currently the v2 alone. Anything else ignores the effect field and plays its usual click, so a profile written for one controller still does something on another rather than going silent. Report layouts verified against the documented sizes: tone 10 bytes, sweep 9, script 4. TONE -> 83 01 fe dc 00 2c 01 04 00 80 SWEEP -> 84 00 fe fa 00 90 01 50 00 SCRIPT -> 85 01 03 fe Also fixes HapticData.with_position, which passed self.frequency back to the constructor even though it is already scaled by 1000 -- so it was multiplied again on every copy, and send_feedback() copies for every HapticPos.BOTH effect. And drops ControllerFlags from modifiers.py, unused since the pad/ stick predicate replaced its last reader. The repo requires docs and both a parser and a profile test for every action; all three are provided for each new command. --- docs/actions.md | 23 +++++++ docs/profile-file.md | 39 ++++++++++++ scc/constants.py | 32 ++++++++++ scc/controller.py | 33 +++++++++- scc/drivers/sc2.py | 42 +++++++++---- scc/modifiers.py | 90 ++++++++++++++++++++++++++++ tests/test_parser/test_modifiers.py | 18 ++++++ tests/test_profile/test_modifiers.py | 29 ++++++++- 8 files changed, 292 insertions(+), 14 deletions(-) diff --git a/docs/actions.md b/docs/actions.md index bf90c23ea..fa8a5a6be 100755 --- a/docs/actions.md +++ b/docs/actions.md @@ -395,6 +395,29 @@ running feedback that you may not be able to stop. should mouse travel between two feedback ticks. +#### feedbacktone(side, [amplitude=512 [, tone_frequency=160 [, duration=200 [, lfo_frequency=0 [, lfo_depth=0 ]]]]], action) +Like feedback(), but plays a sine tone instead of a click. 'tone_frequency' and +'lfo_frequency' are in Hz, 'duration' in milliseconds and 'lfo_depth' is 0 to +255; leave 'lfo_frequency' at 0 for a steady tone. + +Needs a controller that can synthesise waveforms, currently only the Steam +Controller 2. Anything else falls back to a plain click, so a profile written +for one controller still does something on another. + + +#### feedbacksweep(side, [amplitude=512 [, tone_frequency=160 [, end_frequency=40 [, duration=200 ]]]], action) +Like feedback(), but sweeps logarithmically from 'tone_frequency' to +'end_frequency' (both Hz) over 'duration' milliseconds. Same hardware +requirement, and the same fallback, as feedbacktone(). + + +#### feedbackscript(side, [script_id=0 [, amplitude=512 ]], action) +Like feedback(), but plays an effect stored in the controller's own firmware, +selected by 'script_id'. Which ids exist and what each one feels like is +hardware specific and not yet catalogued. Same hardware requirement, and the +same fallback, as feedbacktone(). + + #### ball([friction=10.0, [mass=80.0, ]] action) Enables trackball mode. Moving finger over pad will keep repeating same action with decreasing speed, based on set mass and friction, until virtual diff --git a/docs/profile-file.md b/docs/profile-file.md index 14ccdf5a0..2b4e0bc94 100644 --- a/docs/profile-file.md +++ b/docs/profile-file.md @@ -192,6 +192,45 @@ specifies feedback with amplitude of 512 (default value) and frequency of 5 generated by left motor. +#### `feedbacktone` +Like `feedback`, but plays a sine tone. Value is a list of +`[side, amplitude, tone_frequency, duration, lfo_frequency, lfo_depth]`, of +which only the side is required. Frequencies are in Hz, duration in +milliseconds, `lfo_depth` 0 to 255. + +Example: + + "pad_left": { + "action": "trackball()", + "feedbacktone": ["LEFT", 512, 220, 300, 4, 128] + }, + +#### `feedbacksweep` +Like `feedback`, but sweeps logarithmically between two frequencies. Value is +`[side, amplitude, tone_frequency, end_frequency, duration]`, only the side +being required. + +Example: + + "pad_right": { + "action": "trackball()", + "feedbacksweep": ["RIGHT", 512, 400, 80, 250] + }, + +#### `feedbackscript` +Like `feedback`, but plays an effect stored in the controller's own firmware. +Value is `[side, script_id, amplitude]`, only the side being required. + +Example: + + "pad_right": { + "action": "trackball()", + "feedbackscript": ["RIGHT", 3] + }, + +Only hardware that can synthesise waveforms plays these three -- currently the +Steam Controller 2 alone. Every other controller falls back to a plain click. + #### `smooth` Enables input smoothing (see [smooth modifier](actions.md#smooth)) diff --git a/scc/constants.py b/scc/constants.py index d6e2c43c0..9d7bcf4df 100644 --- a/scc/constants.py +++ b/scc/constants.py @@ -115,6 +115,38 @@ class HapticPos(IntEnum): BOTH = 2 # emulated +class HapticEffect(IntEnum): + """Which kind of haptic effect a HapticData describes. + + CLICK is the only one every controller can do, and the only one a v1 + understands at all; the rest need hardware with a synthesising actuator + (currently just the Steam Controller 2, output reports 0x83-0x85). A + driver that does not know an effect should fall back to CLICK rather than + going silent. + """ + + CLICK = 0 # a single tick -- what feedback() has always meant + TONE = 1 # sine at a fixed frequency, optionally LFO-modulated + SWEEP = 2 # logarithmic frequency sweep between two frequencies + SCRIPT = 3 # a preset effect stored in the controller firmware + + +class HapticEffect(IntEnum): + """Which kind of haptic effect a HapticData describes. + + CLICK is the only one every controller can do, and the only one a v1 + understands at all; the rest need hardware with a synthesising actuator + (currently just the Steam Controller 2, output reports 0x83-0x85). A + driver that does not know an effect should fall back to CLICK rather than + going silent. + """ + + CLICK = 0 # a single tick -- what feedback() has always meant + TONE = 1 # sine at a fixed frequency, optionally LFO-modulated + SWEEP = 2 # logarithmic frequency sweep between two frequencies + SCRIPT = 3 # a preset effect stored in the controller firmware + + class ControllerFlags(IntFlag): """Used by mapper to workaround some physical differences between Steam Controller and other pads.""" diff --git a/scc/controller.py b/scc/controller.py index eeb856845..87725b25d 100644 --- a/scc/controller.py +++ b/scc/controller.py @@ -4,7 +4,7 @@ import time from typing import TYPE_CHECKING -from scc.constants import HapticPos +from scc.constants import HapticEffect, HapticPos if TYPE_CHECKING: from scc.drivers.hiddrv import HIDDecoder @@ -154,10 +154,22 @@ def disconnected(self) -> None: class HapticData: """Simple container to hold haptic feedback settings""" - def __init__(self, position: HapticPos, amplitude: int = 512, frequency: int = 4, period: int = 1024, count: int = 1) -> None: + def __init__(self, position: HapticPos, amplitude: int = 512, frequency: int = 4, period: int = 1024, count: int = 1, + effect=HapticEffect.CLICK, duration=200, tone_frequency=160, + end_frequency=40, lfo_frequency=0, lfo_depth=0, script_id=0) -> None: """'frequency' is used only when emulating touchpad and describes how many pixels should mouse travel between two feedback ticks. + + Everything from 'effect' on describes richer effects that only some + hardware can synthesise, and is ignored by drivers that cannot. They are + kept out of the 'data' tuple deliberately: that tuple is unpacked + positionally by existing drivers, so growing it would break them. + + 'duration' is in milliseconds; 'tone_frequency', 'end_frequency' and + 'lfo_frequency' in Hz; 'lfo_depth' 0..255; 'script_id' selects a preset + stored in the controller's own firmware. + """ data: tuple[int, int, int, int] = (int(position), int(amplitude), int(period), int(count)) if data[0] not in (HapticPos.LEFT, HapticPos.RIGHT, HapticPos.BOTH): @@ -171,11 +183,26 @@ def __init__(self, position: HapticPos, amplitude: int = 512, frequency: int = 4 self.data = data # send to controller self.frequency = frequency # used internally + self.effect = HapticEffect(effect) + self.duration = int(duration) + self.tone_frequency = int(tone_frequency) + self.end_frequency = int(end_frequency) + self.lfo_frequency = int(lfo_frequency) + self.lfo_depth = max(0, min(255, int(lfo_depth))) + self.script_id = max(0, min(255, int(script_id))) def with_position(self, position: HapticPos) -> HapticData: """Creates copy of HapticData with position value changed""" trash, amplitude, period, count = self.data - return HapticData(position, amplitude, self.frequency, period, count) + rv = HapticData(position, amplitude, 1, period, count) + # self.frequency is already scaled by 1000, so it cannot be handed back + # to the constructor -- doing so multiplied it again on every copy, and + # send_feedback() copies for every HapticPos.BOTH effect. + rv.frequency = self.frequency + for attr in ("effect", "duration", "tone_frequency", "end_frequency", + "lfo_frequency", "lfo_depth", "script_id"): + setattr(rv, attr, getattr(self, attr)) + return rv def get_position(self) -> HapticPos: return HapticPos(self.data[0]) diff --git a/scc/drivers/sc2.py b/scc/drivers/sc2.py index 34cd56f87..277f00cd2 100644 --- a/scc/drivers/sc2.py +++ b/scc/drivers/sc2.py @@ -32,7 +32,14 @@ import usb1 -from scc.constants import STICK_PAD_MAX, STICK_PAD_MIN, ControllerFlags, HapticPos, SCButtons +from scc.constants import ( + STICK_PAD_MAX, + STICK_PAD_MIN, + ControllerFlags, + HapticEffect, + HapticPos, + SCButtons, +) from scc.controller import HapticData from scc.drivers.sc_dongle import SCController, SCPacketType from scc.drivers.usb import SCUSBDevice, register_hotplug_device @@ -585,16 +592,31 @@ def feedback(self, data: HapticData) -> None: return side = {HapticPos.LEFT: 0, HapticPos.RIGHT: 1, HapticPos.BOTH: 2}.get(pos, 1) gain = _gain_db(amp) - command = HAPTIC_CLICK_STRONG if gain > HAPTIC_STRONG_CLICK_ABOVE_DB else HAPTIC_CLICK - # "b" -- the gain is signed. Packing it as an unsigned 0..255 amplitude - # made the strength control non-monotonic: it rose over the bottom tenth - # of the slider, sat clamped at max through the middle, then wrapped - # negative so that turning it UP past halfway made the pad quieter. - report = struct.pack(" HAPTIC_STRONG_CLICK_ABOVE_DB else HAPTIC_CLICK + report = struct.pack(" %s gain=%+3d dB report=%s", - amp, "CLICK_STRONG" if command == HAPTIC_CLICK_STRONG else "CLICK", - gain, report.hex(" ")) + log.info("HAPTIC %-6s amplitude=%6d gain=%+3d dB report=%s", + HapticEffect(effect).name, amp, gain, report.hex(" ")) self._driver.send_haptic(self._out_ep, report) diff --git a/scc/modifiers.py b/scc/modifiers.py index a83b994ad..a675b0efa 100644 --- a/scc/modifiers.py +++ b/scc/modifiers.py @@ -43,6 +43,8 @@ STICK_PAD_MIN, LSTICKTILT, TRIGGER_MAX, + HapticEffect, + ControllerFlags, HapticPos, SCButtons, SCPads, SCSticks, SCTouchpads, ) @@ -1391,6 +1393,94 @@ def compress(self): return self.action.compress() +class _EffectFeedbackModifier(FeedbackModifier): + """Shared base for the richer haptic effects. + + These behave exactly like feedback() -- wrap an action, hand it a + HapticData -- and differ only in which effect that data describes. They + are separate commands rather than extra parameters on feedback() because + feedback() appears in every existing profile and its argument list is + positional; growing it would change the meaning of files already on disk. + + Only hardware with a synthesising actuator can play them (currently the + Steam Controller 2). Drivers that cannot fall back to a plain click, so a + profile written for one controller still does something on another. + """ + + EFFECT = None + + def _make_haptic(self, position, amplitude, **kw): + return HapticData(position, amplitude, effect=self.EFFECT, **kw) + + def _attach(self, haptic): + self.haptic = haptic + a = self.action + while a: + if hasattr(a, "set_haptic"): + a.set_haptic(self.haptic) + break + a = a.action if hasattr(a, "action") else None + + @classmethod + def decode(cls, data, a, *b): + args = list(data[cls.COMMAND]) + if hasattr(HapticPos, args[0]): + args[0] = getattr(HapticPos, args[0]) + args.append(a) + return cls(*args) + + def to_string(self, multiline=False, pad=0): + return self._mod_to_string(self.strip_defaults(), multiline, pad) + + +class FeedbackToneModifier(_EffectFeedbackModifier): + """Sine tone at a fixed frequency, optionally modulated by an LFO.""" + + COMMAND = "feedbacktone" + PROFILE_KEY_PRIORITY = -4 + EFFECT = HapticEffect.TONE + + def _mod_init(self, position, amplitude=512, tone_frequency=160, duration=200, + lfo_frequency=0, lfo_depth=0): + self._attach(self._make_haptic(position, amplitude, + tone_frequency=tone_frequency, duration=duration, + lfo_frequency=lfo_frequency, lfo_depth=lfo_depth)) + + def __str__(self): + return "" % (self.action,) + + +class FeedbackSweepModifier(_EffectFeedbackModifier): + """Logarithmic frequency sweep between two frequencies.""" + + COMMAND = "feedbacksweep" + PROFILE_KEY_PRIORITY = -4 + EFFECT = HapticEffect.SWEEP + + def _mod_init(self, position, amplitude=512, tone_frequency=160, + end_frequency=40, duration=200): + self._attach(self._make_haptic(position, amplitude, + tone_frequency=tone_frequency, end_frequency=end_frequency, + duration=duration)) + + def __str__(self): + return "" % (self.action,) + + +class FeedbackScriptModifier(_EffectFeedbackModifier): + """Preset effect stored in the controller's own firmware.""" + + COMMAND = "feedbackscript" + PROFILE_KEY_PRIORITY = -4 + EFFECT = HapticEffect.SCRIPT + + def _mod_init(self, position, script_id=0, amplitude=512): + self._attach(self._make_haptic(position, amplitude, script_id=script_id)) + + def __str__(self): + return "" % (self.action,) + + class RotateInputModifier(Modifier): """Rotates ball or stick input along axis""" diff --git a/tests/test_parser/test_modifiers.py b/tests/test_parser/test_modifiers.py index 133902910..6e65ce685 100644 --- a/tests/test_parser/test_modifiers.py +++ b/tests/test_parser/test_modifiers.py @@ -210,6 +210,24 @@ def test_feedback(self): assert _parses_as_itself(FeedbackModifier(HapticPos.LEFT, MouseAction())) assert _parses_as_itself(FeedbackModifier(HapticPos.RIGHT, MouseAction())) + def test_feedbacktone(self): + """Tests if FeedbackToneModifier survives a to_string/parse round trip.""" + assert _parses_as_itself(FeedbackToneModifier(HapticPos.BOTH, MouseAction())) + assert _parses_as_itself(FeedbackToneModifier(HapticPos.LEFT, 512, 220, MouseAction())) + assert _parses_as_itself( + FeedbackToneModifier(HapticPos.RIGHT, 512, 220, 300, 4, 128, MouseAction())) + + def test_feedbacksweep(self): + """Tests if FeedbackSweepModifier survives a to_string/parse round trip.""" + assert _parses_as_itself(FeedbackSweepModifier(HapticPos.BOTH, MouseAction())) + assert _parses_as_itself( + FeedbackSweepModifier(HapticPos.LEFT, 512, 400, 80, 250, MouseAction())) + + def test_feedbackscript(self): + """Tests if FeedbackScriptModifier survives a to_string/parse round trip.""" + assert _parses_as_itself(FeedbackScriptModifier(HapticPos.BOTH, MouseAction())) + assert _parses_as_itself(FeedbackScriptModifier(HapticPos.RIGHT, 3, 512, MouseAction())) + def test_rotate(self): """Tests if RotateInputModifier can be converted to string and parsed back to same. diff --git a/tests/test_profile/test_modifiers.py b/tests/test_profile/test_modifiers.py index f2195074b..1b0c8d8aa 100644 --- a/tests/test_profile/test_modifiers.py +++ b/tests/test_profile/test_modifiers.py @@ -1,7 +1,7 @@ import inspect from scc.actions import AxisAction, GyroAction -from scc.constants import HapticPos, SCButtons +from scc.constants import HapticEffect, HapticPos, SCButtons from scc.modifiers import * from scc.uinput import Axes @@ -178,6 +178,33 @@ def test_feedback(self): assert a.haptic.get_period() == 2048 assert _is_axis_with_value(a.action) + def test_feedbacktone(self): + """Tests if FeedbackToneModifier is parsed correctly from json.""" + a = parser.from_json_data({"action": "axis(ABS_X)", "feedbacktone": ["RIGHT", 512, 220, 300, 4, 128]}) + assert isinstance(a, FeedbackToneModifier) + assert a.haptic.effect == HapticEffect.TONE + assert a.haptic.tone_frequency == 220 + assert a.haptic.duration == 300 + assert a.haptic.lfo_depth == 128 + assert _is_axis_with_value(a.action) + + def test_feedbacksweep(self): + """Tests if FeedbackSweepModifier is parsed correctly from json.""" + a = parser.from_json_data({"action": "axis(ABS_X)", "feedbacksweep": ["LEFT", 512, 400, 80, 250]}) + assert isinstance(a, FeedbackSweepModifier) + assert a.haptic.effect == HapticEffect.SWEEP + assert a.haptic.tone_frequency == 400 + assert a.haptic.end_frequency == 80 + assert _is_axis_with_value(a.action) + + def test_feedbackscript(self): + """Tests if FeedbackScriptModifier is parsed correctly from json.""" + a = parser.from_json_data({"action": "axis(ABS_X)", "feedbackscript": ["RIGHT", 3]}) + assert isinstance(a, FeedbackScriptModifier) + assert a.haptic.effect == HapticEffect.SCRIPT + assert a.haptic.script_id == 3 + assert _is_axis_with_value(a.action) + def test_rotate(self): """Tests if RotateInputModifier is parsed correctly from json.""" a = parser.from_json_data({"action": "axis(ABS_X)", "rotate": 33.14}) From c210e41f755e9d8f1aaa78ec121bcdde8236dcce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Mon, 10 Aug 2026 20:23:59 +0200 Subject: [PATCH 74/98] feat(gui): choose the haptic effect in the action editor Refs #7. Front end for the tone/sweep/script effects added in 7086d918. The Feedback panel gains an Effect chooser above the side selector, since it decides what the rest of the panel means, and the parameter rows change with it: tone gets frequency, duration and the two LFO controls; sweep gets start and end frequency plus duration; preset gets a script id. Strength stays throughout, and the two click-only sliders -- pad-travel frequency and click period, neither of which means anything to a synthesised effect -- are hidden unless Click is selected. The chooser is hidden entirely on controllers that cannot synthesise waveforms, rather than offering effects that would quietly degrade to a click. Currently that means it appears for the v2 only. Those rows are built in code rather than in the .glade. Six rows of label+scale that are mostly hidden at any one time is a lot of XML to keep correct by hand, and building them from a table keeps the rows, the show/hide rule and the modifier arguments in step. Each effect modifier now declares its own PARAMS, and the editor builds the modifier positionally from that. Keeping the order in the GUI instead meant two lists that had to agree, and they already did not: FeedbackScriptModifier took (position, script_id, amplitude) while the editor passed amplitude second, so choosing a preset would have written the strength into the script id. Its signature now matches its siblings, and a test asserts PARAMS against each _mod_init signature so the two cannot drift again. That test deliberately imports only the modifiers. Importing the GUI from the suite loads Gtk without a version request and breaks the version negotiation in test_setup, which is how the first version of it failed. --- docs/actions.md | 2 +- docs/profile-file.md | 4 +- scc/gui/action_editor.py | 136 +++++++++++++++++++++++++-- scc/gui/ae/__init__.py | 23 +++++ scc/modifiers.py | 12 ++- tests/test_gyro.py | 23 +++++ tests/test_parser/test_modifiers.py | 2 +- tests/test_profile/test_modifiers.py | 2 +- 8 files changed, 190 insertions(+), 14 deletions(-) diff --git a/docs/actions.md b/docs/actions.md index fa8a5a6be..1a46b6cd8 100755 --- a/docs/actions.md +++ b/docs/actions.md @@ -411,7 +411,7 @@ Like feedback(), but sweeps logarithmically from 'tone_frequency' to requirement, and the same fallback, as feedbacktone(). -#### feedbackscript(side, [script_id=0 [, amplitude=512 ]], action) +#### feedbackscript(side, [amplitude=512 [, script_id=0 ]], action) Like feedback(), but plays an effect stored in the controller's own firmware, selected by 'script_id'. Which ids exist and what each one feels like is hardware specific and not yet catalogued. Same hardware requirement, and the diff --git a/docs/profile-file.md b/docs/profile-file.md index 2b4e0bc94..d4db29b15 100644 --- a/docs/profile-file.md +++ b/docs/profile-file.md @@ -219,13 +219,13 @@ Example: #### `feedbackscript` Like `feedback`, but plays an effect stored in the controller's own firmware. -Value is `[side, script_id, amplitude]`, only the side being required. +Value is `[side, amplitude, script_id]`, only the side being required. Example: "pad_right": { "action": "trackball()", - "feedbackscript": ["RIGHT", 3] + "feedbackscript": ["RIGHT", 512, 3] }, Only hardware that can synthesise waveforms plays these three -- currently the diff --git a/scc/gui/action_editor.py b/scc/gui/action_editor.py index 749c834ba..31514ddd6 100644 --- a/scc/gui/action_editor.py +++ b/scc/gui/action_editor.py @@ -12,7 +12,7 @@ from gi.repository import GLib, Gtk from scc.actions import Action, NoAction, RingAction, TriggerAction -from scc.constants import CUT, LINEAR, MINIMUM, ROUND, TRIGGER_CLICK, HapticPos, SCButtons +from scc.constants import CUT, LINEAR, MINIMUM, ROUND, TRIGGER_CLICK, HapticEffect, HapticPos, SCButtons from scc.gui.ae import AEComponent from scc.gui.controller_widget import GYROS, PADS, PRESSABLE, STICKS, TRIGGERS from scc.gui.dwsnc import headerbar @@ -28,6 +28,9 @@ ClickModifier, DeadzoneModifier, FeedbackModifier, + FeedbackScriptModifier, + FeedbackSweepModifier, + FeedbackToneModifier, ModeModifier, NameModifier, RotateInputModifier, @@ -66,6 +69,32 @@ DEADZONE_MODES = [CUT, ROUND, LINEAR, MINIMUM] +# Feedback effects and the parameters each one uses, in display order. The +# widgets for these are built at runtime rather than in the .glade: six rows of +# label+scale that are mostly hidden at any moment is a lot of XML to maintain +# by hand, and building them from this table keeps the rows, the show/hide rule +# and the modifier arguments from drifting apart. +# +# (attribute, label, min, max, step, default) +FEEDBACK_PARAMS = { + "tone_frequency": (_("Frequency (Hz)"), 20, 1000, 1, 160), + "end_frequency": (_("End frequency (Hz)"), 20, 1000, 1, 40), + "duration": (_("Duration (ms)"), 10, 5000, 10, 200), + "lfo_frequency": (_("LFO rate (Hz)"), 0, 50, 1, 0), + "lfo_depth": (_("LFO depth"), 0, 255, 1, 0), + "script_id": (_("Preset"), 0, 255, 1, 0), +} + +# effect -> label and the modifier that carries it. Which parameters each uses +# comes from the modifier's own PARAMS, so the two cannot drift. +FEEDBACK_EFFECTS = ( + (HapticEffect.CLICK, _("Click"), FeedbackModifier), + (HapticEffect.TONE, _("Tone"), FeedbackToneModifier), + (HapticEffect.SWEEP, _("Sweep"), FeedbackSweepModifier), + (HapticEffect.SCRIPT, _("Preset"), FeedbackScriptModifier), +) + + class ActionEditor(Editor): GLADE = "action_editor.ui" ERROR_CSS = " #error {background-color:green; color:red;} " @@ -163,6 +192,7 @@ def setup_widgets(self): self.feedback[i], # default value ), ) + self._build_feedback_effects() for key in SMT: i = SMT.index(key) self.smoothing_widgets.append( @@ -653,11 +683,18 @@ def generate_modifiers(self, action, from_custom: bool = False): cbFeedback = self.builder.get_object("cbFeedback") grFeedback = self.builder.get_object("grFeedback") if from_custom or (cbFeedback.get_active() and grFeedback.get_sensitive()): - # Build FeedbackModifier arguments - feedback = [FEEDBACK_SIDES[cbFeedbackSide.get_active()]] + feedback - feedback += [action] - # Create modifier - action = FeedbackModifier(*feedback) + side = FEEDBACK_SIDES[cbFeedbackSide.get_active()] + effect = self.get_feedback_effect() + if effect == HapticEffect.CLICK: + action = FeedbackModifier(*([side] + feedback + [action])) + else: + # amplitude, then this effect's own parameters in the + # order its modifier declares them + cls = next(e[2] for e in FEEDBACK_EFFECTS if e[0] == effect) + args = [side, self.feedback[0]] + args += [int(self.feedback_effect_rows[k][1].get_value()) + for k in cls.PARAMS] + action = cls(*(args + [action])) if (cm & Action.MOD_SMOOTH) != 0: if self.smoothing != None: @@ -712,6 +749,78 @@ def strip_modifiers(action): return action return action + def _build_feedback_effects(self): + """Adds the effect chooser and its parameter rows to the feedback grid. + + Hidden entirely on controllers that can only do a click, so nobody is + offered an effect that would silently degrade on their hardware. + """ + from scc.gui.ae import has_haptic_effects + grid = self.builder.get_object("grFeedback") + self.feedback_effect_rows = {} + + self._cbFeedbackEffect = Gtk.ComboBoxText() + for trash, label, trash2 in FEEDBACK_EFFECTS: + self._cbFeedbackEffect.append_text(label) + self._cbFeedbackEffect.set_active(0) + self._cbFeedbackEffect.connect("changed", self.on_feedback_effect_changed) + self._lblFeedbackEffect = Gtk.Label(label=_("Effect"), xalign=0) + # row 1 is the side chooser; the effect belongs above it, since it + # decides what the rest of the panel even means + grid.insert_row(1) + grid.attach(self._lblFeedbackEffect, 0, 1, 1, 1) + grid.attach(self._cbFeedbackEffect, 1, 1, 1, 1) + + row = 16 # well past the glade-defined rows + for key, (label, lo, hi, step, default) in FEEDBACK_PARAMS.items(): + lbl = Gtk.Label(label=label, xalign=0) + adj = Gtk.Adjustment(value=default, lower=lo, upper=hi, + step_increment=step, page_increment=step * 10) + scl = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adj) + scl.set_digits(0) + scl.set_hexpand(True) + scl.connect("value-changed", self.on_feedback_effect_changed) + grid.attach(lbl, 0, row, 1, 1) + grid.attach(scl, 1, row, 1, 1) + self.feedback_effect_rows[key] = (lbl, scl, default) + row += 1 + + self._feedback_effects_supported = has_haptic_effects(self.app) + self._update_feedback_effect_rows() + + def get_feedback_effect(self): + """Currently chosen HapticEffect (CLICK if the chooser is not shown).""" + if not getattr(self, "_feedback_effects_supported", False): + return HapticEffect.CLICK + return FEEDBACK_EFFECTS[self._cbFeedbackEffect.get_active()][0] + + def _update_feedback_effect_rows(self): + """Shows only the rows the chosen effect actually uses.""" + supported = getattr(self, "_feedback_effects_supported", False) + self._lblFeedbackEffect.set_visible(supported) + self._cbFeedbackEffect.set_visible(supported) + + effect = self.get_feedback_effect() + used = next(e[2].PARAMS for e in FEEDBACK_EFFECTS if e[0] == effect) + for key, (lbl, scl, trash) in self.feedback_effect_rows.items(): + visible = supported and key in used + lbl.set_visible(visible) + scl.set_visible(visible) + # the click-only sliders (pad-travel frequency, click period) mean + # nothing to a synthesised effect + click_only = effect == HapticEffect.CLICK + for name in ("Frequency", "Period"): + for prefix in ("lblF", "sclF", "btClearF"): + w = self.builder.get_object(prefix + name) + if w: + w.set_visible(click_only) + + def on_feedback_effect_changed(self, *a): + if self._recursing: + return + self._update_feedback_effect_rows() + self.update_modifiers() + def load_modifiers(self, action, index: int = -1): """Parse action for modifiers and update UI accordingly. @@ -732,10 +841,21 @@ def load_modifiers(self, action, index: int = -1): self.click = True action = action.action if isinstance(action, FeedbackModifier): + # the effect subclasses are FeedbackModifiers too, so this one + # branch covers all four; only CLICK uses frequency/period self.feedback_position = action.haptic.get_position() self.feedback[0] = action.haptic.get_amplitude() - self.feedback[1] = action.haptic.get_frequency() - self.feedback[2] = action.haptic.get_period() + effect = getattr(action.haptic, "effect", HapticEffect.CLICK) + if effect == HapticEffect.CLICK: + self.feedback[1] = action.haptic.get_frequency() + self.feedback[2] = action.haptic.get_period() + else: + for i, e in enumerate(FEEDBACK_EFFECTS): + if e[0] == effect: + self._cbFeedbackEffect.set_active(i) + for key, (trash, scl, trash2) in self.feedback_effect_rows.items(): + scl.set_value(getattr(action.haptic, key, 0)) + self._update_feedback_effect_rows() action = action.action if isinstance(action, SmoothModifier): self.smoothing = (action.level, action.multiplier, action.filter) diff --git a/scc/gui/ae/__init__.py b/scc/gui/ae/__init__.py index 422b9c714..98726f60f 100644 --- a/scc/gui/ae/__init__.py +++ b/scc/gui/ae/__init__.py @@ -30,6 +30,29 @@ } +# Controllers whose actuators can synthesise waveforms, and so can play the +# tone / sweep / firmware-script haptic effects rather than only a click. +HAPTIC_EFFECT_TYPES = ("sc2",) + + +def controller_type(app): + """get_type() of the controller the GUI is currently editing, or None.""" + try: + c = app.profile_switchers[0].get_controller() if app else None + return c.get_type() if c else None + except Exception: + return None + + +def has_haptic_effects(app) -> bool: + """True if the current controller can play more than a plain click. + + Used to hide the effect chooser rather than offering options that would + silently degrade to a click on hardware that cannot synthesise them. + """ + return controller_type(app) in HAPTIC_EFFECT_TYPES + + def button_label(app, button, default): """Per-controller display name for a button: the rear paddles are L4/L5/R4/R5 on paddle controllers (sc2/deck), the default (grip) name diff --git a/scc/modifiers.py b/scc/modifiers.py index a675b0efa..0494f57bc 100644 --- a/scc/modifiers.py +++ b/scc/modifiers.py @@ -1408,6 +1408,11 @@ class _EffectFeedbackModifier(FeedbackModifier): """ EFFECT = None + # Parameters after 'amplitude', in _mod_init order. The action editor builds + # these modifiers positionally from this, so it lives here rather than in + # the GUI -- kept in two places it drifts, and the drift is silent: values + # land in the wrong parameter. + PARAMS = () def _make_haptic(self, position, amplitude, **kw): return HapticData(position, amplitude, effect=self.EFFECT, **kw) @@ -1439,6 +1444,7 @@ class FeedbackToneModifier(_EffectFeedbackModifier): COMMAND = "feedbacktone" PROFILE_KEY_PRIORITY = -4 EFFECT = HapticEffect.TONE + PARAMS = ("tone_frequency", "duration", "lfo_frequency", "lfo_depth") def _mod_init(self, position, amplitude=512, tone_frequency=160, duration=200, lfo_frequency=0, lfo_depth=0): @@ -1456,6 +1462,7 @@ class FeedbackSweepModifier(_EffectFeedbackModifier): COMMAND = "feedbacksweep" PROFILE_KEY_PRIORITY = -4 EFFECT = HapticEffect.SWEEP + PARAMS = ("tone_frequency", "end_frequency", "duration") def _mod_init(self, position, amplitude=512, tone_frequency=160, end_frequency=40, duration=200): @@ -1473,8 +1480,11 @@ class FeedbackScriptModifier(_EffectFeedbackModifier): COMMAND = "feedbackscript" PROFILE_KEY_PRIORITY = -4 EFFECT = HapticEffect.SCRIPT + PARAMS = ("script_id",) - def _mod_init(self, position, script_id=0, amplitude=512): + def _mod_init(self, position, amplitude=512, script_id=0): + # amplitude second, matching feedback() and the other two effects, so + # every one of them serialises as (side, amplitude, ...) self._attach(self._make_haptic(position, amplitude, script_id=script_id)) def __str__(self): diff --git a/tests/test_gyro.py b/tests/test_gyro.py index 8a2c5b3dd..30d7ef6db 100644 --- a/tests/test_gyro.py +++ b/tests/test_gyro.py @@ -191,3 +191,26 @@ def test_gated_absolute_gyro_survives_jitter(self): q = int(a * EUREL) action.gyro(m, rate, rate, rate, q, q, q, 0) assert peak(m, Axes.ABS_X) > 0.15 * STICK_PAD_MAX + + +class TestFeedbackEffectSignatures: + """The action editor builds each effect modifier positionally from its + PARAMS. If PARAMS and the signature drift apart the GUI silently writes + values into the wrong parameter -- which it did once, feeding amplitude + into script_id. + """ + + def test_params_match_signatures(self): + import inspect + + from scc.modifiers import ( + FeedbackScriptModifier, + FeedbackSweepModifier, + FeedbackToneModifier, + ) + + for cls in (FeedbackToneModifier, FeedbackSweepModifier, FeedbackScriptModifier): + args = inspect.getfullargspec(cls._mod_init).args + assert args == ["self", "position", "amplitude", *cls.PARAMS], ( + "%s signature %s does not match PARAMS %s" + % (cls.__name__, args, cls.PARAMS)) diff --git a/tests/test_parser/test_modifiers.py b/tests/test_parser/test_modifiers.py index 6e65ce685..9664494e6 100644 --- a/tests/test_parser/test_modifiers.py +++ b/tests/test_parser/test_modifiers.py @@ -226,7 +226,7 @@ def test_feedbacksweep(self): def test_feedbackscript(self): """Tests if FeedbackScriptModifier survives a to_string/parse round trip.""" assert _parses_as_itself(FeedbackScriptModifier(HapticPos.BOTH, MouseAction())) - assert _parses_as_itself(FeedbackScriptModifier(HapticPos.RIGHT, 3, 512, MouseAction())) + assert _parses_as_itself(FeedbackScriptModifier(HapticPos.RIGHT, 512, 3, MouseAction())) def test_rotate(self): """Tests if RotateInputModifier can be converted to string and parsed diff --git a/tests/test_profile/test_modifiers.py b/tests/test_profile/test_modifiers.py index 1b0c8d8aa..0b34d958b 100644 --- a/tests/test_profile/test_modifiers.py +++ b/tests/test_profile/test_modifiers.py @@ -199,7 +199,7 @@ def test_feedbacksweep(self): def test_feedbackscript(self): """Tests if FeedbackScriptModifier is parsed correctly from json.""" - a = parser.from_json_data({"action": "axis(ABS_X)", "feedbackscript": ["RIGHT", 3]}) + a = parser.from_json_data({"action": "axis(ABS_X)", "feedbackscript": ["RIGHT", 512, 3]}) assert isinstance(a, FeedbackScriptModifier) assert a.haptic.effect == HapticEffect.SCRIPT assert a.haptic.script_id == 3 From 19ed6a81575eaf6cf90f912a5d1c67489e8d792b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Mon, 10 Aug 2026 20:30:11 +0200 Subject: [PATCH 75/98] fix(gui): stop the action editor crashing on the effect table The effect chooser was driven by a table in action_editor.py that paired each HapticEffect with its modifier, and read .PARAMS off whichever it found. Only the three new effect modifiers had that attribute; FeedbackModifier, which carries plain Click, did not. Reading it raised during setup_widgets, so the action editor failed to construct at all -- every binding, not just haptic ones: AttributeError: type object 'FeedbackModifier' has no attribute 'PARAMS' Give Click the same declarations as the others (it uses none of the extra parameter rows, its knobs being feedback()'s own frequency and period, but it still has to say so), and move the table to modifiers.py as HAPTIC_EFFECT_MODIFIERS. The move is the actual fix. The table lived in a module the test suite cannot import -- importing the GUI loads Gtk without a version request and breaks version negotiation in test_setup -- so nothing could check it, and a missing attribute on one entry only showed up by opening the editor. In modifiers.py it is plain data: two tests now walk every entry and assert each declares EFFECT, PARAMS, LABEL and COMMAND with no duplicate effects, and that every name in PARAMS is a field HapticData actually carries. Verified that removing PARAMS again fails them. --- scc/gui/action_editor.py | 26 ++++++++------------------ scc/modifiers.py | 21 +++++++++++++++++++++ tests/test_gyro.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 18 deletions(-) diff --git a/scc/gui/action_editor.py b/scc/gui/action_editor.py index 31514ddd6..b4be48db8 100644 --- a/scc/gui/action_editor.py +++ b/scc/gui/action_editor.py @@ -27,10 +27,8 @@ BallModifier, ClickModifier, DeadzoneModifier, + HAPTIC_EFFECT_MODIFIERS, FeedbackModifier, - FeedbackScriptModifier, - FeedbackSweepModifier, - FeedbackToneModifier, ModeModifier, NameModifier, RotateInputModifier, @@ -85,14 +83,6 @@ "script_id": (_("Preset"), 0, 255, 1, 0), } -# effect -> label and the modifier that carries it. Which parameters each uses -# comes from the modifier's own PARAMS, so the two cannot drift. -FEEDBACK_EFFECTS = ( - (HapticEffect.CLICK, _("Click"), FeedbackModifier), - (HapticEffect.TONE, _("Tone"), FeedbackToneModifier), - (HapticEffect.SWEEP, _("Sweep"), FeedbackSweepModifier), - (HapticEffect.SCRIPT, _("Preset"), FeedbackScriptModifier), -) class ActionEditor(Editor): @@ -690,7 +680,7 @@ def generate_modifiers(self, action, from_custom: bool = False): else: # amplitude, then this effect's own parameters in the # order its modifier declares them - cls = next(e[2] for e in FEEDBACK_EFFECTS if e[0] == effect) + cls = next(c for c in HAPTIC_EFFECT_MODIFIERS if c.EFFECT == effect) args = [side, self.feedback[0]] args += [int(self.feedback_effect_rows[k][1].get_value()) for k in cls.PARAMS] @@ -760,8 +750,8 @@ def _build_feedback_effects(self): self.feedback_effect_rows = {} self._cbFeedbackEffect = Gtk.ComboBoxText() - for trash, label, trash2 in FEEDBACK_EFFECTS: - self._cbFeedbackEffect.append_text(label) + for cls in HAPTIC_EFFECT_MODIFIERS: + self._cbFeedbackEffect.append_text(cls.LABEL) self._cbFeedbackEffect.set_active(0) self._cbFeedbackEffect.connect("changed", self.on_feedback_effect_changed) self._lblFeedbackEffect = Gtk.Label(label=_("Effect"), xalign=0) @@ -792,7 +782,7 @@ def get_feedback_effect(self): """Currently chosen HapticEffect (CLICK if the chooser is not shown).""" if not getattr(self, "_feedback_effects_supported", False): return HapticEffect.CLICK - return FEEDBACK_EFFECTS[self._cbFeedbackEffect.get_active()][0] + return HAPTIC_EFFECT_MODIFIERS[self._cbFeedbackEffect.get_active()].EFFECT def _update_feedback_effect_rows(self): """Shows only the rows the chosen effect actually uses.""" @@ -801,7 +791,7 @@ def _update_feedback_effect_rows(self): self._cbFeedbackEffect.set_visible(supported) effect = self.get_feedback_effect() - used = next(e[2].PARAMS for e in FEEDBACK_EFFECTS if e[0] == effect) + used = next(c.PARAMS for c in HAPTIC_EFFECT_MODIFIERS if c.EFFECT == effect) for key, (lbl, scl, trash) in self.feedback_effect_rows.items(): visible = supported and key in used lbl.set_visible(visible) @@ -850,8 +840,8 @@ def load_modifiers(self, action, index: int = -1): self.feedback[1] = action.haptic.get_frequency() self.feedback[2] = action.haptic.get_period() else: - for i, e in enumerate(FEEDBACK_EFFECTS): - if e[0] == effect: + for i, c in enumerate(HAPTIC_EFFECT_MODIFIERS): + if c.EFFECT == effect: self._cbFeedbackEffect.set_active(i) for key, (trash, scl, trash2) in self.feedback_effect_rows.items(): scl.set_value(getattr(action.haptic, key, 0)) diff --git a/scc/modifiers.py b/scc/modifiers.py index 0494f57bc..9b3317bb8 100644 --- a/scc/modifiers.py +++ b/scc/modifiers.py @@ -1353,6 +1353,12 @@ class FeedbackModifier(Modifier): COMMAND = "feedback" PROFILE_KEY_PRIORITY = -4 + # See HAPTIC_EFFECT_MODIFIERS. Click uses none of the extra parameter rows + # -- its knobs are this modifier's own frequency and period -- but it still + # has to declare that, or anything iterating the effects trips over it. + EFFECT = HapticEffect.CLICK + PARAMS = () + LABEL = _("Click") def _mod_init(self, position, amplitude=512, frequency=4, period=1024, count=1): self.haptic = HapticData(position, amplitude, frequency, period, count) @@ -1445,6 +1451,7 @@ class FeedbackToneModifier(_EffectFeedbackModifier): PROFILE_KEY_PRIORITY = -4 EFFECT = HapticEffect.TONE PARAMS = ("tone_frequency", "duration", "lfo_frequency", "lfo_depth") + LABEL = _("Tone") def _mod_init(self, position, amplitude=512, tone_frequency=160, duration=200, lfo_frequency=0, lfo_depth=0): @@ -1463,6 +1470,7 @@ class FeedbackSweepModifier(_EffectFeedbackModifier): PROFILE_KEY_PRIORITY = -4 EFFECT = HapticEffect.SWEEP PARAMS = ("tone_frequency", "end_frequency", "duration") + LABEL = _("Sweep") def _mod_init(self, position, amplitude=512, tone_frequency=160, end_frequency=40, duration=200): @@ -1481,6 +1489,7 @@ class FeedbackScriptModifier(_EffectFeedbackModifier): PROFILE_KEY_PRIORITY = -4 EFFECT = HapticEffect.SCRIPT PARAMS = ("script_id",) + LABEL = _("Preset") def _mod_init(self, position, amplitude=512, script_id=0): # amplitude second, matching feedback() and the other two effects, so @@ -1752,3 +1761,15 @@ def whole(self, mapper, x, y, what) -> None: # Set axis on child action self.action.axis(mapper, angle * self.speed, 0) mapper.force_event.add(FE_PAD) + + +# Every haptic effect the action editor can offer, in display order. Kept here +# rather than in the GUI so it can be tested without importing Gtk -- when it +# lived in action_editor.py a missing attribute on one entry crashed the editor +# on construction and no test could see it. +HAPTIC_EFFECT_MODIFIERS = ( + FeedbackModifier, + FeedbackToneModifier, + FeedbackSweepModifier, + FeedbackScriptModifier, +) diff --git a/tests/test_gyro.py b/tests/test_gyro.py index 30d7ef6db..8f3a66d6c 100644 --- a/tests/test_gyro.py +++ b/tests/test_gyro.py @@ -193,6 +193,39 @@ def test_gated_absolute_gyro_survives_jitter(self): assert peak(m, Axes.ABS_X) > 0.15 * STICK_PAD_MAX +class TestFeedbackEffects: + """The action editor drives its effect chooser entirely off these classes. + A missing attribute on any one of them crashes the editor on construction, + as FeedbackModifier lacking PARAMS did -- so check every entry, not just + the ones that were interesting to write. + """ + + def test_every_effect_is_complete(self): + import scc.actions # noqa: F401 (import order: modifiers needs it first) + from scc.modifiers import HAPTIC_EFFECT_MODIFIERS + + assert HAPTIC_EFFECT_MODIFIERS + seen = set() + for cls in HAPTIC_EFFECT_MODIFIERS: + for attr in ("EFFECT", "PARAMS", "LABEL", "COMMAND"): + assert hasattr(cls, attr), "%s has no %s" % (cls.__name__, attr) + assert cls.EFFECT not in seen, "duplicate effect on %s" % (cls.__name__,) + seen.add(cls.EFFECT) + + def test_params_are_real_haptic_fields(self): + import scc.actions # noqa: F401 + from scc.constants import HapticPos + from scc.controller import HapticData + from scc.modifiers import HAPTIC_EFFECT_MODIFIERS + + h = HapticData(HapticPos.LEFT) + for cls in HAPTIC_EFFECT_MODIFIERS: + for name in cls.PARAMS: + assert hasattr(h, name), ( + "%s.PARAMS names %r, which HapticData does not carry" + % (cls.__name__, name)) + + class TestFeedbackEffectSignatures: """The action editor builds each effect modifier positionally from its PARAMS. If PARAMS and the signature drift apart the GUI silently writes From 3ead5867989ecf45b4a63dff2043da9b32107638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Mon, 10 Aug 2026 20:46:44 +0200 Subject: [PATCH 76/98] fix(gui): keep the haptic effect in the editor's tracked state Switching effects reset the parameters, and confirming the dialog came back with feedback disabled -- sometimes losing unrelated settings such as the mouse output. Preset lost them every time. update_modifiers() is what decides an action is dirty: it diffs every widget against state held on the editor and, on any difference, rebuilds by calling set_action(). The effect chooser and its parameter rows were not in that state. So changing them marked nothing dirty, and the next rebuild triggered by anything else regenerated the action from state that had never heard of them -- taking the effect, its parameters and whatever else was mid-edit. Track feedback_effect and feedback_params alongside feedback_position, diff them like everything else, and generate from the tracked values rather than reading the widgets directly. Loading an action now restores the state as well as the widgets, including resetting to Click, which a tone loaded before a click action would otherwise have kept. Adds tests/test_action_editor.py, which constructs the real editor against a stub app and exercises this. The GUI had no tests because importing it loads Gtk, and both bugs shipped this round would have been caught by opening the editor once -- so the tests skip without a display and run under xvfb-run, rather than not existing. They already earned it: they caught an off-by-one reading the parameter defaults tuple. --- scc/gui/action_editor.py | 28 +++++++++- tests/test_action_editor.py | 108 ++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 tests/test_action_editor.py diff --git a/scc/gui/action_editor.py b/scc/gui/action_editor.py index b4be48db8..852080e39 100644 --- a/scc/gui/action_editor.py +++ b/scc/gui/action_editor.py @@ -139,6 +139,8 @@ def __init__(self, app, callback) -> None: self.deadzone = [0, 0] # Deadzone slider values, set later self.deadzone_mode = None # None for 'disabled' self.feedback_position = None # None for 'disabled' + self.feedback_effect = HapticEffect.CLICK # chosen haptic effect + self.feedback_params = {} # effect parameter values by name self.smoothing = None # None for 'disabled' self.friction = -1 # -1 for 'disabled' self.click = False # Click modifier value. None for disabled @@ -613,6 +615,21 @@ def update_modifiers(self, *a): self.smoothing = smoothing set_action = True + # Feedback effect and its parameters. These have to be diffed here like + # every other modifier setting: update_modifiers is what decides the + # action is dirty, and anything it does not track is dropped the next + # time some other change rebuilds the editor from state. + if getattr(self, "_feedback_effects_supported", False): + effect = self.get_feedback_effect() + if self.feedback_effect != effect: + self.feedback_effect = effect + set_action = True + for key, (trash, scl, trash2) in self.feedback_effect_rows.items(): + value = int(scl.get_value()) + if self.feedback_params.get(key) != value: + self.feedback_params[key] = value + set_action = True + # Rest if self.click is not None and cbRequireClick.get_active() != self.click: self.click = cbRequireClick.get_active() @@ -674,7 +691,7 @@ def generate_modifiers(self, action, from_custom: bool = False): grFeedback = self.builder.get_object("grFeedback") if from_custom or (cbFeedback.get_active() and grFeedback.get_sensitive()): side = FEEDBACK_SIDES[cbFeedbackSide.get_active()] - effect = self.get_feedback_effect() + effect = self.feedback_effect if effect == HapticEffect.CLICK: action = FeedbackModifier(*([side] + feedback + [action])) else: @@ -682,7 +699,7 @@ def generate_modifiers(self, action, from_custom: bool = False): # order its modifier declares them cls = next(c for c in HAPTIC_EFFECT_MODIFIERS if c.EFFECT == effect) args = [side, self.feedback[0]] - args += [int(self.feedback_effect_rows[k][1].get_value()) + args += [self.feedback_params.get(k, FEEDBACK_PARAMS[k][4]) for k in cls.PARAMS] action = cls(*(args + [action])) @@ -773,6 +790,7 @@ def _build_feedback_effects(self): grid.attach(lbl, 0, row, 1, 1) grid.attach(scl, 1, row, 1, 1) self.feedback_effect_rows[key] = (lbl, scl, default) + self.feedback_params[key] = default row += 1 self._feedback_effects_supported = has_haptic_effects(self.app) @@ -836,7 +854,9 @@ def load_modifiers(self, action, index: int = -1): self.feedback_position = action.haptic.get_position() self.feedback[0] = action.haptic.get_amplitude() effect = getattr(action.haptic, "effect", HapticEffect.CLICK) + self.feedback_effect = effect if effect == HapticEffect.CLICK: + self._cbFeedbackEffect.set_active(0) self.feedback[1] = action.haptic.get_frequency() self.feedback[2] = action.haptic.get_period() else: @@ -844,7 +864,9 @@ def load_modifiers(self, action, index: int = -1): if c.EFFECT == effect: self._cbFeedbackEffect.set_active(i) for key, (trash, scl, trash2) in self.feedback_effect_rows.items(): - scl.set_value(getattr(action.haptic, key, 0)) + value = getattr(action.haptic, key, FEEDBACK_PARAMS[key][4]) + self.feedback_params[key] = int(value) + scl.set_value(value) self._update_feedback_effect_rows() action = action.action if isinstance(action, SmoothModifier): diff --git a/tests/test_action_editor.py b/tests/test_action_editor.py new file mode 100644 index 000000000..435f02291 --- /dev/null +++ b/tests/test_action_editor.py @@ -0,0 +1,108 @@ +"""Action editor smoke tests. + +These construct the real ActionEditor against a stub app. They exist because +the GUI is the one part of this codebase the rest of the suite cannot reach -- +importing it loads Gtk, so it was left untested, and two bugs shipped that +opening the editor would have caught immediately: a missing class attribute +that made it fail to construct at all, and effect settings being dropped +whenever anything else rebuilt the editor from its state. + +Skipped without a display. Run them under `xvfb-run -a python -m pytest` to +get the coverage on a headless machine. +""" +import os + +import pytest + +pytestmark = pytest.mark.skipif( + not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"), + reason="needs a display; try xvfb-run", +) + + +@pytest.fixture +def editor(): + import gi + + gi.require_version("Gtk", "4.0") + gi.require_version("Gdk", "4.0") + import scc.actions # noqa: F401 (import order: the GUI needs it first) + from scc.gui.action_editor import ActionEditor + from scc.paths import get_share_path + from scc.profile import Profile + + class FakeController: + def get_type(self): + return "sc2" + + class FakeSwitcher: + def get_controller(self): + return FakeController() + + class FakeApp: + gladepath = os.path.join(os.getcwd(), "ui") + imagepath = os.path.join(get_share_path(), "images") + profile_switchers = (FakeSwitcher(),) + + def __getattr__(self, name): + return lambda *a, **kw: None + + ae = ActionEditor(FakeApp(), lambda *a: None) + ae.set_input(Profile.RPAD, scc.actions.MouseAction()) + yield ae + ae.window.destroy() + + +def test_constructs(editor): + """It used to raise AttributeError during setup_widgets.""" + assert editor.window is not None + + +def test_effect_rows_follow_the_chosen_effect(editor): + from scc.modifiers import HAPTIC_EFFECT_MODIFIERS + + for i, cls in enumerate(HAPTIC_EFFECT_MODIFIERS): + editor._cbFeedbackEffect.set_active(i) + shown = [k for k, (trash, scl, trash2) in editor.feedback_effect_rows.items() if scl.get_visible()] + assert shown == list(cls.PARAMS), ( + "%s shows rows %s but declares %s" % (cls.LABEL, shown, cls.PARAMS)) + assert editor.get_feedback_effect() == cls.EFFECT + + +def test_effect_survives_a_rebuild(editor): + """update_modifiers() rebuilds the action from tracked state. Anything it + does not track is silently lost the next time some other change fires it. + """ + from scc.actions import MouseAction + from scc.constants import HapticEffect + from scc.modifiers import HAPTIC_EFFECT_MODIFIERS + + editor.builder.get_object("cbFeedback").set_active(True) + for i, cls in enumerate(HAPTIC_EFFECT_MODIFIERS): + if cls.EFFECT == HapticEffect.CLICK: + continue + editor._cbFeedbackEffect.set_active(i) + for n, key in enumerate(cls.PARAMS): + editor.feedback_effect_rows[key][1].set_value(100 + n * 37) + editor.update_modifiers() + before = editor.generate_modifiers(MouseAction()).to_string() + editor.update_modifiers() # a rebuild triggered by anything else + after = editor.generate_modifiers(MouseAction()).to_string() + assert before == after, "%s lost its settings on rebuild" % (cls.LABEL,) + assert cls.COMMAND in after, "%s did not survive as %s" % (cls.LABEL, cls.COMMAND) + + +def test_loading_an_effect_restores_the_widgets(editor): + from scc.actions import MouseAction + from scc.constants import HapticEffect, HapticPos + from scc.modifiers import FeedbackToneModifier + + editor._recursing = True + editor.load_modifiers(FeedbackToneModifier(HapticPos.RIGHT, 512, 220, 300, 4, 128, MouseAction())) + editor._recursing = False + + assert editor.get_feedback_effect() == HapticEffect.TONE + assert editor.feedback_effect == HapticEffect.TONE + assert editor.feedback_effect_rows["tone_frequency"][1].get_value() == 220 + assert editor.feedback_effect_rows["duration"][1].get_value() == 300 + assert editor.feedback_params["lfo_depth"] == 128 From 8c8b90aceacc05ca9130e919487d22b05427fe00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Mon, 10 Aug 2026 21:00:02 +0200 Subject: [PATCH 77/98] feat(gui): name the firmware haptic presets instead of a 0-255 slider The preset row was a bare 0-255 slider because I had assumed the script ids were undocumented -- and then suggested probing "0 to 5", a number I had carried over from an fftest effect list that has nothing to do with them. They are documented: 16 named presets, 0x01 to 0x10, from the same reverse engineering the rest of the haptic family came from. So the row is a dropdown of names, and the ids are recorded in the protocol doc. Names are unverified -- what each actually feels like is still to be checked on hardware -- and the list may be partial, so an id we do not know is kept and shown as "Preset N" rather than silently rewritten to something we do. Parameter rows can now be either a dropdown or a slider, for parameters that enumerate rather than scale, read and written through accessors so the rest of the editor does not care which. Also records in TODO why button presses still have no feedback option: it is not an oversight, ButtonAction simply never grew set_haptic, and adding MOD_FEEDBACK to it changes what the editor offers for buttons on every controller -- so it wants its own pass. --- TODO.md | 17 +++++++ docs/steam-controller-v2-protocol.md | 16 ++++++- scc/gui/action_editor.py | 72 +++++++++++++++++++--------- scc/modifiers.py | 25 ++++++++++ tests/test_action_editor.py | 32 +++++++++++-- 5 files changed, 135 insertions(+), 27 deletions(-) diff --git a/TODO.md b/TODO.md index 2da08f1fe..53b3c01c8 100644 --- a/TODO.md +++ b/TODO.md @@ -193,3 +193,20 @@ store. Deferred until there is a reason to want it beyond completeness. Prerequisites: the simpler effects (0x83 tone / 0x84 sweep / 0x85 script) land first, since they settle how per-effect parameters are represented in HapticData and in the GUI. + +## Haptic feedback on button presses + +feedback() is offered by ten actions -- XY, ball, circular, circularabs, dpad, +hipfire, menu, mouse, trackpad, trigger -- but NOT by button. Haptics were +conceived upstream as continuous feedback tied to movement (pad-travel ticks, +trackball detents) rather than a per-press buzz, so ButtonAction never grew +set_haptic. + +On the v2 that reads as a gap: a button press producing a tick is exactly what +the hardware is for, and it is what makes the pads feel like buttons already. + +Deferred deliberately. Adding MOD_FEEDBACK to ButtonAction changes which +modifiers the editor offers for buttons on EVERY controller, so the GUI side +is not a small change and it needs thinking about before it is worth doing -- +what a v1 user is offered, whether the feedback panel makes sense next to a +button binding, and whether it should be per-press or press-and-release. diff --git a/docs/steam-controller-v2-protocol.md b/docs/steam-controller-v2-protocol.md index b1b4cb5a7..c9bdfaaa7 100644 --- a/docs/steam-controller-v2-protocol.md +++ b/docs/steam-controller-v2-protocol.md @@ -187,7 +187,21 @@ at (`docs/HAPTICS.md`): | `0x82` | `HAPTIC_COMMAND` | `side u8, command u8 (0=stop all, 1=click, 2=strong click), gain i8 dB` | | `0x83` | `HAPTIC_LFO_TONE` | `side u8, gain i8, frequency u16 Hz, duration u16, lfo_freq u16 Hz, lfo_depth u8` | | `0x84` | `HAPTIC_LOG_SWEEP` | `side u8, gain i8, duration u16, start_freq u16, end_freq u16` | -| `0x85` | `HAPTIC_SCRIPT` | `side u8, script_id u8, gain i8` | +| `0x85` | `HAPTIC_SCRIPT` | `side u8, script_id u8, gain i8` -- ids below | + +Firmware preset ids for `0x85`, from the same RE and unverified here (the list +may be partial; nothing says `0x10` is the last): + +| id | name | id | name | +| --- | --- | --- | --- | +| `0x01` | CONTROLLER_ON | `0x09` | DOWN_SIX | +| `0x02` | CONTROLLER_VERY_ON | `0x0a` | WHOOP_UP_3 | +| `0x03` | TRILL_UP | `0x0b` | WHOOP_DOWN | +| `0x04` | TRILL_DOWN | `0x0c` | PHONE_RINGING_1 | +| `0x05` | CONTROLLER_OFF | `0x0d` | RINGBACK_TONE | +| `0x06` | UP_FIVE | `0x0e` | PHONE_RINGING_2 | +| `0x07` | DOWN_FIVE | `0x0f` | PHONE_RINGING_3 | +| `0x08` | UP_SIX | `0x10` | WILHELM_SCREAM | | `0x86`–`0x89` | audio stream | actuator PCM / u-law streaming | `0x80` is hardware-verified: `type`, `intensity` and both `gain` fields at 0, diff --git a/scc/gui/action_editor.py b/scc/gui/action_editor.py index 852080e39..4ea17b7af 100644 --- a/scc/gui/action_editor.py +++ b/scc/gui/action_editor.py @@ -28,6 +28,7 @@ ClickModifier, DeadzoneModifier, HAPTIC_EFFECT_MODIFIERS, + HAPTIC_SCRIPTS, FeedbackModifier, ModeModifier, NameModifier, @@ -73,14 +74,16 @@ # by hand, and building them from this table keeps the rows, the show/hide rule # and the modifier arguments from drifting apart. # -# (attribute, label, min, max, step, default) +# (attribute, label, min, max, step, default, choices) +# "choices" turns the row into a named dropdown instead of a slider, for +# parameters that are an enumeration rather than a magnitude. FEEDBACK_PARAMS = { - "tone_frequency": (_("Frequency (Hz)"), 20, 1000, 1, 160), - "end_frequency": (_("End frequency (Hz)"), 20, 1000, 1, 40), - "duration": (_("Duration (ms)"), 10, 5000, 10, 200), - "lfo_frequency": (_("LFO rate (Hz)"), 0, 50, 1, 0), - "lfo_depth": (_("LFO depth"), 0, 255, 1, 0), - "script_id": (_("Preset"), 0, 255, 1, 0), + "tone_frequency": (_("Frequency (Hz)"), 20, 1000, 1, 160, None), + "end_frequency": (_("End frequency (Hz)"), 20, 1000, 1, 40, None), + "duration": (_("Duration (ms)"), 10, 5000, 10, 200, None), + "lfo_frequency": (_("LFO rate (Hz)"), 0, 50, 1, 0, None), + "lfo_depth": (_("LFO depth"), 0, 255, 1, 0, None), + "script_id": (_("Preset"), 0, 255, 1, HAPTIC_SCRIPTS[0][0], HAPTIC_SCRIPTS), } @@ -624,8 +627,8 @@ def update_modifiers(self, *a): if self.feedback_effect != effect: self.feedback_effect = effect set_action = True - for key, (trash, scl, trash2) in self.feedback_effect_rows.items(): - value = int(scl.get_value()) + for key in self.feedback_effect_rows: + value = self._row_value(key) if self.feedback_params.get(key) != value: self.feedback_params[key] = value set_action = True @@ -779,23 +782,48 @@ def _build_feedback_effects(self): grid.attach(self._cbFeedbackEffect, 1, 1, 1, 1) row = 16 # well past the glade-defined rows - for key, (label, lo, hi, step, default) in FEEDBACK_PARAMS.items(): + for key, (label, lo, hi, step, default, choices) in FEEDBACK_PARAMS.items(): lbl = Gtk.Label(label=label, xalign=0) - adj = Gtk.Adjustment(value=default, lower=lo, upper=hi, - step_increment=step, page_increment=step * 10) - scl = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adj) - scl.set_digits(0) - scl.set_hexpand(True) - scl.connect("value-changed", self.on_feedback_effect_changed) + if choices: + w = Gtk.ComboBoxText() + for value, name in choices: + w.append(str(value), name) + w.set_active_id(str(default)) + w.connect("changed", self.on_feedback_effect_changed) + else: + adj = Gtk.Adjustment(value=default, lower=lo, upper=hi, + step_increment=step, page_increment=step * 10) + w = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adj) + w.set_digits(0) + w.connect("value-changed", self.on_feedback_effect_changed) + w.set_hexpand(True) grid.attach(lbl, 0, row, 1, 1) - grid.attach(scl, 1, row, 1, 1) - self.feedback_effect_rows[key] = (lbl, scl, default) + grid.attach(w, 1, row, 1, 1) + self.feedback_effect_rows[key] = (lbl, w, default) self.feedback_params[key] = default row += 1 self._feedback_effects_supported = has_haptic_effects(self.app) self._update_feedback_effect_rows() + def _row_value(self, key): + """Current value of a parameter row, whichever widget kind it uses.""" + trash, w, default = self.feedback_effect_rows[key] + if isinstance(w, Gtk.ComboBoxText): + return int(w.get_active_id()) if w.get_active_id() else default + return int(w.get_value()) + + def _set_row_value(self, key, value): + trash, w, default = self.feedback_effect_rows[key] + if isinstance(w, Gtk.ComboBoxText): + # an id the firmware has but our list does not: keep it rather than + # silently rewriting the profile to something else + if w.set_active_id(str(int(value))) is False: + w.append(str(int(value)), _("Preset %s") % (int(value),)) + w.set_active_id(str(int(value))) + else: + w.set_value(value) + def get_feedback_effect(self): """Currently chosen HapticEffect (CLICK if the chooser is not shown).""" if not getattr(self, "_feedback_effects_supported", False): @@ -810,10 +838,10 @@ def _update_feedback_effect_rows(self): effect = self.get_feedback_effect() used = next(c.PARAMS for c in HAPTIC_EFFECT_MODIFIERS if c.EFFECT == effect) - for key, (lbl, scl, trash) in self.feedback_effect_rows.items(): + for key, (lbl, w, trash) in self.feedback_effect_rows.items(): visible = supported and key in used lbl.set_visible(visible) - scl.set_visible(visible) + w.set_visible(visible) # the click-only sliders (pad-travel frequency, click period) mean # nothing to a synthesised effect click_only = effect == HapticEffect.CLICK @@ -863,10 +891,10 @@ def load_modifiers(self, action, index: int = -1): for i, c in enumerate(HAPTIC_EFFECT_MODIFIERS): if c.EFFECT == effect: self._cbFeedbackEffect.set_active(i) - for key, (trash, scl, trash2) in self.feedback_effect_rows.items(): + for key in self.feedback_effect_rows: value = getattr(action.haptic, key, FEEDBACK_PARAMS[key][4]) self.feedback_params[key] = int(value) - scl.set_value(value) + self._set_row_value(key, value) self._update_feedback_effect_rows() action = action.action if isinstance(action, SmoothModifier): diff --git a/scc/modifiers.py b/scc/modifiers.py index 9b3317bb8..f61299514 100644 --- a/scc/modifiers.py +++ b/scc/modifiers.py @@ -1773,3 +1773,28 @@ def whole(self, mapper, x, y, what) -> None: FeedbackSweepModifier, FeedbackScriptModifier, ) + + +# Preset effects stored in the Steam Controller 2's firmware, played by +# feedbackscript() / output report 0x85. Names are from iczero's reverse +# engineering (collected in CouchTurtle/sc2-research) and are NOT verified +# here -- what each actually feels like is still to be checked on hardware. +# The list may be partial: nothing states that 0x10 is the last one. +HAPTIC_SCRIPTS = ( + (0x01, "Controller on"), + (0x02, "Controller very on"), + (0x03, "Trill up"), + (0x04, "Trill down"), + (0x05, "Controller off"), + (0x06, "Up five"), + (0x07, "Down five"), + (0x08, "Up six"), + (0x09, "Down six"), + (0x0A, "Whoop up"), + (0x0B, "Whoop down"), + (0x0C, "Phone ringing 1"), + (0x0D, "Ringback tone"), + (0x0E, "Phone ringing 2"), + (0x0F, "Phone ringing 3"), + (0x10, "Wilhelm scream"), +) diff --git a/tests/test_action_editor.py b/tests/test_action_editor.py index 435f02291..e578268b0 100644 --- a/tests/test_action_editor.py +++ b/tests/test_action_editor.py @@ -63,7 +63,7 @@ def test_effect_rows_follow_the_chosen_effect(editor): for i, cls in enumerate(HAPTIC_EFFECT_MODIFIERS): editor._cbFeedbackEffect.set_active(i) - shown = [k for k, (trash, scl, trash2) in editor.feedback_effect_rows.items() if scl.get_visible()] + shown = [k for k, (trash, w, trash2) in editor.feedback_effect_rows.items() if w.get_visible()] assert shown == list(cls.PARAMS), ( "%s shows rows %s but declares %s" % (cls.LABEL, shown, cls.PARAMS)) assert editor.get_feedback_effect() == cls.EFFECT @@ -83,7 +83,8 @@ def test_effect_survives_a_rebuild(editor): continue editor._cbFeedbackEffect.set_active(i) for n, key in enumerate(cls.PARAMS): - editor.feedback_effect_rows[key][1].set_value(100 + n * 37) + # via the accessor: some rows are dropdowns, not scales + editor._set_row_value(key, 100 + n * 37) editor.update_modifiers() before = editor.generate_modifiers(MouseAction()).to_string() editor.update_modifiers() # a rebuild triggered by anything else @@ -103,6 +104,29 @@ def test_loading_an_effect_restores_the_widgets(editor): assert editor.get_feedback_effect() == HapticEffect.TONE assert editor.feedback_effect == HapticEffect.TONE - assert editor.feedback_effect_rows["tone_frequency"][1].get_value() == 220 - assert editor.feedback_effect_rows["duration"][1].get_value() == 300 + assert editor._row_value("tone_frequency") == 220 + assert editor._row_value("duration") == 300 assert editor.feedback_params["lfo_depth"] == 128 + + +def test_preset_row_is_a_named_dropdown(editor): + """The firmware presets are an enumeration, not a magnitude, so they get + names rather than a 0-255 slider. + """ + import gi + + gi.require_version("Gtk", "4.0") + from gi.repository import Gtk + + from scc.modifiers import HAPTIC_SCRIPTS + + trash, widget, trash2 = editor.feedback_effect_rows["script_id"] + assert isinstance(widget, Gtk.ComboBoxText) + for value, name in HAPTIC_SCRIPTS: + editor._set_row_value("script_id", value) + assert editor._row_value("script_id") == value + + # an id the firmware may have that our list does not must survive, not be + # silently rewritten to something else + editor._set_row_value("script_id", 0x42) + assert editor._row_value("script_id") == 0x42 From bc6f7a62ee44a462b6620a3ff35cae76fdadf839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Tue, 11 Aug 2026 06:11:18 +0200 Subject: [PATCH 78/98] fix(gui): load the haptic effect widgets after parsing, not during Opening a saved binding brought up an editor with no action in it: output "None", feedback switched off, side back to left, and only the effect itself preserved. load_modifiers() has two halves. The first parses the modifier chain into editor state; the second writes that state into the widgets, and it is guarded by _recursing because every widget write fires a 'changed' handler that calls update_modifiers(). The effect chooser and its parameter rows were being written in the FIRST half, unguarded, so: - the combo emits 'changed' -> update_modifiers() runs - it reads cbFeedback, still unchecked because that widget is not written until much further down, and concludes feedback is off - it marks the action dirty and calls set_action(self._action), which on a freshly opened editor is None The editor then loads None and shows the first page, and the action being edited never arrives. Which half of the panel survived told the story: the effect was kept because it was the one thing already written. Fixed by moving those widget writes down into the guarded block with every other one. The parse loop now only records state. Covered by test_opening_the_editor_keeps_the_feedback, which goes through set_input() -- the entry point the dialog actually uses. test_loading_an_effect_restores_the_widgets went the same way; it had been setting _recursing by hand, and that fake is exactly what hid this. Refs Patola/sc-controller-cc#7 Co-Authored-By: Claude --- scc/gui/action_editor.py | 16 ++++++++++------ tests/test_action_editor.py | 29 ++++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/scc/gui/action_editor.py b/scc/gui/action_editor.py index 4ea17b7af..1d191a887 100644 --- a/scc/gui/action_editor.py +++ b/scc/gui/action_editor.py @@ -884,18 +884,12 @@ def load_modifiers(self, action, index: int = -1): effect = getattr(action.haptic, "effect", HapticEffect.CLICK) self.feedback_effect = effect if effect == HapticEffect.CLICK: - self._cbFeedbackEffect.set_active(0) self.feedback[1] = action.haptic.get_frequency() self.feedback[2] = action.haptic.get_period() else: - for i, c in enumerate(HAPTIC_EFFECT_MODIFIERS): - if c.EFFECT == effect: - self._cbFeedbackEffect.set_active(i) for key in self.feedback_effect_rows: value = getattr(action.haptic, key, FEEDBACK_PARAMS[key][4]) self.feedback_params[key] = int(value) - self._set_row_value(key, value) - self._update_feedback_effect_rows() action = action.action if isinstance(action, SmoothModifier): self.smoothing = (action.level, action.multiplier, action.filter) @@ -932,6 +926,16 @@ def load_modifiers(self, action, index: int = -1): cbFeedback.set_active(True) for i in range(len(self.feedback)): self.feedback_widgets[i][0].set_value(self.feedback[i]) + # The effect chooser and its rows are widgets like any other here: their + # 'changed' handlers call update_modifiers, so writing them any earlier -- + # while the loop above is still parsing -- rebuilds the action from a + # half-loaded editor and discards everything not applied yet. + for i, c in enumerate(HAPTIC_EFFECT_MODIFIERS): + if c.EFFECT == self.feedback_effect: + self._cbFeedbackEffect.set_active(i) + for key in self.feedback_effect_rows: + self._set_row_value(key, self.feedback_params[key]) + self._update_feedback_effect_rows() for grp in self.feedback_widgets: for w in grp[0:-1]: w.set_sensitive(self.feedback_position is not None) diff --git a/tests/test_action_editor.py b/tests/test_action_editor.py index e578268b0..f064a0294 100644 --- a/tests/test_action_editor.py +++ b/tests/test_action_editor.py @@ -93,14 +93,37 @@ def test_effect_survives_a_rebuild(editor): assert cls.COMMAND in after, "%s did not survive as %s" % (cls.LABEL, cls.COMMAND) +def test_opening_the_editor_keeps_the_feedback(editor): + """Open a saved binding the way the GUI does, and get back what was saved. + + set_input() is the real entry point, and it is the one that broke: writing + the effect widgets while load_modifiers was still parsing fired their + 'changed' handlers, and update_modifiers rebuilt the action from an editor + that had not applied the feedback yet -- so the dialog came up with no + action at all and feedback switched off. + """ + from scc.actions import MouseAction + from scc.constants import HapticPos + from scc.modifiers import FeedbackToneModifier + from scc.profile import Profile + + action = FeedbackToneModifier(HapticPos.RIGHT, 512, 220, 300, 4, 128, MouseAction()) + editor.set_input(Profile.RPAD, action) + + assert editor.builder.get_object("entAction").get_text() == action.to_string() + assert editor.builder.get_object("cbFeedback").get_active() + assert editor.feedback_position == HapticPos.RIGHT + assert editor.generate_modifiers(editor._action).to_string() == action.to_string() + + def test_loading_an_effect_restores_the_widgets(editor): from scc.actions import MouseAction from scc.constants import HapticEffect, HapticPos from scc.modifiers import FeedbackToneModifier + from scc.profile import Profile - editor._recursing = True - editor.load_modifiers(FeedbackToneModifier(HapticPos.RIGHT, 512, 220, 300, 4, 128, MouseAction())) - editor._recursing = False + editor.set_input(Profile.RPAD, + FeedbackToneModifier(HapticPos.RIGHT, 512, 220, 300, 4, 128, MouseAction())) assert editor.get_feedback_effect() == HapticEffect.TONE assert editor.feedback_effect == HapticEffect.TONE From 3aa82b6038f109cfb8e695f77c1484171b7382a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Tue, 11 Aug 2026 06:11:41 +0200 Subject: [PATCH 79/98] feat(gui): pair each haptic parameter slider with a spin button A slider spanning 20-1000 Hz in about 200 pixels is roughly 5 Hz per pixel, so there was no way to ask for 220 Hz specifically -- and the arrow keys moved in jumps of 10, because the per-parameter step was being used as the adjustment's step_increment. Each numeric row is now a slider and a spin button sharing one Gtk.Adjustment: drag to explore the range by feel, type or click the spin arrows to land on a value exactly. Sharing the adjustment is what keeps them honest -- there is no synchronising code that could get it wrong, and the 'value-changed' handler hangs off the adjustment rather than either widget. The slider no longer draws its own number, since the spin button is already showing it. Arrow keys and the spin button now always step by 1. The table's step became the page increment instead, for Page Up/Down and trough clicks: 10 Hz for the frequencies, 50 ms for duration. Preset is untouched, it is a named dropdown. Wrapping each row in a Box made it reachable by show_all(), which would have unhidden every parameter row at once, so hidden rows are marked no-show-all. The new assertion in test_effect_rows_follow_the_chosen_effect fails without it. Refs Patola/sc-controller-cc#7 Co-Authored-By: Claude --- scc/gui/action_editor.py | 47 ++++++++++++++++++++++++------------- tests/test_action_editor.py | 43 +++++++++++++++++++++++++++++++-- 2 files changed, 72 insertions(+), 18 deletions(-) diff --git a/scc/gui/action_editor.py b/scc/gui/action_editor.py index 1d191a887..111398db8 100644 --- a/scc/gui/action_editor.py +++ b/scc/gui/action_editor.py @@ -74,15 +74,18 @@ # by hand, and building them from this table keeps the rows, the show/hide rule # and the modifier arguments from drifting apart. # -# (attribute, label, min, max, step, default, choices) +# (attribute, label, min, max, page step, default, choices) # "choices" turns the row into a named dropdown instead of a slider, for # parameters that are an enumeration rather than a magnitude. +# The step is how far Page Up/Down and a click on the trough move; the arrow +# keys and the spin button always move by one, because these are values you +# know rather than dial by feel -- 220 Hz, 300 ms. FEEDBACK_PARAMS = { - "tone_frequency": (_("Frequency (Hz)"), 20, 1000, 1, 160, None), - "end_frequency": (_("End frequency (Hz)"), 20, 1000, 1, 40, None), - "duration": (_("Duration (ms)"), 10, 5000, 10, 200, None), - "lfo_frequency": (_("LFO rate (Hz)"), 0, 50, 1, 0, None), - "lfo_depth": (_("LFO depth"), 0, 255, 1, 0, None), + "tone_frequency": (_("Frequency (Hz)"), 20, 1000, 10, 160, None), + "end_frequency": (_("End frequency (Hz)"), 20, 1000, 10, 40, None), + "duration": (_("Duration (ms)"), 10, 5000, 50, 200, None), + "lfo_frequency": (_("LFO rate (Hz)"), 0, 50, 5, 0, None), + "lfo_depth": (_("LFO depth"), 0, 255, 10, 0, None), "script_id": (_("Preset"), 0, 255, 1, HAPTIC_SCRIPTS[0][0], HAPTIC_SCRIPTS), } @@ -790,16 +793,28 @@ def _build_feedback_effects(self): w.append(str(value), name) w.set_active_id(str(default)) w.connect("changed", self.on_feedback_effect_changed) + w.set_hexpand(True) + field = w else: + # Slider to explore the range by feel, spin button beside it to + # land on a value exactly. They share one adjustment, so neither + # can disagree with the other, and the signal is connected there + # rather than to either widget. adj = Gtk.Adjustment(value=default, lower=lo, upper=hi, - step_increment=step, page_increment=step * 10) + step_increment=1, page_increment=step) + adj.connect("value-changed", self.on_feedback_effect_changed) w = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adj) w.set_digits(0) - w.connect("value-changed", self.on_feedback_effect_changed) - w.set_hexpand(True) + w.set_draw_value(False) # the spin button is showing the number + w.set_hexpand(True) + spin = Gtk.SpinButton(adjustment=adj, climb_rate=1, digits=0) + spin.set_numeric(True) + field = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + field.append(w) + field.append(spin) grid.attach(lbl, 0, row, 1, 1) - grid.attach(w, 1, row, 1, 1) - self.feedback_effect_rows[key] = (lbl, w, default) + grid.attach(field, 1, row, 1, 1) + self.feedback_effect_rows[key] = (lbl, w, field, default) self.feedback_params[key] = default row += 1 @@ -808,13 +823,13 @@ def _build_feedback_effects(self): def _row_value(self, key): """Current value of a parameter row, whichever widget kind it uses.""" - trash, w, default = self.feedback_effect_rows[key] + trash, w, trash2, default = self.feedback_effect_rows[key] if isinstance(w, Gtk.ComboBoxText): return int(w.get_active_id()) if w.get_active_id() else default return int(w.get_value()) def _set_row_value(self, key, value): - trash, w, default = self.feedback_effect_rows[key] + trash, w, trash2, default = self.feedback_effect_rows[key] if isinstance(w, Gtk.ComboBoxText): # an id the firmware has but our list does not: keep it rather than # silently rewriting the profile to something else @@ -838,10 +853,10 @@ def _update_feedback_effect_rows(self): effect = self.get_feedback_effect() used = next(c.PARAMS for c in HAPTIC_EFFECT_MODIFIERS if c.EFFECT == effect) - for key, (lbl, w, trash) in self.feedback_effect_rows.items(): + for key, (lbl, trash, field, trash2) in self.feedback_effect_rows.items(): visible = supported and key in used - lbl.set_visible(visible) - w.set_visible(visible) + for widget in (lbl, field): + widget.set_visible(visible) # the click-only sliders (pad-travel frequency, click period) mean # nothing to a synthesised effect click_only = effect == HapticEffect.CLICK diff --git a/tests/test_action_editor.py b/tests/test_action_editor.py index f064a0294..561d2597d 100644 --- a/tests/test_action_editor.py +++ b/tests/test_action_editor.py @@ -63,11 +63,19 @@ def test_effect_rows_follow_the_chosen_effect(editor): for i, cls in enumerate(HAPTIC_EFFECT_MODIFIERS): editor._cbFeedbackEffect.set_active(i) - shown = [k for k, (trash, w, trash2) in editor.feedback_effect_rows.items() if w.get_visible()] + shown = [k for k, (trash, trash2, field, trash3) + in editor.feedback_effect_rows.items() if field.get_visible()] assert shown == list(cls.PARAMS), ( "%s shows rows %s but declares %s" % (cls.LABEL, shown, cls.PARAMS)) assert editor.get_feedback_effect() == cls.EFFECT + # Presenting the GTK4 window must preserve each row's visibility. + editor.window.present() + still_shown = [k for k, (trash, trash2, field, trash3) + in editor.feedback_effect_rows.items() if field.get_visible()] + assert still_shown == list(cls.PARAMS), ( + "%s revived rows %s after present" % (cls.LABEL, still_shown)) + def test_effect_survives_a_rebuild(editor): """update_modifiers() rebuilds the action from tracked state. Anything it @@ -143,7 +151,7 @@ def test_preset_row_is_a_named_dropdown(editor): from scc.modifiers import HAPTIC_SCRIPTS - trash, widget, trash2 = editor.feedback_effect_rows["script_id"] + trash, widget, trash2, trash3 = editor.feedback_effect_rows["script_id"] assert isinstance(widget, Gtk.ComboBoxText) for value, name in HAPTIC_SCRIPTS: editor._set_row_value("script_id", value) @@ -153,3 +161,34 @@ def test_preset_row_is_a_named_dropdown(editor): # silently rewritten to something else editor._set_row_value("script_id", 0x42) assert editor._row_value("script_id") == 0x42 + + +def test_numeric_rows_pair_a_slider_with_a_spin_button(editor): + """A slider alone cannot hit 220 Hz on a 20-1000 range: that is ~5 Hz per + pixel. The spin button is how you land on a value exactly, and it shares the + slider's adjustment so the two can never disagree. + """ + import gi + + gi.require_version("Gtk", "4.0") + from gi.repository import Gtk + + trash, scale, field, trash2 = editor.feedback_effect_rows["tone_frequency"] + spins = [] + child = field.get_first_child() + while child is not None: + if isinstance(child, Gtk.SpinButton): + spins.append(child) + child = child.get_next_sibling() + assert len(spins) == 1 + assert spins[0].get_adjustment() is scale.get_adjustment() + + spins[0].set_value(223) + assert editor._row_value("tone_frequency") == 223 + editor._set_row_value("tone_frequency", 447) + assert spins[0].get_value_as_int() == 447 + + # arrow keys and the spin button step by one; only Page Up/Down jumps + adj = scale.get_adjustment() + assert adj.get_step_increment() == 1 + assert adj.get_page_increment() > 1 From 6da376de7bf77a462e763cfe457436ffa24e75b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Tue, 11 Aug 2026 06:11:51 +0200 Subject: [PATCH 80/98] docs(sc2): record what the haptic effects and presets do on hardware Reports 0x83, 0x84 and 0x85 are now verified live, not just read out of the SDL3 source and iczero's RE: - a sweep is clearly distinguishable from a flat tone, so start_freq and end_freq do what they claim - the LFO fields work but are weak. At lfo_freq 4 / lfo_depth 128 the modulation is barely perceptible; only at 10 / 255 is it obvious, so depth is probably not a percentage - the preset ids are real and their RE'd names broadly describe what you feel. CONTROLLER_VERY_ON, CONTROLLER_OFF and PHONE_RINGING_1 are strong and unmistakable; several others are subtle - WILHELM_SCREAM is barely perceptible even at maximum gain. It is a long sample, so this is most likely the preset itself rather than anything we send -- worth knowing before someone goes hunting for a bug in the gain path Nothing was found past 0x10, but nothing rules it out either; the list is still recorded as possibly partial. Refs Patola/sc-controller-cc#7 Co-Authored-By: Claude --- docs/steam-controller-v2-protocol.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/steam-controller-v2-protocol.md b/docs/steam-controller-v2-protocol.md index c9bdfaaa7..f98afead5 100644 --- a/docs/steam-controller-v2-protocol.md +++ b/docs/steam-controller-v2-protocol.md @@ -185,12 +185,16 @@ at (`docs/HAPTICS.md`): | `0x80` | `HAPTIC_RUMBLE` | `type u8, intensity u16, left{speed u16, gain i8}, right{speed u16, gain i8}` — **verified live** | | `0x81` | `HAPTIC_PULSE` | `side u8, on_us u16, off_us u16, repeat u16` | | `0x82` | `HAPTIC_COMMAND` | `side u8, command u8 (0=stop all, 1=click, 2=strong click), gain i8 dB` | -| `0x83` | `HAPTIC_LFO_TONE` | `side u8, gain i8, frequency u16 Hz, duration u16, lfo_freq u16 Hz, lfo_depth u8` | -| `0x84` | `HAPTIC_LOG_SWEEP` | `side u8, gain i8, duration u16, start_freq u16, end_freq u16` | -| `0x85` | `HAPTIC_SCRIPT` | `side u8, script_id u8, gain i8` -- ids below | +| `0x83` | `HAPTIC_LFO_TONE` | `side u8, gain i8, frequency u16 Hz, duration u16, lfo_freq u16 Hz, lfo_depth u8` — **verified live** | +| `0x84` | `HAPTIC_LOG_SWEEP` | `side u8, gain i8, duration u16, start_freq u16, end_freq u16` — **verified live** | +| `0x85` | `HAPTIC_SCRIPT` | `side u8, script_id u8, gain i8` — **verified live**, ids below | -Firmware preset ids for `0x85`, from the same RE and unverified here (the list -may be partial; nothing says `0x10` is the last): +Firmware preset ids for `0x85`, names from the same RE (the list may be partial; +nothing says `0x10` is the last). Played on hardware: the ids are real and the +names broadly describe what you feel. CONTROLLER_VERY_ON, CONTROLLER_OFF and +PHONE_RINGING_1 are strong and unmistakable; several others are subtle. +WILHELM_SCREAM is barely perceptible even at maximum gain -- it is a long +sample, so this is most likely the preset itself rather than anything we send: | id | name | id | name | | --- | --- | --- | --- | @@ -204,6 +208,11 @@ may be partial; nothing says `0x10` is the last): | `0x08` | UP_SIX | `0x10` | WILHELM_SCREAM | | `0x86`–`0x89` | audio stream | actuator PCM / u-law streaming | +`0x83` and `0x84` are hardware-verified too. A sweep is clearly distinguishable +from a flat tone, so `start_freq`/`end_freq` do what they say. The LFO fields +work but are weak: at `lfo_freq` 4 / `lfo_depth` 128 the modulation is barely +there, and only at 10 / 255 is it obvious. Depth may not be a percentage. + `0x80` is hardware-verified: `type`, `intensity` and both `gain` fields at 0, the two `speed` fields driving their own actuator independently. Confirmed with `fftest` on the emulated pad, whose "strong rumble (heavy motor)" and "weak From c8616f561932a54688a1a8ed418aac0edaabd7a7 Mon Sep 17 00:00:00 2001 From: Martin Rys Date: Mon, 17 Aug 2026 21:56:16 +0200 Subject: [PATCH 81/98] fix(modifiers): do not lose sensitivity when a ball steps aside With Trackball Mode on, the sensitivity sliders did nothing at all on a DS4 right stick. A ball on a self-centering stick is meaningless -- it works on position deltas, so holding the stick still stops the pointer and releasing it drags the pointer back -- so BallModifier.whole passes RIGHT straight through when that slot carries stick data. But SensitivityModifier walks down to the first action with set_speed() and stops there, which on a trackball binding is the ball, and the ball applies its speed only in _add and _roll: both on the path it just skipped. The speed landed on a modifier that had already stepped aside, and the child stayed at 1.0. The ball now hands its speed to the child before bypassing, multiplied into whatever the child already has (so it is right whichever side of the ball the sens() ended up on) and once only, since set_speed is absolute. It must not happen when the ball is really running, or the speed would be applied twice; that is asserted. Also: BallModifier.compress overrode Modifier.compress and dropped its child-recursion, so a sens() written INSIDE a ball survived as a live SensitivityModifier -- which has no whole(), so the binding did nothing whatsoever. The GUI always writes sens() outside, so only hand-written profiles could hit it. Restored, keeping the ball(circular(...)) turn-around it overrode compress() for in the first place. NOTE: the compress change affects every controller, not just the DS4. Refs Patola/sc-controller-cc#5 Co-Authored-By: Claude --- scc/modifiers.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/scc/modifiers.py b/scc/modifiers.py index f61299514..a94c72439 100644 --- a/scc/modifiers.py +++ b/scc/modifiers.py @@ -408,6 +408,7 @@ def _mod_init( self, friction=DEFAULT_FRICTION, mass=80.0, mean_len=DEFAULT_MEAN_LEN, r=0.02, ampli=65536, degree=40.0, ): self.speed = (1.0, 1.0) + self._speed_handed_over = False self.friction = friction self._xvel = 0.0 self._yvel = 0.0 @@ -436,6 +437,33 @@ def set_speed(self, x, y, *a) -> None: def get_speed(self): return self.speed + def _step_aside(self, mapper): + """Hand this modifier's sensitivity to the child before bypassing. + + SensitivityModifier walks down to the first action with set_speed() and + stops there, so on a trackball binding the speed lands on the ball -- + which then applies it itself, in _add and _roll. Both are skipped when + the ball steps aside for a stick, so without this the sensitivity + sliders do nothing at all: the child is still at 1.0. + + Multiplied into whatever the child already has, so it is right whichever + side of the ball the sens() ended up on, and guarded so it happens once, + because set_speed is absolute rather than cumulative. + """ + if self._speed_handed_over: + return + self._speed_handed_over = True + if self.speed == (1.0, 1.0): + return + a = self.action + while a is not None: + if hasattr(a, "set_speed"): + own = a.get_speed() if hasattr(a, "get_speed") else (1.0, 1.0) + a.set_speed(own[0] * self.speed[0], own[1] * self.speed[1], + own[2] if len(own) > 2 else 1.0) + return + a = getattr(a, "action", None) + def get_compatible_modifiers(self): return ( Action.MOD_SENSITIVITY @@ -543,6 +571,7 @@ def pad(self, mapper, position, what): def change(self, mapper, dx, dy, what): if what in (None, LSTICK, RSTICK): + self._step_aside(mapper) return self.action.change(mapper, dx, dy, what) if mapper.is_touched(what): if mapper.was_touched(what): @@ -562,6 +591,7 @@ def change(self, mapper, dx, dy, what): def whole(self, mapper, x, y, what): if what == RSTICK: + self._step_aside(mapper) return self.action.whole(mapper, x, y, what) if mapper.is_touched(what): if mapper.is_touched(what) and not mapper.was_touched(what): @@ -600,6 +630,13 @@ def get_haptic(self): return WholeHapticAction.get_haptic(self) def compress(self) -> CircularModifier | Self: + # Modifier.compress does this for every other modifier; overriding it + # here dropped it, so a sens() written INSIDE a ball survived as a live + # SensitivityModifier -- which has no whole(), so the binding did + # nothing at all. The GUI always writes sens() outside, so this only + # ever bit hand-written profiles. + if self.action: + self.action = self.action.compress() # ball(circular(...) has to be turned around if isinstance(self.action, CircularModifier): cm = self.action From ecd34f352c1203ab0a5684bd207f7fb62495a608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Tue, 11 Aug 2026 18:53:50 +0200 Subject: [PATCH 82/98] fix(gui): let the haptic sliders reach their stated maximum The Strength slider stopped at 32639 instead of 32767, which reads as an off-by-something in the code rather than as a limit -- and it silently capped the range every haptic strength measurement was taken over. A GtkAdjustment's usable maximum is upper - page_size, and page_size means nothing for a slider. adjFAmplitude and adjFPeriod both carried 128. Guarded by pinning the amplitude slider's range to the driver's own HAPTIC_AMPLITUDE_MIN/MAX rather than banning the property outright: some adjustments use it deliberately (global_settings sets upper 10.01 with page-size 0.01 to land on a round 10.00). Co-Authored-By: Claude --- tests/test_ui.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_ui.py b/tests/test_ui.py index e9ec2a6c8..c13872dab 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -66,3 +66,27 @@ def test_every_toplevel_object_has_id(self) -> None: # assert result.returncode == 0 and not diagnostics, ( # f"GTK diagnostics failed for {filename}\n{diagnostics}" # ) + +def _adjustment(filename, id_): + """Returns a GtkAdjustment's properties as floats.""" + root = ET.parse(filename).getroot() + for obj in root.iter("object"): + if obj.get("class") == "GtkAdjustment" and obj.get("id") == id_: + return {p.get("name"): float(p.text) for p in obj.iter("property")} + raise AssertionError("no adjustment %s in %s" % (id_, filename)) + + +def test_haptic_strength_slider_reaches_the_amplitude_the_driver_accepts(): + """A GtkAdjustment's usable maximum is upper - page_size, and page_size means + nothing for a slider. Left at 128 it stopped the haptic Strength slider at + 32639, which reads to a user as an off-by-something rather than as a limit. + + Some adjustments do use page-size deliberately (global_settings sets + upper 10.01 / page-size 0.01 to land on a round 10.00), so this pins the one + value that has to agree with the driver rather than banning the property. + """ + from scc.drivers.sc2 import HAPTIC_AMPLITUDE_MAX, HAPTIC_AMPLITUDE_MIN + + adj = _adjustment("ui/action_editor.ui", "adjFAmplitude") + assert adj["lower"] == HAPTIC_AMPLITUDE_MIN + assert adj["upper"] - adj.get("page-size", 0) == HAPTIC_AMPLITUDE_MAX From d6a43cf00dc97cda9d6fc255ebe41879eb559798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Tue, 11 Aug 2026 18:53:50 +0200 Subject: [PATCH 83/98] docs: Steam co-drives PlayStation pads over Bluetooth; DS4 haptics TODO README: a DS4/DS5 cannot be claimed exclusively over Bluetooth. A Steam Controller is taken by claiming its USB interface, which detaches the kernel driver; a PlayStation pad over Bluetooth is reached through /dev/hidrawN, and hidraw allows several readers at once. With Steam running, Steam and SC Controller both receive every report and both act on it, and no code of ours can prevent that. Written symptoms-first, because the failure does not look like a conflict: the touchpad glides with an acceleration nobody configured, its click gives a mouse button whatever you bind, and yet your other bindings work fine -- so it reads as half-broken configuration rather than as a second program driving the pad. That misreading cost three rounds of debugging here before someone noticed Steam holding the hidraw node. TODO: haptic feedback on the DS4 is not implemented at all -- all three DS4 controller classes inherit a no-op feedback(), and EvdevController's is an empty TODO docstring. ds5drv.feedback is close to a template, with two things not to copy blindly, and Controller.rumble() is unimplemented for both pads, which is why fftest's heavy and light effects feel identical on them. Co-Authored-By: Claude --- README.md | 25 +++++++++++++++++++++++++ TODO.md | 30 ++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/README.md b/README.md index 8b5254fa5..e81b9c7b0 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,31 @@ Turn this **on** when you regularly use more than one controller — especially two of the same model, such as two Steam Controllers — and want each to reliably keep its own profile. +### PlayStation controllers: turn off Steam's controller support + +A DualShock 4 or DualSense **cannot be claimed exclusively over Bluetooth**, and +this is worth knowing before you conclude something is broken. + +Steam Controllers are taken exclusively: SC Controller claims the USB interface, +which detaches the kernel driver, and nothing else can reach the device. Over +Bluetooth a DS4/DS5 is reached through `/dev/hidrawN` instead — and hidraw +allows **several readers at once**. If Steam is running with PlayStation +controller support enabled, Steam and SC Controller both receive every report +and both act on it. There is no way for us to lock Steam out. + +What that looks like in practice, and it does not look like a conflict: + +- the touchpad moves the pointer with an acceleration and glide you never + configured, whatever the pad is actually bound to +- clicking the touchpad produces a mouse button instead of your binding, and + rebinding it changes nothing +- your own bindings *do* work — so the controller seems half-configured rather + than contested + +Fix it in **Steam → Settings → Controller** by turning off *PlayStation +Controller Support*. Connecting the pad **by USB** also works, for a different +reason: claiming the USB interface takes the device away from Steam too. + ## Like what I'm doing? You can check out the ways to donate on [my website](https://rys.rs/donate), or just go straight to my [Ko-Fi](https://ko-fi.com/martinrys). diff --git a/TODO.md b/TODO.md index 53b3c01c8..d63322d42 100644 --- a/TODO.md +++ b/TODO.md @@ -210,3 +210,33 @@ modifiers the editor offers for buttons on EVERY controller, so the GUI side is not a small change and it needs thinking about before it is worth doing -- what a v1 user is offered, whether the feedback panel makes sense next to a button binding, and whether it should be per-press or press-and-release. + +## Haptic feedback on the DS4 + +The DualShock 4 has rumble motors and the daemon never uses them. No DS4 code +path implements haptics at all: `DS4Controller` and `DS4HidRawController` +inherit the no-op `Controller.feedback()`, and `DS4EvdevController` inherits +`EvdevController.feedback`, whose entire body is a TODO docstring. So a profile +with Feedback Enabled on a DS4 is silently doing nothing -- the GUI offers the +setting, the modifier is stored, and the effect is dropped at the driver. + +This is a gap rather than a regression; it has never worked. + +The DS5 already does it (`ds5drv.feedback`), and the DS4's output report is the +same shape -- a small motor pair, `motor_left` heavy / `motor_right` light, with +a scheduled clear afterwards because the motors misbehave when shut off under +50 ms. That method is close to a template. + +Two things to settle rather than copy blindly: + + - The DS5 halves the left motor's amplitude ("the left motor is heavier, so + we must give it less oomph"). Whether the DS4's motors need the same + correction is a hardware question, not something to assume. + - The DS4 has three driver classes (HID, hidraw, evdev) and which one a pad + gets depends on how it is connected, so the fix has to land somewhere all + three reach, or be written three times. + +Also worth doing at the same time: `Controller.rumble()` is still unimplemented +for the DS4 and DS5, so FF_RUMBLE's two magnitudes get averaged into one level +before they arrive. The heavy and light effects consequently feel identical on +both pads, where the hardware could tell them apart. From 3b9b887cd42a3a5ac7b2165981ceef9093fcb34a Mon Sep 17 00:00:00 2001 From: Sergio Correia Date: Wed, 12 Aug 2026 10:58:09 -0300 Subject: [PATCH 84/98] daemon: set restrictive umask to fix socket and file permissions The daemon was calling os.umask(0) after daemonizing, which left all subsequently created files (including the IPC socket) world-accessible. Change to os.umask(0o077) so files are owner-only by default. For the IPC socket, call os.chmod() to enforce 0600 immediately after bind() and before serve_forever() starts accepting connections, ensuring there is no window where the socket is accessible to other users. Assisted-by: Claude Opus 4.6 Signed-off-by: Sergio Correia --- scc/lib/daemon.py | 2 +- scc/sccdaemon.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/scc/lib/daemon.py b/scc/lib/daemon.py index ad9df9534..e5e62aca4 100644 --- a/scc/lib/daemon.py +++ b/scc/lib/daemon.py @@ -38,7 +38,7 @@ def daemonize(self) -> None: # decouple from parent environment os.chdir("/") os.setsid() - os.umask(0) + os.umask(0o077) # do second fork try: diff --git a/scc/sccdaemon.py b/scc/sccdaemon.py index 7479ab264..42d5fec73 100644 --- a/scc/sccdaemon.py +++ b/scc/sccdaemon.py @@ -787,11 +787,15 @@ class SSHandler(StreamRequestHandler): def handle(self): instance._sshandler(self.connection, self.rfile, self.wfile) - self.sserver = ThreadingUnixStreamServer(self.socket_file, SSHandler) + old_umask = os.umask(0o077) + try: + self.sserver = ThreadingUnixStreamServer(self.socket_file, SSHandler) + finally: + os.umask(old_umask) + os.chmod(self.socket_file, stat.S_IRUSR | stat.S_IWUSR) t = threading.Thread(target=self.sserver.serve_forever) t.daemon = True t.start() - os.chmod(self.socket_file, stat.S_IRUSR | stat.S_IWUSR) log.debug("Created control socket %s", self.socket_file) def _start_gesture( From 8e14ca29def59e5cd6d6dd8c9dd1e92fd4ab405d Mon Sep 17 00:00:00 2001 From: Sergio Correia Date: Wed, 12 Aug 2026 11:00:27 -0300 Subject: [PATCH 85/98] hiddrv: fix no-op CLAMP macro and add bounds checking The CLAMP macro was defined as a no-op (expanding to just x), providing no actual clamping. Replace with a correct implementation. Add a data_len parameter to grab_value/grab_with_size and validate byte_offset against it before reading, preventing out-of-bounds reads from malformed HID reports. Guard the HATSWITCH case against i+1 overflowing the axes array. Recalibrate DS4/DS5 axis parameters to work with the real CLAMP: the old values repurposed clamp_max as a scale factor (e.g. 257), which only worked because CLAMP was a no-op. Normalize fval to [-1, 1] (sticks) or [0, 1] (triggers) and use STICK_PAD_MIN/MAX and TRIGGER_MAX as proper clamp bounds, matching the evdevdrv convention. Set decoder.packet_size in DS4/DS5/BT constructors so the new bounds check has the correct buffer length. Assisted-by: Claude Opus 4.6 Signed-off-by: Sergio Correia --- scc/drivers/ds4drv.py | 53 ++++++++++++++++++++++++------------------- scc/drivers/ds5drv.py | 18 ++++++++------- scc/drivers/hiddrv.c | 41 +++++++++++++++++++++------------ 3 files changed, 67 insertions(+), 45 deletions(-) diff --git a/scc/drivers/ds4drv.py b/scc/drivers/ds4drv.py index 3ba5e8b1f..cd76424ff 100644 --- a/scc/drivers/ds4drv.py +++ b/scc/drivers/ds4drv.py @@ -27,7 +27,7 @@ except ImportError: from scc.lib.hidraw import HIDRaw -from scc.constants import STICK_PAD_MAX, STICK_PAD_MIN, ControllerFlags, HapticPos, SCButtons +from scc.constants import STICK_PAD_MAX, STICK_PAD_MIN, TRIGGER_MAX, ControllerFlags, HapticPos, SCButtons from scc.controller import Controller from scc.drivers.evdevdrv import ( HAVE_EVDEV, @@ -136,10 +136,11 @@ def _load_hid_descriptor(self, config, max_size, vid, pid, test_mode) -> None: size=8, data=AxisDataUnion( axis=AxisModeData( - scale=1.0, - offset=-127.5, - clamp_max=257, - deadzone=10, + scale=2.0 / 255, + offset=-1.0, + clamp_min=STICK_PAD_MIN, + clamp_max=STICK_PAD_MAX, + deadzone=10 * 2.0 / 255, ), ), ) @@ -149,10 +150,11 @@ def _load_hid_descriptor(self, config, max_size, vid, pid, test_mode) -> None: size=8, data=AxisDataUnion( axis=AxisModeData( - scale=-1.0, - offset=127.5, - clamp_max=257, - deadzone=10, + scale=-2.0 / 255, + offset=1.0, + clamp_min=STICK_PAD_MIN, + clamp_max=STICK_PAD_MAX, + deadzone=10 * 2.0 / 255, ), ), ) @@ -162,10 +164,11 @@ def _load_hid_descriptor(self, config, max_size, vid, pid, test_mode) -> None: size=8, data=AxisDataUnion( axis=AxisModeData( - scale=1.0, - offset=-127.5, - clamp_max=257, - deadzone=10, + scale=2.0 / 255, + offset=-1.0, + clamp_min=STICK_PAD_MIN, + clamp_max=STICK_PAD_MAX, + deadzone=10 * 2.0 / 255, ), ), ) @@ -175,10 +178,11 @@ def _load_hid_descriptor(self, config, max_size, vid, pid, test_mode) -> None: size=8, data=AxisDataUnion( axis=AxisModeData( - scale=-1.0, - offset=127.5, - clamp_max=257, - deadzone=10, + scale=-2.0 / 255, + offset=1.0, + clamp_min=STICK_PAD_MIN, + clamp_max=STICK_PAD_MAX, + deadzone=10 * 2.0 / 255, ), ), ) @@ -188,9 +192,9 @@ def _load_hid_descriptor(self, config, max_size, vid, pid, test_mode) -> None: size=8, data=AxisDataUnion( axis=AxisModeData( - scale=1.0, - clamp_max=1, - deadzone=10, + scale=1.0 / 255, + clamp_max=TRIGGER_MAX, + deadzone=10.0 / 255, ), ), ) @@ -200,9 +204,9 @@ def _load_hid_descriptor(self, config, max_size, vid, pid, test_mode) -> None: size=8, data=AxisDataUnion( axis=AxisModeData( - scale=1.0, - clamp_max=1, - deadzone=10, + scale=1.0 / 255, + clamp_max=TRIGGER_MAX, + deadzone=10.0 / 255, ), ), ) @@ -245,6 +249,9 @@ def _load_hid_descriptor(self, config, max_size, vid, pid, test_mode) -> None: for x, sc in enumerate(DS4Controller.BUTTON_MAP): self._decoder.buttons.button_map[x] = button_to_bit(sc) + self._packet_size = 64 + self._decoder.packet_size = 64 + def input(self, endpoint: int, data: bytes | bytearray) -> None: # Special override for CPAD touch button if _lib.decode(ctypes.byref(self._decoder), bytes(data)) and self.mapper: diff --git a/scc/drivers/ds5drv.py b/scc/drivers/ds5drv.py index df2ddfcbb..fc117c8f5 100644 --- a/scc/drivers/ds5drv.py +++ b/scc/drivers/ds5drv.py @@ -34,6 +34,7 @@ STICK_PAD_MAX, STICK_PAD_MIN, STICK_PAD_RES, + TRIGGER_MAX, ControllerFlags, HapticPos, SCButtons, @@ -265,20 +266,20 @@ def _load_hid_descriptor(self, config, max_size, vid, pid, test_mode) -> None: mode=AxisMode.AXIS, byte_offset=1, size=8, - data=AxisDataUnion(axis=AxisModeData(scale=1.0, offset=-127.5, clamp_max=257, deadzone=2)), + data=AxisDataUnion(axis=AxisModeData(scale=2.0/255, offset=-1.0, clamp_min=STICK_PAD_MIN, clamp_max=STICK_PAD_MAX, deadzone=2*2.0/255)), ) self._decoder.axes[AxisType.AXIS_LSTICK_Y] = AxisData( mode=AxisMode.AXIS, byte_offset=2, size=8, - data=AxisDataUnion(axis=AxisModeData(scale=-1.0, offset=127.5, clamp_max=257, deadzone=2)), + data=AxisDataUnion(axis=AxisModeData(scale=-2.0/255, offset=1.0, clamp_min=STICK_PAD_MIN, clamp_max=STICK_PAD_MAX, deadzone=2*2.0/255)), ) self._decoder.axes[AxisType.AXIS_RSTICK_X] = AxisData( mode=AxisMode.AXIS, byte_offset=3, size=8, data=AxisDataUnion( - axis=AxisModeData(scale=1.0, offset=-127.5, clamp_max=257, deadzone=2), + axis=AxisModeData(scale=2.0/255, offset=-1.0, clamp_min=STICK_PAD_MIN, clamp_max=STICK_PAD_MAX, deadzone=2*2.0/255), ), ) self._decoder.axes[AxisType.AXIS_RSTICK_Y] = AxisData( @@ -286,7 +287,7 @@ def _load_hid_descriptor(self, config, max_size, vid, pid, test_mode) -> None: byte_offset=4, size=8, data=AxisDataUnion( - axis=AxisModeData(scale=-1.0, offset=127.5, clamp_max=257, deadzone=2), + axis=AxisModeData(scale=-2.0/255, offset=1.0, clamp_min=STICK_PAD_MIN, clamp_max=STICK_PAD_MAX, deadzone=2*2.0/255), ), ) @@ -294,14 +295,14 @@ def _load_hid_descriptor(self, config, max_size, vid, pid, test_mode) -> None: self._decoder.axes[AxisType.AXIS_LTRIG] = AxisData( mode=AxisMode.AXIS, byte_offset=5, - size=8, # Not sure about the size - data=AxisDataUnion(axis=AxisModeData(scale=1.0, clamp_max=1, deadzone=10)), + size=8, + data=AxisDataUnion(axis=AxisModeData(scale=1.0/255, clamp_max=TRIGGER_MAX, deadzone=10.0/255)), ) self._decoder.axes[AxisType.AXIS_RTRIG] = AxisData( mode=AxisMode.AXIS, byte_offset=6, - size=8, # Not sure about the size - data=AxisDataUnion(axis=AxisModeData(scale=1.0, clamp_max=1, deadzone=10)), + size=8, + data=AxisDataUnion(axis=AxisModeData(scale=1.0/255, clamp_max=TRIGGER_MAX, deadzone=10.0/255)), ) # Gyro @@ -351,6 +352,7 @@ def _load_hid_descriptor(self, config, max_size, vid, pid, test_mode) -> None: self._decoder.buttons.button_map[x] = button_to_bit(sc) self._packet_size = 64 + self._decoder.packet_size = 64 def input(self, endpoint: int, data: bytearray) -> None: # Special override for CPAD touch button diff --git a/scc/drivers/hiddrv.c b/scc/drivers/hiddrv.c index 4c9b62916..37f3652df 100644 --- a/scc/drivers/hiddrv.c +++ b/scc/drivers/hiddrv.c @@ -3,7 +3,8 @@ #include #include #include -#define CLAMP(min, x, max) x +#include +#define CLAMP(min, x, max) ((x) < (min) ? (min) : ((x) > (max) ? (max) : (x))) #define HIDDRV_MODULE_VERSION 8 PyObject* module; @@ -131,15 +132,25 @@ union Value { }; -static union Value grab_value(const char* data, const size_t byte_offset, uint8_t bit_offset) { - union Value val = *((union Value*)(data + byte_offset)); - val.u64 = val.u64 >> bit_offset; +static union Value grab_value(const char* data, size_t data_len, const size_t byte_offset, uint8_t bit_offset) { + union Value val; + val.u64 = 0; + if (byte_offset < data_len) { + size_t avail = data_len - byte_offset; + if (avail > sizeof(val)) + avail = sizeof(val); + memcpy(&val, data + byte_offset, avail); + } + if (bit_offset < 64) + val.u64 = val.u64 >> bit_offset; + else + val.u64 = 0; return val; } -static int grab_with_size(const uint8_t size, const char* data, const size_t byte_offset, uint8_t bit_offset) { - union Value val = grab_value(data, byte_offset, bit_offset); +static int grab_with_size(const uint8_t size, const char* data, size_t data_len, const size_t byte_offset, uint8_t bit_offset) { + union Value val = grab_value(data, data_len, byte_offset, bit_offset); // if (size == 16) // printf(" => %i\n", (int)val.u16); switch (size) { @@ -162,7 +173,7 @@ bool decode(struct HIDDecoder* dec, const char* data) { switch (dec->axes[i].mode) { case AXIS: fval = ((grab_with_size(dec->axes[i].size, - data, dec->axes[i].byte_offset, dec->axes[i].bit_offset) + data, dec->packet_size, dec->axes[i].byte_offset, dec->axes[i].bit_offset) * dec->axes[i].data.axis.scale) + dec->axes[i].data.axis.offset ); @@ -179,10 +190,10 @@ bool decode(struct HIDDecoder* dec, const char* data) { break; case AXIS_NO_SCALE: dec->state.axes[i] = grab_with_size(dec->axes[i].size, - data, dec->axes[i].byte_offset, dec->axes[i].bit_offset); + data, dec->packet_size, dec->axes[i].byte_offset, dec->axes[i].bit_offset); break; case DPAD: - value = grab_value(data, dec->axes[i].byte_offset, + value = grab_value(data, dec->packet_size, dec->axes[i].byte_offset, dec->axes[i].bit_offset); if ((value.u32 >> dec->axes[i].data.dpad.button1) & 1) { dec->state.buttons |= dec->axes[i].data.dpad.button; @@ -193,7 +204,9 @@ bool decode(struct HIDDecoder* dec, const char* data) { } break; case HATSWITCH: - value = grab_value(data, dec->axes[i].byte_offset, + if (i + 1 >= AXIS_COUNT) + break; + value = grab_value(data, dec->packet_size, dec->axes[i].byte_offset, dec->axes[i].bit_offset); switch (value.u8 & 0b1111) { case 0: // up @@ -243,17 +256,17 @@ bool decode(struct HIDDecoder* dec, const char* data) { } break; case DS4ACCEL: - value = grab_value(data, dec->axes[i].byte_offset, + value = grab_value(data, dec->packet_size, dec->axes[i].byte_offset, dec->axes[i].bit_offset); dec->state.axes[i] = value.s16; break; case DS4GYRO: - value = grab_value(data, dec->axes[i].byte_offset, + value = grab_value(data, dec->packet_size, dec->axes[i].byte_offset, dec->axes[i].bit_offset); dec->state.axes[i] = -value.s16; break; case DS4TOUCHPAD: - value = grab_value(data, dec->axes[i].byte_offset, + value = grab_value(data, dec->packet_size, dec->axes[i].byte_offset, dec->axes[i].bit_offset); dec->state.axes[i] = ((value.u16 & 0x0FFF) * dec->axes[i].data.axis.scale) @@ -266,7 +279,7 @@ bool decode(struct HIDDecoder* dec, const char* data) { // Buttons if (dec->buttons.enabled) { - union Value value = grab_value(data, dec->buttons.byte_offset, dec->buttons.bit_offset); + union Value value = grab_value(data, dec->packet_size, dec->buttons.byte_offset, dec->buttons.bit_offset); for (i=0; ibuttons.button_map[i] < 33) { uint32_t bit = (value.u32 >> i) & 1; From e9249a8cedbb005b70a8695f628c2b4e6408da79 Mon Sep 17 00:00:00 2001 From: Sergio Correia Date: Wed, 12 Aug 2026 11:02:07 -0300 Subject: [PATCH 86/98] IPC: restrict Replace: actions and validate Selected:/Profile: paths The IPC socket accepted arbitrary action strings in Replace: commands, including shell() which executes commands via subprocess. The Profile: and Selected: handlers accepted arbitrary filesystem paths, allowing any socket client to load attacker-controlled files as profiles or menus containing shell() actions. Add SafeTalkingActionParser that filters dangerous action types (shell, profile, restart, exit, turnoff) from Replace: commands, including in nested/composite actions. The override changes the default parameter so recursive _parse_action() calls also use the filtered dict. Reject Profile: and Selected: names containing '/' and resolve them through find_profile()/find_menu() which only search known safe directories. Note what this filter is and is not. It is defence in depth, not a security boundary: type() and button() have to keep working for Replace: to have any purpose, and type('...') plus Enter drives whatever terminal happens to be focused. The boundary is the socket being 0600 (see the umask commit); this narrows what a client that already has that access can do without effort. [patola: dropped the realpath containment check in find_profile and find_menu. The basename check already stops traversal completely, and resolving symlinks additionally rejected a profile that is a symlink to somewhere else -- how people keep them in a dotfiles repo -- while only defending against a symlink planted inside the profiles directory, where an attacker could equally just write a profile full of shell() actions. Same reasoning in the daemon's two path checks: abspath instead of realpath, which collapses '..' lexically so traversal still cannot escape. Rejecting '.' and '..' explicitly, because basename('..') == '..' passes the basename check and, for menus, resolves to the directory itself. The submitted realpath check caught that by accident; the lexical one has to say so.] Assisted-by: Claude Opus 4.6 Signed-off-by: Sergio Correia Co-Authored-By: Claude --- scc/sccdaemon.py | 57 ++++++++++++++++++++++++++++++++++++++++++------ scc/tools.py | 10 +++++++++ 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/scc/sccdaemon.py b/scc/sccdaemon.py index 42d5fec73..60b3a500f 100644 --- a/scc/sccdaemon.py +++ b/scc/sccdaemon.py @@ -41,6 +41,7 @@ from scc.mapper import Mapper from scc.menu_data import MenuData from scc.parser import TalkingActionParser +from scc.paths import get_default_menus_path, get_default_profiles_path, get_menus_path, get_profiles_path from scc.poller import Poller from scc.profile import Profile from scc.scheduler import Scheduler @@ -74,6 +75,20 @@ ) log = logging.getLogger("SCCDaemon") + + +class SafeTalkingActionParser(TalkingActionParser): + """Parser that blocks dangerous actions (shell, profile, restart, etc.) + for use in IPC Replace: commands where the caller is not fully trusted.""" + BLOCKED = frozenset(('shell', 'profile', 'restart', 'exit', 'turnoff')) + + def _parse_action(self, frm=None): + if frm is None: + frm = Action.ALL + frm = {k: v for k, v in frm.items() if k not in self.BLOCKED} + return super()._parse_action(frm) + + tlog = logging.getLogger("Socket Thread") @@ -871,7 +886,22 @@ def _handle_message(self, client: Client, message: bytes) -> None: with self.lock: try: filename = message[8:].decode("utf-8").strip("\t ") - self._set_profile(client.mapper, filename) + if "/" in filename: + # abspath, not realpath: this has to reject '..' while + # still accepting a profile that is a symlink to + # somewhere else, which is how people keep them in a + # dotfiles repo. abspath collapses '..' lexically, so + # traversal cannot escape; symlinks are left alone. + path = os.path.abspath(filename) + allowed = (os.path.abspath(get_profiles_path()), + os.path.abspath(get_default_profiles_path())) + if not any(path.startswith(d + os.sep) for d in allowed): + raise ValueError("Profile path outside allowed directories") + else: + path = find_profile(filename) + if not path: + raise ValueError("Profile '%s' not found" % filename) + self._set_profile(client.mapper, path) self._remember_controller_profile(client, filename) log.info("Loaded profile '%s'", filename) client.wfile.write(b"OK.\n") @@ -947,7 +977,11 @@ def _handle_message(self, client: Client, message: bytes) -> None: elif message.startswith(b"Replace:"): try: source, actionstr = message.split(b":", 1)[1].strip(b" \t\r").split(b" ", 1) - action = TalkingActionParser().restart(actionstr.decode()).parse().compress() + parsed = SafeTalkingActionParser().restart(actionstr.decode()).parse() + if parsed is None: + client.wfile.write(b"Fail: action rejected (blocked or parse error)\n") + return + action = parsed.compress() except Exception as e: e = str(e).encode("utf-8").decode("unicode_escape").encode("latin1") client.wfile.write(b"Fail: failed to parse: " + e + b"\n") @@ -1081,16 +1115,25 @@ def release(mapper): if menu_id in (None, "None"): menuaction = self.osd_ids[item_id] elif "." in menu_id: - # TODO: Move this common place - with open(menu_id) as file: - data = json.loads(file.read()) + if "/" in menu_id: + path = os.path.abspath(menu_id) # see the Profile: handler + allowed = (os.path.abspath(get_menus_path()), + os.path.abspath(get_default_menus_path())) + if not any(path.startswith(d + os.sep) for d in allowed): + raise KeyError("Menu path outside allowed directories") + else: + path = find_menu(menu_id) + if not path: + raise KeyError("Menu '%s' not found" % menu_id) + with open(path) as f: + data = json.loads(f.read()) menudata = MenuData.from_json_data(data, TalkingActionParser()) menuaction = menudata.get_by_id(item_id).action else: menuaction = client.mapper.profile.menus[menu_id].get_by_id(item_id).action client.wfile.write(b"OK.\n") - except Exception: - log.warning("Selected menu item is no longer valid.") + except Exception as e: + log.warning("Selected menu item is no longer valid: %s", e) client.wfile.write(b"Fail: Selected menu item is no longer valid\n") if menuaction: client.mapper.schedule(0, press) diff --git a/scc/tools.py b/scc/tools.py index b31918c14..83f2897f9 100644 --- a/scc/tools.py +++ b/scc/tools.py @@ -211,6 +211,11 @@ def find_profile(name: str) -> str | None: Returns None if profile cannot be found. """ + # A plain filename and nothing else. basename() alone lets '..' through, + # since basename('..') == '..', and for menus that resolves to the + # directory itself. + if name in (os.curdir, os.pardir) or os.path.basename(name) != name: + return None filename = f"{name}.sccprofile" for p in (get_profiles_path(), get_default_profiles_path()): path = os.path.join(p, filename) @@ -293,6 +298,11 @@ def find_menu(name: str) -> str | None: Returns None if menu cannot be found. """ + # A plain filename and nothing else. basename() alone lets '..' through, + # since basename('..') == '..', and for menus that resolves to the + # directory itself. + if name in (os.curdir, os.pardir) or os.path.basename(name) != name: + return None for p in (get_menus_path(), get_default_menus_path()): path = os.path.join(p, name) if os.path.exists(path): From 133ca3836b6d5f082a4f71cacc1c544478ac46c7 Mon Sep 17 00:00:00 2001 From: Sergio Correia Date: Wed, 12 Aug 2026 17:10:07 -0300 Subject: [PATCH 87/98] daemon: fix os.system() injection and scheduler task ordering Replace os.system() in on_sa_restart with subprocess.Popen using a list and find_python(), eliminating shell injection via sys.argv[0] and ensuring the correct Python interpreter is used in AppImage environments. Fix Task.__lt__ which was a no-op (missing return). Add a sequence number as a tiebreaker so equal-timestamp tasks maintain FIFO order and PriorityQueue never falls through to comparing incomparable callback objects. Assisted-by: Claude Opus 4.6 Signed-off-by: Sergio Correia --- scc/sccdaemon.py | 3 +- scc/scheduler.py | 75 +++++++++++++++++++++++++++--------------------- 2 files changed, 45 insertions(+), 33 deletions(-) diff --git a/scc/sccdaemon.py b/scc/sccdaemon.py index 60b3a500f..946484851 100644 --- a/scc/sccdaemon.py +++ b/scc/sccdaemon.py @@ -356,7 +356,8 @@ def on_sa_restart(self, *a) -> None: with self.lock: for c in self.clients: c.close() - os.system(f"{sys.executable} {sys.argv[0]} None restart &") + subprocess.Popen([sys.executable, sys.argv[0], "None", "restart"], + start_new_session=True) def on_sa_led(self, mapper: Mapper, action: LedAction) -> None: """Called when 'led' action is used""" diff --git a/scc/scheduler.py b/scc/scheduler.py index 69c8ec506..99b1442bc 100644 --- a/scc/scheduler.py +++ b/scc/scheduler.py @@ -1,8 +1,8 @@ """SC Controller - Scheduler Centralized scheduler that should be used everywhere. -Runs in SCCDaemon's (single-threaded) mainloop. That means all callbacks are -also called on main thread. +Callbacks are executed on the main thread via run(). The schedule() +method may be called from any thread (e.g. socket handler threads). Use schedule(delay, callback, *data) to register one-time task. """ @@ -10,6 +10,7 @@ import logging import queue +import threading import time log = logging.getLogger("Scheduler") @@ -19,9 +20,11 @@ class Scheduler: def __init__(self) -> None: + self._lock = threading.Lock() self._scheduled = queue.PriorityQueue() self._next = None self._now: float = time.time() + self._seq = 0 def schedule(self, delay, callback, *data) -> Task: """Schedules one-time task to be executed no sooner than after 'delay' of seconds. @@ -30,42 +33,47 @@ def schedule(self, delay, callback, *data) -> Task: Returned Task instance can be used to cancel task once scheduled. """ - task = Task(self._now + delay, callback, data) - if self._next is None or task.time < self._next.time: - if self._next: - self._scheduled.put(self._next) - self._next = task - else: - self._scheduled.put(task) - return task + with self._lock: + task = Task(self._now + delay, self._seq, callback, data) + self._seq += 1 + if self._next is None or task.time < self._next.time: + if self._next: + self._scheduled.put(self._next) + self._next = task + else: + self._scheduled.put(task) + return task def cancel_task(self, task: Task) -> bool: """Returns True if task was sucessfully removed or False if task was already executed or not known at all. - Note that this is slow as hell and completly thread-unsafe, - so it _has_ to be called on main thread. + Note that this is slow as hell, so it _has_ to be called on + main thread. """ - if task == self._next: - self._next = None if self._scheduled.empty() else self._scheduled.get() - return True - # Fun part: All tasks are removed from PriorityQueue - # until correct is found. Then everything is put back - tasks, found = [], False - while not self._scheduled.empty(): - t = self._scheduled.get() - if t == task: - found = True - break - tasks.append(t) - for t in tasks: - self._scheduled.put(t) - return found + with self._lock: + if task == self._next: + self._next = None if self._scheduled.empty() else self._scheduled.get() + return True + tasks, found = [], False + while not self._scheduled.empty(): + t = self._scheduled.get() + if t == task: + found = True + break + tasks.append(t) + for t in tasks: + self._scheduled.put(t) + return found def run(self) -> None: self._now = time.time() - while self._next and self._now >= self._next.time: - callback, data = self._next.callback, self._next.data - self._next = None if self._scheduled.empty() else self._scheduled.get() + while True: + with self._lock: + if not (self._next and self._now >= self._next.time): + break + callback, data = self._next.callback, self._next.data + self._next = (None if self._scheduled.empty() + else self._scheduled.get()) callback(*data) def get_poll_timeout(self, maximum: float = 0.01) -> float: @@ -76,8 +84,9 @@ def get_poll_timeout(self, maximum: float = 0.01) -> float: class Task: - def __init__(self, time, callback, data) -> None: + def __init__(self, time, seq, callback, data) -> None: self.time = time + self.seq = seq self.callback = callback self.data = data @@ -87,4 +96,6 @@ def cancel(self) -> None: self.data = () def __lt__(self, other) -> None: - self.time < other.time + if self.time != other.time: + return self.time < other.time + return self.seq < other.seq From e5716e14cd6cf6fc4fb4b163dae99736c604385b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Fri, 14 Aug 2026 10:56:39 +0200 Subject: [PATCH 88/98] fix(gui): keep the haptic Effect chooser when no controller is attached has_haptic_effects() asked whether the connected controller's type is one that can synthesise waveforms. With no controller connected the type is None, which is not in that set, so the Effect row disappeared from the Feedback tab -- reproducible by opening a pad's configuration with the Steam Controller 2 switched off. There are three states, not two: capable, incapable, and nothing attached. An absent controller says nothing about the hardware a profile is written for, and editing a profile with the pad off is ordinary. Treat unknown as capable; a known incapable controller still hides the chooser. Saved effects were never at risk -- update_modifiers skips the effect block when unsupported and _make_action rebuilds from the loaded state, so a Tone opened offline stayed a Tone. There is now a test saying so, because that was the failure worth ruling out. Reported by Patola on a Steam Controller 2. Co-Authored-By: Claude --- scc/gui/ae/__init__.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/scc/gui/ae/__init__.py b/scc/gui/ae/__init__.py index 98726f60f..08496fdcb 100644 --- a/scc/gui/ae/__init__.py +++ b/scc/gui/ae/__init__.py @@ -49,8 +49,20 @@ def has_haptic_effects(app) -> bool: Used to hide the effect chooser rather than offering options that would silently degrade to a click on hardware that cannot synthesise them. + + An UNKNOWN controller counts as capable. There are three states here, not + two: a controller that supports effects, one that does not, and no + controller at all -- daemon not running, or the pad simply switched off. + Treating the third like the second hid the chooser from anyone editing a + profile with their controller off, which is an ordinary thing to do and + says nothing about the hardware the profile is for. Offering an effect + that later degrades to a click is a far smaller harm than making a + feature vanish depending on whether a controller happened to be awake. """ - return controller_type(app) in HAPTIC_EFFECT_TYPES + ctype = controller_type(app) + if ctype is None: + return True + return ctype in HAPTIC_EFFECT_TYPES def button_label(app, button, default): From ddca21789a5303472ddc63fc2edd5a3683b87c99 Mon Sep 17 00:00:00 2001 From: Sergio Correia Date: Fri, 14 Aug 2026 04:29:11 -0300 Subject: [PATCH 89/98] daemon: fix ReplacedAction forwarding and operator precedence bug ReplacedAction.button_press passed mapper as both arguments instead of forwarding the call correctly, causing TypeError on every button press through a Replace'd action. ReportingAction.whole had a misplaced parenthesis that evaluated the comparison inside abs() instead of outside, defeating the Y-axis minimum-difference threshold for event filtering. ReplacedAction.__init__ also bypassed LockedAction.__init__, which meant the held-button-release guard was skipped: if a button was physically held when Replace: was issued, the old action's virtual key stayed stuck down. Add the same release-if-held guard. Assisted-by: Claude Opus 4.6 Signed-off-by: Sergio Correia --- scc/sccdaemon.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scc/sccdaemon.py b/scc/sccdaemon.py index 946484851..132fe7214 100644 --- a/scc/sccdaemon.py +++ b/scc/sccdaemon.py @@ -1472,6 +1472,11 @@ def __init__(self, what, client: Client, new_action, original_action) -> None: ReportingAction.__init__(self, what, client) self.original_action = original_action self.new_action = new_action.compress() + if what in SCButtons.__members__.values() and self.mapper and (self.mapper.buttons & what): + try: + original_action.button_release(self.mapper) + except Exception as e: + log.warning("Failed to release held action while replacing %s: %s", what, e) original_action.cancel(self.mapper) self._store_lock() log.debug("%s replaced by %s", nameof(self.what), self.client) @@ -1483,10 +1488,10 @@ def trigger(self, mapper: Mapper, position, old_position) -> None: self.new_action.trigger(mapper, position, old_position) def button_press(self, mapper: Mapper, number=1) -> None: - self.new_action.button_press(mapper, mapper) + self.new_action.button_press(mapper) def button_release(self, mapper: Mapper) -> None: - self.new_action.button_release(mapper, mapper) + self.new_action.button_release(mapper) def whole(self, mapper: Mapper, x, y, what) -> None: self.new_action.whole(mapper, x, y, what) From 408c8a6b85ecfa1a3c09ecdeb1ab594a7be3dfab Mon Sep 17 00:00:00 2001 From: Sergio Correia Date: Fri, 14 Aug 2026 04:31:07 -0300 Subject: [PATCH 90/98] controller: fix HapticData.__mul__ double-scaling frequency __mul__ passed self.frequency (already scaled by 1000x in the constructor) back through the constructor, which multiplied by 1000 again. This also silently dropped extended effect attributes (tone, sweep, LFO settings). Apply the same pattern as with_position(): construct with a dummy frequency, then copy the pre-scaled frequency and all extended attributes directly. Extract the shared attribute list into a class-level _EFFECT_ATTRS tuple used by both methods. Assisted-by: Claude Opus 4.6 Signed-off-by: Sergio Correia --- scc/controller.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/scc/controller.py b/scc/controller.py index 87725b25d..5da6af5d3 100644 --- a/scc/controller.py +++ b/scc/controller.py @@ -154,6 +154,9 @@ def disconnected(self) -> None: class HapticData: """Simple container to hold haptic feedback settings""" + _EFFECT_ATTRS = ("effect", "duration", "tone_frequency", "end_frequency", + "lfo_frequency", "lfo_depth", "script_id") + def __init__(self, position: HapticPos, amplitude: int = 512, frequency: int = 4, period: int = 1024, count: int = 1, effect=HapticEffect.CLICK, duration=200, tone_frequency=160, end_frequency=40, lfo_frequency=0, lfo_depth=0, script_id=0) -> None: @@ -199,8 +202,7 @@ def with_position(self, position: HapticPos) -> HapticData: # to the constructor -- doing so multiplied it again on every copy, and # send_feedback() copies for every HapticPos.BOTH effect. rv.frequency = self.frequency - for attr in ("effect", "duration", "tone_frequency", "end_frequency", - "lfo_frequency", "lfo_depth", "script_id"): + for attr in self._EFFECT_ATTRS: setattr(rv, attr, getattr(self, attr)) return rv @@ -223,4 +225,8 @@ def __mul__(self, by) -> HapticData: """Allows multiplying HapticData by scalar to get same values with increased amplitude.""" position, amplitude, period, count = self.data amplitude = min(amplitude * by, 0x8000) - return HapticData(position, amplitude, self.frequency, period, count) + rv = HapticData(position, amplitude, 1, period, count) + rv.frequency = self.frequency + for attr in self._EFFECT_ATTRS: + setattr(rv, attr, getattr(self, attr)) + return rv From b10f73a2c164ffd25d23d6f6bd4e85de66a673ba Mon Sep 17 00:00:00 2001 From: Martin Rys Date: Mon, 17 Aug 2026 21:57:39 +0200 Subject: [PATCH 91/98] drivers: fix STICK/RSTICK modeshift axes RangeOP for both STICK and RSTICK created children that resolved to lpad_x/lpad_y axis names, so modeshift conditions on STICK read left-pad state instead of stick state, and RSTICK conditions never matched the right stick at all. Override children's axis_name to stick_x/stick_y and rstick_x/ rstick_y respectively, and guard _state() against AttributeError for controllers whose state struct lacks the requested axis (every cmp_* already treats None as no-match). [patola: took only the actions.py half of the submitted patch. The DS5 Bluetooth touchpad scaling it also carried is held back: it scales unconditionally, where the USB path it copies only scales inside the touched branch, so a lifted finger would report a pad corner instead of centre -- and it scales a cpad_y that is itself extracted wrongly ((data[37] & 0x0F) << 4 keeps 8 bits of a 12-bit field). That needs the extraction fixed first and a DualSense to test on.] Assisted-by: Claude Opus 4.6 Signed-off-by: Sergio Correia --- scc/actions.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/scc/actions.py b/scc/actions.py index af063ebd1..b6e86cc9f 100644 --- a/scc/actions.py +++ b/scc/actions.py @@ -428,17 +428,19 @@ def __init__(self, what, op, value): self.axis_name = "lpad_y" self.min, self.max = float(STICK_PAD_MIN), float(STICK_PAD_MAX) elif what == LSTICK: - # Most special case of all special cases self.axis_name = LSTICK op = "ABS" + op.replace("=", "") self.children = RangeOP(SCButtons.X, op, value), RangeOP(SCButtons.Y, op, value) + self.children[0].axis_name = "lstick_x" + self.children[1].axis_name = "lstick_y" self.min, self.max = float(STICK_PAD_MIN), float(STICK_PAD_MAX) self.op_method = self.cmp_or elif what == RSTICK: - # Most special case of all special cases self.axis_name = RSTICK op = "ABS" + op.replace("=", "") self.children = RangeOP(SCButtons.X, op, value), RangeOP(SCButtons.Y, op, value) + self.children[0].axis_name = "rstick_x" + self.children[1].axis_name = "rstick_y" self.min, self.max = float(STICK_PAD_MIN), float(STICK_PAD_MAX) self.op_method = self.cmp_or else: @@ -459,7 +461,10 @@ def _cmp(self, mapper: Mapper, op, state, rising: bool) -> bool: def _state(self, mapper: Mapper) -> bool: if mapper.state is None: return None - return float(getattr(mapper.state, self.axis_name)) / self.max + try: + return float(getattr(mapper.state, self.axis_name)) / self.max + except AttributeError: + return None def cmp_gt(self, mapper: Mapper): state = self._state(mapper) From ddc9a19c60c751c7b4f3000296dc3853b490d204 Mon Sep 17 00:00:00 2001 From: Sergio Correia Date: Fri, 14 Aug 2026 04:34:36 -0300 Subject: [PATCH 92/98] hiddrv: fix button_map shift UB at position 32 The button_map guard allowed values up to 32 (< 33), but shifting a uint32_t by 32 is undefined behavior in C. Change the guard to < 32 so only valid bit positions 0-31 are used. Assisted-by: Claude Opus 4.6 Signed-off-by: Sergio Correia --- scc/drivers/hiddrv.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scc/drivers/hiddrv.c b/scc/drivers/hiddrv.c index 37f3652df..f1aac93de 100644 --- a/scc/drivers/hiddrv.c +++ b/scc/drivers/hiddrv.c @@ -281,7 +281,7 @@ bool decode(struct HIDDecoder* dec, const char* data) { if (dec->buttons.enabled) { union Value value = grab_value(data, dec->packet_size, dec->buttons.byte_offset, dec->buttons.bit_offset); for (i=0; ibuttons.button_map[i] < 33) { + if (dec->buttons.button_map[i] < 32) { uint32_t bit = (value.u32 >> i) & 1; dec->state.buttons |= bit << dec->buttons.button_map[i]; } From e5a56d6642da4cd93fba6e0221514ad9acdf3ff5 Mon Sep 17 00:00:00 2001 From: Sergio Correia Date: Fri, 14 Aug 2026 04:35:11 -0300 Subject: [PATCH 93/98] daemon: fix Subprocess race between _threaded and kill() The _threaded method and kill() both read and write self.p without synchronization. If kill() checks self.p between _threaded's communicate() return and self.p = None assignment, it may call kill() on None or on a stale Popen. Add a threading.Lock to protect self.p access. Also re-check _killed inside the lock before spawning, closing a narrow TOCTOU between the while-guard and the lock acquisition that could leave an orphaned child process after kill() returns. Assisted-by: Claude Opus 4.6 Signed-off-by: Sergio Correia --- scc/sccdaemon.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/scc/sccdaemon.py b/scc/sccdaemon.py index 132fe7214..db558e708 100644 --- a/scc/sccdaemon.py +++ b/scc/sccdaemon.py @@ -1559,6 +1559,7 @@ def __init__(self, binary_name: str, debug: bool, restart_after: int = 5) -> Non if debug: self.args.append("debug") self._killed: bool = False + self._lock = threading.Lock() self.p = None self.t: Thread = threading.Thread(target=self._threaded) self.t.daemon = True @@ -1566,13 +1567,19 @@ def __init__(self, binary_name: str, debug: bool, restart_after: int = 5) -> Non def _threaded(self, *a) -> None: while not self._killed: - self.p = subprocess.Popen(self.args, stdin=None) - self.p.communicate() - if self.p and self.p.returncode == 8: - log.warning("%s exited with code 8; not restarting", self.binary_name) + with self._lock: + if self._killed: + return + self.p = subprocess.Popen(self.args, stdin=None) + p = self.p + p.communicate() + with self._lock: + p = self.p + if p and p.returncode == 8: + log.warning("%s exited with code 8; not restarting", self.binary_name) + self.p = None + return self.p = None - return - self.p = None if not self._killed: log.warning("%s died; restarting after %ss", self.binary_name, self.restart_after) time.sleep(self.restart_after) @@ -1583,6 +1590,7 @@ def mark_killed(self) -> None: def kill(self) -> None: self.mark_killed() - if self.p: - self.p.kill() - self.p = None + with self._lock: + if self.p: + self.p.kill() + self.p = None From 0845db24588386c3381f1c3d3522ba2c0e1de3c7 Mon Sep 17 00:00:00 2001 From: Martin Rys Date: Mon, 17 Aug 2026 22:02:41 +0200 Subject: [PATCH 94/98] hiddrv: stop unmapped buttons pressing the right stick The "maps to nothing" sentinel was BUTTON_COUNT - 1. That is bit 31, which is RSTICKPRESS -- a real button -- so on a JSON-configured HID controller every input bit the config did not name pressed the right stick, and button_to_bit reported the same thing for a mask it could not resolve. Use BUTTON_UNMAPPED = BUTTON_COUNT instead, which is outside the range the decoder can turn into a button. That value is only safe now that the preceding commit rejects 32 rather than 33: before it, an out-of-range sentinel would have been shifted into a uint32_t by 32 -- undefined behaviour rather than the intended no-op. Found while reviewing Sergio Correia's shift-UB fix, which this builds on. Co-Authored-By: Claude --- scc/drivers/hiddrv.py | 13 ++++++-- tests/test_button_map.py | 72 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 tests/test_button_map.py diff --git a/scc/drivers/hiddrv.py b/scc/drivers/hiddrv.py index 1665c11ca..d0857c42b 100644 --- a/scc/drivers/hiddrv.py +++ b/scc/drivers/hiddrv.py @@ -48,6 +48,13 @@ LIBUSB_DT_REPORT = 0x22 AXIS_COUNT = 24 # Must match number of axis fields in HIDControllerInput and values in AxisType BUTTON_COUNT = 32 # Must match (or be less than) number of bits in HIDControllerInput.buttons +# "This input bit maps to no button". It has to be OUTSIDE 0..BUTTON_COUNT-1: +# the decoder turns a mapping into `1 << button_map[i]`, so any in-range value +# names a real button. This used to be BUTTON_COUNT - 1, which is bit 31 -- +# RSTICKPRESS -- so on a JSON-configured controller every unmapped input bit +# pressed the right stick. 32 is only safe to use now that the C guard rejects +# it (it would otherwise shift a uint32_t by 32, which is undefined). +BUTTON_UNMAPPED = BUTTON_COUNT ALLOWED_SIZES = [1, 2, 4, 8, 16, 32] SYS_DEVICES = "/sys/devices" @@ -67,7 +74,8 @@ def button_to_bit(sc) -> int: bit += 1 if sc & 1 == 1: return bit - return BUTTON_COUNT - 1 + return BUTTON_UNMAPPED + class HIDDrvError(Exception): pass @@ -323,8 +331,7 @@ def _build_button_map(self, config: dict): Generates default if config is not available. """ if config: - # Last possible value is default "maps-to-nothing" mapping - buttons = [BUTTON_COUNT - 1] * BUTTON_COUNT + buttons = [BUTTON_UNMAPPED] * BUTTON_COUNT for keycode, value in config.get("buttons", {}).items(): keycode = int(keycode) - FIRST_BUTTON # TODO(Martin): Compat crutch for saved devices - autoconvert it to current standard instead diff --git a/tests/test_button_map.py b/tests/test_button_map.py new file mode 100644 index 000000000..633d6df9f --- /dev/null +++ b/tests/test_button_map.py @@ -0,0 +1,72 @@ +"""What an unmapped HID input bit maps to. + +The decoder turns each entry into `1 << button_map[i]`, so every value in +0..BUTTON_COUNT-1 names a real button. The "maps to nothing" sentinel was +BUTTON_COUNT - 1 -- bit 31, which is RSTICKPRESS -- so on a JSON-configured +controller every input bit the config did not name pressed the right stick. + +It only became fixable once the C guard rejected 32 rather than 33: before +that, an out-of-range sentinel would have been shifted into a uint32_t by 32, +which is undefined behaviour rather than a no-op. +""" +import pytest + +try: + # OSError, not ImportError, when libhiddrv is unbuilt. + from scc.drivers import hiddrv +except (ImportError, OSError) as e: + pytest.skip("hiddrv unavailable: %s" % (e,), allow_module_level=True) + +from scc.constants import SCButtons + + +def test_the_sentinel_is_not_a_real_button(): + """The whole bug in one line: it has to be outside the valid range.""" + assert hiddrv.BUTTON_UNMAPPED >= hiddrv.BUTTON_COUNT, ( + "%r names bit %d, which is a button" + % (hiddrv.BUTTON_UNMAPPED, hiddrv.BUTTON_UNMAPPED)) + + +def test_bit_31_is_rstickpress_and_so_cannot_be_the_sentinel(): + """Pins why BUTTON_COUNT - 1 was the wrong choice, so that reverting to it + fails here rather than on someone's controller. + """ + assert int(SCButtons.RSTICKPRESS).bit_length() - 1 == hiddrv.BUTTON_COUNT - 1 + assert hiddrv.BUTTON_UNMAPPED != hiddrv.BUTTON_COUNT - 1 + + +def test_an_empty_config_maps_nothing_to_a_button(): + config = {"buttons": {}} + m = hiddrv.USBHIDController._build_button_map(hiddrv.USBHIDController, config) + assert set(m) == {hiddrv.BUTTON_UNMAPPED} + + +def test_named_buttons_survive_and_the_rest_stay_unmapped(): + first = hiddrv.FIRST_BUTTON + config = {"buttons": {str(first): "A", str(first + 1): "RSTICKPRESS"}} + m = hiddrv.USBHIDController._build_button_map(hiddrv.USBHIDController, config) + + assert m[0] == int(SCButtons.A).bit_length() - 1 + assert m[1] == int(SCButtons.RSTICKPRESS).bit_length() - 1 + assert set(m[2:]) == {hiddrv.BUTTON_UNMAPPED}, ( + "unnamed bits map to a real button again") + + +def test_rstickpress_is_reachable_only_when_asked_for(): + """The regression this guards: with the old sentinel, a config naming no + buttons at all still produced a map full of RSTICKPRESS's bit. + """ + rstick_bit = int(SCButtons.RSTICKPRESS).bit_length() - 1 + m = hiddrv.USBHIDController._build_button_map(hiddrv.USBHIDController, {"buttons": {}}) + assert rstick_bit not in set(m) + + +def test_the_sentinel_fits_the_c_field(): + """button_map is a uint8 array; anything wider is silently truncated into + a valid bit position. + """ + assert 0 <= hiddrv.BUTTON_UNMAPPED <= 255 + + +def test_an_unknown_mask_is_reported_as_unmapped(): + assert hiddrv.button_to_bit(0) == hiddrv.BUTTON_UNMAPPED From d174e28359e9e2f5fbbdbf8e9aa70a0fcd1c56af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20=27Patola=27=20Sampaio?= Date: Fri, 14 Aug 2026 19:50:23 +0200 Subject: [PATCH 95/98] fix(config): stop a bad save or parse from wiping every setting Two faults that compounded into data loss. save() wrote in place, so anything killing the process mid-write left truncated JSON; reload() then answered the parse failure by calling create(), which overwrites the file with defaults. One interrupted write therefore discarded every setting the user had, with no copy kept. save() now writes a temp file in the same directory, fsyncs it and os.replace()s it in, so a reader sees either the old contents or the new. reload() moves an unreadable config aside to config.json.broken.N before falling back to defaults, numbered so a second failure cannot overwrite the copy taken during the first -- which would be the only surviving record of the settings. A missing file is treated as a first run rather than an error, so it no longer logs a warning or leaves a stray backup. Also: "Minimize to status icon instead closing" -> "instead of closing". Reported by Patola, who lost his settings twice; the fingerprints matched exactly -- minimize_to_status_icon and autokill_daemon sitting at their defaults, news.last_version reverted from 0.4.8 to the default 0.3.12, and osd_color_theme (which has no default) gone entirely. Co-Authored-By: Claude --- scc/config.py | 61 +++++++++++++++++-- tests/test_config_durability.py | 105 ++++++++++++++++++++++++++++++++ ui/global_settings.ui | 2 +- 3 files changed, 162 insertions(+), 6 deletions(-) create mode 100644 tests/test_config_durability.py diff --git a/scc/config.py b/scc/config.py index 72b33a8d4..e8b0c5da3 100644 --- a/scc/config.py +++ b/scc/config.py @@ -159,13 +159,42 @@ def reload(self) -> None: """(Re)loads configuration. Works as load(), but handles exceptions""" try: self.load() + except FileNotFoundError: + # First run. Nothing to preserve, nothing to warn about. + self.create() except Exception as e: - log.warning("Failed to load configuration; Creating new one.") - log.warning("Reason: %s", (e,)) + # Keep the unreadable file. Everything the user ever configured is + # in it, and create() below overwrites it with defaults -- so + # without this, one bad parse silently discards the lot. It has + # happened: an interrupted save used to leave truncated JSON, and + # the next start "recovered" by wiping it. + backup = self._keep_broken_config() + log.error("Failed to load configuration: %s", e) + if backup: + log.error("Kept the unreadable file as %s; starting from defaults.", backup) + else: + log.error("Could not preserve the unreadable file; starting from defaults.") self.create() if self.check_values(): self.save() + def _keep_broken_config(self): + """Move an unreadable config aside and return where it went, or None. + + Numbered rather than a single .broken, so a second failure cannot + overwrite the copy taken during the first one -- which would be the + only surviving record of the user's settings. + """ + try: + for i in range(1, 100): + backup = "%s.broken.%d" % (self.filename, i) + if not os.path.exists(backup): + os.replace(self.filename, backup) + return backup + except Exception as e: + log.warning("Could not preserve %s: %s", self.filename, e) + return None + def _check_dict(self, values, defaults): """Recursively checks if 'config' contains all keys in 'defaults'. @@ -239,15 +268,37 @@ def create(self) -> None: self.save() def save(self) -> None: - """Saves configuration file""" + """Saves configuration file. + + Atomically: a truncated config.json is not merely a bad save, it is + unreadable at the next start, and recovery from that means falling + back to defaults. Writing in place left exactly that window open -- + anything killing the process mid-write (Ctrl-C on the daemon, a + reboot) cost the user every setting they had. + """ # Check & create directory if not os.path.exists(get_config_path()): os.makedirs(get_config_path()) # Save data = {k: self.values[k] for k in self.values} jstr = Encoder(sort_keys=True, indent=4).encode(data) - with open(self.filename, "w") as file: - file.write(jstr) + # Same directory, so the replace below is a rename within one + # filesystem and therefore atomic. + tmp = "%s.tmp.%d" % (self.filename, os.getpid()) + try: + with open(tmp, "w") as f: + f.write(jstr) + f.flush() + # The rename can otherwise land before the contents do, which + # on a crash leaves a config.json that is present but empty. + os.fsync(f.fileno()) + os.replace(tmp, self.filename) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise log.debug("Configuration saved") def __iter__(self): diff --git a/tests/test_config_durability.py b/tests/test_config_durability.py new file mode 100644 index 000000000..1ed16f7e9 --- /dev/null +++ b/tests/test_config_durability.py @@ -0,0 +1,105 @@ +"""Configuration must survive a bad save and a bad parse. + +Two faults compounded here. save() wrote in place, so anything that killed the +process mid-write left truncated JSON; and reload() answered any parse failure +by calling create(), which overwrites the file with defaults. Together they +turned one interrupted write into "every setting the user ever chose is gone", +with no copy kept. It happened twice on the maintainer's machine before anyone +worked out why. +""" +import json +import os + +import pytest + +from scc.config import Config + + +@pytest.fixture +def cfg(tmp_path, monkeypatch): + """A Config rooted in a temp dir, so nothing here touches ~/.config/scc.""" + monkeypatch.setattr("scc.config.get_config_path", lambda: str(tmp_path)) + c = Config() + c.filename = os.path.join(str(tmp_path), "config.json") + return c + + +def test_a_saved_config_reads_back(cfg): + cfg.set("recent_max", 7) + cfg.save() + fresh = Config() + fresh.filename = cfg.filename + fresh.load() + assert fresh["recent_max"] == 7 + + +def test_save_leaves_no_temp_files_behind(cfg): + cfg.save() + leftovers = [f for f in os.listdir(os.path.dirname(cfg.filename)) if ".tmp." in f] + assert leftovers == [], "temp files accumulate in the config directory: %r" % leftovers + + +def test_the_file_is_never_seen_half_written(cfg, monkeypatch): + """The window that caused this: a reader arriving mid-save must see either + the old contents or the new ones, never a truncated file. + """ + cfg.set("recent_max", 1) + cfg.save() + seen = [] + + real_replace = os.replace + + def watched_replace(src, dst): + # whatever a reader would find at the moment before the swap + with open(dst) as f: + seen.append(f.read()) + return real_replace(src, dst) + + monkeypatch.setattr(os, "replace", watched_replace) + cfg.set("recent_max", 2) + cfg.save() + + assert seen, "save() did not go through os.replace; it is not atomic" + assert json.loads(seen[0])["recent_max"] == 1, "the old file was modified in place" + + +def test_an_unreadable_config_is_kept_not_deleted(cfg): + """The part that actually lost data.""" + with open(cfg.filename, "w") as f: + f.write('{"recent_max": 7, "gui": {') # truncated, as an interrupted write leaves it + cfg.reload() + + backup = cfg.filename + ".broken.1" + assert os.path.exists(backup), "the unreadable config was discarded" + with open(backup) as f: + assert f.read() == '{"recent_max": 7, "gui": {' + assert os.path.exists(cfg.filename), "no usable config was created" + json.loads(open(cfg.filename).read()) # defaults, and valid + + +def test_a_second_failure_does_not_overwrite_the_first_backup(cfg): + """The first backup holds the real settings; a later, emptier failure must + not clobber it. + """ + with open(cfg.filename, "w") as f: + f.write("the original settings") + cfg.reload() + with open(cfg.filename, "w") as f: + f.write("garbage from a later run") + cfg.reload() + + with open(cfg.filename + ".broken.1") as f: + assert f.read() == "the original settings" + assert os.path.exists(cfg.filename + ".broken.2") + + +def test_a_missing_config_is_just_a_first_run(cfg): + """No file is not an error and must not leave a .broken behind. + + (Config.__init__ already wrote one, so remove it to get back to the + genuine first-run state.) + """ + os.unlink(cfg.filename) + cfg.reload() + assert os.path.exists(cfg.filename) + assert not os.path.exists(cfg.filename + ".broken.1") diff --git a/ui/global_settings.ui b/ui/global_settings.ui index 447dbb0d5..985841336 100644 --- a/ui/global_settings.ui +++ b/ui/global_settings.ui @@ -1799,7 +1799,7 @@ grips, triggers, finger positions on both pads and stick angle. - Minimize to status icon instead closing + Minimize to status icon instead of closing cbShowOSD 1 From abf03f19a4e64b55a9910166fa7d38d033aeae73 Mon Sep 17 00:00:00 2001 From: Martin Rys Date: Sun, 13 Sep 2026 19:09:48 +0200 Subject: [PATCH 96/98] fix(osd): stop an idle input cancelling navigation another is driving Navigating an OSD menu with a DualShock 4's left stick was unusable: nudging it scrolled the list far too fast, and holding it fully deflected stopped the selection dead. The d-pad on the same pad was fine, and both were fine on an SC2. One cause, two symptoms. Menus accept STICK, LEFT and DPAD together -- the d-pad arrives as LEFT on a DS4 and as DPAD on an SC2 -- and all of them fed one StickController. _move() emits on every direction change and cancels the repeat timer once the direction reaches zero, so the idle input reporting (0, 0) cancelled the repeat the stick was driving. Movement then happened only on changes: once per event, at the controller's report rate, hence "too fast" -- and nothing at all once the stick was held still and stopped producing events, hence "stalls". set_stick now takes the input the position came from and keeps one entry per source, letting whichever is actually deflected decide. Callers with a single input (launcher, dialog) pass no source and are untouched. Pre-existing: LEFT has always been accepted here. The DPAD added in v0.6.0.7 only affected controllers with a real separate d-pad, which are the ones that behaved correctly. Also makes the packet_size tests from the security series skip rather than fail where libhiddrv is not built. They went in red -- CI has been failing since that merge, because I ran the suite only on a machine that has the extension. Fixes Patola/sc-controller-cc#17 Co-Authored-By: Claude --- scc/osd/__init__.py | 20 ++++- scc/osd/menu.py | 3 +- tests/test_osd_navigation.py | 137 +++++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 tests/test_osd_navigation.py diff --git a/scc/osd/__init__.py b/scc/osd/__init__.py index 8e697fbfe..bc7a62c7a 100644 --- a/scc/osd/__init__.py +++ b/scc/osd/__init__.py @@ -386,6 +386,7 @@ def __init__(self): GObject.GObject.__init__(self) TimerManager.__init__(self) self._direction = 0 + self._positions = {} def _move(self, *a): self.emit("direction", *self.DIRECTION_TO_XY[self._direction]) @@ -394,7 +395,24 @@ def _move(self, *a): else: self.cancel_timer("move") - def set_stick(self, *data): + def set_stick(self, *data, source=None): + """Feed a position in. 'source' names the input it came from, for + callers that accept more than one. + + Without it, every input shares one direction and a neutral reading + cancels whatever another input is driving: _move() emits on each change + and cancels the repeat timer when the direction reaches 0, so a menu fed + by both a stick and a d-pad advanced once per event -- at the controller + report rate rather than the intended interval -- and then stopped dead as + soon as the stick was held still and stopped producing events. + """ + if source is not None: + self._positions[source] = (data[0], data[1]) + # whichever input is actually deflected decides; an idle one + # reading (0, 0) must not cancel a repeat another is driving + data = max(self._positions.values(), + key=lambda p: max(abs(p[0]), abs(p[1]))) + direction = 0 # Y if data[1] < STICK_PAD_MIN / 2: diff --git a/scc/osd/menu.py b/scc/osd/menu.py index ee9f0f017..4ef46a879 100644 --- a/scc/osd/menu.py +++ b/scc/osd/menu.py @@ -735,7 +735,8 @@ def on_event(self, daemon, what, data): # if point_in_gtkrect(i.widget.get_allocation(), x, y): # self.select(self.items.index(i)) else: - self._scon.set_stick(x, y) + # Keep the selected input and DPAD separate in the pacer. + self._scon.set_stick(x, y, source=what) elif what == self._confirm_with: if data[0] == 0: # Button released if self._selected and self._selected.callback: diff --git a/tests/test_osd_navigation.py b/tests/test_osd_navigation.py new file mode 100644 index 000000000..26e27d62c --- /dev/null +++ b/tests/test_osd_navigation.py @@ -0,0 +1,137 @@ +"""Menu navigation pacing. + +StickController turns positions into repeated 'direction' signals. Menus accept +more than one input at once -- the selected input plus DPAD -- and they all +feed one controller. + +Sharing it naively breaks in a way that looks like two unrelated bugs: nudging +the stick scrolls far too fast, and holding it fully deflected stops the list +dead. Both come from the same place. _move() emits on every direction change and +cancels the repeat timer whenever the direction reaches zero, so an idle input +reporting (0, 0) cancels the repeat the stick is driving. Movement then happens +only on changes -- once per event, at the controller's report rate -- and stops +entirely as soon as the stick is held still and stops producing events. + +Needs Gtk for the GObject signal; run under xvfb-run on a headless machine. +""" +import os + +import pytest + +pytestmark = pytest.mark.skipif( + not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"), + reason="needs a display; try xvfb-run", +) + + +@pytest.fixture +def pacer(): + """A StickController with its timer calls recorded rather than run.""" + import gi + + gi.require_version("Gtk", "4.0") + from scc.osd import StickController + + sc = StickController() + sc.emits, sc.timers = [], [] + sc.connect("direction", lambda o, x, y: sc.emits.append((x, y))) + sc.timer = lambda name, delay, cb, *a: sc.timers.append("schedule") + sc.cancel_timer = lambda name: sc.timers.append("cancel") + return sc + + +def test_an_idle_input_does_not_cancel_another_that_is_held(pacer): + """The bug, stated directly: a d-pad sitting at rest must not stop the + repeat that a held stick is driving. + """ + from scc.constants import LEFT, LSTICK, STICK_PAD_MAX + + for trash in range(3): + pacer.set_stick(0, STICK_PAD_MAX, source=LSTICK) + pacer.set_stick(0, 0, source=LEFT) # d-pad, untouched + + assert pacer.emits == [(0, -1)], "moved once per event instead of once per interval" + assert pacer.timers == ["schedule"], "the repeat timer was cancelled by the idle input" + + +def test_releasing_everything_still_stops(pacer): + """The cancel has to survive: once nothing is deflected, the repeat ends.""" + from scc.constants import LEFT, LSTICK, STICK_PAD_MAX + + pacer.set_stick(0, STICK_PAD_MAX, source=LSTICK) + pacer.set_stick(0, 0, source=LEFT) + pacer.set_stick(0, 0, source=LSTICK) + + assert pacer.emits[-1] == (0, 0) + assert pacer.timers[-1] == "cancel" + + +@pytest.mark.parametrize("source_name", ["LSTICK", "LEFT", "DPAD"]) +def test_any_single_input_navigates(pacer, source_name): + """Whichever input a given controller delivers its d-pad on, it works: the + DS4 sends it as LEFT, the SC2 as DPAD. + """ + import scc.constants as c + + source = getattr(c, source_name) + pacer.set_stick(0, c.STICK_PAD_MAX, source=source) + assert pacer.emits == [(0, -1)] + assert pacer.timers == ["schedule"] + + +def test_the_deflected_input_wins_over_an_idle_one(pacer): + """Order must not matter -- the idle input may be reported after the held + one, which is precisely when it used to clobber it. + """ + from scc.constants import DPAD, LSTICK, STICK_PAD_MIN + + pacer.set_stick(0, 0, source=DPAD) + pacer.set_stick(0, STICK_PAD_MIN, source=LSTICK) + pacer.set_stick(0, 0, source=DPAD) + + assert pacer.emits == [(0, 1)], "a later idle report overrode the held input" + + +def test_callers_with_a_single_input_are_unaffected(pacer): + """launcher and dialog pass no source; they must behave exactly as before.""" + from scc.constants import STICK_PAD_MAX + + pacer.set_stick(0, STICK_PAD_MAX) + pacer.set_stick(0, STICK_PAD_MAX) + assert pacer.emits == [(0, -1)], "a repeated identical position re-fired" + pacer.set_stick(0, 0) + assert pacer.emits[-1] == (0, 0) + assert pacer.timers[-1] == "cancel" + + +def test_the_menu_tells_the_pacer_which_input_an_event_came_from(): + """The wiring, not just the pacer. + + StickController can only separate the inputs if the menu says which one an + event arrived on. Without this the fix above is inert -- every input shares + one entry again -- and nothing else in the suite would notice. + """ + from scc.constants import SCPads, SCSticks, STICK_PAD_MAX + from scc.osd.menu import Menu + + seen = [] + + class FakeScon: + def set_stick(self, x, y, source=None): + seen.append((x, y, source)) + + class FakeMenu: + _submenu = None + _use_cursor = False + _control_with = SCSticks.LSTICK + _control_with_dpad = True + _confirm_with = "A" + _cancel_with = "B" + _scon = FakeScon() + + m = FakeMenu() + Menu.on_event(m, None, SCSticks.LSTICK, (0, STICK_PAD_MAX)) + Menu.on_event(m, None, SCPads.DPAD, (0, 0)) + + assert seen == [(0, STICK_PAD_MAX, SCSticks.LSTICK), (0, 0, SCPads.DPAD)], ( + "the menu did not pass the source through: %r" % (seen,)) From ad792f85cd0dbfe7fd330ce583f1f66cbfe14505 Mon Sep 17 00:00:00 2001 From: Martin Rys Date: Sun, 13 Sep 2026 20:05:32 +0200 Subject: [PATCH 97/98] WIP osd removal commit fix --- ui/action_editor.ui | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/action_editor.ui b/ui/action_editor.ui index 6b74862cc..26d9adbf3 100644 --- a/ui/action_editor.ui +++ b/ui/action_editor.ui @@ -1382,7 +1382,7 @@ - +
From 696125ff8dc906dbb9d42a2e20b8964b181083f7 Mon Sep 17 00:00:00 2001 From: Martin Rys Date: Mon, 14 Sep 2026 17:57:43 +0200 Subject: [PATCH 98/98] WIP fix for failing test_haptic_strength_slider_reaches_the_amplitude_the_driver_accepts --- ui/action_editor.ui | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/action_editor.ui b/ui/action_editor.ui index 26d9adbf3..f4ac8eb9b 100644 --- a/ui/action_editor.ui +++ b/ui/action_editor.ui @@ -18,7 +18,6 @@ 16 128 - 128 32 32767 512