Skip to content

Bits and bobs from the SC2 fork - #116

Open
C0rn3j wants to merge 71 commits into
mainfrom
sc2
Open

Bits and bobs from the SC2 fork#116
C0rn3j wants to merge 71 commits into
mainfrom
sc2

Conversation

@C0rn3j

@C0rn3j C0rn3j commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Rather reworked version of #100 kept rebased against main.

This is mostly for myself to have an easy shortcut to view what's left to deal with.

@C0rn3j
C0rn3j force-pushed the sc2 branch 2 times, most recently from 05a2056 to f4dae15 Compare August 2, 2026 20:59
C0rn3j and others added 28 commits August 2, 2026 23:10
…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>

docs: add v2 command channel (from Steam usbmon capture)

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>

docs: confirm gyro enable command and IMU location

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>

sc2: scaffold the new Steam Controller (v2) driver

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>

sc2: lenient input transfer for mixed-length reports

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>

sc2: enable driver + fix live bring-up bugs

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>

sc2: decode and parse the IMU (accel / quaternion / gyro)

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>

sc2: map the 4th system button (View)

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>

sc2: fix gyro pitch polarity (verified live)

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>

sc2: implement click haptics (output report 0x82)

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>

sc2: support the wired (USB-C, 0x1302) transport

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>

sc2: GUI controller config (images/sc2.config.json)

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>

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 <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>

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 <noreply@anthropic.com>
- 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.
- 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().
… 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>
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 <noreply@anthropic.com>
Input Test Mode used the original (v1-era) observe list and a stick/pad cursor
position that pinned the indicator to the top of the area:

- Observe the v2 controls too: the "..." button (DOTS) and the capacitive
  handle-grip sensors (LGRIPTOUCH/RGRIPTOUCH). All are valid SCButtons that
  highlight by id (DOTS via the placed face glyph, the grips via their own
  elements), and they simply never fire on controllers without them.

- Centre the stick/pad cursor on both axes. The rest position used `ay + 1.0`
  (the top of the area) instead of the area's vertical centre, so the left
  stick and both trackpad balls sat half a control too high until pushed. Now
  uses the area height for both centring and the Y offset.

Right stick and d-pad still don't appear in test mode: the daemon's observe
model (source_to_constant) has no positional source for RSTICK/DPAD yet. That
is a separate, daemon-side follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Input Test Mode could not display the v2 right stick or d-pad: the daemon's
observe model only recognised STICK/LEFT/RIGHT/CPAD as positional axis sources
(plus SCButtons), so RSTICK and DPAD could not even be observed.

- daemon: source_to_constant now also accepts "RSTICK" and "DPAD". The _apply
  machinery already routes them to profile.rstick / profile.pads[DPAD], and the
  mapper already evaluates both for controllers flagged HAS_RSTICK/HAS_DPAD, so
  observing them now reports position events like the other sticks/pads.
- gui: observe RSTICK and DPAD in Input Test Mode, add right-stick and d-pad
  test cursors, and position them from the RSTICKTEST / DPADTEST areas.
- image: add the AREA_DPADTEST region to controller-images/sc2.svg (RSTICKTEST
  already existed) and to tools/gen_sc2_image.py so a regen reproduces it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ode modifier

A ModeModifier (mode/hold/doubleclick) on an analog stick or pad calls
mapper.set_button(what, ...) where 'what' is a source string. set_button only
translated LEFT/RIGHT to their touch bits, so RSTICK (and CPAD/DPAD) fell
through to "self.buttons &= ~button" and raised
"bad operand type for unary ~: 'str'". Because all of a frame's input handling
shares one try/except, the crash aborted the rest of that frame -- so on the v2
(whose analog right stick the v1 lacked) it also blocked any menu controlled by
a pad processed after the stick, making OSD menus appear to ignore input.

set_button and set_was_pressed now translate RSTICK -> RSTICKTOUCH,
STICK -> LSTICKTOUCH and CPAD -> CPADTOUCH (matching is_touched) and skip any
other non-button source instead of crashing.

Verified on hardware: Steam Controller v2 radial menu navigates and selects with
no traceback.

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

- 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 <noreply@anthropic.com>
…C v1/v2, Deck)

- sc.svg: give the pad/stick test areas real height (were h=1, so the cursor
  only moved horizontally).
- sc2.svg: v2 Input Test layer — back-paddle highlights
  (LGRIP/LGRIP2/RGRIP/RGRIP2) with readable labels, stick-press highlights,
  trigger headroom.
- deck.svg: back-button highlights L4/L5/R4/R5 -> LGRIP/LGRIP2/RGRIP/RGRIP2
  (opacity:0 shapes revealed on press, with dark readable labels); fix the
  pad/stick cursor areas (LPADTEST/RPADTEST height, reposition RSTICKTEST onto
  the right stick, add the missing STICKTEST and DPADTEST); drop two dead
  opacity:0 ellipses left over on the sticks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Patola and others added 28 commits August 2, 2026 23:11
Runs the 50 shipped controller/button/icon SVGs and 7 source assets
through svgo (tools/svgo.config.js): ~30% smaller with byte-identical
AREA geometry and rendering. Every element id, the <g id="button"> glyph
group and every comma-separated transform is preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Type annotations for every function, class and parameter this branch
introduced (driver, GUI, OSD, mapper, tools and tests), so ruff's
flake8-annotations (ANN) rules pass on the added lines. No behaviour
change.

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

- "Run Program", "Display Current Bindings" and "Edit Bindings" launch
  scc-* / sc-controller helpers via shell(), which trusts PATH and the
  binary's shebang - both unreliable in the AppImage, so they failed
  silently. on_sa_shell now runs those helpers via find_python() +
  find_binary(), the same shebang-bypassing path the daemon uses for its
  own OSD helpers; arbitrary shell commands are unchanged.

- "Turn Controller OFF" is hidden from the OSD menu for the Deck's
  built-in controls (they can't be powered off). The daemon passes the
  controller type via --controller-type; the menu drops turnoff items for
  type "deck". Since the OSD menu normally loads without an action parser
  (the daemon runs actions by id), the Deck menu now parses its actions so
  the filter can see them, and the filter is shared so QuickMenu drops the
  item too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The OSD "Display Current Bindings" template was a hand-drawn per-controller
asset (tools/binding-display-sc2-art.svg) that had to be maintained by hand and
kept in sync with the controller drawing. Replace that with a generator that
builds the template straight from the existing GUI controller drawing.

tools/gen_binding_display.py is now controller-agnostic: driven by a CONTROLLERS
table, for each entry it scales that controller's images/controller-images
drawing into the OSD canvas, recolours it into the binding-display palette (green
outlines over two greys on a dark backdrop so it recedes behind the binding
boxes), strips the AREA_* hotspots and drops a marker ring at each control
anchor. Adding a controller is now just a table entry plus a box layout.

Also relocate the output out of the images/ root into an images/binding-display/
subdir (picked up by setup.py's images/*/ glob), so _resolve_image now looks for
binding-display/<gui-background>.svg. Drop the hand-art asset, regenerate
images/binding-display/sc2.svg, and document the art-generation tools under the
README build section for future contributors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Deck had no Display Current Bindings template, so the OSD fell back to the
generic layout and drew the wrong (Steam Controller v1) picture. Give it a real
one: add a "deck" entry to gen_binding_display.py's CONTROLLERS table (the Deck's
AREA naming differs -- segmented pads/bumpers, no grip-touch) and generate
images/binding-display/deck.svg from the Deck GUI drawing.

The Deck's built-in controller shares the v2's physical control set, so it reuses
the v2 box layout (LAYOUTS["deck"] = LAYOUTS["sc2"]); controls the Deck lacks stay
unbound and their boxes simply render nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Display Current Bindings" menu entry runs shell("scc-osd-show-bindings"),
which was launched with no --controller. OSDWindow.choose_controller then falls
back to the first connected controller, so with several controllers connected it
showed the wrong controller's bindings -- and because the same controller is the
one the OSD locks its cancel button on, the window couldn't be dismissed at all
(the cancel press landed on a different controller).

on_sa_shell now appends --controller <id> for scc-osd-show-bindings, targeting
the controller that actually invoked the action (mirroring on_sa_menu). Arbitrary
user shell commands, and any command already passing --controller, are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Display Bindings window was scaled to fit only the screen *width*, and did
nothing at all when the active screen couldn't be determined -- as on the Steam
Deck under gamescope, which reports no active window. There the 1280x720 image
was shown at full size and overflowed the Deck's 1280x800 screen, pushing the
edge-anchored binding boxes off-screen so their connector lines appeared to shoot
outside the window.

compute_position now caps the image to 80% of the screen in BOTH dimensions and
falls back to the primary monitor when no active screen is reported, so it always
fits with a margin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The per-controller (sc2/deck) layout caps each box's height, so Box.calculate()
auto-scales the label font to keep a crowded box's lines inside the frame. The
original v1 layout (_build_v1) never set max_height, so that auto-shrink never
triggered and a busy box's labels spilled out of the frame and off the screen.

Give the v1 boxes the same max_height caps so they shrink to fit like the sc2/
deck layout. Boxes that already fit keep scale 1.0, so uncrowded v1 displays are
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
read_area_centers read each AREA_* rect's raw x/y, assuming the anchors live in
the drawing's user space. That holds for sc2 (identity anchor layer) but not the
Deck, whose anchors are nested in a separate layer under translated groups. Their
raw coordinates land hundreds of units outside the 446x345 viewBox, so the Deck's
shoulder (and pad) markers were placed below the canvas -- the binding boxes then
drew their connector lines shooting off the bottom of the window.

Accumulate the full ancestor transform chain (translate/scale/matrix/rotate,
space- or comma-separated) down to each anchor, so a marker lands on its control
regardless of how the source drawing nests it. sc2's identity layer is unchanged;
the Deck's markers now sit on the sticks, pads, triggers and buttons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DualShock 4, DualSense and Xbox 360 fell back to the generic (Steam Controller
v1) binding-display image. Give them their own, generated the same way as sc2/
deck: a CONTROLLERS entry (source drawing + per-box AREA anchors) plus a LAYOUTS
entry.

The three are physically alike and their drawings share the same anchor names,
so they share one marker set (gen_binding_display.py _GAMEPAD_MARKERS) and one box
layout (_GAMEPAD_LAYOUT). That layout reflects the gamepad control model, which
differs from the Steam controllers: the right stick is the right pad (pads[RIGHT],
not rstick) and the d-pad is the left pad (pads[LEFT]) -- verified against the
bundled XBox default profile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Input Test moves a test-cursor inside each control's invisible AREA_*TEST rect,
scaling the motion by the rect's width and height. Several controller images ship
those rects flattened to ~0-1px tall (ds4/ds5/x360/remotepad, and the unwired
ps1/psx/snes), so the cursor could only move horizontally -- the left stick showed
no vertical motion, and the pads barely moved.

Square each degenerate rect (height = width, keep its centre, which already sits
on the control). These are hotspot rects, not visible art, so the drawing is
untouched. sc/sc2/deck already have proper squares and are left alone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Input Test observe list never included the touchpad. Add a cpad test-cursor
and observe CPAD -- mapped to the touchpad's AREA_CPAD rect (a real rectangle, so
no *TEST square is needed) -- so the cursor tracks the finger; the daemon emits
CPAD positionally only while touched, so it hides on release like the other pads.
Also observe CPADPRESS so a touchpad click brightens the CPADPRESS element.
Harmless on controllers without a touchpad (neither source ever fires).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DS4/DS5 set HAS_RSTICK and HAS_DPAD, but store the right stick as the right pad
and the d-pad as a hatswitch, so their generic HID state (HIDControllerInput) has
no rstick_* / dpad_* fields. The mapper read them unconditionally, so every input
event raised AttributeError -- caught, but only after aborting the rest of input
processing. That silently killed the right stick, triggers and touchpad on the
DS4 (everything after the sticks block), while buttons and the left stick, handled
earlier, still worked.

Guard both accesses with hasattr(state, ...). Controllers with a real rstick/dpad
in their state (Steam Controller 2, Deck) are unaffected; gamepads on the generic
HID decoder skip the fields they don't have and process the rest normally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…n, 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 <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>
… = 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 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>
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>
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 <noreply@anthropic.com>
…ller 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 <noreply@anthropic.com>
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>

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 <noreply@anthropic.com>

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 <noreply@anthropic.com>

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 <noreply@anthropic.com>

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 <noreply@anthropic.com>

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 <noreply@anthropic.com>

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 <noreply@anthropic.com>

docs(todo): defer AppImage desktop app-id rebrand to org.patola.sc-controller-cc

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

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 <noreply@anthropic.com>

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 <noreply@anthropic.com>

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 <noreply@anthropic.com>

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 <noreply@anthropic.com>

docs(todo): DS4 gyro absolute works (drift + mouse-serialization follow-ups)

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

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 <noreply@anthropic.com>
CONTROLLER_TYPE_NAMES was missing the "ds4bt_hidraw" type (the DS4's
Bluetooth hidraw driver), so a DS4 connected over Bluetooth showed as the
generic "Controller" in the multi-controller list. USB ("ds4") and evdev
("ds4evdev") already mapped to "DualShock 4"; add the third.

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

The Steam Controller v1 was the last controller feeding its quaternion
straight to quat2euler, whose axis convention does not match the calibrated
EUREL one -- so the relative-mouse (lean-to-turn) path moved wrong (pitch
inverted, roll/yaw drifting left), while absolute happened to work.

Held-pose captures show the v1 quaternion (q1=w q2=x q3=y q4=z, norm 32767,
identity at rest, steady when still) uses the IDENTICAL axis convention as
the SC2: x = pitch (nose-up +), y = roll (roll-right +), z = yaw (yaw-left
+). Reuse the SC2's verified quat->euler mapping in the driver, hand the
mapper DS4-convention EUREL angles in q1-q3, and set EUREL_GYROS on
SCController: every controller now shares the single hardware-verified gyro
code path (absolute, relative, tilt, lean-to-turn, laser mouse).

Also add the same SCC_GYRO_CALIB=1 calibration instrument the DS4/SC2 have,
which produced the measurement. All five gyro suite tests verified on v1
hardware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

2 participants