Everything about how the controller behaves — paddle bindings, both
trackpads, and which kind of pad the game sees — is now configured in one
window and saved per game, rather than spread across tray checkboxes that
applied everywhere at once.
Installed games come from the Start Menu and the packaged-app list. Steam
does not shortcut its library with .lnk files, so its games are read from
the .url shortcuts it does write; Xbox and Store titles register no shortcut
file at all and come from the shell's app list instead. None of these
sources can tell a game from any other program, so the picker searches
rather than scrolls — a saved profile is pinned, everything else is reached
by typing.
Trackpads are configured per pad. The previous pair of toggles could only
ever describe one pad doing one thing, because a single TrackpadMouse
instance was switched between pads by a bool; there are two now. A pad can
move the pointer, scroll, feed the DS4 touchpad, or do nothing, and its
click is an ordinary binding dispatched down the same path as the paddles —
it used to be hardwired to a left click that only fired while that pad drove
the mouse.
Key bindings can carry Ctrl, Alt, Shift and Win. The modifiers ride in the
free top byte of the packed binding, so values written by earlier builds
decode unchanged. Windows is chosen from a toggle rather than pressed: the
Start menu opens before the page is told anything, which is what made that
key unbindable before. Choosing modifiers rather than typing them also keeps
combinations like Alt+Tab from being claimed by the system mid-capture.
Ctrl+Alt+Delete is refused outright, being the Secure Attention Sequence
that no ordinary process can send.
Profiles follow the foreground application, and nothing polls to do it. An
unelevated process cannot subscribe to process-start events — the WMI class
that reports them requires administrator rights, as does an ETW kernel
session — but SetWinEventHook reports foreground changes for the cost of one
hook and no thread. Focus is also the better question than "is it running":
a launcher that starts a game and stays resident answers yes long after the
game has exited. Steam profiles are keyed by app id, which a foreground
window cannot supply, so Steam's manifests are read to map that id to an
install directory; a game's launcher, its anti-cheat service and the game
itself all live under it and all resolve to the same profile.
Auto Mode still decides whether the controller is ours at all, so a profile
only loads while we hold the pad. A balloon names the profile when one
loads, under the notification setting, because a profile that quietly fails
to match is otherwise invisible.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Press-to-bind only listened to the controller, so the remap window could
offer gamepad buttons and mouse clicks but nothing from the keyboard.
Capture a key press while a row is listening and bind that instead.
Capture happens in the remap page's keydown handler rather than through a
keyboard hook. The page is already focused while listening, and a
low-level hook is a heavy, AV-bait mechanism for a convenience feature.
The page reports the physical KeyboardEvent.code, which KeyInput turns
into a virtual-key code, so a binding made on one layout means the same
physical key on another. Escape stays the cancel affordance and is
deliberately not bindable.
Keys are delivered with SendInput as scan codes rather than virtual keys,
because software that reads scan codes directly sees nothing useful
otherwise. Note that injected input does not reach every game — anti-cheat
and some input paths ignore it — so this is realistically a desktop,
emulator, and browser feature. Gamepad bindings remain the answer in game.
Extended keys are flagged from an explicit set. MapVirtualKey is
documented to report the 0xE0 prefix under MAPVK_VK_TO_VSC_EX but does
not do so for the navigation cluster, which shares scan codes with the
numpad — a test caught Up arrow being delivered as numpad 8.
Labels come from GetKeyNameText against the active layout, so a non-US
keyboard shows its own key names, and are JSON-escaped on the way to the
page: the backslash key really is named "\".
Paddle release tracking now records what the press actually sent instead
of re-reading the binding on release, so rebinding a paddle mid-hold
releases the input that was pressed rather than stranding it down.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Keyboard and extended mouse bindings can't be enumerated the way gamepad
buttons can — a key binding is a value drawn from ~256 virtual-key codes,
not a member of a closed set. BackButtonConfig held four bare
BackButtonAction values, so there was nowhere to put one.
Introduce BackButtonBinding, a kind/code pair covering built-in actions,
keyboard VK codes, and extended mouse buttons. No behaviour change: only
Kind::Action is produced or consumed today. This is the groundwork for
capturing keys and mouse buttons in the remap window.
Both formats extend without a migration:
- Registry: packed as (kind << 16) | code. Every value written by earlier
builds is a bare BackButtonAction of 0-18, so its kind bits are already
zero and it decodes as Kind::Action. Action bindings still pack to their
bare value, so a downgrade also reads settings written by this build.
- WebView2: built-in actions keep their bare ids ("A", "leftMouse"), and
the open-ended kinds are prefixed ("key:32", "mouse:x1"). Flat strings
keep the hand-rolled JSON reader in RemapWindow usable, and the JS input
catalog needs no changes.
Unrecognised values — corrupt, or written by a future build that knows
kinds this one does not — degrade to None rather than being applied.
Verified the invariants with a standalone test over the full value space:
legacy decoding, pack and id round-trips for every kind, rejection of
malformed input, and the shipped defaults.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Back Buttons as Mouse Click" hijacked L4 and R4 unconditionally: the
virtual controller skipped their bindings entirely, so a paddle mapped to
RT silently did nothing while the option was on. Only the upper paddles
were affected, so the symptom read as "two of my four paddles are dead"
rather than pointing at the toggle. The same gate also suppressed the
Left/Right Click bindings the remap window already offered, meaning the
feature disabled the very capability it existed to provide.
The toggle was a strict subset of the remap window, which already offers
left and right click per paddle, so retire it rather than teach it to
cooperate:
- Remove the backMouseEnabled gates, the tray item, and the duplicate
L4/R4 handling in TrackpadMouse. Paddle mouse output now flows through
the single edge-detection path in ReadLoop.
- Default L4 and R4 to left click so a fresh install still clicks out of
the box.
- Add an explicit Off option to the picker. None was already a real
"do nothing", but Reset now yields left click, so without this there
would be no way to disable a paddle at all.
- Migrate BackButtons=1 into real bindings, filling in only paddles with
no binding of their own so an explicit mapping is never overwritten,
then delete the value so it runs once.
TrackpadMouse::Reset used to release a paddle held while game mode was
torn down. Removing its L4/R4 handling would have stranded the mouse
button down, so that cleanup moves to ReleasePaddleMouseButtons, which
also covers right click and runs on the enable, disable, and destructor
paths.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The receiver stopped working after a controller firmware update. Original
diagnosis and fix by @danbosscher; extended here for Bluetooth, Steam
handoff, and several defects found during QA on the hardware.
The puck publishes a controller interface per slot plus a management
endpoint, and on current firmware an empty slot rejects the lizard-mode
command outright.
- Enumerate only the controller collection (FF00:0001), excluding the
puck's management endpoint at FF00:0002
- Wait for a live 0x42/0x45 state report before sending feature commands,
and skip slots that never produce one
- Prefer an exclusive claim but accept a shared handle when Windows keeps
a compatible write handle open — only while Steam is absent, since a
handle held with Steam running is probably Steam's, and driving the
controller alongside it is worse than refusing. Refusing is what
escalates to the device cycle that takes the handle back.
- Add the Nereid receiver PID (0x1305)
Defects found by QA and fixed here:
- The battery payload is [chargeState, percent] on every transport, not
just Bluetooth. A 92% battery was reported, and fed to the DS4 battery
level, as 1%.
- Game mode could not tell an idle receiver from a contested one, so a
controller that was merely switched off triggered three device cycles,
restarting the receiver's devnodes for nothing.
- A receiver raises no device event when a slot becomes occupied, so
waking a controller went unnoticed for as long as it was left. Poll for
it instead.
- Overlapping cycles undid successful claims: a cycle is asynchronous, and
the arrivals it generated re-entered the acquire path and fired another
on top of it, ripping the device back out a second after game mode came
up. Cycles are now spaced.
- Failure was announced while the last cycle was still landing — the alert
fired one second before success. The verdict now waits out a grace
period.
Verified on the puck, Bluetooth, and wired: handoff to Steam on game
launch, reclaim afterwards, and no duplicate input.
Reclaiming takes around nine seconds on a four-slot receiver, dominated by
SetupDiCallClassInstaller tearing down each interface in turn. Three
attempts to shorten it (batching the cycle, a shorter settle, cycling only
the contested interface) all regressed the reclaim and were reverted; it
needs to be worked out against hardware rather than reasoned about.
Co-Authored-By: Dan Bosscher <danbosscher@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Control modes (tray 'Control Mode' submenu, persisted):
- Manual: tray toggle only (grayed labels now explain why when disabled)
- Auto - Off while Steam running: yield the controller whenever steam.exe
runs; reclaim ~6s after it exits (debounced against Steam self-restarts)
- Auto - Off ONLY while in Steam game: yield only while RunningAppID != 0
SteamWatcher polls the Steam process and RunningAppID every 2s with
asymmetric debounce: transitions toward more Steam activity are reported
immediately (yield fast), transitions away must hold ~6s.
Taking the device from a running Steam requires invalidating its HID write
handle. A new elevated helper (SteamlessDeviceCycle.exe) restarts the
controller device node (unplug/replug equivalent) on demand via a
highest-privileges scheduled task, registered by the installer or a one-time
UAC prompt. The tray app itself NEVER runs elevated: an elevated foreground
window blocks unelevated SendInput via UIPI, which froze Steam Input's
desktop cursor whenever the tray menu was open. The same cycle mechanism
hands the device to an already-running Steam on game launch (Steam only
opens controllers on arrival events).
Crash safety: an unhandled-exception filter best-effort restores lizard
mode without taking locks; the firmware's own revert timeout remains the
final fallback. ClaimExclusive now restores shared access on failure
instead of leaving the device handle closed.
Diagnostics for field disconnect reports:
- Event log at %LOCALAPPDATA%\SteamlessController\events.log (512KB
rotation, read-shareable): connect/disconnect causes, game mode
transitions with failure reasons, battery changes, stalls, keepalive
failures, auto-mode decisions. 'Open Event Log' in the tray menu.
- Silent-stall detection: no reports for 4s while enumerated (battery
death, sleep, wireless drop) is now detected, logged, and alerted with
the last battery reading.
- Disconnect/stall alert balloons, suppressed for intentional handoffs,
with a 'Disconnect Notifications' toggle for users with odd setups.
Startup reverts to the plain HKCU Run key (the elevated-logon-task variant
from an interim build is migrated back and cleaned up). Installer ships and
registers the helper and unregisters it on uninstall; version 1.8.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The core fix: the SC2026 firmware silently reverts to lizard mode (including
its autonomous click haptics) after a period without host feature reports,
which doubled up with software haptics as a crunchy burst. A keepalive now
re-asserts the lizard-off state every 2 seconds while game mode is active.
Haptics reliability and feel:
- Switch trackpad haptics from OUT_HAPTIC_PULSE (grip LRAs) to
OUT_HAPTIC_COMMAND (trackpad LRAs) for localized feel
- Click haptic latch: press fires off the debounced firmware click bit,
release only fires once contact area returns to idle, so force chatter
and mid-hold easing cannot re-fire
- Movement ticks: distance-based with motion deadzone, post-release and
first-touch grace windows, pressing/teleport gates (pause, not reset),
idle decay, and a 50ms send rate limit so firmware waveforms never queue
into a backlog
Handoff and mouse:
- EnableLizardMode now also sends LOAD_DEFAULT_SETTINGS so firmware mouse
emulation resumes immediately on disable instead of waiting for the
firmware revert timeout
- Trackpad mouse: carry sub-pixel movement remainders between frames and
retune sensitivity (0.015 -> 0.01125)
Also: shared/exclusive HID reopen support, local-only HapticTester tool
(gitignored, conditional CMake target), installer version bump to 1.7.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Introduces a ControllerPlatform enum (Xbox, PlayStation) and a full
DualShock 4 translation path alongside the existing Xbox 360 one.
Switching platforms live tears down and recreates the virtual device.
The selection persists in the registry and is exposed via a
"Controller Platform" submenu in the tray context menu.
DS4 translation maps face buttons to Cross/Circle/Square/Triangle,
encodes the D-pad as a HAT switch (with diagonal support), converts
signed 16-bit axes to uint8 with Y-axis inversion, and routes right
trackpad clicks to the DS4 touchpad button.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces a WebView2-based remap window (double-click tray icon or
'Remap Back Buttons...' context menu) that lets users assign any of
L4/L5/R4/R5 to a face button, bumper, trigger, d-pad direction, stick
click, Menu/View, mouse button, or 'none' (natural behavior).
- BackButtonConfig: new enum + struct; values stable as registry DWORDs
- RemapWindow: chromeless WebView2 window with a button picker and
press-to-detect capture mode; settings persist across launches
- ControllerManager: StartButtonCapture/StopButtonCapture API with
rising-edge HID detection for all mappable targets
- VirtualController: ApplyBackAction injects the mapped XInput button
each frame; mouse-button actions are edge-detected in the read loop
- TrackpadMouse: back-button mouse-click handling decoupled from remap
- TrayApp: wires up the remap window, saves/loads all back button
settings to HKCU\Software\SteamlessController
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Each connected controller now gets its own virtual Xbox 360 device via
ViGEmBus, mapping to separate XInput player slots (1, 2, 3...).
Key changes:
- SteamController: add EnumerateAll() to probe and return all live
device paths across wired (0x1302) and dongle (0x1304) PIDs; add
Open(path) overload to open a specific already-validated path.
- ControllerManager: replace single-controller state with a vector of
Slot structs, each owning its own SteamController, VirtualController,
TrackpadMouse, and read thread. SyncDevices() diffs live paths against
current slots on every device-change event, adding new controllers and
removing disconnected ones. Controllers hot-plugged while game mode is
active are automatically brought into game mode.
- Bump installer version to 1.3.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>