diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp index 65bcf9d..e637ed5 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; @@ -211,11 +217,23 @@ void ControllerManager::OnDeviceChange() { SyncDevices(); } -void ControllerManager::EnableGameMode() { +ControllerManager::GameModeOutcome ControllerManager::EnableGameMode(uint32_t stateWaitMs) { 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, stateWaitMs)) { + 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() { @@ -347,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(); @@ -358,17 +376,49 @@ void ControllerManager::OpenSlot(const std::wstring& path) { // Game mode per-slot helpers // --------------------------------------------------------------------------- -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", +ControllerManager::GameModeOutcome +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 + // 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 GameModeOutcome::NoActiveController; + + 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 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 GameModeOutcome::Blocked; + } + 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 GameModeOutcome::Blocked; + } + EventLog::Write("GAMEMODE: exclusive claim unavailable; using shared access %ls", slot.path.c_str()); - return; } 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( @@ -382,7 +432,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) @@ -396,6 +446,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) { @@ -493,14 +544,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 0205541..b953b9f 100644 --- a/src/app/ControllerManager.h +++ b/src/app/ControllerManager.h @@ -26,7 +26,16 @@ 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 }; + + // 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. @@ -38,6 +47,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); @@ -62,7 +78,8 @@ class ControllerManager { void SyncDevices(); void OpenSlot(const std::wstring& path); - void 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); @@ -75,6 +92,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..6f59af5 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) @@ -252,6 +257,21 @@ 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 + // 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; @@ -304,7 +324,10 @@ void TrayApp::ApplySteamState(SteamState state) { TryAcquireController(); } else { KillTimer(m_hwnd, IDT_ACQUIRE); + KillTimer(m_hwnd, IDT_ACQUIRE_VERDICT); + KillTimer(m_hwnd, IDT_WAKE_POLL); m_acquireRetries = 0; + m_lastCycleTick = 0; const bool hadControl = m_controller->IsGameModeActive(); // Good citizen: restore lizard mode and close our HID handles so // Steam can claim the controller without contention. @@ -317,9 +340,9 @@ void TrayApp::ApplySteamState(SteamState state) { } } -void TrayApp::TryAcquireController() { +void TrayApp::TryAcquireController(uint32_t stateWaitMs) { m_controller->OnDeviceChange(); - 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 @@ -327,33 +350,64 @@ 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(stateWaitMs); } if (m_controller->IsGameModeActive()) { + KillTimer(m_hwnd, IDT_ACQUIRE_VERDICT); + KillTimer(m_hwnd, IDT_WAKE_POLL); m_acquireRetries = 0; + m_lastCycleTick = 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 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 + // 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). 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; + 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 // 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..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(); @@ -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 @@ -96,6 +97,25 @@ 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 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 + // 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; + // 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; }; 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 cb88c38..c37bc60 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -92,16 +92,51 @@ static std::vector EnumerateAllValveDevices() { HidD_FreePreparsedData(preparsed); } + CloseHandle(h); + + // Can a write-exclusive handle be had? A "no" here is exactly why game + // mode used to refuse a perfectly good controller, so it is worth + // seeing per interface instead of inferring it from a later failure. + HANDLE exclusive = CreateFileW(path.c_str(), GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_FLAG_OVERLAPPED, nullptr); + const DWORD exclusiveErr = + exclusive == INVALID_HANDLE_VALUE ? GetLastError() : ERROR_SUCCESS; + if (exclusive != INVALID_HANDLE_VALUE) CloseHandle(exclusive); + + // Which slot is actually live? The puck publishes four controller + // interfaces and only an occupied one emits state reports. + const bool controllerIface = + info.usagePage == SteamController::VENDOR_USAGE_PAGE + && info.usage == SteamController::CONTROLLER_USAGE; + size_t reportLen = 0; + uint8_t reportId = 0; + if (controllerIface) { + HidDevice probe; + if (probe.Open(path)) { + uint8_t report[64]{}; + reportLen = probe.ReadInputReport(report, sizeof(report), 250); + if (reportLen > 0) reportId = report[0]; + } + } + const bool vendor = (info.usagePage == SteamController::VENDOR_USAGE_PAGE); printf(" [%zu]%s PID=%04X Usage=%04X:%04X In=%u Out=%u Feat=%u fw=0x%04X %s\n" " \"%ls\" serial=%ls\n" - " path: %ls\n", + " exclusive=%s", out.size(), vendor ? "*" : " ", attrs.ProductID, info.usagePage, info.usage, inLen, outLen, featLen, attrs.VersionNumber, GuessTransport(path), - productBuf, serialBuf, path.c_str()); + productBuf, serialBuf, + exclusiveErr == ERROR_SUCCESS ? "yes" : "no"); + if (exclusiveErr != ERROR_SUCCESS) + printf(" (error %lu)", exclusiveErr); + if (reportLen > 0) + printf(" live=0x%02X/%zu bytes", reportId, reportLen); + else if (controllerIface) + printf(" live=none (empty slot?)"); + printf("\n path: %ls\n", path.c_str()); - CloseHandle(h); out.push_back(std::move(info)); } printf(" (* = vendor collection 0x%04X — the game-input interface)\n\n", @@ -178,10 +213,18 @@ static bool 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; }; @@ -316,7 +359,10 @@ int main(int argc, char** argv) { signal(SIGINT, OnSignal); signal(SIGTERM, OnSignal); - const bool enumOnly = (argc > 1 && strcmp(argv[1], "--enum") == 0); + // --enumerate-only is accepted as an alias so instructions written against + // the puck investigation keep working. + const bool enumOnly = argc > 1 && (strcmp(argv[1], "--enum") == 0 + || strcmp(argv[1], "--enumerate-only") == 0); const bool imuMode = (argc > 1 && strcmp(argv[1], "--imu") == 0); printf("=== SteamProbe — 2026 Steam Controller discovery tool ===\n\n"); @@ -340,6 +386,9 @@ int main(int argc, char** argv) { : defaultIdx; printf("Opening [%zu] (%s)...\n\n", chosen, GuessTransport(ifaces[chosen].path)); + 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 4229e58..494c8d1 100644 --- a/src/steam/SteamController.cpp +++ b/src/steam/SteamController.cpp @@ -54,8 +54,9 @@ 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 (auto const& path : HidDevice::Enumerate(VALVE_VID, pid, VENDOR_USAGE_PAGE)) + 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); return result; } @@ -71,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) @@ -95,7 +97,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) { @@ -112,20 +127,47 @@ 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 before = std::chrono::steady_clock::now(); + const auto remaining = std::chrono::duration_cast( + 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; +} + // --------------------------------------------------------------------------- // 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 e6a5373..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. @@ -21,6 +22,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 @@ -155,6 +163,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(); @@ -162,10 +175,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