mirror of
https://github.com/ddeverill/SteamlessController.git
synced 2026-10-23 07:02:16 -05:00
36b94872c1e9ebca23065b076bb034fee4775bfc
38
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
350161785d |
Report what is actually holding the controller instead of blaming Steam
When an exclusive claim failed, the app assumed Steam was refusing to let
go, burned three device cycles over ~26 seconds, and told the user to
close Steam. All of that could be wrong: any process with the device open
blocks the claim, and no amount of cycling evicts it.
Measured while a process held an open handle and never yielded:
- disabling the HID collection is vetoed (ERROR_GEN_FAILURE, ~6s)
- query-removing it is vetoed (PNP_VetoOutstandingOpen, ~5.4s)
- query-removing the USB parent is vetoed the same way, naming the
child as the blocker
Consent propagates up the tree, so no devnode teardown reachable from
user mode breaks such a handle. The cycle works against Steam only
because Steam cooperates: it yields on DBT_DEVICEQUERYREMOVE and reopens
on arrival, which is why the reopen is a race rather than a standoff.
Against an uncooperative holder there is no eviction path, only
detection — so detection is what this adds.
- CM_Query_And_Remove_SubTreeW reports why a teardown was refused, and
the verdict crosses to the tray app through a status file
- a cycle that broke no handles stops the retry loop immediately, since
repeating it cannot do more than the last one did
- the handle table is scanned to name the processes holding the device,
which is the only actionable output left when eviction is impossible
- messages say what was established, and suggest a replug: a physical
disconnect is a surprise removal that no veto can stop
The event log carries the unfiltered holder list and the scan's coverage,
not just the conclusion — an empty result can mean nothing was holding
the device, or that the scan never reached it, and that difference cannot
be recovered from a user's machine after the fact.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
42f4a23850 |
Allow back paddles to be bound to middle and thumb mouse buttons
Kind::MouseButton was reserved for these in the binding refactor but had no emission arm and no way to pick one. Wire both up: middle click, and the two thumb buttons that games label Mouse 4 and Mouse 5. They were previously unbindable by any route. Middle and the thumb buttons can be captured by pressing them, since none is needed to operate the remap window. Left and right deliberately cannot — the user has to keep clicking Rebind and Cancel while a row listens — so those stay pickable from the catalog instead, as before. Capture suppresses the browser defaults these carry: autoscroll on middle, and history navigation on the thumb buttons. The window's HTML had to be split into two concatenated literals. It crossed MSVC's ~16KB limit for a single string literal (C2026) at 17KB, which fails the build outright rather than truncating quietly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7b2391dd95 |
Repeat held key bindings at the system typematic rate
Holding a paddle bound to a letter produced exactly one character. The emission loop only acts on edges, so a held paddle sent one key-down and nothing further until release. That was the correct consequence of the design rather than a fault in the send path: auto-repeat is not something Windows synthesises for a key it believes is down. A physical keyboard repeats because its own firmware keeps sending make codes while the key is held, and Windows forwards each one. Injected input has no firmware behind it, so the repeat has to be generated here. Re-send the press while the paddle is held, after the user's configured delay and at their configured rate — SPI_GETKEYBOARDDELAY and SPI_GETKEYBOARDSPEED — so a paddle behaves like the key it is bound to rather than at a rate invented here. Both are captured at press time, so the rate cannot shift mid-hold. The read loop already ticks at report rate, so this is a per-frame elapsed check with no new thread or timer. Keys only. Mice do not repeat when held, so mouse-button bindings keep their one-shot behaviour, and gamepad bindings are held state in the pad report, where a repeat would mean nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b5b7e28cf1 |
Allow back paddles to be bound to keyboard keys
Press-to-bind only listened to the controller, so the remap window could offer gamepad buttons and mouse clicks but nothing from the keyboard. Capture a key press while a row is listening and bind that instead. Capture happens in the remap page's keydown handler rather than through a keyboard hook. The page is already focused while listening, and a low-level hook is a heavy, AV-bait mechanism for a convenience feature. The page reports the physical KeyboardEvent.code, which KeyInput turns into a virtual-key code, so a binding made on one layout means the same physical key on another. Escape stays the cancel affordance and is deliberately not bindable. Keys are delivered with SendInput as scan codes rather than virtual keys, because software that reads scan codes directly sees nothing useful otherwise. Note that injected input does not reach every game — anti-cheat and some input paths ignore it — so this is realistically a desktop, emulator, and browser feature. Gamepad bindings remain the answer in game. Extended keys are flagged from an explicit set. MapVirtualKey is documented to report the 0xE0 prefix under MAPVK_VK_TO_VSC_EX but does not do so for the navigation cluster, which shares scan codes with the numpad — a test caught Up arrow being delivered as numpad 8. Labels come from GetKeyNameText against the active layout, so a non-US keyboard shows its own key names, and are JSON-escaped on the way to the page: the backslash key really is named "\". Paddle release tracking now records what the press actually sent instead of re-reading the binding on release, so rebinding a paddle mid-hold releases the input that was pressed rather than stranding it down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0839f79f16 |
Represent paddle bindings as a tagged value, not a flat enum
Keyboard and extended mouse bindings can't be enumerated the way gamepad
buttons can — a key binding is a value drawn from ~256 virtual-key codes,
not a member of a closed set. BackButtonConfig held four bare
BackButtonAction values, so there was nowhere to put one.
Introduce BackButtonBinding, a kind/code pair covering built-in actions,
keyboard VK codes, and extended mouse buttons. No behaviour change: only
Kind::Action is produced or consumed today. This is the groundwork for
capturing keys and mouse buttons in the remap window.
Both formats extend without a migration:
- Registry: packed as (kind << 16) | code. Every value written by earlier
builds is a bare BackButtonAction of 0-18, so its kind bits are already
zero and it decodes as Kind::Action. Action bindings still pack to their
bare value, so a downgrade also reads settings written by this build.
- WebView2: built-in actions keep their bare ids ("A", "leftMouse"), and
the open-ended kinds are prefixed ("key:32", "mouse:x1"). Flat strings
keep the hand-rolled JSON reader in RemapWindow usable, and the JS input
catalog needs no changes.
Unrecognised values — corrupt, or written by a future build that knows
kinds this one does not — degrade to None rather than being applied.
Verified the invariants with a standalone test over the full value space:
legacy decoding, pack and id round-trips for every kind, rejection of
malformed input, and the shipped defaults.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
ba215b2429 |
Fix custom back button mappings being ignored in mouse click mode
"Back Buttons as Mouse Click" hijacked L4 and R4 unconditionally: the virtual controller skipped their bindings entirely, so a paddle mapped to RT silently did nothing while the option was on. Only the upper paddles were affected, so the symptom read as "two of my four paddles are dead" rather than pointing at the toggle. The same gate also suppressed the Left/Right Click bindings the remap window already offered, meaning the feature disabled the very capability it existed to provide. The toggle was a strict subset of the remap window, which already offers left and right click per paddle, so retire it rather than teach it to cooperate: - Remove the backMouseEnabled gates, the tray item, and the duplicate L4/R4 handling in TrackpadMouse. Paddle mouse output now flows through the single edge-detection path in ReadLoop. - Default L4 and R4 to left click so a fresh install still clicks out of the box. - Add an explicit Off option to the picker. None was already a real "do nothing", but Reset now yields left click, so without this there would be no way to disable a paddle at all. - Migrate BackButtons=1 into real bindings, filling in only paddles with no binding of their own so an explicit mapping is never overwritten, then delete the value so it runs once. TrackpadMouse::Reset used to release a paddle held while game mode was torn down. Removing its L4/R4 handling would have stranded the mouse button down, so that cleanup moves to ReleasePaddleMouseButtons, which also covers right click and runs on the enable, disable, and destructor paths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9ef3609da5 |
Fix orphaned ViGEm pads left behind by a failed target add (#52)
vigem_target_add() plugs the child device in with IOCTL_VIGEM_PLUGIN_TARGET, then waits for it to come up with IOCTL_VIGEM_WAIT_DEVICE_READY. When that wait fails, the cleanup path calls vigem_target_remove() -- but target->State is only set to VIGEM_TARGET_CONNECTED on the two success paths above it, so the removal trips its own "State != VIGEM_TARGET_CONNECTED" guard and returns without ever sending IOCTL_VIGEM_UNPLUG_TARGET. The device stays plugged into the bus with no owning process, holding an XInput slot until the machine reboots. Observed locally: a pad created at 19:57:25 outlived the app by five hours and several sessions, and every later session had to take the next serial. The rapid enable/disable churn during device cycling is what triggers the wait timeout in the first place. Mark the target connected before the cleanup so the unplug is actually sent, and pin the return to VIGEM_ERROR_TARGET_NOT_PLUGGED_IN -- a successful unplug would otherwise report VIGEM_ERROR_NONE and register a target in pTargetsList that is no longer plugged in. This is a local patch to the vendored b66d02d ViGEmClient snapshot; upstream is archived, so no future sync will bring it in. Also: - VirtualController records which ViGEm call failed and its error code; ControllerManager logs both. The old driverMissing line could not distinguish a missing bus from a failed target add, which is exactly the distinction needed to spot this leak. - ShowViGEmBalloon() now latches the way ShowElevationBalloon() already did. Every failed enable calls back into UpdateTrayIcon -- several times a second during an acquire-retry loop -- so an unavailable driver produced a wall of identical balloons. The latch clears once the driver is reachable again so a genuine mid-session disappearance still warns. UpdateTrayIcon no longer returns early on vigemMissing, so the icon and tooltip keep tracking the controller instead of freezing. - Reword that balloon: a disabled bus devnode reports the same VIGEM_ERROR_BUS_NOT_FOUND as an uninstalled one, and "is not installed" sends users looking in the wrong place. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
345bf15ca3 |
Fix puck activation after firmware updates (#44)
The receiver stopped working after a controller firmware update. Original diagnosis and fix by @danbosscher; extended here for Bluetooth, Steam handoff, and several defects found during QA on the hardware. The puck publishes a controller interface per slot plus a management endpoint, and on current firmware an empty slot rejects the lizard-mode command outright. - Enumerate only the controller collection (FF00:0001), excluding the puck's management endpoint at FF00:0002 - Wait for a live 0x42/0x45 state report before sending feature commands, and skip slots that never produce one - Prefer an exclusive claim but accept a shared handle when Windows keeps a compatible write handle open — only while Steam is absent, since a handle held with Steam running is probably Steam's, and driving the controller alongside it is worse than refusing. Refusing is what escalates to the device cycle that takes the handle back. - Add the Nereid receiver PID (0x1305) Defects found by QA and fixed here: - The battery payload is [chargeState, percent] on every transport, not just Bluetooth. A 92% battery was reported, and fed to the DS4 battery level, as 1%. - Game mode could not tell an idle receiver from a contested one, so a controller that was merely switched off triggered three device cycles, restarting the receiver's devnodes for nothing. - A receiver raises no device event when a slot becomes occupied, so waking a controller went unnoticed for as long as it was left. Poll for it instead. - Overlapping cycles undid successful claims: a cycle is asynchronous, and the arrivals it generated re-entered the acquire path and fired another on top of it, ripping the device back out a second after game mode came up. Cycles are now spaced. - Failure was announced while the last cycle was still landing — the alert fired one second before success. The verdict now waits out a grace period. Verified on the puck, Bluetooth, and wired: handoff to Steam on game launch, reclaim afterwards, and no duplicate input. Reclaiming takes around nine seconds on a four-slot receiver, dominated by SetupDiCallClassInstaller tearing down each interface in turn. Three attempts to shorten it (batching the cycle, a shorter settle, cycling only the contested interface) all regressed the reclaim and were reverted; it needs to be worked out against hardware rather than reasoned about. Co-Authored-By: Dan Bosscher <danbosscher@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
50a8ae246d |
Add Bluetooth LE support for the Steam Controller
The controller now works wired, through the dongle, or paired over Bluetooth, with the full feature set: game mode, rumble, trackpad haptics, battery, PlayStation-mode gyro, and taking the controller back from a running Steam. Windows exposes the entire vendor protocol over HID-over-GATT under PID 0x1303 — feature reports, output reports, and the 0x45 state report (54 bytes, ~157 Hz) all work through the existing HidDevice stack, so support is a thin layer rather than a new transport backend. - Add SC2026_BT_PID to enumeration and a per-slot Transport tag, detected from the interface path (HOGP GUID / bthenum markers win over PID tokens) and logged on CONNECT/DISCONNECT - Swap the battery report 0x43 payload bytes on Bluetooth slots — the firmware sends [chargeState, percent], reversed from USB/dongle - Bluetooth-specific disconnect alert copy, and a notification when acquisition gives up rather than a silently dead pad Device cycling had to be reworked for reclaiming the controller from Steam. DIF_PROPERTYCHANGE reports success while leaving the device and Steam's handle untouched; CM_Query_And_Remove_SubTree is vetoed by open handles; pnputil /restart-device works once, then leaves the devnode reboot-pending and refuses thereafter. Disable+enable works repeatedly. Two traps found against live hardware. SetupDiCallClassInstaller returned ERROR_INVALID_DATA for a disable Windows had genuinely performed — the app logged the device removal a tenth of a second before the call reported failure — so return codes cannot gate the re-enable, or the controller is stranded disabled and needs Device Manager to recover. CM_Get_DevNode_Status is now the source of truth: whether the node went down decides if a real cycle happened, the re-enable always runs and retries until the node confirms it is enabled, and only a stranded device counts as failure. Some devices also reject a global scope change, so DICS_FLAG_CONFIGSPECIFIC is tried as well. SteamProbe becomes a transport discovery tool: detailed enumeration with transport detection, interface selection, non-fatal lizard-off with a raw-dump fallback, an output-report test, report-rate measurement, and --enum / --imu modes. The windowless cycle helper gains its own cycle.log, since it cannot share the tray app's events.log (held open _SH_DENYWR) and was previously impossible to diagnose. Verified on hardware: game mode, rumble, haptics, battery, IMU streaming, clean removal on power-off/sleep/range loss with automatic reconnect, yielding to Steam on game launch, and reclaiming afterwards (~1.2s). Also confirmed the firmware enforces one host transport at a time, so no cross-transport deduplication is needed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5cd54be8d0 |
Add Steam-aware auto control modes, device-cycle helper, and diagnostics
Control modes (tray 'Control Mode' submenu, persisted): - Manual: tray toggle only (grayed labels now explain why when disabled) - Auto - Off while Steam running: yield the controller whenever steam.exe runs; reclaim ~6s after it exits (debounced against Steam self-restarts) - Auto - Off ONLY while in Steam game: yield only while RunningAppID != 0 SteamWatcher polls the Steam process and RunningAppID every 2s with asymmetric debounce: transitions toward more Steam activity are reported immediately (yield fast), transitions away must hold ~6s. Taking the device from a running Steam requires invalidating its HID write handle. A new elevated helper (SteamlessDeviceCycle.exe) restarts the controller device node (unplug/replug equivalent) on demand via a highest-privileges scheduled task, registered by the installer or a one-time UAC prompt. The tray app itself NEVER runs elevated: an elevated foreground window blocks unelevated SendInput via UIPI, which froze Steam Input's desktop cursor whenever the tray menu was open. The same cycle mechanism hands the device to an already-running Steam on game launch (Steam only opens controllers on arrival events). Crash safety: an unhandled-exception filter best-effort restores lizard mode without taking locks; the firmware's own revert timeout remains the final fallback. ClaimExclusive now restores shared access on failure instead of leaving the device handle closed. Diagnostics for field disconnect reports: - Event log at %LOCALAPPDATA%\SteamlessController\events.log (512KB rotation, read-shareable): connect/disconnect causes, game mode transitions with failure reasons, battery changes, stalls, keepalive failures, auto-mode decisions. 'Open Event Log' in the tray menu. - Silent-stall detection: no reports for 4s while enumerated (battery death, sleep, wireless drop) is now detected, logged, and alerted with the last battery reading. - Disconnect/stall alert balloons, suppressed for intentional handoffs, with a 'Disconnect Notifications' toggle for users with odd setups. Startup reverts to the plain HKCU Run key (the elevated-logon-task variant from an interim build is migrated back and cleaned up). Installer ships and registers the helper and unregisters it on uninstall; version 1.8. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
b80c7b830e |
Overhaul trackpad haptics: fix firmware lizard revert, click latch, tick gating
The core fix: the SC2026 firmware silently reverts to lizard mode (including its autonomous click haptics) after a period without host feature reports, which doubled up with software haptics as a crunchy burst. A keepalive now re-asserts the lizard-off state every 2 seconds while game mode is active. Haptics reliability and feel: - Switch trackpad haptics from OUT_HAPTIC_PULSE (grip LRAs) to OUT_HAPTIC_COMMAND (trackpad LRAs) for localized feel - Click haptic latch: press fires off the debounced firmware click bit, release only fires once contact area returns to idle, so force chatter and mid-hold easing cannot re-fire - Movement ticks: distance-based with motion deadzone, post-release and first-touch grace windows, pressing/teleport gates (pause, not reset), idle decay, and a 50ms send rate limit so firmware waveforms never queue into a backlog Handoff and mouse: - EnableLizardMode now also sends LOAD_DEFAULT_SETTINGS so firmware mouse emulation resumes immediately on disable instead of waiting for the firmware revert timeout - Trackpad mouse: carry sub-pixel movement remainders between frames and retune sensitivity (0.015 -> 0.01125) Also: shared/exclusive HID reopen support, local-only HapticTester tool (gitignored, conditional CMake target), installer version bump to 1.7. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ddb91176fe |
Add distance-based movement haptics and click release haptic for trackpad
Replaces the per-frame movement buzz with a scroll-wheel-style tick that fires once per 5000 raw units of travel. Adds a matching haptic on click release to mirror Steam's press/release feel. Reseeds the movement baseline on click release so stale position delta doesn't bleed into a spurious movement tick on the same frame. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
61ccd6548d |
Add DS4 gyro/touchpad forwarding, battery status, and enhanced haptics
PlayStation mode now emits DS4_REPORT_EX on every frame: - Raw gyro and accelerometer forwarded from SC IMU (enabled via SETTING_IMU_MODE when PS4 mode is active; disabled on teardown) - SC right/left trackpads mapped to DS4 touchpad contacts 1/2, with correct 12-bit XY packing and per-gesture contact IDs; pads claimed for mouse use are suppressed from the touchpad report - Battery level and charge state forwarded from REPORT_BATTERY_STATUS (0x43) reports received in the read loop - DS4 output moved from deprecated notification callback to a polling thread using vigem_target_ds4_await_output_report_timeout() Enhanced haptics applied to both Xbox and PlayStation modes: - Power curve (value^0.68) and cross-channel mixing (large motor bleeds into both haptic actuators; small motor weighted toward right) - Short attack boost on rising motor edges to replicate ERM punch - Trackpad touch and click fire one-shot haptic pulses via new OUT_HAPTIC_PULSE (0x81) and OUT_HAPTIC_COMMAND (0x82) output reports Additional fixes: - REPORT_SECONDARY = 0x43 renamed to REPORT_BATTERY_STATUS - ViGEMClient upgraded from 1.16.18.0 to b66d02d snapshot which exposes DS4_REPORT_EX, vigem_target_ds4_update_ex(), and vigem_target_ds4_await_output_report_timeout() Co-authored-by: david419kr <david419kr@users.noreply.github.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
a8a9e539ac |
Add Xbox / PlayStation controller platform selection
Introduces a ControllerPlatform enum (Xbox, PlayStation) and a full DualShock 4 translation path alongside the existing Xbox 360 one. Switching platforms live tears down and recreates the virtual device. The selection persists in the registry and is exposed via a "Controller Platform" submenu in the tray context menu. DS4 translation maps face buttons to Cross/Circle/Square/Triangle, encodes the D-pad as a HAT switch (with diagonal support), converts signed 16-bit axes to uint8 with Y-axis inversion, and routes right trackpad clicks to the DS4 touchpad button. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
0ce2b57a36 |
Merge main into PR branch; wire rumble into multi-slot architecture
Conflicts resolved: - ControllerManager.cpp: take main's slot-based EnableGameMode/ DisableGameMode dispatch; wire the rumble callback into EnableGameModeSlot and add SetRumble(0,0) to DisableGameModeSlot so rumble stops cleanly before vc is torn down. - VirtualController.h: keep both RumbleCallback/OnRumble (ours) and the new Update(backCfg, backMouseEnabled) signature (main's). Co-Authored-By: Dylan Deverill <ddeverill@gmail.com> |
||
|
|
f7c4f13c90 |
Address review feedback on rumble forwarding PR
- Drop Tick()/m_wantsGameMode auto-enable machinery (out of scope for this PR — belongs in a separate submission) - Remove unused CMD_TRIGGER_RUMBLE constant - Fix WriteOutputReport comment to accurately describe padding behaviour - Fix CancelIo OVERLAPPED use-after-free: drain with GetOverlappedResult before the stack OVERLAPPED goes out of scope - Strip redundant /wd4819 /wd4828 MSVC flags; /utf-8 already covers them - Add comment to HeartbeatLoop explaining the 40 ms tick rationale Co-Authored-By: Dylan Deverill <ddeverill@gmail.com> |
||
|
|
5ca94176b3 |
Add back button remapping UI with registry persistence
Introduces a WebView2-based remap window (double-click tray icon or 'Remap Back Buttons...' context menu) that lets users assign any of L4/L5/R4/R5 to a face button, bumper, trigger, d-pad direction, stick click, Menu/View, mouse button, or 'none' (natural behavior). - BackButtonConfig: new enum + struct; values stable as registry DWORDs - RemapWindow: chromeless WebView2 window with a button picker and press-to-detect capture mode; settings persist across launches - ControllerManager: StartButtonCapture/StopButtonCapture API with rising-edge HID detection for all mappable targets - VirtualController: ApplyBackAction injects the mapped XInput button each frame; mouse-button actions are edge-detected in the read loop - TrackpadMouse: back-button mouse-click handling decoupled from remap - TrayApp: wires up the remap window, saves/loads all back button settings to HKCU\Software\SteamlessController Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
9838bed701 |
Add support for multiple simultaneous Steam Controllers
Each connected controller now gets its own virtual Xbox 360 device via ViGEmBus, mapping to separate XInput player slots (1, 2, 3...). Key changes: - SteamController: add EnumerateAll() to probe and return all live device paths across wired (0x1302) and dongle (0x1304) PIDs; add Open(path) overload to open a specific already-validated path. - ControllerManager: replace single-controller state with a vector of Slot structs, each owning its own SteamController, VirtualController, TrackpadMouse, and read thread. SyncDevices() diffs live paths against current slots on every device-change event, adding new controllers and removing disconnected ones. Controllers hot-plugged while game mode is active are automatically brought into game mode. - Bump installer version to 1.3. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
bc3e9932b1 | Auto-enable Steamless mode after startup reconnect | ||
|
|
a660f671b4 | Add Steam Controller rumble forwarding | ||
|
|
589ed11e4a | Initial release |