The intermediate 1.21.1 through 1.21.4 existed only so test builds could be
told apart in a log, which this issue needed repeatedly. What ships is one
release with two user-facing additions — a per-pad scroll speed setting and
the banner saying when Steamless mode is off — so it takes a minor version
rather than the last of the test patches.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1.21.3 has already run on this machine and written logs under that version.
Rebuilding it with a visible new feature would leave those logs unable to
say which build produced them, which is the ambiguity this issue has
already lost several rounds to.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 — 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>
The reporter already has a 1.21.2 that he reports working. Sending him a
build with a different slider under the same version would leave his next
log unable to say which one produced it, which is the ambiguity the last
several rounds were spent on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
The reporter already has a build calling itself 1.21.1. Sending him a
second one that logs differently under the same version would put us back
where the last two rounds were spent — unable to tell from a log which
build wrote it.
The installer's staleness guard does not cover this: it compares the
packaged binary's version against MyAppVersion, which catches a stale exe
but not a same-version rebuild.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
The Visual Studio generator is multi-config, so CMAKE_BUILD_TYPE does
nothing and "cmake --build build/release" quietly produces DEBUG. The
installer packages build\release\Release\, which then keeps whatever was
last built there — and stamps MyAppVersion on the result regardless.
That shipped: a 1.21.1 installer carrying the 1.21 executable went to the
#95 reporter. Every visible signal agreed it was fine. Setup succeeded,
Add/Remove Programs said 1.21.1, and only the app's own startup banner and
the missing feature gave it away.
Nothing about the failure was loud, so the fix is to make it loud. The
script now reads the FileVersion out of the binary it is about to package
and aborts the compile when it disagrees with MyAppVersion, naming both
versions and the command to run. Verified in both directions: matching
compiles, mismatched aborts.
The release build preset gains the two helper binaries the installer also
packages, so "cmake --build --preset release" — the command the error
points at — is actually sufficient to produce an installer. It previously
built only SteamlessController, which on a clean tree would have left the
other two missing or stale, the same failure by a different route.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The scroll changes go to the #95 reporter as a private build before they go
to anybody else, and the last round cost a round trip precisely because the
logs could not be pinned to a build: the version banner was trimmed out of
the paste, and every line that survived was one 1.20 already wrote.
A distinct version makes the startup banner answer that on its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
Everything since 1.20: the trackpad directional pad and single button
modes, and the pad type now staying put while a game runs rather than
being rebuilt on every alt-tab.
1.20.1 existed only so private test builds could identify themselves in
their own log header, which is the one place it mattered. Nothing shipped
under it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Both diagnostics on this branch are meant to reach a reporter as a
private build before the branch merges, and a build stamped 1.20 is
indistinguishable from the release in the one place it matters — the
log's own header line, which is what comes back attached to every report.
Verified on the binary rather than the source: FileVersion and
ProductVersion both read 1.20.1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
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>
This mode measured a resting thumb and swept a threshold across the gap
to the presses, and the number it produced put kPadPressArea inside the
resting band. Its resting stage only sampled frames where the touch bit
was set, which does not reliably catch a thumb that is genuinely
resting: the run that set the constant reported a median of 0 against a
real resting level of 259..381. A pad with a thumb lying on it then read
as a held press, and consecutive presses merged into one.
Deleted rather than repaired. TrackpadDirectionProbe --live already does
this better, by correlating against the firmware's own haptic
notification — a per-event ground truth rather than a distribution to
reason about — and reporting outright the two numbers a threshold has to
live between. A tool that quietly produced a wrong answer once is worth
less than nothing next to one that did not, and the header now says so
rather than leaving the next person to rediscover it.
The ring-boundary calibration and the tap session are untouched; both
produced numbers that held up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Version in resource.h and the installer script, which the header's own
comment asks to be kept in step. Verified on the built binary rather than
in the source: FileVersion and ProductVersion both read 1.20.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The side byte on the haptic output reports indexes the actuator as
0 = left, 1 = right, 2 = both, 3 = nothing. Left was set to 2, so every
haptic meant for the left pad asked for both actuators and fired the
right one as well. Pressing the left pad buzzed both; pressing the right
buzzed only the right, which is why it read as the right pad reacting to
the left rather than as anything wrong with the left.
Each value was fired on hardware with HapticTester and reported by where
it was felt. A tick on side 2 buzzes both pads exactly as a click does,
so this is the actuator index rather than a per-command quirk or a
bitmask.
The old value came from reading the byte as "the 1-based form of the
original controller's 0 = right / 1 = left". The reversal was real but it
is not a shift: this firmware is 0 = left / 1 = right. That put right on
1 by luck and left on the both-pads value, and the two-thumb test
recorded in the old comment would have confirmed the right pad without
ever isolating the left.
Predates the v1.20 trackpad work — every left-pad haptic since these
constants were written has done this. A directional pad on the right and
a scroll wheel on the left is simply the first configuration that
presses the left pad often enough, and watches the right one closely
enough, to notice.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
v1.20 splits a trackpad by location in Directional Pad mode: a click near
the centre is the Trackpad Click binding, a click out in the ring is the
direction under the thumb. That boundary needs to be a measurement, not a
guess, and nothing in the tree reported where a thumb actually lands.
Reads only — opens shared, never sends a report, never touches lizard
mode, so Steam can keep running alongside it. "cal" walks nine stages
(centre, four cardinals, four diagonals) and reports the centre and
direction radius distributions, whether they are separable at all, a
recommended boundary, per-direction sector accuracy, and how far the
centroid drifts between touch-down and the click bit going high.
First results settle two design questions:
- The coordinate space is square, not circular. Max observed radius
46338 is sqrt(2) * 32767 — both axes saturating at once — so the
boundary belongs in absolute report units rather than as a fraction
of a "usable radius" that means the corner.
- Centroid drift at the click edge is 117 units at one frame back and
~350 at three, against a separation gap of 11700. The pre-press
lookback this tool was built to size turns out not to be needed at
all; the zone can resolve from the click frame itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The per-game picker drew on two sources: a Start Menu shortcut walk and the
installed MSIX package list. Issue #83 is a user whose app never appeared in
it, asking to just point at an .exe. Two structural gaps sat behind that.
Launcher-installed games were missing or wrong. Epic shortcuts a game with a
.url carrying a com.epicgames.launcher:// URI, and the walk keeps only .url
files pointing at Steam, so Epic games were dropped outright — Among Us was
invisible on the machine this was written on. Battle.net and Ubisoft write
.lnk files targeting the launcher executable with a per-game argument, and
IShellLink::GetPath returns only the executable, so a whole Blizzard library
collapsed into one wrongly-named "Battle.net Launcher.exe" entry.
LauncherGames reads what each launcher records about itself instead: Epic's
.item manifests, GOG's and Ubisoft's registry keys, Battle.net's uninstall
entries. Those name an install directory rather than an executable, which is
the better identity anyway and gets a fourth game-id shape, "dir:". A game
folder holds its launcher, its anti-cheat service and the game itself, and
which of them reaches the foreground varies by title and by moment — the same
reasoning SteamAppLocator already documents for Steam, and MatchProfile
resolves these by longest path prefix for the same reason.
Nothing without an installer entry could be listed at all, which is the long
tail the issue is really about. The picker now offers the applications with a
window open right now, and a file browser, both producing an ordinary exe-path
id that needed no new plumbing. The running list is the more useful of the two:
it asks the user to know nothing about where a game was installed.
Also moves the enumeration off the UI thread. It measured 1.4s warm and 5s
cold before this change added five sources to it, and OpenRemapWindow was
calling it synchronously — the window did not appear until it finished.
Fixes#83
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.
Both copies of the number move together: resource.h feeds the VERSIONINFO
resource and the startup line in the event log, InnoInstallerScript.iss feeds
Programs and Features.
Two components rather than three, matching the release tags and the previous
bumps. 1.17.3 was a hotfix number; the patch field goes back to 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
A release carried no version anywhere a user or a maintainer could see it.
The exe had no VERSIONINFO, so its properties showed nothing, and events.log
opened with a bare "=== SteamlessController started ===". Twice while chasing
#79 a reporter's log arrived and there was no way to tell which of two builds
had written it, which cost a round trip each time.
resource.h now holds the version once, and both consumers read it from there:
app.rc builds a VERSIONINFO resource so Explorer and Programs and Features can
report it, and the startup line names it. Keeping them on one definition means
a binary and its log can never disagree about what they are.
InnoInstallerScript.iss still carries its own copy of the number. Inno can read
it back out of the built exe instead, which would leave one place to edit, but
that makes the installer refuse to compile before the app is built and is a
change to how releases are cut rather than to the app.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Game mode refused a shared handle outright whenever steam.exe was running,
on the reasoning that the holder was probably Steam and that refusing would
escalate to a device cycle that took the handle back. On machines where that
cycle never wins the reopen race, it took nothing back and the app simply
never worked: #79 counted 731 blocked claims in a single day against zero
successes, and both of that reporter's successes across the whole log came
through the shared path with Steam closed, never through an exclusive claim.
No exclusive claim is obtainable against a live write handle, however polite
its holder. Windows share arbitration runs in both directions: FILE_SHARE_READ
asks the kernel to deny write access to everyone else, and it must refuse
while such a handle exists. Measured on a puck receiver with Steam running,
even GENERIC_READ with FILE_SHARE_READ returns ERROR_SHARING_VIOLATION, so it
is our share mode being rejected rather than our access. "Steam only holds it
shared" and "we can take it exclusively" were never compatible statements.
Shared is now the last resort rather than a refusal. The claim still prefers
exclusivity and a blocked claim still escalates to a device cycle exactly as
before, so machines that win that race keep winning it. Only once cycling is
over — the budget spent, or a cycle shown to have invalidated no handles —
does the acquire path retry with sharing allowed, immediately before it would
otherwise report failure. Two callers that rebuild an already-running game
mode pass it too: a slot arriving late should join rather than be dropped,
and a platform change should not cost the user their controller.
Sharing is not free, which is why it is last. Steam keeps driving the same
controller, and its stock desktop layout binds both the left stick and the
D-pad to arrow keys. Measured with a dual-channel sampler: 16 D-pad presses
produced 16 matching global UP/DOWN_ARROW keystrokes 1-2ms later, press and
release both mirrored. Those are real SendInput keystrokes, so they reach
processes Steam cannot inject into — the doubling was first reported in a
Game Pass title. The tray reports the state rather than hiding it: a distinct
icon and a "(sharing with Steam)" tooltip, so a user chasing doubled input
knows where to look.
Slots are probed quickly before they are probed properly. A receiver
publishes an interface per slot and only one has a controller in it, but
every empty slot cost the full 250ms state-report timeout before the live one
was even reached — up to 750ms, spent before the first claim attempt. Steam
reclaims the device about 180ms after it re-arrives, so that ordering handed
it the race whenever re-enumeration put the live slot last. A 25ms pass now
runs first, since a live slot streams continuously and answers within a few
reports. Measured across a device cycle with the live slot arriving last:
arrival to exclusive claim fell from 1697ms to 99ms.
A quick probe that comes up short is not evidence of an empty slot, so it
neither records the slot as silent nor logs it; otherwise a controller slow
to resume streaming would be latched out of the full pass that follows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>