From c0c584b176d8d1c50586e0f94fc34d9a7554e9ae Mon Sep 17 00:00:00 2001 From: Dan Bosscher Date: Sat, 1 Aug 2026 15:32:19 +0100 Subject: [PATCH 01/19] Fix puck activation after firmware updates --- src/app/ControllerManager.cpp | 13 +++++++-- src/hid/HidDevice.cpp | 10 ++++--- src/hid/HidDevice.h | 8 ++++-- src/main.cpp | 54 ++++++++++++++++++++++++++++++----- src/steam/SteamController.cpp | 53 ++++++++++++++++++++++++++++------ src/steam/SteamController.h | 19 +++++++++--- 6 files changed, 127 insertions(+), 30 deletions(-) diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp index ed00e15..bc0b123 100644 --- a/src/app/ControllerManager.cpp +++ b/src/app/ControllerManager.cpp @@ -346,11 +346,18 @@ void ControllerManager::OpenSlot(const std::wstring& path) { void ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) { if (slot.gameModeActive) return; - if (!slot.sc->ClaimExclusive()) { - EventLog::Write("GAMEMODE: exclusive claim failed (another process holds a write handle) %ls", - slot.path.c_str()); + if (!slot.sc->WaitForStateReport(250)) { + EventLog::Write("GAMEMODE: inactive puck slot skipped %ls", slot.path.c_str()); + return; + } + const auto claim = slot.sc->ClaimGameModeAccess(); + if (claim == SteamController::AccessClaim::Failed) { + EventLog::Write("GAMEMODE: device reopen failed %ls", slot.path.c_str()); return; } + if (claim == SteamController::AccessClaim::Shared) + EventLog::Write("GAMEMODE: exclusive claim unavailable; using shared access %ls", + slot.path.c_str()); if (!slot.sc->DisableLizardMode()) { EventLog::Write("GAMEMODE: DisableLizardMode failed %ls", slot.path.c_str()); slot.sc->ReleaseToShared(); diff --git a/src/hid/HidDevice.cpp b/src/hid/HidDevice.cpp index 9bb4139..d7de415 100644 --- a/src/hid/HidDevice.cpp +++ b/src/hid/HidDevice.cpp @@ -13,7 +13,8 @@ // Enumeration // --------------------------------------------------------------------------- -std::vector HidDevice::Enumerate(uint16_t vid, uint16_t pid, uint16_t usagePage) { +std::vector HidDevice::Enumerate(uint16_t vid, uint16_t pid, + uint16_t usagePage, uint16_t usage) { GUID hidGuid; HidD_GetHidGuid(&hidGuid); @@ -57,7 +58,8 @@ std::vector HidDevice::Enumerate(uint16_t vid, uint16_t pid, uint1 if (HidD_GetPreparsedData(h, &preparsed)) { HIDP_CAPS caps{}; if (HidP_GetCaps(preparsed, &caps) == HIDP_STATUS_SUCCESS) - match = (caps.UsagePage == usagePage); + match = caps.UsagePage == usagePage + && (usage == 0 || caps.Usage == usage); HidD_FreePreparsedData(preparsed); } else { match = false; @@ -104,8 +106,8 @@ bool HidDevice::Open(const std::wstring& path) { Close(); // Shared open for idle tracking — Steam can coexist while game mode is off. - // ClaimExclusive() downgrades the share mode to FILE_SHARE_READ when game - // mode activates, blocking Steam from obtaining write access at that point. + // ClaimGameModeAccess() prefers FILE_SHARE_READ when game mode activates, + // blocking Steam from obtaining write access when the OS permits it. m_handle = CreateFileW(path.c_str(), GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, diff --git a/src/hid/HidDevice.h b/src/hid/HidDevice.h index 15b7fe0..68553cb 100644 --- a/src/hid/HidDevice.h +++ b/src/hid/HidDevice.h @@ -7,9 +7,11 @@ class HidDevice { public: - // Returns device paths for all HID interfaces matching vid/pid/usagePage. - // Pass usagePage=0 to return all matching interfaces. - static std::vector Enumerate(uint16_t vid, uint16_t pid, uint16_t usagePage = 0); + // Returns device paths for all HID interfaces matching vid/pid/usagePage/usage. + // Pass usagePage=0 to return all matching interfaces; usage=0 matches any usage. + static std::vector Enumerate(uint16_t vid, uint16_t pid, + uint16_t usagePage = 0, + uint16_t usage = 0); HidDevice() = default; ~HidDevice() { Close(); } diff --git a/src/main.cpp b/src/main.cpp index 8422d21..2bd31d6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -69,10 +69,18 @@ static void RunCalibration(SteamController& ctrl) { // Apply force-show overrides before marking axes. auto forceShow = [](size_t i) -> bool { - if (FORCE_SHOW_BYTES_10_11 && (i == 10 || i == 11)) return true; - if (FORCE_SHOW_BYTES_12_13 && (i == 12 || i == 13)) return true; - if (FORCE_SHOW_BYTES_14_15 && (i == 14 || i == 15)) return true; - if (FORCE_SHOW_BYTES_16_17 && (i == 16 || i == 17)) return true; + if constexpr (FORCE_SHOW_BYTES_10_11) { + if (i == 10 || i == 11) return true; + } + if constexpr (FORCE_SHOW_BYTES_12_13) { + if (i == 12 || i == 13) return true; + } + if constexpr (FORCE_SHOW_BYTES_14_15) { + if (i == 14 || i == 15) return true; + } + if constexpr (FORCE_SHOW_BYTES_16_17) { + if (i == 16 || i == 17) return true; + } return false; }; @@ -147,24 +155,53 @@ static void EnumerateAllValveDevices() { HidD_GetProductString(h, productBuf, sizeof(productBuf)); uint16_t usagePage = 0, usage = 0; + uint16_t inputLen = 0, outputLen = 0, featureLen = 0; PHIDP_PREPARSED_DATA preparsed; if (HidD_GetPreparsedData(h, &preparsed)) { HIDP_CAPS caps{}; if (HidP_GetCaps(preparsed, &caps) == HIDP_STATUS_SUCCESS) { usagePage = caps.UsagePage; usage = caps.Usage; + inputLen = caps.InputReportByteLength; + outputLen = caps.OutputReportByteLength; + featureLen = caps.FeatureReportByteLength; } HidD_FreePreparsedData(preparsed); } - printf(" PID=%04X UsagePage=%04X Usage=%04X \"%ls\"\n", - attrs.ProductID, usagePage, usage, productBuf); CloseHandle(h); + + HANDLE exclusive = CreateFileW(path.c_str(), GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_FLAG_OVERLAPPED, nullptr); + DWORD exclusiveError = exclusive == INVALID_HANDLE_VALUE ? GetLastError() : ERROR_SUCCESS; + if (exclusive != INVALID_HANDLE_VALUE) CloseHandle(exclusive); + + size_t reportLen = 0; + uint8_t reportId = 0; + if (usagePage == SteamController::VENDOR_USAGE_PAGE + && usage == SteamController::CONTROLLER_USAGE) { + HidDevice inputDevice; + if (inputDevice.Open(path)) { + uint8_t report[64]{}; + reportLen = inputDevice.ReadInputReport(report, sizeof(report), 250); + if (reportLen > 0) reportId = report[0]; + } + } + + printf(" PID=%04X UsagePage=%04X Usage=%04X In=%u Out=%u Feature=%u Exclusive=%s", + attrs.ProductID, usagePage, usage, inputLen, outputLen, featureLen, + exclusiveError == ERROR_SUCCESS ? "yes" : "no"); + if (exclusiveError != ERROR_SUCCESS) + printf(" (error %lu)", exclusiveError); + if (reportLen > 0) + printf(" LiveReport=0x%02X/%zu", reportId, reportLen); + printf(" \"%ls\"\n %ls\n", productBuf, path.c_str()); } printf("\n"); } -int main() { +int main(int argc, char** argv) { signal(SIGINT, OnSignal); signal(SIGTERM, OnSignal); @@ -173,6 +210,9 @@ int main() { EnumerateAllValveDevices(); + if (argc > 1 && strcmp(argv[1], "--enumerate-only") == 0) + return 0; + SteamController controller; g_controller = &controller; diff --git a/src/steam/SteamController.cpp b/src/steam/SteamController.cpp index 920c7c2..8b8be42 100644 --- a/src/steam/SteamController.cpp +++ b/src/steam/SteamController.cpp @@ -54,7 +54,8 @@ static constexpr uint8_t HAPTIC_COMMAND_CLICK = 2; std::vector SteamController::EnumerateAll() { std::vector result; for (uint16_t pid : { SC2026_PID, SC2026_DONGLE_PID }) - for (auto const& path : HidDevice::Enumerate(VALVE_VID, pid, VENDOR_USAGE_PAGE)) + for (auto const& path : HidDevice::Enumerate( + VALVE_VID, pid, VENDOR_USAGE_PAGE, CONTROLLER_USAGE)) result.push_back(path); return result; } @@ -66,7 +67,20 @@ bool SteamController::Open() { SC2026_PID, SC2026_DONGLE_PID); return false; } - return Open(paths[0]); + + for (auto const& path : paths) { + if (!Open(path)) continue; + + uint8_t report[64]{}; + const size_t n = ReadReport(report, sizeof(report), 500); + if (n > 0 && IsStateReportId(report[0])) + return true; + + Close(); + } + + printf("No active Steam Controller slot is producing state reports.\n"); + return false; } bool SteamController::Open(const std::wstring& path) { @@ -83,20 +97,41 @@ void SteamController::Close() { m_device.Close(); } +bool SteamController::WaitForStateReport(uint32_t timeoutMs) { + const auto deadline = std::chrono::steady_clock::now() + + std::chrono::milliseconds(timeoutMs); + uint8_t report[64]{}; + + while (std::chrono::steady_clock::now() < deadline) { + const auto remaining = std::chrono::duration_cast( + deadline - std::chrono::steady_clock::now()); + const auto waitMs = static_cast(std::max(1, remaining.count())); + const size_t n = ReadReport(report, sizeof(report), waitMs); + if (n > 0 && IsStateReportId(report[0])) + return true; + } + + return false; +} + // --------------------------------------------------------------------------- // Exclusive access control // --------------------------------------------------------------------------- -bool SteamController::ClaimExclusive() { +SteamController::AccessClaim SteamController::ClaimGameModeAccess() { // The read thread must already be stopped before calling Reopen — the // caller (EnableGameModeSlot) calls this before starting the read loop. if (m_device.Reopen(FILE_SHARE_READ)) - return true; - // Another process (Steam) holds a write handle. Reopen already closed our - // old handle, so restore shared access — otherwise the device would be - // left closed and unusable for idle tracking and later retries. - m_device.Reopen(FILE_SHARE_READ | FILE_SHARE_WRITE); - return false; + return AccessClaim::Exclusive; + + // Windows components and controller utilities may keep a compatible write + // handle open even when Steam is not running. Shared access is sufficient + // for feature reports and raw input, so retain the working pre-v1.8 behavior + // instead of rejecting game mode solely because exclusivity is unavailable. + if (m_device.Reopen(FILE_SHARE_READ | FILE_SHARE_WRITE)) + return AccessClaim::Shared; + + return AccessClaim::Failed; } void SteamController::ReleaseToShared() { diff --git a/src/steam/SteamController.h b/src/steam/SteamController.h index e11b2f2..7a2fadf 100644 --- a/src/steam/SteamController.h +++ b/src/steam/SteamController.h @@ -14,6 +14,13 @@ class SteamController { // HID Usage Page for the vendor collection that carries all game input. static constexpr uint16_t VENDOR_USAGE_PAGE = 0xFF00; + static constexpr uint16_t CONTROLLER_USAGE = 0x0001; + + enum class AccessClaim { + Failed, + Shared, + Exclusive, + }; // Input report IDs (device → host) static constexpr uint8_t REPORT_STATE = 0x45; // BLE/no-quaternion state report @@ -148,6 +155,11 @@ class SteamController { return id == REPORT_STATE || id == REPORT_STATE_LEGACY; } + // Read until a live controller-state report arrives or the timeout expires. + // The puck exposes empty slot interfaces, and current firmware may reject + // feature commands until the active slot has produced an input report. + bool WaitForStateReport(uint32_t timeoutMs); + // Two-step sequence: clears digital mappings + sets trackpads to NONE. // Starts the background rumble thread. bool DisableLizardMode(); @@ -155,10 +167,9 @@ class SteamController { // Restores default mappings. Should be called before process exit. bool EnableLizardMode(); - // Reopen the device handle with FILE_SHARE_READ only, preventing other - // processes (e.g. Steam) from obtaining write access. Call before - // DisableLizardMode() when entering game mode. - bool ClaimExclusive(); + // Prefer a write-exclusive handle, falling back to shared access when a + // compatible system handle is already open. Call before DisableLizardMode(). + AccessClaim ClaimGameModeAccess(); // Reopen the device handle with full share flags, allowing other processes // to open the device for write. Call after EnableLizardMode() when leaving From a53280479d5a4a720eb8e7315830af91acc1ba57 Mon Sep 17 00:00:00 2001 From: Dylan Deverill Date: Sat, 1 Aug 2026 19:37:41 -0700 Subject: [PATCH 02/19] Only settle for a shared handle when Steam is absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/ControllerManager.cpp | 32 ++++++++++++++++++++++++++++++-- src/app/ControllerManager.h | 8 ++++++++ src/app/TrayApp.cpp | 9 +++++++-- src/steam/SteamController.cpp | 8 +++++++- 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp index a59ec71..ecd4ca9 100644 --- a/src/app/ControllerManager.cpp +++ b/src/app/ControllerManager.cpp @@ -28,6 +28,12 @@ struct ControllerManager::Slot { bool gameModeActive = false; int lastBatteryPercent = -1; // -1 = never reported + // When this slot was last found silent. Probing for a state report costs + // the full timeout on an empty puck slot, and the acquire path retries in + // a rapid burst — without this, three empty slots would block the UI + // thread for that timeout on every one of those attempts. + std::chrono::steady_clock::time_point lastSilentAt{}; + // Haptic edge-detection state for touch (movement ticks). bool hapticWasRightTouching = false; bool hapticWasLeftTouching = false; @@ -360,18 +366,40 @@ void ControllerManager::OpenSlot(const std::wstring& path) { void ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) { if (slot.gameModeActive) return; + // The puck publishes a controller interface per slot and current firmware + // rejects the lizard-mode command on an empty one, so only act on a slot + // that is actually emitting state. Recently-silent slots are skipped + // outright — see Slot::lastSilentAt. + static constexpr auto kSilentSlotRetryGap = std::chrono::milliseconds(1500); + const auto now = std::chrono::steady_clock::now(); + if (now - slot.lastSilentAt < kSilentSlotRetryGap) return; + if (!slot.sc->WaitForStateReport(250)) { - EventLog::Write("GAMEMODE: inactive puck slot skipped %ls", slot.path.c_str()); + slot.lastSilentAt = std::chrono::steady_clock::now(); + EventLog::Write("GAMEMODE: no state reports from slot (transport=%s), skipping %ls", + SteamController::TransportName(slot.transport), slot.path.c_str()); return; } + const auto claim = slot.sc->ClaimGameModeAccess(); if (claim == SteamController::AccessClaim::Failed) { EventLog::Write("GAMEMODE: device reopen failed %ls", slot.path.c_str()); return; } - if (claim == SteamController::AccessClaim::Shared) + if (claim == SteamController::AccessClaim::Shared) { + // A write handle held while Steam is running is most likely Steam's. + // Proceeding would put two processes on the same controller, both + // driving lizard mode; refusing is what escalates to a device cycle + // that takes the handle back. Only settle for shared when Steam is + // absent and the holder is some benign system component. + if (m_steamPresent) { + EventLog::Write("GAMEMODE: exclusive claim blocked while Steam is running %ls", + slot.path.c_str()); + return; + } EventLog::Write("GAMEMODE: exclusive claim unavailable; using shared access %ls", slot.path.c_str()); + } if (!slot.sc->DisableLizardMode()) { EventLog::Write("GAMEMODE: DisableLizardMode failed %ls", slot.path.c_str()); slot.sc->ReleaseToShared(); diff --git a/src/app/ControllerManager.h b/src/app/ControllerManager.h index 0205541..06c6b41 100644 --- a/src/app/ControllerManager.h +++ b/src/app/ControllerManager.h @@ -38,6 +38,13 @@ class ControllerManager { // fallback if this fails or the process dies harder than a C++ exception. void EmergencyRestoreAll() noexcept; + // Whether steam.exe is currently running. Decides if game mode may settle + // for a shared handle: a write handle held while Steam is absent belongs to + // some benign system component, but one held while Steam is running is + // probably Steam itself, and driving the controller alongside it is worse + // than refusing — refusing is what escalates to a device cycle. + void SetSteamPresent(bool present) { m_steamPresent = present; } + void SetTrackpadMouseEnabled(bool enabled); void SetUseLeftTrackpad(bool enabled); void SetBackButtonConfig(const BackButtonConfig& cfg); @@ -75,6 +82,7 @@ class ControllerManager { bool m_trackpadMouseEnabled = false; bool m_useLeftTrackpad = false; bool m_backButtonsEnabled = false; + bool m_steamPresent = false; ControllerPlatform m_controllerPlatform = ControllerPlatform::Xbox; BackButtonConfig m_backConfig; diff --git a/src/app/TrayApp.cpp b/src/app/TrayApp.cpp index 39c3e99..bfba57f 100644 --- a/src/app/TrayApp.cpp +++ b/src/app/TrayApp.cpp @@ -229,9 +229,14 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { } return 0; - case WM_STEAMSTATE: - ApplySteamState(static_cast(wp)); + case WM_STEAMSTATE: { + const auto steamState = static_cast(wp); + // Outside ApplySteamState, which returns early in Manual mode — the + // shared-handle decision needs to know about Steam in every mode. + m_controller->SetSteamPresent(steamState != SteamState::NoSteam); + ApplySteamState(steamState); return 0; + } case WM_ALERT: { if (!m_notificationsEnabled) diff --git a/src/steam/SteamController.cpp b/src/steam/SteamController.cpp index 73dade4..491d1a5 100644 --- a/src/steam/SteamController.cpp +++ b/src/steam/SteamController.cpp @@ -132,12 +132,18 @@ bool SteamController::WaitForStateReport(uint32_t timeoutMs) { uint8_t report[64]{}; while (std::chrono::steady_clock::now() < deadline) { + const auto before = std::chrono::steady_clock::now(); const auto remaining = std::chrono::duration_cast( - deadline - std::chrono::steady_clock::now()); + deadline - before); const auto waitMs = static_cast(std::max(1, remaining.count())); const size_t n = ReadReport(report, sizeof(report), waitMs); if (n > 0 && IsStateReportId(report[0])) return true; + // A silent slot times out and consumes the wait; returning nothing + // instantly means the read failed outright, so stop rather than spin + // on a dead handle for the rest of the window. + if (n == 0 && std::chrono::steady_clock::now() - before < std::chrono::milliseconds(2)) + return false; } return false; From 9a211d2d0cb9bc53b13e06b683ff3005886c0649 Mon Sep 17 00:00:00 2001 From: Dan Bosscher Date: Mon, 3 Aug 2026 08:51:26 +0100 Subject: [PATCH 03/19] Allow shared controller access while Steam is idle --- src/app/ControllerManager.cpp | 13 ++++++------- src/app/ControllerManager.h | 12 +++++------- src/app/TrayApp.cpp | 6 +++--- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp index ecd4ca9..dd0ab09 100644 --- a/src/app/ControllerManager.cpp +++ b/src/app/ControllerManager.cpp @@ -387,13 +387,12 @@ void ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) { return; } if (claim == SteamController::AccessClaim::Shared) { - // A write handle held while Steam is running is most likely Steam's. - // Proceeding would put two processes on the same controller, both - // driving lizard mode; refusing is what escalates to a device cycle - // that takes the handle back. Only settle for shared when Steam is - // absent and the holder is some benign system component. - if (m_steamPresent) { - EventLog::Write("GAMEMODE: exclusive claim blocked while Steam is running %ls", + // ERROR_SHARING_VIOLATION identifies no owner: on current Windows the + // puck can require shared access even with steam.exe absent. Steam + // Input can genuinely compete while a Steam game is active, though, + // so keep the conservative refusal for that state only. + if (m_steamGameRunning) { + EventLog::Write("GAMEMODE: exclusive claim blocked while a Steam game is running %ls", slot.path.c_str()); return; } diff --git a/src/app/ControllerManager.h b/src/app/ControllerManager.h index 06c6b41..ee819f8 100644 --- a/src/app/ControllerManager.h +++ b/src/app/ControllerManager.h @@ -38,12 +38,10 @@ class ControllerManager { // fallback if this fails or the process dies harder than a C++ exception. void EmergencyRestoreAll() noexcept; - // Whether steam.exe is currently running. Decides if game mode may settle - // for a shared handle: a write handle held while Steam is absent belongs to - // some benign system component, but one held while Steam is running is - // probably Steam itself, and driving the controller alongside it is worse - // than refusing — refusing is what escalates to a device cycle. - void SetSteamPresent(bool present) { m_steamPresent = present; } + // Whether Steam currently reports an active game. Shared access is safe + // while the client is idle, but not while Steam Input may also be mapping + // the controller for a running game. + void SetSteamGameRunning(bool running) { m_steamGameRunning = running; } void SetTrackpadMouseEnabled(bool enabled); void SetUseLeftTrackpad(bool enabled); @@ -82,7 +80,7 @@ class ControllerManager { bool m_trackpadMouseEnabled = false; bool m_useLeftTrackpad = false; bool m_backButtonsEnabled = false; - bool m_steamPresent = false; + bool m_steamGameRunning = false; ControllerPlatform m_controllerPlatform = ControllerPlatform::Xbox; BackButtonConfig m_backConfig; diff --git a/src/app/TrayApp.cpp b/src/app/TrayApp.cpp index bfba57f..e36046a 100644 --- a/src/app/TrayApp.cpp +++ b/src/app/TrayApp.cpp @@ -231,9 +231,9 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { case WM_STEAMSTATE: { const auto steamState = static_cast(wp); - // Outside ApplySteamState, which returns early in Manual mode — the - // shared-handle decision needs to know about Steam in every mode. - m_controller->SetSteamPresent(steamState != SteamState::NoSteam); + // Outside ApplySteamState, which returns early in Manual mode — a + // manual enable must still avoid competing with an active Steam game. + m_controller->SetSteamGameRunning(steamState == SteamState::InGame); ApplySteamState(steamState); return 0; } From 8d81efeb5db9b0232bf5b5553f7a62600eafe52b Mon Sep 17 00:00:00 2001 From: Dan Bosscher Date: Mon, 3 Aug 2026 09:47:48 +0100 Subject: [PATCH 04/19] Prevent shared input conflicts with Steam --- src/app/ControllerManager.cpp | 52 +++++++++++++++++++++++++++++++---- src/app/ControllerManager.h | 11 ++++---- src/app/TrayApp.cpp | 7 +++-- 3 files changed, 56 insertions(+), 14 deletions(-) diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp index dd0ab09..85abc8a 100644 --- a/src/app/ControllerManager.cpp +++ b/src/app/ControllerManager.cpp @@ -26,6 +26,7 @@ struct ControllerManager::Slot { std::thread readThread; std::atomic readRunning{false}; bool gameModeActive = false; + SteamController::AccessClaim accessClaim = SteamController::AccessClaim::Failed; int lastBatteryPercent = -1; // -1 = never reported // When this slot was last found silent. Probing for a state report costs @@ -242,6 +243,27 @@ void ControllerManager::ReleaseDevices() { NotifyStateChanged(); } +void ControllerManager::SetSteamPresent(bool present) { + if (m_steamPresent == present) return; + m_steamPresent = present; + + if (!present) { + m_steamConflictAlertShown = false; + return; + } + + const bool sharedModeActive = std::any_of(m_slots.begin(), m_slots.end(), + [](const auto& slot) { + return slot->gameModeActive + && slot->accessClaim == SteamController::AccessClaim::Shared; + }); + if (!sharedModeActive) return; + + EventLog::Write("GAMEMODE: Steam started during shared access; yielding to prevent duplicate input"); + DisableGameMode(); + NotifySteamConflict(); +} + void ControllerManager::SetTrackpadMouseEnabled(bool enabled) { m_trackpadMouseEnabled = enabled; for (auto& slot : m_slots) { @@ -381,26 +403,31 @@ void ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) { return; } + slot.accessClaim = SteamController::AccessClaim::Failed; const auto claim = slot.sc->ClaimGameModeAccess(); if (claim == SteamController::AccessClaim::Failed) { EventLog::Write("GAMEMODE: device reopen failed %ls", slot.path.c_str()); return; } if (claim == SteamController::AccessClaim::Shared) { - // ERROR_SHARING_VIOLATION identifies no owner: on current Windows the - // puck can require shared access even with steam.exe absent. Steam - // Input can genuinely compete while a Steam game is active, though, - // so keep the conservative refusal for that state only. - if (m_steamGameRunning) { - EventLog::Write("GAMEMODE: exclusive claim blocked while a Steam game is running %ls", + // ERROR_SHARING_VIOLATION does not identify the existing owner. Live + // testing shows that sharing with an idle Steam client is still unsafe: + // whichever mapper starts second can produce duplicate input or starve + // the other reader. The Windows-only holder is benign, so shared access + // remains the compatibility path while Steam is absent. + if (m_steamPresent) { + EventLog::Write("GAMEMODE: shared access blocked while Steam is running %ls", slot.path.c_str()); + NotifySteamConflict(); return; } EventLog::Write("GAMEMODE: exclusive claim unavailable; using shared access %ls", slot.path.c_str()); } + slot.accessClaim = claim; if (!slot.sc->DisableLizardMode()) { EventLog::Write("GAMEMODE: DisableLizardMode failed %ls", slot.path.c_str()); + slot.accessClaim = SteamController::AccessClaim::Failed; slot.sc->ReleaseToShared(); return; } @@ -416,6 +443,7 @@ void ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) { if (slot.vc->IsDriverMissing()) vigemMissingOut = true; slot.vc.reset(); slot.sc->EnableLizardMode(); + slot.accessClaim = SteamController::AccessClaim::Failed; return; } @@ -443,9 +471,21 @@ void ControllerManager::DisableGameModeSlot(Slot& slot) { slot.sc->EnableLizardMode(); // Reopen shared so Steam can obtain write access — game mode is no longer active. slot.sc->ReleaseToShared(); + slot.accessClaim = SteamController::AccessClaim::Failed; slot.gameModeActive = false; } +void ControllerManager::NotifySteamConflict() { + if (m_steamConflictAlertShown) return; + m_steamConflictAlertShown = true; + if (m_alertFn) { + m_alertFn(L"Steam is using the controller", + L"Windows only granted shared controller access while Steam is running. " + L"Steamless Mode was kept off to prevent duplicate or missing input. " + L"Exit Steam completely, then enable Steamless Mode again."); + } +} + // --------------------------------------------------------------------------- // Read loop // --------------------------------------------------------------------------- diff --git a/src/app/ControllerManager.h b/src/app/ControllerManager.h index ee819f8..090bec4 100644 --- a/src/app/ControllerManager.h +++ b/src/app/ControllerManager.h @@ -38,10 +38,9 @@ class ControllerManager { // fallback if this fails or the process dies harder than a C++ exception. void EmergencyRestoreAll() noexcept; - // Whether Steam currently reports an active game. Shared access is safe - // while the client is idle, but not while Steam Input may also be mapping - // the controller for a running game. - void SetSteamGameRunning(bool running) { m_steamGameRunning = running; } + // Shared HID access is only safe while Steam is absent. If Steam appears + // during a shared session, yield before both mappers can emit input. + void SetSteamPresent(bool present); void SetTrackpadMouseEnabled(bool enabled); void SetUseLeftTrackpad(bool enabled); @@ -72,6 +71,7 @@ class ControllerManager { void StartReadLoop(Slot& slot); void StopReadLoop(Slot& slot); void ReadLoop(Slot* slot); + void NotifySteamConflict(); void NotifyStateChanged(bool vigemMissing = false); StateChangedFn m_onStateChanged; @@ -80,7 +80,8 @@ class ControllerManager { bool m_trackpadMouseEnabled = false; bool m_useLeftTrackpad = false; bool m_backButtonsEnabled = false; - bool m_steamGameRunning = false; + bool m_steamPresent = false; + bool m_steamConflictAlertShown = false; ControllerPlatform m_controllerPlatform = ControllerPlatform::Xbox; BackButtonConfig m_backConfig; diff --git a/src/app/TrayApp.cpp b/src/app/TrayApp.cpp index e36046a..7b6059c 100644 --- a/src/app/TrayApp.cpp +++ b/src/app/TrayApp.cpp @@ -231,9 +231,10 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { case WM_STEAMSTATE: { const auto steamState = static_cast(wp); - // Outside ApplySteamState, which returns early in Manual mode — a - // manual enable must still avoid competing with an active Steam game. - m_controller->SetSteamGameRunning(steamState == SteamState::InGame); + // Outside ApplySteamState, which returns early in Manual mode. A shared + // manual session must still yield if Steam starts, and a manual enable + // must not settle for shared access while Steam is already present. + m_controller->SetSteamPresent(steamState != SteamState::NoSteam); ApplySteamState(steamState); return 0; } From b7185974258a341de191a7cf6e2696a0bd2ecac4 Mon Sep 17 00:00:00 2001 From: Dan Bosscher Date: Mon, 3 Aug 2026 10:02:12 +0100 Subject: [PATCH 05/19] Default to yielding while Steam runs --- src/app/TrayApp.cpp | 2 +- src/app/TrayApp.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/TrayApp.cpp b/src/app/TrayApp.cpp index 7b6059c..67ec5b4 100644 --- a/src/app/TrayApp.cpp +++ b/src/app/TrayApp.cpp @@ -658,7 +658,7 @@ void TrayApp::LoadSettings() { }; // 0 = Manual, 1 = OffWhileSteam, 2 = OffOnlyInGame (old builds stored 0/1). - const DWORD mode = readDw(L"AutoSteamMode", 0); + const DWORD mode = readDw(L"AutoSteamMode", 1); m_autoMode = mode == 2 ? AutoMode::OffOnlyInGame : mode == 1 ? AutoMode::OffWhileSteam : AutoMode::Manual; diff --git a/src/app/TrayApp.h b/src/app/TrayApp.h index e809717..bad01ce 100644 --- a/src/app/TrayApp.h +++ b/src/app/TrayApp.h @@ -65,7 +65,7 @@ class TrayApp { UINT m_wmTaskbar = 0; HICON m_iconOff = nullptr; HICON m_iconOn = nullptr; - AutoMode m_autoMode = AutoMode::Manual; + AutoMode m_autoMode = AutoMode::OffWhileSteam; int m_acquireRetries = 0; bool m_elevationBalloonShown = false; bool m_startupEnabled = false; From 4ecc2b08dc552bd3dcf9dae881e206100dd6993c Mon Sep 17 00:00:00 2001 From: Dan Bosscher Date: Mon, 3 Aug 2026 10:37:30 +0100 Subject: [PATCH 06/19] Allow safe coexistence with Steam controller blacklist --- README.md | 12 +++ src/app/ControllerManager.cpp | 15 ++-- src/app/ControllerManager.h | 8 +- src/app/SteamWatcher.cpp | 162 ++++++++++++++++++++++++++++++++-- src/app/SteamWatcher.h | 7 +- src/app/TrayApp.cpp | 18 ++-- 6 files changed, 195 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 895bf53..9e7267b 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,18 @@ cmake --build build/release --config Release --target SteamlessController The Steam Controller exposes a vendor HID collection (usage page `0xFF00`) that carries all game input in a 54-byte report (ID `0x42`) at ~60 Hz. By default the firmware runs in **lizard mode**, emulating a keyboard and mouse so the controller works without drivers. +### Keeping Steam running + +Steam normally opens the physical Steam Controller even while the client is idle. That can produce duplicate keyboard/mouse and virtual-gamepad input if SteamlessController uses the controller at the same time. + +Steam's per-device controller blacklist is the narrow workaround: it leaves the Steam client and Steam Input for other controller types running, while reserving Steam Controllers for SteamlessController. Exit Steam, back up and open `config/config.vdf` under the Steam install directory, then add this property inside the outer `InstallConfigStore` object and restart Steam: + +```vdf +"controller_blacklist" "28de/1302,28de/1303,28de/1304" +``` + +SteamlessController verifies both the setting and Steam's current `logs/controller.txt` session before treating this as safe. If Steam has not loaded the blacklist, it still yields rather than risk duplicate input. Steam games will receive SteamlessController's virtual Xbox controller instead of the physical Steam Controller, so Steam Controller-specific Steam Input layouts, gyro, and touch menus are unavailable until the entry is removed and Steam is restarted. + SteamlessController sends HID feature reports to disable lizard mode, then reads the raw input reports and translates them into a virtual Xbox 360 controller via ViGEmBus. A background heartbeat re-sends the disable command every 800 ms to keep lizard mode off. The full input report layout is documented in [`src/steam/SteamController.h`](src/steam/SteamController.h). diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp index 85abc8a..e227253 100644 --- a/src/app/ControllerManager.cpp +++ b/src/app/ControllerManager.cpp @@ -243,11 +243,11 @@ void ControllerManager::ReleaseDevices() { NotifyStateChanged(); } -void ControllerManager::SetSteamPresent(bool present) { - if (m_steamPresent == present) return; - m_steamPresent = present; +void ControllerManager::SetSteamMayOwnController(bool mayOwn) { + if (m_steamMayOwnController == mayOwn) return; + m_steamMayOwnController = mayOwn; - if (!present) { + if (!mayOwn) { m_steamConflictAlertShown = false; return; } @@ -415,8 +415,8 @@ void ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) { // whichever mapper starts second can produce duplicate input or starve // the other reader. The Windows-only holder is benign, so shared access // remains the compatibility path while Steam is absent. - if (m_steamPresent) { - EventLog::Write("GAMEMODE: shared access blocked while Steam is running %ls", + if (m_steamMayOwnController) { + EventLog::Write("GAMEMODE: shared access blocked because Steam may own %ls", slot.path.c_str()); NotifySteamConflict(); return; @@ -482,7 +482,8 @@ void ControllerManager::NotifySteamConflict() { m_alertFn(L"Steam is using the controller", L"Windows only granted shared controller access while Steam is running. " L"Steamless Mode was kept off to prevent duplicate or missing input. " - L"Exit Steam completely, then enable Steamless Mode again."); + L"Exit Steam, or add the Steam Controller to Steam's controller_blacklist " + L"and restart Steam."); } } diff --git a/src/app/ControllerManager.h b/src/app/ControllerManager.h index 090bec4..5fc0cf5 100644 --- a/src/app/ControllerManager.h +++ b/src/app/ControllerManager.h @@ -38,9 +38,9 @@ class ControllerManager { // fallback if this fails or the process dies harder than a C++ exception. void EmergencyRestoreAll() noexcept; - // Shared HID access is only safe while Steam is absent. If Steam appears - // during a shared session, yield before both mappers can emit input. - void SetSteamPresent(bool present); + // Shared HID access is only safe while Steam cannot see the physical + // controller. If that changes, yield before both mappers can emit input. + void SetSteamMayOwnController(bool mayOwn); void SetTrackpadMouseEnabled(bool enabled); void SetUseLeftTrackpad(bool enabled); @@ -80,7 +80,7 @@ class ControllerManager { bool m_trackpadMouseEnabled = false; bool m_useLeftTrackpad = false; bool m_backButtonsEnabled = false; - bool m_steamPresent = false; + bool m_steamMayOwnController = false; bool m_steamConflictAlertShown = false; ControllerPlatform m_controllerPlatform = ControllerPlatform::Xbox; BackButtonConfig m_backConfig; diff --git a/src/app/SteamWatcher.cpp b/src/app/SteamWatcher.cpp index 92a8bad..da8f588 100644 --- a/src/app/SteamWatcher.cpp +++ b/src/app/SteamWatcher.cpp @@ -1,28 +1,176 @@ #include "SteamWatcher.h" +#include "EventLog.h" #include #include +#include +#include +#include static constexpr int POLL_INTERVAL_MS = 2000; static constexpr int POLL_SLICE_MS = 100; // wake often for fast Stop() static constexpr int LESS_STEAM_POLL_COUNT = 3; // ~6s stable before we take over -static bool IsSteamProcessRunning() { +struct SteamProcess { + bool running = false; + FILETIME started{}; +}; + +static SteamProcess FindSteamProcess() { HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); - if (snap == INVALID_HANDLE_VALUE) return false; + if (snap == INVALID_HANDLE_VALUE) return {}; PROCESSENTRY32W pe{}; pe.dwSize = sizeof(pe); - bool found = false; + DWORD pid = 0; if (Process32FirstW(snap, &pe)) { do { if (_wcsicmp(pe.szExeFile, L"steam.exe") == 0) { - found = true; + pid = pe.th32ProcessID; break; } } while (Process32NextW(snap, &pe)); } CloseHandle(snap); - return found; + if (pid == 0) return {}; + + SteamProcess result{}; + result.running = true; + HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (!process) return result; + + FILETIME exited{}, kernel{}, user{}; + GetProcessTimes(process, &result.started, &exited, &kernel, &user); + CloseHandle(process); + return result; +} + +static bool ReadRegistryString(HKEY root, const wchar_t* subkey, const wchar_t* name, + std::wstring& value) { + DWORD bytes = 0; + if (RegGetValueW(root, subkey, name, RRF_RT_REG_SZ, nullptr, nullptr, &bytes) + != ERROR_SUCCESS || bytes < sizeof(wchar_t)) + return false; + + value.resize(bytes / sizeof(wchar_t)); + if (RegGetValueW(root, subkey, name, RRF_RT_REG_SZ, nullptr, value.data(), &bytes) + != ERROR_SUCCESS) + return false; + while (!value.empty() && value.back() == L'\0') value.pop_back(); + return !value.empty(); +} + +static bool ReadFileTail(const std::wstring& path, size_t maxBytes, std::string& text, + FILETIME* lastWrite = nullptr) { + HANDLE file = CreateFileW(path.c_str(), GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) return false; + + LARGE_INTEGER size{}; + if (!GetFileSizeEx(file, &size) || size.QuadPart < 0) { + CloseHandle(file); + return false; + } + if (lastWrite && !GetFileTime(file, nullptr, nullptr, lastWrite)) { + CloseHandle(file); + return false; + } + + const ULONGLONG fileBytes = static_cast(size.QuadPart); + const DWORD bytes = static_cast(std::min(fileBytes, maxBytes)); + LARGE_INTEGER offset{}; + offset.QuadPart = size.QuadPart - bytes; + if (!SetFilePointerEx(file, offset, nullptr, FILE_BEGIN)) { + CloseHandle(file); + return false; + } + + text.resize(bytes); + DWORD read = 0; + const bool ok = bytes == 0 || ReadFile(file, text.data(), bytes, &read, nullptr); + CloseHandle(file); + if (!ok) return false; + text.resize(read); + return true; +} + +static void LowerAscii(std::string& text) { + std::transform(text.begin(), text.end(), text.begin(), [](unsigned char ch) { + return static_cast(std::tolower(ch)); + }); +} + +static bool ReportControllerHidden(bool hidden, const char* reason) { + static std::string previous; + const std::string current = std::string(hidden ? "hidden: " : "not hidden: ") + reason; + if (current != previous) { + EventLog::Write("STEAM: physical controller %s", current.c_str()); + previous = current; + } + return hidden; +} + +static std::string ControllerBlacklistValue(const std::string& config) { + constexpr char key[] = "\"controller_blacklist\""; + const size_t keyPos = config.rfind(key); + if (keyPos == std::string::npos) return {}; + const size_t open = config.find('"', keyPos + sizeof(key) - 1); + if (open == std::string::npos) return {}; + const size_t close = config.find('"', open + 1); + if (close == std::string::npos) return {}; + return config.substr(open + 1, close - open - 1); +} + +// Steam writes an explicit line for every device suppressed by +// controller_blacklist. Requiring that line from the current client session is +// important: editing config.vdf while Steam is already running does not revoke +// the handles Steam has open. Any missing or ambiguous evidence fails closed. +static bool IsControllerHiddenFromSteam(const SteamProcess& steam) { + if (!steam.running || (steam.started.dwLowDateTime == 0 && steam.started.dwHighDateTime == 0)) + return ReportControllerHidden(false, "process start time unavailable"); + + std::wstring steamPath; + if (!ReadRegistryString(HKEY_CURRENT_USER, L"Software\\Valve\\Steam", L"SteamPath", + steamPath)) + return ReportControllerHidden(false, "SteamPath unavailable"); + std::replace(steamPath.begin(), steamPath.end(), L'/', L'\\'); + + std::string config; + const std::wstring configPath = steamPath + L"\\config\\config.vdf"; + if (!ReadFileTail(configPath, 1024 * 1024, config)) + return ReportControllerHidden(false, "config.vdf unavailable"); + LowerAscii(config); + const std::string blacklist = ControllerBlacklistValue(config); + if (blacklist.empty()) + return ReportControllerHidden(false, "controller_blacklist missing"); + + FILETIME logWrite{}; + std::string log; + if (!ReadFileTail(steamPath + L"\\logs\\controller.txt", 4 * 1024 * 1024, log, + &logWrite) + || CompareFileTime(&logWrite, &steam.started) < 0) + return ReportControllerHidden(false, "current controller log unavailable"); + LowerAscii(log); + + const size_t sessionPos = log.rfind("client version:"); + if (sessionPos == std::string::npos) + return ReportControllerHidden(false, "current controller session unavailable"); + const std::string session = log.substr(sessionPos); + + constexpr const char* ids[] = {"28de/1302", "28de/1303", "28de/1304"}; + bool hidden = false; + for (const char* id : ids) { + std::string type(id); + std::replace(type.begin(), type.end(), '/', ' '); + if (session.find("type: " + type) != std::string::npos) + return ReportControllerHidden(false, "Steam opened a physical controller"); + if (blacklist.find(id) != std::string::npos + && session.find(std::string("hiding blacklisted device ") + id) + != std::string::npos) + hidden = true; + } + return ReportControllerHidden(hidden, hidden ? "confirmed by current Steam session" + : "no matching hide event"); } // Steam publishes the app id of the running game here and resets it to 0 on @@ -39,7 +187,9 @@ static bool IsGameRunning() { } SteamState SteamWatcher::Detect() { - if (!IsSteamProcessRunning()) return SteamState::NoSteam; + const SteamProcess steam = FindSteamProcess(); + if (!steam.running) return SteamState::NoSteam; + if (IsControllerHiddenFromSteam(steam)) return SteamState::ControllerHidden; return IsGameRunning() ? SteamState::InGame : SteamState::SteamIdle; } diff --git a/src/app/SteamWatcher.h b/src/app/SteamWatcher.h index 29496b1..8853d2b 100644 --- a/src/app/SteamWatcher.h +++ b/src/app/SteamWatcher.h @@ -8,9 +8,10 @@ // reported immediately (yield the controller fast), transitions to a LOWER // value are debounced (don't grab it during a Steam restart or game relaunch). enum class SteamState { - NoSteam = 0, // steam.exe not running - SteamIdle = 1, // Steam running, no game active (RunningAppID == 0) - InGame = 2, // Steam running a game + NoSteam = 0, // steam.exe not running + ControllerHidden = 1, // Steam is running but its active session ignored the controller + SteamIdle = 2, // Steam running, no game active (RunningAppID == 0) + InGame = 3, // Steam running a game }; // Watches the Steam process and its RunningAppID registry value, reporting diff --git a/src/app/TrayApp.cpp b/src/app/TrayApp.cpp index 67ec5b4..2d8a1b5 100644 --- a/src/app/TrayApp.cpp +++ b/src/app/TrayApp.cpp @@ -232,9 +232,10 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { case WM_STEAMSTATE: { const auto steamState = static_cast(wp); // Outside ApplySteamState, which returns early in Manual mode. A shared - // manual session must still yield if Steam starts, and a manual enable - // must not settle for shared access while Steam is already present. - m_controller->SetSteamPresent(steamState != SteamState::NoSteam); + // manual session must still yield if Steam can see the controller, and + // a manual enable must not settle for unsafe shared access. + m_controller->SetSteamMayOwnController(steamState == SteamState::SteamIdle + || steamState == SteamState::InGame); ApplySteamState(steamState); return 0; } @@ -293,8 +294,10 @@ void TrayApp::SetAutoMode(AutoMode mode) { bool TrayApp::WantControl(SteamState state) const { switch (m_autoMode) { - case AutoMode::OffWhileSteam: return state == SteamState::NoSteam; - case AutoMode::OffOnlyInGame: return state != SteamState::InGame; + case AutoMode::OffWhileSteam: + return state == SteamState::NoSteam || state == SteamState::ControllerHidden; + case AutoMode::OffOnlyInGame: + return state != SteamState::InGame; default: return false; // Manual: tray toggle decides } } @@ -302,7 +305,7 @@ bool TrayApp::WantControl(SteamState state) const { void TrayApp::ApplySteamState(SteamState state) { if (m_autoMode == AutoMode::Manual) return; - EventLog::Write("AUTO: steam state %d (0=none 1=idle 2=inGame) -> %s", + EventLog::Write("AUTO: steam state %d (0=none 1=controllerHidden 2=idle 3=inGame) -> %s", static_cast(state), WantControl(state) ? "take control" : "yield"); if (WantControl(state)) { @@ -318,7 +321,8 @@ void TrayApp::ApplySteamState(SteamState state) { // 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) + if (hadControl && state != SteamState::NoSteam + && state != SteamState::ControllerHidden) RestartControllerDevices(); } } From a1f9b805d13d1cb05d5bc257690c328e58d66162 Mon Sep 17 00:00:00 2001 From: Dan Bosscher Date: Mon, 3 Aug 2026 10:59:44 +0100 Subject: [PATCH 07/19] Add selectable Steam coexistence strategies --- CMakeLists.txt | 5 + README.md | 12 +- src/app/ControllerManager.cpp | 4 +- src/app/SteamStrategy.cpp | 452 ++++++++++++++++++++++++++++++++++ src/app/SteamStrategy.h | 30 +++ src/app/SteamWatcher.cpp | 97 ++++++++ src/app/TrayApp.cpp | 138 +++++++++++ src/app/TrayApp.h | 13 + 8 files changed, 746 insertions(+), 5 deletions(-) create mode 100644 src/app/SteamStrategy.cpp create mode 100644 src/app/SteamStrategy.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 7ee2f2c..b12c297 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -91,6 +91,7 @@ add_executable(SteamlessController WIN32 src/app/VirtualController.cpp src/app/TrackpadMouse.cpp src/app/SteamWatcher.cpp + src/app/SteamStrategy.cpp src/app/DeviceRestart.cpp src/app/EventLog.cpp src/app/RemapWindow.cpp @@ -108,6 +109,10 @@ target_link_libraries(SteamlessController PRIVATE ole32 shlwapi cfgmgr32 + comctl32 + shell32 + wbemuuid + oleaut32 ) if (MSVC) target_compile_options(SteamlessController PRIVATE /W4 /WX /utf-8 /wd4828) diff --git a/README.md b/README.md index 9e7267b..df7b2f7 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ When **Steamless Mode** is active, the app disables the controller's built-in ke - Windows 10 or later (64-bit) - [ViGEmBus](https://github.com/nefarius/ViGEmBus/releases/latest) driver installed - Steam Controller — wired USB (PID `0x1302`), wireless dongle (PID `0x1304`), or Bluetooth LE (PID `0x1303`, pair via Windows Bluetooth settings) -- Steam **closed** (Steam claims the controller when running) +- Steam **closed**, or a coexistence strategy selected from the tray's debug menu ### To build - [Visual Studio 2022](https://visualstudio.microsoft.com/) with the **Desktop development with C++** workload @@ -76,13 +76,19 @@ The Steam Controller exposes a vendor HID collection (usage page `0xFF00`) that Steam normally opens the physical Steam Controller even while the client is idle. That can produce duplicate keyboard/mouse and virtual-gamepad input if SteamlessController uses the controller at the same time. -Steam's per-device controller blacklist is the narrow workaround: it leaves the Steam client and Steam Input for other controller types running, while reserving Steam Controllers for SteamlessController. Exit Steam, back up and open `config/config.vdf` under the Steam install directory, then add this property inside the outer `InstallConfigStore` object and restart Steam: +The tray's **Debug: Steam coexistence** submenu can apply one of three strategies. Hover an option to see its tradeoffs; selecting one safely releases the controller, closes Steam, applies the setting, and relaunches Steam immediately. If a Steam game is running, the app asks before closing it. + +- **Yield to Steam** makes no Steam-specific controller changes. SteamlessController stays off whenever Steam can see the physical controller. This preserves full Steam Input but Game Pass use requires closing Steam. +- **Hide Steam Controllers from Steam** is recommended for Game Pass. It adds only the three Steam Controller product IDs to Steam's per-device blacklist, leaving Steam Input available for other controller types. Steam Controller-specific layouts, gyro, and touch menus are unavailable in Steam. +- **Launch Steam with `-nojoy`** disables all Steam client controller support, including Steam Input and Big Picture controller navigation. The app adds the flag to Steam's existing Windows startup entry; launching Steam another way without the flag is detected and SteamlessController safely yields. + +The blacklist option backs up `config/config.vdf` to `config/config.vdf.steamlesscontroller.bak`, preserves unrelated blacklist entries, and manages this property inside the outer `InstallConfigStore` object: ```vdf "controller_blacklist" "28de/1302,28de/1303,28de/1304" ``` -SteamlessController verifies both the setting and Steam's current `logs/controller.txt` session before treating this as safe. If Steam has not loaded the blacklist, it still yields rather than risk duplicate input. Steam games will receive SteamlessController's virtual Xbox controller instead of the physical Steam Controller, so Steam Controller-specific Steam Input layouts, gyro, and touch menus are unavailable until the entry is removed and Steam is restarted. +SteamlessController verifies both the setting and Steam's current `logs/controller.txt` session before treating the blacklist as safe. For `-nojoy`, it verifies the running `steam.exe` command line through Windows Management Instrumentation. Missing or stale evidence always makes SteamlessController yield rather than risk duplicate input. SteamlessController sends HID feature reports to disable lizard mode, then reads the raw input reports and translates them into a virtual Xbox 360 controller via ViGEmBus. A background heartbeat re-sends the disable command every 800 ms to keep lizard mode off. diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp index e227253..dee1c3a 100644 --- a/src/app/ControllerManager.cpp +++ b/src/app/ControllerManager.cpp @@ -482,8 +482,8 @@ void ControllerManager::NotifySteamConflict() { m_alertFn(L"Steam is using the controller", L"Windows only granted shared controller access while Steam is running. " L"Steamless Mode was kept off to prevent duplicate or missing input. " - L"Exit Steam, or add the Steam Controller to Steam's controller_blacklist " - L"and restart Steam."); + L"Exit Steam, or choose Debug: Steam coexistence in the tray menu and " + L"apply the recommended controller-hiding option."); } } diff --git a/src/app/SteamStrategy.cpp b/src/app/SteamStrategy.cpp new file mode 100644 index 0000000..529fb01 --- /dev/null +++ b/src/app/SteamStrategy.cpp @@ -0,0 +1,452 @@ +#include "SteamStrategy.h" +#include "EventLog.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr wchar_t APP_REG_KEY[] = L"Software\\SteamlessController"; +constexpr wchar_t STEAM_REG_KEY[] = L"Software\\Valve\\Steam"; +constexpr wchar_t RUN_REG_KEY[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\Run"; +constexpr wchar_t STEAM_RUN_VALUE[] = L"Steam"; +constexpr wchar_t STRATEGY_VALUE[] = L"SteamStrategy"; +constexpr std::array STEAM_CONTROLLER_IDS = { + "28de/1302", "28de/1303", "28de/1304" +}; + +bool ReadRegistryString(HKEY root, const wchar_t* keyName, const wchar_t* valueName, + std::wstring& value, DWORD* typeOut = nullptr) { + DWORD type = 0; + DWORD bytes = 0; + if (RegGetValueW(root, keyName, valueName, RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ, + &type, nullptr, &bytes) != ERROR_SUCCESS + || bytes < sizeof(wchar_t)) + return false; + + value.resize(bytes / sizeof(wchar_t)); + if (RegGetValueW(root, keyName, valueName, RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ, + &type, value.data(), &bytes) != ERROR_SUCCESS) + return false; + while (!value.empty() && value.back() == L'\0') value.pop_back(); + if (typeOut) *typeOut = type; + return !value.empty(); +} + +bool ReadFileBytes(const std::wstring& path, std::string& bytes) { + HANDLE file = CreateFileW(path.c_str(), GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) return false; + + LARGE_INTEGER size{}; + if (!GetFileSizeEx(file, &size) || size.QuadPart < 0 + || size.QuadPart > static_cast(64 * 1024 * 1024)) { + CloseHandle(file); + return false; + } + + bytes.resize(static_cast(size.QuadPart)); + DWORD read = 0; + const bool ok = bytes.empty() + || (ReadFile(file, bytes.data(), static_cast(bytes.size()), &read, nullptr) + && read == static_cast(bytes.size())); + CloseHandle(file); + return ok; +} + +bool WriteFileAtomically(const std::wstring& path, const std::string& bytes) { + const std::wstring temporary = path + L".steamlesscontroller.tmp"; + HANDLE file = CreateFileW(temporary.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) return false; + + DWORD written = 0; + const bool wrote = bytes.empty() + || (WriteFile(file, bytes.data(), static_cast(bytes.size()), &written, nullptr) + && written == static_cast(bytes.size())); + const bool flushed = wrote && FlushFileBuffers(file); + CloseHandle(file); + if (!flushed) { + DeleteFileW(temporary.c_str()); + return false; + } + if (!MoveFileExW(temporary.c_str(), path.c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { + DeleteFileW(temporary.c_str()); + return false; + } + return true; +} + +std::string LowerAscii(std::string text) { + std::transform(text.begin(), text.end(), text.begin(), [](unsigned char ch) { + return static_cast(std::tolower(ch)); + }); + return text; +} + +std::string TrimAscii(std::string text) { + const size_t first = text.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) return {}; + const size_t last = text.find_last_not_of(" \t\r\n"); + return text.substr(first, last - first + 1); +} + +bool IsSteamControllerId(const std::string& value) { + const std::string lower = LowerAscii(TrimAscii(value)); + return std::any_of(STEAM_CONTROLLER_IDS.begin(), STEAM_CONTROLLER_IDS.end(), + [&](const char* id) { return lower == id; }); +} + +struct VdfValue { + bool found = false; + size_t start = 0; + size_t end = 0; + std::string value; +}; + +VdfValue FindControllerBlacklist(const std::string& config) { + const std::string lower = LowerAscii(config); + constexpr char key[] = "\"controller_blacklist\""; + const size_t keyPos = lower.rfind(key); + if (keyPos == std::string::npos) return {}; + const size_t open = config.find('"', keyPos + sizeof(key) - 1); + if (open == std::string::npos) return {}; + const size_t close = config.find('"', open + 1); + if (close == std::string::npos) return {}; + return {true, open + 1, close, config.substr(open + 1, close - open - 1)}; +} + +std::string UpdateControllerBlacklist(std::string config, bool addControllers) { + const VdfValue existing = FindControllerBlacklist(config); + std::vector entries; + if (existing.found) { + size_t start = 0; + while (start <= existing.value.size()) { + const size_t comma = existing.value.find(',', start); + std::string entry = TrimAscii(existing.value.substr( + start, comma == std::string::npos ? std::string::npos : comma - start)); + if (!entry.empty() && !IsSteamControllerId(entry)) + entries.push_back(std::move(entry)); + if (comma == std::string::npos) break; + start = comma + 1; + } + } + if (addControllers) + for (const char* id : STEAM_CONTROLLER_IDS) entries.emplace_back(id); + + std::string value; + for (const std::string& entry : entries) { + if (!value.empty()) value += ','; + value += entry; + } + + if (existing.found) { + config.replace(existing.start, existing.end - existing.start, value); + return config; + } + if (!addControllers) return config; + + const size_t close = config.find_last_of('}'); + if (close == std::string::npos) return {}; + const char* newline = config.find("\r\n") != std::string::npos ? "\r\n" : "\n"; + std::string property = std::string("\t\"controller_blacklist\"\t\t\"") + + value + "\"" + newline; + config.insert(close, property); + return config; +} + +bool GetSteamPaths(std::wstring& steamPath, std::wstring& steamExe, + std::wstring& configPath) { + if (!ReadRegistryString(HKEY_CURRENT_USER, STEAM_REG_KEY, L"SteamPath", steamPath)) + return false; + std::replace(steamPath.begin(), steamPath.end(), L'/', L'\\'); + while (!steamPath.empty() && steamPath.back() == L'\\') steamPath.pop_back(); + steamExe = steamPath + L"\\steam.exe"; + configPath = steamPath + L"\\config\\config.vdf"; + return GetFileAttributesW(steamExe.c_str()) != INVALID_FILE_ATTRIBUTES + && GetFileAttributesW(configPath.c_str()) != INVALID_FILE_ATTRIBUTES; +} + +bool IsSteamProcessRunning() { + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snap == INVALID_HANDLE_VALUE) return false; + PROCESSENTRY32W pe{}; + pe.dwSize = sizeof(pe); + bool running = false; + if (Process32FirstW(snap, &pe)) { + do { + if (_wcsicmp(pe.szExeFile, L"steam.exe") == 0) { + running = true; + break; + } + } while (Process32NextW(snap, &pe)); + } + CloseHandle(snap); + return running; +} + +std::wstring QuoteArgument(const std::wstring& argument) { + if (!argument.empty() && argument.find_first_of(L" \t\n\v\"") == std::wstring::npos) + return argument; + + std::wstring quoted = L"\""; + size_t backslashes = 0; + for (const wchar_t ch : argument) { + if (ch == L'\\') { + ++backslashes; + } else if (ch == L'"') { + quoted.append(backslashes * 2 + 1, L'\\'); + quoted += L'"'; + backslashes = 0; + } else { + quoted.append(backslashes, L'\\'); + backslashes = 0; + quoted += ch; + } + } + quoted.append(backslashes * 2, L'\\'); + quoted += L'"'; + return quoted; +} + +std::vector ParseArguments(const std::wstring& command) { + int count = 0; + LPWSTR* argv = CommandLineToArgvW(command.c_str(), &count); + if (!argv) return {}; + std::vector args; + for (int i = 0; i < count; ++i) args.emplace_back(argv[i]); + LocalFree(argv); + return args; +} + +std::wstring SteamCommand(const std::wstring& steamExe, const std::wstring& existing, + bool noJoy) { + std::vector args = ParseArguments(existing); + if (!args.empty()) args.erase(args.begin()); + args.erase(std::remove_if(args.begin(), args.end(), [](const std::wstring& arg) { + return _wcsicmp(arg.c_str(), L"-nojoy") == 0; + }), args.end()); + if (noJoy) args.emplace_back(L"-nojoy"); + if (args.empty()) args.emplace_back(L"-silent"); + + std::wstring command = QuoteArgument(steamExe); + for (const std::wstring& arg : args) + command += L" " + QuoteArgument(arg); + return command; +} + +bool WriteSteamRunCommandIfPresent(const std::wstring& command, std::wstring& error) { + HKEY key = nullptr; + if (RegOpenKeyExW(HKEY_CURRENT_USER, RUN_REG_KEY, 0, KEY_QUERY_VALUE | KEY_SET_VALUE, + &key) != ERROR_SUCCESS) { + error = L"Could not open the Windows startup registry key."; + return false; + } + + const bool exists = RegQueryValueExW(key, STEAM_RUN_VALUE, nullptr, nullptr, + nullptr, nullptr) == ERROR_SUCCESS; + bool ok = true; + if (exists) { + ok = RegSetValueExW(key, STEAM_RUN_VALUE, 0, REG_SZ, + reinterpret_cast(command.c_str()), + static_cast((command.size() + 1) * sizeof(wchar_t))) + == ERROR_SUCCESS; + if (!ok) error = L"Could not update Steam's Windows startup command."; + } + RegCloseKey(key); + return ok; +} + +bool SaveStrategy(SteamStrategy strategy) { + HKEY key = nullptr; + if (RegCreateKeyExW(HKEY_CURRENT_USER, APP_REG_KEY, 0, nullptr, + REG_OPTION_NON_VOLATILE, KEY_SET_VALUE, nullptr, &key, nullptr) + != ERROR_SUCCESS) + return false; + const DWORD value = static_cast(strategy); + const bool ok = RegSetValueExW(key, STRATEGY_VALUE, 0, REG_DWORD, + reinterpret_cast(&value), sizeof(value)) + == ERROR_SUCCESS; + RegCloseKey(key); + return ok; +} + +bool StopSteam(const std::wstring& steamExe, std::wstring& error) { + if (!IsSteamProcessRunning()) return true; + std::wstring command = QuoteArgument(steamExe) + L" -shutdown"; + STARTUPINFOW si{}; + si.cb = sizeof(si); + PROCESS_INFORMATION pi{}; + if (!CreateProcessW(nullptr, command.data(), nullptr, nullptr, FALSE, 0, + nullptr, nullptr, &si, &pi)) { + error = L"Steam could not be asked to shut down."; + return false; + } + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + + for (int waited = 0; waited < 30000; waited += 200) { + if (!IsSteamProcessRunning()) return true; + Sleep(200); + } + error = L"Steam did not finish shutting down, so no settings were changed."; + return false; +} + +bool StartSteam(std::wstring command, std::wstring& error) { + STARTUPINFOW si{}; + si.cb = sizeof(si); + PROCESS_INFORMATION pi{}; + if (!CreateProcessW(nullptr, command.data(), nullptr, nullptr, FALSE, 0, + nullptr, nullptr, &si, &pi)) { + error = L"The strategy was saved, but Steam could not be restarted."; + return false; + } + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + return true; +} + +bool ConfigContainsSteamController(const std::wstring& configPath) { + std::string config; + if (!ReadFileBytes(configPath, config)) return false; + const VdfValue value = FindControllerBlacklist(config); + if (!value.found) return false; + size_t start = 0; + while (start <= value.value.size()) { + const size_t comma = value.value.find(',', start); + if (IsSteamControllerId(value.value.substr( + start, comma == std::string::npos ? std::string::npos : comma - start))) + return true; + if (comma == std::string::npos) break; + start = comma + 1; + } + return false; +} + +bool CommandHasNoJoy(const std::wstring& command) { + const std::vector args = ParseArguments(command); + return std::any_of(args.begin(), args.end(), [](const std::wstring& arg) { + return _wcsicmp(arg.c_str(), L"-nojoy") == 0; + }); +} + +} // namespace + +namespace SteamStrategies { + +SteamStrategy DetectConfigured() { + DWORD saved = 0; + DWORD bytes = sizeof(saved); + if (RegGetValueW(HKEY_CURRENT_USER, APP_REG_KEY, STRATEGY_VALUE, + RRF_RT_REG_DWORD, nullptr, &saved, &bytes) == ERROR_SUCCESS + && saved <= static_cast(SteamStrategy::NoJoy)) + return static_cast(saved); + + std::wstring startup; + if (ReadRegistryString(HKEY_CURRENT_USER, RUN_REG_KEY, STEAM_RUN_VALUE, startup) + && CommandHasNoJoy(startup)) + return SteamStrategy::NoJoy; + + std::wstring steamPath, steamExe, configPath; + if (GetSteamPaths(steamPath, steamExe, configPath) + && ConfigContainsSteamController(configPath)) + return SteamStrategy::ControllerBlacklist; + return SteamStrategy::YieldToSteam; +} + +ApplyResult ApplyAndRestart(SteamStrategy strategy) { + std::wstring steamPath, steamExe, configPath; + if (!GetSteamPaths(steamPath, steamExe, configPath)) + return {false, L"Steam's installation or config.vdf could not be found."}; + + std::wstring existingRun; + const bool steamRunExists = ReadRegistryString( + HKEY_CURRENT_USER, RUN_REG_KEY, STEAM_RUN_VALUE, existingRun); + const std::wstring previousLaunchCommand = SteamCommand( + steamExe, existingRun, CommandHasNoJoy(existingRun)); + const std::wstring launchCommand = SteamCommand( + steamExe, existingRun, strategy == SteamStrategy::NoJoy); + + std::wstring error; + if (!StopSteam(steamExe, error)) return {false, std::move(error)}; + auto failAfterStop = [&](std::wstring message) { + std::wstring restartError; + if (!StartSteam(previousLaunchCommand, restartError)) { + message += L" Steam also could not be restarted with its previous settings."; + } + return ApplyResult{false, std::move(message)}; + }; + + std::string original; + if (!ReadFileBytes(configPath, original)) + return failAfterStop(L"Steam was stopped, but config.vdf could not be read."); + const std::wstring backupPath = configPath + L".steamlesscontroller.bak"; + if (GetFileAttributesW(backupPath.c_str()) == INVALID_FILE_ATTRIBUTES + && !CopyFileW(configPath.c_str(), backupPath.c_str(), TRUE)) + return failAfterStop(L"Steam was stopped, but config.vdf could not be backed up."); + + const std::string updated = UpdateControllerBlacklist( + original, strategy == SteamStrategy::ControllerBlacklist); + if (updated.empty()) + return failAfterStop(L"Steam was stopped, but config.vdf was not valid VDF."); + if (updated != original && !WriteFileAtomically(configPath, updated)) + return failAfterStop(L"Steam was stopped, but config.vdf could not be updated."); + + if (!WriteSteamRunCommandIfPresent(launchCommand, error)) { + if (updated != original) WriteFileAtomically(configPath, original); + return failAfterStop(std::move(error)); + } + if (!SaveStrategy(strategy)) { + if (updated != original) WriteFileAtomically(configPath, original); + if (steamRunExists) { + std::wstring ignored; + WriteSteamRunCommandIfPresent(existingRun, ignored); + } + return failAfterStop(L"The strategy could not be saved."); + } + + EventLog::Write("STEAM STRATEGY: applied %d (0=yield 1=blacklist 2=nojoy)", + static_cast(strategy)); + if (!StartSteam(launchCommand, error)) return {true, std::move(error)}; + return {true, {}}; +} + +bool IsGameRunning() { + DWORD appId = 0; + DWORD bytes = sizeof(appId); + return RegGetValueW(HKEY_CURRENT_USER, STEAM_REG_KEY, L"RunningAppID", + RRF_RT_REG_DWORD, nullptr, &appId, &bytes) == ERROR_SUCCESS + && appId != 0; +} + +bool IsNoJoySelected() { + return DetectConfigured() == SteamStrategy::NoJoy; +} + +const wchar_t* Tooltip(SteamStrategy strategy) { + switch (strategy) { + case SteamStrategy::YieldToSteam: + return L"Pros: least invasive; Steam Input, gyro, touch menus, and Big Picture " + L"controller navigation keep working.\nCons: Steamless yields whenever Steam " + L"can see the controller, so Game Pass needs Steam closed."; + case SteamStrategy::ControllerBlacklist: + return L"Pros: recommended for Game Pass; Steam stays open and other controller " + L"types keep Steam Input.\nCons: Steam Controller layouts, gyro, and touch " + L"menus in Steam are unavailable while it is hidden."; + case SteamStrategy::NoJoy: + return L"Pros: simple, driver-free, and Steam can stay open.\nCons: disables all " + L"Steam client controller support, including Steam Input and Big Picture " + L"controller navigation. Launching Steam without -nojoy bypasses it."; + } + return L""; +} + +} // namespace SteamStrategies diff --git a/src/app/SteamStrategy.h b/src/app/SteamStrategy.h new file mode 100644 index 0000000..b0dbe6a --- /dev/null +++ b/src/app/SteamStrategy.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +enum class SteamStrategy { + YieldToSteam = 0, + ControllerBlacklist = 1, + NoJoy = 2, +}; + +namespace SteamStrategies { + +struct ApplyResult { + bool applied = false; + std::wstring message; +}; + +// Loads the saved choice, or infers it from Steam's current config for +// installations that predate this setting. +SteamStrategy DetectConfigured(); + +// Applies the selected strategy, stops Steam cleanly, and starts it again. +// Steam is always relaunched so the new controller policy takes effect now. +ApplyResult ApplyAndRestart(SteamStrategy strategy); + +bool IsGameRunning(); +bool IsNoJoySelected(); +const wchar_t* Tooltip(SteamStrategy strategy); + +} // namespace SteamStrategies diff --git a/src/app/SteamWatcher.cpp b/src/app/SteamWatcher.cpp index da8f588..4b4617a 100644 --- a/src/app/SteamWatcher.cpp +++ b/src/app/SteamWatcher.cpp @@ -1,10 +1,14 @@ #include "SteamWatcher.h" #include "EventLog.h" +#include "SteamStrategy.h" #include #include +#include +#include #include #include #include +#include static constexpr int POLL_INTERVAL_MS = 2000; static constexpr int POLL_SLICE_MS = 100; // wake often for fast Stop() @@ -12,6 +16,7 @@ static constexpr int LESS_STEAM_POLL_COUNT = 3; // ~6s stable before we take struct SteamProcess { bool running = false; + DWORD pid = 0; FILETIME started{}; }; @@ -35,6 +40,7 @@ static SteamProcess FindSteamProcess() { SteamProcess result{}; result.running = true; + result.pid = pid; HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); if (!process) return result; @@ -44,6 +50,87 @@ static SteamProcess FindSteamProcess() { return result; } +static bool CommandLineHasNoJoy(const std::wstring& commandLine) { + int count = 0; + LPWSTR* argv = CommandLineToArgvW(commandLine.c_str(), &count); + if (!argv) return false; + bool found = false; + for (int i = 1; i < count; ++i) { + if (_wcsicmp(argv[i], L"-nojoy") == 0) { + found = true; + break; + } + } + LocalFree(argv); + return found; +} + +// Win32_Process.CommandLine is a documented Windows source for another +// process's command line. It lets the watcher prove the running Steam session +// really has -nojoy instead of trusting a stale startup setting. +static bool SteamProcessHasNoJoy(DWORD pid) { + IWbemLocator* locator = nullptr; + IWbemServices* services = nullptr; + IEnumWbemClassObject* results = nullptr; + IWbemClassObject* object = nullptr; + bool hasNoJoy = false; + + HRESULT hr = CoCreateInstance(CLSID_WbemLocator, nullptr, CLSCTX_INPROC_SERVER, + IID_IWbemLocator, + reinterpret_cast(&locator)); + if (FAILED(hr)) goto cleanup; + + { + BSTR namespaceName = SysAllocString(L"ROOT\\CIMV2"); + hr = namespaceName + ? locator->ConnectServer(namespaceName, nullptr, nullptr, nullptr, 0, + nullptr, nullptr, &services) + : E_OUTOFMEMORY; + SysFreeString(namespaceName); + } + if (FAILED(hr)) goto cleanup; + + hr = CoSetProxyBlanket(services, RPC_C_AUTHN_WINNT, RPC_C_AUTHZ_NONE, nullptr, + RPC_C_AUTHN_LEVEL_CALL, RPC_C_IMP_LEVEL_IMPERSONATE, + nullptr, EOAC_NONE); + if (FAILED(hr)) goto cleanup; + + { + const std::wstring queryText = L"SELECT CommandLine FROM Win32_Process WHERE ProcessId=" + + std::to_wstring(pid); + BSTR language = SysAllocString(L"WQL"); + BSTR query = SysAllocString(queryText.c_str()); + hr = language && query + ? services->ExecQuery(language, query, + WBEM_FLAG_FORWARD_ONLY | WBEM_FLAG_RETURN_IMMEDIATELY, + nullptr, &results) + : E_OUTOFMEMORY; + SysFreeString(language); + SysFreeString(query); + } + if (FAILED(hr) || !results) goto cleanup; + + { + ULONG returned = 0; + hr = results->Next(2000, 1, &object, &returned); + if (FAILED(hr) || returned != 1 || !object) goto cleanup; + + VARIANT value{}; + VariantInit(&value); + hr = object->Get(L"CommandLine", 0, &value, nullptr, nullptr); + if (SUCCEEDED(hr) && V_VT(&value) == VT_BSTR && V_BSTR(&value)) + hasNoJoy = CommandLineHasNoJoy(V_BSTR(&value)); + VariantClear(&value); + } + +cleanup: + if (object) object->Release(); + if (results) results->Release(); + if (services) services->Release(); + if (locator) locator->Release(); + return hasNoJoy; +} + static bool ReadRegistryString(HKEY root, const wchar_t* subkey, const wchar_t* name, std::wstring& value) { DWORD bytes = 0; @@ -189,6 +276,14 @@ static bool IsGameRunning() { SteamState SteamWatcher::Detect() { const SteamProcess steam = FindSteamProcess(); if (!steam.running) return SteamState::NoSteam; + if (SteamStrategies::IsNoJoySelected()) { + if (SteamProcessHasNoJoy(steam.pid)) { + ReportControllerHidden(true, "confirmed Steam -nojoy command line"); + return SteamState::ControllerHidden; + } + ReportControllerHidden(false, "selected -nojoy is absent from running Steam"); + return IsGameRunning() ? SteamState::InGame : SteamState::SteamIdle; + } if (IsControllerHiddenFromSteam(steam)) return SteamState::ControllerHidden; return IsGameRunning() ? SteamState::InGame : SteamState::SteamIdle; } @@ -206,6 +301,7 @@ void SteamWatcher::Stop() { } void SteamWatcher::PollLoop() { + const HRESULT com = CoInitializeEx(nullptr, COINIT_MULTITHREADED); SteamState reported = Detect(); m_state = reported; if (m_onChange) m_onChange(reported); @@ -247,4 +343,5 @@ void SteamWatcher::PollLoop() { } } } + if (SUCCEEDED(com)) CoUninitialize(); } diff --git a/src/app/TrayApp.cpp b/src/app/TrayApp.cpp index 2d8a1b5..7ab6b70 100644 --- a/src/app/TrayApp.cpp +++ b/src/app/TrayApp.cpp @@ -3,9 +3,11 @@ #include "ControllerPlatform.h" #include "DeviceRestart.h" #include "EventLog.h" +#include "SteamStrategy.h" #include "steam/SteamController.h" #include "resource.h" #include +#include #include #include #include @@ -57,6 +59,7 @@ TrayApp::~TrayApp() { // Stop the watcher before anything else so its callback can't post to a // window (or reference a controller) that is being torn down. m_steamWatcher.Stop(); + if (m_menuTooltip) DestroyWindow(m_menuTooltip); RemoveTrayIcon(); g_app = nullptr; } @@ -79,6 +82,8 @@ bool TrayApp::Init(HINSTANCE hInstance) { 0, 0, 0, 0, 0, HWND_MESSAGE, nullptr, hInstance, nullptr); if (!m_hwnd) return false; + CreateMenuTooltip(); + // Register for HID device arrival/removal notifications. DEV_BROADCAST_DEVICEINTERFACE_W filter{}; filter.dbcc_size = sizeof(filter); @@ -104,6 +109,7 @@ bool TrayApp::Init(HINSTANCE hInstance) { }); LoadSettings(); + m_steamStrategy = SteamStrategies::DetectConfigured(); // Converge the startup mechanism with the loaded mode — e.g. the first // elevated run after enabling in-game mode migrates the Run key to the // highest-privileges logon task (and back when the mode changes). @@ -221,6 +227,15 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { m_notificationsEnabled ? "enabled" : "disabled"); SaveSettings(); break; + case IDM_STEAM_YIELD: + ApplySteamStrategy(SteamStrategy::YieldToSteam); + break; + case IDM_STEAM_BLACKLIST: + ApplySteamStrategy(SteamStrategy::ControllerBlacklist); + break; + case IDM_STEAM_NOJOY: + ApplySteamStrategy(SteamStrategy::NoJoy); + break; case IDM_EXIT: EventLog::Write("=== SteamlessController exiting (user request) ==="); m_controller->DisableGameMode(); @@ -229,6 +244,20 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { } return 0; + case WM_MENUSELECT: { + const UINT flags = HIWORD(wp); + const UINT commandId = LOWORD(wp); + if (flags == 0xFFFF || (flags & MF_POPUP) != 0) + HideMenuTooltip(); + else + ShowMenuTooltip(commandId); + return 0; + } + + case WM_EXITMENULOOP: + HideMenuTooltip(); + return 0; + case WM_STEAMSTATE: { const auto steamState = static_cast(wp); // Outside ApplySteamState, which returns early in Manual mode. A shared @@ -302,6 +331,100 @@ bool TrayApp::WantControl(SteamState state) const { } } +void TrayApp::ApplySteamStrategy(SteamStrategy strategy) { + if (SteamStrategies::IsGameRunning()) { + const int answer = MessageBoxW( + m_hwnd, + L"A Steam game is running. Applying this option will close the game when " + L"Steam restarts. Apply it now?", + L"Restart Steam", + MB_ICONWARNING | MB_YESNO | MB_DEFBUTTON2); + if (answer != IDYES) return; + } + + EventLog::Write("USER: applying Steam coexistence strategy %d", + static_cast(strategy)); + KillTimer(m_hwnd, IDT_ACQUIRE); + m_acquireRetries = 0; + m_steamWatcher.Stop(); + + // Steam must close before config.vdf is edited, and releasing first gives + // lizard mode a chance to come back while neither application owns input. + m_controller->ReleaseDevices(); + MSG stale{}; + while (PeekMessageW(&stale, m_hwnd, WM_STEAMSTATE, WM_STEAMSTATE, PM_REMOVE)) {} + + const SteamStrategies::ApplyResult result = SteamStrategies::ApplyAndRestart(strategy); + if (result.applied) m_steamStrategy = strategy; + + m_steamWatcher.Start([this](SteamState state) { + PostMessageW(m_hwnd, WM_STEAMSTATE, static_cast(state), 0); + }); + + if (!result.message.empty()) { + MessageBoxW(m_hwnd, result.message.c_str(), + result.applied ? L"Steam restart warning" : L"Steam strategy error", + MB_OK | (result.applied ? MB_ICONWARNING : MB_ICONERROR)); + } +} + +void TrayApp::CreateMenuTooltip() { + INITCOMMONCONTROLSEX controls{}; + controls.dwSize = sizeof(controls); + controls.dwICC = ICC_WIN95_CLASSES; + InitCommonControlsEx(&controls); + + m_menuTooltip = CreateWindowExW( + WS_EX_TOPMOST, TOOLTIPS_CLASSW, nullptr, + WS_POPUP | TTS_ALWAYSTIP | TTS_NOPREFIX, + CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, + m_hwnd, nullptr, m_hInstance, nullptr); + if (!m_menuTooltip) return; + + SetWindowPos(m_menuTooltip, HWND_TOPMOST, 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + SendMessageW(m_menuTooltip, TTM_SETMAXTIPWIDTH, 0, 430); + SendMessageW(m_menuTooltip, TTM_SETDELAYTIME, TTDT_AUTOPOP, 30000); + + m_menuTooltipInfo.cbSize = sizeof(m_menuTooltipInfo); + m_menuTooltipInfo.uFlags = TTF_TRACK | TTF_ABSOLUTE; + m_menuTooltipInfo.hwnd = m_hwnd; + m_menuTooltipInfo.uId = 1; + m_menuTooltipInfo.lpszText = const_cast(L""); + SendMessageW(m_menuTooltip, TTM_ADDTOOLW, 0, + reinterpret_cast(&m_menuTooltipInfo)); +} + +void TrayApp::ShowMenuTooltip(UINT commandId) { + SteamStrategy strategy; + switch (commandId) { + case IDM_STEAM_YIELD: strategy = SteamStrategy::YieldToSteam; break; + case IDM_STEAM_BLACKLIST: strategy = SteamStrategy::ControllerBlacklist; break; + case IDM_STEAM_NOJOY: strategy = SteamStrategy::NoJoy; break; + default: + HideMenuTooltip(); + return; + } + if (!m_menuTooltip) return; + + m_menuTooltipText = SteamStrategies::Tooltip(strategy); + m_menuTooltipInfo.lpszText = m_menuTooltipText.data(); + SendMessageW(m_menuTooltip, TTM_UPDATETIPTEXTW, 0, + reinterpret_cast(&m_menuTooltipInfo)); + POINT cursor{}; + GetCursorPos(&cursor); + SendMessageW(m_menuTooltip, TTM_TRACKPOSITION, 0, + MAKELPARAM(cursor.x + 24, cursor.y + 20)); + SendMessageW(m_menuTooltip, TTM_TRACKACTIVATE, TRUE, + reinterpret_cast(&m_menuTooltipInfo)); +} + +void TrayApp::HideMenuTooltip() { + if (m_menuTooltip) + SendMessageW(m_menuTooltip, TTM_TRACKACTIVATE, FALSE, + reinterpret_cast(&m_menuTooltipInfo)); +} + void TrayApp::ApplySteamState(SteamState state) { if (m_autoMode == AutoMode::Manual) return; @@ -778,6 +901,21 @@ void TrayApp::ShowContextMenu() { AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); + HMENU debugMenu = CreatePopupMenu(); + AppendMenuW(debugMenu, MF_STRING, IDM_STEAM_YIELD, L"Yield to Steam"); + AppendMenuW(debugMenu, MF_STRING, IDM_STEAM_BLACKLIST, + L"Hide Steam Controllers from Steam (recommended)"); + AppendMenuW(debugMenu, MF_STRING, IDM_STEAM_NOJOY, L"Launch Steam with -nojoy"); + CheckMenuRadioItem(debugMenu, IDM_STEAM_YIELD, IDM_STEAM_NOJOY, + m_steamStrategy == SteamStrategy::YieldToSteam ? IDM_STEAM_YIELD + : m_steamStrategy == SteamStrategy::ControllerBlacklist + ? IDM_STEAM_BLACKLIST : IDM_STEAM_NOJOY, + MF_BYCOMMAND); + AppendMenuW(menu, MF_STRING | MF_POPUP, + reinterpret_cast(debugMenu), L"Debug: Steam coexistence"); + + AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); + AppendMenuW(menu, MF_STRING | (startupOn ? MF_CHECKED : MF_UNCHECKED), IDM_STARTUP, L"Start with Windows"); diff --git a/src/app/TrayApp.h b/src/app/TrayApp.h index bad01ce..8f71632 100644 --- a/src/app/TrayApp.h +++ b/src/app/TrayApp.h @@ -1,9 +1,11 @@ #pragma once #include +#include #include #include #include #include "RemapWindow.h" +#include "SteamStrategy.h" #include "SteamWatcher.h" class ControllerManager; @@ -38,6 +40,10 @@ class TrayApp { void LoadSettings(); void SaveSettings(); void OpenRemapWindow(); + void ApplySteamStrategy(SteamStrategy strategy); + void CreateMenuTooltip(); + void ShowMenuTooltip(UINT commandId); + void HideMenuTooltip(); // The tray app never runs elevated (an elevated foreground window blocks // unelevated SendInput — Steam Input's desktop cursor — via UIPI). @@ -66,6 +72,10 @@ class TrayApp { HICON m_iconOff = nullptr; HICON m_iconOn = nullptr; AutoMode m_autoMode = AutoMode::OffWhileSteam; + SteamStrategy m_steamStrategy = SteamStrategy::YieldToSteam; + HWND m_menuTooltip = nullptr; + TTTOOLINFOW m_menuTooltipInfo{}; + std::wstring m_menuTooltipText; int m_acquireRetries = 0; bool m_elevationBalloonShown = false; bool m_startupEnabled = false; @@ -92,6 +102,9 @@ class TrayApp { static constexpr UINT IDM_MODE_GAME = 1012; static constexpr UINT IDM_OPENLOG = 1013; static constexpr UINT IDM_NOTIFICATIONS = 1014; + static constexpr UINT IDM_STEAM_YIELD = 1020; + static constexpr UINT IDM_STEAM_BLACKLIST = 1021; + static constexpr UINT IDM_STEAM_NOJOY = 1022; static constexpr UINT WM_TRAY = WM_APP + 1; static constexpr UINT WM_STEAMSTATE = WM_APP + 2; static constexpr UINT WM_ALERT = WM_APP + 3; From 0245f7f406b80457781f659a6f5e94ec2b46750d Mon Sep 17 00:00:00 2001 From: Dan Bosscher Date: Mon, 3 Aug 2026 11:20:48 +0100 Subject: [PATCH 08/19] Restore Steam settings when disabling or uninstalling --- README.md | 8 +- resources/InnoInstallerScript.iss | 26 +++ src/app/SteamStrategy.cpp | 265 +++++++++++++++++++++++++----- src/app/SteamStrategy.h | 7 + src/app/TrayApp.cpp | 36 +++- src/app/TrayApp.h | 1 + src/app/main.cpp | 9 +- 7 files changed, 302 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index df7b2f7..911835a 100644 --- a/README.md +++ b/README.md @@ -78,9 +78,9 @@ Steam normally opens the physical Steam Controller even while the client is idle The tray's **Debug: Steam coexistence** submenu can apply one of three strategies. Hover an option to see its tradeoffs; selecting one safely releases the controller, closes Steam, applies the setting, and relaunches Steam immediately. If a Steam game is running, the app asks before closing it. -- **Yield to Steam** makes no Steam-specific controller changes. SteamlessController stays off whenever Steam can see the physical controller. This preserves full Steam Input but Game Pass use requires closing Steam. -- **Hide Steam Controllers from Steam** is recommended for Game Pass. It adds only the three Steam Controller product IDs to Steam's per-device blacklist, leaving Steam Input available for other controller types. Steam Controller-specific layouts, gyro, and touch menus are unavailable in Steam. -- **Launch Steam with `-nojoy`** disables all Steam client controller support, including Steam Input and Big Picture controller navigation. The app adds the flag to Steam's existing Windows startup entry; launching Steam another way without the flag is detected and SteamlessController safely yields. +- **Restore Steam's original controller settings** is the explicit off switch. It restores the Valve PID blacklist and `-nojoy` state recorded before SteamlessController first changed them, then SteamlessController yields whenever Steam can see the physical controller. +- **Reserve Steam Controller for Steamless** is recommended for Game Pass. It adds only the three Steam Controller product IDs to Steam's per-device blacklist, leaving Steam Input available for other controller types. Steam Controller-specific layouts, gyro, and touch menus are unavailable in Steam. +- **Disable all Steam controller support (`-nojoy`)** disables all Steam client controller support, including Steam Input and Big Picture controller navigation. The app adds the flag to Steam's existing Windows startup entry; launching Steam another way without the flag is detected and SteamlessController safely yields. The blacklist option backs up `config/config.vdf` to `config/config.vdf.steamlesscontroller.bak`, preserves unrelated blacklist entries, and manages this property inside the outer `InstallConfigStore` object: @@ -90,6 +90,8 @@ The blacklist option backs up `config/config.vdf` to `config/config.vdf.steamles SteamlessController verifies both the setting and Steam's current `logs/controller.txt` session before treating the blacklist as safe. For `-nojoy`, it verifies the running `steam.exe` command line through Windows Management Instrumentation. Missing or stale evidence always makes SteamlessController yield rather than risk duplicate input. +Before its first strategy change, SteamlessController records whether each Valve PID and `-nojoy` were already configured. Uninstall closes the tray app, restores that recorded Steam baseline, removes the dedicated backup, Windows startup entry, and settings, and only then removes the application files. Steam is restarted if it was running; if restoration fails, uninstall stops so it can be retried. + SteamlessController sends HID feature reports to disable lizard mode, then reads the raw input reports and translates them into a virtual Xbox 360 controller via ViGEmBus. A background heartbeat re-sends the disable command every 800 ms to keep lizard mode off. The full input report layout is documented in [`src/steam/SteamController.h`](src/steam/SteamController.h). diff --git a/resources/InnoInstallerScript.iss b/resources/InnoInstallerScript.iss index 3b6d2a2..f76a939 100644 --- a/resources/InnoInstallerScript.iss +++ b/resources/InnoInstallerScript.iss @@ -22,6 +22,9 @@ AppSupportURL={#MyAppURL} AppUpdatesURL={#MyAppURL} DefaultDirName={autopf}\{#MyAppName} UninstallDisplayIcon={app}\{#MyAppExeName} +; Setup and Uninstall must not replace/remove the tray app while it is still +; watching Steam or holding the controller. +AppMutex=SteamlessController_SingleInstance SourceDir=.. ; "ArchitecturesAllowed=x64compatible" specifies that Setup cannot run ; on anything but x64 and Windows 11 on Arm. @@ -65,3 +68,26 @@ Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChang [UninstallRun] Filename: "{app}\SteamlessDeviceCycle.exe"; Parameters: "--unregister"; RunOnceId: "RemoveCycleTask"; Flags: waituntilterminated runhidden +[Code] +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +var + ResultCode: Integer; +begin + if CurUninstallStep = usUninstall then + begin + { Restore the user's original Steam blacklist/startup state before the + executable and its recorded baseline are removed. Abort leaves the app + installed so cleanup can be retried instead of silently orphaning a + persistent Steam setting. } + if (not Exec(ExpandConstant('{app}\{#MyAppExeName}'), + '--uninstall-cleanup', '', SW_HIDE, + ewWaitUntilTerminated, ResultCode)) or (ResultCode <> 0) then + begin + MsgBox('SteamlessController could not restore Steam''s original controller settings. ' + + 'Steam was restarted with its previous settings; please close Steam and retry uninstall.', + mbError, MB_OK); + Abort; + end; + end; +end; + diff --git a/src/app/SteamStrategy.cpp b/src/app/SteamStrategy.cpp index 529fb01..c92d5f8 100644 --- a/src/app/SteamStrategy.cpp +++ b/src/app/SteamStrategy.cpp @@ -15,10 +15,16 @@ constexpr wchar_t APP_REG_KEY[] = L"Software\\SteamlessController"; constexpr wchar_t STEAM_REG_KEY[] = L"Software\\Valve\\Steam"; constexpr wchar_t RUN_REG_KEY[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\Run"; constexpr wchar_t STEAM_RUN_VALUE[] = L"Steam"; +constexpr wchar_t APP_RUN_VALUE[] = L"SteamlessController"; constexpr wchar_t STRATEGY_VALUE[] = L"SteamStrategy"; +constexpr wchar_t BASELINE_CAPTURED_VALUE[] = L"SteamBaselineCaptured"; +constexpr wchar_t BASELINE_BLACKLIST_VALUE[] = L"SteamBaselineBlacklistMask"; +constexpr wchar_t BASELINE_NOJOY_VALUE[] = L"SteamBaselineNoJoy"; constexpr std::array STEAM_CONTROLLER_IDS = { "28de/1302", "28de/1303", "28de/1304" }; +constexpr DWORD ALL_STEAM_CONTROLLER_IDS = + (1u << STEAM_CONTROLLER_IDS.size()) - 1u; bool ReadRegistryString(HKEY root, const wchar_t* keyName, const wchar_t* valueName, std::wstring& value, DWORD* typeOut = nullptr) { @@ -98,10 +104,11 @@ std::string TrimAscii(std::string text) { return text.substr(first, last - first + 1); } -bool IsSteamControllerId(const std::string& value) { +DWORD SteamControllerIdBit(const std::string& value) { const std::string lower = LowerAscii(TrimAscii(value)); - return std::any_of(STEAM_CONTROLLER_IDS.begin(), STEAM_CONTROLLER_IDS.end(), - [&](const char* id) { return lower == id; }); + for (size_t i = 0; i < STEAM_CONTROLLER_IDS.size(); ++i) + if (lower == STEAM_CONTROLLER_IDS[i]) return 1u << i; + return 0; } struct VdfValue { @@ -123,7 +130,25 @@ VdfValue FindControllerBlacklist(const std::string& config) { return {true, open + 1, close, config.substr(open + 1, close - open - 1)}; } -std::string UpdateControllerBlacklist(std::string config, bool addControllers) { +DWORD ControllerBlacklistValueMask(const std::string& value) { + DWORD mask = 0; + size_t start = 0; + while (start <= value.size()) { + const size_t comma = value.find(',', start); + mask |= SteamControllerIdBit(value.substr( + start, comma == std::string::npos ? std::string::npos : comma - start)); + if (comma == std::string::npos) break; + start = comma + 1; + } + return mask; +} + +DWORD ControllerBlacklistConfigMask(const std::string& config) { + const VdfValue value = FindControllerBlacklist(config); + return value.found ? ControllerBlacklistValueMask(value.value) : 0; +} + +std::string UpdateControllerBlacklist(std::string config, DWORD desiredControllerMask) { const VdfValue existing = FindControllerBlacklist(config); std::vector entries; if (existing.found) { @@ -132,14 +157,15 @@ std::string UpdateControllerBlacklist(std::string config, bool addControllers) { const size_t comma = existing.value.find(',', start); std::string entry = TrimAscii(existing.value.substr( start, comma == std::string::npos ? std::string::npos : comma - start)); - if (!entry.empty() && !IsSteamControllerId(entry)) + if (!entry.empty() && SteamControllerIdBit(entry) == 0) entries.push_back(std::move(entry)); if (comma == std::string::npos) break; start = comma + 1; } } - if (addControllers) - for (const char* id : STEAM_CONTROLLER_IDS) entries.emplace_back(id); + for (size_t i = 0; i < STEAM_CONTROLLER_IDS.size(); ++i) + if ((desiredControllerMask & (1u << i)) != 0) + entries.emplace_back(STEAM_CONTROLLER_IDS[i]); std::string value; for (const std::string& entry : entries) { @@ -151,7 +177,7 @@ std::string UpdateControllerBlacklist(std::string config, bool addControllers) { config.replace(existing.start, existing.end - existing.start, value); return config; } - if (!addControllers) return config; + if (desiredControllerMask == 0) return config; const size_t close = config.find_last_of('}'); if (close == std::string::npos) return {}; @@ -226,6 +252,13 @@ std::vector ParseArguments(const std::wstring& command) { return args; } +bool CommandHasNoJoy(const std::wstring& command) { + const std::vector args = ParseArguments(command); + return std::any_of(args.begin(), args.end(), [](const std::wstring& arg) { + return _wcsicmp(arg.c_str(), L"-nojoy") == 0; + }); +} + std::wstring SteamCommand(const std::wstring& steamExe, const std::wstring& existing, bool noJoy) { std::vector args = ParseArguments(existing); @@ -264,6 +297,80 @@ bool WriteSteamRunCommandIfPresent(const std::wstring& command, std::wstring& er return ok; } +bool ReadAppDword(const wchar_t* name, DWORD& value) { + DWORD bytes = sizeof(value); + return RegGetValueW(HKEY_CURRENT_USER, APP_REG_KEY, name, RRF_RT_REG_DWORD, + nullptr, &value, &bytes) == ERROR_SUCCESS; +} + +bool WriteAppDword(HKEY key, const wchar_t* name, DWORD value) { + return RegSetValueExW(key, name, 0, REG_DWORD, + reinterpret_cast(&value), sizeof(value)) + == ERROR_SUCCESS; +} + +bool HasSavedStrategy() { + DWORD strategy = 0; + return ReadAppDword(STRATEGY_VALUE, strategy) + && strategy <= static_cast(SteamStrategy::NoJoy); +} + +struct SteamBaseline { + DWORD blacklistMask = 0; + bool noJoy = false; +}; + +bool LoadBaseline(SteamBaseline& baseline) { + DWORD captured = 0; + DWORD noJoy = 0; + if (!ReadAppDword(BASELINE_CAPTURED_VALUE, captured) || captured == 0 + || !ReadAppDword(BASELINE_BLACKLIST_VALUE, baseline.blacklistMask) + || !ReadAppDword(BASELINE_NOJOY_VALUE, noJoy)) + return false; + baseline.noJoy = noJoy != 0; + return true; +} + +bool SaveBaseline(const SteamBaseline& baseline) { + HKEY key = nullptr; + if (RegCreateKeyExW(HKEY_CURRENT_USER, APP_REG_KEY, 0, nullptr, + REG_OPTION_NON_VOLATILE, KEY_SET_VALUE, nullptr, &key, nullptr) + != ERROR_SUCCESS) + return false; + + // Captured is written last so a partial write is never treated as a + // complete restore point. + const bool ok = WriteAppDword(key, BASELINE_BLACKLIST_VALUE, baseline.blacklistMask) + && WriteAppDword(key, BASELINE_NOJOY_VALUE, baseline.noJoy ? 1 : 0) + && WriteAppDword(key, BASELINE_CAPTURED_VALUE, 1); + RegCloseKey(key); + return ok; +} + +bool EnsureBaseline(const std::string& currentConfig, const std::wstring& startupCommand, + SteamBaseline& baseline) { + if (LoadBaseline(baseline)) return true; + + // a1f9b80 could already have applied a strategy before baseline tracking + // existed. That version explicitly managed all three Valve IDs and + // -nojoy, so migrate them as Steamless-owned. Fresh installs capture the + // actual pre-management state and restore it on uninstall. + const bool migrating = HasSavedStrategy(); + baseline.blacklistMask = migrating ? 0 : ControllerBlacklistConfigMask(currentConfig); + baseline.noJoy = !migrating && CommandHasNoJoy(startupCommand); + return SaveBaseline(baseline); +} + +void DeleteSteamlessStartupAndSettings() { + HKEY runKey = nullptr; + if (RegOpenKeyExW(HKEY_CURRENT_USER, RUN_REG_KEY, 0, KEY_SET_VALUE, &runKey) + == ERROR_SUCCESS) { + RegDeleteValueW(runKey, APP_RUN_VALUE); + RegCloseKey(runKey); + } + RegDeleteTreeW(HKEY_CURRENT_USER, APP_REG_KEY); +} + bool SaveStrategy(SteamStrategy strategy) { HKEY key = nullptr; if (RegCreateKeyExW(HKEY_CURRENT_USER, APP_REG_KEY, 0, nullptr, @@ -317,25 +424,7 @@ bool StartSteam(std::wstring command, std::wstring& error) { bool ConfigContainsSteamController(const std::wstring& configPath) { std::string config; if (!ReadFileBytes(configPath, config)) return false; - const VdfValue value = FindControllerBlacklist(config); - if (!value.found) return false; - size_t start = 0; - while (start <= value.value.size()) { - const size_t comma = value.value.find(',', start); - if (IsSteamControllerId(value.value.substr( - start, comma == std::string::npos ? std::string::npos : comma - start))) - return true; - if (comma == std::string::npos) break; - start = comma + 1; - } - return false; -} - -bool CommandHasNoJoy(const std::wstring& command) { - const std::vector args = ParseArguments(command); - return std::any_of(args.begin(), args.end(), [](const std::wstring& arg) { - return _wcsicmp(arg.c_str(), L"-nojoy") == 0; - }); + return ControllerBlacklistConfigMask(config) != 0; } } // namespace @@ -370,11 +459,9 @@ ApplyResult ApplyAndRestart(SteamStrategy strategy) { std::wstring existingRun; const bool steamRunExists = ReadRegistryString( HKEY_CURRENT_USER, RUN_REG_KEY, STEAM_RUN_VALUE, existingRun); + const bool previousNoJoy = CommandHasNoJoy(existingRun); const std::wstring previousLaunchCommand = SteamCommand( - steamExe, existingRun, CommandHasNoJoy(existingRun)); - const std::wstring launchCommand = SteamCommand( - steamExe, existingRun, strategy == SteamStrategy::NoJoy); - + steamExe, existingRun, previousNoJoy); std::wstring error; if (!StopSteam(steamExe, error)) return {false, std::move(error)}; auto failAfterStop = [&](std::wstring message) { @@ -393,8 +480,17 @@ ApplyResult ApplyAndRestart(SteamStrategy strategy) { && !CopyFileW(configPath.c_str(), backupPath.c_str(), TRUE)) return failAfterStop(L"Steam was stopped, but config.vdf could not be backed up."); - const std::string updated = UpdateControllerBlacklist( - original, strategy == SteamStrategy::ControllerBlacklist); + SteamBaseline baseline; + if (!EnsureBaseline(original, existingRun, baseline)) + return failAfterStop(L"Steam was stopped, but its original settings could not be saved."); + + const DWORD desiredBlacklist = strategy == SteamStrategy::ControllerBlacklist + ? ALL_STEAM_CONTROLLER_IDS + : strategy == SteamStrategy::YieldToSteam ? baseline.blacklistMask : 0; + const bool desiredNoJoy = strategy == SteamStrategy::NoJoy + || (strategy == SteamStrategy::YieldToSteam && baseline.noJoy); + const std::wstring launchCommand = SteamCommand(steamExe, existingRun, desiredNoJoy); + const std::string updated = UpdateControllerBlacklist(original, desiredBlacklist); if (updated.empty()) return failAfterStop(L"Steam was stopped, but config.vdf was not valid VDF."); if (updated != original && !WriteFileAtomically(configPath, updated)) @@ -419,6 +515,64 @@ ApplyResult ApplyAndRestart(SteamStrategy strategy) { return {true, {}}; } +ApplyResult CleanupForUninstall() { + SteamBaseline baseline; + const bool hasBaseline = LoadBaseline(baseline); + const bool hasStrategy = HasSavedStrategy(); + std::wstring steamPath, steamExe, configPath; + if (!GetSteamPaths(steamPath, steamExe, configPath)) { + // Steam itself is gone, so there is no persistent Steam config left to + // restore. Still remove SteamlessController's per-user state. + DeleteSteamlessStartupAndSettings(); + return {true, {}}; + } + const std::wstring backupPath = configPath + L".steamlesscontroller.bak"; + if (!hasBaseline && !hasStrategy) { + DeleteFileW(backupPath.c_str()); + DeleteSteamlessStartupAndSettings(); + return {true, {}}; + } + + std::wstring existingRun; + ReadRegistryString(HKEY_CURRENT_USER, RUN_REG_KEY, STEAM_RUN_VALUE, existingRun); + const bool steamWasRunning = IsSteamProcessRunning(); + const std::wstring previousLaunchCommand = SteamCommand( + steamExe, existingRun, CommandHasNoJoy(existingRun)); + std::wstring error; + if (!StopSteam(steamExe, error)) return {false, std::move(error)}; + auto failAfterStop = [&](std::wstring message) { + if (steamWasRunning) { + std::wstring restartError; + if (!StartSteam(previousLaunchCommand, restartError)) + message += L" Steam also could not be restarted with its previous settings."; + } + return ApplyResult{false, std::move(message)}; + }; + + std::string original; + if (!ReadFileBytes(configPath, original)) + return failAfterStop(L"Steam was stopped, but config.vdf could not be read for cleanup."); + if (!EnsureBaseline(original, existingRun, baseline)) + return failAfterStop(L"Steam was stopped, but its original settings could not be recovered."); + + const std::string restored = UpdateControllerBlacklist(original, baseline.blacklistMask); + if (restored.empty() || (restored != original && !WriteFileAtomically(configPath, restored))) + return failAfterStop(L"Steam was stopped, but its controller blacklist could not be restored."); + + const std::wstring restoredRun = SteamCommand(steamExe, existingRun, baseline.noJoy); + if (!WriteSteamRunCommandIfPresent(restoredRun, error)) { + if (restored != original) WriteFileAtomically(configPath, original); + return failAfterStop(std::move(error)); + } + + EventLog::Write("STEAM STRATEGY: restored pre-Steamless settings for uninstall"); + DeleteFileW(backupPath.c_str()); + DeleteSteamlessStartupAndSettings(); + if (steamWasRunning && !StartSteam(restoredRun, error)) + return {true, std::move(error)}; + return {true, {}}; +} + bool IsGameRunning() { DWORD appId = 0; DWORD bytes = sizeof(appId); @@ -434,17 +588,44 @@ bool IsNoJoySelected() { const wchar_t* Tooltip(SteamStrategy strategy) { switch (strategy) { case SteamStrategy::YieldToSteam: - return L"Pros: least invasive; Steam Input, gyro, touch menus, and Big Picture " - L"controller navigation keep working.\nCons: Steamless yields whenever Steam " - L"can see the controller, so Game Pass needs Steam closed."; + return L"Restores the Steam Controller blacklist and -nojoy state recorded before " + L"SteamlessController first changed them. This is the explicit off switch; " + L"Steamless then yields whenever Steam can see the physical controller."; + case SteamStrategy::ControllerBlacklist: + return L"Persists across Steam restarts. Steam stays open and other controller " + L"types keep Steam Input, but Steam Controller layouts, gyro, and touch " + L"menus in Steam are unavailable. Choose 'Restore Steam's original " + L"controller settings' to undo it; uninstall also restores the baseline."; + case SteamStrategy::NoJoy: + return L"Adds -nojoy to Steam's existing Windows startup command. Steam can stay " + L"open, but all Steam Input and Big Picture controller navigation are " + L"disabled. Launching Steam another way without the flag bypasses it. " + L"Choose 'Restore Steam's original controller settings' to undo it; " + L"uninstall also restores the baseline startup command."; + } + return L""; +} + +const wchar_t* StatusLabel(SteamStrategy strategy) { + switch (strategy) { + case SteamStrategy::YieldToSteam: return L"Original Steam settings restored (Steamless off)"; + case SteamStrategy::ControllerBlacklist: return L"Steam Controller reserved for Steamless"; + case SteamStrategy::NoJoy: return L"Steam controller support disabled (-nojoy)"; + } + return L"Unknown"; +} + +const wchar_t* AppliedMessage(SteamStrategy strategy) { + switch (strategy) { + case SteamStrategy::YieldToSteam: + return L"Steam's original controller settings are restored. Steamless will yield " + L"whenever Steam can see the physical controller."; case SteamStrategy::ControllerBlacklist: - return L"Pros: recommended for Game Pass; Steam stays open and other controller " - L"types keep Steam Input.\nCons: Steam Controller layouts, gyro, and touch " - L"menus in Steam are unavailable while it is hidden."; + return L"The Steam Controller is now reserved for Steamless. Choose 'Restore Steam's " + L"original controller settings' at any time to undo this."; case SteamStrategy::NoJoy: - return L"Pros: simple, driver-free, and Steam can stay open.\nCons: disables all " - L"Steam client controller support, including Steam Input and Big Picture " - L"controller navigation. Launching Steam without -nojoy bypasses it."; + return L"Steam now starts with -nojoy. Choose 'Restore Steam's original controller " + L"settings' at any time to undo this."; } return L""; } diff --git a/src/app/SteamStrategy.h b/src/app/SteamStrategy.h index b0dbe6a..76d569b 100644 --- a/src/app/SteamStrategy.h +++ b/src/app/SteamStrategy.h @@ -23,8 +23,15 @@ SteamStrategy DetectConfigured(); // Steam is always relaunched so the new controller policy takes effect now. ApplyResult ApplyAndRestart(SteamStrategy strategy); +// Restores the Steam settings captured before the first strategy change, +// removes SteamlessController's own startup/settings entries, and restarts +// Steam if it was running. Used by the uninstaller. +ApplyResult CleanupForUninstall(); + bool IsGameRunning(); bool IsNoJoySelected(); const wchar_t* Tooltip(SteamStrategy strategy); +const wchar_t* StatusLabel(SteamStrategy strategy); +const wchar_t* AppliedMessage(SteamStrategy strategy); } // namespace SteamStrategies diff --git a/src/app/TrayApp.cpp b/src/app/TrayApp.cpp index 7ab6b70..f664d9e 100644 --- a/src/app/TrayApp.cpp +++ b/src/app/TrayApp.cpp @@ -365,6 +365,9 @@ void TrayApp::ApplySteamStrategy(SteamStrategy strategy) { MessageBoxW(m_hwnd, result.message.c_str(), result.applied ? L"Steam restart warning" : L"Steam strategy error", MB_OK | (result.applied ? MB_ICONWARNING : MB_ICONERROR)); + } else if (result.applied) { + ShowInfoBalloon(L"Steam coexistence updated", + SteamStrategies::AppliedMessage(strategy)); } } @@ -594,6 +597,18 @@ void TrayApp::ShowAlertBalloon(const std::wstring& title, const std::wstring& te Shell_NotifyIconW(NIM_MODIFY, &nid); } +void TrayApp::ShowInfoBalloon(const std::wstring& title, const std::wstring& text) { + NOTIFYICONDATAW nid{}; + nid.cbSize = sizeof(nid); + nid.hWnd = m_hwnd; + nid.uID = TRAY_UID; + nid.uFlags = NIF_INFO; + nid.dwInfoFlags = NIIF_INFO; + wcsncpy_s(nid.szInfoTitle, title.c_str(), _TRUNCATE); + wcsncpy_s(nid.szInfo, text.c_str(), _TRUNCATE); + Shell_NotifyIconW(NIM_MODIFY, &nid); +} + void TrayApp::OpenEventLog() { // Touch the log so the file exists even on a fresh install. EventLog::Write("USER: opened event log"); @@ -902,17 +917,30 @@ void TrayApp::ShowContextMenu() { AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); HMENU debugMenu = CreatePopupMenu(); - AppendMenuW(debugMenu, MF_STRING, IDM_STEAM_YIELD, L"Yield to Steam"); + std::wstring currentStrategy = L"Current: "; + currentStrategy += SteamStrategies::StatusLabel(m_steamStrategy); + AppendMenuW(debugMenu, MF_STRING | MF_GRAYED, 0, currentStrategy.c_str()); + AppendMenuW(debugMenu, MF_SEPARATOR, 0, nullptr); + AppendMenuW(debugMenu, MF_STRING, IDM_STEAM_YIELD, + L"Restore Steam's original controller settings"); AppendMenuW(debugMenu, MF_STRING, IDM_STEAM_BLACKLIST, - L"Hide Steam Controllers from Steam (recommended)"); - AppendMenuW(debugMenu, MF_STRING, IDM_STEAM_NOJOY, L"Launch Steam with -nojoy"); + L"Reserve Steam Controller for Steamless (recommended)"); + AppendMenuW(debugMenu, MF_STRING, IDM_STEAM_NOJOY, + L"Disable all Steam controller support (-nojoy)"); CheckMenuRadioItem(debugMenu, IDM_STEAM_YIELD, IDM_STEAM_NOJOY, m_steamStrategy == SteamStrategy::YieldToSteam ? IDM_STEAM_YIELD : m_steamStrategy == SteamStrategy::ControllerBlacklist ? IDM_STEAM_BLACKLIST : IDM_STEAM_NOJOY, MF_BYCOMMAND); + const wchar_t* shortStatus = m_steamStrategy == SteamStrategy::YieldToSteam + ? L"Steamless off" + : m_steamStrategy == SteamStrategy::ControllerBlacklist + ? L"reserved for Steamless" : L"-nojoy"; + std::wstring debugLabel = L"Debug: Steam coexistence ("; + debugLabel += shortStatus; + debugLabel += L")"; AppendMenuW(menu, MF_STRING | MF_POPUP, - reinterpret_cast(debugMenu), L"Debug: Steam coexistence"); + reinterpret_cast(debugMenu), debugLabel.c_str()); AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); diff --git a/src/app/TrayApp.h b/src/app/TrayApp.h index 8f71632..efe4fbc 100644 --- a/src/app/TrayApp.h +++ b/src/app/TrayApp.h @@ -35,6 +35,7 @@ class TrayApp { void UpdateTrayIcon(bool connected, bool gameModeActive, bool vigemMissing = false); void ShowViGEmBalloon(); void ShowAlertBalloon(const std::wstring& title, const std::wstring& text); + void ShowInfoBalloon(const std::wstring& title, const std::wstring& text); void OpenEventLog(); void ShowContextMenu(); void LoadSettings(); diff --git a/src/app/main.cpp b/src/app/main.cpp index 0d491b4..f1cd025 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -1,7 +1,14 @@ #include "TrayApp.h" +#include "SteamStrategy.h" #include -int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, PWSTR, int) { +int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, PWSTR cmdLine, int) { + // The Inno uninstaller runs this before removing files. It deliberately + // bypasses the tray singleton because AppMutex has already required the + // interactive instance to close. + if (cmdLine && _wcsicmp(cmdLine, L"--uninstall-cleanup") == 0) + return SteamStrategies::CleanupForUninstall().applied ? 0 : 1; + // Enable per-monitor v2 DPI awareness so the WebView2 window renders crisp // on high-DPI displays and the WM_NCHITTEST pixel math stays correct. SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); From af7182f6a082e2f03bf0b4cc6ea55b72ae80bcf0 Mon Sep 17 00:00:00 2001 From: Dan Bosscher Date: Mon, 3 Aug 2026 11:25:58 +0100 Subject: [PATCH 09/19] Support Nereid Steam Controller receiver --- README.md | 6 +++--- src/app/SteamStrategy.cpp | 4 ++-- src/app/SteamWatcher.cpp | 4 +++- src/steam/SteamController.cpp | 10 ++++++---- src/steam/SteamController.h | 7 ++++--- 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 911835a..a252887 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ When **Steamless Mode** is active, the app disables the controller's built-in ke ### To run - Windows 10 or later (64-bit) - [ViGEmBus](https://github.com/nefarius/ViGEmBus/releases/latest) driver installed -- Steam Controller — wired USB (PID `0x1302`), wireless dongle (PID `0x1304`), or Bluetooth LE (PID `0x1303`, pair via Windows Bluetooth settings) +- 2026 Steam Controller (Triton) — wired USB (PID `0x1302`), Bluetooth LE (PID `0x1303`, pair via Windows Bluetooth settings), Proteus/Puck receiver (PID `0x1304`), or Nereid receiver (PID `0x1305`) - Steam **closed**, or a coexistence strategy selected from the tray's debug menu ### To build @@ -79,13 +79,13 @@ Steam normally opens the physical Steam Controller even while the client is idle The tray's **Debug: Steam coexistence** submenu can apply one of three strategies. Hover an option to see its tradeoffs; selecting one safely releases the controller, closes Steam, applies the setting, and relaunches Steam immediately. If a Steam game is running, the app asks before closing it. - **Restore Steam's original controller settings** is the explicit off switch. It restores the Valve PID blacklist and `-nojoy` state recorded before SteamlessController first changed them, then SteamlessController yields whenever Steam can see the physical controller. -- **Reserve Steam Controller for Steamless** is recommended for Game Pass. It adds only the three Steam Controller product IDs to Steam's per-device blacklist, leaving Steam Input available for other controller types. Steam Controller-specific layouts, gyro, and touch menus are unavailable in Steam. +- **Reserve Steam Controller for Steamless** is recommended for Game Pass. It adds only the four supported 2026 Steam Controller product IDs to Steam's per-device blacklist, leaving Steam Input available for other controller types. Steam Controller-specific layouts, gyro, and touch menus are unavailable in Steam. - **Disable all Steam controller support (`-nojoy`)** disables all Steam client controller support, including Steam Input and Big Picture controller navigation. The app adds the flag to Steam's existing Windows startup entry; launching Steam another way without the flag is detected and SteamlessController safely yields. The blacklist option backs up `config/config.vdf` to `config/config.vdf.steamlesscontroller.bak`, preserves unrelated blacklist entries, and manages this property inside the outer `InstallConfigStore` object: ```vdf -"controller_blacklist" "28de/1302,28de/1303,28de/1304" +"controller_blacklist" "28de/1302,28de/1303,28de/1304,28de/1305" ``` SteamlessController verifies both the setting and Steam's current `logs/controller.txt` session before treating the blacklist as safe. For `-nojoy`, it verifies the running `steam.exe` command line through Windows Management Instrumentation. Missing or stale evidence always makes SteamlessController yield rather than risk duplicate input. diff --git a/src/app/SteamStrategy.cpp b/src/app/SteamStrategy.cpp index c92d5f8..7b8b4d9 100644 --- a/src/app/SteamStrategy.cpp +++ b/src/app/SteamStrategy.cpp @@ -20,8 +20,8 @@ constexpr wchar_t STRATEGY_VALUE[] = L"SteamStrategy"; constexpr wchar_t BASELINE_CAPTURED_VALUE[] = L"SteamBaselineCaptured"; constexpr wchar_t BASELINE_BLACKLIST_VALUE[] = L"SteamBaselineBlacklistMask"; constexpr wchar_t BASELINE_NOJOY_VALUE[] = L"SteamBaselineNoJoy"; -constexpr std::array STEAM_CONTROLLER_IDS = { - "28de/1302", "28de/1303", "28de/1304" +constexpr std::array STEAM_CONTROLLER_IDS = { + "28de/1302", "28de/1303", "28de/1304", "28de/1305" }; constexpr DWORD ALL_STEAM_CONTROLLER_IDS = (1u << STEAM_CONTROLLER_IDS.size()) - 1u; diff --git a/src/app/SteamWatcher.cpp b/src/app/SteamWatcher.cpp index 4b4617a..d866c2a 100644 --- a/src/app/SteamWatcher.cpp +++ b/src/app/SteamWatcher.cpp @@ -244,7 +244,9 @@ static bool IsControllerHiddenFromSteam(const SteamProcess& steam) { return ReportControllerHidden(false, "current controller session unavailable"); const std::string session = log.substr(sessionPos); - constexpr const char* ids[] = {"28de/1302", "28de/1303", "28de/1304"}; + constexpr const char* ids[] = { + "28de/1302", "28de/1303", "28de/1304", "28de/1305" + }; bool hidden = false; for (const char* id : ids) { std::string type(id); diff --git a/src/steam/SteamController.cpp b/src/steam/SteamController.cpp index 491d1a5..41c9b76 100644 --- a/src/steam/SteamController.cpp +++ b/src/steam/SteamController.cpp @@ -54,7 +54,8 @@ static constexpr uint8_t HAPTIC_COMMAND_CLICK = 2; std::vector SteamController::EnumerateAll() { std::vector result; - for (uint16_t pid : { SC2026_PID, SC2026_BT_PID, SC2026_DONGLE_PID }) + for (uint16_t pid : { SC2026_PID, SC2026_BT_PID, SC2026_DONGLE_PID, + SC2026_NEREID_DONGLE_PID }) for (auto const& path : HidDevice::Enumerate( VALVE_VID, pid, VENDOR_USAGE_PAGE, CONTROLLER_USAGE)) result.push_back(path); @@ -72,7 +73,8 @@ SteamController::Transport SteamController::TransportFromPath(const std::wstring p.find(L"bthledevice") != std::wstring::npos || p.find(L"bthenum") != std::wstring::npos) return Transport::Bluetooth; - if (p.find(L"pid_1304") != std::wstring::npos) + if (p.find(L"pid_1304") != std::wstring::npos || + p.find(L"pid_1305") != std::wstring::npos) return Transport::Dongle; if (p.find(L"pid_1302") != std::wstring::npos || p.find(L"pid_1303") != std::wstring::npos) @@ -92,8 +94,8 @@ const char* SteamController::TransportName(Transport t) { bool SteamController::Open() { auto paths = EnumerateAll(); if (paths.empty()) { - printf("No Steam Controller found (wired PID=%04X or dongle PID=%04X).\n", - SC2026_PID, SC2026_DONGLE_PID); + printf("No Steam Controller found (wired PID=%04X or dongle PIDs=%04X/%04X).\n", + SC2026_PID, SC2026_DONGLE_PID, SC2026_NEREID_DONGLE_PID); return false; } diff --git a/src/steam/SteamController.h b/src/steam/SteamController.h index 6ccac35..8aab57c 100644 --- a/src/steam/SteamController.h +++ b/src/steam/SteamController.h @@ -9,9 +9,10 @@ class SteamController { public: static constexpr uint16_t VALVE_VID = 0x28DE; - static constexpr uint16_t SC2026_PID = 0x1302; // wired USB - static constexpr uint16_t SC2026_BT_PID = 0x1303; // Bluetooth LE (HID over GATT) - static constexpr uint16_t SC2026_DONGLE_PID = 0x1304; // wireless dongle ("Steam Controller Puck") + static constexpr uint16_t SC2026_PID = 0x1302; // wired USB + static constexpr uint16_t SC2026_BT_PID = 0x1303; // Bluetooth LE (HID over GATT) + static constexpr uint16_t SC2026_DONGLE_PID = 0x1304; // Proteus dongle ("Steam Controller Puck") + static constexpr uint16_t SC2026_NEREID_DONGLE_PID = 0x1305; // Nereid receiver // How the controller is linked to the PC. Bluetooth is detected from the // device interface path (HOGP service GUID / bthenum), the other two by PID. From b189bf351a36502fa95aea2a22619c1b76c9520e Mon Sep 17 00:00:00 2001 From: Dan Bosscher Date: Mon, 3 Aug 2026 11:55:11 +0100 Subject: [PATCH 10/19] Handle controller sleep and make Steam ownership toggleable --- README.md | 7 +- src/app/ControllerManager.cpp | 62 ++++--- src/app/ControllerManager.h | 12 +- src/app/SteamStrategy.cpp | 26 ++- src/app/SteamStrategy.h | 8 +- src/app/TrayApp.cpp | 335 +++++++++++++++++++++++++--------- src/app/TrayApp.h | 38 ++-- 7 files changed, 346 insertions(+), 142 deletions(-) diff --git a/README.md b/README.md index a252887..14e3ff8 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,14 @@ When **Steamless Mode** is active, the app disables the controller's built-in ke ## Features - **NEW in 1.9** - Bluetooth! Use the controller wired, through the dongle, or paired over Bluetooth LE -- **NEW in 1.8** - Automatically enable/disable based on if Steam is running, or a Steam game is running! +- **NEW in 1.8** - Strategy-aware automatic control, with Steam handoff choices shown only when Steam can own the controller - **NEW in 1.8** - Advanced debugging and local logging to help diagnose issues - Rumble Support v2.0 - Support for multiple Steam Controllers! Long live split screen! - New shiny UI for remapping L4/L5 R4/L5 back buttons! - Support for Xbox OR PlayStation controllers - with PS Touchpad and Gyro plumbing. - System tray icon shows connection and mode status +- Left-click the tray icon to toggle Steamless Mode; right-click for settings - **Steamless Mode** — disables lizard mode and exposes controller as Xbox 360 gamepad - **Trackpad Mouse** — use the right (or left) trackpad as a mouse cursor - **Back Buttons for Clicking** — map R4/R5 (or L4/L5) to left/right mouse click @@ -78,7 +79,7 @@ Steam normally opens the physical Steam Controller even while the client is idle The tray's **Debug: Steam coexistence** submenu can apply one of three strategies. Hover an option to see its tradeoffs; selecting one safely releases the controller, closes Steam, applies the setting, and relaunches Steam immediately. If a Steam game is running, the app asks before closing it. -- **Restore Steam's original controller settings** is the explicit off switch. It restores the Valve PID blacklist and `-nojoy` state recorded before SteamlessController first changed them, then SteamlessController yields whenever Steam can see the physical controller. +- **Use Steam's original controller support** restores the Valve PID blacklist and `-nojoy` state recorded before SteamlessController first changed them, then SteamlessController yields whenever Steam can see the physical controller. - **Reserve Steam Controller for Steamless** is recommended for Game Pass. It adds only the four supported 2026 Steam Controller product IDs to Steam's per-device blacklist, leaving Steam Input available for other controller types. Steam Controller-specific layouts, gyro, and touch menus are unavailable in Steam. - **Disable all Steam controller support (`-nojoy`)** disables all Steam client controller support, including Steam Input and Big Picture controller navigation. The app adds the flag to Steam's existing Windows startup entry; launching Steam another way without the flag is detected and SteamlessController safely yields. @@ -90,6 +91,8 @@ The blacklist option backs up `config/config.vdf` to `config/config.vdf.steamles SteamlessController verifies both the setting and Steam's current `logs/controller.txt` session before treating the blacklist as safe. For `-nojoy`, it verifies the running `steam.exe` command line through Windows Management Instrumentation. Missing or stale evidence always makes SteamlessController yield rather than risk duplicate input. +Left-clicking the tray icon turns Steamless off by restoring that Steam baseline, or turns it back on with the last selected coexistence strategy. If Steam is running, the app asks before restarting it; checking **Don't ask again** makes future tray toggles restart Steam automatically. An already-closed Steam client remains closed. + Before its first strategy change, SteamlessController records whether each Valve PID and `-nojoy` were already configured. Uninstall closes the tray app, restores that recorded Steam baseline, removes the dedicated backup, Windows startup entry, and settings, and only then removes the application files. Steam is restarted if it was running; if restoration fails, uninstall stops so it can be retried. SteamlessController sends HID feature reports to disable lizard mode, then reads the raw input reports and translates them into a virtual Xbox 360 controller via ViGEmBus. A background heartbeat re-sends the disable command every 800 ms to keep lizard mode off. diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp index dee1c3a..bbfe7ef 100644 --- a/src/app/ControllerManager.cpp +++ b/src/app/ControllerManager.cpp @@ -34,6 +34,7 @@ struct ControllerManager::Slot { // a rapid burst — without this, three empty slots would block the UI // thread for that timeout on every one of those attempts. std::chrono::steady_clock::time_point lastSilentAt{}; + bool silentLogged = false; // Haptic edge-detection state for touch (movement ticks). bool hapticWasRightTouching = false; @@ -218,11 +219,18 @@ void ControllerManager::OnDeviceChange() { SyncDevices(); } -void ControllerManager::EnableGameMode() { +ControllerManager::EnableGameModeResult +ControllerManager::EnableGameMode(uint32_t stateWaitMs) { bool anyVigemMissing = false; - for (auto& slot : m_slots) - EnableGameModeSlot(*slot, anyVigemMissing); + bool anyBlocked = false; + for (auto& slot : m_slots) { + const auto result = EnableGameModeSlot(*slot, anyVigemMissing, stateWaitMs); + if (result == EnableGameModeResult::Blocked) anyBlocked = true; + } NotifyStateChanged(anyVigemMissing); + if (IsGameModeActive()) return EnableGameModeResult::Enabled; + return anyBlocked ? EnableGameModeResult::Blocked + : EnableGameModeResult::NoActiveController; } void ControllerManager::DisableGameMode() { @@ -375,7 +383,7 @@ void ControllerManager::OpenSlot(const std::wstring& path) { if (IsGameModeActive()) { bool dummy = false; - EnableGameModeSlot(*m_slots.back(), dummy); + EnableGameModeSlot(*m_slots.back(), dummy, 250); } else { // Restore lizard mode in case a previous session crashed without cleaning up. m_slots.back()->sc->EnableLizardMode(); @@ -386,28 +394,37 @@ void ControllerManager::OpenSlot(const std::wstring& path) { // Game mode per-slot helpers // --------------------------------------------------------------------------- -void ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) { - if (slot.gameModeActive) return; +ControllerManager::EnableGameModeResult +ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut, + uint32_t stateWaitMs) { + if (slot.gameModeActive) return EnableGameModeResult::Enabled; // The puck publishes a controller interface per slot and current firmware // rejects the lizard-mode command on an empty one, so only act on a slot // that is actually emitting state. Recently-silent slots are skipped // outright — see Slot::lastSilentAt. - static constexpr auto kSilentSlotRetryGap = std::chrono::milliseconds(1500); + static constexpr auto kSilentSlotRetryGap = std::chrono::milliseconds(500); const auto now = std::chrono::steady_clock::now(); - if (now - slot.lastSilentAt < kSilentSlotRetryGap) return; + if (now - slot.lastSilentAt < kSilentSlotRetryGap) + return EnableGameModeResult::NoActiveController; - if (!slot.sc->WaitForStateReport(250)) { + if (!slot.sc->WaitForStateReport(stateWaitMs)) { slot.lastSilentAt = std::chrono::steady_clock::now(); - EventLog::Write("GAMEMODE: no state reports from slot (transport=%s), skipping %ls", - SteamController::TransportName(slot.transport), slot.path.c_str()); - return; + if (!slot.silentLogged) { + slot.silentLogged = true; + EventLog::Write("GAMEMODE: no state reports from slot (transport=%s), waiting %ls", + SteamController::TransportName(slot.transport), slot.path.c_str()); + } + return EnableGameModeResult::NoActiveController; } + if (slot.silentLogged) + EventLog::Write("GAMEMODE: state reports resumed %ls", slot.path.c_str()); + slot.silentLogged = false; slot.accessClaim = SteamController::AccessClaim::Failed; const auto claim = slot.sc->ClaimGameModeAccess(); if (claim == SteamController::AccessClaim::Failed) { EventLog::Write("GAMEMODE: device reopen failed %ls", slot.path.c_str()); - return; + return EnableGameModeResult::Blocked; } if (claim == SteamController::AccessClaim::Shared) { // ERROR_SHARING_VIOLATION does not identify the existing owner. Live @@ -419,7 +436,7 @@ void ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) { EventLog::Write("GAMEMODE: shared access blocked because Steam may own %ls", slot.path.c_str()); NotifySteamConflict(); - return; + return EnableGameModeResult::Blocked; } EventLog::Write("GAMEMODE: exclusive claim unavailable; using shared access %ls", slot.path.c_str()); @@ -429,7 +446,7 @@ void ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) { EventLog::Write("GAMEMODE: DisableLizardMode failed %ls", slot.path.c_str()); slot.accessClaim = SteamController::AccessClaim::Failed; slot.sc->ReleaseToShared(); - return; + return EnableGameModeResult::Blocked; } slot.vc = std::make_unique( @@ -444,7 +461,7 @@ void ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) { slot.vc.reset(); slot.sc->EnableLizardMode(); slot.accessClaim = SteamController::AccessClaim::Failed; - return; + return EnableGameModeResult::Blocked; } if (m_controllerPlatform == ControllerPlatform::PlayStation) @@ -458,6 +475,7 @@ void ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) { slot.trackpad.SetUseLeftTrackpad(m_useLeftTrackpad); slot.trackpad.SetBackButtonsEnabled(m_backButtonsEnabled); StartReadLoop(slot); + return EnableGameModeResult::Enabled; } void ControllerManager::DisableGameModeSlot(Slot& slot) { @@ -568,14 +586,10 @@ void ControllerManager::ReadLoop(Slot* slot) { // Battery status — update DS4 battery level; no further processing needed. if (buf[0] == SteamController::REPORT_BATTERY_STATUS) { if (n >= 3) { - // USB/dongle payload order is [percent, chargeState]; over - // Bluetooth the firmware sends the same two bytes swapped — - // observed BT reports held a constant 1 (CHARGE_STATE_DISCHARGING) - // in buf[1] while buf[2] tracked the real charge level. - uint8_t percent = buf[1]; - uint8_t chargeState = buf[2]; - if (slot->transport == SteamController::Transport::Bluetooth) - std::swap(percent, chargeState); + // TritonBatteryStatus_t starts with charge state, then level, + // on every transport. + const uint8_t chargeState = buf[1]; + const uint8_t percent = buf[2]; if (slot->vc) slot->vc->SetBatteryState(percent, chargeState); if (slot->lastBatteryPercent != static_cast(percent)) { diff --git a/src/app/ControllerManager.h b/src/app/ControllerManager.h index 5fc0cf5..98cfe9d 100644 --- a/src/app/ControllerManager.h +++ b/src/app/ControllerManager.h @@ -2,6 +2,7 @@ #include "BackButtonConfig.h" #include "ControllerPlatform.h" #include +#include #include #include #include @@ -26,7 +27,13 @@ class ControllerManager { // read loops copy it without locking. void SetAlertCallback(AlertFn fn) { m_alertFn = std::move(fn); } - void EnableGameMode(); + enum class EnableGameModeResult { + Enabled, + NoActiveController, + Blocked, + }; + + EnableGameModeResult EnableGameMode(uint32_t stateWaitMs = 250); void DisableGameMode(); // Disables game mode then closes all device handles so another process // (e.g. Steam) can claim the controller. Safe to call when already disabled. @@ -66,7 +73,8 @@ class ControllerManager { void SyncDevices(); void OpenSlot(const std::wstring& path); - void EnableGameModeSlot(Slot& slot, bool& vigemMissingOut); + EnableGameModeResult EnableGameModeSlot(Slot& slot, bool& vigemMissingOut, + uint32_t stateWaitMs); void DisableGameModeSlot(Slot& slot); void StartReadLoop(Slot& slot); void StopReadLoop(Slot& slot); diff --git a/src/app/SteamStrategy.cpp b/src/app/SteamStrategy.cpp index 7b8b4d9..29ae1bc 100644 --- a/src/app/SteamStrategy.cpp +++ b/src/app/SteamStrategy.cpp @@ -451,11 +451,13 @@ SteamStrategy DetectConfigured() { return SteamStrategy::YieldToSteam; } -ApplyResult ApplyAndRestart(SteamStrategy strategy) { +ApplyResult ApplyAndRestart(SteamStrategy strategy, bool launchIfStopped) { std::wstring steamPath, steamExe, configPath; if (!GetSteamPaths(steamPath, steamExe, configPath)) return {false, L"Steam's installation or config.vdf could not be found."}; + const bool steamWasRunning = IsSteamProcessRunning(); + const bool shouldLaunchSteam = steamWasRunning || launchIfStopped; std::wstring existingRun; const bool steamRunExists = ReadRegistryString( HKEY_CURRENT_USER, RUN_REG_KEY, STEAM_RUN_VALUE, existingRun); @@ -465,9 +467,11 @@ ApplyResult ApplyAndRestart(SteamStrategy strategy) { std::wstring error; if (!StopSteam(steamExe, error)) return {false, std::move(error)}; auto failAfterStop = [&](std::wstring message) { - std::wstring restartError; - if (!StartSteam(previousLaunchCommand, restartError)) { - message += L" Steam also could not be restarted with its previous settings."; + if (shouldLaunchSteam) { + std::wstring restartError; + if (!StartSteam(previousLaunchCommand, restartError)) { + message += L" Steam also could not be restarted with its previous settings."; + } } return ApplyResult{false, std::move(message)}; }; @@ -511,7 +515,8 @@ ApplyResult ApplyAndRestart(SteamStrategy strategy) { EventLog::Write("STEAM STRATEGY: applied %d (0=yield 1=blacklist 2=nojoy)", static_cast(strategy)); - if (!StartSteam(launchCommand, error)) return {true, std::move(error)}; + if (shouldLaunchSteam && !StartSteam(launchCommand, error)) + return {true, std::move(error)}; return {true, {}}; } @@ -581,6 +586,10 @@ bool IsGameRunning() { && appId != 0; } +bool IsSteamRunning() { + return IsSteamProcessRunning(); +} + bool IsNoJoySelected() { return DetectConfigured() == SteamStrategy::NoJoy; } @@ -589,8 +598,9 @@ const wchar_t* Tooltip(SteamStrategy strategy) { switch (strategy) { case SteamStrategy::YieldToSteam: return L"Restores the Steam Controller blacklist and -nojoy state recorded before " - L"SteamlessController first changed them. This is the explicit off switch; " - L"Steamless then yields whenever Steam can see the physical controller."; + L"SteamlessController first changed them. Steamless then yields whenever " + L"Steam can see the physical controller; the Steam Handoff menu controls " + L"whether it may take over while Steam is idle."; case SteamStrategy::ControllerBlacklist: return L"Persists across Steam restarts. Steam stays open and other controller " L"types keep Steam Input, but Steam Controller layouts, gyro, and touch " @@ -608,7 +618,7 @@ const wchar_t* Tooltip(SteamStrategy strategy) { const wchar_t* StatusLabel(SteamStrategy strategy) { switch (strategy) { - case SteamStrategy::YieldToSteam: return L"Original Steam settings restored (Steamless off)"; + case SteamStrategy::YieldToSteam: return L"Steam's original controller settings"; case SteamStrategy::ControllerBlacklist: return L"Steam Controller reserved for Steamless"; case SteamStrategy::NoJoy: return L"Steam controller support disabled (-nojoy)"; } diff --git a/src/app/SteamStrategy.h b/src/app/SteamStrategy.h index 76d569b..3e24f4c 100644 --- a/src/app/SteamStrategy.h +++ b/src/app/SteamStrategy.h @@ -19,9 +19,10 @@ struct ApplyResult { // installations that predate this setting. SteamStrategy DetectConfigured(); -// Applies the selected strategy, stops Steam cleanly, and starts it again. -// Steam is always relaunched so the new controller policy takes effect now. -ApplyResult ApplyAndRestart(SteamStrategy strategy); +// Applies the selected strategy and restarts Steam when it was running. +// launchIfStopped preserves the debug menu's explicit relaunch behavior while +// allowing the tray on/off switch to leave an already-closed Steam closed. +ApplyResult ApplyAndRestart(SteamStrategy strategy, bool launchIfStopped = true); // Restores the Steam settings captured before the first strategy change, // removes SteamlessController's own startup/settings entries, and restarts @@ -29,6 +30,7 @@ ApplyResult ApplyAndRestart(SteamStrategy strategy); ApplyResult CleanupForUninstall(); bool IsGameRunning(); +bool IsSteamRunning(); bool IsNoJoySelected(); const wchar_t* Tooltip(SteamStrategy strategy); const wchar_t* StatusLabel(SteamStrategy strategy); diff --git a/src/app/TrayApp.cpp b/src/app/TrayApp.cpp index f664d9e..14659e1 100644 --- a/src/app/TrayApp.cpp +++ b/src/app/TrayApp.cpp @@ -14,6 +14,8 @@ #include #include +#pragma comment(linker, "\"/manifestdependency:type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'\"") + static TrayApp* g_app = nullptr; static constexpr wchar_t WNDCLASS_NAME[] = L"SteamlessControllerTray"; @@ -110,9 +112,14 @@ bool TrayApp::Init(HINSTANCE hInstance) { LoadSettings(); m_steamStrategy = SteamStrategies::DetectConfigured(); - // Converge the startup mechanism with the loaded mode — e.g. the first - // elevated run after enabling in-game mode migrates the Run key to the - // highest-privileges logon task (and back when the mode changes). + if (!m_preferredStrategyLoaded) + m_preferredSteamStrategy = m_steamStrategy; + // An old Manual setting did not mean the Steam reservation itself was + // inactive. On first migration, reflect the configuration that is really + // applied rather than claiming Steamless is off while Steam remains blocked. + if (!m_enabledSettingLoaded && m_steamStrategy != SteamStrategy::YieldToSteam) + m_steamlessEnabled = true; + // Converge the persisted Start with Windows choice with the Run key. UpdateStartupRegistration(); AddTrayIcon(); UpdateTrayIcon(m_controller->IsConnected(), m_controller->IsGameModeActive(), false); @@ -124,13 +131,14 @@ bool TrayApp::Init(HINSTANCE hInstance) { // the cycle itself — so the UAC prompt ambushed the user mid-game-launch. // A no-op once the task exists, which is the common case. Must run after // AddTrayIcon, since a declined prompt reports via the tray balloon. - if (m_autoMode == AutoMode::OffOnlyInGame) + if (m_steamlessEnabled + && m_steamStrategy == SteamStrategy::YieldToSteam + && m_handoffMode == HandoffMode::OnlyInGame) EnsureCycleTaskRegistered(); - // Start watching for Steam regardless of auto mode — the state is applied - // only when auto mode is on, and having it warm makes toggling auto mode - // take effect instantly. The initial callback asserts the correct state - // at startup (which also self-heals after a previous crash). + // Keep Steam state warm even while Steamless is switched off so turning it + // back on can apply the selected ownership policy immediately. The initial + // callback also asserts the correct state after startup or a crash. m_steamWatcher.Start([this](SteamState state) { PostMessageW(m_hwnd, WM_STEAMSTATE, static_cast(state), 0); }); @@ -164,19 +172,27 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { if (LOWORD(lp) == NIN_BALLOONUSERCLICK) ShellExecuteW(nullptr, L"open", L"https://github.com/nefarius/ViGEmBus/releases/latest", nullptr, nullptr, SW_SHOWNORMAL); - else if (LOWORD(lp) == WM_LBUTTONDBLCLK) + else if (LOWORD(lp) == WM_LBUTTONDBLCLK) { + KillTimer(m_hwnd, IDT_TRAY_CLICK); + m_suppressNextLeftUp = true; OpenRemapWindow(); - else if (LOWORD(lp) == WM_RBUTTONUP || LOWORD(lp) == WM_LBUTTONUP) + } else if (LOWORD(lp) == WM_LBUTTONUP) { + if (m_suppressNextLeftUp) { + m_suppressNextLeftUp = false; + } else { + // Delay until the double-click window closes so the existing + // double-click remap action does not also toggle twice. + SetTimer(m_hwnd, IDT_TRAY_CLICK, GetDoubleClickTime(), nullptr); + } + } else if (LOWORD(lp) == WM_RBUTTONUP) { ShowContextMenu(); + } return 0; case WM_COMMAND: switch (LOWORD(wp)) { case IDM_TOGGLE: - if (m_controller->IsGameModeActive()) - m_controller->DisableGameMode(); - else - m_controller->EnableGameMode(); + ToggleSteamlessMode(); break; case IDM_TRACKPAD: m_controller->SetTrackpadMouseEnabled(!m_controller->IsTrackpadMouseEnabled()); @@ -201,18 +217,15 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { m_controller->SetControllerPlatform(ControllerPlatform::PlayStation); SaveSettings(); break; - case IDM_MODE_MANUAL: - SetAutoMode(AutoMode::Manual); + case IDM_HANDOFF_STEAM: + SetHandoffMode(HandoffMode::WhileSteamRuns); break; - case IDM_MODE_STEAM: - SetAutoMode(AutoMode::OffWhileSteam); - break; - case IDM_MODE_GAME: + case IDM_HANDOFF_GAME: // Make sure the elevated helper task exists (one-time UAC when // the installer didn't register it) before the mode needs it. if (!IsProcessElevated()) EnsureCycleTaskRegistered(); - SetAutoMode(AutoMode::OffOnlyInGame); + SetHandoffMode(HandoffMode::OnlyInGame); break; case IDM_STARTUP: m_startupEnabled = !m_startupEnabled; @@ -260,9 +273,9 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { case WM_STEAMSTATE: { const auto steamState = static_cast(wp); - // Outside ApplySteamState, which returns early in Manual mode. A shared - // manual session must still yield if Steam can see the controller, and - // a manual enable must not settle for unsafe shared access. + // This remains current while Steamless is switched off. If the user + // turns it back on, a shared claim must still be rejected whenever + // Steam can see and may own the physical controller. m_controller->SetSteamMayOwnController(steamState == SteamState::SteamIdle || steamState == SteamState::InGame); ApplySteamState(steamState); @@ -286,19 +299,21 @@ 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 (WantControl(m_steamWatcher.GetState())) TryAcquireController(); + } else if (wp == IDT_TRAY_CLICK) { + KillTimer(m_hwnd, IDT_TRAY_CLICK); + ToggleSteamlessMode(); } return 0; case WM_DEVICECHANGE: 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())) + // A controller arriving (plugged in, waking, or re-arriving after + // a device cycle) should be acquired whenever the current policy + // wants control. + if (wp == DBT_DEVICEARRIVAL && WantControl(m_steamWatcher.GetState())) TryAcquireController(); } return TRUE; @@ -310,42 +325,49 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { return DefWindowProcW(hwnd, msg, wp, lp); } -void TrayApp::SetAutoMode(AutoMode mode) { - EventLog::Write("MODE: control mode set to %d (0=manual 1=offWhileSteam 2=offOnlyInGame)", +void TrayApp::SetHandoffMode(HandoffMode mode) { + EventLog::Write("MODE: Steam handoff set to %d (1=whileSteamRuns 2=onlyInGame)", static_cast(mode)); - m_autoMode = mode; + m_handoffMode = mode; m_acquireRetries = 0; + m_waitingForWake = false; + KillTimer(m_hwnd, IDT_ACQUIRE); SaveSettings(); - UpdateStartupRegistration(); - if (mode != AutoMode::Manual) + if (m_steamlessEnabled) ApplySteamState(m_steamWatcher.GetState()); } bool TrayApp::WantControl(SteamState state) const { - switch (m_autoMode) { - case AutoMode::OffWhileSteam: + if (!m_steamlessEnabled) return false; + switch (m_handoffMode) { + case HandoffMode::WhileSteamRuns: return state == SteamState::NoSteam || state == SteamState::ControllerHidden; - case AutoMode::OffOnlyInGame: + case HandoffMode::OnlyInGame: return state != SteamState::InGame; - default: return false; // Manual: tray toggle decides } + return false; } -void TrayApp::ApplySteamStrategy(SteamStrategy strategy) { - if (SteamStrategies::IsGameRunning()) { +bool TrayApp::ApplySteamStrategy(SteamStrategy strategy, + bool launchIfStopped, + bool enableSteamless, + bool rememberPreference, + bool confirmRunningGame) { + if (confirmRunningGame && SteamStrategies::IsGameRunning()) { const int answer = MessageBoxW( m_hwnd, L"A Steam game is running. Applying this option will close the game when " L"Steam restarts. Apply it now?", L"Restart Steam", MB_ICONWARNING | MB_YESNO | MB_DEFBUTTON2); - if (answer != IDYES) return; + if (answer != IDYES) return false; } EventLog::Write("USER: applying Steam coexistence strategy %d", static_cast(strategy)); KillTimer(m_hwnd, IDT_ACQUIRE); m_acquireRetries = 0; + m_waitingForWake = false; m_steamWatcher.Stop(); // Steam must close before config.vdf is edited, and releasing first gives @@ -354,8 +376,19 @@ void TrayApp::ApplySteamStrategy(SteamStrategy strategy) { MSG stale{}; while (PeekMessageW(&stale, m_hwnd, WM_STEAMSTATE, WM_STEAMSTATE, PM_REMOVE)) {} - const SteamStrategies::ApplyResult result = SteamStrategies::ApplyAndRestart(strategy); - if (result.applied) m_steamStrategy = strategy; + const SteamStrategies::ApplyResult result = + SteamStrategies::ApplyAndRestart(strategy, launchIfStopped); + if (result.applied) { + m_steamStrategy = strategy; + m_steamlessEnabled = enableSteamless; + if (rememberPreference) + m_preferredSteamStrategy = strategy; + SaveSettings(); + if (strategy == SteamStrategy::YieldToSteam + && m_steamlessEnabled + && m_handoffMode == HandoffMode::OnlyInGame) + EnsureCycleTaskRegistered(); + } m_steamWatcher.Start([this](SteamState state) { PostMessageW(m_hwnd, WM_STEAMSTATE, static_cast(state), 0); @@ -366,9 +399,14 @@ void TrayApp::ApplySteamStrategy(SteamStrategy strategy) { result.applied ? L"Steam restart warning" : L"Steam strategy error", MB_OK | (result.applied ? MB_ICONWARNING : MB_ICONERROR)); } else if (result.applied) { - ShowInfoBalloon(L"Steam coexistence updated", - SteamStrategies::AppliedMessage(strategy)); + ShowInfoBalloon(enableSteamless ? L"Steam coexistence updated" + : L"Steamless turned off", + enableSteamless + ? SteamStrategies::AppliedMessage(strategy) + : L"Steam's original controller settings were restored. Steam can " + L"take the physical controller again."); } + return result.applied; } void TrayApp::CreateMenuTooltip() { @@ -429,7 +467,7 @@ void TrayApp::HideMenuTooltip() { } void TrayApp::ApplySteamState(SteamState state) { - if (m_autoMode == AutoMode::Manual) return; + if (!m_steamlessEnabled) return; EventLog::Write("AUTO: steam state %d (0=none 1=controllerHidden 2=idle 3=inGame) -> %s", static_cast(state), @@ -440,6 +478,7 @@ void TrayApp::ApplySteamState(SteamState state) { } else { KillTimer(m_hwnd, IDT_ACQUIRE); m_acquireRetries = 0; + m_waitingForWake = false; const bool hadControl = m_controller->IsGameModeActive(); // Good citizen: restore lizard mode and close our HID handles so // Steam can claim the controller without contention. @@ -455,36 +494,54 @@ void TrayApp::ApplySteamState(SteamState state) { void TrayApp::TryAcquireController() { m_controller->OnDeviceChange(); - m_controller->EnableGameMode(); + auto result = m_controller->EnableGameMode(m_waitingForWake ? 40 : 250); // 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) { + for (int i = 0; i < 10 && result == ControllerManager::EnableGameModeResult::Blocked + && !m_controller->IsGameModeActive(); ++i) { Sleep(50); m_controller->OnDeviceChange(); - m_controller->EnableGameMode(); + result = m_controller->EnableGameMode(40); } if (m_controller->IsGameModeActive()) { + if (m_waitingForWake) + EventLog::Write("AUTO: controller woke; Steamless Mode acquired"); + m_waitingForWake = false; + m_acquireRetries = 0; + return; + } + if (!m_controller->IsConnected()) { + m_waitingForWake = false; + return; // nothing plugged in — arrival will retrigger + } + if (result == ControllerManager::EnableGameModeResult::NoActiveController) { m_acquireRetries = 0; + if (!m_waitingForWake) + EventLog::Write("AUTO: receiver present but controller inactive; waiting for wake"); + m_waitingForWake = true; + SetTimer(m_hwnd, IDT_ACQUIRE, WAKE_RETRY_MS, nullptr); return; } - if (!m_controller->IsConnected()) return; // nothing plugged in — arrival will retrigger + m_waitingForWake = false; - // Steam holds a write handle, so our exclusive claim can't succeed while - // its handle lives. Cycle the device to invalidate it and retry on - // re-arrival (the timer is a fallback in case the arrival event is missed). + // A reporting slot exists but access or a required command failed. Cycle + // the device to invalidate stale handles and retry on re-arrival (the timer + // is a fallback in case the arrival event is missed). if (m_acquireRetries >= MAX_ACQUIRE_CYCLES) { EventLog::Write("AUTO: giving up acquiring after %d device cycles", MAX_ACQUIRE_CYCLES); // Don't leave the user with a silently dead pad and no explanation. if (m_notificationsEnabled) ShowAlertBalloon(L"Could not take the controller", - L"Steam is holding the controller and did not release it. " - L"Closing Steam will hand it back."); + L"Controller reports are arriving, but access or the required " + L"controller command still fails after retrying. Close other " + L"controller tools, then turn Steamless off and on to retry."); return; } ++m_acquireRetries; - EventLog::Write("AUTO: exclusive claim blocked — cycling device (attempt %d)", m_acquireRetries); + EventLog::Write("AUTO: active controller acquisition 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 @@ -597,6 +654,94 @@ void TrayApp::ShowAlertBalloon(const std::wstring& title, const std::wstring& te Shell_NotifyIconW(NIM_MODIFY, &nid); } +bool TrayApp::ConfirmToggleSteamRestart(bool enabling, SteamStrategy targetStrategy) { + if (!m_confirmSteamRestart || !SteamStrategies::IsSteamRunning()) + return true; + + const wchar_t* instruction = enabling + ? L"Restart Steam to turn Steamless on?" + : L"Restart Steam to turn Steamless off?"; + std::wstring content; + if (!enabling) { + content = L"Steam's original controller settings will be restored so Steam can " + L"take the physical controller back."; + } else if (targetStrategy == SteamStrategy::NoJoy) { + content = L"Steam will restart with -nojoy so Steamless can take the physical " + L"controller. All Steam controller support will be disabled."; + } else { + content = L"Steam will restart with the Steam Controller reserved for Steamless. " + L"Other controller types will remain available to Steam Input."; + } + if (SteamStrategies::IsGameRunning()) + content += L" A Steam game is running and will close."; + + TASKDIALOGCONFIG dialog{}; + dialog.cbSize = sizeof(dialog); + dialog.hwndParent = m_hwnd; + dialog.dwFlags = TDF_ALLOW_DIALOG_CANCELLATION | TDF_SIZE_TO_CONTENT; + dialog.dwCommonButtons = TDCBF_YES_BUTTON | TDCBF_NO_BUTTON; + dialog.pszWindowTitle = L"Restart Steam"; + dialog.pszMainIcon = TD_WARNING_ICON; + dialog.pszMainInstruction = instruction; + dialog.pszContent = content.c_str(); + dialog.pszVerificationText = L"Don't ask again"; + dialog.nDefaultButton = IDNO; + + int button = IDNO; + BOOL verificationChecked = FALSE; + const HRESULT hr = TaskDialogIndirect(&dialog, &button, nullptr, &verificationChecked); + if (FAILED(hr)) { + button = MessageBoxW(m_hwnd, content.c_str(), instruction, + MB_ICONWARNING | MB_YESNO | MB_DEFBUTTON2); + verificationChecked = FALSE; + } + if (button != IDYES) return false; + + if (verificationChecked) { + m_confirmSteamRestart = false; + SaveSettings(); + } + return true; +} + +void TrayApp::ToggleSteamlessMode() { + if (m_steamlessEnabled && m_steamStrategy != SteamStrategy::YieldToSteam) { + if (!ConfirmToggleSteamRestart(false, SteamStrategy::YieldToSteam)) + return; + EventLog::Write("USER: turning Steamless off and restoring Steam settings"); + ApplySteamStrategy(SteamStrategy::YieldToSteam, + false, false, false, false); + return; + } + if (!m_steamlessEnabled + && m_preferredSteamStrategy != SteamStrategy::YieldToSteam) { + if (!ConfirmToggleSteamRestart(true, m_preferredSteamStrategy)) + return; + EventLog::Write("USER: turning Steamless on with strategy %d", + static_cast(m_preferredSteamStrategy)); + ApplySteamStrategy(m_preferredSteamStrategy, + false, true, false, false); + return; + } + + m_steamlessEnabled = !m_steamlessEnabled; + m_acquireRetries = 0; + m_waitingForWake = false; + KillTimer(m_hwnd, IDT_ACQUIRE); + SaveSettings(); + if (!m_steamlessEnabled) { + EventLog::Write("USER: Steamless disabled from tray (Steam settings already restored)"); + const bool hadControl = m_controller->IsGameModeActive(); + const SteamState state = m_steamWatcher.GetState(); + m_controller->ReleaseDevices(); + if (hadControl && (state == SteamState::SteamIdle || state == SteamState::InGame)) + RestartControllerDevices(); + } else { + EventLog::Write("USER: Steamless enabled from tray with original Steam settings"); + ApplySteamState(m_steamWatcher.GetState()); + } +} + void TrayApp::ShowInfoBalloon(const std::wstring& title, const std::wstring& text) { NOTIFYICONDATAW nid{}; nid.cbSize = sizeof(nid); @@ -799,11 +944,20 @@ void TrayApp::LoadSettings() { return def; }; - // 0 = Manual, 1 = OffWhileSteam, 2 = OffOnlyInGame (old builds stored 0/1). - const DWORD mode = readDw(L"AutoSteamMode", 1); - m_autoMode = mode == 2 ? AutoMode::OffOnlyInGame - : mode == 1 ? AutoMode::OffWhileSteam - : AutoMode::Manual; + // Migrate the old 0=manual value to master-off. Values 1 and 2 retain + // their previous Steam handoff schedules. + const DWORD legacyMode = readDw(L"AutoSteamMode", 1); + m_handoffMode = legacyMode == 2 ? HandoffMode::OnlyInGame + : HandoffMode::WhileSteamRuns; + const DWORD enabled = readDw(L"SteamlessEnabled", MAXDWORD); + m_enabledSettingLoaded = enabled <= 1; + m_steamlessEnabled = m_enabledSettingLoaded ? enabled != 0 : legacyMode != 0; + const DWORD preferred = readDw(L"PreferredSteamStrategy", MAXDWORD); + if (preferred <= static_cast(SteamStrategy::NoJoy)) { + m_preferredSteamStrategy = static_cast(preferred); + m_preferredStrategyLoaded = true; + } + m_confirmSteamRestart = readDw(L"ConfirmSteamRestart", 1) != 0; m_controller->SetTrackpadMouseEnabled(readDw(L"TrackpadMouse", 0) != 0); m_controller->SetBackButtonsEnabled (readDw(L"BackButtons", 0) != 0); m_controller->SetUseLeftTrackpad (readDw(L"UseLeftTrackpad", 0) != 0); @@ -841,7 +995,10 @@ void TrayApp::SaveSettings() { reinterpret_cast(&val), sizeof(val)); }; - writeDw(L"AutoSteamMode", static_cast(m_autoMode)); + writeDw(L"AutoSteamMode", static_cast(m_handoffMode)); + writeDw(L"SteamlessEnabled", m_steamlessEnabled ? 1 : 0); + writeDw(L"PreferredSteamStrategy", static_cast(m_preferredSteamStrategy)); + writeDw(L"ConfirmSteamRestart", m_confirmSteamRestart ? 1 : 0); writeDw(L"StartupMechanism", static_cast(m_startupMechanism)); writeDw(L"ShowNotifications", m_notificationsEnabled ? 1 : 0); writeDw(L"TrackpadMouse", m_controller->IsTrackpadMouseEnabled() ? 1 : 0); @@ -860,7 +1017,6 @@ void TrayApp::SaveSettings() { void TrayApp::ShowContextMenu() { bool connected = m_controller->IsConnected(); - bool gameModeOn = m_controller->IsGameModeActive(); bool trackpadOn = m_controller->IsTrackpadMouseEnabled(); bool backMouseOn = m_controller->IsBackButtonsEnabled(); bool leftPad = m_controller->IsUseLeftTrackpad(); @@ -868,27 +1024,25 @@ void TrayApp::ShowContextMenu() { HMENU menu = CreatePopupMenu(); - // 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"; + std::wstring toggleLabel = m_steamlessEnabled ? L"Turn Steamless Off" + : L"Turn Steamless On"; if (!connected) toggleLabel += L" (no controller detected)"; - else if (!manual) - toggleLabel += L" (managed by Auto Mode)"; - UINT toggleFlags = MF_STRING | ((connected && manual) ? MF_ENABLED : MF_GRAYED); - AppendMenuW(menu, toggleFlags, IDM_TOGGLE, toggleLabel.c_str()); - - HMENU modeMenu = CreatePopupMenu(); - AppendMenuW(modeMenu, MF_STRING | (manual ? MF_CHECKED : MF_UNCHECKED), - IDM_MODE_MANUAL, L"Manual"); - AppendMenuW(modeMenu, MF_STRING | (m_autoMode == AutoMode::OffWhileSteam ? MF_CHECKED : MF_UNCHECKED), - IDM_MODE_STEAM, L"Auto - Off while Steam running"); - AppendMenuW(modeMenu, MF_STRING | (m_autoMode == AutoMode::OffOnlyInGame ? MF_CHECKED : MF_UNCHECKED), - IDM_MODE_GAME, L"Auto - Off ONLY while in Steam game"); - AppendMenuW(menu, MF_STRING | MF_POPUP, - reinterpret_cast(modeMenu), L"Control Mode"); + AppendMenuW(menu, MF_STRING, IDM_TOGGLE, toggleLabel.c_str()); + + if (m_steamlessEnabled && m_steamStrategy == SteamStrategy::YieldToSteam) { + HMENU handoffMenu = CreatePopupMenu(); + AppendMenuW(handoffMenu, + MF_STRING | (m_handoffMode == HandoffMode::WhileSteamRuns + ? MF_CHECKED : MF_UNCHECKED), + IDM_HANDOFF_STEAM, L"Use Steamless only while Steam is closed"); + AppendMenuW(handoffMenu, + MF_STRING | (m_handoffMode == HandoffMode::OnlyInGame + ? MF_CHECKED : MF_UNCHECKED), + IDM_HANDOFF_GAME, L"Use Steamless except during Steam games"); + AppendMenuW(menu, MF_STRING | MF_POPUP, + reinterpret_cast(handoffMenu), L"Steam Handoff"); + } AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); @@ -917,23 +1071,24 @@ void TrayApp::ShowContextMenu() { AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); HMENU debugMenu = CreatePopupMenu(); - std::wstring currentStrategy = L"Current: "; - currentStrategy += SteamStrategies::StatusLabel(m_steamStrategy); + std::wstring currentStrategy = m_steamlessEnabled ? L"Current: " : L"Off; when on: "; + currentStrategy += SteamStrategies::StatusLabel( + m_steamlessEnabled ? m_steamStrategy : m_preferredSteamStrategy); AppendMenuW(debugMenu, MF_STRING | MF_GRAYED, 0, currentStrategy.c_str()); AppendMenuW(debugMenu, MF_SEPARATOR, 0, nullptr); AppendMenuW(debugMenu, MF_STRING, IDM_STEAM_YIELD, - L"Restore Steam's original controller settings"); + L"Use Steam's original controller support"); AppendMenuW(debugMenu, MF_STRING, IDM_STEAM_BLACKLIST, L"Reserve Steam Controller for Steamless (recommended)"); AppendMenuW(debugMenu, MF_STRING, IDM_STEAM_NOJOY, L"Disable all Steam controller support (-nojoy)"); CheckMenuRadioItem(debugMenu, IDM_STEAM_YIELD, IDM_STEAM_NOJOY, - m_steamStrategy == SteamStrategy::YieldToSteam ? IDM_STEAM_YIELD - : m_steamStrategy == SteamStrategy::ControllerBlacklist + m_preferredSteamStrategy == SteamStrategy::YieldToSteam ? IDM_STEAM_YIELD + : m_preferredSteamStrategy == SteamStrategy::ControllerBlacklist ? IDM_STEAM_BLACKLIST : IDM_STEAM_NOJOY, MF_BYCOMMAND); - const wchar_t* shortStatus = m_steamStrategy == SteamStrategy::YieldToSteam - ? L"Steamless off" + const wchar_t* shortStatus = !m_steamlessEnabled ? L"off" + : m_steamStrategy == SteamStrategy::YieldToSteam ? L"Steam handoff" : m_steamStrategy == SteamStrategy::ControllerBlacklist ? L"reserved for Steamless" : L"-nojoy"; std::wstring debugLabel = L"Debug: Steam coexistence ("; diff --git a/src/app/TrayApp.h b/src/app/TrayApp.h index efe4fbc..c099a3a 100644 --- a/src/app/TrayApp.h +++ b/src/app/TrayApp.h @@ -10,12 +10,10 @@ class ControllerManager; -// Who decides when SteamlessController takes over the physical controller. -enum class AutoMode { - Manual = 0, // tray toggle only - OffWhileSteam = 1, // yield whenever steam.exe is running - OffOnlyInGame = 2, // yield only while a game is running (needs admin to - // wrest the device from a running Steam) +// When Steamless yields under Steam's original controller settings. +enum class HandoffMode { + WhileSteamRuns = 1, + OnlyInGame = 2, }; class TrayApp { @@ -38,10 +36,16 @@ class TrayApp { void ShowInfoBalloon(const std::wstring& title, const std::wstring& text); void OpenEventLog(); void ShowContextMenu(); + void ToggleSteamlessMode(); void LoadSettings(); void SaveSettings(); void OpenRemapWindow(); - void ApplySteamStrategy(SteamStrategy strategy); + bool ApplySteamStrategy(SteamStrategy strategy, + bool launchIfStopped = true, + bool enableSteamless = true, + bool rememberPreference = true, + bool confirmRunningGame = true); + bool ConfirmToggleSteamRestart(bool enabling, SteamStrategy targetStrategy); void CreateMenuTooltip(); void ShowMenuTooltip(UINT commandId); void HideMenuTooltip(); @@ -59,8 +63,8 @@ class TrayApp { void DeleteLegacyStartupTask(); void UpdateStartupRegistration(); - // Auto-mode plumbing. - void SetAutoMode(AutoMode mode); + // Automatic ownership plumbing. + void SetHandoffMode(HandoffMode mode); bool WantControl(SteamState state) const; void ApplySteamState(SteamState state); void TryAcquireController(); @@ -72,12 +76,19 @@ class TrayApp { UINT m_wmTaskbar = 0; HICON m_iconOff = nullptr; HICON m_iconOn = nullptr; - AutoMode m_autoMode = AutoMode::OffWhileSteam; + HandoffMode m_handoffMode = HandoffMode::WhileSteamRuns; SteamStrategy m_steamStrategy = SteamStrategy::YieldToSteam; + SteamStrategy m_preferredSteamStrategy = SteamStrategy::YieldToSteam; HWND m_menuTooltip = nullptr; TTTOOLINFOW m_menuTooltipInfo{}; std::wstring m_menuTooltipText; int m_acquireRetries = 0; + bool m_waitingForWake = false; + bool m_suppressNextLeftUp = false; + bool m_steamlessEnabled = true; + bool m_confirmSteamRestart = true; + bool m_preferredStrategyLoaded = false; + bool m_enabledSettingLoaded = false; bool m_elevationBalloonShown = false; bool m_startupEnabled = false; int m_startupMechanism = 0; // 0 none, 1 Run key, 2 elevated task @@ -98,9 +109,8 @@ class TrayApp { static constexpr UINT IDM_BACKBUTTONS = 1007; static constexpr UINT IDM_PLATFORM_XBOX = 1008; static constexpr UINT IDM_PLATFORM_PS = 1009; - static constexpr UINT IDM_MODE_MANUAL = 1010; - static constexpr UINT IDM_MODE_STEAM = 1011; - static constexpr UINT IDM_MODE_GAME = 1012; + static constexpr UINT IDM_HANDOFF_STEAM = 1011; + static constexpr UINT IDM_HANDOFF_GAME = 1012; static constexpr UINT IDM_OPENLOG = 1013; static constexpr UINT IDM_NOTIFICATIONS = 1014; static constexpr UINT IDM_STEAM_YIELD = 1020; @@ -111,5 +121,7 @@ class TrayApp { static constexpr UINT WM_ALERT = WM_APP + 3; static constexpr UINT TRAY_UID = 1; static constexpr UINT_PTR IDT_ACQUIRE = 1; + static constexpr UINT_PTR IDT_TRAY_CLICK = 2; static constexpr int MAX_ACQUIRE_CYCLES = 3; + static constexpr UINT WAKE_RETRY_MS = 750; }; From 352676e4be803d50ab8afece07208e64ce1e02db Mon Sep 17 00:00:00 2001 From: Dylan Deverill Date: Mon, 3 Aug 2026 19:25:10 -0700 Subject: [PATCH 11/19] Narrow back to the puck activation fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Steam coexistence work (strategy menu, config.vdf blacklist, -nojoy, Steam restart, uninstall cleanup, tray toggle changes) is being split into its own PR so the puck fix — which is the P0 here, since the receiver is broken today — can land on its own. Nothing is lost: those commits remain in this branch's history and can be cherry-picked onto a fresh branch for the follow-up. This is a revert of the tree, not of the work. Reverts the branch tree to a532804, the last state reviewed and built. Co-Authored-By: Claude Fable 5 --- CMakeLists.txt | 5 - README.md | 29 +- resources/InnoInstallerScript.iss | 26 -- src/app/ControllerManager.cpp | 116 ++---- src/app/ControllerManager.h | 25 +- src/app/SteamStrategy.cpp | 643 ------------------------------ src/app/SteamStrategy.h | 39 -- src/app/SteamWatcher.cpp | 259 +----------- src/app/SteamWatcher.h | 7 +- src/app/TrayApp.cpp | 482 ++++------------------ src/app/TrayApp.h | 50 +-- src/app/main.cpp | 9 +- src/steam/SteamController.cpp | 10 +- src/steam/SteamController.h | 7 +- 14 files changed, 149 insertions(+), 1558 deletions(-) delete mode 100644 src/app/SteamStrategy.cpp delete mode 100644 src/app/SteamStrategy.h diff --git a/CMakeLists.txt b/CMakeLists.txt index b12c297..7ee2f2c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -91,7 +91,6 @@ add_executable(SteamlessController WIN32 src/app/VirtualController.cpp src/app/TrackpadMouse.cpp src/app/SteamWatcher.cpp - src/app/SteamStrategy.cpp src/app/DeviceRestart.cpp src/app/EventLog.cpp src/app/RemapWindow.cpp @@ -109,10 +108,6 @@ target_link_libraries(SteamlessController PRIVATE ole32 shlwapi cfgmgr32 - comctl32 - shell32 - wbemuuid - oleaut32 ) if (MSVC) target_compile_options(SteamlessController PRIVATE /W4 /WX /utf-8 /wd4828) diff --git a/README.md b/README.md index 14e3ff8..895bf53 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,13 @@ When **Steamless Mode** is active, the app disables the controller's built-in ke ## Features - **NEW in 1.9** - Bluetooth! Use the controller wired, through the dongle, or paired over Bluetooth LE -- **NEW in 1.8** - Strategy-aware automatic control, with Steam handoff choices shown only when Steam can own the controller +- **NEW in 1.8** - Automatically enable/disable based on if Steam is running, or a Steam game is running! - **NEW in 1.8** - Advanced debugging and local logging to help diagnose issues - Rumble Support v2.0 - Support for multiple Steam Controllers! Long live split screen! - New shiny UI for remapping L4/L5 R4/L5 back buttons! - Support for Xbox OR PlayStation controllers - with PS Touchpad and Gyro plumbing. - System tray icon shows connection and mode status -- Left-click the tray icon to toggle Steamless Mode; right-click for settings - **Steamless Mode** — disables lizard mode and exposes controller as Xbox 360 gamepad - **Trackpad Mouse** — use the right (or left) trackpad as a mouse cursor - **Back Buttons for Clicking** — map R4/R5 (or L4/L5) to left/right mouse click @@ -33,8 +32,8 @@ When **Steamless Mode** is active, the app disables the controller's built-in ke ### To run - Windows 10 or later (64-bit) - [ViGEmBus](https://github.com/nefarius/ViGEmBus/releases/latest) driver installed -- 2026 Steam Controller (Triton) — wired USB (PID `0x1302`), Bluetooth LE (PID `0x1303`, pair via Windows Bluetooth settings), Proteus/Puck receiver (PID `0x1304`), or Nereid receiver (PID `0x1305`) -- Steam **closed**, or a coexistence strategy selected from the tray's debug menu +- Steam Controller — wired USB (PID `0x1302`), wireless dongle (PID `0x1304`), or Bluetooth LE (PID `0x1303`, pair via Windows Bluetooth settings) +- Steam **closed** (Steam claims the controller when running) ### To build - [Visual Studio 2022](https://visualstudio.microsoft.com/) with the **Desktop development with C++** workload @@ -73,28 +72,6 @@ cmake --build build/release --config Release --target SteamlessController The Steam Controller exposes a vendor HID collection (usage page `0xFF00`) that carries all game input in a 54-byte report (ID `0x42`) at ~60 Hz. By default the firmware runs in **lizard mode**, emulating a keyboard and mouse so the controller works without drivers. -### Keeping Steam running - -Steam normally opens the physical Steam Controller even while the client is idle. That can produce duplicate keyboard/mouse and virtual-gamepad input if SteamlessController uses the controller at the same time. - -The tray's **Debug: Steam coexistence** submenu can apply one of three strategies. Hover an option to see its tradeoffs; selecting one safely releases the controller, closes Steam, applies the setting, and relaunches Steam immediately. If a Steam game is running, the app asks before closing it. - -- **Use Steam's original controller support** restores the Valve PID blacklist and `-nojoy` state recorded before SteamlessController first changed them, then SteamlessController yields whenever Steam can see the physical controller. -- **Reserve Steam Controller for Steamless** is recommended for Game Pass. It adds only the four supported 2026 Steam Controller product IDs to Steam's per-device blacklist, leaving Steam Input available for other controller types. Steam Controller-specific layouts, gyro, and touch menus are unavailable in Steam. -- **Disable all Steam controller support (`-nojoy`)** disables all Steam client controller support, including Steam Input and Big Picture controller navigation. The app adds the flag to Steam's existing Windows startup entry; launching Steam another way without the flag is detected and SteamlessController safely yields. - -The blacklist option backs up `config/config.vdf` to `config/config.vdf.steamlesscontroller.bak`, preserves unrelated blacklist entries, and manages this property inside the outer `InstallConfigStore` object: - -```vdf -"controller_blacklist" "28de/1302,28de/1303,28de/1304,28de/1305" -``` - -SteamlessController verifies both the setting and Steam's current `logs/controller.txt` session before treating the blacklist as safe. For `-nojoy`, it verifies the running `steam.exe` command line through Windows Management Instrumentation. Missing or stale evidence always makes SteamlessController yield rather than risk duplicate input. - -Left-clicking the tray icon turns Steamless off by restoring that Steam baseline, or turns it back on with the last selected coexistence strategy. If Steam is running, the app asks before restarting it; checking **Don't ask again** makes future tray toggles restart Steam automatically. An already-closed Steam client remains closed. - -Before its first strategy change, SteamlessController records whether each Valve PID and `-nojoy` were already configured. Uninstall closes the tray app, restores that recorded Steam baseline, removes the dedicated backup, Windows startup entry, and settings, and only then removes the application files. Steam is restarted if it was running; if restoration fails, uninstall stops so it can be retried. - SteamlessController sends HID feature reports to disable lizard mode, then reads the raw input reports and translates them into a virtual Xbox 360 controller via ViGEmBus. A background heartbeat re-sends the disable command every 800 ms to keep lizard mode off. The full input report layout is documented in [`src/steam/SteamController.h`](src/steam/SteamController.h). diff --git a/resources/InnoInstallerScript.iss b/resources/InnoInstallerScript.iss index f76a939..3b6d2a2 100644 --- a/resources/InnoInstallerScript.iss +++ b/resources/InnoInstallerScript.iss @@ -22,9 +22,6 @@ AppSupportURL={#MyAppURL} AppUpdatesURL={#MyAppURL} DefaultDirName={autopf}\{#MyAppName} UninstallDisplayIcon={app}\{#MyAppExeName} -; Setup and Uninstall must not replace/remove the tray app while it is still -; watching Steam or holding the controller. -AppMutex=SteamlessController_SingleInstance SourceDir=.. ; "ArchitecturesAllowed=x64compatible" specifies that Setup cannot run ; on anything but x64 and Windows 11 on Arm. @@ -68,26 +65,3 @@ Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChang [UninstallRun] Filename: "{app}\SteamlessDeviceCycle.exe"; Parameters: "--unregister"; RunOnceId: "RemoveCycleTask"; Flags: waituntilterminated runhidden -[Code] -procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); -var - ResultCode: Integer; -begin - if CurUninstallStep = usUninstall then - begin - { Restore the user's original Steam blacklist/startup state before the - executable and its recorded baseline are removed. Abort leaves the app - installed so cleanup can be retried instead of silently orphaning a - persistent Steam setting. } - if (not Exec(ExpandConstant('{app}\{#MyAppExeName}'), - '--uninstall-cleanup', '', SW_HIDE, - ewWaitUntilTerminated, ResultCode)) or (ResultCode <> 0) then - begin - MsgBox('SteamlessController could not restore Steam''s original controller settings. ' + - 'Steam was restarted with its previous settings; please close Steam and retry uninstall.', - mbError, MB_OK); - Abort; - end; - end; -end; - diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp index bbfe7ef..ecd4ca9 100644 --- a/src/app/ControllerManager.cpp +++ b/src/app/ControllerManager.cpp @@ -26,7 +26,6 @@ struct ControllerManager::Slot { std::thread readThread; std::atomic readRunning{false}; bool gameModeActive = false; - SteamController::AccessClaim accessClaim = SteamController::AccessClaim::Failed; int lastBatteryPercent = -1; // -1 = never reported // When this slot was last found silent. Probing for a state report costs @@ -34,7 +33,6 @@ struct ControllerManager::Slot { // a rapid burst — without this, three empty slots would block the UI // thread for that timeout on every one of those attempts. std::chrono::steady_clock::time_point lastSilentAt{}; - bool silentLogged = false; // Haptic edge-detection state for touch (movement ticks). bool hapticWasRightTouching = false; @@ -219,18 +217,11 @@ void ControllerManager::OnDeviceChange() { SyncDevices(); } -ControllerManager::EnableGameModeResult -ControllerManager::EnableGameMode(uint32_t stateWaitMs) { +void ControllerManager::EnableGameMode() { bool anyVigemMissing = false; - bool anyBlocked = false; - for (auto& slot : m_slots) { - const auto result = EnableGameModeSlot(*slot, anyVigemMissing, stateWaitMs); - if (result == EnableGameModeResult::Blocked) anyBlocked = true; - } + for (auto& slot : m_slots) + EnableGameModeSlot(*slot, anyVigemMissing); NotifyStateChanged(anyVigemMissing); - if (IsGameModeActive()) return EnableGameModeResult::Enabled; - return anyBlocked ? EnableGameModeResult::Blocked - : EnableGameModeResult::NoActiveController; } void ControllerManager::DisableGameMode() { @@ -251,27 +242,6 @@ void ControllerManager::ReleaseDevices() { NotifyStateChanged(); } -void ControllerManager::SetSteamMayOwnController(bool mayOwn) { - if (m_steamMayOwnController == mayOwn) return; - m_steamMayOwnController = mayOwn; - - if (!mayOwn) { - m_steamConflictAlertShown = false; - return; - } - - const bool sharedModeActive = std::any_of(m_slots.begin(), m_slots.end(), - [](const auto& slot) { - return slot->gameModeActive - && slot->accessClaim == SteamController::AccessClaim::Shared; - }); - if (!sharedModeActive) return; - - EventLog::Write("GAMEMODE: Steam started during shared access; yielding to prevent duplicate input"); - DisableGameMode(); - NotifySteamConflict(); -} - void ControllerManager::SetTrackpadMouseEnabled(bool enabled) { m_trackpadMouseEnabled = enabled; for (auto& slot : m_slots) { @@ -383,7 +353,7 @@ void ControllerManager::OpenSlot(const std::wstring& path) { if (IsGameModeActive()) { bool dummy = false; - EnableGameModeSlot(*m_slots.back(), dummy, 250); + EnableGameModeSlot(*m_slots.back(), dummy); } else { // Restore lizard mode in case a previous session crashed without cleaning up. m_slots.back()->sc->EnableLizardMode(); @@ -394,59 +364,46 @@ void ControllerManager::OpenSlot(const std::wstring& path) { // Game mode per-slot helpers // --------------------------------------------------------------------------- -ControllerManager::EnableGameModeResult -ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut, - uint32_t stateWaitMs) { - if (slot.gameModeActive) return EnableGameModeResult::Enabled; +void ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) { + if (slot.gameModeActive) return; // The puck publishes a controller interface per slot and current firmware // rejects the lizard-mode command on an empty one, so only act on a slot // that is actually emitting state. Recently-silent slots are skipped // outright — see Slot::lastSilentAt. - static constexpr auto kSilentSlotRetryGap = std::chrono::milliseconds(500); + static constexpr auto kSilentSlotRetryGap = std::chrono::milliseconds(1500); const auto now = std::chrono::steady_clock::now(); - if (now - slot.lastSilentAt < kSilentSlotRetryGap) - return EnableGameModeResult::NoActiveController; + if (now - slot.lastSilentAt < kSilentSlotRetryGap) return; - if (!slot.sc->WaitForStateReport(stateWaitMs)) { + if (!slot.sc->WaitForStateReport(250)) { slot.lastSilentAt = std::chrono::steady_clock::now(); - if (!slot.silentLogged) { - slot.silentLogged = true; - EventLog::Write("GAMEMODE: no state reports from slot (transport=%s), waiting %ls", - SteamController::TransportName(slot.transport), slot.path.c_str()); - } - return EnableGameModeResult::NoActiveController; + EventLog::Write("GAMEMODE: no state reports from slot (transport=%s), skipping %ls", + SteamController::TransportName(slot.transport), slot.path.c_str()); + return; } - if (slot.silentLogged) - EventLog::Write("GAMEMODE: state reports resumed %ls", slot.path.c_str()); - slot.silentLogged = false; - slot.accessClaim = SteamController::AccessClaim::Failed; const auto claim = slot.sc->ClaimGameModeAccess(); if (claim == SteamController::AccessClaim::Failed) { EventLog::Write("GAMEMODE: device reopen failed %ls", slot.path.c_str()); - return EnableGameModeResult::Blocked; + return; } if (claim == SteamController::AccessClaim::Shared) { - // ERROR_SHARING_VIOLATION does not identify the existing owner. Live - // testing shows that sharing with an idle Steam client is still unsafe: - // whichever mapper starts second can produce duplicate input or starve - // the other reader. The Windows-only holder is benign, so shared access - // remains the compatibility path while Steam is absent. - if (m_steamMayOwnController) { - EventLog::Write("GAMEMODE: shared access blocked because Steam may own %ls", + // A write handle held while Steam is running is most likely Steam's. + // Proceeding would put two processes on the same controller, both + // driving lizard mode; refusing is what escalates to a device cycle + // that takes the handle back. Only settle for shared when Steam is + // absent and the holder is some benign system component. + if (m_steamPresent) { + EventLog::Write("GAMEMODE: exclusive claim blocked while Steam is running %ls", slot.path.c_str()); - NotifySteamConflict(); - return EnableGameModeResult::Blocked; + return; } EventLog::Write("GAMEMODE: exclusive claim unavailable; using shared access %ls", slot.path.c_str()); } - slot.accessClaim = claim; if (!slot.sc->DisableLizardMode()) { EventLog::Write("GAMEMODE: DisableLizardMode failed %ls", slot.path.c_str()); - slot.accessClaim = SteamController::AccessClaim::Failed; slot.sc->ReleaseToShared(); - return EnableGameModeResult::Blocked; + return; } slot.vc = std::make_unique( @@ -460,8 +417,7 @@ ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut, if (slot.vc->IsDriverMissing()) vigemMissingOut = true; slot.vc.reset(); slot.sc->EnableLizardMode(); - slot.accessClaim = SteamController::AccessClaim::Failed; - return EnableGameModeResult::Blocked; + return; } if (m_controllerPlatform == ControllerPlatform::PlayStation) @@ -475,7 +431,6 @@ ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut, slot.trackpad.SetUseLeftTrackpad(m_useLeftTrackpad); slot.trackpad.SetBackButtonsEnabled(m_backButtonsEnabled); StartReadLoop(slot); - return EnableGameModeResult::Enabled; } void ControllerManager::DisableGameModeSlot(Slot& slot) { @@ -489,22 +444,9 @@ void ControllerManager::DisableGameModeSlot(Slot& slot) { slot.sc->EnableLizardMode(); // Reopen shared so Steam can obtain write access — game mode is no longer active. slot.sc->ReleaseToShared(); - slot.accessClaim = SteamController::AccessClaim::Failed; slot.gameModeActive = false; } -void ControllerManager::NotifySteamConflict() { - if (m_steamConflictAlertShown) return; - m_steamConflictAlertShown = true; - if (m_alertFn) { - m_alertFn(L"Steam is using the controller", - L"Windows only granted shared controller access while Steam is running. " - L"Steamless Mode was kept off to prevent duplicate or missing input. " - L"Exit Steam, or choose Debug: Steam coexistence in the tray menu and " - L"apply the recommended controller-hiding option."); - } -} - // --------------------------------------------------------------------------- // Read loop // --------------------------------------------------------------------------- @@ -586,10 +528,14 @@ void ControllerManager::ReadLoop(Slot* slot) { // Battery status — update DS4 battery level; no further processing needed. if (buf[0] == SteamController::REPORT_BATTERY_STATUS) { if (n >= 3) { - // TritonBatteryStatus_t starts with charge state, then level, - // on every transport. - const uint8_t chargeState = buf[1]; - const uint8_t percent = buf[2]; + // USB/dongle payload order is [percent, chargeState]; over + // Bluetooth the firmware sends the same two bytes swapped — + // observed BT reports held a constant 1 (CHARGE_STATE_DISCHARGING) + // in buf[1] while buf[2] tracked the real charge level. + uint8_t percent = buf[1]; + uint8_t chargeState = buf[2]; + if (slot->transport == SteamController::Transport::Bluetooth) + std::swap(percent, chargeState); if (slot->vc) slot->vc->SetBatteryState(percent, chargeState); if (slot->lastBatteryPercent != static_cast(percent)) { diff --git a/src/app/ControllerManager.h b/src/app/ControllerManager.h index 98cfe9d..06c6b41 100644 --- a/src/app/ControllerManager.h +++ b/src/app/ControllerManager.h @@ -2,7 +2,6 @@ #include "BackButtonConfig.h" #include "ControllerPlatform.h" #include -#include #include #include #include @@ -27,13 +26,7 @@ class ControllerManager { // read loops copy it without locking. void SetAlertCallback(AlertFn fn) { m_alertFn = std::move(fn); } - enum class EnableGameModeResult { - Enabled, - NoActiveController, - Blocked, - }; - - EnableGameModeResult EnableGameMode(uint32_t stateWaitMs = 250); + void EnableGameMode(); void DisableGameMode(); // Disables game mode then closes all device handles so another process // (e.g. Steam) can claim the controller. Safe to call when already disabled. @@ -45,9 +38,12 @@ class ControllerManager { // fallback if this fails or the process dies harder than a C++ exception. void EmergencyRestoreAll() noexcept; - // Shared HID access is only safe while Steam cannot see the physical - // controller. If that changes, yield before both mappers can emit input. - void SetSteamMayOwnController(bool mayOwn); + // Whether steam.exe is currently running. Decides if game mode may settle + // for a shared handle: a write handle held while Steam is absent belongs to + // some benign system component, but one held while Steam is running is + // probably Steam itself, and driving the controller alongside it is worse + // than refusing — refusing is what escalates to a device cycle. + void SetSteamPresent(bool present) { m_steamPresent = present; } void SetTrackpadMouseEnabled(bool enabled); void SetUseLeftTrackpad(bool enabled); @@ -73,13 +69,11 @@ class ControllerManager { void SyncDevices(); void OpenSlot(const std::wstring& path); - EnableGameModeResult EnableGameModeSlot(Slot& slot, bool& vigemMissingOut, - uint32_t stateWaitMs); + void EnableGameModeSlot(Slot& slot, bool& vigemMissingOut); void DisableGameModeSlot(Slot& slot); void StartReadLoop(Slot& slot); void StopReadLoop(Slot& slot); void ReadLoop(Slot* slot); - void NotifySteamConflict(); void NotifyStateChanged(bool vigemMissing = false); StateChangedFn m_onStateChanged; @@ -88,8 +82,7 @@ class ControllerManager { bool m_trackpadMouseEnabled = false; bool m_useLeftTrackpad = false; bool m_backButtonsEnabled = false; - bool m_steamMayOwnController = false; - bool m_steamConflictAlertShown = false; + bool m_steamPresent = false; ControllerPlatform m_controllerPlatform = ControllerPlatform::Xbox; BackButtonConfig m_backConfig; diff --git a/src/app/SteamStrategy.cpp b/src/app/SteamStrategy.cpp deleted file mode 100644 index 29ae1bc..0000000 --- a/src/app/SteamStrategy.cpp +++ /dev/null @@ -1,643 +0,0 @@ -#include "SteamStrategy.h" -#include "EventLog.h" -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { - -constexpr wchar_t APP_REG_KEY[] = L"Software\\SteamlessController"; -constexpr wchar_t STEAM_REG_KEY[] = L"Software\\Valve\\Steam"; -constexpr wchar_t RUN_REG_KEY[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\Run"; -constexpr wchar_t STEAM_RUN_VALUE[] = L"Steam"; -constexpr wchar_t APP_RUN_VALUE[] = L"SteamlessController"; -constexpr wchar_t STRATEGY_VALUE[] = L"SteamStrategy"; -constexpr wchar_t BASELINE_CAPTURED_VALUE[] = L"SteamBaselineCaptured"; -constexpr wchar_t BASELINE_BLACKLIST_VALUE[] = L"SteamBaselineBlacklistMask"; -constexpr wchar_t BASELINE_NOJOY_VALUE[] = L"SteamBaselineNoJoy"; -constexpr std::array STEAM_CONTROLLER_IDS = { - "28de/1302", "28de/1303", "28de/1304", "28de/1305" -}; -constexpr DWORD ALL_STEAM_CONTROLLER_IDS = - (1u << STEAM_CONTROLLER_IDS.size()) - 1u; - -bool ReadRegistryString(HKEY root, const wchar_t* keyName, const wchar_t* valueName, - std::wstring& value, DWORD* typeOut = nullptr) { - DWORD type = 0; - DWORD bytes = 0; - if (RegGetValueW(root, keyName, valueName, RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ, - &type, nullptr, &bytes) != ERROR_SUCCESS - || bytes < sizeof(wchar_t)) - return false; - - value.resize(bytes / sizeof(wchar_t)); - if (RegGetValueW(root, keyName, valueName, RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ, - &type, value.data(), &bytes) != ERROR_SUCCESS) - return false; - while (!value.empty() && value.back() == L'\0') value.pop_back(); - if (typeOut) *typeOut = type; - return !value.empty(); -} - -bool ReadFileBytes(const std::wstring& path, std::string& bytes) { - HANDLE file = CreateFileW(path.c_str(), GENERIC_READ, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); - if (file == INVALID_HANDLE_VALUE) return false; - - LARGE_INTEGER size{}; - if (!GetFileSizeEx(file, &size) || size.QuadPart < 0 - || size.QuadPart > static_cast(64 * 1024 * 1024)) { - CloseHandle(file); - return false; - } - - bytes.resize(static_cast(size.QuadPart)); - DWORD read = 0; - const bool ok = bytes.empty() - || (ReadFile(file, bytes.data(), static_cast(bytes.size()), &read, nullptr) - && read == static_cast(bytes.size())); - CloseHandle(file); - return ok; -} - -bool WriteFileAtomically(const std::wstring& path, const std::string& bytes) { - const std::wstring temporary = path + L".steamlesscontroller.tmp"; - HANDLE file = CreateFileW(temporary.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, - FILE_ATTRIBUTE_NORMAL, nullptr); - if (file == INVALID_HANDLE_VALUE) return false; - - DWORD written = 0; - const bool wrote = bytes.empty() - || (WriteFile(file, bytes.data(), static_cast(bytes.size()), &written, nullptr) - && written == static_cast(bytes.size())); - const bool flushed = wrote && FlushFileBuffers(file); - CloseHandle(file); - if (!flushed) { - DeleteFileW(temporary.c_str()); - return false; - } - if (!MoveFileExW(temporary.c_str(), path.c_str(), - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { - DeleteFileW(temporary.c_str()); - return false; - } - return true; -} - -std::string LowerAscii(std::string text) { - std::transform(text.begin(), text.end(), text.begin(), [](unsigned char ch) { - return static_cast(std::tolower(ch)); - }); - return text; -} - -std::string TrimAscii(std::string text) { - const size_t first = text.find_first_not_of(" \t\r\n"); - if (first == std::string::npos) return {}; - const size_t last = text.find_last_not_of(" \t\r\n"); - return text.substr(first, last - first + 1); -} - -DWORD SteamControllerIdBit(const std::string& value) { - const std::string lower = LowerAscii(TrimAscii(value)); - for (size_t i = 0; i < STEAM_CONTROLLER_IDS.size(); ++i) - if (lower == STEAM_CONTROLLER_IDS[i]) return 1u << i; - return 0; -} - -struct VdfValue { - bool found = false; - size_t start = 0; - size_t end = 0; - std::string value; -}; - -VdfValue FindControllerBlacklist(const std::string& config) { - const std::string lower = LowerAscii(config); - constexpr char key[] = "\"controller_blacklist\""; - const size_t keyPos = lower.rfind(key); - if (keyPos == std::string::npos) return {}; - const size_t open = config.find('"', keyPos + sizeof(key) - 1); - if (open == std::string::npos) return {}; - const size_t close = config.find('"', open + 1); - if (close == std::string::npos) return {}; - return {true, open + 1, close, config.substr(open + 1, close - open - 1)}; -} - -DWORD ControllerBlacklistValueMask(const std::string& value) { - DWORD mask = 0; - size_t start = 0; - while (start <= value.size()) { - const size_t comma = value.find(',', start); - mask |= SteamControllerIdBit(value.substr( - start, comma == std::string::npos ? std::string::npos : comma - start)); - if (comma == std::string::npos) break; - start = comma + 1; - } - return mask; -} - -DWORD ControllerBlacklistConfigMask(const std::string& config) { - const VdfValue value = FindControllerBlacklist(config); - return value.found ? ControllerBlacklistValueMask(value.value) : 0; -} - -std::string UpdateControllerBlacklist(std::string config, DWORD desiredControllerMask) { - const VdfValue existing = FindControllerBlacklist(config); - std::vector entries; - if (existing.found) { - size_t start = 0; - while (start <= existing.value.size()) { - const size_t comma = existing.value.find(',', start); - std::string entry = TrimAscii(existing.value.substr( - start, comma == std::string::npos ? std::string::npos : comma - start)); - if (!entry.empty() && SteamControllerIdBit(entry) == 0) - entries.push_back(std::move(entry)); - if (comma == std::string::npos) break; - start = comma + 1; - } - } - for (size_t i = 0; i < STEAM_CONTROLLER_IDS.size(); ++i) - if ((desiredControllerMask & (1u << i)) != 0) - entries.emplace_back(STEAM_CONTROLLER_IDS[i]); - - std::string value; - for (const std::string& entry : entries) { - if (!value.empty()) value += ','; - value += entry; - } - - if (existing.found) { - config.replace(existing.start, existing.end - existing.start, value); - return config; - } - if (desiredControllerMask == 0) return config; - - const size_t close = config.find_last_of('}'); - if (close == std::string::npos) return {}; - const char* newline = config.find("\r\n") != std::string::npos ? "\r\n" : "\n"; - std::string property = std::string("\t\"controller_blacklist\"\t\t\"") - + value + "\"" + newline; - config.insert(close, property); - return config; -} - -bool GetSteamPaths(std::wstring& steamPath, std::wstring& steamExe, - std::wstring& configPath) { - if (!ReadRegistryString(HKEY_CURRENT_USER, STEAM_REG_KEY, L"SteamPath", steamPath)) - return false; - std::replace(steamPath.begin(), steamPath.end(), L'/', L'\\'); - while (!steamPath.empty() && steamPath.back() == L'\\') steamPath.pop_back(); - steamExe = steamPath + L"\\steam.exe"; - configPath = steamPath + L"\\config\\config.vdf"; - return GetFileAttributesW(steamExe.c_str()) != INVALID_FILE_ATTRIBUTES - && GetFileAttributesW(configPath.c_str()) != INVALID_FILE_ATTRIBUTES; -} - -bool IsSteamProcessRunning() { - HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); - if (snap == INVALID_HANDLE_VALUE) return false; - PROCESSENTRY32W pe{}; - pe.dwSize = sizeof(pe); - bool running = false; - if (Process32FirstW(snap, &pe)) { - do { - if (_wcsicmp(pe.szExeFile, L"steam.exe") == 0) { - running = true; - break; - } - } while (Process32NextW(snap, &pe)); - } - CloseHandle(snap); - return running; -} - -std::wstring QuoteArgument(const std::wstring& argument) { - if (!argument.empty() && argument.find_first_of(L" \t\n\v\"") == std::wstring::npos) - return argument; - - std::wstring quoted = L"\""; - size_t backslashes = 0; - for (const wchar_t ch : argument) { - if (ch == L'\\') { - ++backslashes; - } else if (ch == L'"') { - quoted.append(backslashes * 2 + 1, L'\\'); - quoted += L'"'; - backslashes = 0; - } else { - quoted.append(backslashes, L'\\'); - backslashes = 0; - quoted += ch; - } - } - quoted.append(backslashes * 2, L'\\'); - quoted += L'"'; - return quoted; -} - -std::vector ParseArguments(const std::wstring& command) { - int count = 0; - LPWSTR* argv = CommandLineToArgvW(command.c_str(), &count); - if (!argv) return {}; - std::vector args; - for (int i = 0; i < count; ++i) args.emplace_back(argv[i]); - LocalFree(argv); - return args; -} - -bool CommandHasNoJoy(const std::wstring& command) { - const std::vector args = ParseArguments(command); - return std::any_of(args.begin(), args.end(), [](const std::wstring& arg) { - return _wcsicmp(arg.c_str(), L"-nojoy") == 0; - }); -} - -std::wstring SteamCommand(const std::wstring& steamExe, const std::wstring& existing, - bool noJoy) { - std::vector args = ParseArguments(existing); - if (!args.empty()) args.erase(args.begin()); - args.erase(std::remove_if(args.begin(), args.end(), [](const std::wstring& arg) { - return _wcsicmp(arg.c_str(), L"-nojoy") == 0; - }), args.end()); - if (noJoy) args.emplace_back(L"-nojoy"); - if (args.empty()) args.emplace_back(L"-silent"); - - std::wstring command = QuoteArgument(steamExe); - for (const std::wstring& arg : args) - command += L" " + QuoteArgument(arg); - return command; -} - -bool WriteSteamRunCommandIfPresent(const std::wstring& command, std::wstring& error) { - HKEY key = nullptr; - if (RegOpenKeyExW(HKEY_CURRENT_USER, RUN_REG_KEY, 0, KEY_QUERY_VALUE | KEY_SET_VALUE, - &key) != ERROR_SUCCESS) { - error = L"Could not open the Windows startup registry key."; - return false; - } - - const bool exists = RegQueryValueExW(key, STEAM_RUN_VALUE, nullptr, nullptr, - nullptr, nullptr) == ERROR_SUCCESS; - bool ok = true; - if (exists) { - ok = RegSetValueExW(key, STEAM_RUN_VALUE, 0, REG_SZ, - reinterpret_cast(command.c_str()), - static_cast((command.size() + 1) * sizeof(wchar_t))) - == ERROR_SUCCESS; - if (!ok) error = L"Could not update Steam's Windows startup command."; - } - RegCloseKey(key); - return ok; -} - -bool ReadAppDword(const wchar_t* name, DWORD& value) { - DWORD bytes = sizeof(value); - return RegGetValueW(HKEY_CURRENT_USER, APP_REG_KEY, name, RRF_RT_REG_DWORD, - nullptr, &value, &bytes) == ERROR_SUCCESS; -} - -bool WriteAppDword(HKEY key, const wchar_t* name, DWORD value) { - return RegSetValueExW(key, name, 0, REG_DWORD, - reinterpret_cast(&value), sizeof(value)) - == ERROR_SUCCESS; -} - -bool HasSavedStrategy() { - DWORD strategy = 0; - return ReadAppDword(STRATEGY_VALUE, strategy) - && strategy <= static_cast(SteamStrategy::NoJoy); -} - -struct SteamBaseline { - DWORD blacklistMask = 0; - bool noJoy = false; -}; - -bool LoadBaseline(SteamBaseline& baseline) { - DWORD captured = 0; - DWORD noJoy = 0; - if (!ReadAppDword(BASELINE_CAPTURED_VALUE, captured) || captured == 0 - || !ReadAppDword(BASELINE_BLACKLIST_VALUE, baseline.blacklistMask) - || !ReadAppDword(BASELINE_NOJOY_VALUE, noJoy)) - return false; - baseline.noJoy = noJoy != 0; - return true; -} - -bool SaveBaseline(const SteamBaseline& baseline) { - HKEY key = nullptr; - if (RegCreateKeyExW(HKEY_CURRENT_USER, APP_REG_KEY, 0, nullptr, - REG_OPTION_NON_VOLATILE, KEY_SET_VALUE, nullptr, &key, nullptr) - != ERROR_SUCCESS) - return false; - - // Captured is written last so a partial write is never treated as a - // complete restore point. - const bool ok = WriteAppDword(key, BASELINE_BLACKLIST_VALUE, baseline.blacklistMask) - && WriteAppDword(key, BASELINE_NOJOY_VALUE, baseline.noJoy ? 1 : 0) - && WriteAppDword(key, BASELINE_CAPTURED_VALUE, 1); - RegCloseKey(key); - return ok; -} - -bool EnsureBaseline(const std::string& currentConfig, const std::wstring& startupCommand, - SteamBaseline& baseline) { - if (LoadBaseline(baseline)) return true; - - // a1f9b80 could already have applied a strategy before baseline tracking - // existed. That version explicitly managed all three Valve IDs and - // -nojoy, so migrate them as Steamless-owned. Fresh installs capture the - // actual pre-management state and restore it on uninstall. - const bool migrating = HasSavedStrategy(); - baseline.blacklistMask = migrating ? 0 : ControllerBlacklistConfigMask(currentConfig); - baseline.noJoy = !migrating && CommandHasNoJoy(startupCommand); - return SaveBaseline(baseline); -} - -void DeleteSteamlessStartupAndSettings() { - HKEY runKey = nullptr; - if (RegOpenKeyExW(HKEY_CURRENT_USER, RUN_REG_KEY, 0, KEY_SET_VALUE, &runKey) - == ERROR_SUCCESS) { - RegDeleteValueW(runKey, APP_RUN_VALUE); - RegCloseKey(runKey); - } - RegDeleteTreeW(HKEY_CURRENT_USER, APP_REG_KEY); -} - -bool SaveStrategy(SteamStrategy strategy) { - HKEY key = nullptr; - if (RegCreateKeyExW(HKEY_CURRENT_USER, APP_REG_KEY, 0, nullptr, - REG_OPTION_NON_VOLATILE, KEY_SET_VALUE, nullptr, &key, nullptr) - != ERROR_SUCCESS) - return false; - const DWORD value = static_cast(strategy); - const bool ok = RegSetValueExW(key, STRATEGY_VALUE, 0, REG_DWORD, - reinterpret_cast(&value), sizeof(value)) - == ERROR_SUCCESS; - RegCloseKey(key); - return ok; -} - -bool StopSteam(const std::wstring& steamExe, std::wstring& error) { - if (!IsSteamProcessRunning()) return true; - std::wstring command = QuoteArgument(steamExe) + L" -shutdown"; - STARTUPINFOW si{}; - si.cb = sizeof(si); - PROCESS_INFORMATION pi{}; - if (!CreateProcessW(nullptr, command.data(), nullptr, nullptr, FALSE, 0, - nullptr, nullptr, &si, &pi)) { - error = L"Steam could not be asked to shut down."; - return false; - } - CloseHandle(pi.hThread); - CloseHandle(pi.hProcess); - - for (int waited = 0; waited < 30000; waited += 200) { - if (!IsSteamProcessRunning()) return true; - Sleep(200); - } - error = L"Steam did not finish shutting down, so no settings were changed."; - return false; -} - -bool StartSteam(std::wstring command, std::wstring& error) { - STARTUPINFOW si{}; - si.cb = sizeof(si); - PROCESS_INFORMATION pi{}; - if (!CreateProcessW(nullptr, command.data(), nullptr, nullptr, FALSE, 0, - nullptr, nullptr, &si, &pi)) { - error = L"The strategy was saved, but Steam could not be restarted."; - return false; - } - CloseHandle(pi.hThread); - CloseHandle(pi.hProcess); - return true; -} - -bool ConfigContainsSteamController(const std::wstring& configPath) { - std::string config; - if (!ReadFileBytes(configPath, config)) return false; - return ControllerBlacklistConfigMask(config) != 0; -} - -} // namespace - -namespace SteamStrategies { - -SteamStrategy DetectConfigured() { - DWORD saved = 0; - DWORD bytes = sizeof(saved); - if (RegGetValueW(HKEY_CURRENT_USER, APP_REG_KEY, STRATEGY_VALUE, - RRF_RT_REG_DWORD, nullptr, &saved, &bytes) == ERROR_SUCCESS - && saved <= static_cast(SteamStrategy::NoJoy)) - return static_cast(saved); - - std::wstring startup; - if (ReadRegistryString(HKEY_CURRENT_USER, RUN_REG_KEY, STEAM_RUN_VALUE, startup) - && CommandHasNoJoy(startup)) - return SteamStrategy::NoJoy; - - std::wstring steamPath, steamExe, configPath; - if (GetSteamPaths(steamPath, steamExe, configPath) - && ConfigContainsSteamController(configPath)) - return SteamStrategy::ControllerBlacklist; - return SteamStrategy::YieldToSteam; -} - -ApplyResult ApplyAndRestart(SteamStrategy strategy, bool launchIfStopped) { - std::wstring steamPath, steamExe, configPath; - if (!GetSteamPaths(steamPath, steamExe, configPath)) - return {false, L"Steam's installation or config.vdf could not be found."}; - - const bool steamWasRunning = IsSteamProcessRunning(); - const bool shouldLaunchSteam = steamWasRunning || launchIfStopped; - std::wstring existingRun; - const bool steamRunExists = ReadRegistryString( - HKEY_CURRENT_USER, RUN_REG_KEY, STEAM_RUN_VALUE, existingRun); - const bool previousNoJoy = CommandHasNoJoy(existingRun); - const std::wstring previousLaunchCommand = SteamCommand( - steamExe, existingRun, previousNoJoy); - std::wstring error; - if (!StopSteam(steamExe, error)) return {false, std::move(error)}; - auto failAfterStop = [&](std::wstring message) { - if (shouldLaunchSteam) { - std::wstring restartError; - if (!StartSteam(previousLaunchCommand, restartError)) { - message += L" Steam also could not be restarted with its previous settings."; - } - } - return ApplyResult{false, std::move(message)}; - }; - - std::string original; - if (!ReadFileBytes(configPath, original)) - return failAfterStop(L"Steam was stopped, but config.vdf could not be read."); - const std::wstring backupPath = configPath + L".steamlesscontroller.bak"; - if (GetFileAttributesW(backupPath.c_str()) == INVALID_FILE_ATTRIBUTES - && !CopyFileW(configPath.c_str(), backupPath.c_str(), TRUE)) - return failAfterStop(L"Steam was stopped, but config.vdf could not be backed up."); - - SteamBaseline baseline; - if (!EnsureBaseline(original, existingRun, baseline)) - return failAfterStop(L"Steam was stopped, but its original settings could not be saved."); - - const DWORD desiredBlacklist = strategy == SteamStrategy::ControllerBlacklist - ? ALL_STEAM_CONTROLLER_IDS - : strategy == SteamStrategy::YieldToSteam ? baseline.blacklistMask : 0; - const bool desiredNoJoy = strategy == SteamStrategy::NoJoy - || (strategy == SteamStrategy::YieldToSteam && baseline.noJoy); - const std::wstring launchCommand = SteamCommand(steamExe, existingRun, desiredNoJoy); - const std::string updated = UpdateControllerBlacklist(original, desiredBlacklist); - if (updated.empty()) - return failAfterStop(L"Steam was stopped, but config.vdf was not valid VDF."); - if (updated != original && !WriteFileAtomically(configPath, updated)) - return failAfterStop(L"Steam was stopped, but config.vdf could not be updated."); - - if (!WriteSteamRunCommandIfPresent(launchCommand, error)) { - if (updated != original) WriteFileAtomically(configPath, original); - return failAfterStop(std::move(error)); - } - if (!SaveStrategy(strategy)) { - if (updated != original) WriteFileAtomically(configPath, original); - if (steamRunExists) { - std::wstring ignored; - WriteSteamRunCommandIfPresent(existingRun, ignored); - } - return failAfterStop(L"The strategy could not be saved."); - } - - EventLog::Write("STEAM STRATEGY: applied %d (0=yield 1=blacklist 2=nojoy)", - static_cast(strategy)); - if (shouldLaunchSteam && !StartSteam(launchCommand, error)) - return {true, std::move(error)}; - return {true, {}}; -} - -ApplyResult CleanupForUninstall() { - SteamBaseline baseline; - const bool hasBaseline = LoadBaseline(baseline); - const bool hasStrategy = HasSavedStrategy(); - std::wstring steamPath, steamExe, configPath; - if (!GetSteamPaths(steamPath, steamExe, configPath)) { - // Steam itself is gone, so there is no persistent Steam config left to - // restore. Still remove SteamlessController's per-user state. - DeleteSteamlessStartupAndSettings(); - return {true, {}}; - } - const std::wstring backupPath = configPath + L".steamlesscontroller.bak"; - if (!hasBaseline && !hasStrategy) { - DeleteFileW(backupPath.c_str()); - DeleteSteamlessStartupAndSettings(); - return {true, {}}; - } - - std::wstring existingRun; - ReadRegistryString(HKEY_CURRENT_USER, RUN_REG_KEY, STEAM_RUN_VALUE, existingRun); - const bool steamWasRunning = IsSteamProcessRunning(); - const std::wstring previousLaunchCommand = SteamCommand( - steamExe, existingRun, CommandHasNoJoy(existingRun)); - std::wstring error; - if (!StopSteam(steamExe, error)) return {false, std::move(error)}; - auto failAfterStop = [&](std::wstring message) { - if (steamWasRunning) { - std::wstring restartError; - if (!StartSteam(previousLaunchCommand, restartError)) - message += L" Steam also could not be restarted with its previous settings."; - } - return ApplyResult{false, std::move(message)}; - }; - - std::string original; - if (!ReadFileBytes(configPath, original)) - return failAfterStop(L"Steam was stopped, but config.vdf could not be read for cleanup."); - if (!EnsureBaseline(original, existingRun, baseline)) - return failAfterStop(L"Steam was stopped, but its original settings could not be recovered."); - - const std::string restored = UpdateControllerBlacklist(original, baseline.blacklistMask); - if (restored.empty() || (restored != original && !WriteFileAtomically(configPath, restored))) - return failAfterStop(L"Steam was stopped, but its controller blacklist could not be restored."); - - const std::wstring restoredRun = SteamCommand(steamExe, existingRun, baseline.noJoy); - if (!WriteSteamRunCommandIfPresent(restoredRun, error)) { - if (restored != original) WriteFileAtomically(configPath, original); - return failAfterStop(std::move(error)); - } - - EventLog::Write("STEAM STRATEGY: restored pre-Steamless settings for uninstall"); - DeleteFileW(backupPath.c_str()); - DeleteSteamlessStartupAndSettings(); - if (steamWasRunning && !StartSteam(restoredRun, error)) - return {true, std::move(error)}; - return {true, {}}; -} - -bool IsGameRunning() { - DWORD appId = 0; - DWORD bytes = sizeof(appId); - return RegGetValueW(HKEY_CURRENT_USER, STEAM_REG_KEY, L"RunningAppID", - RRF_RT_REG_DWORD, nullptr, &appId, &bytes) == ERROR_SUCCESS - && appId != 0; -} - -bool IsSteamRunning() { - return IsSteamProcessRunning(); -} - -bool IsNoJoySelected() { - return DetectConfigured() == SteamStrategy::NoJoy; -} - -const wchar_t* Tooltip(SteamStrategy strategy) { - switch (strategy) { - case SteamStrategy::YieldToSteam: - return L"Restores the Steam Controller blacklist and -nojoy state recorded before " - L"SteamlessController first changed them. Steamless then yields whenever " - L"Steam can see the physical controller; the Steam Handoff menu controls " - L"whether it may take over while Steam is idle."; - case SteamStrategy::ControllerBlacklist: - return L"Persists across Steam restarts. Steam stays open and other controller " - L"types keep Steam Input, but Steam Controller layouts, gyro, and touch " - L"menus in Steam are unavailable. Choose 'Restore Steam's original " - L"controller settings' to undo it; uninstall also restores the baseline."; - case SteamStrategy::NoJoy: - return L"Adds -nojoy to Steam's existing Windows startup command. Steam can stay " - L"open, but all Steam Input and Big Picture controller navigation are " - L"disabled. Launching Steam another way without the flag bypasses it. " - L"Choose 'Restore Steam's original controller settings' to undo it; " - L"uninstall also restores the baseline startup command."; - } - return L""; -} - -const wchar_t* StatusLabel(SteamStrategy strategy) { - switch (strategy) { - case SteamStrategy::YieldToSteam: return L"Steam's original controller settings"; - case SteamStrategy::ControllerBlacklist: return L"Steam Controller reserved for Steamless"; - case SteamStrategy::NoJoy: return L"Steam controller support disabled (-nojoy)"; - } - return L"Unknown"; -} - -const wchar_t* AppliedMessage(SteamStrategy strategy) { - switch (strategy) { - case SteamStrategy::YieldToSteam: - return L"Steam's original controller settings are restored. Steamless will yield " - L"whenever Steam can see the physical controller."; - case SteamStrategy::ControllerBlacklist: - return L"The Steam Controller is now reserved for Steamless. Choose 'Restore Steam's " - L"original controller settings' at any time to undo this."; - case SteamStrategy::NoJoy: - return L"Steam now starts with -nojoy. Choose 'Restore Steam's original controller " - L"settings' at any time to undo this."; - } - return L""; -} - -} // namespace SteamStrategies diff --git a/src/app/SteamStrategy.h b/src/app/SteamStrategy.h deleted file mode 100644 index 3e24f4c..0000000 --- a/src/app/SteamStrategy.h +++ /dev/null @@ -1,39 +0,0 @@ -#pragma once - -#include - -enum class SteamStrategy { - YieldToSteam = 0, - ControllerBlacklist = 1, - NoJoy = 2, -}; - -namespace SteamStrategies { - -struct ApplyResult { - bool applied = false; - std::wstring message; -}; - -// Loads the saved choice, or infers it from Steam's current config for -// installations that predate this setting. -SteamStrategy DetectConfigured(); - -// Applies the selected strategy and restarts Steam when it was running. -// launchIfStopped preserves the debug menu's explicit relaunch behavior while -// allowing the tray on/off switch to leave an already-closed Steam closed. -ApplyResult ApplyAndRestart(SteamStrategy strategy, bool launchIfStopped = true); - -// Restores the Steam settings captured before the first strategy change, -// removes SteamlessController's own startup/settings entries, and restarts -// Steam if it was running. Used by the uninstaller. -ApplyResult CleanupForUninstall(); - -bool IsGameRunning(); -bool IsSteamRunning(); -bool IsNoJoySelected(); -const wchar_t* Tooltip(SteamStrategy strategy); -const wchar_t* StatusLabel(SteamStrategy strategy); -const wchar_t* AppliedMessage(SteamStrategy strategy); - -} // namespace SteamStrategies diff --git a/src/app/SteamWatcher.cpp b/src/app/SteamWatcher.cpp index d866c2a..92a8bad 100644 --- a/src/app/SteamWatcher.cpp +++ b/src/app/SteamWatcher.cpp @@ -1,267 +1,30 @@ #include "SteamWatcher.h" -#include "EventLog.h" -#include "SteamStrategy.h" #include #include -#include -#include -#include -#include -#include -#include static constexpr int POLL_INTERVAL_MS = 2000; static constexpr int POLL_SLICE_MS = 100; // wake often for fast Stop() static constexpr int LESS_STEAM_POLL_COUNT = 3; // ~6s stable before we take over -struct SteamProcess { - bool running = false; - DWORD pid = 0; - FILETIME started{}; -}; - -static SteamProcess FindSteamProcess() { +static bool IsSteamProcessRunning() { HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); - if (snap == INVALID_HANDLE_VALUE) return {}; + if (snap == INVALID_HANDLE_VALUE) return false; PROCESSENTRY32W pe{}; pe.dwSize = sizeof(pe); - DWORD pid = 0; + bool found = false; if (Process32FirstW(snap, &pe)) { do { if (_wcsicmp(pe.szExeFile, L"steam.exe") == 0) { - pid = pe.th32ProcessID; + found = true; break; } } while (Process32NextW(snap, &pe)); } CloseHandle(snap); - if (pid == 0) return {}; - - SteamProcess result{}; - result.running = true; - result.pid = pid; - HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); - if (!process) return result; - - FILETIME exited{}, kernel{}, user{}; - GetProcessTimes(process, &result.started, &exited, &kernel, &user); - CloseHandle(process); - return result; -} - -static bool CommandLineHasNoJoy(const std::wstring& commandLine) { - int count = 0; - LPWSTR* argv = CommandLineToArgvW(commandLine.c_str(), &count); - if (!argv) return false; - bool found = false; - for (int i = 1; i < count; ++i) { - if (_wcsicmp(argv[i], L"-nojoy") == 0) { - found = true; - break; - } - } - LocalFree(argv); return found; } -// Win32_Process.CommandLine is a documented Windows source for another -// process's command line. It lets the watcher prove the running Steam session -// really has -nojoy instead of trusting a stale startup setting. -static bool SteamProcessHasNoJoy(DWORD pid) { - IWbemLocator* locator = nullptr; - IWbemServices* services = nullptr; - IEnumWbemClassObject* results = nullptr; - IWbemClassObject* object = nullptr; - bool hasNoJoy = false; - - HRESULT hr = CoCreateInstance(CLSID_WbemLocator, nullptr, CLSCTX_INPROC_SERVER, - IID_IWbemLocator, - reinterpret_cast(&locator)); - if (FAILED(hr)) goto cleanup; - - { - BSTR namespaceName = SysAllocString(L"ROOT\\CIMV2"); - hr = namespaceName - ? locator->ConnectServer(namespaceName, nullptr, nullptr, nullptr, 0, - nullptr, nullptr, &services) - : E_OUTOFMEMORY; - SysFreeString(namespaceName); - } - if (FAILED(hr)) goto cleanup; - - hr = CoSetProxyBlanket(services, RPC_C_AUTHN_WINNT, RPC_C_AUTHZ_NONE, nullptr, - RPC_C_AUTHN_LEVEL_CALL, RPC_C_IMP_LEVEL_IMPERSONATE, - nullptr, EOAC_NONE); - if (FAILED(hr)) goto cleanup; - - { - const std::wstring queryText = L"SELECT CommandLine FROM Win32_Process WHERE ProcessId=" - + std::to_wstring(pid); - BSTR language = SysAllocString(L"WQL"); - BSTR query = SysAllocString(queryText.c_str()); - hr = language && query - ? services->ExecQuery(language, query, - WBEM_FLAG_FORWARD_ONLY | WBEM_FLAG_RETURN_IMMEDIATELY, - nullptr, &results) - : E_OUTOFMEMORY; - SysFreeString(language); - SysFreeString(query); - } - if (FAILED(hr) || !results) goto cleanup; - - { - ULONG returned = 0; - hr = results->Next(2000, 1, &object, &returned); - if (FAILED(hr) || returned != 1 || !object) goto cleanup; - - VARIANT value{}; - VariantInit(&value); - hr = object->Get(L"CommandLine", 0, &value, nullptr, nullptr); - if (SUCCEEDED(hr) && V_VT(&value) == VT_BSTR && V_BSTR(&value)) - hasNoJoy = CommandLineHasNoJoy(V_BSTR(&value)); - VariantClear(&value); - } - -cleanup: - if (object) object->Release(); - if (results) results->Release(); - if (services) services->Release(); - if (locator) locator->Release(); - return hasNoJoy; -} - -static bool ReadRegistryString(HKEY root, const wchar_t* subkey, const wchar_t* name, - std::wstring& value) { - DWORD bytes = 0; - if (RegGetValueW(root, subkey, name, RRF_RT_REG_SZ, nullptr, nullptr, &bytes) - != ERROR_SUCCESS || bytes < sizeof(wchar_t)) - return false; - - value.resize(bytes / sizeof(wchar_t)); - if (RegGetValueW(root, subkey, name, RRF_RT_REG_SZ, nullptr, value.data(), &bytes) - != ERROR_SUCCESS) - return false; - while (!value.empty() && value.back() == L'\0') value.pop_back(); - return !value.empty(); -} - -static bool ReadFileTail(const std::wstring& path, size_t maxBytes, std::string& text, - FILETIME* lastWrite = nullptr) { - HANDLE file = CreateFileW(path.c_str(), GENERIC_READ, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); - if (file == INVALID_HANDLE_VALUE) return false; - - LARGE_INTEGER size{}; - if (!GetFileSizeEx(file, &size) || size.QuadPart < 0) { - CloseHandle(file); - return false; - } - if (lastWrite && !GetFileTime(file, nullptr, nullptr, lastWrite)) { - CloseHandle(file); - return false; - } - - const ULONGLONG fileBytes = static_cast(size.QuadPart); - const DWORD bytes = static_cast(std::min(fileBytes, maxBytes)); - LARGE_INTEGER offset{}; - offset.QuadPart = size.QuadPart - bytes; - if (!SetFilePointerEx(file, offset, nullptr, FILE_BEGIN)) { - CloseHandle(file); - return false; - } - - text.resize(bytes); - DWORD read = 0; - const bool ok = bytes == 0 || ReadFile(file, text.data(), bytes, &read, nullptr); - CloseHandle(file); - if (!ok) return false; - text.resize(read); - return true; -} - -static void LowerAscii(std::string& text) { - std::transform(text.begin(), text.end(), text.begin(), [](unsigned char ch) { - return static_cast(std::tolower(ch)); - }); -} - -static bool ReportControllerHidden(bool hidden, const char* reason) { - static std::string previous; - const std::string current = std::string(hidden ? "hidden: " : "not hidden: ") + reason; - if (current != previous) { - EventLog::Write("STEAM: physical controller %s", current.c_str()); - previous = current; - } - return hidden; -} - -static std::string ControllerBlacklistValue(const std::string& config) { - constexpr char key[] = "\"controller_blacklist\""; - const size_t keyPos = config.rfind(key); - if (keyPos == std::string::npos) return {}; - const size_t open = config.find('"', keyPos + sizeof(key) - 1); - if (open == std::string::npos) return {}; - const size_t close = config.find('"', open + 1); - if (close == std::string::npos) return {}; - return config.substr(open + 1, close - open - 1); -} - -// Steam writes an explicit line for every device suppressed by -// controller_blacklist. Requiring that line from the current client session is -// important: editing config.vdf while Steam is already running does not revoke -// the handles Steam has open. Any missing or ambiguous evidence fails closed. -static bool IsControllerHiddenFromSteam(const SteamProcess& steam) { - if (!steam.running || (steam.started.dwLowDateTime == 0 && steam.started.dwHighDateTime == 0)) - return ReportControllerHidden(false, "process start time unavailable"); - - std::wstring steamPath; - if (!ReadRegistryString(HKEY_CURRENT_USER, L"Software\\Valve\\Steam", L"SteamPath", - steamPath)) - return ReportControllerHidden(false, "SteamPath unavailable"); - std::replace(steamPath.begin(), steamPath.end(), L'/', L'\\'); - - std::string config; - const std::wstring configPath = steamPath + L"\\config\\config.vdf"; - if (!ReadFileTail(configPath, 1024 * 1024, config)) - return ReportControllerHidden(false, "config.vdf unavailable"); - LowerAscii(config); - const std::string blacklist = ControllerBlacklistValue(config); - if (blacklist.empty()) - return ReportControllerHidden(false, "controller_blacklist missing"); - - FILETIME logWrite{}; - std::string log; - if (!ReadFileTail(steamPath + L"\\logs\\controller.txt", 4 * 1024 * 1024, log, - &logWrite) - || CompareFileTime(&logWrite, &steam.started) < 0) - return ReportControllerHidden(false, "current controller log unavailable"); - LowerAscii(log); - - const size_t sessionPos = log.rfind("client version:"); - if (sessionPos == std::string::npos) - return ReportControllerHidden(false, "current controller session unavailable"); - const std::string session = log.substr(sessionPos); - - constexpr const char* ids[] = { - "28de/1302", "28de/1303", "28de/1304", "28de/1305" - }; - bool hidden = false; - for (const char* id : ids) { - std::string type(id); - std::replace(type.begin(), type.end(), '/', ' '); - if (session.find("type: " + type) != std::string::npos) - return ReportControllerHidden(false, "Steam opened a physical controller"); - if (blacklist.find(id) != std::string::npos - && session.find(std::string("hiding blacklisted device ") + id) - != std::string::npos) - hidden = true; - } - return ReportControllerHidden(hidden, hidden ? "confirmed by current Steam session" - : "no matching hide event"); -} - // Steam publishes the app id of the running game here and resets it to 0 on // exit. Covers Steam games and non-Steam shortcuts launched through Steam; // games launched entirely outside Steam are invisible to it (acceptable — we @@ -276,17 +39,7 @@ static bool IsGameRunning() { } SteamState SteamWatcher::Detect() { - const SteamProcess steam = FindSteamProcess(); - if (!steam.running) return SteamState::NoSteam; - if (SteamStrategies::IsNoJoySelected()) { - if (SteamProcessHasNoJoy(steam.pid)) { - ReportControllerHidden(true, "confirmed Steam -nojoy command line"); - return SteamState::ControllerHidden; - } - ReportControllerHidden(false, "selected -nojoy is absent from running Steam"); - return IsGameRunning() ? SteamState::InGame : SteamState::SteamIdle; - } - if (IsControllerHiddenFromSteam(steam)) return SteamState::ControllerHidden; + if (!IsSteamProcessRunning()) return SteamState::NoSteam; return IsGameRunning() ? SteamState::InGame : SteamState::SteamIdle; } @@ -303,7 +56,6 @@ void SteamWatcher::Stop() { } void SteamWatcher::PollLoop() { - const HRESULT com = CoInitializeEx(nullptr, COINIT_MULTITHREADED); SteamState reported = Detect(); m_state = reported; if (m_onChange) m_onChange(reported); @@ -345,5 +97,4 @@ void SteamWatcher::PollLoop() { } } } - if (SUCCEEDED(com)) CoUninitialize(); } diff --git a/src/app/SteamWatcher.h b/src/app/SteamWatcher.h index 8853d2b..29496b1 100644 --- a/src/app/SteamWatcher.h +++ b/src/app/SteamWatcher.h @@ -8,10 +8,9 @@ // reported immediately (yield the controller fast), transitions to a LOWER // value are debounced (don't grab it during a Steam restart or game relaunch). enum class SteamState { - NoSteam = 0, // steam.exe not running - ControllerHidden = 1, // Steam is running but its active session ignored the controller - SteamIdle = 2, // Steam running, no game active (RunningAppID == 0) - InGame = 3, // Steam running a game + NoSteam = 0, // steam.exe not running + SteamIdle = 1, // Steam running, no game active (RunningAppID == 0) + InGame = 2, // Steam running a game }; // Watches the Steam process and its RunningAppID registry value, reporting diff --git a/src/app/TrayApp.cpp b/src/app/TrayApp.cpp index 14659e1..bfba57f 100644 --- a/src/app/TrayApp.cpp +++ b/src/app/TrayApp.cpp @@ -3,19 +3,15 @@ #include "ControllerPlatform.h" #include "DeviceRestart.h" #include "EventLog.h" -#include "SteamStrategy.h" #include "steam/SteamController.h" #include "resource.h" #include -#include #include #include #include #include #include -#pragma comment(linker, "\"/manifestdependency:type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'\"") - static TrayApp* g_app = nullptr; static constexpr wchar_t WNDCLASS_NAME[] = L"SteamlessControllerTray"; @@ -61,7 +57,6 @@ TrayApp::~TrayApp() { // Stop the watcher before anything else so its callback can't post to a // window (or reference a controller) that is being torn down. m_steamWatcher.Stop(); - if (m_menuTooltip) DestroyWindow(m_menuTooltip); RemoveTrayIcon(); g_app = nullptr; } @@ -84,8 +79,6 @@ bool TrayApp::Init(HINSTANCE hInstance) { 0, 0, 0, 0, 0, HWND_MESSAGE, nullptr, hInstance, nullptr); if (!m_hwnd) return false; - CreateMenuTooltip(); - // Register for HID device arrival/removal notifications. DEV_BROADCAST_DEVICEINTERFACE_W filter{}; filter.dbcc_size = sizeof(filter); @@ -111,15 +104,9 @@ bool TrayApp::Init(HINSTANCE hInstance) { }); LoadSettings(); - m_steamStrategy = SteamStrategies::DetectConfigured(); - if (!m_preferredStrategyLoaded) - m_preferredSteamStrategy = m_steamStrategy; - // An old Manual setting did not mean the Steam reservation itself was - // inactive. On first migration, reflect the configuration that is really - // applied rather than claiming Steamless is off while Steam remains blocked. - if (!m_enabledSettingLoaded && m_steamStrategy != SteamStrategy::YieldToSteam) - m_steamlessEnabled = true; - // Converge the persisted Start with Windows choice with the Run key. + // Converge the startup mechanism with the loaded mode — e.g. the first + // elevated run after enabling in-game mode migrates the Run key to the + // highest-privileges logon task (and back when the mode changes). UpdateStartupRegistration(); AddTrayIcon(); UpdateTrayIcon(m_controller->IsConnected(), m_controller->IsGameModeActive(), false); @@ -131,14 +118,13 @@ bool TrayApp::Init(HINSTANCE hInstance) { // the cycle itself — so the UAC prompt ambushed the user mid-game-launch. // A no-op once the task exists, which is the common case. Must run after // AddTrayIcon, since a declined prompt reports via the tray balloon. - if (m_steamlessEnabled - && m_steamStrategy == SteamStrategy::YieldToSteam - && m_handoffMode == HandoffMode::OnlyInGame) + if (m_autoMode == AutoMode::OffOnlyInGame) EnsureCycleTaskRegistered(); - // Keep Steam state warm even while Steamless is switched off so turning it - // back on can apply the selected ownership policy immediately. The initial - // callback also asserts the correct state after startup or a crash. + // Start watching for Steam regardless of auto mode — the state is applied + // only when auto mode is on, and having it warm makes toggling auto mode + // take effect instantly. The initial callback asserts the correct state + // at startup (which also self-heals after a previous crash). m_steamWatcher.Start([this](SteamState state) { PostMessageW(m_hwnd, WM_STEAMSTATE, static_cast(state), 0); }); @@ -172,27 +158,19 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { if (LOWORD(lp) == NIN_BALLOONUSERCLICK) ShellExecuteW(nullptr, L"open", L"https://github.com/nefarius/ViGEmBus/releases/latest", nullptr, nullptr, SW_SHOWNORMAL); - else if (LOWORD(lp) == WM_LBUTTONDBLCLK) { - KillTimer(m_hwnd, IDT_TRAY_CLICK); - m_suppressNextLeftUp = true; + else if (LOWORD(lp) == WM_LBUTTONDBLCLK) OpenRemapWindow(); - } else if (LOWORD(lp) == WM_LBUTTONUP) { - if (m_suppressNextLeftUp) { - m_suppressNextLeftUp = false; - } else { - // Delay until the double-click window closes so the existing - // double-click remap action does not also toggle twice. - SetTimer(m_hwnd, IDT_TRAY_CLICK, GetDoubleClickTime(), nullptr); - } - } else if (LOWORD(lp) == WM_RBUTTONUP) { + else if (LOWORD(lp) == WM_RBUTTONUP || LOWORD(lp) == WM_LBUTTONUP) ShowContextMenu(); - } return 0; case WM_COMMAND: switch (LOWORD(wp)) { case IDM_TOGGLE: - ToggleSteamlessMode(); + if (m_controller->IsGameModeActive()) + m_controller->DisableGameMode(); + else + m_controller->EnableGameMode(); break; case IDM_TRACKPAD: m_controller->SetTrackpadMouseEnabled(!m_controller->IsTrackpadMouseEnabled()); @@ -217,15 +195,18 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { m_controller->SetControllerPlatform(ControllerPlatform::PlayStation); SaveSettings(); break; - case IDM_HANDOFF_STEAM: - SetHandoffMode(HandoffMode::WhileSteamRuns); + case IDM_MODE_MANUAL: + SetAutoMode(AutoMode::Manual); + break; + case IDM_MODE_STEAM: + SetAutoMode(AutoMode::OffWhileSteam); break; - case IDM_HANDOFF_GAME: + case IDM_MODE_GAME: // Make sure the elevated helper task exists (one-time UAC when // the installer didn't register it) before the mode needs it. if (!IsProcessElevated()) EnsureCycleTaskRegistered(); - SetHandoffMode(HandoffMode::OnlyInGame); + SetAutoMode(AutoMode::OffOnlyInGame); break; case IDM_STARTUP: m_startupEnabled = !m_startupEnabled; @@ -240,15 +221,6 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { m_notificationsEnabled ? "enabled" : "disabled"); SaveSettings(); break; - case IDM_STEAM_YIELD: - ApplySteamStrategy(SteamStrategy::YieldToSteam); - break; - case IDM_STEAM_BLACKLIST: - ApplySteamStrategy(SteamStrategy::ControllerBlacklist); - break; - case IDM_STEAM_NOJOY: - ApplySteamStrategy(SteamStrategy::NoJoy); - break; case IDM_EXIT: EventLog::Write("=== SteamlessController exiting (user request) ==="); m_controller->DisableGameMode(); @@ -257,27 +229,11 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { } return 0; - case WM_MENUSELECT: { - const UINT flags = HIWORD(wp); - const UINT commandId = LOWORD(wp); - if (flags == 0xFFFF || (flags & MF_POPUP) != 0) - HideMenuTooltip(); - else - ShowMenuTooltip(commandId); - return 0; - } - - case WM_EXITMENULOOP: - HideMenuTooltip(); - return 0; - case WM_STEAMSTATE: { const auto steamState = static_cast(wp); - // This remains current while Steamless is switched off. If the user - // turns it back on, a shared claim must still be rejected whenever - // Steam can see and may own the physical controller. - m_controller->SetSteamMayOwnController(steamState == SteamState::SteamIdle - || steamState == SteamState::InGame); + // Outside ApplySteamState, which returns early in Manual mode — the + // shared-handle decision needs to know about Steam in every mode. + m_controller->SetSteamPresent(steamState != SteamState::NoSteam); ApplySteamState(steamState); return 0; } @@ -299,21 +255,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 (WantControl(m_steamWatcher.GetState())) + if (m_autoMode != AutoMode::Manual && WantControl(m_steamWatcher.GetState())) TryAcquireController(); - } else if (wp == IDT_TRAY_CLICK) { - KillTimer(m_hwnd, IDT_TRAY_CLICK); - ToggleSteamlessMode(); } return 0; case WM_DEVICECHANGE: if (wp == DBT_DEVICEARRIVAL || wp == DBT_DEVICEREMOVECOMPLETE) { m_controller->OnDeviceChange(); - // A controller arriving (plugged in, waking, or re-arriving after - // a device cycle) should be acquired whenever the current policy - // wants control. - if (wp == DBT_DEVICEARRIVAL && WantControl(m_steamWatcher.GetState())) + // 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())) TryAcquireController(); } return TRUE; @@ -325,151 +279,29 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { return DefWindowProcW(hwnd, msg, wp, lp); } -void TrayApp::SetHandoffMode(HandoffMode mode) { - EventLog::Write("MODE: Steam handoff set to %d (1=whileSteamRuns 2=onlyInGame)", +void TrayApp::SetAutoMode(AutoMode mode) { + EventLog::Write("MODE: control mode set to %d (0=manual 1=offWhileSteam 2=offOnlyInGame)", static_cast(mode)); - m_handoffMode = mode; + m_autoMode = mode; m_acquireRetries = 0; - m_waitingForWake = false; - KillTimer(m_hwnd, IDT_ACQUIRE); SaveSettings(); - if (m_steamlessEnabled) + UpdateStartupRegistration(); + if (mode != AutoMode::Manual) ApplySteamState(m_steamWatcher.GetState()); } bool TrayApp::WantControl(SteamState state) const { - if (!m_steamlessEnabled) return false; - switch (m_handoffMode) { - case HandoffMode::WhileSteamRuns: - return state == SteamState::NoSteam || state == SteamState::ControllerHidden; - case HandoffMode::OnlyInGame: - return state != SteamState::InGame; + switch (m_autoMode) { + case AutoMode::OffWhileSteam: return state == SteamState::NoSteam; + case AutoMode::OffOnlyInGame: return state != SteamState::InGame; + default: return false; // Manual: tray toggle decides } - return false; -} - -bool TrayApp::ApplySteamStrategy(SteamStrategy strategy, - bool launchIfStopped, - bool enableSteamless, - bool rememberPreference, - bool confirmRunningGame) { - if (confirmRunningGame && SteamStrategies::IsGameRunning()) { - const int answer = MessageBoxW( - m_hwnd, - L"A Steam game is running. Applying this option will close the game when " - L"Steam restarts. Apply it now?", - L"Restart Steam", - MB_ICONWARNING | MB_YESNO | MB_DEFBUTTON2); - if (answer != IDYES) return false; - } - - EventLog::Write("USER: applying Steam coexistence strategy %d", - static_cast(strategy)); - KillTimer(m_hwnd, IDT_ACQUIRE); - m_acquireRetries = 0; - m_waitingForWake = false; - m_steamWatcher.Stop(); - - // Steam must close before config.vdf is edited, and releasing first gives - // lizard mode a chance to come back while neither application owns input. - m_controller->ReleaseDevices(); - MSG stale{}; - while (PeekMessageW(&stale, m_hwnd, WM_STEAMSTATE, WM_STEAMSTATE, PM_REMOVE)) {} - - const SteamStrategies::ApplyResult result = - SteamStrategies::ApplyAndRestart(strategy, launchIfStopped); - if (result.applied) { - m_steamStrategy = strategy; - m_steamlessEnabled = enableSteamless; - if (rememberPreference) - m_preferredSteamStrategy = strategy; - SaveSettings(); - if (strategy == SteamStrategy::YieldToSteam - && m_steamlessEnabled - && m_handoffMode == HandoffMode::OnlyInGame) - EnsureCycleTaskRegistered(); - } - - m_steamWatcher.Start([this](SteamState state) { - PostMessageW(m_hwnd, WM_STEAMSTATE, static_cast(state), 0); - }); - - if (!result.message.empty()) { - MessageBoxW(m_hwnd, result.message.c_str(), - result.applied ? L"Steam restart warning" : L"Steam strategy error", - MB_OK | (result.applied ? MB_ICONWARNING : MB_ICONERROR)); - } else if (result.applied) { - ShowInfoBalloon(enableSteamless ? L"Steam coexistence updated" - : L"Steamless turned off", - enableSteamless - ? SteamStrategies::AppliedMessage(strategy) - : L"Steam's original controller settings were restored. Steam can " - L"take the physical controller again."); - } - return result.applied; -} - -void TrayApp::CreateMenuTooltip() { - INITCOMMONCONTROLSEX controls{}; - controls.dwSize = sizeof(controls); - controls.dwICC = ICC_WIN95_CLASSES; - InitCommonControlsEx(&controls); - - m_menuTooltip = CreateWindowExW( - WS_EX_TOPMOST, TOOLTIPS_CLASSW, nullptr, - WS_POPUP | TTS_ALWAYSTIP | TTS_NOPREFIX, - CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, - m_hwnd, nullptr, m_hInstance, nullptr); - if (!m_menuTooltip) return; - - SetWindowPos(m_menuTooltip, HWND_TOPMOST, 0, 0, 0, 0, - SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); - SendMessageW(m_menuTooltip, TTM_SETMAXTIPWIDTH, 0, 430); - SendMessageW(m_menuTooltip, TTM_SETDELAYTIME, TTDT_AUTOPOP, 30000); - - m_menuTooltipInfo.cbSize = sizeof(m_menuTooltipInfo); - m_menuTooltipInfo.uFlags = TTF_TRACK | TTF_ABSOLUTE; - m_menuTooltipInfo.hwnd = m_hwnd; - m_menuTooltipInfo.uId = 1; - m_menuTooltipInfo.lpszText = const_cast(L""); - SendMessageW(m_menuTooltip, TTM_ADDTOOLW, 0, - reinterpret_cast(&m_menuTooltipInfo)); -} - -void TrayApp::ShowMenuTooltip(UINT commandId) { - SteamStrategy strategy; - switch (commandId) { - case IDM_STEAM_YIELD: strategy = SteamStrategy::YieldToSteam; break; - case IDM_STEAM_BLACKLIST: strategy = SteamStrategy::ControllerBlacklist; break; - case IDM_STEAM_NOJOY: strategy = SteamStrategy::NoJoy; break; - default: - HideMenuTooltip(); - return; - } - if (!m_menuTooltip) return; - - m_menuTooltipText = SteamStrategies::Tooltip(strategy); - m_menuTooltipInfo.lpszText = m_menuTooltipText.data(); - SendMessageW(m_menuTooltip, TTM_UPDATETIPTEXTW, 0, - reinterpret_cast(&m_menuTooltipInfo)); - POINT cursor{}; - GetCursorPos(&cursor); - SendMessageW(m_menuTooltip, TTM_TRACKPOSITION, 0, - MAKELPARAM(cursor.x + 24, cursor.y + 20)); - SendMessageW(m_menuTooltip, TTM_TRACKACTIVATE, TRUE, - reinterpret_cast(&m_menuTooltipInfo)); -} - -void TrayApp::HideMenuTooltip() { - if (m_menuTooltip) - SendMessageW(m_menuTooltip, TTM_TRACKACTIVATE, FALSE, - reinterpret_cast(&m_menuTooltipInfo)); } void TrayApp::ApplySteamState(SteamState state) { - if (!m_steamlessEnabled) return; + if (m_autoMode == AutoMode::Manual) return; - EventLog::Write("AUTO: steam state %d (0=none 1=controllerHidden 2=idle 3=inGame) -> %s", + EventLog::Write("AUTO: steam state %d (0=none 1=idle 2=inGame) -> %s", static_cast(state), WantControl(state) ? "take control" : "yield"); if (WantControl(state)) { @@ -478,7 +310,6 @@ void TrayApp::ApplySteamState(SteamState state) { } else { KillTimer(m_hwnd, IDT_ACQUIRE); m_acquireRetries = 0; - m_waitingForWake = false; const bool hadControl = m_controller->IsGameModeActive(); // Good citizen: restore lizard mode and close our HID handles so // Steam can claim the controller without contention. @@ -486,62 +317,43 @@ void TrayApp::ApplySteamState(SteamState state) { // 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 - && state != SteamState::ControllerHidden) + if (hadControl && state != SteamState::NoSteam) RestartControllerDevices(); } } void TrayApp::TryAcquireController() { m_controller->OnDeviceChange(); - auto result = m_controller->EnableGameMode(m_waitingForWake ? 40 : 250); + m_controller->EnableGameMode(); // 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 && result == ControllerManager::EnableGameModeResult::Blocked - && !m_controller->IsGameModeActive(); ++i) { + for (int i = 0; i < 10 && !m_controller->IsGameModeActive(); ++i) { Sleep(50); m_controller->OnDeviceChange(); - result = m_controller->EnableGameMode(40); + m_controller->EnableGameMode(); } if (m_controller->IsGameModeActive()) { - if (m_waitingForWake) - EventLog::Write("AUTO: controller woke; Steamless Mode acquired"); - m_waitingForWake = false; - m_acquireRetries = 0; - return; - } - if (!m_controller->IsConnected()) { - m_waitingForWake = false; - return; // nothing plugged in — arrival will retrigger - } - if (result == ControllerManager::EnableGameModeResult::NoActiveController) { m_acquireRetries = 0; - if (!m_waitingForWake) - EventLog::Write("AUTO: receiver present but controller inactive; waiting for wake"); - m_waitingForWake = true; - SetTimer(m_hwnd, IDT_ACQUIRE, WAKE_RETRY_MS, nullptr); return; } - m_waitingForWake = false; + if (!m_controller->IsConnected()) return; // nothing plugged in — arrival will retrigger - // A reporting slot exists but access or a required command failed. Cycle - // the device to invalidate stale handles and retry on re-arrival (the timer - // is a fallback in case the arrival event is missed). + // Steam holds a write handle, so our exclusive claim can't succeed while + // its handle lives. Cycle the device to invalidate it and retry on + // re-arrival (the timer is a fallback in case the arrival event is missed). if (m_acquireRetries >= MAX_ACQUIRE_CYCLES) { EventLog::Write("AUTO: giving up acquiring after %d device cycles", MAX_ACQUIRE_CYCLES); // Don't leave the user with a silently dead pad and no explanation. if (m_notificationsEnabled) ShowAlertBalloon(L"Could not take the controller", - L"Controller reports are arriving, but access or the required " - L"controller command still fails after retrying. Close other " - L"controller tools, then turn Steamless off and on to retry."); + L"Steam is holding the controller and did not release it. " + L"Closing Steam will hand it back."); return; } ++m_acquireRetries; - EventLog::Write("AUTO: active controller acquisition blocked — cycling device (attempt %d)", - m_acquireRetries); + EventLog::Write("AUTO: 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 @@ -654,106 +466,6 @@ void TrayApp::ShowAlertBalloon(const std::wstring& title, const std::wstring& te Shell_NotifyIconW(NIM_MODIFY, &nid); } -bool TrayApp::ConfirmToggleSteamRestart(bool enabling, SteamStrategy targetStrategy) { - if (!m_confirmSteamRestart || !SteamStrategies::IsSteamRunning()) - return true; - - const wchar_t* instruction = enabling - ? L"Restart Steam to turn Steamless on?" - : L"Restart Steam to turn Steamless off?"; - std::wstring content; - if (!enabling) { - content = L"Steam's original controller settings will be restored so Steam can " - L"take the physical controller back."; - } else if (targetStrategy == SteamStrategy::NoJoy) { - content = L"Steam will restart with -nojoy so Steamless can take the physical " - L"controller. All Steam controller support will be disabled."; - } else { - content = L"Steam will restart with the Steam Controller reserved for Steamless. " - L"Other controller types will remain available to Steam Input."; - } - if (SteamStrategies::IsGameRunning()) - content += L" A Steam game is running and will close."; - - TASKDIALOGCONFIG dialog{}; - dialog.cbSize = sizeof(dialog); - dialog.hwndParent = m_hwnd; - dialog.dwFlags = TDF_ALLOW_DIALOG_CANCELLATION | TDF_SIZE_TO_CONTENT; - dialog.dwCommonButtons = TDCBF_YES_BUTTON | TDCBF_NO_BUTTON; - dialog.pszWindowTitle = L"Restart Steam"; - dialog.pszMainIcon = TD_WARNING_ICON; - dialog.pszMainInstruction = instruction; - dialog.pszContent = content.c_str(); - dialog.pszVerificationText = L"Don't ask again"; - dialog.nDefaultButton = IDNO; - - int button = IDNO; - BOOL verificationChecked = FALSE; - const HRESULT hr = TaskDialogIndirect(&dialog, &button, nullptr, &verificationChecked); - if (FAILED(hr)) { - button = MessageBoxW(m_hwnd, content.c_str(), instruction, - MB_ICONWARNING | MB_YESNO | MB_DEFBUTTON2); - verificationChecked = FALSE; - } - if (button != IDYES) return false; - - if (verificationChecked) { - m_confirmSteamRestart = false; - SaveSettings(); - } - return true; -} - -void TrayApp::ToggleSteamlessMode() { - if (m_steamlessEnabled && m_steamStrategy != SteamStrategy::YieldToSteam) { - if (!ConfirmToggleSteamRestart(false, SteamStrategy::YieldToSteam)) - return; - EventLog::Write("USER: turning Steamless off and restoring Steam settings"); - ApplySteamStrategy(SteamStrategy::YieldToSteam, - false, false, false, false); - return; - } - if (!m_steamlessEnabled - && m_preferredSteamStrategy != SteamStrategy::YieldToSteam) { - if (!ConfirmToggleSteamRestart(true, m_preferredSteamStrategy)) - return; - EventLog::Write("USER: turning Steamless on with strategy %d", - static_cast(m_preferredSteamStrategy)); - ApplySteamStrategy(m_preferredSteamStrategy, - false, true, false, false); - return; - } - - m_steamlessEnabled = !m_steamlessEnabled; - m_acquireRetries = 0; - m_waitingForWake = false; - KillTimer(m_hwnd, IDT_ACQUIRE); - SaveSettings(); - if (!m_steamlessEnabled) { - EventLog::Write("USER: Steamless disabled from tray (Steam settings already restored)"); - const bool hadControl = m_controller->IsGameModeActive(); - const SteamState state = m_steamWatcher.GetState(); - m_controller->ReleaseDevices(); - if (hadControl && (state == SteamState::SteamIdle || state == SteamState::InGame)) - RestartControllerDevices(); - } else { - EventLog::Write("USER: Steamless enabled from tray with original Steam settings"); - ApplySteamState(m_steamWatcher.GetState()); - } -} - -void TrayApp::ShowInfoBalloon(const std::wstring& title, const std::wstring& text) { - NOTIFYICONDATAW nid{}; - nid.cbSize = sizeof(nid); - nid.hWnd = m_hwnd; - nid.uID = TRAY_UID; - nid.uFlags = NIF_INFO; - nid.dwInfoFlags = NIIF_INFO; - wcsncpy_s(nid.szInfoTitle, title.c_str(), _TRUNCATE); - wcsncpy_s(nid.szInfo, text.c_str(), _TRUNCATE); - Shell_NotifyIconW(NIM_MODIFY, &nid); -} - void TrayApp::OpenEventLog() { // Touch the log so the file exists even on a fresh install. EventLog::Write("USER: opened event log"); @@ -944,20 +656,11 @@ void TrayApp::LoadSettings() { return def; }; - // Migrate the old 0=manual value to master-off. Values 1 and 2 retain - // their previous Steam handoff schedules. - const DWORD legacyMode = readDw(L"AutoSteamMode", 1); - m_handoffMode = legacyMode == 2 ? HandoffMode::OnlyInGame - : HandoffMode::WhileSteamRuns; - const DWORD enabled = readDw(L"SteamlessEnabled", MAXDWORD); - m_enabledSettingLoaded = enabled <= 1; - m_steamlessEnabled = m_enabledSettingLoaded ? enabled != 0 : legacyMode != 0; - const DWORD preferred = readDw(L"PreferredSteamStrategy", MAXDWORD); - if (preferred <= static_cast(SteamStrategy::NoJoy)) { - m_preferredSteamStrategy = static_cast(preferred); - m_preferredStrategyLoaded = true; - } - m_confirmSteamRestart = readDw(L"ConfirmSteamRestart", 1) != 0; + // 0 = Manual, 1 = OffWhileSteam, 2 = OffOnlyInGame (old builds stored 0/1). + const DWORD mode = readDw(L"AutoSteamMode", 0); + m_autoMode = mode == 2 ? AutoMode::OffOnlyInGame + : mode == 1 ? AutoMode::OffWhileSteam + : AutoMode::Manual; m_controller->SetTrackpadMouseEnabled(readDw(L"TrackpadMouse", 0) != 0); m_controller->SetBackButtonsEnabled (readDw(L"BackButtons", 0) != 0); m_controller->SetUseLeftTrackpad (readDw(L"UseLeftTrackpad", 0) != 0); @@ -995,10 +698,7 @@ void TrayApp::SaveSettings() { reinterpret_cast(&val), sizeof(val)); }; - writeDw(L"AutoSteamMode", static_cast(m_handoffMode)); - writeDw(L"SteamlessEnabled", m_steamlessEnabled ? 1 : 0); - writeDw(L"PreferredSteamStrategy", static_cast(m_preferredSteamStrategy)); - writeDw(L"ConfirmSteamRestart", m_confirmSteamRestart ? 1 : 0); + writeDw(L"AutoSteamMode", static_cast(m_autoMode)); writeDw(L"StartupMechanism", static_cast(m_startupMechanism)); writeDw(L"ShowNotifications", m_notificationsEnabled ? 1 : 0); writeDw(L"TrackpadMouse", m_controller->IsTrackpadMouseEnabled() ? 1 : 0); @@ -1017,6 +717,7 @@ void TrayApp::SaveSettings() { void TrayApp::ShowContextMenu() { bool connected = m_controller->IsConnected(); + bool gameModeOn = m_controller->IsGameModeActive(); bool trackpadOn = m_controller->IsTrackpadMouseEnabled(); bool backMouseOn = m_controller->IsBackButtonsEnabled(); bool leftPad = m_controller->IsUseLeftTrackpad(); @@ -1024,25 +725,27 @@ void TrayApp::ShowContextMenu() { HMENU menu = CreatePopupMenu(); - std::wstring toggleLabel = m_steamlessEnabled ? L"Turn Steamless Off" - : L"Turn Steamless On"; + // 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"; if (!connected) toggleLabel += L" (no controller detected)"; - AppendMenuW(menu, MF_STRING, IDM_TOGGLE, toggleLabel.c_str()); - - if (m_steamlessEnabled && m_steamStrategy == SteamStrategy::YieldToSteam) { - HMENU handoffMenu = CreatePopupMenu(); - AppendMenuW(handoffMenu, - MF_STRING | (m_handoffMode == HandoffMode::WhileSteamRuns - ? MF_CHECKED : MF_UNCHECKED), - IDM_HANDOFF_STEAM, L"Use Steamless only while Steam is closed"); - AppendMenuW(handoffMenu, - MF_STRING | (m_handoffMode == HandoffMode::OnlyInGame - ? MF_CHECKED : MF_UNCHECKED), - IDM_HANDOFF_GAME, L"Use Steamless except during Steam games"); - AppendMenuW(menu, MF_STRING | MF_POPUP, - reinterpret_cast(handoffMenu), L"Steam Handoff"); - } + else if (!manual) + toggleLabel += L" (managed by Auto Mode)"; + UINT toggleFlags = MF_STRING | ((connected && manual) ? MF_ENABLED : MF_GRAYED); + AppendMenuW(menu, toggleFlags, IDM_TOGGLE, toggleLabel.c_str()); + + HMENU modeMenu = CreatePopupMenu(); + AppendMenuW(modeMenu, MF_STRING | (manual ? MF_CHECKED : MF_UNCHECKED), + IDM_MODE_MANUAL, L"Manual"); + AppendMenuW(modeMenu, MF_STRING | (m_autoMode == AutoMode::OffWhileSteam ? MF_CHECKED : MF_UNCHECKED), + IDM_MODE_STEAM, L"Auto - Off while Steam running"); + AppendMenuW(modeMenu, MF_STRING | (m_autoMode == AutoMode::OffOnlyInGame ? MF_CHECKED : MF_UNCHECKED), + IDM_MODE_GAME, L"Auto - Off ONLY while in Steam game"); + AppendMenuW(menu, MF_STRING | MF_POPUP, + reinterpret_cast(modeMenu), L"Control Mode"); AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); @@ -1070,35 +773,6 @@ void TrayApp::ShowContextMenu() { AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); - HMENU debugMenu = CreatePopupMenu(); - std::wstring currentStrategy = m_steamlessEnabled ? L"Current: " : L"Off; when on: "; - currentStrategy += SteamStrategies::StatusLabel( - m_steamlessEnabled ? m_steamStrategy : m_preferredSteamStrategy); - AppendMenuW(debugMenu, MF_STRING | MF_GRAYED, 0, currentStrategy.c_str()); - AppendMenuW(debugMenu, MF_SEPARATOR, 0, nullptr); - AppendMenuW(debugMenu, MF_STRING, IDM_STEAM_YIELD, - L"Use Steam's original controller support"); - AppendMenuW(debugMenu, MF_STRING, IDM_STEAM_BLACKLIST, - L"Reserve Steam Controller for Steamless (recommended)"); - AppendMenuW(debugMenu, MF_STRING, IDM_STEAM_NOJOY, - L"Disable all Steam controller support (-nojoy)"); - CheckMenuRadioItem(debugMenu, IDM_STEAM_YIELD, IDM_STEAM_NOJOY, - m_preferredSteamStrategy == SteamStrategy::YieldToSteam ? IDM_STEAM_YIELD - : m_preferredSteamStrategy == SteamStrategy::ControllerBlacklist - ? IDM_STEAM_BLACKLIST : IDM_STEAM_NOJOY, - MF_BYCOMMAND); - const wchar_t* shortStatus = !m_steamlessEnabled ? L"off" - : m_steamStrategy == SteamStrategy::YieldToSteam ? L"Steam handoff" - : m_steamStrategy == SteamStrategy::ControllerBlacklist - ? L"reserved for Steamless" : L"-nojoy"; - std::wstring debugLabel = L"Debug: Steam coexistence ("; - debugLabel += shortStatus; - debugLabel += L")"; - AppendMenuW(menu, MF_STRING | MF_POPUP, - reinterpret_cast(debugMenu), debugLabel.c_str()); - - AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); - AppendMenuW(menu, MF_STRING | (startupOn ? MF_CHECKED : MF_UNCHECKED), IDM_STARTUP, L"Start with Windows"); diff --git a/src/app/TrayApp.h b/src/app/TrayApp.h index c099a3a..e809717 100644 --- a/src/app/TrayApp.h +++ b/src/app/TrayApp.h @@ -1,19 +1,19 @@ #pragma once #include -#include #include #include #include #include "RemapWindow.h" -#include "SteamStrategy.h" #include "SteamWatcher.h" class ControllerManager; -// When Steamless yields under Steam's original controller settings. -enum class HandoffMode { - WhileSteamRuns = 1, - OnlyInGame = 2, +// Who decides when SteamlessController takes over the physical controller. +enum class AutoMode { + Manual = 0, // tray toggle only + OffWhileSteam = 1, // yield whenever steam.exe is running + OffOnlyInGame = 2, // yield only while a game is running (needs admin to + // wrest the device from a running Steam) }; class TrayApp { @@ -33,22 +33,11 @@ class TrayApp { void UpdateTrayIcon(bool connected, bool gameModeActive, bool vigemMissing = false); void ShowViGEmBalloon(); void ShowAlertBalloon(const std::wstring& title, const std::wstring& text); - void ShowInfoBalloon(const std::wstring& title, const std::wstring& text); void OpenEventLog(); void ShowContextMenu(); - void ToggleSteamlessMode(); void LoadSettings(); void SaveSettings(); void OpenRemapWindow(); - bool ApplySteamStrategy(SteamStrategy strategy, - bool launchIfStopped = true, - bool enableSteamless = true, - bool rememberPreference = true, - bool confirmRunningGame = true); - bool ConfirmToggleSteamRestart(bool enabling, SteamStrategy targetStrategy); - void CreateMenuTooltip(); - void ShowMenuTooltip(UINT commandId); - void HideMenuTooltip(); // The tray app never runs elevated (an elevated foreground window blocks // unelevated SendInput — Steam Input's desktop cursor — via UIPI). @@ -63,8 +52,8 @@ class TrayApp { void DeleteLegacyStartupTask(); void UpdateStartupRegistration(); - // Automatic ownership plumbing. - void SetHandoffMode(HandoffMode mode); + // Auto-mode plumbing. + void SetAutoMode(AutoMode mode); bool WantControl(SteamState state) const; void ApplySteamState(SteamState state); void TryAcquireController(); @@ -76,19 +65,8 @@ class TrayApp { UINT m_wmTaskbar = 0; HICON m_iconOff = nullptr; HICON m_iconOn = nullptr; - HandoffMode m_handoffMode = HandoffMode::WhileSteamRuns; - SteamStrategy m_steamStrategy = SteamStrategy::YieldToSteam; - SteamStrategy m_preferredSteamStrategy = SteamStrategy::YieldToSteam; - HWND m_menuTooltip = nullptr; - TTTOOLINFOW m_menuTooltipInfo{}; - std::wstring m_menuTooltipText; + AutoMode m_autoMode = AutoMode::Manual; int m_acquireRetries = 0; - bool m_waitingForWake = false; - bool m_suppressNextLeftUp = false; - bool m_steamlessEnabled = true; - bool m_confirmSteamRestart = true; - bool m_preferredStrategyLoaded = false; - bool m_enabledSettingLoaded = false; bool m_elevationBalloonShown = false; bool m_startupEnabled = false; int m_startupMechanism = 0; // 0 none, 1 Run key, 2 elevated task @@ -109,19 +87,15 @@ class TrayApp { static constexpr UINT IDM_BACKBUTTONS = 1007; static constexpr UINT IDM_PLATFORM_XBOX = 1008; static constexpr UINT IDM_PLATFORM_PS = 1009; - static constexpr UINT IDM_HANDOFF_STEAM = 1011; - static constexpr UINT IDM_HANDOFF_GAME = 1012; + static constexpr UINT IDM_MODE_MANUAL = 1010; + static constexpr UINT IDM_MODE_STEAM = 1011; + static constexpr UINT IDM_MODE_GAME = 1012; static constexpr UINT IDM_OPENLOG = 1013; static constexpr UINT IDM_NOTIFICATIONS = 1014; - static constexpr UINT IDM_STEAM_YIELD = 1020; - static constexpr UINT IDM_STEAM_BLACKLIST = 1021; - static constexpr UINT IDM_STEAM_NOJOY = 1022; static constexpr UINT WM_TRAY = WM_APP + 1; static constexpr UINT WM_STEAMSTATE = WM_APP + 2; static constexpr UINT WM_ALERT = WM_APP + 3; static constexpr UINT TRAY_UID = 1; static constexpr UINT_PTR IDT_ACQUIRE = 1; - static constexpr UINT_PTR IDT_TRAY_CLICK = 2; static constexpr int MAX_ACQUIRE_CYCLES = 3; - static constexpr UINT WAKE_RETRY_MS = 750; }; diff --git a/src/app/main.cpp b/src/app/main.cpp index f1cd025..0d491b4 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -1,14 +1,7 @@ #include "TrayApp.h" -#include "SteamStrategy.h" #include -int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, PWSTR cmdLine, int) { - // The Inno uninstaller runs this before removing files. It deliberately - // bypasses the tray singleton because AppMutex has already required the - // interactive instance to close. - if (cmdLine && _wcsicmp(cmdLine, L"--uninstall-cleanup") == 0) - return SteamStrategies::CleanupForUninstall().applied ? 0 : 1; - +int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, PWSTR, int) { // Enable per-monitor v2 DPI awareness so the WebView2 window renders crisp // on high-DPI displays and the WM_NCHITTEST pixel math stays correct. SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); diff --git a/src/steam/SteamController.cpp b/src/steam/SteamController.cpp index 41c9b76..491d1a5 100644 --- a/src/steam/SteamController.cpp +++ b/src/steam/SteamController.cpp @@ -54,8 +54,7 @@ static constexpr uint8_t HAPTIC_COMMAND_CLICK = 2; std::vector SteamController::EnumerateAll() { std::vector result; - for (uint16_t pid : { SC2026_PID, SC2026_BT_PID, SC2026_DONGLE_PID, - SC2026_NEREID_DONGLE_PID }) + for (uint16_t pid : { SC2026_PID, SC2026_BT_PID, SC2026_DONGLE_PID }) for (auto const& path : HidDevice::Enumerate( VALVE_VID, pid, VENDOR_USAGE_PAGE, CONTROLLER_USAGE)) result.push_back(path); @@ -73,8 +72,7 @@ SteamController::Transport SteamController::TransportFromPath(const std::wstring p.find(L"bthledevice") != std::wstring::npos || p.find(L"bthenum") != std::wstring::npos) return Transport::Bluetooth; - if (p.find(L"pid_1304") != std::wstring::npos || - p.find(L"pid_1305") != std::wstring::npos) + if (p.find(L"pid_1304") != std::wstring::npos) return Transport::Dongle; if (p.find(L"pid_1302") != std::wstring::npos || p.find(L"pid_1303") != std::wstring::npos) @@ -94,8 +92,8 @@ const char* SteamController::TransportName(Transport t) { bool SteamController::Open() { auto paths = EnumerateAll(); if (paths.empty()) { - printf("No Steam Controller found (wired PID=%04X or dongle PIDs=%04X/%04X).\n", - SC2026_PID, SC2026_DONGLE_PID, SC2026_NEREID_DONGLE_PID); + printf("No Steam Controller found (wired PID=%04X or dongle PID=%04X).\n", + SC2026_PID, SC2026_DONGLE_PID); return false; } diff --git a/src/steam/SteamController.h b/src/steam/SteamController.h index 8aab57c..6ccac35 100644 --- a/src/steam/SteamController.h +++ b/src/steam/SteamController.h @@ -9,10 +9,9 @@ class SteamController { public: static constexpr uint16_t VALVE_VID = 0x28DE; - static constexpr uint16_t SC2026_PID = 0x1302; // wired USB - static constexpr uint16_t SC2026_BT_PID = 0x1303; // Bluetooth LE (HID over GATT) - static constexpr uint16_t SC2026_DONGLE_PID = 0x1304; // Proteus dongle ("Steam Controller Puck") - static constexpr uint16_t SC2026_NEREID_DONGLE_PID = 0x1305; // Nereid receiver + static constexpr uint16_t SC2026_PID = 0x1302; // wired USB + static constexpr uint16_t SC2026_BT_PID = 0x1303; // Bluetooth LE (HID over GATT) + static constexpr uint16_t SC2026_DONGLE_PID = 0x1304; // wireless dongle ("Steam Controller Puck") // How the controller is linked to the PC. Bluetooth is detected from the // device interface path (HOGP service GUID / bthenum), the other two by PID. From afed2db6e1e8843cb3e4d1cb62134d8b2d13b2f3 Mon Sep 17 00:00:00 2001 From: Dylan Deverill Date: Mon, 3 Aug 2026 19:26:03 -0700 Subject: [PATCH 12/19] Add the Nereid receiver PID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carried over from the coexistence branch as the one piece of it that is self-contained: 0x1305 alongside the Proteus puck at 0x1304, and the same path token treated as a dongle transport. Purely additive — it only affects users who own that receiver and get nothing today. Untested here; no Nereid hardware on hand. Co-Authored-By: Claude Fable 5 --- src/steam/SteamController.cpp | 5 +++-- src/steam/SteamController.h | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/steam/SteamController.cpp b/src/steam/SteamController.cpp index 491d1a5..494c8d1 100644 --- a/src/steam/SteamController.cpp +++ b/src/steam/SteamController.cpp @@ -54,7 +54,7 @@ static constexpr uint8_t HAPTIC_COMMAND_CLICK = 2; std::vector SteamController::EnumerateAll() { std::vector result; - for (uint16_t pid : { SC2026_PID, SC2026_BT_PID, SC2026_DONGLE_PID }) + for (uint16_t pid : { SC2026_PID, SC2026_BT_PID, SC2026_DONGLE_PID, SC2026_NEREID_PID }) for (auto const& path : HidDevice::Enumerate( VALVE_VID, pid, VENDOR_USAGE_PAGE, CONTROLLER_USAGE)) result.push_back(path); @@ -72,7 +72,8 @@ SteamController::Transport SteamController::TransportFromPath(const std::wstring p.find(L"bthledevice") != std::wstring::npos || p.find(L"bthenum") != std::wstring::npos) return Transport::Bluetooth; - if (p.find(L"pid_1304") != std::wstring::npos) + if (p.find(L"pid_1304") != std::wstring::npos || + p.find(L"pid_1305") != std::wstring::npos) return Transport::Dongle; if (p.find(L"pid_1302") != std::wstring::npos || p.find(L"pid_1303") != std::wstring::npos) diff --git a/src/steam/SteamController.h b/src/steam/SteamController.h index 6ccac35..63f02a9 100644 --- a/src/steam/SteamController.h +++ b/src/steam/SteamController.h @@ -12,6 +12,7 @@ class SteamController { static constexpr uint16_t SC2026_PID = 0x1302; // wired USB static constexpr uint16_t SC2026_BT_PID = 0x1303; // Bluetooth LE (HID over GATT) static constexpr uint16_t SC2026_DONGLE_PID = 0x1304; // wireless dongle ("Steam Controller Puck") + static constexpr uint16_t SC2026_NEREID_PID = 0x1305; // Nereid receiver // How the controller is linked to the PC. Bluetooth is detected from the // device interface path (HOGP service GUID / bthenum), the other two by PID. From 2116ff7a1da5b71e036ef1285ad1225aee33adf4 Mon Sep 17 00:00:00 2001 From: Dylan Deverill Date: Mon, 3 Aug 2026 20:19:37 -0700 Subject: [PATCH 13/19] Fix battery scaling, needless cycling, and a premature failure alert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects surfaced by QA against the puck. The battery payload is [chargeState, percent] on every transport, not just Bluetooth. Dongle reports read 1/92 and 1/91 — a constant 1 (CHARGE_STATE_DISCHARGING) followed by the real level — so a 92% battery was being reported, and fed to the DS4 battery level, as 1%. Game mode could not tell an idle receiver from a contested one. A puck publishes a slot interface whether or not a controller is paired into it, so with the controller switched off every slot is silent, which the acquire path read as "blocked" and answered with three device cycles. That restarted the receiver's devnodes repeatedly, logged a misleading ownership error, and on a machine without a registered cycle task would also throw a UAC prompt at someone who had merely not turned their controller on yet. EnableGameMode now reports NoActiveController separately, and the acquire path waits for the wake-up device-change event instead of cycling. Finally, the app announced failure while the last cycle was still landing. Observed: gave up at 20:12:24.478, arrival at 20:12:25.396, game mode enabled at 20:12:25.415 — the balloon fired a second before success. A cycle on a four-interface receiver outruns the 2.5s retry timer once the helper's disable/enable pause is included. The timer goes to 4s, and the verdict is deferred behind a grace timer that stays quiet if the controller arrives after all. Co-Authored-By: Claude Fable 5 --- src/app/ControllerManager.cpp | 51 ++++++++++++++++++++++------------- src/app/ControllerManager.h | 10 +++++-- src/app/TrayApp.cpp | 41 +++++++++++++++++++++------- src/app/TrayApp.h | 10 ++++++- 4 files changed, 80 insertions(+), 32 deletions(-) diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp index ecd4ca9..c9b091d 100644 --- a/src/app/ControllerManager.cpp +++ b/src/app/ControllerManager.cpp @@ -217,11 +217,23 @@ void ControllerManager::OnDeviceChange() { SyncDevices(); } -void ControllerManager::EnableGameMode() { +ControllerManager::GameModeOutcome ControllerManager::EnableGameMode() { bool anyVigemMissing = false; - for (auto& slot : m_slots) - EnableGameModeSlot(*slot, anyVigemMissing); + bool anyBlocked = false; + bool anyEnabled = false; + for (auto& slot : m_slots) { + switch (EnableGameModeSlot(*slot, anyVigemMissing)) { + case GameModeOutcome::Enabled: anyEnabled = true; break; + case GameModeOutcome::Blocked: anyBlocked = true; break; + case GameModeOutcome::NoActiveController: break; + } + } NotifyStateChanged(anyVigemMissing); + 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; } void ControllerManager::DisableGameMode() { @@ -364,27 +376,29 @@ void ControllerManager::OpenSlot(const std::wstring& path) { // Game mode per-slot helpers // --------------------------------------------------------------------------- -void ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) { - if (slot.gameModeActive) return; +ControllerManager::GameModeOutcome +ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) { + if (slot.gameModeActive) return GameModeOutcome::Enabled; // The puck publishes a controller interface per slot and current firmware // rejects the lizard-mode command on an empty one, so only act on a slot // that is actually emitting state. Recently-silent slots are skipped // outright — see Slot::lastSilentAt. static constexpr auto kSilentSlotRetryGap = std::chrono::milliseconds(1500); const auto now = std::chrono::steady_clock::now(); - if (now - slot.lastSilentAt < kSilentSlotRetryGap) return; + if (now - slot.lastSilentAt < kSilentSlotRetryGap) + return GameModeOutcome::NoActiveController; if (!slot.sc->WaitForStateReport(250)) { slot.lastSilentAt = std::chrono::steady_clock::now(); EventLog::Write("GAMEMODE: no state reports from slot (transport=%s), skipping %ls", SteamController::TransportName(slot.transport), slot.path.c_str()); - return; + return GameModeOutcome::NoActiveController; } const auto claim = slot.sc->ClaimGameModeAccess(); if (claim == SteamController::AccessClaim::Failed) { EventLog::Write("GAMEMODE: device reopen failed %ls", slot.path.c_str()); - return; + return GameModeOutcome::Blocked; } if (claim == SteamController::AccessClaim::Shared) { // A write handle held while Steam is running is most likely Steam's. @@ -395,7 +409,7 @@ void ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) { if (m_steamPresent) { EventLog::Write("GAMEMODE: exclusive claim blocked while Steam is running %ls", slot.path.c_str()); - return; + return GameModeOutcome::Blocked; } EventLog::Write("GAMEMODE: exclusive claim unavailable; using shared access %ls", slot.path.c_str()); @@ -403,7 +417,7 @@ void ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) { if (!slot.sc->DisableLizardMode()) { EventLog::Write("GAMEMODE: DisableLizardMode failed %ls", slot.path.c_str()); slot.sc->ReleaseToShared(); - return; + return GameModeOutcome::Blocked; } slot.vc = std::make_unique( @@ -417,7 +431,7 @@ void ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) { if (slot.vc->IsDriverMissing()) vigemMissingOut = true; slot.vc.reset(); slot.sc->EnableLizardMode(); - return; + return GameModeOutcome::Blocked; } if (m_controllerPlatform == ControllerPlatform::PlayStation) @@ -431,6 +445,7 @@ void ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) { slot.trackpad.SetUseLeftTrackpad(m_useLeftTrackpad); slot.trackpad.SetBackButtonsEnabled(m_backButtonsEnabled); StartReadLoop(slot); + return GameModeOutcome::Enabled; } void ControllerManager::DisableGameModeSlot(Slot& slot) { @@ -528,14 +543,12 @@ void ControllerManager::ReadLoop(Slot* slot) { // Battery status — update DS4 battery level; no further processing needed. if (buf[0] == SteamController::REPORT_BATTERY_STATUS) { if (n >= 3) { - // USB/dongle payload order is [percent, chargeState]; over - // Bluetooth the firmware sends the same two bytes swapped — - // observed BT reports held a constant 1 (CHARGE_STATE_DISCHARGING) - // in buf[1] while buf[2] tracked the real charge level. - uint8_t percent = buf[1]; - uint8_t chargeState = buf[2]; - if (slot->transport == SteamController::Transport::Bluetooth) - std::swap(percent, chargeState); + // Payload is [chargeState, percent] on every transport, not + // the [percent, chargeState] originally assumed. Confirmed on + // the dongle too: reports read 1/92 and 1/91 — a constant 1 + // (CHARGE_STATE_DISCHARGING) followed by the real level. + const uint8_t chargeState = buf[1]; + const uint8_t percent = buf[2]; if (slot->vc) slot->vc->SetBatteryState(percent, chargeState); if (slot->lastBatteryPercent != static_cast(percent)) { diff --git a/src/app/ControllerManager.h b/src/app/ControllerManager.h index 06c6b41..df191b4 100644 --- a/src/app/ControllerManager.h +++ b/src/app/ControllerManager.h @@ -26,7 +26,13 @@ class ControllerManager { // read loops copy it without locking. void SetAlertCallback(AlertFn fn) { m_alertFn = std::move(fn); } - void EnableGameMode(); + // Why game mode did not come up, so callers can tell an idle receiver from + // 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 }; + + GameModeOutcome EnableGameMode(); void DisableGameMode(); // Disables game mode then closes all device handles so another process // (e.g. Steam) can claim the controller. Safe to call when already disabled. @@ -69,7 +75,7 @@ class ControllerManager { void SyncDevices(); void OpenSlot(const std::wstring& path); - void EnableGameModeSlot(Slot& slot, bool& vigemMissingOut); + GameModeOutcome EnableGameModeSlot(Slot& slot, bool& vigemMissingOut); void DisableGameModeSlot(Slot& slot); void StartReadLoop(Slot& slot); void StopReadLoop(Slot& slot); diff --git a/src/app/TrayApp.cpp b/src/app/TrayApp.cpp index bfba57f..618fb4f 100644 --- a/src/app/TrayApp.cpp +++ b/src/app/TrayApp.cpp @@ -257,6 +257,16 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { KillTimer(m_hwnd, IDT_ACQUIRE); if (m_autoMode != AutoMode::Manual && WantControl(m_steamWatcher.GetState())) TryAcquireController(); + } 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); + if (m_notificationsEnabled) + ShowAlertBalloon(L"Could not take the controller", + L"Steam is holding the controller and did not release it. " + L"Closing Steam will hand it back."); } return 0; @@ -309,6 +319,7 @@ void TrayApp::ApplySteamState(SteamState state) { TryAcquireController(); } else { KillTimer(m_hwnd, IDT_ACQUIRE); + KillTimer(m_hwnd, IDT_ACQUIRE_VERDICT); m_acquireRetries = 0; const bool hadControl = m_controller->IsGameModeActive(); // Good citizen: restore lizard mode and close our HID handles so @@ -324,7 +335,7 @@ void TrayApp::ApplySteamState(SteamState state) { void TrayApp::TryAcquireController() { m_controller->OnDeviceChange(); - m_controller->EnableGameMode(); + auto outcome = m_controller->EnableGameMode(); // 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 @@ -332,24 +343,32 @@ void TrayApp::TryAcquireController() { for (int i = 0; i < 10 && !m_controller->IsGameModeActive(); ++i) { Sleep(50); m_controller->OnDeviceChange(); - m_controller->EnableGameMode(); + outcome = m_controller->EnableGameMode(); } if (m_controller->IsGameModeActive()) { + KillTimer(m_hwnd, IDT_ACQUIRE_VERDICT); m_acquireRetries = 0; return; } if (!m_controller->IsConnected()) return; // nothing plugged in — arrival will retrigger + // No slot is emitting state: the controller is off, asleep, or the + // receiver has no controller paired into it. Cycling the device cannot + // conjure one up, and doing it anyway restarts the receiver's devnodes + // (and can prompt for elevation) just because nothing is switched on. + // Wake-up arrives as a device-change event, which retriggers this. + if (outcome == ControllerManager::GameModeOutcome::NoActiveController) + return; + // Steam holds a write handle, so our exclusive claim can't succeed while // its handle lives. Cycle the device to invalidate it and retry on // re-arrival (the timer is a fallback in case the arrival event is missed). if (m_acquireRetries >= MAX_ACQUIRE_CYCLES) { - EventLog::Write("AUTO: giving up acquiring after %d device cycles", MAX_ACQUIRE_CYCLES); - // Don't leave the user with a silently dead pad and no explanation. - if (m_notificationsEnabled) - ShowAlertBalloon(L"Could not take the controller", - L"Steam is holding the controller and did not release it. " - L"Closing Steam will hand it back."); + // Don't announce failure yet. The last cycle's re-arrival can still be + // in flight — on a four-interface receiver the round trip outran the + // retry timer, so the app cried failure a second before succeeding. + // Let the grace timer deliver the verdict instead. + SetTimer(m_hwnd, IDT_ACQUIRE_VERDICT, ACQUIRE_VERDICT_MS, nullptr); return; } ++m_acquireRetries; @@ -357,8 +376,10 @@ void TrayApp::TryAcquireController() { m_controller->ReleaseDevices(); if (!RestartControllerDevices()) return; // helper unavailable — balloon shown // The cycle runs asynchronously via the helper task; the device-arrival - // notification drives the claim, with this timer as the fallback. - SetTimer(m_hwnd, IDT_ACQUIRE, 2500, nullptr); + // notification drives the claim, with this timer as the fallback. It must + // outlast a cycle: the helper waits a second between disable and enable, + // then a multi-slot receiver has to re-enumerate every interface. + SetTimer(m_hwnd, IDT_ACQUIRE, ACQUIRE_RETRY_MS, nullptr); } bool TrayApp::RestartControllerDevices() { diff --git a/src/app/TrayApp.h b/src/app/TrayApp.h index e809717..2308772 100644 --- a/src/app/TrayApp.h +++ b/src/app/TrayApp.h @@ -96,6 +96,14 @@ class TrayApp { static constexpr UINT WM_STEAMSTATE = WM_APP + 2; static constexpr UINT WM_ALERT = WM_APP + 3; static constexpr UINT TRAY_UID = 1; - static constexpr UINT_PTR IDT_ACQUIRE = 1; + static constexpr UINT_PTR IDT_ACQUIRE = 1; + static constexpr UINT_PTR IDT_ACQUIRE_VERDICT = 2; static constexpr int MAX_ACQUIRE_CYCLES = 3; + // Must outlast a full device cycle: the helper waits a second between + // disable and enable, then a multi-slot receiver re-enumerates every + // interface. At 2500 the app declared failure while the last cycle's + // arrival was still in flight. + 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; }; From 79f08e4291f9437fcb4e81ffd792e6152a75fe9f Mon Sep 17 00:00:00 2001 From: Dylan Deverill Date: Mon, 3 Aug 2026 20:26:41 -0700 Subject: [PATCH 14/19] Stop overlapping device cycles from undoing a successful claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Acquisition worked and then destroyed itself. A cycle is asynchronous — fired through Task Scheduler, with the helper pausing a second between disable and enable — and every device arrival it produces re-enters TryAcquireController. Finding the claim still blocked, that fired another cycle on top of the one already running. From the log: cycle 1 at 20:22:52.515, cycle 2 at 20:22:54.857 (2.3s later, triggered by cycle 1's own arrivals rather than the 4s timer), game mode enabled at 20:22:55.355 from cycle 1 — then cycle 2 landed and removed the device at 20:22:56.367, disabling what had just succeeded. It recovered at 20:22:57.537, but from the outside this reads as the app flapping and never taking the controller. Put a floor under the spacing: no new cycle within 4s of the last, just re-arm the retry timer and let the one in flight finish. Co-Authored-By: Claude Fable 5 --- src/app/TrayApp.cpp | 15 +++++++++++++++ src/app/TrayApp.h | 5 +++++ 2 files changed, 20 insertions(+) diff --git a/src/app/TrayApp.cpp b/src/app/TrayApp.cpp index 618fb4f..0491db9 100644 --- a/src/app/TrayApp.cpp +++ b/src/app/TrayApp.cpp @@ -321,6 +321,7 @@ void TrayApp::ApplySteamState(SteamState state) { KillTimer(m_hwnd, IDT_ACQUIRE); KillTimer(m_hwnd, IDT_ACQUIRE_VERDICT); 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. @@ -348,6 +349,7 @@ void TrayApp::TryAcquireController() { if (m_controller->IsGameModeActive()) { KillTimer(m_hwnd, IDT_ACQUIRE_VERDICT); m_acquireRetries = 0; + m_lastCycleTick = 0; return; } if (!m_controller->IsConnected()) return; // nothing plugged in — arrival will retrigger @@ -360,6 +362,18 @@ void TrayApp::TryAcquireController() { if (outcome == ControllerManager::GameModeOutcome::NoActiveController) return; + // A cycle is asynchronous — fired through Task Scheduler, and the helper + // pauses a second between disable and enable. Every device arrival it + // produces re-enters this function, so without a floor on the spacing we + // stack another cycle on top of one still in flight. Observed: game mode + // came up at :55.355 and our own second cycle ripped the device back out + // at :56.367. Wait for the one already running to finish instead. + const ULONGLONG nowTick = GetTickCount64(); + if (m_lastCycleTick != 0 && nowTick - m_lastCycleTick < CYCLE_MIN_GAP_MS) { + SetTimer(m_hwnd, IDT_ACQUIRE, ACQUIRE_RETRY_MS, nullptr); + return; + } + // Steam holds a write handle, so our exclusive claim can't succeed while // its handle lives. Cycle the device to invalidate it and retry on // re-arrival (the timer is a fallback in case the arrival event is missed). @@ -372,6 +386,7 @@ void TrayApp::TryAcquireController() { return; } ++m_acquireRetries; + m_lastCycleTick = nowTick; EventLog::Write("AUTO: exclusive claim blocked — cycling device (attempt %d)", m_acquireRetries); m_controller->ReleaseDevices(); if (!RestartControllerDevices()) return; // helper unavailable — balloon shown diff --git a/src/app/TrayApp.h b/src/app/TrayApp.h index 2308772..3bb6966 100644 --- a/src/app/TrayApp.h +++ b/src/app/TrayApp.h @@ -67,6 +67,7 @@ class TrayApp { HICON m_iconOn = nullptr; AutoMode m_autoMode = AutoMode::Manual; int m_acquireRetries = 0; + ULONGLONG m_lastCycleTick = 0; bool m_elevationBalloonShown = false; bool m_startupEnabled = false; int m_startupMechanism = 0; // 0 none, 1 Run key, 2 elevated task @@ -106,4 +107,8 @@ class TrayApp { 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; + // 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. + static constexpr ULONGLONG CYCLE_MIN_GAP_MS = 4000; }; From 126065678fd01c690e63f91e720cdf22c08da2a6 Mon Sep 17 00:00:00 2001 From: Dylan Deverill Date: Mon, 3 Aug 2026 20:32:55 -0700 Subject: [PATCH 15/19] Poll for the controller waking; a receiver sends no event for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression from the previous commit. Skipping the device cycle when no slot is emitting state was right, but it left nothing to retry: the code assumed a controller switching on would arrive as a device-change event. It doesn't. A receiver publishes all of its slot interfaces permanently, whether or not a controller is paired into one, so waking a controller creates and removes no HID interface and Windows says nothing. Measured: 88 seconds of complete log silence after the controller was switched on, with the app sitting there having skipped all four silent slots. Previously the cycling loop retried often enough to stumble across the wake, which is why this never showed. Arm a poll instead. The probe is 80ms rather than 250ms because a live slot streams continuously — 80ms is around twenty reports on the dongle — so polling four silent slots costs ~320ms every two seconds while idle, and stops the moment a controller appears. Co-Authored-By: Claude Fable 5 --- src/app/ControllerManager.cpp | 11 ++++++----- src/app/ControllerManager.h | 8 ++++++-- src/app/TrayApp.cpp | 29 +++++++++++++++++++++-------- src/app/TrayApp.h | 9 ++++++++- 4 files changed, 41 insertions(+), 16 deletions(-) diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp index c9b091d..e637ed5 100644 --- a/src/app/ControllerManager.cpp +++ b/src/app/ControllerManager.cpp @@ -217,12 +217,12 @@ void ControllerManager::OnDeviceChange() { SyncDevices(); } -ControllerManager::GameModeOutcome ControllerManager::EnableGameMode() { +ControllerManager::GameModeOutcome ControllerManager::EnableGameMode(uint32_t stateWaitMs) { bool anyVigemMissing = false; bool anyBlocked = false; bool anyEnabled = false; for (auto& slot : m_slots) { - switch (EnableGameModeSlot(*slot, anyVigemMissing)) { + switch (EnableGameModeSlot(*slot, anyVigemMissing, stateWaitMs)) { case GameModeOutcome::Enabled: anyEnabled = true; break; case GameModeOutcome::Blocked: anyBlocked = true; break; case GameModeOutcome::NoActiveController: break; @@ -365,7 +365,7 @@ void ControllerManager::OpenSlot(const std::wstring& path) { if (IsGameModeActive()) { bool dummy = false; - EnableGameModeSlot(*m_slots.back(), dummy); + EnableGameModeSlot(*m_slots.back(), dummy, 250); } else { // Restore lizard mode in case a previous session crashed without cleaning up. m_slots.back()->sc->EnableLizardMode(); @@ -377,7 +377,8 @@ void ControllerManager::OpenSlot(const std::wstring& path) { // --------------------------------------------------------------------------- ControllerManager::GameModeOutcome -ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) { +ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut, + uint32_t stateWaitMs) { if (slot.gameModeActive) return GameModeOutcome::Enabled; // The puck publishes a controller interface per slot and current firmware // rejects the lizard-mode command on an empty one, so only act on a slot @@ -388,7 +389,7 @@ ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) { if (now - slot.lastSilentAt < kSilentSlotRetryGap) return GameModeOutcome::NoActiveController; - if (!slot.sc->WaitForStateReport(250)) { + if (!slot.sc->WaitForStateReport(stateWaitMs)) { slot.lastSilentAt = std::chrono::steady_clock::now(); EventLog::Write("GAMEMODE: no state reports from slot (transport=%s), skipping %ls", SteamController::TransportName(slot.transport), slot.path.c_str()); diff --git a/src/app/ControllerManager.h b/src/app/ControllerManager.h index df191b4..b953b9f 100644 --- a/src/app/ControllerManager.h +++ b/src/app/ControllerManager.h @@ -32,7 +32,10 @@ class ControllerManager { // switched on is pointless churn. enum class GameModeOutcome { Enabled, NoActiveController, Blocked }; - GameModeOutcome EnableGameMode(); + // 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 — + // a live slot streams continuously, so it answers almost immediately. + GameModeOutcome EnableGameMode(uint32_t stateWaitMs = 250); void DisableGameMode(); // Disables game mode then closes all device handles so another process // (e.g. Steam) can claim the controller. Safe to call when already disabled. @@ -75,7 +78,8 @@ class ControllerManager { void SyncDevices(); void OpenSlot(const std::wstring& path); - GameModeOutcome EnableGameModeSlot(Slot& slot, bool& vigemMissingOut); + GameModeOutcome EnableGameModeSlot(Slot& slot, bool& vigemMissingOut, + uint32_t stateWaitMs); void DisableGameModeSlot(Slot& slot); void StartReadLoop(Slot& slot); void StopReadLoop(Slot& slot); diff --git a/src/app/TrayApp.cpp b/src/app/TrayApp.cpp index 0491db9..6f59af5 100644 --- a/src/app/TrayApp.cpp +++ b/src/app/TrayApp.cpp @@ -257,6 +257,11 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { KillTimer(m_hwnd, IDT_ACQUIRE); if (m_autoMode != AutoMode::Manual && WantControl(m_steamWatcher.GetState())) 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())) + 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 @@ -320,6 +325,7 @@ void TrayApp::ApplySteamState(SteamState state) { } 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(); @@ -334,9 +340,9 @@ void TrayApp::ApplySteamState(SteamState state) { } } -void TrayApp::TryAcquireController() { +void TrayApp::TryAcquireController(uint32_t stateWaitMs) { m_controller->OnDeviceChange(); - auto outcome = m_controller->EnableGameMode(); + auto outcome = m_controller->EnableGameMode(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 @@ -344,10 +350,11 @@ void TrayApp::TryAcquireController() { for (int i = 0; i < 10 && !m_controller->IsGameModeActive(); ++i) { Sleep(50); m_controller->OnDeviceChange(); - outcome = m_controller->EnableGameMode(); + outcome = m_controller->EnableGameMode(stateWaitMs); } if (m_controller->IsGameModeActive()) { KillTimer(m_hwnd, IDT_ACQUIRE_VERDICT); + KillTimer(m_hwnd, IDT_WAKE_POLL); m_acquireRetries = 0; m_lastCycleTick = 0; return; @@ -355,12 +362,18 @@ void TrayApp::TryAcquireController() { if (!m_controller->IsConnected()) return; // nothing plugged in — arrival will retrigger // No slot is emitting state: the controller is off, asleep, or the - // receiver has no controller paired into it. Cycling the device cannot - // conjure one up, and doing it anyway restarts the receiver's devnodes - // (and can prompt for elevation) just because nothing is switched on. - // Wake-up arrives as a device-change event, which retriggers this. - if (outcome == ControllerManager::GameModeOutcome::NoActiveController) + // receiver has no controller paired into it. Cycling cannot conjure one + // up, and doing it anyway restarts the receiver's devnodes (and can + // prompt for elevation) just because nothing is switched on. + // + // Poll instead of waiting for an event. A receiver keeps publishing all + // of its slot interfaces whether or not a controller is paired into one, + // so switching the controller on produces no WM_DEVICECHANGE at all — + // measured: 88 seconds of silence after the controller was turned on. + if (outcome == ControllerManager::GameModeOutcome::NoActiveController) { + SetTimer(m_hwnd, IDT_WAKE_POLL, WAKE_POLL_MS, nullptr); return; + } // A cycle is asynchronous — fired through Task Scheduler, and the helper // pauses a second between disable and enable. Every device arrival it diff --git a/src/app/TrayApp.h b/src/app/TrayApp.h index 3bb6966..64af80d 100644 --- a/src/app/TrayApp.h +++ b/src/app/TrayApp.h @@ -56,7 +56,7 @@ class TrayApp { void SetAutoMode(AutoMode mode); bool WantControl(SteamState state) const; void ApplySteamState(SteamState state); - void TryAcquireController(); + void TryAcquireController(uint32_t stateWaitMs = 250); bool RestartControllerDevices(); void ShowElevationBalloon(); @@ -99,6 +99,13 @@ class TrayApp { static constexpr UINT TRAY_UID = 1; static constexpr UINT_PTR IDT_ACQUIRE = 1; static constexpr UINT_PTR IDT_ACQUIRE_VERDICT = 2; + static constexpr UINT_PTR IDT_WAKE_POLL = 3; + // A multi-slot receiver publishes every slot interface permanently, so a + // controller waking up produces no device-change event — the only way to + // notice is to keep asking. The probe is short because a live slot streams + // continuously and answers within a few reports. + static constexpr UINT WAKE_POLL_MS = 2000; + static constexpr UINT WAKE_PROBE_MS = 80; static constexpr int MAX_ACQUIRE_CYCLES = 3; // Must outlast a full device cycle: the helper waits a second between // disable and enable, then a multi-slot receiver re-enumerates every From 98d1531bd669059962aaf56f4f8013f1b6556ef3 Mon Sep 17 00:00:00 2001 From: Dylan Deverill Date: Mon, 3 Aug 2026 20:51:01 -0700 Subject: [PATCH 16/19] Batch the device cycle and keep watching for more controllers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reclaiming took 9.1s on a puck, 6.6s of which was the cycle helper. CycleDevices restarted each interface in turn and each paid its own one-second settle, so a four-slot receiver paid it four times. Disable the whole set, settle once, enable the whole set — and drop the settle to 400ms, since the one second was a conservative guess rather than a measured requirement. Callers get a per-device outcome so the log can still tell CYCLED from FELL BACK TO RESTART, and now also from LEFT DISABLED. Also fixes a multi-controller bug in the wake poll. EnableGameMode reports Enabled if any slot came up, and the acquire path then killed the poll outright — so with player one active, player two switching on would never be noticed, because a receiver raises no device event for a slot becoming occupied. Keep polling while any slot is still inactive, at a slower cadence than when we have nothing: each poll briefly blocks the UI thread per empty slot, and this case is opportunistic rather than something a user is waiting on. Untested against multiple controllers — no hardware here for it. Co-Authored-By: Claude Fable 5 --- src/app/ControllerManager.cpp | 5 + src/app/ControllerManager.h | 3 + src/app/DeviceRestart.cpp | 182 ++++++++++++++++++------------- src/app/DeviceRestart.h | 28 ++++- src/app/TrayApp.cpp | 12 +- src/app/TrayApp.h | 9 +- src/helper/DeviceCycleHelper.cpp | 24 ++-- 7 files changed, 169 insertions(+), 94 deletions(-) diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp index e637ed5..8a1679d 100644 --- a/src/app/ControllerManager.cpp +++ b/src/app/ControllerManager.cpp @@ -213,6 +213,11 @@ bool ControllerManager::IsGameModeActive() const { [](const auto& s) { return s->gameModeActive; }); } +bool ControllerManager::HasInactiveSlot() const { + return std::any_of(m_slots.begin(), m_slots.end(), + [](const auto& s) { return !s->gameModeActive; }); +} + void ControllerManager::OnDeviceChange() { SyncDevices(); } diff --git a/src/app/ControllerManager.h b/src/app/ControllerManager.h index b953b9f..1b94fa5 100644 --- a/src/app/ControllerManager.h +++ b/src/app/ControllerManager.h @@ -62,6 +62,9 @@ class ControllerManager { bool IsConnected() const { return !m_slots.empty(); } bool IsGameModeActive() const; + // True while some connected slot is not in game mode — an empty receiver + // slot, or a controller that has not been switched on yet. + bool HasInactiveSlot() const; bool IsTrackpadMouseEnabled() const { return m_trackpadMouseEnabled; } bool IsUseLeftTrackpad() const { return m_useLeftTrackpad; } bool IsBackButtonsEnabled() const { return m_backButtonsEnabled; } diff --git a/src/app/DeviceRestart.cpp b/src/app/DeviceRestart.cpp index 768e3d4..93e73d3 100644 --- a/src/app/DeviceRestart.cpp +++ b/src/app/DeviceRestart.cpp @@ -1,11 +1,20 @@ #include "DeviceRestart.h" #include #include +#include namespace DeviceRestart { -static bool SetDeviceState(HDEVINFO devs, SP_DEVINFO_DATA& devInfo, - DWORD stateChange, DWORD scope) { +namespace { + +// How long the whole batch is left down before being brought back. One second +// was a conservative guess; the settle only has to outlast the removal +// propagating, and paying it once per batch rather than once per device is +// what makes a multi-slot receiver usable. +constexpr DWORD kSettleMs = 400; + +bool SetDeviceState(HDEVINFO devs, SP_DEVINFO_DATA& devInfo, + DWORD stateChange, DWORD scope) { SP_PROPCHANGE_PARAMS pc{}; pc.ClassInstallHeader.cbSize = sizeof(SP_CLASSINSTALL_HEADER); pc.ClassInstallHeader.InstallFunction = DIF_PROPERTYCHANGE; @@ -16,98 +25,123 @@ static bool SetDeviceState(HDEVINFO devs, SP_DEVINFO_DATA& devInfo, && SetupDiCallClassInstaller(DIF_PROPERTYCHANGE, devs, &devInfo); } -static bool IsDevNodeDisabled(DEVINST devInst) { +bool IsDevNodeDisabled(DEVINST devInst) { ULONG status = 0, problem = 0; if (CM_Get_DevNode_Status(&status, &problem, devInst, 0) != CR_SUCCESS) return false; // can't tell — don't report a disabled state we didn't see return (status & DN_HAS_PROBLEM) != 0 && problem == CM_PROB_DISABLED; } -// Take the devnode down and bring it back, invalidating every open handle to -// it — including the one Steam holds, which is the whole point of a cycle. -// -// Disable/enable rather than the politer DICS_PROPCHANGE "restart", because -// on a Bluetooth HID child that restart reports success while leaving the -// device and Steam's handle untouched. A disable asks nobody's permission, -// so it is the only thing that reliably breaks the handle. +struct Target { + std::wstring path; + SP_DEVINFO_DATA devInfo{}; + bool valid = false; + bool wentDown = false; + DWORD error = ERROR_SUCCESS; +}; + +} // namespace + +// Disable/enable rather than the politer DICS_PROPCHANGE "restart", because on +// a Bluetooth HID child that restart reports success while leaving the device +// and the holder's handle untouched. CM_Query_And_Remove_SubTree is no better: +// it asks permission that an open handle can veto. A disable asks nobody. // -// SetupDiCallClassInstaller's return value cannot be trusted here: a disable -// that Windows genuinely performed still came back as ERROR_INVALID_DATA on -// this Bluetooth HID child. So the devnode's own status is the source of -// truth, and the re-enable is never gated on what the disable claimed — -// getting that wrong strands the controller switched off, needing Device -// Manager to recover, which is far worse than failing to cycle at all. -static bool CycleDevNode(HDEVINFO devs, SP_DEVINFO_DATA& devInfo, DWORD* errorOut) { - // Some devices reject a global scope change and only accept a - // config-specific one, so try both before believing a disable was refused. - if (!SetDeviceState(devs, devInfo, DICS_DISABLE, DICS_FLAG_GLOBAL)) - SetDeviceState(devs, devInfo, DICS_DISABLE, DICS_FLAG_CONFIGSPECIFIC); - - // Did it actually go down? This, not the return code above, decides - // whether a real cycle happened. - const bool wentDown = IsDevNodeDisabled(devInfo.DevInst); - - if (wentDown) - Sleep(1000); // let the removal propagate before bringing it back - - // Always re-enable, whatever the disable reported, and keep going until - // the devnode confirms it is no longer disabled. - bool enabled = !IsDevNodeDisabled(devInfo.DevInst); - for (int attempt = 0; attempt < 3 && !enabled; ++attempt) { - if (attempt) Sleep(500); - SetDeviceState(devs, devInfo, DICS_ENABLE, DICS_FLAG_GLOBAL); - if (IsDevNodeDisabled(devInfo.DevInst)) - SetDeviceState(devs, devInfo, DICS_ENABLE, DICS_FLAG_CONFIGSPECIFIC); - enabled = !IsDevNodeDisabled(devInfo.DevInst); +// SetupDiCallClassInstaller's return value cannot be trusted here — a disable +// Windows genuinely performed still came back ERROR_INVALID_DATA — so the +// devnode's own status is the source of truth, and the re-enable never depends +// on what the disable claimed. Getting that wrong strands the controller +// switched off, needing Device Manager to recover, which is far worse than +// failing to cycle at all. +std::vector RestartInterfaceDevices(const std::vector& paths) { + std::vector results; + results.reserve(paths.size()); + if (paths.empty()) return results; + + HDEVINFO devs = SetupDiCreateDeviceInfoList(nullptr, nullptr); + if (devs == INVALID_HANDLE_VALUE) { + const DWORD err = GetLastError(); + for (auto const& path : paths) + results.push_back({path, false, false, true, err}); + return results; } - if (!enabled) { - // The controller is stranded disabled — the caller must surface this, - // it is not a quiet failure. - if (errorOut) *errorOut = GetLastError(); - return false; + std::vector targets; + targets.reserve(paths.size()); + for (auto const& path : paths) { + Target target; + target.path = path; + SP_DEVICE_INTERFACE_DATA ifData{}; + ifData.cbSize = sizeof(ifData); + if (SetupDiOpenDeviceInterfaceW(devs, path.c_str(), 0, &ifData)) { + // Query with no output buffer purely to resolve the owning devnode + // — the call fails with ERROR_INSUFFICIENT_BUFFER but still fills + // devInfo. + target.devInfo.cbSize = sizeof(target.devInfo); + SetupDiGetDeviceInterfaceDetailW(devs, &ifData, nullptr, 0, nullptr, &target.devInfo); + target.valid = target.devInfo.DevInst != 0; + } + if (!target.valid) target.error = GetLastError(); + targets.push_back(std::move(target)); } - if (!wentDown) { - // Nothing was taken down, so no handle was broken. Fall back to - // asking for a plain restart, which is enough on USB. - if (errorOut) *errorOut = ERROR_NOT_SUPPORTED; - return SetDeviceState(devs, devInfo, DICS_PROPCHANGE, DICS_FLAG_GLOBAL); + // Take the whole set down first, so one settle covers all of them. + for (auto& target : targets) { + if (!target.valid) continue; + // Some devices reject a global scope change and accept only a + // config-specific one, so try both before believing a disable failed. + if (!SetDeviceState(devs, target.devInfo, DICS_DISABLE, DICS_FLAG_GLOBAL)) + SetDeviceState(devs, target.devInfo, DICS_DISABLE, DICS_FLAG_CONFIGSPECIFIC); + target.wentDown = IsDevNodeDisabled(target.devInfo.DevInst); } - return true; -} + if (std::any_of(targets.begin(), targets.end(), + [](const Target& t) { return t.wentDown; })) + Sleep(kSettleMs); -bool RestartInterfaceDevice(const std::wstring& interfacePath, DWORD* errorOut) { - if (errorOut) *errorOut = ERROR_SUCCESS; + for (auto& target : targets) { + if (!target.valid) { + results.push_back({target.path, false, false, true, target.error}); + continue; + } - HDEVINFO devs = SetupDiCreateDeviceInfoList(nullptr, nullptr); - if (devs == INVALID_HANDLE_VALUE) { - if (errorOut) *errorOut = GetLastError(); - return false; - } + // The re-enable always runs, and keeps going until the devnode + // confirms it is no longer disabled. + bool enabled = !IsDevNodeDisabled(target.devInfo.DevInst); + for (int attempt = 0; attempt < 3 && !enabled; ++attempt) { + if (attempt) Sleep(250); + SetDeviceState(devs, target.devInfo, DICS_ENABLE, DICS_FLAG_GLOBAL); + if (IsDevNodeDisabled(target.devInfo.DevInst)) + SetDeviceState(devs, target.devInfo, DICS_ENABLE, DICS_FLAG_CONFIGSPECIFIC); + enabled = !IsDevNodeDisabled(target.devInfo.DevInst); + } - bool ok = false; - SP_DEVICE_INTERFACE_DATA ifData{}; - ifData.cbSize = sizeof(ifData); - if (SetupDiOpenDeviceInterfaceW(devs, interfacePath.c_str(), 0, &ifData)) { - // Query with no output buffer purely to resolve the owning devnode — - // the call fails with ERROR_INSUFFICIENT_BUFFER but still fills devInfo. - SP_DEVINFO_DATA devInfo{}; - devInfo.cbSize = sizeof(devInfo); - SetupDiGetDeviceInterfaceDetailW(devs, &ifData, nullptr, 0, nullptr, &devInfo); - - if (devInfo.DevInst != 0) { - ok = CycleDevNode(devs, devInfo, errorOut); - } else if (errorOut) { - *errorOut = GetLastError(); + RestartOutcome outcome{target.path, target.wentDown, false, enabled, ERROR_SUCCESS}; + if (!enabled) { + outcome.error = GetLastError(); + } else if (!target.wentDown) { + // Nothing came down, so no handle was broken. Fall back to asking + // for a plain restart, which is enough on transports where the + // class installer honours it. + outcome.restartRequested = + SetDeviceState(devs, target.devInfo, DICS_PROPCHANGE, DICS_FLAG_GLOBAL); + outcome.error = ERROR_NOT_SUPPORTED; } - } else if (errorOut) { - *errorOut = GetLastError(); + results.push_back(std::move(outcome)); } SetupDiDestroyDeviceInfoList(devs); - return ok; + return results; +} + +bool RestartInterfaceDevice(const std::wstring& interfacePath, DWORD* errorOut) { + const auto results = RestartInterfaceDevices({interfacePath}); + if (results.empty()) { + if (errorOut) *errorOut = ERROR_INVALID_PARAMETER; + return false; + } + if (errorOut) *errorOut = results[0].error; + return results[0].enabled && (results[0].cycled || results[0].restartRequested); } } diff --git a/src/app/DeviceRestart.h b/src/app/DeviceRestart.h index 066db7b..e382f51 100644 --- a/src/app/DeviceRestart.h +++ b/src/app/DeviceRestart.h @@ -1,16 +1,32 @@ #pragma once #include #include +#include namespace DeviceRestart { -// Restarts the PnP device node backing an HID interface path — equivalent to -// unplugging and replugging the device. Every process's open handles to it -// (including Steam's) are invalidated, and the re-arrival becomes an open -// race that whoever reacts to the arrival notification first wins. +struct RestartOutcome { + std::wstring path; + bool cycled = false; // devnode genuinely went down and came back + bool restartRequested = false; // disable refused; asked for a plain restart + bool enabled = true; // false means it was left disabled — surface it + DWORD error = ERROR_SUCCESS; +}; + +// Restarts the PnP device nodes backing the given HID interface paths — +// equivalent to unplugging and replugging them. Every process's open handles +// (including Steam's) are invalidated, and the re-arrival becomes an open race +// that whoever reacts to the arrival notification first wins. +// +// Batched deliberately: all the devices are disabled, then one settle covers +// the whole set, then all are re-enabled. The settle dominates the cost, so +// cycling a four-slot receiver one device at a time paid it four times over — +// measured at 6.6s before a reclaim could even begin. // -// Requires administrator rights; fails with ERROR_ACCESS_DENIED in errorOut -// when the process is not elevated. +// Requires administrator rights; fails with ERROR_ACCESS_DENIED otherwise. +std::vector RestartInterfaceDevices(const std::vector& paths); + +// Single-device convenience wrapper. bool RestartInterfaceDevice(const std::wstring& interfacePath, DWORD* errorOut = nullptr); } diff --git a/src/app/TrayApp.cpp b/src/app/TrayApp.cpp index 6f59af5..0f34bcc 100644 --- a/src/app/TrayApp.cpp +++ b/src/app/TrayApp.cpp @@ -354,9 +354,17 @@ void TrayApp::TryAcquireController(uint32_t stateWaitMs) { } if (m_controller->IsGameModeActive()) { KillTimer(m_hwnd, IDT_ACQUIRE_VERDICT); - KillTimer(m_hwnd, IDT_WAKE_POLL); m_acquireRetries = 0; m_lastCycleTick = 0; + // Having one controller does not mean we are done watching. A receiver + // publishes every slot interface permanently, so a second controller + // switching on raises no device event either — without this, player two + // never gets picked up. Slower cadence than when we have nothing, since + // this is opportunistic rather than something the user is waiting on. + if (m_controller->HasInactiveSlot()) + SetTimer(m_hwnd, IDT_WAKE_POLL, WAKE_POLL_ACTIVE_MS, nullptr); + else + KillTimer(m_hwnd, IDT_WAKE_POLL); return; } if (!m_controller->IsConnected()) return; // nothing plugged in — arrival will retrigger @@ -371,7 +379,7 @@ void TrayApp::TryAcquireController(uint32_t stateWaitMs) { // so switching the controller on produces no WM_DEVICECHANGE at all — // measured: 88 seconds of silence after the controller was turned on. if (outcome == ControllerManager::GameModeOutcome::NoActiveController) { - SetTimer(m_hwnd, IDT_WAKE_POLL, WAKE_POLL_MS, nullptr); + SetTimer(m_hwnd, IDT_WAKE_POLL, WAKE_POLL_IDLE_MS, nullptr); return; } diff --git a/src/app/TrayApp.h b/src/app/TrayApp.h index 64af80d..795567a 100644 --- a/src/app/TrayApp.h +++ b/src/app/TrayApp.h @@ -104,8 +104,13 @@ class TrayApp { // controller waking up produces no device-change event — the only way to // notice is to keep asking. The probe is short because a live slot streams // continuously and answers within a few reports. - static constexpr UINT WAKE_POLL_MS = 2000; - static constexpr UINT WAKE_PROBE_MS = 80; + // Brisk while we have no controller at all — the user is waiting on it. + static constexpr UINT WAKE_POLL_IDLE_MS = 2000; + // Relaxed once something is already in game mode: this is only watching + // for additional controllers joining, and each poll briefly blocks the UI + // thread once per still-empty slot. + static constexpr UINT WAKE_POLL_ACTIVE_MS = 5000; + static constexpr UINT WAKE_PROBE_MS = 80; static constexpr int MAX_ACQUIRE_CYCLES = 3; // Must outlast a full device cycle: the helper waits a second between // disable and enable, then a multi-slot receiver re-enumerates every diff --git a/src/helper/DeviceCycleHelper.cpp b/src/helper/DeviceCycleHelper.cpp index 4f2804b..5901a0f 100644 --- a/src/helper/DeviceCycleHelper.cpp +++ b/src/helper/DeviceCycleHelper.cpp @@ -78,16 +78,20 @@ static int CycleDevices() { const auto paths = SteamController::EnumerateAll(); CycleLog("START: %zu interface(s) to cycle", paths.size()); - for (const auto& path : paths) { - DWORD err = ERROR_SUCCESS; - const bool ok = DeviceRestart::RestartInterfaceDevice(path, &err); - // err is only non-zero on the disable-refused fallback path, so - // ok=true with err set means the cycle silently did nothing. - CycleLog("%s (transport=%s err=%lu) %ls", - ok ? (err == ERROR_SUCCESS ? "CYCLED" : "FELL BACK TO RESTART") : "FAILED", - SteamController::TransportName(SteamController::TransportFromPath(path)), - err, path.c_str()); - if (ok) any = true; + // One batch: every device down, one settle, every device back. Cycling + // them individually paid the settle once per interface, which on a + // four-slot receiver delayed the reclaim by seconds. + for (const auto& outcome : DeviceRestart::RestartInterfaceDevices(paths)) { + const char* verdict = !outcome.enabled ? "LEFT DISABLED" + : outcome.cycled ? "CYCLED" + : outcome.restartRequested ? "FELL BACK TO RESTART" + : "FAILED"; + CycleLog("%s (transport=%s err=%lu) %ls", verdict, + SteamController::TransportName( + SteamController::TransportFromPath(outcome.path)), + outcome.error, outcome.path.c_str()); + if (outcome.enabled && (outcome.cycled || outcome.restartRequested)) + any = true; } CycleLog("DONE: exit=%d", any ? 0 : 1); From 89b208fbae88863399320528b241e5f14b63ed52 Mon Sep 17 00:00:00 2001 From: Dylan Deverill Date: Mon, 3 Aug 2026 20:55:19 -0700 Subject: [PATCH 17/19] Revert to cycling one interface at a time; batching lost the race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batching the cycle was wrong on both counts it was justified by. It saved nothing. The settle was not the dominant cost — the SetupDiCallClassInstaller disable calls are, and they are paid per device either way. Batched, the whole cycle still took ~5s. Worse, it broke the reclaim. Cycling sequentially happens to bring the occupied slot back last and alone, giving the app an uncontested shot at it: at 20:41 the live slot re-enabled at .690 and game mode came up at .735, winning by 45ms. Released together, all four interfaces arrive at once, the app spends its time probing the empty ones, and Steam claims the live one first. Three cycles in a row failed that way, ending in the give-up alert, with a flood of Steam connect/disconnect toasts along the way. Keeps the 400ms settle (down from 1s) and the per-device outcome reporting, both of which stand on their own. Real speed-up here is to cycle only the contested interface rather than all of them, which also fixes reclaiming one controller disrupting the others. That needs the helper to learn which path to target, so it is its own change. Co-Authored-By: Claude Fable 5 --- src/helper/DeviceCycleHelper.cpp | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/helper/DeviceCycleHelper.cpp b/src/helper/DeviceCycleHelper.cpp index 5901a0f..6a9b015 100644 --- a/src/helper/DeviceCycleHelper.cpp +++ b/src/helper/DeviceCycleHelper.cpp @@ -78,10 +78,20 @@ static int CycleDevices() { const auto paths = SteamController::EnumerateAll(); CycleLog("START: %zu interface(s) to cycle", paths.size()); - // One batch: every device down, one settle, every device back. Cycling - // them individually paid the settle once per interface, which on a - // four-slot receiver delayed the reclaim by seconds. - for (const auto& outcome : DeviceRestart::RestartInterfaceDevices(paths)) { + // Deliberately one interface at a time, not one batch. Batching them was + // measured and lost: it saved nothing (the class-installer disable calls + // dominate, not the settle) and it broke the reclaim outright. Cycling + // sequentially brings the occupied slot back last and alone, so the app + // gets an uncontested shot at it — it won that race by 45ms. Released + // together, all four arrive at once, the app spends its time probing the + // empty ones, and Steam takes the live one first. + std::vector outcomes; + for (const auto& path : paths) { + auto one = DeviceRestart::RestartInterfaceDevices({ path }); + if (!one.empty()) outcomes.push_back(std::move(one.front())); + } + + for (const auto& outcome : outcomes) { const char* verdict = !outcome.enabled ? "LEFT DISABLED" : outcome.cycled ? "CYCLED" : outcome.restartRequested ? "FELL BACK TO RESTART" From 987a67242e493b75218bf5e6b9b748a99f439327 Mon Sep 17 00:00:00 2001 From: Dylan Deverill Date: Mon, 3 Aug 2026 21:00:09 -0700 Subject: [PATCH 18/19] Cycle only the contested interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reclaim restarted every Valve device present. On a four-slot receiver that meant three empty slots torn down for nothing, and in a multi- controller setup it dropped controllers Steam had never touched — one person quitting a game briefly killed everyone's pad. The app already knows which interface was refused, so it now names it. The scheduled task runs the helper with no arguments and cannot be given any, so the paths are handed over through a file the helper consumes and deletes; an empty or stale handover still means "cycle everything", which keeps a helper run by hand working as before. A handover older than a minute is discarded so a crashed run cannot steer a later cycle. Targets are captured before ReleaseDevices, which clears the slots. Co-Authored-By: Claude Fable 5 --- src/app/ControllerManager.cpp | 15 +++++- src/app/ControllerManager.h | 3 ++ src/app/DeviceRestart.cpp | 92 ++++++++++++++++++++++++++++++++ src/app/DeviceRestart.h | 12 +++++ src/app/TrayApp.cpp | 12 ++++- src/helper/DeviceCycleHelper.cpp | 9 +++- 6 files changed, 138 insertions(+), 5 deletions(-) diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp index 8a1679d..ccd869b 100644 --- a/src/app/ControllerManager.cpp +++ b/src/app/ControllerManager.cpp @@ -34,6 +34,10 @@ struct ControllerManager::Slot { // thread for that timeout on every one of those attempts. std::chrono::steady_clock::time_point lastSilentAt{}; + // Set when the last attempt was refused by an existing handle holder, so + // a cycle can target this interface instead of every device present. + bool lastAttemptBlocked = false; + // Haptic edge-detection state for touch (movement ticks). bool hapticWasRightTouching = false; bool hapticWasLeftTouching = false; @@ -218,6 +222,13 @@ bool ControllerManager::HasInactiveSlot() const { [](const auto& s) { return !s->gameModeActive; }); } +std::vector ControllerManager::BlockedSlotPaths() const { + std::vector paths; + for (auto const& slot : m_slots) + if (slot->lastAttemptBlocked) paths.push_back(slot->path); + return paths; +} + void ControllerManager::OnDeviceChange() { SyncDevices(); } @@ -227,7 +238,9 @@ ControllerManager::GameModeOutcome ControllerManager::EnableGameMode(uint32_t st bool anyBlocked = false; bool anyEnabled = false; for (auto& slot : m_slots) { - switch (EnableGameModeSlot(*slot, anyVigemMissing, stateWaitMs)) { + const auto outcome = EnableGameModeSlot(*slot, anyVigemMissing, stateWaitMs); + slot->lastAttemptBlocked = (outcome == GameModeOutcome::Blocked); + switch (outcome) { case GameModeOutcome::Enabled: anyEnabled = true; break; case GameModeOutcome::Blocked: anyBlocked = true; break; case GameModeOutcome::NoActiveController: break; diff --git a/src/app/ControllerManager.h b/src/app/ControllerManager.h index 1b94fa5..7eb336e 100644 --- a/src/app/ControllerManager.h +++ b/src/app/ControllerManager.h @@ -65,6 +65,9 @@ class ControllerManager { // True while some connected slot is not in game mode — an empty receiver // slot, or a controller that has not been switched on yet. bool HasInactiveSlot() const; + // Interfaces whose most recent attempt was refused by whoever already + // holds them. These are the only ones a device cycle needs to touch. + std::vector BlockedSlotPaths() const; bool IsTrackpadMouseEnabled() const { return m_trackpadMouseEnabled; } bool IsUseLeftTrackpad() const { return m_useLeftTrackpad; } bool IsBackButtonsEnabled() const { return m_backButtonsEnabled; } diff --git a/src/app/DeviceRestart.cpp b/src/app/DeviceRestart.cpp index 93e73d3..042e5e3 100644 --- a/src/app/DeviceRestart.cpp +++ b/src/app/DeviceRestart.cpp @@ -134,6 +134,98 @@ std::vector RestartInterfaceDevices(const std::vector& paths) { + const std::wstring file = CycleTargetsPath(); + if (file.empty()) return false; + if (paths.empty()) { + DeleteFileW(file.c_str()); + return true; + } + + std::wstring body; + for (auto const& path : paths) { + body += path; + body += L'\n'; + } + + HANDLE handle = CreateFileW(file.c_str(), GENERIC_WRITE, 0, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (handle == INVALID_HANDLE_VALUE) return false; + + const wchar_t bom = 0xFEFF; + DWORD written = 0; + bool ok = WriteFile(handle, &bom, sizeof(bom), &written, nullptr) != 0 + && WriteFile(handle, body.data(), + static_cast(body.size() * sizeof(wchar_t)), + &written, nullptr) != 0; + CloseHandle(handle); + if (!ok) DeleteFileW(file.c_str()); + return ok; +} + +std::vector ConsumeCycleTargets() { + std::vector paths; + const std::wstring file = CycleTargetsPath(); + if (file.empty()) return paths; + + WIN32_FILE_ATTRIBUTE_DATA attrs{}; + if (!GetFileAttributesExW(file.c_str(), GetFileExInfoStandard, &attrs)) + return paths; + + ULARGE_INTEGER wrote{}, now{}; + wrote.LowPart = attrs.ftLastWriteTime.dwLowDateTime; + wrote.HighPart = attrs.ftLastWriteTime.dwHighDateTime; + FILETIME nowFt{}; + GetSystemTimeAsFileTime(&nowFt); + now.LowPart = nowFt.dwLowDateTime; + now.HighPart = nowFt.dwHighDateTime; + const ULONGLONG ageMs = now.QuadPart > wrote.QuadPart + ? (now.QuadPart - wrote.QuadPart) / 10000ULL : 0ULL; + + HANDLE handle = CreateFileW(file.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (handle != INVALID_HANDLE_VALUE) { + std::wstring body; + wchar_t buffer[512]; + DWORD read = 0; + while (ReadFile(handle, buffer, sizeof(buffer), &read, nullptr) && read > 0) + body.append(buffer, read / sizeof(wchar_t)); + CloseHandle(handle); + + if (!body.empty() && body.front() == 0xFEFF) body.erase(body.begin()); + size_t start = 0; + while (start < body.size()) { + size_t end = body.find(L'\n', start); + if (end == std::wstring::npos) end = body.size(); + std::wstring line = body.substr(start, end - start); + while (!line.empty() && (line.back() == L'\r' || line.back() == L'\n')) + line.pop_back(); + if (!line.empty()) paths.push_back(std::move(line)); + start = end + 1; + } + } + + DeleteFileW(file.c_str()); + if (ageMs > kTargetsMaxAgeMs) paths.clear(); + return paths; +} + bool RestartInterfaceDevice(const std::wstring& interfacePath, DWORD* errorOut) { const auto results = RestartInterfaceDevices({interfacePath}); if (results.empty()) { diff --git a/src/app/DeviceRestart.h b/src/app/DeviceRestart.h index e382f51..500ca86 100644 --- a/src/app/DeviceRestart.h +++ b/src/app/DeviceRestart.h @@ -29,4 +29,16 @@ std::vector RestartInterfaceDevices(const std::vector& paths); + +// Reads and deletes the handover file. Empty when absent or stale, which +// means "cycle everything" — the behaviour for a helper run by hand. +std::vector ConsumeCycleTargets(); + } diff --git a/src/app/TrayApp.cpp b/src/app/TrayApp.cpp index 0f34bcc..6b36368 100644 --- a/src/app/TrayApp.cpp +++ b/src/app/TrayApp.cpp @@ -408,8 +408,14 @@ void TrayApp::TryAcquireController(uint32_t stateWaitMs) { } ++m_acquireRetries; m_lastCycleTick = nowTick; - EventLog::Write("AUTO: exclusive claim blocked — cycling device (attempt %d)", m_acquireRetries); + // Capture the contested interfaces before ReleaseDevices clears the slots. + // Cycling only these leaves any other controller untouched, and on a + // multi-slot receiver skips three empty slots' worth of teardown. + const auto targets = m_controller->BlockedSlotPaths(); + EventLog::Write("AUTO: exclusive claim blocked — cycling %zu device(s) (attempt %d)", + targets.size(), m_acquireRetries); m_controller->ReleaseDevices(); + DeviceRestart::WriteCycleTargets(targets); if (!RestartControllerDevices()) return; // helper unavailable — balloon shown // The cycle runs asynchronously via the helper task; the device-arrival // notification drives the claim, with this timer as the fallback. It must @@ -424,8 +430,10 @@ bool TrayApp::RestartControllerDevices() { // the pairing (which lives up at the BTHLEDEVICE layer), and it // re-enumerates as fast as a USB replug. if (IsProcessElevated()) { + auto paths = DeviceRestart::ConsumeCycleTargets(); + if (paths.empty()) paths = SteamController::EnumerateAll(); bool anyRestarted = false; - for (const auto& path : SteamController::EnumerateAll()) + for (const auto& path : paths) if (DeviceRestart::RestartInterfaceDevice(path)) anyRestarted = true; return anyRestarted; diff --git a/src/helper/DeviceCycleHelper.cpp b/src/helper/DeviceCycleHelper.cpp index 6a9b015..87d21e8 100644 --- a/src/helper/DeviceCycleHelper.cpp +++ b/src/helper/DeviceCycleHelper.cpp @@ -75,8 +75,13 @@ static bool RunToolHidden(std::wstring cmdline) { static int CycleDevices() { bool any = false; - const auto paths = SteamController::EnumerateAll(); - CycleLog("START: %zu interface(s) to cycle", paths.size()); + // The tray app names the contested interfaces when it fires us; an empty + // handover (or a run by hand) means cycle everything. + auto paths = DeviceRestart::ConsumeCycleTargets(); + const bool targeted = !paths.empty(); + if (!targeted) paths = SteamController::EnumerateAll(); + CycleLog("START: %zu interface(s) to cycle (%s)", paths.size(), + targeted ? "targeted" : "all"); // Deliberately one interface at a time, not one batch. Batching them was // measured and lost: it saved nothing (the class-installer disable calls From 04c0d375616e3513e3e27fc879c250015b16b143 Mon Sep 17 00:00:00 2001 From: Dylan Deverill Date: Mon, 3 Aug 2026 21:05:40 -0700 Subject: [PATCH 19/19] Revert the reclaim speed work; keep the behaviour that works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three attempts to make the reclaim faster all made it worse, so this returns the tree to 1260656 — the state verified end to end on the puck, where the reclaim took 9.1s but succeeded on the first cycle with no spurious failure alert. Reverted: - batching the cycle (saved nothing, and losing the sequential order cost the race against Steam) - the 400ms settle and per-device outcome reporting that rode along with it - cycling only the contested interface Also reverted, and worth re-doing on its own: keeping the wake poll armed while another controller is active. That fixed a real multi-controller gap — player two switching on is never noticed, because a receiver raises no device event for a slot becoming occupied — but it was never verified, and it does not belong in a change meant to restore known-good behaviour. The 9.1s reclaim is dominated by SetupDiCallClassInstaller disabling four interfaces in turn. Making that faster means not cycling all four, which is exactly what failed here; it needs to be worked out with hardware rather than reasoned about. Co-Authored-By: Claude Fable 5 --- src/app/ControllerManager.cpp | 20 +-- src/app/ControllerManager.h | 6 - src/app/DeviceRestart.cpp | 272 +++++++++---------------------- src/app/DeviceRestart.h | 40 +---- src/app/TrayApp.cpp | 24 +-- src/app/TrayApp.h | 9 +- src/helper/DeviceCycleHelper.cpp | 43 ++--- 7 files changed, 98 insertions(+), 316 deletions(-) diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp index ccd869b..e637ed5 100644 --- a/src/app/ControllerManager.cpp +++ b/src/app/ControllerManager.cpp @@ -34,10 +34,6 @@ struct ControllerManager::Slot { // thread for that timeout on every one of those attempts. std::chrono::steady_clock::time_point lastSilentAt{}; - // Set when the last attempt was refused by an existing handle holder, so - // a cycle can target this interface instead of every device present. - bool lastAttemptBlocked = false; - // Haptic edge-detection state for touch (movement ticks). bool hapticWasRightTouching = false; bool hapticWasLeftTouching = false; @@ -217,18 +213,6 @@ bool ControllerManager::IsGameModeActive() const { [](const auto& s) { return s->gameModeActive; }); } -bool ControllerManager::HasInactiveSlot() const { - return std::any_of(m_slots.begin(), m_slots.end(), - [](const auto& s) { return !s->gameModeActive; }); -} - -std::vector ControllerManager::BlockedSlotPaths() const { - std::vector paths; - for (auto const& slot : m_slots) - if (slot->lastAttemptBlocked) paths.push_back(slot->path); - return paths; -} - void ControllerManager::OnDeviceChange() { SyncDevices(); } @@ -238,9 +222,7 @@ ControllerManager::GameModeOutcome ControllerManager::EnableGameMode(uint32_t st bool anyBlocked = false; bool anyEnabled = false; for (auto& slot : m_slots) { - const auto outcome = EnableGameModeSlot(*slot, anyVigemMissing, stateWaitMs); - slot->lastAttemptBlocked = (outcome == GameModeOutcome::Blocked); - switch (outcome) { + switch (EnableGameModeSlot(*slot, anyVigemMissing, stateWaitMs)) { case GameModeOutcome::Enabled: anyEnabled = true; break; case GameModeOutcome::Blocked: anyBlocked = true; break; case GameModeOutcome::NoActiveController: break; diff --git a/src/app/ControllerManager.h b/src/app/ControllerManager.h index 7eb336e..b953b9f 100644 --- a/src/app/ControllerManager.h +++ b/src/app/ControllerManager.h @@ -62,12 +62,6 @@ class ControllerManager { bool IsConnected() const { return !m_slots.empty(); } bool IsGameModeActive() const; - // True while some connected slot is not in game mode — an empty receiver - // slot, or a controller that has not been switched on yet. - bool HasInactiveSlot() const; - // Interfaces whose most recent attempt was refused by whoever already - // holds them. These are the only ones a device cycle needs to touch. - std::vector BlockedSlotPaths() const; bool IsTrackpadMouseEnabled() const { return m_trackpadMouseEnabled; } bool IsUseLeftTrackpad() const { return m_useLeftTrackpad; } bool IsBackButtonsEnabled() const { return m_backButtonsEnabled; } diff --git a/src/app/DeviceRestart.cpp b/src/app/DeviceRestart.cpp index 042e5e3..768e3d4 100644 --- a/src/app/DeviceRestart.cpp +++ b/src/app/DeviceRestart.cpp @@ -1,20 +1,11 @@ #include "DeviceRestart.h" #include #include -#include namespace DeviceRestart { -namespace { - -// How long the whole batch is left down before being brought back. One second -// was a conservative guess; the settle only has to outlast the removal -// propagating, and paying it once per batch rather than once per device is -// what makes a multi-slot receiver usable. -constexpr DWORD kSettleMs = 400; - -bool SetDeviceState(HDEVINFO devs, SP_DEVINFO_DATA& devInfo, - DWORD stateChange, DWORD scope) { +static bool SetDeviceState(HDEVINFO devs, SP_DEVINFO_DATA& devInfo, + DWORD stateChange, DWORD scope) { SP_PROPCHANGE_PARAMS pc{}; pc.ClassInstallHeader.cbSize = sizeof(SP_CLASSINSTALL_HEADER); pc.ClassInstallHeader.InstallFunction = DIF_PROPERTYCHANGE; @@ -25,215 +16,98 @@ bool SetDeviceState(HDEVINFO devs, SP_DEVINFO_DATA& devInfo, && SetupDiCallClassInstaller(DIF_PROPERTYCHANGE, devs, &devInfo); } -bool IsDevNodeDisabled(DEVINST devInst) { +static bool IsDevNodeDisabled(DEVINST devInst) { ULONG status = 0, problem = 0; if (CM_Get_DevNode_Status(&status, &problem, devInst, 0) != CR_SUCCESS) return false; // can't tell — don't report a disabled state we didn't see return (status & DN_HAS_PROBLEM) != 0 && problem == CM_PROB_DISABLED; } -struct Target { - std::wstring path; - SP_DEVINFO_DATA devInfo{}; - bool valid = false; - bool wentDown = false; - DWORD error = ERROR_SUCCESS; -}; - -} // namespace - -// Disable/enable rather than the politer DICS_PROPCHANGE "restart", because on -// a Bluetooth HID child that restart reports success while leaving the device -// and the holder's handle untouched. CM_Query_And_Remove_SubTree is no better: -// it asks permission that an open handle can veto. A disable asks nobody. +// Take the devnode down and bring it back, invalidating every open handle to +// it — including the one Steam holds, which is the whole point of a cycle. // -// SetupDiCallClassInstaller's return value cannot be trusted here — a disable -// Windows genuinely performed still came back ERROR_INVALID_DATA — so the -// devnode's own status is the source of truth, and the re-enable never depends -// on what the disable claimed. Getting that wrong strands the controller -// switched off, needing Device Manager to recover, which is far worse than -// failing to cycle at all. -std::vector RestartInterfaceDevices(const std::vector& paths) { - std::vector results; - results.reserve(paths.size()); - if (paths.empty()) return results; - - HDEVINFO devs = SetupDiCreateDeviceInfoList(nullptr, nullptr); - if (devs == INVALID_HANDLE_VALUE) { - const DWORD err = GetLastError(); - for (auto const& path : paths) - results.push_back({path, false, false, true, err}); - return results; - } - - std::vector targets; - targets.reserve(paths.size()); - for (auto const& path : paths) { - Target target; - target.path = path; - SP_DEVICE_INTERFACE_DATA ifData{}; - ifData.cbSize = sizeof(ifData); - if (SetupDiOpenDeviceInterfaceW(devs, path.c_str(), 0, &ifData)) { - // Query with no output buffer purely to resolve the owning devnode - // — the call fails with ERROR_INSUFFICIENT_BUFFER but still fills - // devInfo. - target.devInfo.cbSize = sizeof(target.devInfo); - SetupDiGetDeviceInterfaceDetailW(devs, &ifData, nullptr, 0, nullptr, &target.devInfo); - target.valid = target.devInfo.DevInst != 0; - } - if (!target.valid) target.error = GetLastError(); - targets.push_back(std::move(target)); +// Disable/enable rather than the politer DICS_PROPCHANGE "restart", because +// on a Bluetooth HID child that restart reports success while leaving the +// device and Steam's handle untouched. A disable asks nobody's permission, +// so it is the only thing that reliably breaks the handle. +// +// SetupDiCallClassInstaller's return value cannot be trusted here: a disable +// that Windows genuinely performed still came back as ERROR_INVALID_DATA on +// this Bluetooth HID child. So the devnode's own status is the source of +// truth, and the re-enable is never gated on what the disable claimed — +// getting that wrong strands the controller switched off, needing Device +// Manager to recover, which is far worse than failing to cycle at all. +static bool CycleDevNode(HDEVINFO devs, SP_DEVINFO_DATA& devInfo, DWORD* errorOut) { + // Some devices reject a global scope change and only accept a + // config-specific one, so try both before believing a disable was refused. + if (!SetDeviceState(devs, devInfo, DICS_DISABLE, DICS_FLAG_GLOBAL)) + SetDeviceState(devs, devInfo, DICS_DISABLE, DICS_FLAG_CONFIGSPECIFIC); + + // Did it actually go down? This, not the return code above, decides + // whether a real cycle happened. + const bool wentDown = IsDevNodeDisabled(devInfo.DevInst); + + if (wentDown) + Sleep(1000); // let the removal propagate before bringing it back + + // Always re-enable, whatever the disable reported, and keep going until + // the devnode confirms it is no longer disabled. + bool enabled = !IsDevNodeDisabled(devInfo.DevInst); + for (int attempt = 0; attempt < 3 && !enabled; ++attempt) { + if (attempt) Sleep(500); + SetDeviceState(devs, devInfo, DICS_ENABLE, DICS_FLAG_GLOBAL); + if (IsDevNodeDisabled(devInfo.DevInst)) + SetDeviceState(devs, devInfo, DICS_ENABLE, DICS_FLAG_CONFIGSPECIFIC); + enabled = !IsDevNodeDisabled(devInfo.DevInst); } - // Take the whole set down first, so one settle covers all of them. - for (auto& target : targets) { - if (!target.valid) continue; - // Some devices reject a global scope change and accept only a - // config-specific one, so try both before believing a disable failed. - if (!SetDeviceState(devs, target.devInfo, DICS_DISABLE, DICS_FLAG_GLOBAL)) - SetDeviceState(devs, target.devInfo, DICS_DISABLE, DICS_FLAG_CONFIGSPECIFIC); - target.wentDown = IsDevNodeDisabled(target.devInfo.DevInst); + if (!enabled) { + // The controller is stranded disabled — the caller must surface this, + // it is not a quiet failure. + if (errorOut) *errorOut = GetLastError(); + return false; } - if (std::any_of(targets.begin(), targets.end(), - [](const Target& t) { return t.wentDown; })) - Sleep(kSettleMs); - - for (auto& target : targets) { - if (!target.valid) { - results.push_back({target.path, false, false, true, target.error}); - continue; - } - - // The re-enable always runs, and keeps going until the devnode - // confirms it is no longer disabled. - bool enabled = !IsDevNodeDisabled(target.devInfo.DevInst); - for (int attempt = 0; attempt < 3 && !enabled; ++attempt) { - if (attempt) Sleep(250); - SetDeviceState(devs, target.devInfo, DICS_ENABLE, DICS_FLAG_GLOBAL); - if (IsDevNodeDisabled(target.devInfo.DevInst)) - SetDeviceState(devs, target.devInfo, DICS_ENABLE, DICS_FLAG_CONFIGSPECIFIC); - enabled = !IsDevNodeDisabled(target.devInfo.DevInst); - } - - RestartOutcome outcome{target.path, target.wentDown, false, enabled, ERROR_SUCCESS}; - if (!enabled) { - outcome.error = GetLastError(); - } else if (!target.wentDown) { - // Nothing came down, so no handle was broken. Fall back to asking - // for a plain restart, which is enough on transports where the - // class installer honours it. - outcome.restartRequested = - SetDeviceState(devs, target.devInfo, DICS_PROPCHANGE, DICS_FLAG_GLOBAL); - outcome.error = ERROR_NOT_SUPPORTED; - } - results.push_back(std::move(outcome)); + if (!wentDown) { + // Nothing was taken down, so no handle was broken. Fall back to + // asking for a plain restart, which is enough on USB. + if (errorOut) *errorOut = ERROR_NOT_SUPPORTED; + return SetDeviceState(devs, devInfo, DICS_PROPCHANGE, DICS_FLAG_GLOBAL); } - SetupDiDestroyDeviceInfoList(devs); - return results; + return true; } -namespace { - -std::wstring CycleTargetsPath() { - wchar_t local[MAX_PATH]; - if (!GetEnvironmentVariableW(L"LOCALAPPDATA", local, MAX_PATH)) return {}; - const std::wstring dir = std::wstring(local) + L"\\SteamlessController"; - CreateDirectoryW(dir.c_str(), nullptr); - return dir + L"\\cycle-targets.txt"; -} - -// A handover left behind by a crashed run must not steer a later cycle at -// devices nobody asked about. -constexpr ULONGLONG kTargetsMaxAgeMs = 60'000; - -} // namespace - -bool WriteCycleTargets(const std::vector& paths) { - const std::wstring file = CycleTargetsPath(); - if (file.empty()) return false; - if (paths.empty()) { - DeleteFileW(file.c_str()); - return true; - } +bool RestartInterfaceDevice(const std::wstring& interfacePath, DWORD* errorOut) { + if (errorOut) *errorOut = ERROR_SUCCESS; - std::wstring body; - for (auto const& path : paths) { - body += path; - body += L'\n'; + HDEVINFO devs = SetupDiCreateDeviceInfoList(nullptr, nullptr); + if (devs == INVALID_HANDLE_VALUE) { + if (errorOut) *errorOut = GetLastError(); + return false; } - HANDLE handle = CreateFileW(file.c_str(), GENERIC_WRITE, 0, nullptr, - CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); - if (handle == INVALID_HANDLE_VALUE) return false; - - const wchar_t bom = 0xFEFF; - DWORD written = 0; - bool ok = WriteFile(handle, &bom, sizeof(bom), &written, nullptr) != 0 - && WriteFile(handle, body.data(), - static_cast(body.size() * sizeof(wchar_t)), - &written, nullptr) != 0; - CloseHandle(handle); - if (!ok) DeleteFileW(file.c_str()); - return ok; -} - -std::vector ConsumeCycleTargets() { - std::vector paths; - const std::wstring file = CycleTargetsPath(); - if (file.empty()) return paths; - - WIN32_FILE_ATTRIBUTE_DATA attrs{}; - if (!GetFileAttributesExW(file.c_str(), GetFileExInfoStandard, &attrs)) - return paths; - - ULARGE_INTEGER wrote{}, now{}; - wrote.LowPart = attrs.ftLastWriteTime.dwLowDateTime; - wrote.HighPart = attrs.ftLastWriteTime.dwHighDateTime; - FILETIME nowFt{}; - GetSystemTimeAsFileTime(&nowFt); - now.LowPart = nowFt.dwLowDateTime; - now.HighPart = nowFt.dwHighDateTime; - const ULONGLONG ageMs = now.QuadPart > wrote.QuadPart - ? (now.QuadPart - wrote.QuadPart) / 10000ULL : 0ULL; - - HANDLE handle = CreateFileW(file.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, - OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); - if (handle != INVALID_HANDLE_VALUE) { - std::wstring body; - wchar_t buffer[512]; - DWORD read = 0; - while (ReadFile(handle, buffer, sizeof(buffer), &read, nullptr) && read > 0) - body.append(buffer, read / sizeof(wchar_t)); - CloseHandle(handle); - - if (!body.empty() && body.front() == 0xFEFF) body.erase(body.begin()); - size_t start = 0; - while (start < body.size()) { - size_t end = body.find(L'\n', start); - if (end == std::wstring::npos) end = body.size(); - std::wstring line = body.substr(start, end - start); - while (!line.empty() && (line.back() == L'\r' || line.back() == L'\n')) - line.pop_back(); - if (!line.empty()) paths.push_back(std::move(line)); - start = end + 1; + bool ok = false; + SP_DEVICE_INTERFACE_DATA ifData{}; + ifData.cbSize = sizeof(ifData); + if (SetupDiOpenDeviceInterfaceW(devs, interfacePath.c_str(), 0, &ifData)) { + // Query with no output buffer purely to resolve the owning devnode — + // the call fails with ERROR_INSUFFICIENT_BUFFER but still fills devInfo. + SP_DEVINFO_DATA devInfo{}; + devInfo.cbSize = sizeof(devInfo); + SetupDiGetDeviceInterfaceDetailW(devs, &ifData, nullptr, 0, nullptr, &devInfo); + + if (devInfo.DevInst != 0) { + ok = CycleDevNode(devs, devInfo, errorOut); + } else if (errorOut) { + *errorOut = GetLastError(); } + } else if (errorOut) { + *errorOut = GetLastError(); } - DeleteFileW(file.c_str()); - if (ageMs > kTargetsMaxAgeMs) paths.clear(); - return paths; -} - -bool RestartInterfaceDevice(const std::wstring& interfacePath, DWORD* errorOut) { - const auto results = RestartInterfaceDevices({interfacePath}); - if (results.empty()) { - if (errorOut) *errorOut = ERROR_INVALID_PARAMETER; - return false; - } - if (errorOut) *errorOut = results[0].error; - return results[0].enabled && (results[0].cycled || results[0].restartRequested); + SetupDiDestroyDeviceInfoList(devs); + return ok; } } diff --git a/src/app/DeviceRestart.h b/src/app/DeviceRestart.h index 500ca86..066db7b 100644 --- a/src/app/DeviceRestart.h +++ b/src/app/DeviceRestart.h @@ -1,44 +1,16 @@ #pragma once #include #include -#include namespace DeviceRestart { -struct RestartOutcome { - std::wstring path; - bool cycled = false; // devnode genuinely went down and came back - bool restartRequested = false; // disable refused; asked for a plain restart - bool enabled = true; // false means it was left disabled — surface it - DWORD error = ERROR_SUCCESS; -}; - -// Restarts the PnP device nodes backing the given HID interface paths — -// equivalent to unplugging and replugging them. Every process's open handles -// (including Steam's) are invalidated, and the re-arrival becomes an open race -// that whoever reacts to the arrival notification first wins. -// -// Batched deliberately: all the devices are disabled, then one settle covers -// the whole set, then all are re-enabled. The settle dominates the cost, so -// cycling a four-slot receiver one device at a time paid it four times over — -// measured at 6.6s before a reclaim could even begin. +// Restarts the PnP device node backing an HID interface path — equivalent to +// unplugging and replugging the device. Every process's open handles to it +// (including Steam's) are invalidated, and the re-arrival becomes an open +// race that whoever reacts to the arrival notification first wins. // -// Requires administrator rights; fails with ERROR_ACCESS_DENIED otherwise. -std::vector RestartInterfaceDevices(const std::vector& paths); - -// Single-device convenience wrapper. +// Requires administrator rights; fails with ERROR_ACCESS_DENIED in errorOut +// when the process is not elevated. bool RestartInterfaceDevice(const std::wstring& interfacePath, DWORD* errorOut = nullptr); -// The tray app cannot pass arguments to the elevated helper — the scheduled -// task runs it with none — so the interfaces worth cycling are handed over -// through a file. Cycling only the contested interface, rather than every -// Valve device present, keeps a reclaim from disturbing controllers that -// were never contested, and on a multi-slot receiver it is several times -// faster. -bool WriteCycleTargets(const std::vector& paths); - -// Reads and deletes the handover file. Empty when absent or stale, which -// means "cycle everything" — the behaviour for a helper run by hand. -std::vector ConsumeCycleTargets(); - } diff --git a/src/app/TrayApp.cpp b/src/app/TrayApp.cpp index 6b36368..6f59af5 100644 --- a/src/app/TrayApp.cpp +++ b/src/app/TrayApp.cpp @@ -354,17 +354,9 @@ void TrayApp::TryAcquireController(uint32_t stateWaitMs) { } if (m_controller->IsGameModeActive()) { KillTimer(m_hwnd, IDT_ACQUIRE_VERDICT); + KillTimer(m_hwnd, IDT_WAKE_POLL); m_acquireRetries = 0; m_lastCycleTick = 0; - // Having one controller does not mean we are done watching. A receiver - // publishes every slot interface permanently, so a second controller - // switching on raises no device event either — without this, player two - // never gets picked up. Slower cadence than when we have nothing, since - // this is opportunistic rather than something the user is waiting on. - if (m_controller->HasInactiveSlot()) - SetTimer(m_hwnd, IDT_WAKE_POLL, WAKE_POLL_ACTIVE_MS, nullptr); - else - KillTimer(m_hwnd, IDT_WAKE_POLL); return; } if (!m_controller->IsConnected()) return; // nothing plugged in — arrival will retrigger @@ -379,7 +371,7 @@ void TrayApp::TryAcquireController(uint32_t stateWaitMs) { // so switching the controller on produces no WM_DEVICECHANGE at all — // measured: 88 seconds of silence after the controller was turned on. if (outcome == ControllerManager::GameModeOutcome::NoActiveController) { - SetTimer(m_hwnd, IDT_WAKE_POLL, WAKE_POLL_IDLE_MS, nullptr); + SetTimer(m_hwnd, IDT_WAKE_POLL, WAKE_POLL_MS, nullptr); return; } @@ -408,14 +400,8 @@ void TrayApp::TryAcquireController(uint32_t stateWaitMs) { } ++m_acquireRetries; m_lastCycleTick = nowTick; - // Capture the contested interfaces before ReleaseDevices clears the slots. - // Cycling only these leaves any other controller untouched, and on a - // multi-slot receiver skips three empty slots' worth of teardown. - const auto targets = m_controller->BlockedSlotPaths(); - EventLog::Write("AUTO: exclusive claim blocked — cycling %zu device(s) (attempt %d)", - targets.size(), m_acquireRetries); + EventLog::Write("AUTO: exclusive claim blocked — cycling device (attempt %d)", m_acquireRetries); m_controller->ReleaseDevices(); - DeviceRestart::WriteCycleTargets(targets); if (!RestartControllerDevices()) return; // helper unavailable — balloon shown // The cycle runs asynchronously via the helper task; the device-arrival // notification drives the claim, with this timer as the fallback. It must @@ -430,10 +416,8 @@ bool TrayApp::RestartControllerDevices() { // the pairing (which lives up at the BTHLEDEVICE layer), and it // re-enumerates as fast as a USB replug. if (IsProcessElevated()) { - auto paths = DeviceRestart::ConsumeCycleTargets(); - if (paths.empty()) paths = SteamController::EnumerateAll(); bool anyRestarted = false; - for (const auto& path : paths) + for (const auto& path : SteamController::EnumerateAll()) if (DeviceRestart::RestartInterfaceDevice(path)) anyRestarted = true; return anyRestarted; diff --git a/src/app/TrayApp.h b/src/app/TrayApp.h index 795567a..64af80d 100644 --- a/src/app/TrayApp.h +++ b/src/app/TrayApp.h @@ -104,13 +104,8 @@ class TrayApp { // controller waking up produces no device-change event — the only way to // notice is to keep asking. The probe is short because a live slot streams // continuously and answers within a few reports. - // Brisk while we have no controller at all — the user is waiting on it. - static constexpr UINT WAKE_POLL_IDLE_MS = 2000; - // Relaxed once something is already in game mode: this is only watching - // for additional controllers joining, and each poll briefly blocks the UI - // thread once per still-empty slot. - static constexpr UINT WAKE_POLL_ACTIVE_MS = 5000; - static constexpr UINT WAKE_PROBE_MS = 80; + static constexpr UINT WAKE_POLL_MS = 2000; + static constexpr UINT WAKE_PROBE_MS = 80; static constexpr int MAX_ACQUIRE_CYCLES = 3; // Must outlast a full device cycle: the helper waits a second between // disable and enable, then a multi-slot receiver re-enumerates every diff --git a/src/helper/DeviceCycleHelper.cpp b/src/helper/DeviceCycleHelper.cpp index 87d21e8..4f2804b 100644 --- a/src/helper/DeviceCycleHelper.cpp +++ b/src/helper/DeviceCycleHelper.cpp @@ -75,38 +75,19 @@ static bool RunToolHidden(std::wstring cmdline) { static int CycleDevices() { bool any = false; - // The tray app names the contested interfaces when it fires us; an empty - // handover (or a run by hand) means cycle everything. - auto paths = DeviceRestart::ConsumeCycleTargets(); - const bool targeted = !paths.empty(); - if (!targeted) paths = SteamController::EnumerateAll(); - CycleLog("START: %zu interface(s) to cycle (%s)", paths.size(), - targeted ? "targeted" : "all"); - - // Deliberately one interface at a time, not one batch. Batching them was - // measured and lost: it saved nothing (the class-installer disable calls - // dominate, not the settle) and it broke the reclaim outright. Cycling - // sequentially brings the occupied slot back last and alone, so the app - // gets an uncontested shot at it — it won that race by 45ms. Released - // together, all four arrive at once, the app spends its time probing the - // empty ones, and Steam takes the live one first. - std::vector outcomes; - for (const auto& path : paths) { - auto one = DeviceRestart::RestartInterfaceDevices({ path }); - if (!one.empty()) outcomes.push_back(std::move(one.front())); - } + const auto paths = SteamController::EnumerateAll(); + CycleLog("START: %zu interface(s) to cycle", paths.size()); - for (const auto& outcome : outcomes) { - const char* verdict = !outcome.enabled ? "LEFT DISABLED" - : outcome.cycled ? "CYCLED" - : outcome.restartRequested ? "FELL BACK TO RESTART" - : "FAILED"; - CycleLog("%s (transport=%s err=%lu) %ls", verdict, - SteamController::TransportName( - SteamController::TransportFromPath(outcome.path)), - outcome.error, outcome.path.c_str()); - if (outcome.enabled && (outcome.cycled || outcome.restartRequested)) - any = true; + for (const auto& path : paths) { + DWORD err = ERROR_SUCCESS; + const bool ok = DeviceRestart::RestartInterfaceDevice(path, &err); + // err is only non-zero on the disable-refused fallback path, so + // ok=true with err set means the cycle silently did nothing. + CycleLog("%s (transport=%s err=%lu) %ls", + ok ? (err == ERROR_SUCCESS ? "CYCLED" : "FELL BACK TO RESTART") : "FAILED", + SteamController::TransportName(SteamController::TransportFromPath(path)), + err, path.c_str()); + if (ok) any = true; } CycleLog("DONE: exit=%d", any ? 0 : 1);