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>
This commit is contained in:
Dylan DeverillandClaude Opus 5 authored and GitHub committed 2026-08-05 01:03:26 -07:00
1 parent f2da1ce31e
commit 94f1bf94dd
2 files changed
+66 -30

No files matched your search

+58 -29
View File
@@ -167,10 +167,22 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
case WM_COMMAND:
switch (LOWORD(wp)) {
case IDM_TOGGLE:
if (m_controller->IsGameModeActive())
m_controller->DisableGameMode();
else
m_controller->EnableGameMode();
// Only reachable in manual mode (grayed otherwise), so the click
// is the sole source of intent — record it and take the same
// acquire/release path the auto modes use. A second click while an
// acquisition is still cycling cancels it rather than restarting.
if (m_wantControl) {
ReleaseControl();
} else {
// Taking the device back from a running Steam needs the
// elevated helper. Settle the one-time UAC prompt on this
// click instead of letting it ambush the first cycle.
if (!IsProcessElevated())
EnsureCycleTaskRegistered();
m_wantControl = true;
m_acquireRetries = 0;
TryAcquireController();
}
break;
case IDM_TRACKPAD:
m_controller->SetTrackpadMouseEnabled(!m_controller->IsTrackpadMouseEnabled());
@@ -255,19 +267,19 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
case WM_TIMER:
if (wp == IDT_ACQUIRE) {
KillTimer(m_hwnd, IDT_ACQUIRE);
if (m_autoMode != AutoMode::Manual && WantControl(m_steamWatcher.GetState()))
if (m_wantControl)
TryAcquireController();
} else if (wp == IDT_WAKE_POLL) {
KillTimer(m_hwnd, IDT_WAKE_POLL);
// Cheap probe — we are only asking whether anything woke up.
if (m_autoMode != AutoMode::Manual && WantControl(m_steamWatcher.GetState()))
if (m_wantControl)
TryAcquireController(WAKE_PROBE_MS);
} else if (wp == IDT_ACQUIRE_VERDICT) {
KillTimer(m_hwnd, IDT_ACQUIRE_VERDICT);
// The last cycle's re-arrival had a grace period to land. If it
// did, the controller is ours and there is nothing to report.
if (m_controller->IsGameModeActive()) return 0;
EventLog::Write("AUTO: giving up acquiring after %d device cycles", MAX_ACQUIRE_CYCLES);
EventLog::Write("ACQUIRE: giving up after %d device cycles", MAX_ACQUIRE_CYCLES);
if (m_notificationsEnabled)
ShowAlertBalloon(L"Could not take the controller",
L"Steam is holding the controller and did not release it. "
@@ -279,10 +291,9 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
if (wp == DBT_DEVICEARRIVAL || wp == DBT_DEVICEREMOVECOMPLETE) {
m_controller->OnDeviceChange();
// A controller arriving (plugged in, or re-arriving after a device
// cycle) should be acquired without a manual toggle when the
// active auto mode wants control right now.
if (wp == DBT_DEVICEARRIVAL && m_autoMode != AutoMode::Manual
&& WantControl(m_steamWatcher.GetState()))
// cycle) should be acquired straight away whenever control is
// wanted — this is what wins the reopen race against Steam.
if (wp == DBT_DEVICEARRIVAL && m_wantControl)
TryAcquireController();
}
return TRUE;
@@ -301,7 +312,12 @@ void TrayApp::SetAutoMode(AutoMode mode) {
m_acquireRetries = 0;
SaveSettings();
UpdateStartupRegistration();
if (mode != AutoMode::Manual)
if (mode == AutoMode::Manual)
// Manual takes its intent from the tray toggle. Adopt whatever is in
// effect right now rather than acquiring or releasing behind the
// user's back just because they changed mode.
m_wantControl = m_controller->IsGameModeActive();
else
ApplySteamState(m_steamWatcher.GetState());
}
@@ -320,26 +336,35 @@ void TrayApp::ApplySteamState(SteamState state) {
static_cast<int>(state),
WantControl(state) ? "take control" : "yield");
if (WantControl(state)) {
m_wantControl = true;
m_acquireRetries = 0;
TryAcquireController();
} else {
KillTimer(m_hwnd, IDT_ACQUIRE);
KillTimer(m_hwnd, IDT_ACQUIRE_VERDICT);
KillTimer(m_hwnd, IDT_WAKE_POLL);
m_acquireRetries = 0;
m_lastCycleTick = 0;
const bool hadControl = m_controller->IsGameModeActive();
// Good citizen: restore lizard mode and close our HID handles so
// Steam can claim the controller without contention.
m_controller->ReleaseDevices();
// Steam only (re)opens controllers on device-arrival events. If it is
// already running and we held the device, it never saw one — cycle
// the device so Steam adopts it immediately.
if (hadControl && state != SteamState::NoSteam)
RestartControllerDevices();
ReleaseControl();
}
}
// Hand the controller back. Shared by the auto modes yielding to Steam and by
// the manual toggle being switched off — in both cases the user is done with
// us and Steam should be able to pick the pad straight back up.
void TrayApp::ReleaseControl() {
KillTimer(m_hwnd, IDT_ACQUIRE);
KillTimer(m_hwnd, IDT_ACQUIRE_VERDICT);
KillTimer(m_hwnd, IDT_WAKE_POLL);
m_wantControl = false;
m_acquireRetries = 0;
m_lastCycleTick = 0;
const bool hadControl = m_controller->IsGameModeActive();
// Good citizen: restore lizard mode and close our HID handles so
// Steam can claim the controller without contention.
m_controller->ReleaseDevices();
// Steam only (re)opens controllers on device-arrival events. If it is
// already running and we held the device, it never saw one — cycle
// the device so Steam adopts it immediately.
if (hadControl && m_steamWatcher.GetState() != SteamState::NoSteam)
RestartControllerDevices();
}
void TrayApp::TryAcquireController(uint32_t stateWaitMs) {
m_controller->OnDeviceChange();
auto outcome = m_controller->EnableGameMode(stateWaitMs);
@@ -400,7 +425,7 @@ void TrayApp::TryAcquireController(uint32_t stateWaitMs) {
}
++m_acquireRetries;
m_lastCycleTick = nowTick;
EventLog::Write("AUTO: exclusive claim blocked — cycling device (attempt %d)", m_acquireRetries);
EventLog::Write("ACQUIRE: exclusive claim blocked — cycling device (attempt %d)", m_acquireRetries);
m_controller->ReleaseDevices();
if (!RestartControllerDevices()) return; // helper unavailable — balloon shown
// The cycle runs asynchronously via the helper task; the device-arrival
@@ -777,8 +802,12 @@ void TrayApp::ShowContextMenu() {
// Manual toggle is disabled while an auto mode owns the decision — when
// grayed, the label itself says why.
const bool manual = (m_autoMode == AutoMode::Manual);
std::wstring toggleLabel = gameModeOn ? L"Disable Steamless Mode"
: L"Enable Steamless Mode";
// In manual mode the label tracks intent, not the current handle state:
// acquiring can spend several seconds cycling the device, and during that
// window the toggle's job is to offer a cancel.
const bool toggleOn = manual ? m_wantControl : gameModeOn;
std::wstring toggleLabel = toggleOn ? L"Disable Steamless Mode"
: L"Enable Steamless Mode";
if (!connected)
toggleLabel += L" (no controller detected)";
else if (!manual)
+8 -1
View File
@@ -52,11 +52,12 @@ private:
void DeleteLegacyStartupTask();
void UpdateStartupRegistration();
// Auto-mode plumbing.
// Control plumbing, shared by every mode.
void SetAutoMode(AutoMode mode);
bool WantControl(SteamState state) const;
void ApplySteamState(SteamState state);
void TryAcquireController(uint32_t stateWaitMs = 250);
void ReleaseControl();
bool RestartControllerDevices();
void ShowElevationBalloon();
@@ -66,6 +67,12 @@ private:
HICON m_iconOff = nullptr;
HICON m_iconOn = nullptr;
AutoMode m_autoMode = AutoMode::Manual;
// Do we want the controller right now? Acquiring is asynchronous — a
// blocked claim escalates to a device cycle that lands seconds later as a
// WM_DEVICECHANGE — so intent has to outlive the call that started it.
// Auto modes derive this from Steam's state; manual mode from the tray
// toggle, which is otherwise nowhere on record.
bool m_wantControl = false;
int m_acquireRetries = 0;
ULONGLONG m_lastCycleTick = 0;
bool m_elevationBalloonShown = false;