From af1c815cf5b4eb9b1b0ceb72224c2ba2db501959 Mon Sep 17 00:00:00 2001 From: Dylan Deverill Date: Thu, 13 Aug 2026 21:36:06 -0700 Subject: [PATCH] Stop treating a missing ViGEm bus as a contested controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/ControllerManager.cpp | 82 +++++++++++++++++++++++++++++------ src/app/ControllerManager.h | 22 ++++++++-- src/app/TrayApp.cpp | 82 ++++++++++++++++++++++++++++++----- src/app/TrayApp.h | 13 +++++- 4 files changed, 170 insertions(+), 29 deletions(-) diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp index df5ecf8..2821c27 100644 --- a/src/app/ControllerManager.cpp +++ b/src/app/ControllerManager.cpp @@ -1,6 +1,7 @@ #include "ControllerManager.h" #include "EventLog.h" #include "InputInjection.h" +#include "ViGEmBusInfo.h" #include "VirtualController.h" #include "TrackpadMouse.h" #include "KeyInput.h" @@ -200,6 +201,22 @@ void ControllerManager::EmergencyRestoreAll() noexcept { } } +// EventLog's %ls reaches fprintf, which converts wide characters through the C +// locale and silently abandons the rest of the line at the first one it cannot +// encode. Measured: a bus report ending in an em dash logged as its first +// clause and nothing else. Device names on a localised Windows will do the +// same, so hand the log UTF-8 bytes and let %s pass them through untouched. +static std::string Utf8(const std::wstring& text) { + if (text.empty()) return {}; + const int bytes = WideCharToMultiByte(CP_UTF8, 0, text.c_str(), -1, + nullptr, 0, nullptr, nullptr); + if (bytes <= 1) return {}; + std::string out(static_cast(bytes), '\0'); + WideCharToMultiByte(CP_UTF8, 0, text.c_str(), -1, out.data(), bytes, nullptr, nullptr); + out.resize(static_cast(bytes) - 1); // drop the terminator + return out; +} + // --------------------------------------------------------------------------- // Lifecycle // --------------------------------------------------------------------------- @@ -243,22 +260,40 @@ void ControllerManager::OnDeviceChange() { } ControllerManager::GameModeOutcome ControllerManager::EnableGameMode(uint32_t stateWaitMs) { - bool anyVigemMissing = false; - bool anyBlocked = false; - bool anyEnabled = false; + // Once the absence of a bus driver is established, re-establish it the + // cheap way. Finding out by attempting an enable costs the user's + // controller: each attempt claims the device exclusively and toggles + // lizard mode off and back on, and the retry that waits for a driver to + // be installed would otherwise do that every 30 seconds for as long as + // the machine goes without one. Enumerating touches no device at all. + if (m_lastPadDriverMissing && ViGEmBusInfo::Enumerate().empty()) { + NotifyStateChanged(true); + return GameModeOutcome::VirtualPadUnavailable; + } + + bool anyPadUnavailable = false; + bool anyBlocked = false; + bool anyEnabled = false; for (auto& slot : m_slots) { - switch (EnableGameModeSlot(*slot, anyVigemMissing, stateWaitMs)) { - case GameModeOutcome::Enabled: anyEnabled = true; break; - case GameModeOutcome::Blocked: anyBlocked = true; break; - case GameModeOutcome::NoActiveController: break; + switch (EnableGameModeSlot(*slot, anyPadUnavailable, stateWaitMs)) { + case GameModeOutcome::Enabled: anyEnabled = true; break; + case GameModeOutcome::Blocked: anyBlocked = true; break; + case GameModeOutcome::VirtualPadUnavailable: break; // tracked by the out-param + case GameModeOutcome::NoActiveController: break; } } - NotifyStateChanged(anyVigemMissing); + NotifyStateChanged(anyPadUnavailable); if (anyEnabled) return GameModeOutcome::Enabled; // Only report Blocked when something actually stood in the way. Slots that // are merely silent mean no controller is switched on, which no amount of // device cycling will change. - return anyBlocked ? GameModeOutcome::Blocked : GameModeOutcome::NoActiveController; + // + // Blocked outranks a missing pad: contention is the one of the two a + // device cycle can still resolve, so if any slot is contested the caller + // should hear about that rather than settle into the slow ViGEm retry. + if (anyBlocked) return GameModeOutcome::Blocked; + if (anyPadUnavailable) return GameModeOutcome::VirtualPadUnavailable; + return GameModeOutcome::NoActiveController; } void ControllerManager::DisableGameMode() { @@ -467,7 +502,7 @@ void ControllerManager::OpenSlot(const std::wstring& path) { // --------------------------------------------------------------------------- ControllerManager::GameModeOutcome -ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut, +ControllerManager::EnableGameModeSlot(Slot& slot, bool& padUnavailableOut, uint32_t stateWaitMs) { if (slot.gameModeActive) return GameModeOutcome::Enabled; // The puck publishes a controller interface per slot and current firmware @@ -523,12 +558,31 @@ ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut, EventLog::Write("GAMEMODE: ViGEm virtual controller failed (stage=%s, err=0x%08X, driverMissing=%d)", slot.vc->FailStage(), slot.vc->LastError(), slot.vc->IsDriverMissing() ? 1 : 0); - if (slot.vc->IsDriverMissing()) vigemMissingOut = true; + // The line above says a call failed; this one says what the machine + // actually looks like. A second bus is the most likely cause of an + // otherwise inexplicable connect failure and the hardest thing to + // guess at from the outside, so it goes in the log every time. + if (!slot.vc->BusReport().empty()) { + EventLog::Write("GAMEMODE: %s", Utf8(slot.vc->BusReport()).c_str()); + m_lastBusReport = slot.vc->BusReport(); + } + m_lastPadDriverMissing = slot.vc->IsDriverMissing(); + padUnavailableOut = true; slot.vc.reset(); slot.sc->EnableLizardMode(); - return GameModeOutcome::Blocked; + // Hand the device back. Without a virtual pad this slot is going + // nowhere, and an exclusive handle held for nothing locks Steam out + // and makes the app itself the holder that vetoes its own device + // cycle — which is the veto the reporter's #65 logs recorded. + slot.sc->ReleaseToShared(); + return GameModeOutcome::VirtualPadUnavailable; } + // A pad was created, so whatever was missing is not missing now — clear it + // or the cheap pre-flight above would keep short-circuiting a machine that + // has since had the driver installed. + m_lastPadDriverMissing = false; + if (m_profile.platform == ControllerPlatform::PlayStation) slot.sc->SetImuEnabled(true); @@ -962,6 +1016,6 @@ void ControllerManager::ReadLoop(Slot* slot) { } } -void ControllerManager::NotifyStateChanged(bool vigemMissing) { - m_onStateChanged(!m_slots.empty(), IsGameModeActive(), vigemMissing); +void ControllerManager::NotifyStateChanged(bool padUnavailable) { + m_onStateChanged(!m_slots.empty(), IsGameModeActive(), padUnavailable); } diff --git a/src/app/ControllerManager.h b/src/app/ControllerManager.h index 06d6f5b..1e4b8ce 100644 --- a/src/app/ControllerManager.h +++ b/src/app/ControllerManager.h @@ -31,7 +31,13 @@ public: // a contested one. A puck publishes a slot interface whether or not a // controller is paired into it, and cycling the device because nothing is // switched on is pointless churn. - enum class GameModeOutcome { Enabled, NoActiveController, Blocked }; + // + // VirtualPadUnavailable is emphatically not Blocked: nothing is wrong with + // the controller, so retrying fast or restarting its devnode accomplishes + // nothing. Reporting it as Blocked is what pointed the device-cycling + // machinery at a reporter's puck over a missing bus driver and left its + // HID collections disabled (#65). + enum class GameModeOutcome { Enabled, NoActiveController, Blocked, VirtualPadUnavailable }; // stateWaitMs is how long each slot is given to prove it is live. Short // values are for polling a receiver whose controller is switched off — @@ -65,6 +71,14 @@ public: bool IsGameModeActive() const; const ControllerProfile& GetProfile() const { return m_profile; } + // Set when a virtual pad could not be created, so the tray can say which + // of the two very different problems it was: no bus driver at all (go + // install one) or a bus that is present and did not work (a rebranded fork + // is the usual reason, and telling the user to install ViGEmBus again + // would be wrong). Both are only meaningful after a failed enable. + bool LastPadDriverMissing() const { return m_lastPadDriverMissing; } + const std::wstring& LastBusReport() const { return m_lastBusReport; } + // Called by RemapWindow when a row enters/exits listening state. // Callback fires on the read thread — use PostMessage to marshal to the UI thread. void StartButtonCapture(std::function callback); @@ -75,14 +89,14 @@ private: void SyncDevices(); void OpenSlot(const std::wstring& path); - GameModeOutcome EnableGameModeSlot(Slot& slot, bool& vigemMissingOut, + GameModeOutcome EnableGameModeSlot(Slot& slot, bool& padUnavailableOut, uint32_t stateWaitMs); void DisableGameModeSlot(Slot& slot); void ReleaseHeldPaddleInputs(Slot& slot); void StartReadLoop(Slot& slot); void StopReadLoop(Slot& slot); void ReadLoop(Slot* slot); - void NotifyStateChanged(bool vigemMissing = false); + void NotifyStateChanged(bool padUnavailable = false); // Pushes the current profile's pad settings into one slot's trackpads and // its virtual controller. Shared by SetProfile and slot creation. @@ -92,6 +106,8 @@ private: AlertFn m_alertFn; std::vector> m_slots; bool m_steamPresent = false; + bool m_lastPadDriverMissing = false; + std::wstring m_lastBusReport; ControllerProfile m_profile; std::atomic m_capturing{false}; diff --git a/src/app/TrayApp.cpp b/src/app/TrayApp.cpp index 5240755..88ddd43 100644 --- a/src/app/TrayApp.cpp +++ b/src/app/TrayApp.cpp @@ -499,6 +499,9 @@ void TrayApp::ReleaseControl() { m_acquireRetries = 0; m_lastCycleTick = 0; m_cycleInFlight = false; // cleared with the tick it is measured against + // One driver notice per attempt, and letting go ends the attempt. Turning + // Steamless Mode back on is a fresh ask and deserves a fresh answer. + m_vigemBalloonShown = false; // Whatever game profile was live belongs to a controller we no longer // have. Dropping it now means re-acquiring starts from the default and // re-resolves, rather than resuming a profile for a game long since quit. @@ -554,7 +557,14 @@ void TrayApp::TryAcquireController(uint32_t stateWaitMs) { // A short burst of rapid retries: right after a device cycle we race // Steam's re-enumeration for the exclusive open, and starting the claim // the instant the device arrives is what wins that race. - for (int i = 0; i < 10 && !m_controller->IsGameModeActive(); ++i) { + // + // A missing virtual pad is not a race with anybody, so the burst is called + // off the moment that is the answer. Left running it re-attempted eleven + // times in half a second and re-armed the driver notification on each + // pass — the toast storm reported in #68. + for (int i = 0; i < 10 + && outcome != ControllerManager::GameModeOutcome::VirtualPadUnavailable + && !m_controller->IsGameModeActive(); ++i) { Sleep(50); m_controller->OnDeviceChange(); outcome = m_controller->EnableGameMode(stateWaitMs); @@ -572,6 +582,22 @@ void TrayApp::TryAcquireController(uint32_t stateWaitMs) { } if (!m_controller->IsConnected()) return; // nothing plugged in — arrival will retrigger + // The controller is fine; we just have nowhere to send its input. None of + // the machinery below applies — the retry counter, the cycling, and the + // holder scan all exist to get a HID handle away from another process, + // and no amount of restarting the puck installs a bus driver. Cycling + // here is exactly what disabled a reporter's HID collections in #65. + // + // So sit on a slow timer instead. The user is being told what to install, + // and this is what notices once they have. + if (outcome == ControllerManager::GameModeOutcome::VirtualPadUnavailable) { + // A verdict left pending by an earlier contested attempt would report + // this as Steam holding the controller, which it plainly is not. + KillTimer(m_hwnd, IDT_ACQUIRE_VERDICT); + SetTimer(m_hwnd, IDT_ACQUIRE, VIGEM_RETRY_MS, nullptr); + return; + } + // No slot is emitting state: the controller is off, asleep, or the // receiver has no controller paired into it. Cycling cannot conjure one // up, and doing it anyway restarts the receiver's devnodes (and can @@ -990,16 +1016,21 @@ void TrayApp::RemoveTrayIcon() { Shell_NotifyIconW(NIM_DELETE, &nid); } -void TrayApp::UpdateTrayIcon(bool connected, bool gameModeActive, bool vigemMissing) { - if (vigemMissing) ShowViGEmBalloon(); - else m_vigemBalloonShown = false; +void TrayApp::UpdateTrayIcon(bool connected, bool gameModeActive, bool padUnavailable) { + if (padUnavailable) ShowViGEmBalloon(); + // Cleared only by a pad that actually came up, not by any notification + // that happens to carry false. The acquire path notifies repeatedly within + // a single attempt, so clearing on false re-armed the balloon between + // consecutive failures and turned one attempt into a burst of toasts. + // ReleaseControl clears it too, so a deliberate re-enable says it again. + else if (gameModeActive) m_vigemBalloonShown = false; // Fall through rather than returning early — the icon and tooltip still need // to track the controller while the driver is unavailable. - const wchar_t* tip = gameModeActive ? L"Steamless Controller — Steamless Mode ON" - : vigemMissing ? L"Steamless Controller — ViGEmBus driver unavailable" - : connected ? L"Steamless Controller — Connected (Steamless Mode OFF)" - : L"Steamless Controller — No controller found"; + const wchar_t* tip = gameModeActive ? L"Steamless Controller — Steamless Mode ON" + : padUnavailable ? L"Steamless Controller — no virtual gamepad bus" + : connected ? L"Steamless Controller — Connected (Steamless Mode OFF)" + : L"Steamless Controller — No controller found"; NOTIFYICONDATAW nid{}; nid.cbSize = sizeof(nid); @@ -1039,19 +1070,37 @@ void TrayApp::OpenEventLog() { // gets a wall of identical balloons. UpdateTrayIcon clears the latch once the // driver is reachable again, so a genuine mid-session disappearance still warns. void TrayApp::ShowViGEmBalloon() { + if (!m_notificationsEnabled) return; if (m_vigemBalloonShown) return; m_vigemBalloonShown = true; + // Two different problems wear the same failure internally, and the advice + // for one is wrong for the other. Telling somebody who already has a bus + // to go install ViGEmBus is what #65 spent three weeks on, so say which + // case this is — the manager established it by enumerating, not by + // guessing from an error code. + // + // Missing is by far the likelier of the two: the one confirmed way to get + // here is an installer that skipped ViGEmBus (#68). The other branch names + // no culprit on purpose — a bus that is present and unusable has several + // possible causes and we have never seen one in the field. + const bool missing = m_controller->LastPadDriverMissing(); + m_balloonAction = missing ? BalloonAction::ViGEmDownload : BalloonAction::None; + NOTIFYICONDATAW nid{}; nid.cbSize = sizeof(nid); nid.hWnd = m_hwnd; nid.uID = TRAY_UID; nid.uFlags = NIF_INFO; nid.dwInfoFlags = NIIF_WARNING; - wcscpy_s(nid.szInfoTitle, L"Driver required"); + wcscpy_s(nid.szInfoTitle, missing ? L"Driver required" : L"Virtual gamepad unavailable"); wcscpy_s(nid.szInfo, - L"ViGEmBus is unavailable — not installed, or disabled in Device " - L"Manager under System devices. Click here to download it."); + missing + ? L"ViGEmBus is unavailable — not installed, or disabled in Device " + L"Manager under System devices. Click here to download it." + : L"A virtual gamepad bus is present but would not accept a " + L"controller. It may be disabled, or a version this app cannot " + L"talk to. The event log has the details."); Shell_NotifyIconW(NIM_MODIFY, &nid); } @@ -1367,6 +1416,17 @@ void TrayApp::SaveSettings() { } void TrayApp::ShowContextMenu() { + // Re-enumerate before reading state off the manager. Releasing the + // controller clears its slot list and only a device event refills it, so + // after a manual disable the menu grayed out its own toggle as "(no + // controller detected)" and stayed that way — the controller was sitting + // right there, and only restarting the app brought the toggle back (#68). + // + // Not while a cycle we asked for is running, though: this opens handles, + // and an open handle is what vetoes a cycle, ours included. + if (!m_cycleInFlight) + m_controller->OnDeviceChange(); + bool connected = m_controller->IsConnected(); bool gameModeOn = m_controller->IsGameModeActive(); bool startupOn = m_startupEnabled; diff --git a/src/app/TrayApp.h b/src/app/TrayApp.h index 8b5c2bf..df15678 100644 --- a/src/app/TrayApp.h +++ b/src/app/TrayApp.h @@ -34,7 +34,7 @@ private: void AddTrayIcon(); void RemoveTrayIcon(); - void UpdateTrayIcon(bool connected, bool gameModeActive, bool vigemMissing = false); + void UpdateTrayIcon(bool connected, bool gameModeActive, bool padUnavailable = false); void ShowViGEmBalloon(); // What clicking the balloon should do. Balloons are transient and the click @@ -122,6 +122,11 @@ private: // Verdict of the most recent device cycle, read back from the helper. DeviceRestart::CycleResult m_lastCycleStatus; bool m_elevationBalloonShown = false; + // Latched for the lifetime of one attempt, and deliberately not cleared by + // an ordinary state notification: the acquire path notifies several times + // per attempt, and clearing it there let a single attempt raise a balloon + // on every pass. Reported from the field as toasts Windows was still + // replaying minutes after the app had been closed (#68). bool m_vigemBalloonShown = false; bool m_startupEnabled = false; int m_startupMechanism = 0; // 0 none, 1 Run key, 2 elevated task @@ -218,6 +223,12 @@ private: static constexpr UINT ACQUIRE_RETRY_MS = 4000; // Grace after the final cycle before reporting failure to the user. static constexpr UINT ACQUIRE_VERDICT_MS = 3000; + // Retry spacing when the controller is fine but no virtual pad can be + // created. Nothing here is a race, so the fast cadence above buys nothing: + // what it is waiting for is a human installing a driver. Slow enough that + // waiting through the whole thing costs a handful of log lines, quick + // enough that the app picks a new driver up on its own. + static constexpr UINT VIGEM_RETRY_MS = 30000; // Minimum spacing between device cycles. A cycle is asynchronous, so // without this the arrivals it generates re-enter the acquire path and // fire another one on top of it.