Fix puck activation after firmware updates (#44)

The receiver stopped working after a controller firmware update. Original
diagnosis and fix by @danbosscher; extended here for Bluetooth, Steam
handoff, and several defects found during QA on the hardware.

The puck publishes a controller interface per slot plus a management
endpoint, and on current firmware an empty slot rejects the lizard-mode
command outright.

- Enumerate only the controller collection (FF00:0001), excluding the
  puck's management endpoint at FF00:0002
- Wait for a live 0x42/0x45 state report before sending feature commands,
  and skip slots that never produce one
- Prefer an exclusive claim but accept a shared handle when Windows keeps
  a compatible write handle open — only while Steam is absent, since a
  handle held with Steam running is probably Steam's, and driving the
  controller alongside it is worse than refusing. Refusing is what
  escalates to the device cycle that takes the handle back.
- Add the Nereid receiver PID (0x1305)

Defects found by QA and fixed here:

- The battery payload is [chargeState, percent] on every transport, not
  just Bluetooth. A 92% battery was reported, and fed to the DS4 battery
  level, as 1%.
- Game mode could not tell an idle receiver from a contested one, so a
  controller that was merely switched off triggered three device cycles,
  restarting the receiver's devnodes for nothing.
- A receiver raises no device event when a slot becomes occupied, so
  waking a controller went unnoticed for as long as it was left. Poll for
  it instead.
- Overlapping cycles undid successful claims: a cycle is asynchronous, and
  the arrivals it generated re-entered the acquire path and fired another
  on top of it, ripping the device back out a second after game mode came
  up. Cycles are now spaced.
- Failure was announced while the last cycle was still landing — the alert
  fired one second before success. The verdict now waits out a grace
  period.

Verified on the puck, Bluetooth, and wired: handoff to Steam on game
launch, reclaim afterwards, and no duplicate input.

Reclaiming takes around nine seconds on a four-slot receiver, dominated by
SetupDiCallClassInstaller tearing down each interface in turn. Three
attempts to shorten it (batching the cycle, a shorter settle, cycling only
the contested interface) all regressed the reclaim and were reverted; it
needs to be worked out against hardware rather than reasoned about.

Co-Authored-By: Dan Bosscher <danbosscher@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-08-03 23:34:08 -07:00
1 parent 719f76a911
commit 345bf15ca3
9 files changed
+314 -66

No files matched your search

+68 -19
View File
@@ -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<VirtualController>(
@@ -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<int>(percent)) {
+20 -2
View File
@@ -26,7 +26,16 @@ public:
// 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 @@ public:
// 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 @@ private:
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 @@ private:
bool m_trackpadMouseEnabled = false;
bool m_useLeftTrackpad = false;
bool m_backButtonsEnabled = false;
bool m_steamPresent = false;
ControllerPlatform m_controllerPlatform = ControllerPlatform::Xbox;
BackButtonConfig m_backConfig;
+67 -13
View File
@@ -229,9 +229,14 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
}
return 0;
case WM_STEAMSTATE:
ApplySteamState(static_cast<SteamState>(wp));
case WM_STEAMSTATE: {
const auto steamState = static_cast<SteamState>(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() {
+22 -2
View File
@@ -56,7 +56,7 @@ private:
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 @@ private:
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 @@ private:
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;
};
+6 -4
View File
@@ -13,7 +13,8 @@
// Enumeration
// ---------------------------------------------------------------------------
std::vector<std::wstring> HidDevice::Enumerate(uint16_t vid, uint16_t pid, uint16_t usagePage) {
std::vector<std::wstring> 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<std::wstring> 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,
+5 -3
View File
@@ -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<std::wstring> 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<std::wstring> Enumerate(uint16_t vid, uint16_t pid,
uint16_t usagePage = 0,
uint16_t usage = 0);
HidDevice() = default;
~HidDevice() { Close(); }
+57 -8
View File
@@ -92,16 +92,51 @@ static std::vector<IfaceInfo> 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;
+53 -11
View File
@@ -54,8 +54,9 @@ static constexpr uint8_t HAPTIC_COMMAND_CLICK = 2;
std::vector<std::wstring> SteamController::EnumerateAll() {
std::vector<std::wstring> 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<std::chrono::milliseconds>(
deadline - before);
const auto waitMs = static_cast<uint32_t>(std::max<int64_t>(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() {
+16 -4
View File
@@ -12,6 +12,7 @@ public:
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 @@ public:
// 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 @@ public:
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 @@ public:
// 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