43 Commits
Author SHA1 Message Date
Dylan DeverillandClaude Sonnet 5 19e5735504 Let Manual mode enable itself on launch, off by default for upgrades (#107)
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>
2026-09-17 00:03:51 -07:00
Arvin GandClaude Opus 5 527ee65257 Take the dock in a cycle of its own, after the controller
Riding the dock along in the acquire cycle had three costs. The helper
reports one verdict per run, so a dock that cycled cleanly stood in for a
slot that was vetoed and hid the checks that stop us cycling against our own
handle. Later attempts grew to the docks plus every slot, outrunning the 4 s
gap before "claiming anyway" reopens slots the helper had not reached yet.
And a dock that could not be claimed after the takeover was only logged.

The acquire cycle is slots only again, exactly as before. Once the
controller is ours, a dock Steam still holds is cycled on its own after
500 ms, when the helper run that won the slot has exited, and taken back by
a dock-only pounce, at most twice per takeover. Cycling the dock does not
interrupt the controller. The self-held release keeps held docks, since no
slot cycle includes them.

Tested on a puck: slot cycle and takeover as before, dock claimed on the
first dock cycle, no prompt when docking, Steam gets both back on exit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 00:08:54 -07:00
Arvin GandClaude Opus 5 6e553a9609 Hold the puck's dock interface so Steam cannot offer to re-pair
With Steamless mode on and Steam running, setting the controller on the puck
raised Steam's "Pair Steam Controller to your Puck?" prompt. Answering Yes
re-paired the controller onto a slot Steam still held, leaving the virtual pad
up on a slot that had gone silent beside a controller Steam was now driving
(#102).

The puck reports a docked controller through a dock interface of its own
(usage page 0xFF00, usage 0x0002, MI_06), separate from the slot interfaces.
Steam answers that event with the prompt because it cannot see the controller
is paired while we hold the slot. Game mode now holds the dock interface the
same way it holds the slot, so the event never reaches Steam:

- Acquire cycles list unheld docks ahead of the slot and the pounce takes
  them back; docks already held stay held through the cycle.
- Release and exit cycle any held dock so Steam reopens it.
- Cycle progress is tracked per interface. The helper cycles a request one
  interface after another, and judging the set as a whole ended the cycle
  between the first returning and the next going down, so the resync
  reopened the next interface ahead of its turn and vetoed it.

Tested on a puck: no prompt when docking with Steamless mode on, Steam gets
the dock and controller back on release, and no pairing is written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 00:08:54 -07:00
Dylan DeverillandClaude Opus 5 d2b29cd883 Say in the window when Steamless mode is off (#95)
Every setting in Customize Controls is inert while Steamless mode is off —
trackpad modes, scroll speed, paddles, all of it. Nothing on screen said
so. The event log has said so since the PADS line landed, which turned out
to be no help at all: people are looking at the window while they test, not
at a log.

That has now eaten three test sessions on this issue alone. The reporter
changed settings twice with it off and reported the feature broken; the
maintainer then did the same and reported scrolling regressed. In every
case the settings were saved correctly and simply were not driving
anything, and working that out took a round trip each time.

So the window says it, and offers to fix it. In manual mode the banner
carries an Enable button; in the auto modes it explains that the mode
decides instead, because a button there would either lie or be undone a
moment later.

The button routes back out to TrayApp::EnableFromUser — extracted from the
tray toggle so both are literally the same path. A shortcut of its own
would have skipped EnsureCycleTaskRegistered and moved the one-time UAC
prompt to the first device cycle, which is precisely where it ambushes
people.

Clicking Enable does not hide the banner. Enabling can fail — no ViGEm, or
a device another process will not let go — and a banner that vanished on
click would claim a success nothing had verified. The state is pushed from
UpdateTrayIcon, so the banner clears when control is actually taken, and
comes back if it is lost.

That push is posted through WM_CONTROLSTATE rather than made directly: the
controller notifies from its read thread and WebView2 must only be touched
on the UI thread. The state is read in the handler rather than carried in
the message, so what the window gets is the current answer rather than the
one that prompted the post.

Two things about the wording, both learned by writing it wrong first.

It says nothing about whether settings are saved. An earlier draft ended
"they are saved either way", which reads as though the page saves on its
own — it does not, Apply does. A banner about one thing being off is the
wrong place to imply something else is automatic.

And its punctuation is ASCII. GetHtml widens the embedded page a byte at a
time, so a literal em dash arrives as two garbage units; the static markup
has always sidestepped this by writing &#8212; instead. Every other
non-ASCII character in the page sits in a comment, where it never reaches
a reader, and this string was the first displayed text to trip over it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:11:33 -07:00
Dylan DeverillandClaude Opus 5 e8c269bf0e Add a per-pad scroll speed setting (#95)
Normalising the wheel setting removes the difference between machines; it
does not settle what the shared feel should be. Steam has a scroll speed
slider and we had a compile-time constant, so a pad that scrolled too fast
for someone left them nothing to do about it but stop using scroll mode.

Per pad rather than global, because the two pads are already configured
independently and either can be the one scrolling. Shown only in scroll
mode, alongside the scroll direction it belongs with.

Applied after the wheel correction so the percentage means the same thing
everywhere: 100% is the calibrated feel, not "whatever this machine
happens to do". Bounded 25-400%.

Zero is the value the registry yields for every profile written before
this existed, so it has to read as "unset" and land on the default rather
than as "do not scroll". ScrollSpeedFromDword owns that on the C++ side
and clampSpeed owns it in the page, each the single place its side bounds
the value — the page previously clamped only on render, which let a stored
out-of-range value reach savedProfile unbounded and show up as an unsaved
change the user never made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:11:33 -07:00
Dylan DeverillandClaude Opus 5 5796177be9 Track the pad-type hold by process, not by profile selection
Two fixes to the hold, both found by forcing paths that do not occur on
this machine.

The liveness fallback never released. It asked whether the pid could be
opened and read success as proof of life, but a pid stays valid for as
long as anything holds a handle to the process object — and a launcher or
an anti-cheat service generally does. Measured against Fortnite with the
fallback forced: the game exited, the pid stayed openable, and the hold
stood forever, leaving a PlayStation pad that never went back to Xbox.
It now asks whether the process has exited rather than whether it can be
opened. Both remaining ways to fool it — an exit code of 259, or pid
reuse between two checks — leave a hold standing too long, which costs
the deferred rebuild rather than causing a wrong one.

The hold was also gated on the profile selection changing, having been
written behind the early return that skips a foreground change selecting
the profile already selected. But a hold follows a process and a
selection follows a profile, and those do not change together: a loader
handing off to the game it launched is the same profile wearing a new
pid, so the selection does not change, the return fires, and the hold
stays on the old process. When that one exits the pad rebuilds under a
game that has only just started.

Both liveness paths are now confirmed on hardware — Marathon and Fortnite
by handle, Fortnite by pid with the fallback forced — holding across
alt-tabs and releasing within the poll interval of the game closing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:33:38 -07:00
Dylan DeverillandClaude Opus 5 491537ffc6 Report trackpad frames that moved further than a finger can (#95)
The wheel-lines diagnostic did its job and ruled itself out: the reporter
was on 10 lines per notch, which really was scrolling them 3.3x faster
than the default, but setting it back to 3 changed nothing. Report id and
length, screen size and now the wheel setting all match a machine where
scrolling is fine. The transport does not — theirs is the dongle.

Movement is integrated from position deltas, so dropped reports are
harmless by themselves: the displacement adds up the same whether it
arrives over ten frames or one. The case that is not harmless is a finger
lifting and landing somewhere else without the touch bit ever being seen
false, because then the gap between two unrelated points is applied as a
single enormous swipe. A wireless transport dropping the odd report is
exactly how those frames go missing.

So this logs any single frame that moves further than a finger plausibly
can, with the milliseconds since the previous touched frame attached.
That number is what separates the two: a gap at the report rate means the
sensor jumped and the touch bit is lying about a finger that left, while
a long gap means reports went missing during real movement.

No clamp yet, deliberately. A threshold picked before knowing which of
those is happening would clip fast flicks on a transport that simply
reports less often, and the right value is the one the logs describe. The
haptic tick path has distrusted jumps like this for a while
(TRACKPAD_TICK_MAX_STEP); movement never learned to, and this is the
evidence for what it should learn.

Also logs the applied profile when it changes, which says something the
existing "switched to" line cannot: a profile set to follow the default
matches, is reported as switched to, and then supplies none of its own
bindings. That is the shape of "my custom bindings do nothing until I
open customize controls", and nothing in a log could show it before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:33:38 -07:00
Dylan DeverillandClaude Opus 5 e4f3b51d33 Hold the virtual pad type for as long as its game is running (#92)
Alt-tabbing out of a game switched the profile back to the default
immediately, and a profile that changes platform tears the virtual pad
down and builds it again. That is the Windows device connect/disconnect
sound the report describes, twice per round trip — and worse than the
noise, a game polling XInput sees its controller unplugged and replugged
while it is still running.

The delay that should have covered this never ran. RELEASE_GRACE_MS
guards handing the physical controller back, and in Manual mode
WantControlNow keeps answering yes when the foreground changes, so the
profile was pushed with nothing in the way. Exposing that delay as a
setting, which was the plan, would have made a number configurable that
this path never consults.

The unit being switched was also wrong. A platform is only meaningful to
a game: desktop bindings are keyboard and mouse going out through
SendInput, which never touches the virtual pad, so nothing on the desktop
cares what type it is. Bindings are the opposite — they describe what the
user is doing right now, and when the desktop is in front the desktop's
bindings are the ones wanted.

So the two are split. Bindings still follow the foreground, exactly as
before. The pad type follows whichever profiled game is running: claimed
when a game comes to the front, kept across alt-tabs, and released when
the process exits — the one moment the rebuild costs nothing, because
the game that needed it has gone.

Liveness is a wait on a process handle, falling back to re-opening by pid
where anti-cheat refuses one, since being denied access to a process is
proof it exists. Polled every two seconds rather than watched: nothing is
looking at the pad in the moment a game exits.

Deliberately does nothing for AutoMode::OffUnlessProfile, which releases
the physical controller the moment a profiled game is not in front and
destroys the pad outright — leaving no pad to hold a type for. That mode
exists to be restrictive and stays that way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:33:38 -07:00
Dylan DeverillandClaude Opus 5 234eb308b1 Add the directional pad data model and its persistence
Phase 1 of the v1.20 trackpad work: the settings a Directional Pad and a
Single Button pad need, and reading and writing them. Nothing resolves a
direction yet and neither mode is selectable, so behaviour is unchanged.

TrackpadMode gains DirectionalPad and SingleButton, appended so a build
that predates them reads a profile naming one as None through the guard
that was already there rather than as some other mode.

TrackpadSettings gains four direction bindings, a touch binding and a
DiagonalMode. Directions default to the gamepad d-pad so choosing the
mode does the obvious thing before anything is rebound. Touch is a
binding on every mode but None and DS4Touchpad, not just the two new
ones: it cannot collide with a direction because directions resolve from
the click, and a thumb crosses zones constantly while sliding, so zoning
it would only make it flicker.

kPadRingRadius carries the centre/ring boundary at 12000 raw units. Not
a fraction of the pad: the coordinate space is square, both axes
saturate at 32767, so the corner sits 1.41x further out than an edge and
no single fraction describes one circle. The value comes from
TrackpadZoneProbe against real presses — centre presses reached at most
6035 and the loosest direction press was 17731, eyes-free.

Both registry sites read and write per pad through one prefixed helper
rather than two copies of twelve names, which is where a left-pad name
ends up reading a right-pad value. The six settings that already existed
keep their exact names, so 1.19 profiles load untouched, and the six new
ones per pad default to the struct's own values.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00
Dylan Deverill bd4206e891 Find far more games, and let a user name one we missed
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
2026-08-31 01:33:18 -07:00
Veri7ion 1eb2a59d6d Fix tray icon restoration after Explorer restart
Fixes the tray icon not being restored when Windows Explorer is restarted.

TrayApp already registers for the TaskbarCreated shell notification and calls AddTrayIcon() when it is received. However, the window used to receive this notification was created as a message-only window using HWND_MESSAGE.

Message-only windows do not receive the broadcast shell notification sent when Explorer recreates the taskbar, so AddTrayIcon() was never called and the application was left running without a tray icon.

This changes the tray window to a hidden top-level window, allowing it to receive TaskbarCreated while remaining invisible.

Result
After restarting Windows Explorer, the Steamless Controller tray icon is automatically registered again without needing to restart the application.
2026-08-22 00:43:31 -07:00
Dylan DeverillandClaude Opus 5 745ad65b0c Narrow the release cycle to the interface that had the controller
The acquire path has cycled a single interface since the receiver work; the
release path still took down all four. Only one slot is ever occupied, so the
other three were being disabled and re-enabled for nothing — and each one
handed Steam another arrival to react to, when the only arrival that matters
is on the interface it will actually read the controller from.

The cost was hidden until the reopen veto was fixed, because the vetoes
dominated: a release cycle ran 15.47s, of which about 13s was two vetoed
interfaces.

Measured end to end by launching a Steam game in auto mode 2, which is the
only thing that exercises this path:

  10:33:30.394  AUTO: steam state 2, want=0
  10:33:30.394  RELEASE: closing all device handles
  10:33:30.459  RELEASE: cycling device so Steam sees an arrival
  10:33:31.512  CYCLE: done after 1063 ms
  10:33:31.518  CONNECT mi_02, and the rest after it

1.06s against 15.47s, one interface cycled rather than four, no Kernel-PnP 225
events, and the reopens land after the cycle instead of 26ms into it.

The handoff itself is confirmed rather than assumed: quitting the game had our
next claim refused with "another process holds a write handle", so Steam had
genuinely adopted the controller in the interval. We then took it back on one
narrowed cycle, 2.2s from the game exiting to game mode being live.

Unconditional here, unlike the acquire path, which narrows only its first
attempt so that a wrong guess cannot keep costing it across retries. A release
has no second attempt to protect, and an empty path already deletes the
request file and falls back to cycling everything.

LastLivePath is read before ReleaseDevices rather than after. The value
survives either way — it is only ever assigned, never cleared — but taking it
while the slots are still up has the two paths asking the same question at the
same point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:46:57 -07:00
Dylan DeverillandClaude Opus 5 f30595c0f8 Stop reopening the controller during our own device cycle
WM_DEVICECHANGE called ControllerManager::OnDeviceChange unconditionally, and
OnDeviceChange opens a read/write handle on every interface it finds. An open
handle vetoes a device removal no matter who owns it, so the app spent every
cycle fighting handles it had reopened milliseconds after asking for the
cycle.

A release on this machine, with the log timestamps:

  23:22:31.671  RELEASE: closing all device handles
  23:22:31.701  RELEASE: cycling device so Steam sees an arrival
  23:22:31.727  CONNECT mi_02       26ms after asking for the cycle
  23:22:31.737  CONNECT mi_03
  23:22:31.749  CONNECT mi_05
  23:22:31.762  CONNECT mi_04
  23:22:37.107  DISCONNECT mi_02    the helper finally wins it
  23:22:47.210  cycle done          15.47s

Kernel-PnP 225 names SteamlessController.exe as what stopped the removal, at
23:22:37 and again at 23:22:43. The helper's log prices it: an unobstructed
interface cycles in about 1.2s, a vetoed one in about 6.5s. Three vetoes are
on record for one evening, two in a single release.

The second hole is the same root cause and hurts acquisition. The test for
"the cycle has delivered" was ControllerManager::IsConnected, which is true
whenever any Steam Controller interface enumerates. A receiver publishes four
and a narrowed cycle takes down one, so the surviving three held that true for
the whole cycle: the guard came down on the first arrival of anything
HID-shaped, including the device being cycled, before it had even gone. The
comment above it claimed to identify "our controller specifically, not merely
something HID-shaped". On a puck it never could.

Arrival is now judged by the interfaces the cycle actually took down going
away and coming back, which is the only honest signal available. Tracked
through SteamController::EnumerateAll, because HidDevice::Enumerate opens each
candidate with a zero access mask and closes it before moving on — unlike
OpenSlot it leaves nothing behind for PnP to trip over, so watching a cycle
cannot veto it. While a cycle is in flight no slot is opened at all.

RestartControllerDevices records what the cycle owes before starting it, and
consumes the narrowing request rather than reading it, so one caller's
narrowing cannot leak into another caller's cycle.

EndCycle is the single exit, with a 20s watchdog behind it. Every normal
ending is an event that might not arrive, and a flag left raised means the
device is never opened again and the tray reports no controller until
restart — the previous code cleared the flag from five places and had no
backstop at all. It skips the resync for the caller that is about to release
the device anyway, where reopening would be the very veto this prevents.

Measured after, on the same hardware: the acquire cycle runs 1.13s against a
first interface that used to take 6.5s, no CONNECT appears between the release
and the cycle completing, and no Kernel-PnP 225 events are logged.

Both faults predate the acquisition work on this branch; origin/main is
byte-identical here. Narrowing the cycle to one interface made the second one
bite harder, since only one of four interfaces now ever leaves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:46:57 -07:00
Dylan DeverillandClaude Opus 5 408413a041 Reconcile Steam's state on a timer, so a lost edge is not permanent
SteamWatcher announces a state change exactly once, through a single
PostMessage. Nothing repeats it. If that message is lost, dropped, or arrives
while nothing is pumping, the app keeps a stale view of Steam for the rest of
the session — a game launches and the controller is never handed back.

This is not hypothetical. An instance on this machine sat at steam=1 while a
game was running, and the release only happened once something else moved the
state again. The cause of the lost edge is still unknown; this bounds its cost
either way, which is the part worth having before the cause is found.

The timer compares the watcher's current state against the last one actually
acted on and applies the difference. Any single lost edge now costs seconds
rather than the rest of the session.

Logged loudly when it fires, because it should never fire. If it happens in
the field the log says so, rather than the reporter having to notice the
controller behaving oddly and describe it.

m_lastAppliedSteamState is recorded before ApplySteamState's Manual check, so
leaving Manual mode does not find a stale value and report a change that never
actually went missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:46:57 -07:00
Dylan DeverillandClaude Opus 5 f10a18347c Take the controller back the instant the cycle returns it
Acquiring against a running Steam was losing a race we were not really
running. The old sequence waited for WM_DEVICECHANGE, enumerated every HID
interface on the machine, opened the device shared, closed it, and only then
reopened it exclusively. Steam had to be anywhere in that.

Measured first, because every timing figure in #79 so far had come from
reading log line order and that was wrong twice. A losing attempt cost 656ms,
of which about 545ms was the retry burst sleeping, 46.6ms was eleven full
enumerations, and 0.2ms was the claim itself. A winning attempt took 31ms
start to finish. The in-process work was never the problem; the time was going
somewhere the log could not see.

The claim now happens before the probe. A live slot answers a state probe in a
report or two but an empty one costs the whole timeout, so walking the list
probe-first left the occupied slot unclaimed until every empty slot ahead of
it had timed out — up to 75ms on a four-slot receiver, against the ~180ms
Steam takes to reopen the device here and under 73ms on the machine in #79.
A sweep claims every slot up front and the per-slot path uses that result;
slots that turn out to be empty hand theirs straight back.

The pounce thread stops waiting to be told. WM_DEVICECHANGE is one of the last
events in an arrival, not the first: PnP registers the interface, dispatches
to every listener, and ours queues behind whatever else is pending. Since we
are the ones who asked for the cycle, a thread armed before it spins
CreateFileW on the paths we already know and takes the device exclusively on
the first open. It only keeps a handle after seeing the path disappear —
grabbing earlier would hold the very handle that vetoes our own cycle, and PnP
does not care that the handle is ours. Adopted slots skip the claim sweep,
because reopening would give the device back for exactly as long as it takes
to ask for it again.

Adoption happens before the in-flight guard. A caught handle is proof the
cycle delivered, and adopting after the guard meant sitting on a device we had
already won for the whole CYCLE_MIN_GAP_MS: measured at 4030ms between the
grab and the adopt, now 19ms.

The burst no longer runs on a blocked claim. A held handle does not free
itself, so retrying it ten times at 50ms intervals only delayed the cycle that
could actually free it. It still runs for a slot that has not started
streaming, which is what it is good for.

The cycle is narrowed to the occupied interface. A receiver publishes one per
slot and only one is ever occupied, so cycling all of them multiplied the
downtime and gave Steam an arrival per interface to react to. The app names
the target in cycle.request and the helper consumes it on read, so a stale
request cannot narrow a later cycle. Only on the first attempt: if narrowing
did not work the assumption behind it may be wrong, and later attempts cycle
everything.

Measured after: a losing attempt is 15ms rather than 656ms, one enumeration
rather than eleven, and the whole blocked-claim-to-game-mode path is 1.20s,
nearly all of it the devnode being down.

Two things make this observable rather than another round of inference. The
acquire logs where its milliseconds went, won or lost. And when we do settle
for a shared handle, the helper is asked who is holding the device, so the
answer lands in a log the reporter already sends instead of needing a command
run by hand — the helper's own scan only runs when PnP vetoes a cycle, and the
case worth explaining is the opposite one, where every cycle succeeds and the
controller is taken back anyway.

The scan's counters also stop lying. "queried" counts handles whose names were
resolved, which is far fewer than those examined and is not a coverage figure
— candidates are skipped when their process already has a known holder, when
OpenProcess is refused, when DuplicateHandle fails, or when the object is not
device-like. Read as coverage it says 7% when the scan in fact examined
everything, which is how a complete answer got mistaken for a partial one.
It now reports examined against total, and says that a deadline means a wedged
worker rather than a short scan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:46:57 -07:00
Dylan DeverillandClaude Opus 5 5e220d4724 Cycle the controller through the helper even when already elevated
RestartControllerDevices took a shortcut on an elevated run: rather than
firing the helper, it cycled every interface itself, in a loop, on the UI
thread. Each interface disables, waits a second for the removal to propagate,
and enables, so a four-interface receiver spent about five seconds inside that
loop with no message pump running.

Nothing was pumping, so every WM_DEVICECHANGE the cycle produced sat in the
queue until it finished. The first interface was back — and taken by Steam —
seconds before we drained those messages and claimed. A reporter's log in #79
shows it exactly: RELEASE at 15:49:26.049, then five seconds of no log lines at
all, then four CONNECTs inside 22ms as the queue emptied, then the claim
refused. Eleven of that session's fourteen cycles ran this path and lost; the
three that went through the helper are the ones that behaved.

It also explains the app appearing to hang mid-cycle, reported as the tray menu
opening as a white rectangle: the UI thread was inside the cycle.

The helper is a separate process, so the cycle runs while this one keeps
pumping and the arrival drives the claim as soon as it lands. Measured on the
same elevated configuration: helper starts 56ms after the request, whole
cycle-to-claim 1.27s, message loop live throughout.

Dropping the shortcut also removes a fork that had already drifted — the
in-process path wrote nothing to cycle.log, so an elevated run left no record
of a cycle having happened at all, which is why the helper's holder scan never
appeared in that reporter's logs.

The three EnsureCycleTaskRegistered call sites no longer skip registration when
elevated. They assumed an elevated run never needed the task; it does now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:46:57 -07:00
Dylan DeverillandClaude Opus 5 c3f58de856 Stamp the build into the binary and into the first line of its log
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>
2026-08-19 22:46:57 -07:00
Dylan DeverillandClaude Opus 5 234d016ac8 Settle for a shared handle when cycling cannot win the controller
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>
2026-08-19 22:46:57 -07:00
Dylan DeverillandClaude Opus 5 2d93ceba28 Let a game profile follow the default, and be deleted
Both of these follow from the mode added in the previous commit. Profiles used
to be rare — you made one when you wanted a game to play differently — and it
turned them routine, because under "off unless a game profile is running" a
profile is also how you say a game should have the controller at all. Two things
that did not matter when profiles were rare matter once there are a dozen.

The first is that every profile was a full copy of the controls, forked from the
default at the moment it was created and never tracking it again. A profile made
only to turn the controller on had no business carrying controls of its own, and
improving the default did nothing for any of them. useDefaultMappings makes such
a profile a follower: ActiveProfile resolves it to the default rather than
copying anything, so editing the default reaches every game following it without
a single stored profile being touched. Games never saved before start out
following; games already saved keep what they were saved as, and the stored
controls stay written either way, so unticking gives a game back exactly the
controls it had. False is the stored default, because every profile written
before this had controls of its own and reading those back as followers would
have discarded them.

The second is that there was no way to remove a profile. Creating one is a
couple of clicks and undoing it was impossible, which is a poor trade in any
case and a worse one now that an unwanted profile also turns the controller on
by itself. The delete path reuses what was already there: the registry writer
already pruned subkeys past the end of a shrunken map, and ActiveProfile already
resolves a missing id to the default, so the runtime consequence of deleting the
live game's profile is a fallback that already existed. Like applying, it makes
no control decision while the window is open — a row may be listening for a
button press, and dropping the controller under it would end that mid-capture.
Closing the window re-evaluates, which is what actually lets go.

The page keeps one modal rather than gaining a second. Escape, the backdrop and
the keydown handler all assume exactly one thing can sit above everything else,
and a second would have had to be taught to each of them.

Applying now pushes unconditionally instead of comparing the edited id against
the live one. That comparison cannot see the new case: edit the default while a
following game is live and the ids do not match, but the live controls have just
changed. Only ActiveProfile knows about following, so it decides.

The game index the page sends moved into GameIndexFrom, shared by apply and
delete so one place decides what a valid index is. It also tightened apply,
which used to let a malformed index fall through to the same branch as an absent
one and be read as the default profile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:35:16 -07:00
Dylan DeverillandClaude Opus 5 c243576ec2 Add an auto mode that runs only for games with a profile
The existing auto modes all answer "is Steam busy": Manual defers to the tray
toggle, and the two Steam modes yield whenever Steam, or a Steam game, is
running. None of them can express the thing people keep asking for, which is a
controller that stays out of the way at the desktop and comes to life in a game.

OffUnlessProfile answers a different question, and only that one: is a game the
user has made a profile for in front? That keeps the outcome readable off the
mode name. An earlier attempt at this — an enable/disable setting on the default
profile, layered over whichever auto mode happened to be selected — did not: the
result depended on the mode, on the Steam rule and on the profile together, and
could not be predicted from any one of them.

It is also the one mode that does not consult Steam. A Steam game launched from
the Steam client always publishes RunningAppID, so both existing modes yield for
it and a per-game profile could never apply — per-game profiles were dead for
most of what a Steam Controller owner actually plays. Having made a profile for
a game is the user saying they want us driving it rather than Steam Input, and
the acquire path already knows how to take the device from a live Steam. With
nothing matched the mode wants nothing, so it never contends with Steam except
over games it was told about.

Making that work meant inverting who drives whom. OnForegroundChanged used to
return early unless the controller was already ours, on the grounds that a
yielded controller has no bindings to switch; it is now the path that decides
whether we hold the device at all, so it has to run precisely when we do not.
ActivateProfile split into SelectProfile, which records what matched,
PushActiveProfile, which applies it, and EvaluateControl, which acquires or
releases — the control decision reads the selection, and leaving them joined had
ReleaseControl and profile switching calling each other.

Releases wait five seconds. Every release and re-acquire round trip unplugs and
replugs the virtual pad, which a running game sees as the controller being
pulled out, and alt-tabbing to a browser mid-game is ordinary. Steam turning up
is exempt, since the whole point of yielding is that Steam is about to open the
device. For the same reason PushActiveProfile only runs when control is wanted:
pushing the default profile on the way out of a game rebuilt the pad under it
within a frame, five seconds before the grace period had any say.

ShowAlertBalloon now logs what it raises. Notifications were the one thing the
app did that left no trace, so a report of seeing one twice could not be
answered — the log showed a profile loading once and said nothing about how many
times the toast was drawn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:35:16 -07:00
Dylan DeverillandClaude Opus 5 af1c815cf5 Stop treating a missing ViGEm bus as a contested controller
A virtual pad that could not be created was reported as
GameModeOutcome::Blocked — the same answer the manager gives when another
process holds the controller. The acquire path answers Blocked by restarting
the controller's devnode to invalidate somebody's handle, so a ViGEm problem
drove device cycles at hardware with nothing wrong with it. That is what left
a reporter's puck collections stuck at problem code 22 in #65.

VirtualPadUnavailable is now its own outcome, and the acquire path returns on
it ahead of all the contention machinery: no retry counter, no cycling, no
holder scan. Measured against a disabled bus with Steam closed, so that
contention could not account for the result: zero device cycles across the
whole episode, where the previous build spent three.

Blocked still outranks it in the aggregate, because contention is the one of
the two that a cycle can actually resolve.

The failing slot releases its exclusive handle. It claimed the device and
switched lizard mode off before discovering it had nowhere to send input,
then held that claim for nothing — locking Steam out, and making the app
itself the handle that vetoes its own device cycle, which is the veto #65's
logs recorded.

The driver notice fires once. The acquire burst re-entered EnableGameMode
eleven times in half a second and every pass re-armed the balloon, which is
the toast storm reported in #68 — Windows was still replaying queued toasts
minutes after the app had been closed. The burst is called off as soon as a
missing pad is the answer, the latch is cleared only by a pad that actually
came up, and this path retries every 30 seconds rather than every four.

Retrying no longer disturbs the controller. Learning that the bus is still
absent by attempting an enable costs an exclusive claim and a lizard mode
toggle every time, so once the absence is established it is re-established by
enumerating, which touches no device at all.

The tray menu re-enumerates before it is drawn. Releasing clears the slot
list and only a device event refilled it, so after a manual disable the
toggle grayed itself out as "(no controller detected)" and stayed that way
until the app was restarted — #68's third item, and why a restart was the
only recovery.

The bus report reaches the log as UTF-8. EventLog's %ls goes through fprintf,
which converts through the C locale and abandons the line at the first
character it cannot encode: the report logged as "no virtual gamepad bus is
present" and stopped dead at the em dash that followed. Converted at this
call site only — the remaining %ls arguments carry device paths and process
names and have the same flaw wherever those are not ASCII.

Recovery no longer needs a restart. With the retry running, re-enabling the
bus brought game mode up 30 seconds later with no relaunch and no clicking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 22:17:28 -07:00
Dylan DeverillandClaude Opus 5 a087c236b2 Add per-game control profiles
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>
2026-08-13 16:53:55 -07:00
Dylan DeverillandClaude Opus 5 bda3a24756 Stop the app from cycling the controller to escape its own handle
Reported against 1.13 (#65): the puck's vendor collections sat at problem
22 after the app cycled them, and did so again within seconds of every
boot because the acquire runs at every startup. The holder scan in the
user's log names the blocker plainly:

  HOLDERS: all=[SteamlessController.exe (7696)] reportable=[none]
  FELL BACK TO RESTART (transport=dongle err=50 veto=open handle ...)

The app releases its handles before asking for a cycle, but it reopens too
soon. TryAcquireController opens the device at the top, every arrival the
cycle produces re-enters it, and the existing spacing check sits below
that open — it only stopped a second cycle stacking on the first, never
the reopen. PnP does not care that the handle vetoing the teardown belongs
to us. So the cycle is refused, and each refused cycle is a chance to
leave the devnode switched off.

Hold the claim off while a cycle is in flight. Raised where cycles are
started so the recovery path is covered too, cleared by the arrival that
ends the cycle so the reopen race against Steam is not slowed — measured
at 61 ms from arrival to game mode, with ten re-entries suppressed in
between. The helper also reports when every handle it found was one of
ours, and the app then releases rather than cycling again; steam.exe is
deliberately excluded, since it holds the device by design and yields.

Two further defects the report exposed:

A disable was judged by reading the devnode's status once, immediately.
Read too early it says "nothing went down", which skips the re-enable
entirely and clears the pending record, so a disable landing a moment
later is left in place with nothing tracking it and a cycle verdict
claiming the opposite — which is how a FELL BACK TO RESTART verdict and a
disabled collection came to coexist. Poll for it to settle instead, only
on the path about to conclude the disable was refused.

Enabling in place does not always work. A node can sit at problem 22 with
ConfigFlags clear, and for those DICS_ENABLE reports success and changes
nothing however often it is asked, while rebuilding the node does work.
Fall back to remove and re-enumerate, in the cycle and in the recovery
path. Confirmed enabled after the rebuild, not merely removed: a
persistent ConfigFlags disable is read again by the rebuilt node and
disables it just the same, and treating removal as proof would report
success over a dead device and discard the record that is the only way
back. Verified by forcing the in-place enable off — the rebuild ran and
correctly reported STILL DISABLED rather than a false success.

Not covered here: vigem_connect failing against the Oculus fork of the
ViGEm bus, which is what kept the app retrying long enough for this to
bite. That is a separate issue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:56:43 -07:00
Dylan DeverillandClaude Opus 5 9e289a2178 Log a heartbeat so an idle app and a stopped one stop looking alike
Nothing is written while the app sits idle with nothing changing — AUTO
lines fire on Steam state changes, not on state — and nothing is written
while it is wedged or dead either. A gap in the log therefore means either
"healthy, with nothing to say" or "stopped working", and after the fact
there is no way to tell which.

That is not a hypothetical shortcoming. Diagnosing a controller that had
gone missing turned on a 33-hour gap that read as a hang and was very
likely an idle app with a game in the foreground, and nothing in the
record could settle it.

Beat every 15 minutes with the state that matters: connected, game mode,
Steam state, auto mode, uptime. Idling for a month costs a fraction of the
log's 512 KB, and the resolution bounds a stop to within a quarter hour,
which only has to beat "somewhere in the last 33 hours".

Driven by the window timer deliberately. A background thread would prove
only that the process still exists; this proves the message loop is being
pumped, which is what "not hung" means here — the acquire and release
paths both run on it and both can sit inside SetupAPI for seconds.

A beat that arrives late also says so. Lateness is measured with unbiased
interrupt time, which does not advance while the machine is suspended, so
a lid being shut does not read as a stall. Measured by suspending the UI
thread for 20 s: one beat on resume reporting 23 s blocked, timers having
coalesced rather than fired four times.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 00:14:55 -07:00
Dylan DeverillandClaude Opus 5 25e4f86446 Put back a controller devnode left disabled by an unfinished cycle
A cycle disables the devnode and re-enables it about a second later. The
disable is global, so it lands in the registry immediately and outlives
the process that asked for it — and a replug, and a reboot. If the process
does not survive that second, the controller is left switched off with
nothing running to put it back, and recovering it needs Device Manager.

The window is not theoretical. On the elevated path the cycle runs inside
the long-lived tray app rather than the second-long helper, so being
killed mid-cycle is an ordinary thing for a user to do. Measured by ending
the helper the instant ConfigFlags went to 1: the HID collection stays
disabled across replug and reboot, and nothing in the app notices.

Record the intent to disable before performing it, committed to disk, and
drop the record only once the devnode confirms it is back. What is left
behind is an unfinished cycle, and the next run undoes it — in process
when elevated, through the helper's scheduled task otherwise, since
enabling a devnode needs administrator rights. A cycle that ends stranded
deliberately leaves its record armed. The helper also reconciles before
cycling: a switched-off controller enumerates nothing, so otherwise the
cycle finds no interfaces and silently does nothing, which looks exactly
like a controller that is not plugged in.

The record names the devnode and its parent. The disable is aimed at the
HID collection child and lands there, but recording both costs nothing and
keeps recovery independent of that.

A devnode can also be disabled by something else entirely — Device
Manager, regedit, pnputil, a tool that hides HID devices. That is not
undone silently, because it may well be deliberate. It is no longer
invisible either: it publishes no interfaces, so from in here it is
indistinguishable from an empty USB port, while the device still lights up
and still chimes on arrival. Name it at startup and offer a tray item to
switch it back on, which routes through the same recovery path rather than
growing a second way to enable a devnode.

Also log the cycle on the release path. It announced nothing at all, so a
device left disabled by an interrupted release appeared to come from
nowhere — the acquire path has always said what it was doing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 00:14:55 -07:00
Dylan DeverillandClaude Opus 5 09fe805119 Tell the user when Windows is discarding the input this app injects
Users reported the trackpad going dead after a takeover — haptics still
firing, cursor not moving — and recovering the moment focus moved to
another window or the window in front was closed. Haptics are HID writes
and the virtual pad is ViGEm, so both keep working regardless; SendInput
was the one stage of the pipeline that could fail and leave no trace.

Reproduced on this machine with an elevated Device Manager in front:

  - 137 px of movement injected, cursor pinned at (910,781) for 5s
  - foreground mmc.exe at High integrity, this process at Medium
  - SendInput returned success for every one of those events

That last point decides the shape of the fix. UIPI is documented as
failing the call with ERROR_ACCESS_DENIED, but on Windows 11 26200 the
call reports success and the input is dropped afterwards, so no
return-value check can see it. The condition is only observable by asking,
before injecting, whether the foreground window outranks this process.

So that is what Send now does, on every event:

  - one check covers cursor movement, trackpad clicks, paddle buttons and
    keys, none of which can be judged from their own result. A blocked
    paddle press previously produced no evidence anywhere
  - the foreground lookup is cached against the window it describes;
    injection happens at report rate, foreground changes do not
  - the state changing is what gets logged, not the events: one line
    naming the blocker, one when it stops
  - the tray balloon says which application is blocking input and that
    switching away from it restores the controller, rate-limited to one
    per blocker per minute so alt-tabbing cannot spam it
  - the check never gates the event. It observes and reports; if it is
    ever wrong about a system, the input goes out exactly as before

A foreground process whose token cannot be read is deliberately not
named. From Low integrity, OpenProcess succeeds while OpenProcessToken
fails with ERROR_ACCESS_DENIED, and protected processes refuse it too —
so an unreadable level means unknown, not blocked. Guessing would put a
wrong application's name in front of the user; the snapshot records
"unreadable" instead.

Backing this up, for the causes that produce no error and no integrity
mismatch: injected movement is checked against the cursor actually
moving, which is what catches a cursor clip a game left behind or a hook
filtering injected events. It stays quiet whenever a blocker is already
known, so it cannot contradict the better explanation. A refused call is
still logged with a dropped-event count and a recovery line, as a cheap
catch-all for systems where the call does fail.

The read loop also logs the shape of the first state report it gets.
TrackpadMouse requires 30 bytes where the haptics need 28, so a short
report would produce working haptics and a dead cursor — the same symptom
from a completely different cause, and not one that can be told apart
from a user's description. This machine reports 0x42 at 54 bytes over
USB, which rules it out here but not on transports we have less evidence
for.

EventLog::Write is annotated _Printf_format_string_ so a mismatched
specifier is caught by /analyze. It is not enforced in the normal build,
but these lines are read long after the fact by someone trying to explain
a fault they cannot reproduce, and the paths that log least often are
exactly the ones no test run exercises.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:49:06 -07:00
Dylan DeverillandClaude Opus 5 350161785d Report what is actually holding the controller instead of blaming Steam
When an exclusive claim failed, the app assumed Steam was refusing to let
go, burned three device cycles over ~26 seconds, and told the user to
close Steam. All of that could be wrong: any process with the device open
blocks the claim, and no amount of cycling evicts it.

Measured while a process held an open handle and never yielded:

  - disabling the HID collection is vetoed (ERROR_GEN_FAILURE, ~6s)
  - query-removing it is vetoed (PNP_VetoOutstandingOpen, ~5.4s)
  - query-removing the USB parent is vetoed the same way, naming the
    child as the blocker

Consent propagates up the tree, so no devnode teardown reachable from
user mode breaks such a handle. The cycle works against Steam only
because Steam cooperates: it yields on DBT_DEVICEQUERYREMOVE and reopens
on arrival, which is why the reopen is a race rather than a standoff.
Against an uncooperative holder there is no eviction path, only
detection — so detection is what this adds.

  - CM_Query_And_Remove_SubTreeW reports why a teardown was refused, and
    the verdict crosses to the tray app through a status file
  - a cycle that broke no handles stops the retry loop immediately, since
    repeating it cannot do more than the last one did
  - the handle table is scanned to name the processes holding the device,
    which is the only actionable output left when eviction is impossible
  - messages say what was established, and suggest a replug: a physical
    disconnect is a surprise removal that no veto can stop

The event log carries the unfiltered holder list and the scan's coverage,
not just the conclusion — an empty result can mean nothing was holding
the device, or that the scan never reached it, and that difference cannot
be recovered from a user's machine after the fact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:07:26 -07:00
Dylan DeverillandClaude Opus 5 0839f79f16 Represent paddle bindings as a tagged value, not a flat enum
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>
2026-08-05 18:55:24 -07:00
Dylan DeverillandClaude Opus 5 ba215b2429 Fix custom back button mappings being ignored in mouse click mode
"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>
2026-08-05 18:55:24 -07:00
Dylan DeverillandClaude Opus 5 9ef3609da5 Fix orphaned ViGEm pads left behind by a failed target add (#52)
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>
2026-08-05 01:15:29 -07:00
Dylan DeverillandClaude Opus 5 94f1bf94dd Share one acquire/release path across manual and auto modes (#53)
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>
2026-08-05 01:03:26 -07:00
345bf15ca3 Fix puck activation after firmware updates (#44)
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>
2026-08-03 23:34:08 -07:00
Dylan DeverillandClaude Fable 5 7feb53f3ea Validate the cycle task points at the current helper
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>
2026-08-01 18:36:27 -07:00
Dylan DeverillandClaude Fable 5 50a8ae246d Add Bluetooth LE support for the Steam Controller
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>
2026-08-01 17:41:43 -07:00
Dylan DeverillandClaude Fable 5 5cd54be8d0 Add Steam-aware auto control modes, device-cycle helper, and diagnostics
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>
2026-07-07 14:32:14 -07:00
Dylan DeverillandClaude Sonnet 4.6 a8a9e539ac Add Xbox / PlayStation controller platform selection
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>
2026-06-21 01:22:28 -07:00
Dylan Deverill 0ce2b57a36 Merge main into PR branch; wire rumble into multi-slot architecture
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>
2026-06-20 23:13:24 -07:00
Dylan Deverill f7c4f13c90 Address review feedback on rumble forwarding PR
- 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>
2026-06-20 23:04:38 -07:00
Dylan DeverillandClaude Sonnet 4.6 5ca94176b3 Add back button remapping UI with registry persistence
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>
2026-06-20 22:40:13 -07:00
ntspaceowl e5416d53a5 Refresh tray icon after startup state sync 2026-05-30 22:07:46 +09:00
ntspaceowl bc3e9932b1 Auto-enable Steamless mode after startup reconnect 2026-05-30 22:00:00 +09:00
Dylan DeverillandClaude Sonnet 4.6 f4a2f04315 Add Start with Windows, installer script, and release build support
- 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>
2026-05-10 19:57:47 -07:00
Dylan Deverill 589ed11e4a Initial release 2026-05-10 19:28:03 -07:00