mirror of
https://github.com/ddeverill/SteamlessController.git
synced 2026-10-22 14:42:16 -05:00
v1.23
78
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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 — 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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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 |
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|