78 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
Dylan DeverillandClaude Sonnet 5 09ca4c30b4 Let Escape be captured as a key-combo binding (#105)
Escape used to be intercepted globally as the remap window's cancel
affordance, so pressing it while listening just exited capture instead
of binding anything. Now it only dismisses the unsaved-changes modal
or the game picker; while a row is listening, Escape falls through to
the normal capture path (alone or as part of a combo), and the VK
table gained the missing entry so it resolves to a real binding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 23:41:48 -07:00
Dylan DeverillandClaude Sonnet 5 f56559fe35 Share one is-this-dock-held check across ClaimDocks, AdoptDock, and UnheldDockPaths
The three each repeated the same any_of over m_docks. Pulled it into
IsDockHeld so they can't drift on what "held" means later.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 00:08:54 -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 dbb5edb641 Report scroll speed as what it actually feels like, and reach lower (#95)
Two things, both found by working out what the log was really saying.

The PADS line reported speed x correction as a "net % of calibrated". That
is the multiplier on the wheel units we send, not the distance anything
moves — the machine's lines-per-notch multiplies them straight back up. The
two cancel exactly:

  L = D x SENSITIVITY x (3/linesPerNotch) x speed/100 / WHEEL_DELTA
        x linesPerNotch
    = D x SENSITIVITY x 3 x speed/100 / WHEEL_DELTA

which is the whole point of correcting for it. So the speed setting alone
says how fast a pad scrolls, and the log said 8% for a pad that was in fact
scrolling at 25% of calibrated. The reporter's log is the one being read to
decide what to change next, so a figure that misstates it by 3.3x is worse
than no figure. The correction is still printed — it explains why two
machines at the same speed send different wheel numbers — but it is no
longer presented as something to multiply by. TrackpadDirectionProbe now
checks the cancellation directly at 1, 3, 10, 20 and 100 lines per notch.

The floor drops from 25% to 10%, because the reporter settled on exactly
25% and asked to go lower — he was sitting on the end of the range.

The slider now offers stops instead of a continuous percentage:

  0.1x 0.15x 0.25x 0.35x 0.5x 0.75x 1x 1.5x 2x 3x 4x

Roughly a constant ratio per step, which a linear slider cannot be: over
10..400 linear, three quarters of the track sits above 1x where nobody has
asked to go, and the region people use is squeezed into the first quarter.
The stops also have names, and "set it to 0.5x" ends a support round trip
that "set it to 47%" starts another one of — which on this issue is not a
small consideration.

25 and 100 are both stops on purpose, so neither the reporter's setting nor
the default is rounded out from under someone. Values between stops remain
valid and stored — the C++ side still accepts anything in range, and an
older build wrote continuous ones — they simply show as the nearest stop
and only become it if the user applies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:11:33 -07:00
Dylan DeverillandClaude Opus 5 36b94872c1 Record what the pads are set to, not just when control was taken (#95)
Two rounds of #95 diagnostics produced logs that could not answer the
question they were collected for, and both times the reason was the same:
the only line describing the trackpad setup came from EnableGameMode, and
it was gated on a pad already being claimed for the desktop at that
moment.

That gate assumes configuration precedes enabling. It usually does not —
the settings window is how you configure a pad, and people open it after
turning the app on. The reporter's last log never enabled at all; the
maintainer's next one enabled first and configured five seconds later.
Neither produced the wheel line, so neither could say whether the pad
under test was even in scroll mode.

So the pad setup is now logged where it is applied rather than only where
control is taken, once per change:

  PADS: left=scroll (natural, speed 25%, wheel correction x0.30, net 8%
        of calibrated) right=pointer

The net figure is the point. A scroll-feels-wrong report has two
independent multipliers behind it — the user's speed setting and the
machine's lines-per-notch — and neither alone explains the feel, so the
line carries both and their product. The correction is dropped on a
machine left at the Windows default, where saying it three times adds
nothing.

The line also says when game mode is off, because settings changed then do
nothing whatsoever and until now nothing told anybody. That is the exact
shape of the reporter's "no change even at 25%".

The enable-time environment line loses its gate as well. Withholding it
when no pad is claimed hid it from the report most likely to need it — a
trackpad doing nothing at all, which deserves a line saying so rather than
silence.

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 b477e9a4b7 Scroll the same distance whatever the machine's wheel setting is (#95)
Scroll is injected as wheel events, and Windows multiplies every notch by
the machine's lines-per-notch setting before anything moves. That setting
is not ours, is not the same everywhere, and sits downstream of everything
we do — so identical injection scrolls 3.3x further on a machine set to 10
than on one left at the default 3. It is why a scroll-is-far-too-fast
report can be entirely real and still not reproduce on a developer's
machine running the same build.

Dividing it back out makes a given swipe cover the same ground everywhere,
which is what a gesture-to-scroll mapping should do in the first place.

Anchored at the Windows default rather than at 1, so the correction is
exactly 1.0 on a machine nobody has touched: this changes nothing for the
great majority of installs and only moves the ones that were already out
of step. WheelCorrection is kept pure and separate from the setting that
feeds it so that property is checked by TrackpadDirectionProbe rather than
merely asserted in a comment.

The reporter's logs ruled out the alternative before this was written: no
TRACKPAD jump lines across roughly a minute of pad activity on either
transport, so their pad is not dropping reports or lying about the touch
bit, and the amplification is not in our arithmetic. The enablement log
now carries the correction beside the setting, so the next report says
both what the machine does and what we did about it.

Also lands the scroll speed the scale multiplies by, so the two corrections
live together. Nothing sets it yet — it is stored, clamped and defaulted
here, and reaches the UI in the commit that follows.

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 a46db6cc1e Log the machine's wheel-lines setting alongside injection (#95)
Scroll is injected as wheel events, 120 units to a notch, and Windows
multiplies each notch by the user's lines-per-notch before anything
moves. Identical injection therefore scrolls wildly different amounts on
two machines, and a "scroll is far too fast" report gives no way to tell
that apart from a bug in our own arithmetic.

The default is 3 lines. WHEEL_PAGESCROLL makes a notch a whole page,
which turns the gentlest flick into a page jump — "I barely touch the pad
and it scrolls all the way down a page" describes that setting exactly,
and it is not something a sensitivity control could tune away, since with
a page as the smallest unit the granularity is the problem rather than
the scale.

Recorded in the injection environment line, which already fires at game
mode takeover whenever a pad is driving the desktop, so it costs nothing
and arrives attached to every future report of this kind.

The reporter's log and ours are otherwise identical — both id=0x42
len=54 — so the report layout is ruled out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00
Dylan DeverillandClaude Opus 5 71a50df221 Actuate the DS4 touchpad at the same force as everything else (#96)
The DS4 touchpad press was the last thing still reading the firmware's
click bit, which reports fewer than half of a thumb's presses and wants
more force the more heavily the pad is already being rested on. That is
what "having to press pretty hard to activate the trackpad" is, and why
it reproduces for one person and not another: the threshold is force
based, so thumb size and resting weight decide how hard is hard enough.

It now takes the same resolved press every other binding uses, so every
press in the app actuates at one threshold rather than the touchpad
keeping its own, much heavier one.

No sensitivity setting, which is what the report asked for. The pad
already actuates about as lightly as it can without a resting thumb
tripping it, so there would be nothing useful to turn down. If it still
reads heavy after this, that is evidence kPadPressArea itself is too high
for everyone rather than a reason to add a knob most people would never
find.

Also fixes a second reader of the same bit, found while checking for
others: TapState asked it whether a contact had become a press, so a
gentle press the firmware missed left the contact looking like a tap and
fired the tap binding on lift, on top of the direction the press had
already sent. With a tap bound to a mouse button that is a stray click on
every soft press.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00
Dylan DeverillandClaude Opus 5 02fea1fff3 Raise the press threshold to 1800, measured against real resting
The 250 this replaces was calibrated against a thumb held OFF the pad.
TrackpadZoneProbe's resting stage only samples frames where the touch bit
is set, and the run that produced it reported a median of 0 and a maximum
of 33 — numbers no thumb resting on a pad produces. A thumb actually
resting reads 259..381, so 250 sat inside the resting band: a pad with a
thumb lying on it read as a held press, the area never fell back through
the threshold, and consecutive presses merged into one. The same
swallowing the old click haptic suffered, reached from a threshold too
low rather than one too high.

Traced live against the firmware's own click bit, four bands, none
overlapping:

  thumb resting or gliding      259..381
  presses the firmware misses  2840..3921
  presses it calls a click     4026..4427   (releases again around 2200)
  a press being held           4671..14784

1800 sits in the middle of the empty span between a thumb that is merely
down and one that is pressing — about five times a resting thumb, and
still far below the presses the click bit drops, which is the whole
reason the area is read at all.

The dip that 250's comment also rested on is not in the traces: while a
press is held the area only climbs. The apparent mid-press dips were
separate press attempts merged into one span by that same low threshold.

TrackpadDirectionProbe gains a live mode for this. The firmware plays its
own haptic at the moment it judges a press happened and announces it to
the host in a 0x40 report, which turns the haptics being felt into a
per-event ground truth: each one is timestamped against our area-based
detection, the click bit and direction changes, with the deltas
summarised on exit. It judges presses with the detector the app ships, so
what it measures is what a user feels. It also reports the two numbers a
threshold has to live between — the lowest area while a click is held,
and the floor between consecutive clicks — and says outright when they
overlap, since no threshold works if they do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00
Dylan DeverillandClaude Opus 5 377aa0b18e Detect a trackpad press from contact area, not the firmware's click bit
Measured with TrackpadZoneProbe: pressing the pad twenty times with a
thumb that stays on it produced nine rising edges on BTN_TP_RT_CLICK. The
presses were all in the report — a press flattens the fingertip and the
contact area spikes for every one — so they are recovered from the area
instead, and the click bit is kept only as a corroborating signal for the
firm presses it does report.

One threshold, not two. Above kPadPressArea is pressed, below it is not,
and everything below counts as backed off however heavily a particular
thumb rests. A separate release level would be a second per-person number
with nothing behind it, and one guessed too high latches the detector and
swallows every press after the first — which is precisely the bug the
click haptic had, where the release waited on an area a planted thumb
never reaches.

250 because a resting thumb reports almost nothing: over 1250 resting
samples the median was 0 and the highest 33, against presses peaking past
6000. Deliberately near the bottom of that range rather than the middle.
The area dips and recovers within a single press, so a threshold up in
the press range splits one press into several — the sweep counted 24
presses at 1092 where twenty were made — while one just above resting
does not. Two frames of confirmation on each side, which is not a
per-person number but a description of the sensor's noise, and is what a
single threshold needs in place of the second level it does without.
Three frames starts merging genuinely separate presses.

One answer now drives everything a press feeds: the directions, the click
binding on both delivery paths, and the click haptic, which was firing
for fewer than half the presses a thumb made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00
Dylan DeverillandClaude Opus 5 3865120f77 Decide a trackpad tap on the lift instead of after a fixed wait
The confirmation window added in the previous commit does not work, and
the measurement that sized it says why. Over 148 presses the finger is
down for a median of 59 frames before the click and sometimes thousands:
a thumb is planted and then pressed. Suppressing a tap on every press
would need a wait longer than the longest of those, so any window short
enough for a tap to feel responsive still fires before nearly every
press. Twelve frames would have caught well under a fifth of them.

A tap is knowable exactly, just not at the moment of contact. It is a
contact that ended without a click, was brief, and did not travel — all
three known at the lift. So that is where it is decided now, with no
window to tune and no latency to trade off. It reports as a short pulse
rather than a hold, because the event is over by the time it is known to
have happened; the row is labelled Trackpad Tap, which is what it now is.

The same session validates the click haptic fix from two commits ago:
the shortest gap held the click bit low for 7 frames against a 4-frame
release window, so all 148 presses fire. The probe had been replaying the
old area-based latch and reporting 132 of 148, which described code that
no longer exists — its replay now mirrors the current constants, and its
touch-to-click section explains the lift rather than recommending a
window that cannot work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00
Dylan DeverillandClaude Opus 5 fbdc10a815 Report a trackpad tap only when the contact was not a press
A press sets the report's touch bit on its way down — the finger lands,
force builds, and only then does the click bit follow — so a touch
binding dispatched off the raw bit fired on every press, before the press
itself. Pressing a direction triggered whatever the touch was bound to
first. Not specific to the directional pad: clicking a pointer pad did
it too, it was just never somewhere a thumb presses constantly.

Touch is not an independent signal. A click always implies one, so the
raw bit cannot be what a binding fires on. What makes a touch its own
event is a contact that does NOT become a press, which is why the row is
now labelled Trackpad Tap.

A finger must therefore be down for twelve frames without clicking before
a tap is reported, and a contact that clicks first reports none — nor any
later in that same contact, having stopped being a tap. Nothing else
separates the two: contact area does not (a thumb loading for a press
sits around 2500, above a light rest and overlapping a grinding one), and
the click that would settle it has not happened yet. Only waiting does.

Both delivery paths read the confirmed answer off PadDigital rather than
re-deriving it, as they already do for directions and the click's zone.

TrackpadZoneProbe's tap mode now also reports how long a finger is down
before the click, which is the ceiling on the confirmation window: twelve
is a starting value, and the shortest real press is what it has to stay
under. That session also echoes each tap as it happens and reports the
stream rate, after a run captured two presses out of many with no way to
tell a dropped report from a press that never happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00
Dylan DeverillandClaude Opus 5 f955c9d239 Expose the directional pad and single button modes in the remap window
Phase 5 of the v1.20 trackpad work, and the point at which either mode
can be chosen without editing the registry.

Movement Mode gains Directional Pad and Single Button. Each pad grows ten
rows — four directions and a touch, per pad — which need no machinery of
their own: they are bindings like any other and go through the same
rebind, capture and reset paths the paddles already use. What each mode
shows is a table rather than a chain of conditionals, because the answer
is now six modes wide and the old two-branch form had no room for the
distinction that matters: a mode either resolves directions or it does
not, while touch rides along with anything using the pad at all.

A directional pad also gets a Diagonals dropdown and one line saying how
its surface is split, which is the only thing about these rows that
cannot be read off their labels.

Changing mode now cancels a capture on any row the new mode hides,
rather than only on the DS4 case. Switching a pad from Directional Pad to
Mouse Pointer while rebinding Up would otherwise leave the row listening
behind hidden UI, and the next button pressed would bind to something the
user could not see. A row the new mode keeps — touch, in every mode that
has it — goes on listening.

Key labels are now gathered from every binding a profile holds rather
than just the clicks, so a direction bound to a key still has a name
ready when its mode is switched back on.

Verified by extracting the page and driving it in a browser: all six
modes show exactly the rows intended, a profile round-trips through
save and load with every new key, one written before these existed reads
back as the documented defaults, and the capture-cancel rule holds in
both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00
Dylan DeverillandClaude Opus 5 536536950e Tick a directional pad on direction changes, not on distance
Phase 4 of the v1.20 trackpad work.

The movement haptic ticks once per 6500 units of travel, which is the
right feedback for a pad steering a pointer and the wrong one for a pad
being used as a d-pad: distance means nothing there, so sliding around
inside a single direction buzzes continuously for no event, while rolling
from one direction to the next — the only thing that actually happens —
gets no acknowledgement at all.

A directional pad now ticks once per direction change instead, which is
the detent a d-pad gives. Only between two directions: arriving from
nothing is the press and going back to nothing is the release, and both
already fire their own click haptic.

That tick sits outside the distance gates rather than among them. Every
one of those would suppress it — directions only change while the click
is held, and the gates exist precisely to stop a press bleeding a tick
into the click haptic.

A single button reports no movement at all, so it ticks for nothing. The
other three modes are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00
Dylan DeverillandClaude Opus 5 a4c6475e84 Fire the click haptic on every press, not just the first of a burst
The click haptic latch re-armed only once contact area fell to an
absolute idle level (<=1000) for eight frames. Contact area only gets
that low when the thumb leaves the pad, so tapping a pad the way you walk
down a menu — thumb planted, pressure modulating — left the latch waiting
for a release that never came, and every press after the first went
unfelt. Lifting clear of the pad was the only way to feel the next one.

Measured rather than reasoned: TrackpadZoneProbe's tap mode replays the
real latch over recorded presses, and a genuine two-tap burst had the gap
between them bottom out at 1236 against a threshold of 1000, with the
second press duly swallowed. A thumb resting between taps sits around
2500.

Release now confirms on the click bit holding false for four frames,
matching how the press edge already works. The original reason for
distrusting the bit was chatter around the firmware's force threshold,
which a confirmation window answers the same way it answers it on press.
Area was not just unnecessary but backwards for this: grinding a hard
press dips it to 1000-2000, LOWER than the planted thumb between taps
that has to count as a release, so no absolute threshold separates the
two. Duration does — the same probe measured a real gap holding the bit
low for 18 frames, well clear of four.

Predates the directional pad work; a pad clicked like a mouse button is
released by lifting off, so nothing before now tapped one fast enough to
notice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00
Dylan DeverillandClaude Opus 5 e14c0a799d Dispatch trackpad directions, touches and zoned clicks
Phase 3 of the v1.20 trackpad work, and the point at which a directional
pad does something: its bindings now reach both the virtual gamepad and
SendInput. Neither mode is selectable from the UI yet, so this is only
reachable by setting LeftPadMode or RightPadMode to 4 by hand.

The edge table grows from 6 bindings to 16 — the four paddles, then each
pad's click, touch and four directions. Every entry still has the same
shape, so auto-repeat and the release-on-teardown sweep pick the new ones
up for free, both being sized off kBindableCount.

Directions are the first edge here that is not a bit in the report. They
are resolved from a position, so the previous report cannot be re-read
for them and the slot carries the answer instead. That write sits outside
the hasPrev guard, alongside the report copy it is the equivalent of: the
first frame of a read loop has to establish a baseline rather than
dispatch against a stale one, and skipping it on any frame would stand a
direction up or down for good.

A directional pad's click only reaches its click binding when the press
landed inside the centre circle. Out in the ring the same press is a
direction, and delivering both is the one-press-two-meanings collision
the zone split exists to prevent. Every other mode answers "centre", so
they gate to exactly what they did before.

The virtual controller is now updated after the pads rather than before.
It is handed what they resolved, so the old order would have reported
last frame's directions — a frame of lag on every press, and a direction
still held for a frame after release. Both report paths walk the pad
bindings through one shared helper rather than a copy each; on the DS4
side that walk happens before the hat is computed, so a direction bound
to the d-pad folds into it and diagonals come out as diagonals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -07:00
Dylan DeverillandClaude Opus 5 93ff4a0f52 Resolve directional pad directions in TrackpadInput
Phase 2 of the v1.20 trackpad work. Nothing dispatches these yet — the
directions are resolved and handed out, and Phase 3 wires them to the
virtual pad and to SendInput.

TrackpadMouse is renamed TrackpadInput. The name stopped being true once
the class resolves d-pad directions as well as driving the mouse; the
registry value L"TrackpadMouse" keeps its name, being the retired toggle
this has nothing to do with.

Directions come from the click rather than from touch, so a resting thumb
presses nothing, and the pad is split by where the click lands: inside
kPadRingRadius the press is the pad's click binding, outside it is the
direction under the thumb. The zone is decided once on the rising edge
and never revisited — sliding from ring to centre mid-press would
otherwise release a direction and fire the click, which is one physical
press meaning two things and the collision the split exists to stop.
Within a ring press the direction does keep following the thumb, so a
roll from up to right registers as both in turn.

Eight-way presses two directions on a diagonal; four-way rounds to the
nearer cardinal. A sector is held until the thumb clears its boundary by
8 degrees, because directions are edge-dispatched and a thumb parked on a
boundary would otherwise emit a stream of key down/up pairs rather than
merely flickering.

TrackpadDirectionProbe drives all of this through synthesized state
reports and checks 50 cases: sector bearings including the wrap through
0/360, both diagonal modes, the ring boundary either side, the zone
latching in both directions, release clearing state, hysteresis both
holding and letting go, every other mode resolving nothing, and a short
report being ignored. Feeding whole reports rather than calling the
resolver also covers the byte offsets, which is where a left-pad reader
ends up reading right-pad coordinates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:26:09 -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 DeverillandClaude Opus 5 3f2e311437 Re-split the remap page literals under the C2026 limit
The page is built from adjacent raw string literals because MSVC caps one
at 16380 bytes. The three pieces had drifted to roughly 17.5KB each,
which the old toolchain accepted and VS 2026 does not — main stopped
compiling with three C2026s the moment the generator moved, and nothing
warns on the way there.

Six pieces now, largest 12.7KB. The splits fall after </style>, between
the WebView bridge and the actions it drives, and between the picker and
the row rendering, so each one lands where two complete constructs meet.
Adjacent literals are concatenated by the compiler, so this is invisible
at runtime: the extracted page is line-for-line identical, bar one blank
line at each new seam.

The comment that governs this also drifted — it advised keeping pieces
near 16KB, which is the cap itself rather than a budget under it, and
told the next person to rebalance the halves rather than add a split.
Both are why the pieces grew past the limit. It now names a 12KB budget
and says to add a seam instead.

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 9d656c2809 Stop unrelated balloons offering a driver download
ShowAlertBalloon defaulted its action to BalloonAction::ViGEmDownload, so
every caller that did not think about the question sent the user to the
ViGEmBus releases page when the notification was clicked. Five did:

  a Bluetooth connection dropping, or a USB controller being unplugged
  another process holding the device
  a device cycle that never took the devnode down
  Steam refusing to hand the controller back
  a devnode that needs re-enabling in Device Manager

None of those are ViGEmBus problems and none of them are improved by
installing it. The advice was actively misleading for the last one, where the
fix is Device Manager, and for the disconnect balloons, which are routine.

Doing nothing is the default now, so forgetting to consider it is inert and
opening something has to be asked for. The two balloons that genuinely act
already say so: ShowViGEmBalloon sets the action directly, and only when the
manager established the bus is actually missing rather than present and
unusable; the disabled-devnode balloon passes EnableDisabledDevice.

The member's initial value moves to None for the same reason, and None leads
the enum so the harmless case is what a default-constructed value lands on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:46:57 -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 588b2cc9bc Let a profile for a game that is gone still be reached
A profile whose game is no longer installed could not be seen, edited or
deleted. SendInitState walks the installed list and looks profiles up against
it, so an entry with no installed game was never sent to the page — while
staying live in m_gameProfiles, still matched by MatchProfile, and under the new
mode still turning the controller on. Invisible and working at once, and the
case where deleting a profile is most likely to be what someone wants.

What kept it unreachable is that the page names games by index into the
installed list, and an orphan has no index. Raw ids cannot cross that channel:
they hold non-ASCII and backslashes the parser does not unescape. So the indices
are extended instead — one appended entry per orphaned profile, past the real
games, with m_installedCount marking where they start. Apply and delete then
work through the paths that already existed, unchanged, and SendInitState's
profile loop picks the orphans up on its own because it iterates the same list.

Their names come from displayName, which was stored for exactly this: naming a
profile when the installed list cannot. Profiles older than that field fall back
to the raw id.

Nothing is pruned automatically. Not being in the installed list is not the same
as being uninstalled — a deleted Start Menu shortcut, a Steam library on a drive
that is currently disconnected, or a game whose shortcut was never created all
look identical from here, and quietly deleting a profile in any of those cases
would destroy real work to tidy a cosmetic problem. They are labelled and left,
and if the game returns they simply stop being orphans.

An enumeration that finds nothing at all is treated as knowing nothing rather
than as everything being uninstalled. A COM failure reading shell:AppsFolder is
far likelier than an empty PC, and flagging every profile as missing on that
evidence would invite someone to delete all of them.

Deleting an orphan drops its picker entry along with the profile. The entry only
existed because the profile referred to it, and left behind it stops being
pinned — after which the only section it could appear in is the one labelled
INSTALLED GAMES, which it never was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:35:16 -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 598d1bf210 Add a touch keyboard binding that needs no elevation to click
A paddle bound to Ctrl+Win+O opens osk.exe, which carries uiAccess="true"
in its manifest and so runs at High integrity. UIPI then discards every
trackpad click aimed at it: the keyboard appears and not one of its keys
can be pressed. Elevating this app fixes that and costs a UAC prompt on
every launch, plus the cursor-freeze bug that keeps the tray unelevated.

The touch keyboard is a different window, and that is what makes this
work. Measured on Windows 11: TabTip.exe runs High but owns no visible
window, while TextInputHost.exe — which owns "Windows Input Experience",
the surface the user actually clicks — runs at Medium, the same level as
this app. So the clicks land with nothing elevated anywhere.

TabTip is asked to show the keyboard through ITipInvocation::Toggle.
Neither that interface nor CLSID_UIHostNoLaunch is in the SDK, so both
are declared from their registered IIDs. The class has no LocalServer32
— COM will not start the server — so a machine where TabTip is not
already running gets it started and polled for.

The toggle runs on a worker thread. It is a cross-process call that can
block, and every caller is a controller read loop running at report
rate.

Closes #71.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 23:40:01 -07:00
Dylan DeverillandClaude Opus 5 9a7f90d7f9 Keep a log line from ending at its first non-ASCII character
Both logs format wide strings with %ls, which fprintf converts through the C
locale. The default locale encodes nothing above ASCII, and it does not fail
loudly when it meets something: it abandons the rest of the line. Measured
with the same call shape EventLog uses — narrow format string, %ls argument,
FILE* stream:

  default  : [before
  utf8ctype: [before — after éü]

The first line is the whole output. No closing bracket, no newline, no error.

This surfaced on a bus report ending in an em dash, which logged its opening
clause and stopped mid-sentence, and was patched at that one call site. The
flaw is not confined there. Every value in these logs worth reading is one
that can carry non-ASCII: a game's name in PROFILE, the foreground window and
executable path in INJECT, the process holding the controller and the veto
name in ACQUIRE, and the same holder lists again in the helper's cycle.log.
Device paths are ASCII by construction and were the only reason this went
unnoticed.

Setting LC_CTYPE at both entry points fixes every site at once, including
sites not yet written, which converting the fifteen existing call sites would
not. LC_CTYPE alone, so number and date formatting keep the C locale's
behaviour, and at the top of wWinMain so it lands before the first log line
and before any thread exists to race it. The local converter added with the
bus report goes away with it — one mechanism rather than two, and no call
site left doing it differently for reasons nobody will remember.

No console fallout: both binaries are WIN32 subsystem with nowhere to print,
and the console probes are separate executables that do not set the locale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 23:09:54 -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 255f4f9cad Ask the system which virtual gamepad buses exist instead of guessing
vigem_connect returns VIGEM_ERROR_BUS_NOT_FOUND both when no bus driver is
installed and when one is present and did not answer, and the client never
records which product's bus it tried. The app inferred "driver missing" from
that error, so a driverMissing=1 that happened to be right was
indistinguishable from one that was wrong — which is most of why #65 took as
long as it did to pin down.

ViGEmBusInfo enumerates the interface GUID directly. The failure line is now
followed by what the machine actually has, and driverMissing is set from an
empty enumeration rather than inferred from an error code with several
causes.

The GUID comes from the vendored client's own header rather than a copy, so
updating third_party cannot leave a stale value behind here. That matters
because the header instructs forks to change it, and several products ship
one: measured on the #68 reporter's machine, which carries both the Oculus
and Virtual Desktop forks, neither publishes this GUID and only the Nefarius
bus appears. An empty result therefore does mean no usable bus, which is
what makes it safe to drive the "go install the driver" advice from. It also
retires the theory that the client had been connecting to a fork.

A disabled bus reads the same as an absent one — the devnode stays present
and reports a problem, but its interface is withdrawn entirely — so that
case belongs in the empty-list wording rather than in a state of its own.

ViGEmBusProbe prints the same inventory, since "vigem_connect failed" is not
a question a reporter's log can answer on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 22:17:28 -07:00
Dylan DeverillandClaude Opus 5 c4efa63328 Split the remap page's script across a third string literal
The per-game profile work grew the script half of the page to 26 366 bytes,
and MSVC truncates any single string literal past ~16 380 with C2026, so
main did not compile at all in a release build:

  RemapWindow.cpp(647,1): error C2026: string too big, trailing characters
  truncated

The 65 535 figure the comment cited is the limit on the concatenated
result, not on each piece, which is what let the literal grow past the cap
without anyone noticing. Corrected that, and split the script at the
per-game picker so both halves sit near 13 K. The whole document still
joins to roughly 40 K, well inside the concatenation limit.

Nothing about the page itself changes — a raw literal boundary is not
content, and the newline it introduces falls between two functions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:05:48 -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 838cd7ae68 Widen the disable settle window to what a receiver actually needs
Measured on a four-slot receiver (PID 1304), the gap between ConfigFlags
landing in the registry and the devnode admitting to problem 22 ran from
96 ms to 330 ms across its slots, on an idle machine:

  22:27:30.533  MI_03=0/1     flag written, node still reports healthy
  22:27:30.863  MI_03=22/1    node catches up, 330 ms later

The previous window was 450 ms, which covers that measurement and very
little else. Judging a disable inside the gap is the failure this window
exists to prevent — it reads as "the disable was refused", which skips the
re-enable and clears the pending record while the flag is already
committed, leaving a devnode switched off with nothing tracking it.

A slower or busier machine has no headroom there, so size it for the bad
case: ~950 ms. It is only ever spent on the path about to conclude the
disable failed, and a device that goes down promptly still pays none of
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:56:43 -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