The stick deadzone fix (#117) is confirmed by the reporter and ships as 1.25
rather than a patch release, superseding the 1.24.1 bump before it.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
People kept updating control mappings that did nothing, because Steamless
mode was off and nothing said so until the in-window banner landed. The
banner helps once you're looking at the window, but the fix people actually
asked for is not needing to remember to flip the tray toggle every session.
Manual mode now has its own "Enable on Launch" item, checked by default only
for fresh installs — LoadSettings already returns before touching any member
when the registry key doesn't exist at all, so that fork was free. An
existing install keeps today's off-until-toggled behavior unless the user
opts in, since flipping it under them wasn't part of the ask. Hidden outside
Manual mode: the auto modes never make an "at launch" decision, they
resolve control from Steam/game state continuously, so there is nothing for
the item to mean there.
Routes through EnableFromUser() at the end of Init(), after the profile is
pushed — the same path the tray toggle and the remap window's banner button
already share, so a fresh install's first acquire carries the right bindings
and goes through the same elevated-helper registration as a manual click.
Bumped to 1.23; this is the last change queued before packaging.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The intermediate 1.21.1 through 1.21.4 existed only so test builds could be
told apart in a log, which this issue needed repeatedly. What ships is one
release with two user-facing additions — a per-pad scroll speed setting and
the banner saying when Steamless mode is off — so it takes a minor version
rather than the last of the test patches.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1.21.3 has already run on this machine and written logs under that version.
Rebuilding it with a visible new feature would leave those logs unable to
say which build produced them, which is the ambiguity this issue has
already lost several rounds to.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reporter already has a 1.21.2 that he reports working. Sending him a
build with a different slider under the same version would leave his next
log unable to say which one produced it, which is the ambiguity the last
several rounds were spent on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reporter already has a build calling itself 1.21.1. Sending him a
second one that logs differently under the same version would put us back
where the last two rounds were spent — unable to tell from a log which
build wrote it.
The installer's staleness guard does not cover this: it compares the
packaged binary's version against MyAppVersion, which catches a stale exe
but not a same-version rebuild.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Visual Studio generator is multi-config, so CMAKE_BUILD_TYPE does
nothing and "cmake --build build/release" quietly produces DEBUG. The
installer packages build\release\Release\, which then keeps whatever was
last built there — and stamps MyAppVersion on the result regardless.
That shipped: a 1.21.1 installer carrying the 1.21 executable went to the
#95 reporter. Every visible signal agreed it was fine. Setup succeeded,
Add/Remove Programs said 1.21.1, and only the app's own startup banner and
the missing feature gave it away.
Nothing about the failure was loud, so the fix is to make it loud. The
script now reads the FileVersion out of the binary it is about to package
and aborts the compile when it disagrees with MyAppVersion, naming both
versions and the command to run. Verified in both directions: matching
compiles, mismatched aborts.
The release build preset gains the two helper binaries the installer also
packages, so "cmake --build --preset release" — the command the error
points at — is actually sufficient to produce an installer. It previously
built only SteamlessController, which on a clean tree would have left the
other two missing or stale, the same failure by a different route.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The scroll changes go to the #95 reporter as a private build before they go
to anybody else, and the last round cost a round trip precisely because the
logs could not be pinned to a build: the version banner was trimmed out of
the paste, and every line that survived was one 1.20 already wrote.
A distinct version makes the startup banner answer that on its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Everything since 1.20: the trackpad directional pad and single button
modes, and the pad type now staying put while a game runs rather than
being rebuilt on every alt-tab.
1.20.1 existed only so private test builds could identify themselves in
their own log header, which is the one place it mattered. Nothing shipped
under it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both diagnostics on this branch are meant to reach a reporter as a
private build before the branch merges, and a build stamped 1.20 is
indistinguishable from the release in the one place it matters — the
log's own header line, which is what comes back attached to every report.
Verified on the binary rather than the source: FileVersion and
ProductVersion both read 1.20.1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Version in resource.h and the installer script, which the header's own
comment asks to be kept in step. Verified on the built binary rather than
in the source: FileVersion and ProductVersion both read 1.20.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The per-game picker drew on two sources: a Start Menu shortcut walk and the
installed MSIX package list. Issue #83 is a user whose app never appeared in
it, asking to just point at an .exe. Two structural gaps sat behind that.
Launcher-installed games were missing or wrong. Epic shortcuts a game with a
.url carrying a com.epicgames.launcher:// URI, and the walk keeps only .url
files pointing at Steam, so Epic games were dropped outright — Among Us was
invisible on the machine this was written on. Battle.net and Ubisoft write
.lnk files targeting the launcher executable with a per-game argument, and
IShellLink::GetPath returns only the executable, so a whole Blizzard library
collapsed into one wrongly-named "Battle.net Launcher.exe" entry.
LauncherGames reads what each launcher records about itself instead: Epic's
.item manifests, GOG's and Ubisoft's registry keys, Battle.net's uninstall
entries. Those name an install directory rather than an executable, which is
the better identity anyway and gets a fourth game-id shape, "dir:". A game
folder holds its launcher, its anti-cheat service and the game itself, and
which of them reaches the foreground varies by title and by moment — the same
reasoning SteamAppLocator already documents for Steam, and MatchProfile
resolves these by longest path prefix for the same reason.
Nothing without an installer entry could be listed at all, which is the long
tail the issue is really about. The picker now offers the applications with a
window open right now, and a file browser, both producing an ordinary exe-path
id that needed no new plumbing. The running list is the more useful of the two:
it asks the user to know nothing about where a game was installed.
Also moves the enumeration off the UI thread. It measured 1.4s warm and 5s
cold before this change added five sources to it, and OpenRemapWindow was
calling it synchronously — the window did not appear until it finished.
Fixes#83
Both copies of the number move together: resource.h feeds the VERSIONINFO
resource and the startup line in the event log, InnoInstallerScript.iss feeds
Programs and Features.
Two components rather than three, matching the release tags and the previous
bumps. 1.17.3 was a hotfix number; the patch field goes back to 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A release carried no version anywhere a user or a maintainer could see it.
The exe had no VERSIONINFO, so its properties showed nothing, and events.log
opened with a bare "=== SteamlessController started ===". Twice while chasing
#79 a reporter's log arrived and there was no way to tell which of two builds
had written it, which cost a round trip each time.
resource.h now holds the version once, and both consumers read it from there:
app.rc builds a VERSIONINFO resource so Explorer and Programs and Features can
report it, and the startup line names it. Keeping them on one definition means
a binary and its log can never disagree about what they are.
InnoInstallerScript.iss still carries its own copy of the number. Inno can read
it back out of the built exe instead, which would leave one place to edit, but
that makes the installer refuse to compile before the app is built and is a
change to how releases are cut rather than to the app.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Game mode refused a shared handle outright whenever steam.exe was running,
on the reasoning that the holder was probably Steam and that refusing would
escalate to a device cycle that took the handle back. On machines where that
cycle never wins the reopen race, it took nothing back and the app simply
never worked: #79 counted 731 blocked claims in a single day against zero
successes, and both of that reporter's successes across the whole log came
through the shared path with Steam closed, never through an exclusive claim.
No exclusive claim is obtainable against a live write handle, however polite
its holder. Windows share arbitration runs in both directions: FILE_SHARE_READ
asks the kernel to deny write access to everyone else, and it must refuse
while such a handle exists. Measured on a puck receiver with Steam running,
even GENERIC_READ with FILE_SHARE_READ returns ERROR_SHARING_VIOLATION, so it
is our share mode being rejected rather than our access. "Steam only holds it
shared" and "we can take it exclusively" were never compatible statements.
Shared is now the last resort rather than a refusal. The claim still prefers
exclusivity and a blocked claim still escalates to a device cycle exactly as
before, so machines that win that race keep winning it. Only once cycling is
over — the budget spent, or a cycle shown to have invalidated no handles —
does the acquire path retry with sharing allowed, immediately before it would
otherwise report failure. Two callers that rebuild an already-running game
mode pass it too: a slot arriving late should join rather than be dropped,
and a platform change should not cost the user their controller.
Sharing is not free, which is why it is last. Steam keeps driving the same
controller, and its stock desktop layout binds both the left stick and the
D-pad to arrow keys. Measured with a dual-channel sampler: 16 D-pad presses
produced 16 matching global UP/DOWN_ARROW keystrokes 1-2ms later, press and
release both mirrored. Those are real SendInput keystrokes, so they reach
processes Steam cannot inject into — the doubling was first reported in a
Game Pass title. The tray reports the state rather than hiding it: a distinct
icon and a "(sharing with Steam)" tooltip, so a user chasing doubled input
knows where to look.
Slots are probed quickly before they are probed properly. A receiver
publishes an interface per slot and only one has a controller in it, but
every empty slot cost the full 250ms state-report timeout before the live one
was even reached — up to 750ms, spent before the first claim attempt. Steam
reclaims the device about 180ms after it re-arrives, so that ordering handed
it the race whenever re-enumeration put the live slot last. A 25ms pass now
runs first, since a live slot streams continuously and answers within a few
reports. Measured across a device cycle with the live slot arriving last:
arrival to exclusive claim fell from 1697ms to 99ms.
A quick probe that comes up short is not evidence of an empty slot, so it
neither records the slot as silent nor logs it; otherwise a controller slow
to resume streaming would be latched out of the full pass that follows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With another program's rebranded ViGEm already on the machine, setup's driver
install silently does nothing and reports success. The user finds out when
they click Enable Steamless Mode and no controller reaches the game. Reported
in #68, where the only ViGEm driver package left afterwards was the Oculus
fork's; running the ViGEmBus installer by hand then offered Modify/Repair/
Remove rather than a fresh install, so the bundle had registered itself
without a working driver ever landing.
Everything downstream of that — the connect failures, the retry storm, and on
1.13 the device cycling that stranded a reporter's HID collections — starts
here. The preceding commits make that state survivable and diagnosable; this
one keeps it from being reached.
Setup now asks the same question the app asks at runtime: is a device
publishing the ViGEm bus interface present? Every cheaper check was measured
and found to lie. The bundle exits 0 when it detects a related product and
declines to act, so the exit code says nothing. The registry records the
product whether or not a driver landed. And the driver's version is no guide
either, since 1.22.0 ships a binary still stamped 1.21.442.0 — which cost an
afternoon before it was pinned down.
ViGEmBusProbe carries the check, and is installed rather than left a build
artefact: its other job is being the thing to ask a user to run when a
controller never reaches a game. Exit code 0 means at least one usable bus,
1 means none. Verified both directions against a real bus and a disabled one.
A failed check does not fail the install. The app is fine, and it now picks
the driver up on its own once it appears, so the message says what to install
and that no reinstall is needed afterwards. Silent installs log the finding
instead of stopping on a dialog nobody is present to close.
It informs rather than repairs. Running msiexec /fa or the bundle's own repair
would be acting on shared-identity MSIs on the one kind of machine known to
have other products' drivers registered against them, which is not a risk
worth taking to save a download.
The installer code itself is unverified: there is no Inno Setup on the machine
this was written on, so the Pascal has not been compiled and no install has
been run. It also assumes ssPostInstall follows the non-postinstall [Run]
entries. That assumption fails loudly if wrong — every healthy install would
warn — which is why the check sits there rather than behind a wrapper built to
guarantee the ordering.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers the per-game control profile work since 1.14. Paddle bindings, both
trackpads and the virtual pad's platform are configured in one window and
can be saved per game, replacing the tray checkboxes that applied to
everything at once. The saved profile follows whichever game is in front,
and key bindings can now carry Ctrl, Alt, Shift and Win.
Existing settings are read forward rather than reset. Values written by
earlier builds decode unchanged — the modifier field occupies bits that were
always zero — and the retired trackpad toggles are converted once into the
per-pad equivalent they described: the pad that drove the mouse becomes a
pointer whose click is the left button it was always hardwired to, and on
PlayStation the other pad keeps feeding the DS4 touchpad, which is what it
had been doing all along.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers the disabled-devnode work since 1.13. A device cycle switches the
devnode off and back on about a second later, and the disable is global —
it lands in the registry at once and outlives the process, a replug, and a
reboot. Killing the app inside that second left the controller switched
off with nothing running to put it back, recoverable only through Device
Manager. The intent to disable is now recorded before the disable and
cleared only once the devnode confirms it is back, so an unfinished cycle
is undone on the next run.
A devnode disabled by something else — Device Manager, regedit, pnputil, a
tool that hides HID devices — is no longer indistinguishable from an empty
USB port. It is named at startup and can be switched back on from the tray
rather than silently, since it may well be deliberate.
Also adds a heartbeat to the event log. Idle and stopped previously looked
identical there, which is what made the fault behind this release hard to
read after the fact.
Still the only place carrying the app version, and nothing in the app
compares version strings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers the input-blocking work since 1.12: the app now detects when
Windows is discarding what it injects — an elevated window in the
foreground blocks it, and haptics keep working throughout, so the
controller looks alive while the trackpad and buttons do nothing — names
the application responsible in a tray balloon, and records enough in the
event log to tell that apart from a cursor clip, a filtering hook, or a
state report too short for the trackpad path.
1.12 was cut from a working-tree edit that was never committed, so it has
no commit of its own and the version history steps 1.11 to 1.13. The
released 1.12 build carried the same code as 1.11.
Still the only place carrying the app version, and nothing in the app
compares version strings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers the two fixes since 1.10: trackpad haptics were playing on the
opposite pad, and a failed controller handoff now reports which process
is actually holding the device rather than assuming Steam is.
Still the only place carrying the app version, and nothing in the app
compares version strings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
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>
- 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>