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.