Commit Graph
59 Commits
Author SHA1 Message Date
Dylan DeverillandClaude Opus 5 a087c236b2 Add per-game control profiles
Everything about how the controller behaves — paddle bindings, both
trackpads, and which kind of pad the game sees — is now configured in one
window and saved per game, rather than spread across tray checkboxes that
applied everywhere at once.

Installed games come from the Start Menu and the packaged-app list. Steam
does not shortcut its library with .lnk files, so its games are read from
the .url shortcuts it does write; Xbox and Store titles register no shortcut
file at all and come from the shell's app list instead. None of these
sources can tell a game from any other program, so the picker searches
rather than scrolls — a saved profile is pinned, everything else is reached
by typing.

Trackpads are configured per pad. The previous pair of toggles could only
ever describe one pad doing one thing, because a single TrackpadMouse
instance was switched between pads by a bool; there are two now. A pad can
move the pointer, scroll, feed the DS4 touchpad, or do nothing, and its
click is an ordinary binding dispatched down the same path as the paddles —
it used to be hardwired to a left click that only fired while that pad drove
the mouse.

Key bindings can carry Ctrl, Alt, Shift and Win. The modifiers ride in the
free top byte of the packed binding, so values written by earlier builds
decode unchanged. Windows is chosen from a toggle rather than pressed: the
Start menu opens before the page is told anything, which is what made that
key unbindable before. Choosing modifiers rather than typing them also keeps
combinations like Alt+Tab from being claimed by the system mid-capture.
Ctrl+Alt+Delete is refused outright, being the Secure Attention Sequence
that no ordinary process can send.

Profiles follow the foreground application, and nothing polls to do it. An
unelevated process cannot subscribe to process-start events — the WMI class
that reports them requires administrator rights, as does an ETW kernel
session — but SetWinEventHook reports foreground changes for the cost of one
hook and no thread. Focus is also the better question than "is it running":
a launcher that starts a game and stays resident answers yes long after the
game has exited. Steam profiles are keyed by app id, which a foreground
window cannot supply, so Steam's manifests are read to map that id to an
install directory; a game's launcher, its anti-cheat service and the game
itself all live under it and all resolve to the same profile.

Auto Mode still decides whether the controller is ours at all, so a profile
only loads while we hold the pad. A balloon names the profile when one
loads, under the notification setting, because a profile that quietly fails
to match is otherwise invisible.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v1.14
2026-08-11 22:56:43 -07:00
Dylan DeverillandClaude Opus 5 bda3a24756 Stop the app from cycling the controller to escape its own handle
Reported against 1.13 (#65): the puck's vendor collections sat at problem
22 after the app cycled them, and did so again within seconds of every
boot because the acquire runs at every startup. The holder scan in the
user's log names the blocker plainly:

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

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

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

Two further defects the report exposed:

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:56:43 -07:00
Dylan DeverillandClaude Opus 5 197a7b0f58 Bump installer version to 1.14
Covers the disabled-devnode work since 1.13. A device cycle switches the
devnode off and back on about a second later, and the disable is global —
it lands in the registry at once and outlives the process, a replug, and a
reboot. Killing the app inside that second left the controller switched
off with nothing running to put it back, recoverable only through Device
Manager. The intent to disable is now recorded before the disable and
cleared only once the devnode confirms it is back, so an unfinished cycle
is undone on the next run.

A devnode disabled by something else — Device Manager, regedit, pnputil, a
tool that hides HID devices — is no longer indistinguishable from an empty
USB port. It is named at startup and can be switched back on from the tray
rather than silently, since it may well be deliberate.

Also adds a heartbeat to the event log. Idle and stopped previously looked
identical there, which is what made the fault behind this release hard to
read after the fact.

Still the only place carrying the app version, and nothing in the app
compares version strings.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 00:14:55 -07:00
Dylan DeverillandClaude Opus 5 a46b169f29 Bump installer version to 1.13
Covers the input-blocking work since 1.12: the app now detects when
Windows is discarding what it injects — an elevated window in the
foreground blocks it, and haptics keep working throughout, so the
controller looks alive while the trackpad and buttons do nothing — names
the application responsible in a tray balloon, and records enough in the
event log to tell that apart from a cursor clip, a filtering hook, or a
state report too short for the trackpad path.

1.12 was cut from a working-tree edit that was never committed, so it has
no commit of its own and the version history steps 1.11 to 1.13. The
released 1.12 build carried the same code as 1.11.

Still the only place carrying the app version, and nothing in the app
compares version strings.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:49:06 -07:00
Dylan DeverillandClaude Opus 5 e1e252619a Bump installer version to 1.11
Covers the two fixes since 1.10: trackpad haptics were playing on the
opposite pad, and a failed controller handoff now reports which process
is actually holding the device rather than assuming Steam is.

Still the only place carrying the app version, and nothing in the app
compares version strings.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v1.11
2026-08-05 21:07:26 -07:00
Dylan DeverillandClaude Opus 5 cb6b1f3cc6 Fix trackpad haptics playing on the opposite pad
The side byte on the haptic output reports was inverted: 1 is the right
pad and 2 is the left, not the reverse. Both the click pulse and the
movement tick went out through the same helper, so both landed on the
wrong actuator.

This survived since the haptics went in because it is imperceptible with
one thumb down — an LRA firing in the opposite grip still reads as "the
controller ticked" and gets attributed to the finger that is moving. It
takes a second contact point to localise. The values are now named
constants carrying the confirmed mapping so an inversion cannot slip
back in silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:07:12 -07:00
Dylan DeverillandClaude Opus 5 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>
2026-08-05 19:04:03 -07:00
Dylan DeverillandClaude Opus 5 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>
2026-08-05 18:55:24 -07:00
Dylan DeverillandClaude Opus 5 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>
2026-08-05 18:55:24 -07:00
Dylan DeverillandClaude Opus 5 0839f79f16 Represent paddle bindings as a tagged value, not a flat enum
Keyboard and extended mouse bindings can't be enumerated the way gamepad
buttons can — a key binding is a value drawn from ~256 virtual-key codes,
not a member of a closed set. BackButtonConfig held four bare
BackButtonAction values, so there was nowhere to put one.

Introduce BackButtonBinding, a kind/code pair covering built-in actions,
keyboard VK codes, and extended mouse buttons. No behaviour change: only
Kind::Action is produced or consumed today. This is the groundwork for
capturing keys and mouse buttons in the remap window.

Both formats extend without a migration:

- Registry: packed as (kind << 16) | code. Every value written by earlier
  builds is a bare BackButtonAction of 0-18, so its kind bits are already
  zero and it decodes as Kind::Action. Action bindings still pack to their
  bare value, so a downgrade also reads settings written by this build.
- WebView2: built-in actions keep their bare ids ("A", "leftMouse"), and
  the open-ended kinds are prefixed ("key:32", "mouse:x1"). Flat strings
  keep the hand-rolled JSON reader in RemapWindow usable, and the JS input
  catalog needs no changes.

Unrecognised values — corrupt, or written by a future build that knows
kinds this one does not — degrade to None rather than being applied.

Verified the invariants with a standalone test over the full value space:
legacy decoding, pack and id round-trips for every kind, rejection of
malformed input, and the shipped defaults.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:55:24 -07:00
Dylan DeverillandClaude Opus 5 ba215b2429 Fix custom back button mappings being ignored in mouse click mode
"Back Buttons as Mouse Click" hijacked L4 and R4 unconditionally: the
virtual controller skipped their bindings entirely, so a paddle mapped to
RT silently did nothing while the option was on. Only the upper paddles
were affected, so the symptom read as "two of my four paddles are dead"
rather than pointing at the toggle. The same gate also suppressed the
Left/Right Click bindings the remap window already offered, meaning the
feature disabled the very capability it existed to provide.

The toggle was a strict subset of the remap window, which already offers
left and right click per paddle, so retire it rather than teach it to
cooperate:

- Remove the backMouseEnabled gates, the tray item, and the duplicate
  L4/R4 handling in TrackpadMouse. Paddle mouse output now flows through
  the single edge-detection path in ReadLoop.
- Default L4 and R4 to left click so a fresh install still clicks out of
  the box.
- Add an explicit Off option to the picker. None was already a real
  "do nothing", but Reset now yields left click, so without this there
  would be no way to disable a paddle at all.
- Migrate BackButtons=1 into real bindings, filling in only paddles with
  no binding of their own so an explicit mapping is never overwritten,
  then delete the value so it runs once.

TrackpadMouse::Reset used to release a paddle held while game mode was
torn down. Removing its L4/R4 handling would have stranded the mouse
button down, so that cleanup moves to ReleasePaddleMouseButtons, which
also covers right click and runs on the enable, disable, and destructor
paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:55:24 -07:00
Dylan DeverillandClaude Opus 5 0833d51868 Bump installer version to 1.10 (#54)
Covers the two fixes merged since 1.9: manual mode can now take the
controller back from a running Steam (#53), and a failed ViGEm target add
no longer orphans a pad that holds an XInput slot until reboot (#52).

The installer script is still the only place carrying the app version, and
nothing in the app compares version strings -- AppVersion is a display and
registry value only, with no VersionInfoVersion field derived from it. So
1.10 sorting below 1.9 lexically has no effect beyond how it reads in
Programs and Features.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
V1.10
2026-08-05 01:17:56 -07:00
Dylan DeverillandClaude Opus 5 9ef3609da5 Fix orphaned ViGEm pads left behind by a failed target add (#52)
vigem_target_add() plugs the child device in with IOCTL_VIGEM_PLUGIN_TARGET,
then waits for it to come up with IOCTL_VIGEM_WAIT_DEVICE_READY. When that
wait fails, the cleanup path calls vigem_target_remove() -- but target->State
is only set to VIGEM_TARGET_CONNECTED on the two success paths above it, so
the removal trips its own "State != VIGEM_TARGET_CONNECTED" guard and returns
without ever sending IOCTL_VIGEM_UNPLUG_TARGET. The device stays plugged into
the bus with no owning process, holding an XInput slot until the machine
reboots.

Observed locally: a pad created at 19:57:25 outlived the app by five hours and
several sessions, and every later session had to take the next serial. The
rapid enable/disable churn during device cycling is what triggers the wait
timeout in the first place.

Mark the target connected before the cleanup so the unplug is actually sent,
and pin the return to VIGEM_ERROR_TARGET_NOT_PLUGGED_IN -- a successful unplug
would otherwise report VIGEM_ERROR_NONE and register a target in pTargetsList
that is no longer plugged in. This is a local patch to the vendored b66d02d
ViGEmClient snapshot; upstream is archived, so no future sync will bring it in.

Also:

- VirtualController records which ViGEm call failed and its error code;
  ControllerManager logs both. The old driverMissing line could not
  distinguish a missing bus from a failed target add, which is exactly the
  distinction needed to spot this leak.

- ShowViGEmBalloon() now latches the way ShowElevationBalloon() already did.
  Every failed enable calls back into UpdateTrayIcon -- several times a second
  during an acquire-retry loop -- so an unavailable driver produced a wall of
  identical balloons. The latch clears once the driver is reachable again so a
  genuine mid-session disappearance still warns. UpdateTrayIcon no longer
  returns early on vigemMissing, so the icon and tooltip keep tracking the
  controller instead of freezing.

- Reword that balloon: a disabled bus devnode reports the same
  VIGEM_ERROR_BUS_NOT_FOUND as an uninstalled one, and "is not installed"
  sends users looking in the wrong place.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 01:15:29 -07:00
Dylan DeverillandClaude Opus 5 94f1bf94dd Share one acquire/release path across manual and auto modes (#53)
Manual mode could not take the controller back from a running Steam. The
tray toggle called ControllerManager::EnableGameMode() once and discarded
the result, so a Blocked outcome -- Steam holding the exclusive write
handle -- was simply dropped. The auto modes wrapped the same call in
TryAcquireController(), which escalates a blocked claim to a device cycle
via the elevated helper and retries on re-arrival. Only closing Steam
freed the pad in manual mode.

The two paths diverged because acquiring is asynchronous: a blocked claim
fires a cycle through Task Scheduler and comes back seconds later as a
WM_DEVICECHANGE, so the retry loop lives in timers and message handlers
rather than on the stack. Each of those re-entry points has to re-answer
"do we still want control?", and the code answered it by recomputing
m_autoMode != Manual && WantControl(steamState) -- deriving intent from
Steam. That works in the auto modes, where Steam's state is the intent.
Manual mode had nothing to derive from: its intent lived in a menu click
that was never recorded anywhere. WantControl() said as much, returning
false for Manual with the comment "tray toggle decides" -- and the tray
toggle's decision went unstored.

Store it. m_wantControl replaces the derived guard at all three re-entry
points (the IDT_ACQUIRE and IDT_WAKE_POLL timers, and the arrival branch
of WM_DEVICECHANGE), leaving WantControl() with a single caller in
ApplySteamState. The toggle sets it and calls the shared path, so manual
mode gets the retry burst, device cycling, wake polling, and the failure
balloon it never had.

Release is unified the same way: ReleaseControl() lifts the yield branch
out of ApplySteamState, substituting m_steamWatcher.GetState() for the
SteamState parameter the manual caller has no access to. Manual toggle-off
now also cycles the devnode, so a running Steam sees an arrival event and
re-adopts the pad instead of leaving it unclaimed.

Three follow-on details:

- Manual toggle-on calls EnsureCycleTaskRegistered(), matching what
  IDM_MODE_GAME already does, so the one-time UAC prompt lands on a menu
  click rather than ambushing the first cycle.

- SetAutoMode(Manual) adopts IsGameModeActive() rather than inheriting a
  stale m_wantControl from the auto mode being left.

- The toggle's menu label tracks m_wantControl in manual mode, not the
  live handle state. Acquisition can spend several seconds cycling, and a
  second click during that window should cancel rather than start over.

Both AUTO: log lines in the acquire path are now reachable from manual
mode, so they are relabelled ACQUIRE: -- these are exactly the lines read
when diagnosing a failed handoff, and the old prefix pointed away from it.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 01:03:26 -07:00
Dylan DeverillandClaude Fable 5 f2da1ce31e Bump installer version to 1.9 (#50)
Matches the README, which advertises Bluetooth support as new in 1.9.
The installer script is the only place carrying the app version.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
v1.9
2026-08-03 23:39:20 -07:00
345bf15ca3 Fix puck activation after firmware updates (#44)
The receiver stopped working after a controller firmware update. Original
diagnosis and fix by @danbosscher; extended here for Bluetooth, Steam
handoff, and several defects found during QA on the hardware.

The puck publishes a controller interface per slot plus a management
endpoint, and on current firmware an empty slot rejects the lizard-mode
command outright.

- Enumerate only the controller collection (FF00:0001), excluding the
  puck's management endpoint at FF00:0002
- Wait for a live 0x42/0x45 state report before sending feature commands,
  and skip slots that never produce one
- Prefer an exclusive claim but accept a shared handle when Windows keeps
  a compatible write handle open — only while Steam is absent, since a
  handle held with Steam running is probably Steam's, and driving the
  controller alongside it is worse than refusing. Refusing is what
  escalates to the device cycle that takes the handle back.
- Add the Nereid receiver PID (0x1305)

Defects found by QA and fixed here:

- The battery payload is [chargeState, percent] on every transport, not
  just Bluetooth. A 92% battery was reported, and fed to the DS4 battery
  level, as 1%.
- Game mode could not tell an idle receiver from a contested one, so a
  controller that was merely switched off triggered three device cycles,
  restarting the receiver's devnodes for nothing.
- A receiver raises no device event when a slot becomes occupied, so
  waking a controller went unnoticed for as long as it was left. Poll for
  it instead.
- Overlapping cycles undid successful claims: a cycle is asynchronous, and
  the arrivals it generated re-entered the acquire path and fired another
  on top of it, ripping the device back out a second after game mode came
  up. Cycles are now spaced.
- Failure was announced while the last cycle was still landing — the alert
  fired one second before success. The verdict now waits out a grace
  period.

Verified on the puck, Bluetooth, and wired: handoff to Steam on game
launch, reclaim afterwards, and no duplicate input.

Reclaiming takes around nine seconds on a four-slot receiver, dominated by
SetupDiCallClassInstaller tearing down each interface in turn. Three
attempts to shorten it (batching the cycle, a shorter settle, cycling only
the contested interface) all regressed the reclaim and were reverted; it
needs to be worked out against hardware rather than reasoned about.

Co-Authored-By: Dan Bosscher <danbosscher@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 23:34:08 -07:00
Dylan DeverillandClaude Fable 5 719f76a911 Scale the remap window for DPI and make it resizable (#47)
main.cpp declares PER_MONITOR_AWARE_V2, so Windows scales nothing for us,
but the remap window was created with its 760x668 design size as raw
physical pixels. WebView2 correctly renders its CSS at the monitor's scale
inside that frame, so at 200% the content needed 1520x1336 in a 760x668
window — the reported "super small" window with cramped content.

WS_THICKFRAME was also simply absent, so there was no sizing border at all.
The existing WM_NCHITTEST handler already had a comment about letting
Windows handle "the resize border", so that code was written expecting a
resizable window that never was one.

- Size from GetDpiForWindow, clamped to the monitor work area: 760x668 at
  200% is taller than a 1080p screen
- Centre on the monitor under the cursor rather than the primary one, which
  also picks up the right scaling on a mixed-DPI desktop
- WS_THICKFRAME to resize. Its border is in the non-client area, outside the
  rect WebView2 occupies, so it stays grabbable even though the web content
  covers the whole client area
- WM_GETMINMAXINFO for a scaled minimum, WM_DPICHANGED to follow the window
  between monitors at the physical size the user set

Window class background changed from white to the page colour: resizing
repaints the frame before WebView2 catches up, and white flashed against
the dark UI once dragging borders became possible.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 18:47:35 -07:00
Dylan DeverillandClaude Fable 5 7feb53f3ea Validate the cycle task points at the current helper
EnsureCycleTaskRegistered only checked that the scheduled task existed,
never that it pointed at the current helper. A task left behind by an older
or relocated install still runs and schtasks still reports success, but it
executes a helper that may be gone or predate current device support — so
the cycle does nothing, the controller is never reclaimed from Steam, and
nothing anywhere reports a failure.

Not theoretical: an uninstall left a task pointing into Program Files while
the app ran from a build folder, and every cycle the app ordered was a
silent no-op. From the app's side a successful schtasks /Run is
indistinguishable from a working cycle, which made it expensive to find.

Query the task as XML and compare its <Command> against our own helper path,
re-registering on mismatch and logging that it happened. The XML declares
encoding="UTF-16" while redirected output is single-byte, so it is read
narrow; the path is compared against both codepage renderings rather than
guessing which the console used.

Also register at startup when the loaded mode is OffOnlyInGame, instead of
waiting for a cycle to discover the problem. Without it a missing or stale
task is only noticed during a cycle — which happens exactly when a game
launches — so the UAC prompt ambushed the user mid-launch. A no-op when the
task is present and correct, so installed users see no change. Installer
users never hit any of this: setup runs --register under the elevation it
already has.

Verified by relocating the app: launching from the registered location is
silent, launching from a copy logs the stale task and re-registers to the
new path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 18:36:27 -07:00
Dylan DeverillandClaude Fable 5 50a8ae246d Add Bluetooth LE support for the Steam Controller
The controller now works wired, through the dongle, or paired over
Bluetooth, with the full feature set: game mode, rumble, trackpad haptics,
battery, PlayStation-mode gyro, and taking the controller back from a
running Steam.

Windows exposes the entire vendor protocol over HID-over-GATT under PID
0x1303 — feature reports, output reports, and the 0x45 state report (54
bytes, ~157 Hz) all work through the existing HidDevice stack, so support
is a thin layer rather than a new transport backend.

- Add SC2026_BT_PID to enumeration and a per-slot Transport tag, detected
  from the interface path (HOGP GUID / bthenum markers win over PID
  tokens) and logged on CONNECT/DISCONNECT
- Swap the battery report 0x43 payload bytes on Bluetooth slots — the
  firmware sends [chargeState, percent], reversed from USB/dongle
- Bluetooth-specific disconnect alert copy, and a notification when
  acquisition gives up rather than a silently dead pad

Device cycling had to be reworked for reclaiming the controller from
Steam. DIF_PROPERTYCHANGE reports success while leaving the device and
Steam's handle untouched; CM_Query_And_Remove_SubTree is vetoed by open
handles; pnputil /restart-device works once, then leaves the devnode
reboot-pending and refuses thereafter. Disable+enable works repeatedly.

Two traps found against live hardware. SetupDiCallClassInstaller returned
ERROR_INVALID_DATA for a disable Windows had genuinely performed — the app
logged the device removal a tenth of a second before the call reported
failure — so return codes cannot gate the re-enable, or the controller is
stranded disabled and needs Device Manager to recover. CM_Get_DevNode_Status
is now the source of truth: whether the node went down decides if a real
cycle happened, the re-enable always runs and retries until the node
confirms it is enabled, and only a stranded device counts as failure. Some
devices also reject a global scope change, so DICS_FLAG_CONFIGSPECIFIC is
tried as well.

SteamProbe becomes a transport discovery tool: detailed enumeration with
transport detection, interface selection, non-fatal lizard-off with a
raw-dump fallback, an output-report test, report-rate measurement, and
--enum / --imu modes. The windowless cycle helper gains its own cycle.log,
since it cannot share the tray app's events.log (held open _SH_DENYWR) and
was previously impossible to diagnose.

Verified on hardware: game mode, rumble, haptics, battery, IMU streaming,
clean removal on power-off/sleep/range loss with automatic reconnect,
yielding to Steam on game launch, and reclaiming afterwards (~1.2s). Also
confirmed the firmware enforces one host transport at a time, so no
cross-transport deduplication is needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 17:41:43 -07:00
Dylan Deverill 5c3f4aacf0 Update README.md 2026-07-07 14:51:10 -07:00
Dylan Deverill 66292bc100 Merge pull request #36 from ddeverill/feat/steam-aware-auto-control
Add Steam-aware auto control modes, device-cycle helper, and diagnostics
v1.8
2026-07-07 14:33:00 -07:00
Dylan DeverillandClaude Fable 5 5cd54be8d0 Add Steam-aware auto control modes, device-cycle helper, and diagnostics
Control modes (tray 'Control Mode' submenu, persisted):
- Manual: tray toggle only (grayed labels now explain why when disabled)
- Auto - Off while Steam running: yield the controller whenever steam.exe
  runs; reclaim ~6s after it exits (debounced against Steam self-restarts)
- Auto - Off ONLY while in Steam game: yield only while RunningAppID != 0

SteamWatcher polls the Steam process and RunningAppID every 2s with
asymmetric debounce: transitions toward more Steam activity are reported
immediately (yield fast), transitions away must hold ~6s.

Taking the device from a running Steam requires invalidating its HID write
handle. A new elevated helper (SteamlessDeviceCycle.exe) restarts the
controller device node (unplug/replug equivalent) on demand via a
highest-privileges scheduled task, registered by the installer or a one-time
UAC prompt. The tray app itself NEVER runs elevated: an elevated foreground
window blocks unelevated SendInput via UIPI, which froze Steam Input's
desktop cursor whenever the tray menu was open. The same cycle mechanism
hands the device to an already-running Steam on game launch (Steam only
opens controllers on arrival events).

Crash safety: an unhandled-exception filter best-effort restores lizard
mode without taking locks; the firmware's own revert timeout remains the
final fallback. ClaimExclusive now restores shared access on failure
instead of leaving the device handle closed.

Diagnostics for field disconnect reports:
- Event log at %LOCALAPPDATA%\SteamlessController\events.log (512KB
  rotation, read-shareable): connect/disconnect causes, game mode
  transitions with failure reasons, battery changes, stalls, keepalive
  failures, auto-mode decisions. 'Open Event Log' in the tray menu.
- Silent-stall detection: no reports for 4s while enumerated (battery
  death, sleep, wireless drop) is now detected, logged, and alerted with
  the last battery reading.
- Disconnect/stall alert balloons, suppressed for intentional handoffs,
  with a 'Disconnect Notifications' toggle for users with odd setups.

Startup reverts to the plain HKCU Run key (the elevated-logon-task variant
from an interim build is migrated back and cleaned up). Installer ships and
registers the helper and unregisters it on uninstall; version 1.8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:32:14 -07:00
Dylan Deverill 224e6d4cd7 Merge pull request #34 from ddeverill/feat/trackpad-haptics-keepalive
Overhaul trackpad haptics: fix firmware lizard revert, click latch, tick gating
v1.7
2026-07-06 12:51:36 -07:00
Dylan DeverillandClaude Fable 5 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>
2026-07-06 12:50:38 -07:00
Dylan Deverill 57f8844e7b Update README.md 2026-06-22 00:10:37 -07:00
Dylan DeverillandClaude Sonnet 4.6 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>
v1.6
2026-06-22 00:02:08 -07:00
Dylan Deverilland@david419kr 0b5eecc698 Merge pull request #26 from ddeverill/feat/ds4-motion-haptics
Add DS4 gyro/touchpad forwarding, battery status, and enhanced haptics

Co-authored-by: @david419kr
2026-06-21 23:22:23 -07:00
Dylan DeverillandClaude Sonnet 4.6 9f5a4bde6a Check vigem_target_ds4_update_ex return value; remove debug logging
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 23:18:26 -07:00
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>
2026-06-21 21:54:22 -07:00
Dylan Deverill 69311f1e83 Merge pull request #25 from danbosscher/fix-vigembus-installer-args
Fix ViGEmBus silent installer arguments
2026-06-21 21:21:23 -07:00
Dan Bosscher dc6b5d7db8 Fix ViGEmBus silent installer arguments 2026-06-21 12:53:53 +01:00
Dylan DeverillandClaude Sonnet 4.6 fbef4118e5 Bump installer version to 1.5
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 01:25:41 -07:00
Dylan DeverillandClaude Sonnet 4.6 a8a9e539ac Add Xbox / PlayStation controller platform selection
Introduces a ControllerPlatform enum (Xbox, PlayStation) and a full
DualShock 4 translation path alongside the existing Xbox 360 one.
Switching platforms live tears down and recreates the virtual device.
The selection persists in the registry and is exposed via a
"Controller Platform" submenu in the tray context menu.

DS4 translation maps face buttons to Cross/Circle/Square/Triangle,
encodes the D-pad as a HAT switch (with diagonal support), converts
signed 16-bit axes to uint8 with Y-axis inversion, and routes right
trackpad clicks to the DS4 touchpad button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 01:22:28 -07:00
Dylan Deverill 9599372101 Update README.md
Updated with new features outlined
2026-06-20 23:39:06 -07:00
Dylan Deverill 02d16590b9 Add Steam Controller rumble forwarding (#19)
Add Steam Controller rumble forwarding
v1.5
2026-06-20 23:24:05 -07:00
Dylan Deverill dc2aed188e Restore /wd4828 on SteamlessController target
/utf-8 enforces UTF-8 source encoding, which makes C4828 fire on
ViGEmClient.h (non-UTF-8 bytes in its comments). Only SteamlessController
includes ViGEm headers (via VirtualController.cpp), so /wd4828 is needed
there specifically. Other targets remain clean.

Co-Authored-By: Dylan Deverill <ddeverill@gmail.com>
2026-06-20 23:16:17 -07:00
Dylan Deverill 0ce2b57a36 Merge main into PR branch; wire rumble into multi-slot architecture
Conflicts resolved:
- ControllerManager.cpp: take main's slot-based EnableGameMode/
  DisableGameMode dispatch; wire the rumble callback into
  EnableGameModeSlot and add SetRumble(0,0) to DisableGameModeSlot
  so rumble stops cleanly before vc is torn down.
- VirtualController.h: keep both RumbleCallback/OnRumble (ours) and
  the new Update(backCfg, backMouseEnabled) signature (main's).

Co-Authored-By: Dylan Deverill <ddeverill@gmail.com>
2026-06-20 23:13:24 -07:00
Dylan Deverill 37cc98b05c Fix CancelIo OVERLAPPED drain bug in ReadInputReport
Same latent use-after-free as WriteOutputReport: after CancelIo, the
kernel can still complete the IRP and write into the stack OVERLAPPED
after the function returns. Block with GetOverlappedResult(..., TRUE)
to drain before ov goes out of scope.

Co-Authored-By: Dylan Deverill <ddeverill@gmail.com>
2026-06-20 23:08:46 -07:00
Dylan Deverill f7c4f13c90 Address review feedback on rumble forwarding PR
- Drop Tick()/m_wantsGameMode auto-enable machinery (out of scope for
  this PR — belongs in a separate submission)
- Remove unused CMD_TRIGGER_RUMBLE constant
- Fix WriteOutputReport comment to accurately describe padding behaviour
- Fix CancelIo OVERLAPPED use-after-free: drain with GetOverlappedResult
  before the stack OVERLAPPED goes out of scope
- Strip redundant /wd4819 /wd4828 MSVC flags; /utf-8 already covers them
- Add comment to HeartbeatLoop explaining the 40 ms tick rationale

Co-Authored-By: Dylan Deverill <ddeverill@gmail.com>
2026-06-20 23:04:38 -07:00
Dylan DeverillandClaude Sonnet 4.6 5ca94176b3 Add back button remapping UI with registry persistence
Introduces a WebView2-based remap window (double-click tray icon or
'Remap Back Buttons...' context menu) that lets users assign any of
L4/L5/R4/R5 to a face button, bumper, trigger, d-pad direction, stick
click, Menu/View, mouse button, or 'none' (natural behavior).

- BackButtonConfig: new enum + struct; values stable as registry DWORDs
- RemapWindow: chromeless WebView2 window with a button picker and
  press-to-detect capture mode; settings persist across launches
- ControllerManager: StartButtonCapture/StopButtonCapture API with
  rising-edge HID detection for all mappable targets
- VirtualController: ApplyBackAction injects the mapped XInput button
  each frame; mouse-button actions are edge-detected in the read loop
- TrackpadMouse: back-button mouse-click handling decoupled from remap
- TrayApp: wires up the remap window, saves/loads all back button
  settings to HKCU\Software\SteamlessController

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
v1.4
2026-06-20 22:40:13 -07:00
Dylan DeverillandClaude Sonnet 4.6 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>
v1.3
2026-06-19 20:46:48 -07:00
ntspaceowl e5416d53a5 Refresh tray icon after startup state sync 2026-05-30 22:07:46 +09:00
ntspaceowl bc3e9932b1 Auto-enable Steamless mode after startup reconnect 2026-05-30 22:00:00 +09:00
ntspaceowl a660f671b4 Add Steam Controller rumble forwarding 2026-05-28 00:22:50 +09:00
Dylan Deverill 50ff3cde40 Merge pull request #17 from ddeverill/fix/firmware-report-id-update
Fix firmware compatibility: REPORT_STATE 0x42 to 0x45
v1.2
2026-05-25 08:41:12 -07:00