Skip to content

Bits and bobs from the SC2 fork - #116

Open
C0rn3j wants to merge 113 commits into
mainfrom
sc2
Open

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

Conversation

@C0rn3j

@C0rn3j C0rn3j commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Rather reworked version of #100 and its follow-up hard fork, kept rebased against main.

This is mostly for myself, to have an easy shortcut to view what's left to check out to either implement it or not.

@C0rn3j
C0rn3j force-pushed the sc2 branch 3 times, most recently from f4dae15 to ce8d822 Compare August 2, 2026 21:11
@C0rn3j
C0rn3j force-pushed the main branch 2 times, most recently from 69fd146 to 2178249 Compare August 9, 2026 14:38
@C0rn3j
C0rn3j force-pushed the sc2 branch 2 times, most recently from b4bff7e to d2a6658 Compare August 13, 2026 17:11
C0rn3j and others added 23 commits August 17, 2026 20:52
…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 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.
… 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>
Patola and others added 3 commits August 17, 2026 21:49
Opening a saved binding brought up an editor with no action in it:
output "None", feedback switched off, side back to left, and only the
effect itself preserved.

load_modifiers() has two halves. The first parses the modifier chain
into editor state; the second writes that state into the widgets, and it
is guarded by _recursing because every widget write fires a 'changed'
handler that calls update_modifiers(). The effect chooser and its
parameter rows were being written in the FIRST half, unguarded, so:

  - the combo emits 'changed' -> update_modifiers() runs
  - it reads cbFeedback, still unchecked because that widget is not
    written until much further down, and concludes feedback is off
  - it marks the action dirty and calls set_action(self._action), which
    on a freshly opened editor is None

The editor then loads None and shows the first page, and the action
being edited never arrives. Which half of the panel survived told the
story: the effect was kept because it was the one thing already written.

Fixed by moving those widget writes down into the guarded block with
every other one. The parse loop now only records state.

Covered by test_opening_the_editor_keeps_the_feedback, which goes
through set_input() -- the entry point the dialog actually uses.
test_loading_an_effect_restores_the_widgets went the same way; it had
been setting _recursing by hand, and that fake is exactly what hid this.

Refs Patola#7

Co-Authored-By: Claude <noreply@anthropic.com>
A slider spanning 20-1000 Hz in about 200 pixels is roughly 5 Hz per
pixel, so there was no way to ask for 220 Hz specifically -- and the
arrow keys moved in jumps of 10, because the per-parameter step was
being used as the adjustment's step_increment.

Each numeric row is now a slider and a spin button sharing one
Gtk.Adjustment: drag to explore the range by feel, type or click the
spin arrows to land on a value exactly. Sharing the adjustment is what
keeps them honest -- there is no synchronising code that could get it
wrong, and the 'value-changed' handler hangs off the adjustment rather
than either widget. The slider no longer draws its own number, since the
spin button is already showing it.

Arrow keys and the spin button now always step by 1. The table's step
became the page increment instead, for Page Up/Down and trough clicks:
10 Hz for the frequencies, 50 ms for duration. Preset is untouched, it
is a named dropdown.

Wrapping each row in a Box made it reachable by show_all(), which would
have unhidden every parameter row at once, so hidden rows are marked
no-show-all. The new assertion in test_effect_rows_follow_the_chosen_effect
fails without it.

Refs Patola#7

Co-Authored-By: Claude <noreply@anthropic.com>
Reports 0x83, 0x84 and 0x85 are now verified live, not just read out of
the SDL3 source and iczero's RE:

  - a sweep is clearly distinguishable from a flat tone, so start_freq
    and end_freq do what they claim
  - the LFO fields work but are weak. At lfo_freq 4 / lfo_depth 128 the
    modulation is barely perceptible; only at 10 / 255 is it obvious, so
    depth is probably not a percentage
  - the preset ids are real and their RE'd names broadly describe what
    you feel. CONTROLLER_VERY_ON, CONTROLLER_OFF and PHONE_RINGING_1 are
    strong and unmistakable; several others are subtle
  - WILHELM_SCREAM is barely perceptible even at maximum gain. It is a
    long sample, so this is most likely the preset itself rather than
    anything we send -- worth knowing before someone goes hunting for a
    bug in the gain path

Nothing was found past 0x10, but nothing rules it out either; the list
is still recorded as possibly partial.

Refs Patola#7

Co-Authored-By: Claude <noreply@anthropic.com>
C0rn3j and others added 14 commits August 17, 2026 21:56
With Trackball Mode on, the sensitivity sliders did nothing at all on a
DS4 right stick.

A ball on a self-centering stick is meaningless -- it works on position
deltas, so holding the stick still stops the pointer and releasing it
drags the pointer back -- so BallModifier.whole passes RIGHT straight
through when that slot carries stick data. But SensitivityModifier walks
down to the first action with set_speed() and stops there, which on a
trackball binding is the ball, and the ball applies its speed only in
_add and _roll: both on the path it just skipped. The speed landed on a
modifier that had already stepped aside, and the child stayed at 1.0.

The ball now hands its speed to the child before bypassing, multiplied
into whatever the child already has (so it is right whichever side of the
ball the sens() ended up on) and once only, since set_speed is absolute.
It must not happen when the ball is really running, or the speed would be
applied twice; that is asserted.

Also: BallModifier.compress overrode Modifier.compress and dropped its
child-recursion, so a sens() written INSIDE a ball survived as a live
SensitivityModifier -- which has no whole(), so the binding did nothing
whatsoever. The GUI always writes sens() outside, so only hand-written
profiles could hit it. Restored, keeping the ball(circular(...))
turn-around it overrode compress() for in the first place.

NOTE: the compress change affects every controller, not just the DS4.

Refs Patola#5

Co-Authored-By: Claude <noreply@anthropic.com>
The Strength slider stopped at 32639 instead of 32767, which reads as an
off-by-something in the code rather than as a limit -- and it silently
capped the range every haptic strength measurement was taken over.

A GtkAdjustment's usable maximum is upper - page_size, and page_size means
nothing for a slider. adjFAmplitude and adjFPeriod both carried 128.

Guarded by pinning the amplitude slider's range to the driver's own
HAPTIC_AMPLITUDE_MIN/MAX rather than banning the property outright: some
adjustments use it deliberately (global_settings sets upper 10.01 with
page-size 0.01 to land on a round 10.00).

Co-Authored-By: Claude <noreply@anthropic.com>
README: a DS4/DS5 cannot be claimed exclusively over Bluetooth. A Steam
Controller is taken by claiming its USB interface, which detaches the
kernel driver; a PlayStation pad over Bluetooth is reached through
/dev/hidrawN, and hidraw allows several readers at once. With Steam
running, Steam and SC Controller both receive every report and both act on
it, and no code of ours can prevent that.

Written symptoms-first, because the failure does not look like a conflict:
the touchpad glides with an acceleration nobody configured, its click
gives a mouse button whatever you bind, and yet your other bindings work
fine -- so it reads as half-broken configuration rather than as a second
program driving the pad. That misreading cost three rounds of debugging
here before someone noticed Steam holding the hidraw node.

TODO: haptic feedback on the DS4 is not implemented at all -- all three
DS4 controller classes inherit a no-op feedback(), and EvdevController's
is an empty TODO docstring. ds5drv.feedback is close to a template, with
two things not to copy blindly, and Controller.rumble() is unimplemented
for both pads, which is why fftest's heavy and light effects feel
identical on them.

Co-Authored-By: Claude <noreply@anthropic.com>
The daemon was calling os.umask(0) after daemonizing, which left all
subsequently created files (including the IPC socket) world-accessible.
Change to os.umask(0o077) so files are owner-only by default.

For the IPC socket, call os.chmod() to enforce 0600 immediately after
bind() and before serve_forever() starts accepting connections,
ensuring there is no window where the socket is accessible to other
users.

Assisted-by: Claude Opus 4.6
Signed-off-by: Sergio Correia <scorreia@redhat.com>
The Bluetooth long-packet reassembly extracts a 4-bit packet
number from the incoming data (range 0-15) and uses it to compute
a write offset into a 256-byte buffer. For packet numbers >= 14
the offset exceeds the buffer, and the subsequent memcpy corrupts
adjacent struct fields on the heap.

Reject packets whose computed offset would overflow the buffer.

Confirmed by walking the arithmetic: with PACKET_SIZE 20 the offset
is 18n + 2, so packet 14 writes to 272 and packet 15 to 290, past
the end of a 256-byte buffer -- 16 and 34 bytes of heap corruption
driven by a nibble a paired device chooses.

[patola: offset made size_t. As submitted, the bounds check compared
 an int against sizeof and warned under -Wsign-compare, which is not
 where you want a signedness question. Restored the two comment lines
 the patch replaced -- they explain the +2.]

Assisted-by: Claude Opus 4.6
Signed-off-by: Sergio Correia <sergio@correia.cc>
Co-Authored-By: Claude <noreply@anthropic.com>
The CLAMP macro was defined as a no-op (expanding to just x),
providing no actual clamping. Replace with a correct implementation.

Add a data_len parameter to grab_value/grab_with_size and validate
byte_offset against it before reading, preventing out-of-bounds
reads from malformed HID reports. Guard the HATSWITCH case against
i+1 overflowing the axes array.

Recalibrate DS4/DS5 axis parameters to work with the real CLAMP:
the old values repurposed clamp_max as a scale factor (e.g. 257),
which only worked because CLAMP was a no-op. Normalize fval to
[-1, 1] (sticks) or [0, 1] (triggers) and use STICK_PAD_MIN/MAX
and TRIGGER_MAX as proper clamp bounds, matching the evdevdrv
convention. Set decoder.packet_size in DS4/DS5/BT constructors
so the new bounds check has the correct buffer length.

Assisted-by: Claude Opus 4.6
Signed-off-by: Sergio Correia <scorreia@redhat.com>
The IPC socket accepted arbitrary action strings in Replace: commands,
including shell() which executes commands via subprocess. The Profile:
and Selected: handlers accepted arbitrary filesystem paths, allowing
any socket client to load attacker-controlled files as profiles or
menus containing shell() actions.

Add SafeTalkingActionParser that filters dangerous action types
(shell, profile, restart, exit, turnoff) from Replace: commands,
including in nested/composite actions. The override changes the
default parameter so recursive _parse_action() calls also use the
filtered dict.

Reject Profile: and Selected: names containing '/' and resolve them
through find_profile()/find_menu() which only search known safe
directories.

Note what this filter is and is not. It is defence in depth, not a
security boundary: type() and button() have to keep working for
Replace: to have any purpose, and type('...') plus Enter drives
whatever terminal happens to be focused. The boundary is the socket
being 0600 (see the umask commit); this narrows what a client that
already has that access can do without effort.

[patola: dropped the realpath containment check in find_profile and
 find_menu. The basename check already stops traversal completely, and
 resolving symlinks additionally rejected a profile that is a symlink
 to somewhere else -- how people keep them in a dotfiles repo -- while
 only defending against a symlink planted inside the profiles
 directory, where an attacker could equally just write a profile full
 of shell() actions. Same reasoning in the daemon's two path checks:
 abspath instead of realpath, which collapses '..' lexically so
 traversal still cannot escape.

 Rejecting '.' and '..' explicitly, because basename('..') == '..'
 passes the basename check and, for menus, resolves to the directory
 itself. The submitted realpath check caught that by accident; the
 lexical one has to say so.]

Assisted-by: Claude Opus 4.6
Signed-off-by: Sergio Correia <sergio@correia.cc>
Co-Authored-By: Claude <noreply@anthropic.com>
Replace os.system() in on_sa_restart with subprocess.Popen using a
list and find_python(), eliminating shell injection via sys.argv[0]
and ensuring the correct Python interpreter is used in AppImage
environments.

Fix Task.__lt__ which was a no-op (missing return). Add a sequence
number as a tiebreaker so equal-timestamp tasks maintain FIFO order
and PriorityQueue never falls through to comparing incomparable
callback objects.

Assisted-by: Claude Opus 4.6
Signed-off-by: Sergio Correia <scorreia@redhat.com>
The header lookup ended in an unguarded `else`, so a machine with neither
kernel headers nor the bundled copy did not get a diagnosis -- it got
FileNotFoundError on /usr/include/linux/input.h, a path it had never
been told to care about, raised from inside an import three levels down.
That is exactly the state a build sandbox is in, and reading it from the
far end cost a round trip.

The three candidates move into _find_event_codes_header(), which raises
ImportError naming every path it tried and how to satisfy it. `exists`
is injectable so the empty case is testable without a machine in that
state; the preference order is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
has_haptic_effects() asked whether the connected controller's type is one
that can synthesise waveforms. With no controller connected the type is
None, which is not in that set, so the Effect row disappeared from the
Feedback tab -- reproducible by opening a pad's configuration with the
Steam Controller 2 switched off.

There are three states, not two: capable, incapable, and nothing
attached. An absent controller says nothing about the hardware a profile
is written for, and editing a profile with the pad off is ordinary. Treat
unknown as capable; a known incapable controller still hides the chooser.

Saved effects were never at risk -- update_modifiers skips the effect
block when unsupported and _make_action rebuilds from the loaded state,
so a Tone opened offline stayed a Tone. There is now a test saying so,
because that was the failure worth ruling out.

Reported by Patola on a Steam Controller 2.

Co-Authored-By: Claude <noreply@anthropic.com>
ReplacedAction.button_press passed mapper as both arguments instead
of forwarding the call correctly, causing TypeError on every button
press through a Replace'd action.

ReportingAction.whole had a misplaced parenthesis that evaluated the
comparison inside abs() instead of outside, defeating the Y-axis
minimum-difference threshold for event filtering.

ReplacedAction.__init__ also bypassed LockedAction.__init__, which
meant the held-button-release guard was skipped: if a button was
physically held when Replace: was issued, the old action's virtual
key stayed stuck down. Add the same release-if-held guard.

Assisted-by: Claude Opus 4.6
Signed-off-by: Sergio Correia <sergio@correia.cc>
force_restart() appended a bare (vendor_id, product_id) tuple to
_retry_devices, but the retry loop unpacks each entry as
(syspath, (vendor, product)). The mismatch raised ValueError,
crashing the USB mainloop and killing communication with all
controllers.

Store syspath on USBDevice instances when they are successfully
handled, and include it in the retry tuple.

Assisted-by: Claude Opus 4.6
Signed-off-by: Sergio Correia <sergio@correia.cc>
__mul__ passed self.frequency (already scaled by 1000x in the
constructor) back through the constructor, which multiplied by
1000 again. This also silently dropped extended effect attributes
(tone, sweep, LFO settings).

Apply the same pattern as with_position(): construct with a dummy
frequency, then copy the pre-scaled frequency and all extended
attributes directly. Extract the shared attribute list into a
class-level _EFFECT_ATTRS tuple used by both methods.

Assisted-by: Claude Opus 4.6
Signed-off-by: Sergio Correia <sergio@correia.cc>
The outer switch in remotepad_controller.c had no break between
RETRO_DEVICE_JOYPAD and RETRO_DEVICE_ANALOG, causing every button
event to also execute the analog processing path.

The CemuHook Python wrapper passed 6 arguments to a 5-parameter C
function. The extra unused buffer argument is removed.

Assisted-by: Claude Opus 4.6
Signed-off-by: Sergio Correia <sergio@correia.cc>
C0rn3j and others added 3 commits August 17, 2026 21:57
RangeOP for both STICK and RSTICK created children that resolved
to lpad_x/lpad_y axis names, so modeshift conditions on STICK
read left-pad state instead of stick state, and RSTICK conditions
never matched the right stick at all.

Override children's axis_name to stick_x/stick_y and rstick_x/
rstick_y respectively, and guard _state() against AttributeError
for controllers whose state struct lacks the requested axis (every
cmp_* already treats None as no-match).

[patola: took only the actions.py half of the submitted patch. The
DS5 Bluetooth touchpad scaling it also carried is held back: it
scales unconditionally, where the USB path it copies only scales
inside the touched branch, so a lifted finger would report a pad
corner instead of centre -- and it scales a cpad_y that is itself
extracted wrongly ((data[37] & 0x0F) << 4 keeps 8 bits of a 12-bit
field). That needs the extraction fixed first and a DualSense to
test on.]

Assisted-by: Claude Opus 4.6
Signed-off-by: Sergio Correia <sergio@correia.cc>
The button_map guard allowed values up to 32 (< 33), but shifting
a uint32_t by 32 is undefined behavior in C. Change the guard to
< 32 so only valid bit positions 0-31 are used.

Assisted-by: Claude Opus 4.6
Signed-off-by: Sergio Correia <sergio@correia.cc>
The _threaded method and kill() both read and write self.p without
synchronization. If kill() checks self.p between _threaded's
communicate() return and self.p = None assignment, it may call
kill() on None or on a stale Popen. Add a threading.Lock to
protect self.p access.

Also re-check _killed inside the lock before spawning, closing a
narrow TOCTOU between the while-guard and the lock acquisition
that could leave an orphaned child process after kill() returns.

Assisted-by: Claude Opus 4.6
Signed-off-by: Sergio Correia <sergio@correia.cc>
@C0rn3j
C0rn3j force-pushed the sc2 branch 2 times, most recently from a917b6c to c5bd306 Compare August 17, 2026 20:01
C0rn3j and others added 2 commits August 17, 2026 22:02
The "maps to nothing" sentinel was BUTTON_COUNT - 1. That is bit 31,
which is RSTICKPRESS -- a real button -- so on a JSON-configured HID
controller every input bit the config did not name pressed the right
stick, and button_to_bit reported the same thing for a mask it could not
resolve.

Use BUTTON_UNMAPPED = BUTTON_COUNT instead, which is outside the range
the decoder can turn into a button. That value is only safe now that the
preceding commit rejects 32 rather than 33: before it, an out-of-range
sentinel would have been shifted into a uint32_t by 32 -- undefined
behaviour rather than the intended no-op.

Found while reviewing Sergio Correia's shift-UB fix, which this builds on.

Co-Authored-By: Claude <noreply@anthropic.com>
Two faults that compounded into data loss. save() wrote in place, so
anything killing the process mid-write left truncated JSON; reload()
then answered the parse failure by calling create(), which overwrites
the file with defaults. One interrupted write therefore discarded every
setting the user had, with no copy kept.

save() now writes a temp file in the same directory, fsyncs it and
os.replace()s it in, so a reader sees either the old contents or the
new. reload() moves an unreadable config aside to config.json.broken.N
before falling back to defaults, numbered so a second failure cannot
overwrite the copy taken during the first -- which would be the only
surviving record of the settings. A missing file is treated as a first
run rather than an error, so it no longer logs a warning or leaves a
stray backup.

Also: "Minimize to status icon instead closing" -> "instead of closing".

Reported by Patola, who lost his settings twice; the fingerprints
matched exactly -- minimize_to_status_icon and autokill_daemon sitting
at their defaults, news.last_version reverted from 0.4.8 to the default
0.3.12, and osd_color_theme (which has no default) gone entirely.

Co-Authored-By: Claude <noreply@anthropic.com>
Navigating an OSD menu with a DualShock 4's left stick was unusable:
nudging it scrolled the list far too fast, and holding it fully deflected
stopped the selection dead. The d-pad on the same pad was fine, and both
were fine on an SC2.

One cause, two symptoms. Menus accept STICK, LEFT and DPAD together --
the d-pad arrives as LEFT on a DS4 and as DPAD on an SC2 -- and all of
them fed one StickController. _move() emits on every direction change and
cancels the repeat timer once the direction reaches zero, so the idle
input reporting (0, 0) cancelled the repeat the stick was driving.
Movement then happened only on changes: once per event, at the
controller's report rate, hence "too fast" -- and nothing at all once the
stick was held still and stopped producing events, hence "stalls".

set_stick now takes the input the position came from and keeps one entry
per source, letting whichever is actually deflected decide. Callers with a
single input (launcher, dialog) pass no source and are untouched.

Pre-existing: LEFT has always been accepted here. The DPAD added in
v0.6.0.7 only affected controllers with a real separate d-pad, which are
the ones that behaved correctly.

Also makes the packet_size tests from the security series skip rather
than fail where libhiddrv is not built. They went in red -- CI has been
failing since that merge, because I ran the suite only on a machine that
has the extension.

Fixes Patola#17

Co-Authored-By: Claude <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.

3 participants