Skip to content

Steam Controller 2 (2026) driver, Steam Deck support, multi-controller UX, and OSD/GUI fixes#100

Open
Patola wants to merge 130 commits into
C0rn3j:mainfrom
Patola:steam-controller-v2
Open

Steam Controller 2 (2026) driver, Steam Deck support, multi-controller UX, and OSD/GUI fixes#100
Patola wants to merge 130 commits into
C0rn3j:mainfrom
Patola:steam-controller-v2

Conversation

@Patola

@Patola Patola commented Jul 3, 2026

Copy link
Copy Markdown

Fixes #98 — yes, it works with the new Steam Controller. 🙂

Summary

This branch adds a full user-mode driver for the new Steam Controller 2 (2026), substantially improves Steam Deck support, introduces multi-controller UX (a controller selector plus per-controller profiles, images and icons), and fixes a range of Input-Test, OSD, GUI/mapper and packaging issues. It closes with an asset-optimization (svgo) and type-annotation pass.

Everything here has been hardware-tested on real devices — Steam Controller v1 (wired and dongle), the Steam Controller 2 wireless Puck, and the Steam Deck (via AppImage). Commits are kept focused (one thing each) for bisectability. This is an LLM-assisted contribution; the human author drove the hardware reverse-engineering and testing.


Steam Controller 2 (2026) driver — scc/drivers/sc2.py

  • Reverse-engineered the v2 HID protocol (report 0x42) from USB captures. Protocol notes and a capture/diff harness are included under docs/ and tools/sc2-probe/.
  • New driver for the wireless "Controller Puck" (0x1304, a 4-slot dongle) and the wired USB-C transport (0x1302); Bluetooth (0x1303) is scaffolded as a follow-up.
  • Full input decode: buttons, both sticks, a real D-pad, both trackpads (position + pressure + capacitive touch), analog and digital triggers, capacitive stick-touch and handle-grip sensors, and the IMU (accel / quaternion / gyro — polarity verified live).
  • Click haptics (output report 0x82); lizard-mode kept disabled; controllers survive turn-off/on (the Puck re-attaches without tearing down the dongle).
  • GUI: a dedicated v2 controller image + images/sc2.config.json. The capacitive stick-touch and grip sensors are bindable directly, or usable as conditions in mode-shift combinations.

Steam Deck

  • Capacitive stick-touch parity with the v2.
  • Correct button layout (D-Pad to the top, Steam to the left column), back-button mapping, and rear-paddle order (L4/R4 above L5/R5, matching the device).
  • Per-controller icons under images/deck/.
  • Defaults "disable emulation on close" on (the Deck has no built-in keyboard, so a lingering daemon keeps emulating).

Multi-controller UX

  • A controller selector replaces the per-controller profile bars: pick a controller, then its profile. The first-connected controller is primary.
  • Each controller remembers its own profile across (re)connects — by connection slot, or by hardware serial when "Use Serial Numbers to Identify Controllers" is on.
  • Per-controller button-image overrides and distinct controller icons for v1 / v2 / Deck.
  • New README section documenting multi-controller usage (with a screenshot).

Input Test mode

  • Works for the v2 and the Deck; shows the right stick and D-pad; fixes highlight/test-area offsets; a viewBox-aware cursor; re-arms on reconnect; and clears highlights when the mode is turned off.

OSD

  • Menu fixes: Wayland window shaping (a cairo circle-clip instead of the X SHAPE extension), Python 3.13 compatibility, long-menu scrolling, and autoswitch handling.
  • A per-controller binding display with a dedicated v2 layout.
  • On-screen keyboard fixed on Wayland; "Edit Bindings" opens the OSD-keyboard editor; removed the abandoned controller-driven OSD edit mode.

GUI / mapper

  • An "Act on release" (inverted-button) modifier, useful for always-on sensors like grip touch.
  • All four grips + right-stick press offered in the button and mode-shift choosers.
  • The Steam logo on the SC v1 "C" button, sized to match the generic button.
  • Mapper fixes for non-Deck HAS_RSTICK controllers (right pad / stick / D-pad) and a v2 right-stick crash under a mode modifier.

v1 / general fixes

  • Repair a Python 2→3 port bug that dropped v1 controllers on hotplug; keep the v1 dongle alive when GET_SERIAL stalls; spawn helpers with a valid interpreter when sys.executable is bogus (AppImage); build the uinput enums via the functional IntEnum API; and repair "register new controller" and "Restart emulation".

Packaging / AppImage

  • Bundle libxml2 + ICU (so librsvg works on minimal hosts) and ayatana-appindicator + libdbusmenu (so the tray icon works); brand the AppImages as sc-controller-cc; ship a single, most-compatible jammy build; and document the one-time udev-rules step (noting the Steam Deck doesn't need it).

Asset optimization (svgo)

The 50 shipped SC1/SC2/Deck SVGs (plus 7 source assets) are run through svgo~30% smaller with no visible change. These SVGs aren't only artwork; the GTK GUI reads them in ways a naive minifier silently breaks, so tools/svgo.config.js disables exactly the passes that would:

  • cleanupIds off — the GUI looks elements up by id (AREA_* regions, controller/root/background/label_template, per-control ids).
  • convertShapeToPath off — hover / Input-Test areas are read as <rect x/y/width/height>.
  • removeViewBox / removeHiddenElems off — coordinate math needs the viewBox; the Input-Test overlay lives in a display:none layer.
  • removeUnknownsAndDefaults off — custom attributes (e.g. scc-button-scale) must survive.
  • collapseGroups / moveElemsAttrsToGroup / moveGroupAttrsToElems off — button glyphs are <g id="button"><g transform=…><path/></g></g>, and controller_image._fill_button_images overwrites the button group's transform when placing a glyph; flattening lands id="button" on the path with the normalisation transform, which then gets clobbered.
  • convertTransform offSVGEditor.parse_transform uses a comma-only regex; svgo's translate(a,b)translate(a b) rewrite makes the GUI read the element as untransformed, dropping the sc/deck controller-group offset.

tools/gen_sc2_image.py and tools/gen_binding_display.py now pipe their output through svgo (helper tools/_svgo.py) so regenerated assets stay optimized, with a source-preserving config variant for the generator-parsed art.

Type annotations

Every function, class and parameter this branch introduces now carries type annotations (driver, GUI, OSD, mapper, tools/, tests) — ruff's flake8-annotations (ANN) rules pass on the added lines. Forward references use from __future__ import annotations + TYPE_CHECKING, matching the existing style. No runtime behaviour change.


Testing

  • Hardware: SC v1 (wired + dongle), SC v2 Puck, and the Steam Deck (AppImage build).
  • svgo: config verified to preserve every element id, the <g id="button"> structure, comma-separated transforms and AREA geometry byte-for-byte; all SVGs render (rsvg-convert); the generators still produce valid output.
  • Typing: ruff --select ANN clean on the added lines; unit tests pass; full import of the touched modules succeeds.

Patola and others added 30 commits June 14, 2026 11:31
…ness

Reverse-engineered the new Steam Controller's main gamepad HID report
(0x42) from live captures of real hardware via its wireless Puck
(28de:1304). Documents the full byte layout: 4-byte button bitfield
(incl. capacitive stick/pad/grip touch and analog+digital triggers),
two analog sticks, two trackpads with pressure, and 16-bit triggers.

Notes that the IMU is disabled by default and the controller defaults to
lizard mode; both the command channel (lizard-off, gyro-on) and the IMU
stream remain to be reverse-engineered.

Adds tools/sc2-probe/, the read-only hidraw capture harness used to
produce these findings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sniffed Steam's USB traffic while it configured the controller and
decoded the host->device command protocol: SET_REPORT (0x21/0x09) with
wValue=0x03<id> (feature) / 0x02<id> (output), wIndex=interface (per
slot), 64-byte [reportID, packetType, length, params] payloads.

Opcodes match sc_dongle.py's SCPacketType: 0x81 CLEAR_MAPPINGS (lizard
disable, resent as heartbeat), 0x8E LIZARD_MODE, 0x87 CONFIGURE/LED,
0xAE GET_SERIAL, 0xC1 SET_AUDIO_INDICES, plus v2-only key/value config
(0xED "user/wireless_transport", "esb/bond"). LED level confirmed as
87 03 2d <level>. Gyro-enable register still TBD.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New scc/drivers/sc2.py implementing the reverse-engineered v2 protocol:
report 0x42 parsing (buttons, two sticks, two pads with pressure, analog
+ digital triggers, d-pad, grips/paddles, capacitive touch), mapped to
SCButtons; the wireless Puck (0x1304) as a 4-slot dongle; and the v2
command transport (SET_REPORT to feature report 0x01 per interface) with
CLEAR_MAPPINGS unlizard heartbeat + replayed CONFIGURE/LED blocks.

Modeled on steamdeck.py (parsing/mapping) and sc_dongle.py (multi-slot +
commands). Gyro enable, haptics, real GET_SERIAL read-back, the wired
(0x1302)/Bluetooth (0x1303) transports, GUI assets and live testing are
still TODO (marked inline). tests/test_sc2.py locks the 0x42 layout with
synthetic frames (no hardware needed); 11 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Live bring-up validated the protocol (lizard-off via CLEAR_MAPPINGS,
buttons/sticks/triggers/pads all decode correctly on real hardware), but
exposed an integration bug: the puck's interrupt-IN endpoint multiplexes
reports of several sizes (0x42=54B, plus shorter 0x43/0x44/0x7b). The
shared USBDevice.set_input_interrupt drops and stops resubmitting any
report whose length != the requested size, which would freeze input on
the first short report. Replace it with a per-driver lenient transfer
that requests the full 64-byte max packet, accepts any length, filters by
report ID in parse_input, and always resubmits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
End-to-end bring-up in scc-daemon on real hardware now works: the puck is
detected, the controller registers, lizard mode is disabled, and button /
stick / pad / trigger input reaches uinput (verified digital -> BTN_* and
analog -> ABS_X/Y).

Fixes found during bring-up:
- enable the driver by default (config.py "drivers": add "sc2": True);
  it was skipped as a disabled driver.
- SET_REPORT length: command builders no longer pre-pad to 64; send_control
  prepends the 0x01 report-ID byte and clamps to exactly 64 bytes. A 65-byte
  transfer was stalling the device (LIBUSB_ERROR_PIPE) on the first command.
- override disconnected() as a no-op (the inherited SCController version
  touches a dongle-only _available_serials attribute and crashed on unplug).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Live experiment (rotate controller, toggle gyro): the byte after `87 0f 30`
is the gyro/accel enable -- 0x18 on, 0x00 off -- and once enabled the IMU
streams in report 0x42 at offsets ~31-53 (bytes 31-53 go from static to
60-256 distinct values when moving). Matches what Steam sends. The driver's
configure() already emits 0x18; parse_input still zeroes the gyro fields
pending decode of the accel/gyro/quaternion sub-layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Captured isolated rotations with the gyro enabled and decoded report 0x42's
IMU block (offsets 30-53): 30-33 timestamp, 34-39 accelerometer (Z holds
~1g at rest), 40-47 orientation quaternion (w~32767 at rest), 48-53 gyro
pitch/roll/yaw. Verified each gyro axis dominates only its own motion
(pitch->@48, roll->@50, yaw->@52) and accel_z tracks gravity.

parse_input now fills accel_x/y/z, gpitch/groll/gyaw and q1..q4 from these
offsets instead of zeroing them; configure() already enables the gyro.
Accel X/Y labels and IMU signs remain provisional (polarity TBD). Adds IMU
assertions to the parser test (12 tests pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The controller has four system buttons, not three: the View button (⧉,
top-left) was untested and unmapped. Found at off3 bit 0x40 (it also emits
a lizard keyboard report). Mapped View -> BACK, and moved QuickAccess (…)
from BACK to DOTS so the four map cleanly to C / START / BACK / DOTS
(Steam / Menu / View / QuickAccess). off3 is now fully mapped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Loaded a gyro->mouse profile in scc-daemon and checked cursor direction:
yaw is natural (right->right) but pitch was inverted (up->down). Negated
gpitch in parse_input so pitch-up aims up; re-verified live (up->up,
right->right). Gyro roll sign remains untested/provisional.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Feedback:" handler did position.strip(" \t\r") on a bytes object
(TypeError) and the error path wrote b"Fail: %s\n" % (e,) (bytes %% str),
so the command always failed and dropped the connection. Decode the
position to ASCII before getattr(HapticPos, ...), and build the Fail
message with str(e).encode(). Affects all controllers, not just v2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Captured Steam's trackpad haptic feedback and decoded the rumble command:
output report 0x82 = [0x82, side, effect, amplitude] on the interrupt-OUT
endpoint (number == interface). side 0/1/2 = left/right/both, effect 0x01
= click (0x02 longer), amplitude 0x00(medium)..0xff(strong). The device
stalls this report over SET_REPORT control, so feedback() submits an
interrupt-OUT transfer instead. Verified live via the daemon's Feedback
command: right/left/both clicks land on the correct side.

It's a per-call click (fits pad/scroll detents); continuous variable
rumble, if supported, would use a yet-uncaptured report.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The cabled controller enumerates as a single HID interface 0 (interrupt IN
0x81 / OUT 0x01, no CDC) with the same report descriptor and 0x42 report as
the puck, so everything reuses. Refactor the USB device into a shared
SC2Device base (lenient interrupt-IN, SET_REPORT/feature-0x01 commands,
interrupt-OUT haptics, controller bookkeeping) with SC2Puck (4 slots) and
SC2Wired (interface 0) subclasses, and give SC2Controller an explicit
out-endpoint (puck OUT ep == interface; wired OUT ep == 1). Register 0x1302.

Verified live over USB-C: detection, registration, buttons/sticks input,
and L/R/both haptics all work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
get_gui_config_file() now returns "sc2.config.json" so the GUI renders the
controller with its real buttons/axes/gyro. The v2's controls match the
Steam Deck, so the config mirrors deck.config.json and reuses the "deck"
background image for now (a dedicated controller-images/sc2.svg is TODO).
Verified the daemon advertises it: "Controller: <id> sc2 19 sc2.config.json".

The core SC/Deck drivers have no GUI enable/disable toggle (always on), so
sc2 follows suit -- no global_settings change needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mapper assumed HAS_RSTICK / d-pad controllers were always IS_DECK,
which broke the new Steam Controller (HAS_RSTICK | HAS_DPAD, not IS_DECK):

- MouseAction.whole force-treated the right *pad* (what == RIGHT) as a
  stick (velocity-from-position) whenever HAS_RSTICK was set, so mouse()
  on the right pad became a joystick. The right *stick* already arrives as
  RSTICK, so drop the RIGHT clause -> the right pad is a relative trackball
  again. (Verified live on the v2; also restores trackball behaviour for
  the Deck's right pad.)
- Gate right-stick processing on HAS_RSTICK and the d-pad on HAS_DPAD
  instead of IS_DECK, so controllers with those flags but without IS_DECK
  get their right stick and d-pad. The Deck sets all three flags, so it is
  unaffected.

Full test suite (156) passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The modeshift "combination" list only had Left/Right Grip -- the original
SC's two back buttons. The Deck and the new Steam Controller have four back
buttons (L4/R4 -> LGRIP/RGRIP, L5/R5 -> LGRIP2/RGRIP2) and a right stick
(R3 -> RSTICKPRESS). Add LGRIP2, RGRIP2 and RSTICKPRESS to the chooser so
those can be used as modeshift combinations. Benefits the Deck too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Same gap as the modeshift chooser: the d-pad-emulation source picker
(ae/dpad.glade), the special-action button picker (ae/special_action.glade)
and the controller-settings picker (controller_settings.glade) only listed
Left/Right Grip and Stick Press. Add Left/Right Grip 2 (LGRIP2/RGRIP2 = the
L5/R5 back buttons) and Right Stick Press (RSTICKPRESS) so every binding
dialog offers the full Deck / new-Steam-Controller button set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two gaps the original SC's button set didn't cover:

- The capacitive stick-touch sensors had no SCButtons constants, so the
  decoded bits (LStick = off5 0x01, RStick = off4 0x10) were unmapped (the
  Deck leaves them out for the same reason). Add SCButtons.LSTICKTOUCH /
  RSTICKTOUCH (free bits 16/17), map them in the v2 driver, and add
  "Left/Right Stick Touched" to all four button choosers (modeshift +
  ae/dpad, ae/special_action, controller_settings).
- Now that there's a "Right Stick Pressed", relabel the old "Stick
  Pressed" / "Stick Press" to "Left Stick Pressed" / "Left Stick Press".

Driver mapping unit-tested; full suite (157) passes. (The Steam Deck driver
could now map its stick-touch bits too, via the same constants.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Deck reports stick-touch (DeckButton.LSTICKTOUCH/RSTICKTOUCH) but they
were left unmapped because SCButtons had no equivalent. Now that
SCButtons.LSTICKTOUCH/RSTICKTOUCH exist (added for the new controller), map
the Deck's bits too, so "Left/Right Stick Touched" works on the Deck as
well. The shared mapper/action and GUI-chooser fixes already cover the Deck.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The new Steam Controller (like the v1) has capacitive sensors on the
handles -- distinct from the L4/L5/R4/R5 grip buttons -- which the Steam
Deck lacks. Decoded as off5 0x20 (left) / 0x10 (right). Add
SCButtons.LGRIPTOUCH/RGRIPTOUCH (free bits 18/19), map them in the v2
driver, and add "Left/Right Grip Sensing" to all four button choosers
(modeshift + ae/dpad, ae/special_action, controller_settings). These read
"on" whenever the handles are held, which suits grip-activated modeshifts.

Driver mapping unit-tested; full suite (158) passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Grip sensing reads "on" most of the time while holding the controller, so
note a future option to invert it -- fire when the grip is released -- as a
general "inverted button" condition usable for any always-on sensor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Match the thumbstick-sensor labels ("Left/Right Stick Touched"): the
capacitive handle grips are now "Left/Right Grip Touched" in all four
button choosers. Label only; the LGRIPTOUCH/RGRIPTOUCH constants are
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The new controller's type is "sc2", but there were no sc2-* entries in
controller-icons/, so the GUI's auto-assignment fell through to a random
icon (a red player-colour) and saved it. Add sc2-0..sc2-6 (copies of the
Steam Controller icons; sc2-0 is the blue one) so an sc2 controller gets a
proper Steam-Controller icon. A dedicated v2 icon is future polish.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Record the agreed interface decisions: stick-touch belongs in a new "Touch"
tab of the stick/pad editor (not the main image); grip-touch stays exposed
on the main controller image (no parent control to nest under); and the
dedicated v2 controller artwork with AREA_* anchors + matching config is
still to come.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nsor bindings

Replace the borrowed Steam Deck GUI image with dedicated v2 artwork and add
first-class support for the controller's capacitive sensors.

Controller image & assets (generated by tools/gen_sc2_image.py from
tools/sc2-source.svg + tools/sc2-assets/):
- controller-images/sc2.svg: traced v2 body, blank face buttons, control-name
  ids so sticks/pads/dpad/bumpers/grips highlight on hover, darker body.
- button-images/sc2_*.svg: v2 face-button overlay glyphs lifted from the art
  (monochrome ABXY, round Steam, single dots, view/menu) - no duplication.
- images/sc2/*.svg: v2-specific side-panel icons (leaned-square pads, real
  view/menu, oval L4/R4/L5/R5 paddles, grip-touch silhouettes).
- sc2.config.json points at all of the above.

Capacitive sensors:
- Stick-touch: new "Touch" tab in the stick's pressed-action editor
  (ModeshiftEditor) binds LSTICKTOUCH/RSTICKTOUCH; shown only for the stick
  press, hidden elsewhere.
- Grip-touch: exposed on the controller face (curved handle overlay, green on
  hover) and as buttons in the side-panel grid.
- Both usable as conditions in mode-shift combinations.

Fixes:
- Per-controller side-panel icon override (images/<background>/<name>.svg),
  leaving v1/Deck untouched.
- Right-stick (and center-pad) "pressed action" now opens the editor
  (RSTICK->RSTICKPRESS, CPAD->CPADPRESS).
- set_action no longer throws when saving a button with no on-screen widget
  (the touch sensors).

README: note v2 support + the stick-touch/grip-sensor binding & combinations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Several independent fixes to the OSD menu:

- Register menu generators in the OSD process. menu.py now imports
  scc.osd.menu_generators and scc.x11.autoswitcher so MENU_GENERATORS is
  populated; previously it was empty in the OSD process, so every
  {"generator": ...} entry was silently dropped (MenuData.from_json_data) -
  "recent profiles" never appeared and the "All Profiles" / "Autoswitch
  Options" submenus showed only their separator.

- Long vertical menus now scroll. The item list is wrapped in a
  ScrolledWindow capped to the monitor height (sized after the items are
  packed, since the box is empty when it's wrapped and a GtkFixed won't
  re-expand it), and the viewport scrolls to keep the selection visible
  (incl. layer-shell/Wayland). Grid/radial menus opt out via scroll_wrap().

- Autoswitch Options no longer crashes the OSD daemon. It identifies the
  focused window through X, which can't see native Wayland windows and can
  hard-abort the process under XWayland; on a Wayland session it now skips
  those queries and shows "Autoswitch needs an X11 session". On X11 it works
  fully. Also fixed a None-title TypeError in Condition.matches and a
  wrong-variable crash (self.title vs display_title) in the generator.

- Install a no-op Xlib error handler (xwrappers) so a stray X protocol error
  (e.g. a window that vanishes mid-query) no longer aborts the process.

- MenuData.generate now logs and skips a failing generator instead of letting
  it take down the whole menu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two related hot-plug fixes:

- GUI: when the last controller is turned off, keep showing its image instead
  of reverting to the default (v1) Steam Controller image. A _controller_shown
  flag gates the revert so the default is only loaded at startup, before any
  controller has been connected. (scc/gui/app.py)

- sc2: turning a Puck-slot controller off makes the next control flush raise a
  USB pipe error, which the shared USB layer treated as the whole dongle
  disconnecting - it closed the device, so the controller never came back when
  turned on again. SC2Device.flush() now catches the pipe error and drops only
  that slot's controller (_slot_gone), keeping the dongle open and listening so
  the controller re-attaches on the next input. A genuinely unplugged dongle
  raises USBErrorNoDevice instead, which still propagates and closes normally.
  This also resolves a libusb mutex-assertion coredump seen when unplugging the
  dongle after it had been left half-torn-down by the old close-on-flush path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two OSD-keyboard robustness fixes:

- Wayland crash: the keyboard's label rendering reads the active X keyboard
  layout group through XKB (X.Display(GdkX11.x11_get_default_xdisplay()) +
  X.get_xkb_state). That only works on GTK's X11 backend; under a native
  Wayland session GdkX11.x11_get_default_xdisplay() returns no usable Display
  and the XKB call aborts the whole OSD daemon at the C level (uncatchable in
  Python), so "Display Keyboard" did nothing. The X display is now opened only
  on an actual X11 backend and the layout group defaults to 0 on Wayland, with
  the two get_xkb_state reads guarded. The keyboard renders and types normally
  on Wayland; only live multi-layout-group switching of the labels is lost there.

- redraw_background: scc-osd-daemon's _check_colorconfig_change() calls
  Keyboard.redraw_background() when the OSD colours change while the keyboard is
  visible, but the method did not exist (AttributeError -> daemon crash). Add it
  as a guarded queue_draw() of the background image.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… layout

"Display Current Bindings" always rendered the fixed v1 binding-display.svg
template and a hardcoded 5-box layout built for the v1 control set, so it showed
the v1 controller regardless of which one was connected, and its boxes overflowed
the screen on busier profiles.

- binding_display.py now resolves a per-controller image: an explicit
  gui.binding_display, else binding-display-<gui background>.svg (e.g.
  binding-display-sc2.svg), else the generic template. The window is built once
  the connected controller is known (on_daemon_connected) so it can pick the
  right image, and it draws that controller's current profile right away.

- The Generator box layout is per-controller now. The original 5-box layout is
  kept verbatim as the v1 fallback (_build_v1); a LAYOUTS table drives others.
  LAYOUTS["sc2"] is the Steam Deck-style v2 set: six boxes (system, left/right
  shoulder, left/right thumb, face) covering two sticks, a D-pad, two pads, four
  system buttons and the back paddles + grip-squeeze. Every control is listed but
  only bound ones draw a line, and a box with no bound controls is hidden - so
  grip-squeeze and the touch/press variants show up only when actually bound.

- Boxes auto-fit: a per-box max_height plus font auto-scaling shrinks a crowded
  box (e.g. a stick bound to a big radial) so all its lines stay inside it,
  fixing the overflow.

- tools/gen_binding_display.py generates images/binding-display-sc2.svg from the
  restyled controller art (tools/binding-display-sc2-art.svg) inlined verbatim,
  plus the AREA_* anchors of the GUI image, placing the six markers_<box>
  connector groups. Edit the art asset in Inkscape and re-run to regenerate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…oller --osd)

The OSD menu's "Edit Bindings" runs `sc-controller --osd`, which used the
controller-driven "OSD mode" (osd_mode): it reused the full main window and drove
it by injecting X11-style GDK events and matching windows by XID. That only works
on the X11 backend, and even there it was fragile (a mispositioned, black-
rendering hint overlay); on Wayland it just spawned a duplicate main window.

--osd now opens only the standalone OSD-keyboard bindings editor instead - the
same dialog as Settings > Menus & Keyboard > Advanced - on both X11 and Wayland.
It is a plain GTK window with no backend dependency, so it behaves consistently
everywhere:

- no main window is shown (so it cannot pile up duplicate main windows) and no
  tray icon;
- the OSK.* actions are registered first so the OSD-keyboard profile parses;
- closing the editor quits the process;
- an flock-based single-instance guard makes a repeat launch a no-op instead of
  stacking a second editor window.

osd_mode is left in place but is now unreachable (osk_edit_mode replaces it); it
is removed in the next commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This is the single, isolated removal of osd_mode, kept separate from v0.4 so the
v0.4..v0.5 diff is the complete record of the feature should it ever be wanted
back.

What osd_mode was: launching `sc-controller --osd` opened the main window in a
special mode you navigated with the controller itself - the pad drove focus and
a floating hint overlay (OSDModeMappings) showed the button legend - so bindings
could be edited from the couch without a keyboard or mouse.

Why it is abandoned:
- X11 only. It drives the GUI by synthesising X11-style GDK input events
  (OSDModeKeyboard/OSDModeMouse via Gtk.main_do_event) and matches windows by
  XID. Under a native Wayland GDK backend none of that works: focus cannot move
  (GTK_IS_WIDGET warnings) and there is no XID to match.
- Even on X11 it is fragile: the hint overlay latches onto the wrong active
  window and renders black (it is an override-redirect window), and editing
  happens in the full main window rather than a focused dialog.
- As of v0.4 "Edit Bindings" (`sc-controller --osd`) opens the standalone
  OSD-keyboard bindings editor instead, on both X11 and Wayland - a plain GTK
  dialog that is consistent and reliable - which made osd_mode unreachable dead
  code (osk_edit_mode replaced it).

Removed: scc/gui/osd_mode.py (OSDModeMapper/Keyboard/Mouse/Mappings); App.osd_mode,
App.osd_mode_mapper and all their conditionals; App.enable_osd_mode and
OSD_MODE_PROF_NAME; the OsdmodeMappings window in glade/app.glade; the
on_Dialog_key_press_event handler and its glade signal (action_editor); the
osd_mode button-grab/name-entry guards (action_editor, ae/buttons); and the
now-unused default profile .scc-osd.profile_editor.sccprofile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@C0rn3j

C0rn3j commented Jul 9, 2026

Copy link
Copy Markdown
Owner

b42597a How do I reproduce this issue? I do have 2x SCv1 controllers.

387c45c Can be dropped.

Remaining Appimage commits can be dropped - let's keep attaching all builds, easier for users to find issues with them, we can add a nice release table with explanations instead or something if the idea was to have users prefer the oldest build.

Patola and others added 3 commits July 9, 2026 18:24
The USBErrorBusy handler in handle_new_device called the registered failure
callback as fail_cb(*tp) -- just (vid, pid) -- but the callbacks (ds4drv,
ds5drv) are fail_cb(syspath, vid, pid), and the open-failure path above
already passes all three. The mismatch raised TypeError instead of running
the evdev fallback whenever a device was already claimed by another daemon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Turn on the complementary filter (_ACCEL_ALPHA = 0.98) with the accel<->gyro
correspondence measured on a real DS4 by logging the gravity unit vector at
held orientations: at rest gravity sits on accel +Y, nose-down swings it to
+Z, a roll swings it to X (scale ~8200 LSB/g). The previous (disabled)
formulas had pitch reading the roll axis -- why they dragged pitch/roll to a
mis-derived "level". Now pitch = elevation of gravity out of the X-Y plane
and roll = elevation out of the Y-Z plane (decoupled: a pure roll reads ~0
pitch and vice versa), with roll sign-flipped to match the gyro direction.

Verified on hardware: a held tilt stays pinned with no drift on pitch and
roll; yaw remains gyro-only (no gravity reference; drifts slowly, inherent
without a magnetometer).

Also add the calibration instrument used for the measurement, kept for the
same pass on SC2/Deck/DS5: SCC_GYRO_CALIB=1 logs the accel gravity unit
vector plus the integrated gyro angles at ~4 Hz (and opts the DS4 logger
into INFO so it is visible under the daemon's default WARNING).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s map)

TiltAction.gyro decoded q1-q4 as a quaternion unconditionally; it never got
the EUREL_GYROS branch GyroAbsAction has. On euler controllers (DS4) q1-q3
hold euler angles in 2**15/PI fixed point with q4 always 0, so quat2euler
computed atan2 of near-zero noise products -- arbitrary full-range angles
that constantly crossed the +-0.75 rad threshold and fired tilt actions
(continuous yaw output) with the pad at rest on the table.

Read q1-q3 directly as euler when the controller has EUREL_GYROS, mapped to
the slot wiring: slots are (front down/up, TILTED left/right, ROTATED
left/right) = (pitch, roll, yaw), so yaw/roll are swapped into that order,
and pitch/yaw are negated to match the slot firing directions. All six
motions plus rest-silence verified on DS4 hardware. The quaternion path for
other controllers is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Patola

Patola commented Jul 9, 2026

Copy link
Copy Markdown
Author

b42597a How do I reproduce this issue? I do have 2x SCv1 controllers.

Archaeology: introduced by c3dd3ad ("Fixed dongle connections", May 2020) during the Python 3 port — the original py2 ord(data[4]) started raising TypeError on py3 (int, not str), and wrapping it in str() silenced the exception while inverting the logic. Your current main still has it (sc_dongle.py:101).

Hardware repro (2× SCv1 + 1 wireless dongle), on current main:

  1. Pair both v1s to the same dongle (one-time, via Steam if not already paired).
  2. Run scc-daemon debug in a terminal (so you see controllers being added/removed).
  3. Power on controller A, without touching any other control, and wait. The dongle sends the HOTPLUG connect packet (status 0x03, data[4] == 2) — with the bug, nothing is added. An idle controller only emits IDLE (0x04) packets, which are ignored, so it stays invisible until some control activity produces an INPUT (0x01) packet (that path has its own add-fallback, which is why single-controller use mostly masked the bug).
  4. Power on controller B: same story — it announces via HOTPLUG and doesn't appear.
  5. The nastier half — spurious removal: any HOTPLUG connect re-announce for an endpoint that's already registered falls into the elif endpoint in self._controllers branch and removes the live controller. Triggers are firmware-timing dependent (re-link after a radio dropout / sleep-wake / dongle re-announce when the other controller joins), so it's intermittent — that's what made a second v1 "silently" vanish rather than fail loudly.

Expected with the fix (data[4] == 2): both controllers appear immediately on power-on, before any input, and re-announces are idempotent (re-add if missing, never remove).

Caveat for your test: our branch also fixes a separate v1 multi-controller issue (5c898df, GET_SERIAL stall tearing down the dongle) — on main that one can also make a second v1 misbehave, so if you test there you may see compounded symptoms. Step 3 (power on, don't touch, watch it not appear) isolates this commit's bug cleanly.

387c45c Can be dropped.

Why? This is a serious bug that caused a crash in OSD when in Wayland. Has it been fixed elsewhere?

Remaining Appimage commits can be dropped - let's keep attaching all builds, easier for users to find issues with them, we can add a nice release table with explanations instead or something if the idea was to have users prefer the oldest build.

@C0rn3j

C0rn3j commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Why?

Cause it's now in master

Patola and others added 14 commits July 10, 2026 04:13
… = lean-to-turn

The gyro->mouse mapping had the semantics backwards and half-dead: with
"Absolute" checked, the held angle was fed to mouse_move every frame, making
cursor VELOCITY proportional to tilt (a joystick semantic on a delta device:
90 deg scrolled faster than 45 and never stopped); with it unchecked, mouse
axes did nothing at all (GyroAction skipped Rels by design).

New semantics, matched against Steam's gyro-mouse:

- Absolute checked (GyroAbsAction): laser pointer. The angular rate is the
  move delta, exactly like MouseAction.gyro (the dedicated Mouse gyro
  editor); the rate integrates to the rotation angle, so the cursor tracks
  the controller's absolute orientation and stops when the rotation stops.
  Per-gyro-axis signs (pitch +, yaw -, roll -) hw-verified on the DS4.

- Unchecked (GyroAction): lean-to-turn. Cursor velocity is proportional to
  the held tilt angle (saturating at +-90 deg): lean and it keeps moving,
  return to level and it stops. Uses the fused absolute angle (EUREL or
  quat2euler), so "level = stop" is anchored to gravity for pitch/roll.
  Useful where holding a leaned position should keep turning.

Stick axes are unchanged in both modes (absolute = deflection follows the
held angle; relative = deflection follows the rate). The now-unused
GyroAbsAction.MOUSE_FACTOR velocity constant moves to GyroAction, and the
gyro editor comment is updated. All verified on DS4 hardware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The report-0x42 IMU boundary was reverse-engineered wrong: the protocol notes
placed an x/y/z/w quaternion at offsets 40-47 and gyro rates at 48-53. Held-
pose captures prove the real split -- the four i16 at 46/48/50/52 have norm
exactly 32768 in EVERY orientation (a unit quaternion, laid out w@46 x@48
y@50 z@52), while 40-45 read ~0 whenever the controller is still (the actual
angular rates). We had been feeding orientation components to the mapper as
"rates" (so relative mode followed the angle and saturated at ~30 deg) and
rates+w into quat2euler (so absolute mode was dead or axis-scrambled).

IMU axes, verified from seven held poses: x = pitch (nose-up +), y = roll
(roll-right +), z = yaw (yaw-left +); accel gravity reads +Z flat, -Y
nose-down, -X roll-right. The quaternion is firmware-fused: pitch/roll are
gravity-referenced (drift free), yaw is enable-time-referenced and drifts.

parse_input now converts the quaternion to euler host-side and hands the
mapper DS4-convention EUREL angles in q1-q3 (SC2Controller gains
EUREL_GYROS), with the raw rates sign-mapped to match: angles + at
nose-down / yaw-right / roll-right, rates + the opposite way, the per-axis
opposition the DS4 established. Every gyro path -- absolute, relative, tilt,
laser-pointer mouse, lean-to-turn -- thus shares the code and conventions
already hardware-verified on the DS4, and all of them plus the six tilt
slots are verified on real SC2 hardware (across two puck firmware updates).

Known limitation: near +-90 deg roll the euler decomposition hits its
gimbal-lock pole and pitch/yaw become noise-degenerate (inherent to any
euler projection; the quaternion itself is fine).

Also add the calibration instrument used for the measurement (and kept for
future firmware changes, e.g. the 0x45 NoQuat stream): SCC_GYRO_CALIB=1 logs
the accel gravity unit vector, the parsed rates and the derived euler angles
at ~4 Hz. Tests gain real held-pose captures as ground-truth fixtures; the
protocol notes are corrected accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AREA_LT / AREA_RT sat outboard of the bumpers at bumper height, so hovering
Left/Right Trigger brightened a region BESIDE the shoulder buttons. The
trigger art itself is drawn above the bumpers (hand-added root-space paths;
the committed viewBox is hand-extended to y=-45 to show them). Move the two
area rects onto the art's actual bounding boxes, and sync the generator's
REGION table so a regeneration keeps them there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Gemini 3.1 Pro <gemini-code-assist@google.com>
Co-authored-by: Claude <noreply@anthropic.com>
The v0.6.0.4 jammy AppImage build broke: the wheel now declares the
requires-python >=3.11 source-install floor, and jammy bundles Python 3.10,
so pip refuses it ("requires a different Python: 3.10.12 not in '>=3.11'").
The runtime is deliberately kept 3.10-compatible for exactly this AppImage
(functional IntEnum construction), so pass --ignore-requires-python.

Known-remaining (pre-existing, separate): the "tests need 3.11" skip guard
compares $UBUNTU_CODENAME, which is never set in that RUN (sourced only in
the previous, separate shell), so it errors and silently skips the tests on
EVERY base. Fixing it requires first fixing the three long-failing tests
(the 'inverted' modifier has no docs/tests entries), or every non-jammy
build starts failing -- follow-up, not part of this hotfix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The InvertedButtonModifier ("act on release") shipped without a docs anchor
in docs/actions.md or test_inverted methods, so the suite's completeness
meta-tests (test_every_action_has_docs and both TestModifiers.test_tests)
have been failing since the feature landed. Nobody noticed because the
AppImage build's test step silently never ran (see the next commit).

Add the actions.md entry and parser + profile round-trip tests. The full
suite is green, unfiltered: 160 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Dockerfile's "tests need Python 3.11+" guard compared an UNSET, unquoted
${UBUNTU_CODENAME}: /etc/os-release was sourced only in the previous RUN,
which is a separate shell, so the test `[ != 'jammy' ]` was a syntax error
("unexpected operator") whose non-zero status the `if` swallowed -- the test
suite has silently never run on ANY base since the step was added.

Source /etc/os-release in the same RUN and quote the expansion. Tests now
run on noble/bookworm/trixie (Python >= 3.11) and are skipped on jammy
(3.10), as always intended. Verified with a local container build on noble:
160 tests collected and passed in-container.

Requires the previous commit (the 'inverted' coverage): with the gate
working, its three failing meta-tests would have broken every non-jammy
build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…kept)

Brings in upstream through d472d3d: our C0rn3j#101 (minimal-host AppImage fix,
squash-merged as 15a2561), the Python 3.11 floor done upstream (e28d007,
identical to ours - clean merge), CI action bumps, fedora 43/44 test bases,
the new resolute base, and a gamecontrollerdb refresh.

Fork-deliberate divergences preserved in conflict/auto-merge resolution:

- jammy STAYS: upstream replaced jammy with resolute; jammy is this fork's
  release base (glibc 2.35 = Ubuntu 22.04 LTS onwards), so it is re-added to
  the build and test matrices alongside upstream's new bases, the release
  keeps attaching the jammy pair, and the Dockerfile keeps the
  jammy-proposed python-build workaround plus the tests-skip guard and pip
  --ignore-requires-python (jammy bundles Python 3.10).
- Release checksums STAY: upstream dropped the per-asset .sha256 step
  (3584f6e); this fork ships them.
- scc/osd/keyboard.py: upstream's Wayland crash fix (4ebb635) is equivalent
  to the one this fork already had (387c45c); ours is kept verbatim.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Code-verified feasibility notes: the emulated pad's X360 identity is pure
config (config["output"]), a keyboard+mouse-only mode already exists as the
undocumented SCC_NOGAMEPAD env var, and DS4/DS5 output splits into a cheap
evdev identity preset (glyphs, no gyro) vs a real /dev/uhid emulation
(kernel hid-playstation binds -> native in-game gyro).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MouseAction.gyro (the "Joystick or Mouse" tab's Mouse (Desktop) output) still
hardcoded its pre-calibration negation of all three rates, which inverted
pitch (screen Y) once the DS4/SC2 drivers were normalized to the shared rate
convention -- while its yaw/roll negation happened to match. Use
GyroAction.MOUSE_RATE_SIGN (hw-verified on DS4 + SC2) as the single source
of truth. Verified on SC2 hardware: pitch up now moves the cursor up.

Note for the Steam Controller v1 (whose driver predates the normalization):
if its rates run opposite, the correction belongs in sc_dongle.py -- drivers
normalize to the one convention. Queued in the v1 regression pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…labels)

The button lists were static and Steam-Controller-v1-shaped, which broke down
on the SC2 (reported by a user):

- The gyro-enable dropdowns offered no way to gate the gyro on the SC2's
  capacitive handles -- the natural "aim while gripping" enabler. "Left/Right
  Grip" there is the rear upper paddle, which users read as the handle
  sensor. Add "Left/Right Grip Touched" (LGRIPTOUCH/RGRIPTOUCH) and the
  lower paddles (LGRIP2/RGRIP2) to both gyro editors' enabler lists.
- On paddle controllers (sc2, deck) the four rear paddles are physically
  labeled L4/L5/R4/R5; calling them "Left Grip (2)" is confusing there,
  while the v1's squeeze grips must KEEP the grip naming. New button_label()
  helper renames just the paddles per controller type, applied to the gyro
  enabler lists, the modeshift/chord editor and the action editor header.
- The reverse problem: the v1 (and anything else without them) must not see
  "Grip Touched" / "Grip 2" / stick-touch entries at all. New
  button_available() filters list entries against the controller's gui
  config "buttons" capability list -- restricted to an OPTIONAL_BUTTONS set
  whose presence that list records reliably, so configless controllers
  (e.g. DS4) never lose valid entries.

Labels are display-only: profiles still store LGRIP/RGRIP2/... internally,
so saved profiles and cross-controller reuse are unaffected. Verified on
SC2 (L4/R4/L5/R5 + Touched entries, all functional as enablers) and v1
(classic grip naming, no phantom entries) hardware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reported: resetgyro (Special Action "recenter gyro sensor") did nothing.
Two real causes and one by-design case:

- Lean-to-turn (relative gyro -> mouse) had NO neutral reference at all: it
  read the driver's fused absolute angle directly, so its zero was gravity
  level for pitch/roll and an arbitrary power-on orientation for yaw --
  unrecenterable and making yaw-lean unusable. GyroAction now captures a
  neutral pose on the first event after (re)activation and measures the
  lean against it (anglediff), with reset() re-capturing. The ModeModifier
  deactivation hook already calls reset(), so a gated lean re-references on
  every activation, Steam-style.
- mapper.reset_gyros only reset GyroAbsAction; broaden to GyroAction (the
  parent class), covering absolute (ir) and relative (lean neutral) alike,
  through ModeModifier wrapping.
- The laser-pointer mouse (absolute -> mouse) is rate-based and has no
  center by nature; recentering rightly leaves it alone (now documented).

Verified on hardware: recenter-while-leaned stops the lean cursor and moves
the neutral; an absolute stick recenters its deflection at the current pose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@C0rn3j

C0rn3j commented Jul 12, 2026

Copy link
Copy Markdown
Owner

late edit -

I've done almost all of the needed rebasing work myself and now keep it in the sc2 branch - main...C0rn3j:sc-controller:sc2 - it would be nice if in the future you rebased your branch instead of doing merge commits to keep up with upstream, it creates more mess otherwise.

I would highly suggest to just hard reset your branch against the upstream sc2 branch, unless you want to dupe a lot of the rebasing effort.

Note the branch drops all the DS4/DS5 commits, as main diverged too much from your fork there.
Add any remaining necessary fixes back.


baa394e Can be dropped.
b42597a Can be dropped.
2b378da Can be dropped.
3494465 Can be dropped.
c86305a Can be dropped.
b84d3a6 Can be dropped.
a920e42 Can be dropped after splitting it into last controller image (drop) and sc2 fixes (keep)
f408014 Can be dropped after splitting it into the radial fix (drop) and the locking fix (keep)
2ec35ce Can be split into its five elements (sigh) and the autoswitcher changes can be dropped and the menu.py imports can be dropped also
6b8c865 has (I think) the wrong way to fix the issue, see AppImage/AppImageKit#1435 - if am correct, can be dropped now that I applied a workaround for appimage - and the appimage bits in e206727 too can get dropped after you make it atomic


I can't reproduce the hotplug bug - I did once get SCC into a state where the controller was eventually dropped while leaving them idle and I had to use it for input to get it to re-appear, but could not do it twice... I'll keep trying I suppose.

EDIT: I could repro it again but your change does not seem to fix that, restarting scc-daemon does not pick up on the controller anyway until it is touched, so I suppose that's a different bug.

First instance log:

...
D SCCDaemon     Controller added: <SCWireless sc2>
D OSD DaemonCtrl    Daemon reported profile change for ds4: /home/c0rn3j/.config/scc/profiles/XBox Controller.sccprofile
D OSD DaemonCtrl    Daemon reported profile change for sc1: /home/c0rn3j/.config/scc/profiles/XBox Controller.sccprofile
D OSD DaemonCtrl    Daemon reported profile change for sc2: /home/c0rn3j/.config/scc/profiles/XBox Controller.sccprofile
I SCCDaemon     Loaded profile '/home/c0rn3j/.config/scc/profiles/XBox Controller.sccprofile'
D OSD DaemonCtrl    Daemon reported profile change for sc2: /home/c0rn3j/.config/scc/profiles/XBox Controller.sccprofile
D SCCDaemon     Controller removed: <SCWireless sc2>

EDIT2: I did read the history and it does indeed to seem to be wrongly converted code though, shipped the fix.


Running tools/gen_sc2_image.py generates different sc2, LGRIP, LGRIP2, RGRP and RGRIP2 SVGs.

@C0rn3j
C0rn3j force-pushed the main branch 9 times, most recently from f7dce8c to 77949e9 Compare July 20, 2026 19:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Steam Controller (2026) support

3 participants