Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c0c584b
Fix puck activation after firmware updates
danbosscher Aug 1, 2026
ba0d736
Merge remote-tracking branch 'origin/main' into agent/fix-puck-hid-en…
ddeverill Aug 2, 2026
a532804
Only settle for a shared handle when Steam is absent
ddeverill Aug 2, 2026
9a211d2
Allow shared controller access while Steam is idle
danbosscher Aug 3, 2026
8d81efe
Prevent shared input conflicts with Steam
danbosscher Aug 3, 2026
b718597
Default to yielding while Steam runs
danbosscher Aug 3, 2026
4ecc2b0
Allow safe coexistence with Steam controller blacklist
danbosscher Aug 3, 2026
a1f9b80
Add selectable Steam coexistence strategies
danbosscher Aug 3, 2026
0245f7f
Restore Steam settings when disabling or uninstalling
danbosscher Aug 3, 2026
af7182f
Support Nereid Steam Controller receiver
danbosscher Aug 3, 2026
b189bf3
Handle controller sleep and make Steam ownership toggleable
danbosscher Aug 3, 2026
352676e
Narrow back to the puck activation fix
ddeverill Aug 4, 2026
afed2db
Add the Nereid receiver PID
ddeverill Aug 4, 2026
2116ff7
Fix battery scaling, needless cycling, and a premature failure alert
ddeverill Aug 4, 2026
79f08e4
Stop overlapping device cycles from undoing a successful claim
ddeverill Aug 4, 2026
1260656
Poll for the controller waking; a receiver sends no event for it
ddeverill Aug 4, 2026
98d1531
Batch the device cycle and keep watching for more controllers
ddeverill Aug 4, 2026
89b208f
Revert to cycling one interface at a time; batching lost the race
ddeverill Aug 4, 2026
987a672
Cycle only the contested interface
ddeverill Aug 4, 2026
04c0d37
Revert the reclaim speed work; keep the behaviour that works
ddeverill Aug 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 68 additions & 19 deletions src/app/ControllerManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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();
Expand All @@ -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>(
Expand All @@ -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)
Expand All @@ -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) {
Expand Down Expand Up @@ -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)) {
Expand Down
22 changes: 20 additions & 2 deletions src/app/ControllerManager.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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;

Expand Down
80 changes: 67 additions & 13 deletions src/app/TrayApp.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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;

Expand Down Expand Up @@ -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.
Expand All @@ -317,43 +340,74 @@ 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
// the instant the device arrives is what wins that race.
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() {
Expand Down
24 changes: 22 additions & 2 deletions src/app/TrayApp.h
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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
Expand Down Expand Up @@ -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;
};
Loading