Covers the two fixes merged since 1.9: manual mode can now take the
controller back from a running Steam (#53), and a failed ViGEm target add
no longer orphans a pad that holds an XInput slot until reboot (#52).
The installer script is still the only place carrying the app version, and
nothing in the app compares version strings -- AppVersion is a display and
registry value only, with no VersionInfoVersion field derived from it. So
1.10 sorting below 1.9 lexically has no effect beyond how it reads in
Programs and Features.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
vigem_target_add() plugs the child device in with IOCTL_VIGEM_PLUGIN_TARGET,
then waits for it to come up with IOCTL_VIGEM_WAIT_DEVICE_READY. When that
wait fails, the cleanup path calls vigem_target_remove() -- but target->State
is only set to VIGEM_TARGET_CONNECTED on the two success paths above it, so
the removal trips its own "State != VIGEM_TARGET_CONNECTED" guard and returns
without ever sending IOCTL_VIGEM_UNPLUG_TARGET. The device stays plugged into
the bus with no owning process, holding an XInput slot until the machine
reboots.
Observed locally: a pad created at 19:57:25 outlived the app by five hours and
several sessions, and every later session had to take the next serial. The
rapid enable/disable churn during device cycling is what triggers the wait
timeout in the first place.
Mark the target connected before the cleanup so the unplug is actually sent,
and pin the return to VIGEM_ERROR_TARGET_NOT_PLUGGED_IN -- a successful unplug
would otherwise report VIGEM_ERROR_NONE and register a target in pTargetsList
that is no longer plugged in. This is a local patch to the vendored b66d02d
ViGEmClient snapshot; upstream is archived, so no future sync will bring it in.
Also:
- VirtualController records which ViGEm call failed and its error code;
ControllerManager logs both. The old driverMissing line could not
distinguish a missing bus from a failed target add, which is exactly the
distinction needed to spot this leak.
- ShowViGEmBalloon() now latches the way ShowElevationBalloon() already did.
Every failed enable calls back into UpdateTrayIcon -- several times a second
during an acquire-retry loop -- so an unavailable driver produced a wall of
identical balloons. The latch clears once the driver is reachable again so a
genuine mid-session disappearance still warns. UpdateTrayIcon no longer
returns early on vigemMissing, so the icon and tooltip keep tracking the
controller instead of freezing.
- Reword that balloon: a disabled bus devnode reports the same
VIGEM_ERROR_BUS_NOT_FOUND as an uninstalled one, and "is not installed"
sends users looking in the wrong place.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Manual mode could not take the controller back from a running Steam. The
tray toggle called ControllerManager::EnableGameMode() once and discarded
the result, so a Blocked outcome -- Steam holding the exclusive write
handle -- was simply dropped. The auto modes wrapped the same call in
TryAcquireController(), which escalates a blocked claim to a device cycle
via the elevated helper and retries on re-arrival. Only closing Steam
freed the pad in manual mode.
The two paths diverged because acquiring is asynchronous: a blocked claim
fires a cycle through Task Scheduler and comes back seconds later as a
WM_DEVICECHANGE, so the retry loop lives in timers and message handlers
rather than on the stack. Each of those re-entry points has to re-answer
"do we still want control?", and the code answered it by recomputing
m_autoMode != Manual && WantControl(steamState) -- deriving intent from
Steam. That works in the auto modes, where Steam's state is the intent.
Manual mode had nothing to derive from: its intent lived in a menu click
that was never recorded anywhere. WantControl() said as much, returning
false for Manual with the comment "tray toggle decides" -- and the tray
toggle's decision went unstored.
Store it. m_wantControl replaces the derived guard at all three re-entry
points (the IDT_ACQUIRE and IDT_WAKE_POLL timers, and the arrival branch
of WM_DEVICECHANGE), leaving WantControl() with a single caller in
ApplySteamState. The toggle sets it and calls the shared path, so manual
mode gets the retry burst, device cycling, wake polling, and the failure
balloon it never had.
Release is unified the same way: ReleaseControl() lifts the yield branch
out of ApplySteamState, substituting m_steamWatcher.GetState() for the
SteamState parameter the manual caller has no access to. Manual toggle-off
now also cycles the devnode, so a running Steam sees an arrival event and
re-adopts the pad instead of leaving it unclaimed.
Three follow-on details:
- Manual toggle-on calls EnsureCycleTaskRegistered(), matching what
IDM_MODE_GAME already does, so the one-time UAC prompt lands on a menu
click rather than ambushing the first cycle.
- SetAutoMode(Manual) adopts IsGameModeActive() rather than inheriting a
stale m_wantControl from the auto mode being left.
- The toggle's menu label tracks m_wantControl in manual mode, not the
live handle state. Acquisition can spend several seconds cycling, and a
second click during that window should cancel rather than start over.
Both AUTO: log lines in the acquire path are now reachable from manual
mode, so they are relabelled ACQUIRE: -- these are exactly the lines read
when diagnosing a failed handoff, and the old prefix pointed away from it.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Matches the README, which advertises Bluetooth support as new in 1.9.
The installer script is the only place carrying the app version.
Co-authored-by: Claude Fable 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>
main.cpp declares PER_MONITOR_AWARE_V2, so Windows scales nothing for us,
but the remap window was created with its 760x668 design size as raw
physical pixels. WebView2 correctly renders its CSS at the monitor's scale
inside that frame, so at 200% the content needed 1520x1336 in a 760x668
window — the reported "super small" window with cramped content.
WS_THICKFRAME was also simply absent, so there was no sizing border at all.
The existing WM_NCHITTEST handler already had a comment about letting
Windows handle "the resize border", so that code was written expecting a
resizable window that never was one.
- Size from GetDpiForWindow, clamped to the monitor work area: 760x668 at
200% is taller than a 1080p screen
- Centre on the monitor under the cursor rather than the primary one, which
also picks up the right scaling on a mixed-DPI desktop
- WS_THICKFRAME to resize. Its border is in the non-client area, outside the
rect WebView2 occupies, so it stays grabbable even though the web content
covers the whole client area
- WM_GETMINMAXINFO for a scaled minimum, WM_DPICHANGED to follow the window
between monitors at the physical size the user set
Window class background changed from white to the page colour: resizing
repaints the frame before WebView2 catches up, and white flashed against
the dark UI once dragging borders became possible.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
EnsureCycleTaskRegistered only checked that the scheduled task existed,
never that it pointed at the current helper. A task left behind by an older
or relocated install still runs and schtasks still reports success, but it
executes a helper that may be gone or predate current device support — so
the cycle does nothing, the controller is never reclaimed from Steam, and
nothing anywhere reports a failure.
Not theoretical: an uninstall left a task pointing into Program Files while
the app ran from a build folder, and every cycle the app ordered was a
silent no-op. From the app's side a successful schtasks /Run is
indistinguishable from a working cycle, which made it expensive to find.
Query the task as XML and compare its <Command> against our own helper path,
re-registering on mismatch and logging that it happened. The XML declares
encoding="UTF-16" while redirected output is single-byte, so it is read
narrow; the path is compared against both codepage renderings rather than
guessing which the console used.
Also register at startup when the loaded mode is OffOnlyInGame, instead of
waiting for a cycle to discover the problem. Without it a missing or stale
task is only noticed during a cycle — which happens exactly when a game
launches — so the UAC prompt ambushed the user mid-launch. A no-op when the
task is present and correct, so installed users see no change. Installer
users never hit any of this: setup runs --register under the elevation it
already has.
Verified by relocating the app: launching from the registered location is
silent, launching from a copy logs the stale task and re-registers to the
new path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The controller now works wired, through the dongle, or paired over
Bluetooth, with the full feature set: game mode, rumble, trackpad haptics,
battery, PlayStation-mode gyro, and taking the controller back from a
running Steam.
Windows exposes the entire vendor protocol over HID-over-GATT under PID
0x1303 — feature reports, output reports, and the 0x45 state report (54
bytes, ~157 Hz) all work through the existing HidDevice stack, so support
is a thin layer rather than a new transport backend.
- Add SC2026_BT_PID to enumeration and a per-slot Transport tag, detected
from the interface path (HOGP GUID / bthenum markers win over PID
tokens) and logged on CONNECT/DISCONNECT
- Swap the battery report 0x43 payload bytes on Bluetooth slots — the
firmware sends [chargeState, percent], reversed from USB/dongle
- Bluetooth-specific disconnect alert copy, and a notification when
acquisition gives up rather than a silently dead pad
Device cycling had to be reworked for reclaiming the controller from
Steam. DIF_PROPERTYCHANGE reports success while leaving the device and
Steam's handle untouched; CM_Query_And_Remove_SubTree is vetoed by open
handles; pnputil /restart-device works once, then leaves the devnode
reboot-pending and refuses thereafter. Disable+enable works repeatedly.
Two traps found against live hardware. SetupDiCallClassInstaller returned
ERROR_INVALID_DATA for a disable Windows had genuinely performed — the app
logged the device removal a tenth of a second before the call reported
failure — so return codes cannot gate the re-enable, or the controller is
stranded disabled and needs Device Manager to recover. CM_Get_DevNode_Status
is now the source of truth: whether the node went down decides if a real
cycle happened, the re-enable always runs and retries until the node
confirms it is enabled, and only a stranded device counts as failure. Some
devices also reject a global scope change, so DICS_FLAG_CONFIGSPECIFIC is
tried as well.
SteamProbe becomes a transport discovery tool: detailed enumeration with
transport detection, interface selection, non-fatal lizard-off with a
raw-dump fallback, an output-report test, report-rate measurement, and
--enum / --imu modes. The windowless cycle helper gains its own cycle.log,
since it cannot share the tray app's events.log (held open _SH_DENYWR) and
was previously impossible to diagnose.
Verified on hardware: game mode, rumble, haptics, battery, IMU streaming,
clean removal on power-off/sleep/range loss with automatic reconnect,
yielding to Steam on game launch, and reclaiming afterwards (~1.2s). Also
confirmed the firmware enforces one host transport at a time, so no
cross-transport deduplication is needed.
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>
Replaces the per-frame movement buzz with a scroll-wheel-style tick that
fires once per 5000 raw units of travel. Adds a matching haptic on click
release to mirror Steam's press/release feel. Reseeds the movement
baseline on click release so stale position delta doesn't bleed into a
spurious movement tick on the same frame.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PlayStation mode now emits DS4_REPORT_EX on every frame:
- Raw gyro and accelerometer forwarded from SC IMU (enabled via
SETTING_IMU_MODE when PS4 mode is active; disabled on teardown)
- SC right/left trackpads mapped to DS4 touchpad contacts 1/2,
with correct 12-bit XY packing and per-gesture contact IDs;
pads claimed for mouse use are suppressed from the touchpad report
- Battery level and charge state forwarded from REPORT_BATTERY_STATUS
(0x43) reports received in the read loop
- DS4 output moved from deprecated notification callback to a polling
thread using vigem_target_ds4_await_output_report_timeout()
Enhanced haptics applied to both Xbox and PlayStation modes:
- Power curve (value^0.68) and cross-channel mixing (large motor
bleeds into both haptic actuators; small motor weighted toward right)
- Short attack boost on rising motor edges to replicate ERM punch
- Trackpad touch and click fire one-shot haptic pulses via new
OUT_HAPTIC_PULSE (0x81) and OUT_HAPTIC_COMMAND (0x82) output reports
Additional fixes:
- REPORT_SECONDARY = 0x43 renamed to REPORT_BATTERY_STATUS
- ViGEMClient upgraded from 1.16.18.0 to b66d02d snapshot which
exposes DS4_REPORT_EX, vigem_target_ds4_update_ex(), and
vigem_target_ds4_await_output_report_timeout()
Co-authored-by: david419kr <david419kr@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <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>
/utf-8 enforces UTF-8 source encoding, which makes C4828 fire on
ViGEmClient.h (non-UTF-8 bytes in its comments). Only SteamlessController
includes ViGEm headers (via VirtualController.cpp), so /wd4828 is needed
there specifically. Other targets remain clean.
Co-Authored-By: Dylan Deverill <ddeverill@gmail.com>
Conflicts resolved:
- ControllerManager.cpp: take main's slot-based EnableGameMode/
DisableGameMode dispatch; wire the rumble callback into
EnableGameModeSlot and add SetRumble(0,0) to DisableGameModeSlot
so rumble stops cleanly before vc is torn down.
- VirtualController.h: keep both RumbleCallback/OnRumble (ours) and
the new Update(backCfg, backMouseEnabled) signature (main's).
Co-Authored-By: Dylan Deverill <ddeverill@gmail.com>
Same latent use-after-free as WriteOutputReport: after CancelIo, the
kernel can still complete the IRP and write into the stack OVERLAPPED
after the function returns. Block with GetOverlappedResult(..., TRUE)
to drain before ov goes out of scope.
Co-Authored-By: Dylan Deverill <ddeverill@gmail.com>
- Drop Tick()/m_wantsGameMode auto-enable machinery (out of scope for
this PR — belongs in a separate submission)
- Remove unused CMD_TRIGGER_RUMBLE constant
- Fix WriteOutputReport comment to accurately describe padding behaviour
- Fix CancelIo OVERLAPPED use-after-free: drain with GetOverlappedResult
before the stack OVERLAPPED goes out of scope
- Strip redundant /wd4819 /wd4828 MSVC flags; /utf-8 already covers them
- Add comment to HeartbeatLoop explaining the 40 ms tick rationale
Co-Authored-By: Dylan Deverill <ddeverill@gmail.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>
A Valve firmware update changed the main STATE report ID from 0x42 to
0x45. REPORT_STATE is updated to match; the now-redundant REPORT_EXTENDED
constant (which previously held 0x45) is removed.
Also adds better probe diagnostics to Open() — distinguishes between an
unexpected report ID (firmware mismatch) and a read timeout (empty slot).
Fixes a stale debug flag in SteamProbe: FORCE_SHOW_BYTES_16_17 reverted
to false (right joystick Y, not X).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Steam Controller Puck exposes up to four FF00/0001 HID interfaces
(one per paired controller slot). Previously, Open() blindly picked the
first interface, which could be an empty slot — feature reports sent to
it were silently ignored, so lizard mode was never disabled.
Now Open() iterates all FF00/0001 interfaces for both the wired PID
(0x1302) and the dongle PID (0x1304), reads a single input report with
a 500 ms timeout, and latches onto the first interface that responds
with a live 0x42 STATE report.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Added EnumerateAllValveDevices() to SteamProbe which lists every HID
interface with VID=0x28DE at startup — printing PID, usage page, usage,
and product string. This makes it easy to identify the wireless dongle's
PID without needing Device Manager.
Updated HidDevice::Enumerate to treat pid=0 as a wildcard so callers
can enumerate all devices for a given vendor.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Start with Windows toggle in tray menu — writes/removes the Run registry
key so the app launches automatically at login
- Inno Setup installer script for packaging the release exe
- CMakePresets.json for named debug/release build configurations
- README updated with correct Visual Studio 2025 build commands and a
note for VS 2022 users
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>