The shared-access fallback fixed a real problem — something in Windows
holds a compatible write handle on the live puck collection even with
Steam closed — but applied unconditionally it also swallowed the signal
the Steam handoff depends on.
A failed exclusive claim is precisely how the app detects that Steam is
holding the controller: game mode failing to activate is what escalates
TryAcquireController to the device cycle that takes the handle back. With
the fallback always succeeding, the app would instead report success and
drive the controller alongside Steam, both sending competing lizard-mode
feature reports, and would never cycle. None of the PR's validation would
catch it, since every check was run with Steam closed.
Gate it on whether steam.exe is running. TrayApp already watches that for
the auto modes, so it now pushes presence into ControllerManager from the
WM_STEAMSTATE handler — outside ApplySteamState, which returns early in
Manual mode, because this decision matters in every mode.
Also cheapen the state-report probe. It costs the full 250ms timeout on an
empty puck slot and runs per slot, while the acquire path retries in a
burst of eleven — three empty slots would have blocked the UI thread for
roughly eight seconds. Slots found silent are skipped for 1.5s, so the
burst probes each at most once. And WaitForStateReport now stops when a
read returns instantly rather than timing out, instead of spinning on a
dead handle for the rest of the window.
The skip message no longer says "puck", since the gate applies to every
transport.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
/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>
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>
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>
- 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>
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>
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>
A Valve firmware update changed the main STATE report ID from 0x42 to
0x45. REPORT_STATE is updated to match; the now-redundant REPORT_EXTENDED
constant (which previously held 0x45) is removed.
Also adds better probe diagnostics to Open() — distinguishes between an
unexpected report ID (firmware mismatch) and a read timeout (empty slot).
Fixes a stale debug flag in SteamProbe: FORCE_SHOW_BYTES_16_17 reverted
to false (right joystick Y, not X).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Steam Controller Puck exposes up to four FF00/0001 HID interfaces
(one per paired controller slot). Previously, Open() blindly picked the
first interface, which could be an empty slot — feature reports sent to
it were silently ignored, so lizard mode was never disabled.
Now Open() iterates all FF00/0001 interfaces for both the wired PID
(0x1302) and the dongle PID (0x1304), reads a single input report with
a 500 ms timeout, and latches onto the first interface that responds
with a live 0x42 STATE report.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Added EnumerateAllValveDevices() to SteamProbe which lists every HID
interface with VID=0x28DE at startup — printing PID, usage page, usage,
and product string. This makes it easy to identify the wireless dongle's
PID without needing Device Manager.
Updated HidDevice::Enumerate to treat pid=0 as a wildcard so callers
can enumerate all devices for a given vendor.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Start with Windows toggle in tray menu — writes/removes the Run registry
key so the app launches automatically at login
- Inno Setup installer script for packaging the release exe
- CMakePresets.json for named debug/release build configurations
- README updated with correct Visual Studio 2025 build commands and a
note for VS 2022 users
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>