#include "ControllerManager.h" #include "EventLog.h" #include "VirtualController.h" #include "TrackpadMouse.h" #include "KeyInput.h" #include "steam/SteamController.h" #include #include #include #include #include #include #include #include #include #include // --------------------------------------------------------------------------- // Slot // --------------------------------------------------------------------------- struct ControllerManager::Slot { std::wstring path; SteamController::Transport transport = SteamController::Transport::Unknown; std::unique_ptr sc; std::unique_ptr vc; TrackpadMouse trackpad; std::thread readThread; std::atomic readRunning{false}; 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{}; // What each paddle (L4, L5, R4, R5) is currently holding down, so leaving // game mode mid-press releases it instead of stranding the input down. // Records what the press actually sent rather than reading the binding // again on release, so rebinding a paddle mid-hold still releases cleanly. BackButtonBinding paddleHeld[4]; // Auto-repeat for held key bindings. When the next repeat is due, and the // gap to use after that — both captured at press time from the user's // keyboard settings, so the rate cannot shift mid-hold. std::chrono::steady_clock::time_point paddleRepeatAt[4]{}; std::chrono::milliseconds paddleRepeatGap[4]{}; // Haptic edge-detection state for touch (movement ticks). bool hapticWasRightTouching = false; bool hapticWasLeftTouching = false; // Click haptic state machine — two states per trackpad. // WaitingForPress: will fire the press haptic the first time the click bit // goes high; ignores any further highs until release fires. // WaitingForRelease: will fire the release haptic the first time the click bit // goes low; ignores any further lows until next press fires. // This fires exactly once per downstroke and once per upstroke regardless of // how many times the click bit chatters around the threshold. enum class ClickState { WaitingForPress, WaitingForRelease }; ClickState hapticRightClickState = ClickState::WaitingForPress; ClickState hapticLeftClickState = ClickState::WaitingForPress; // Previous trackpad positions and accumulated travel for distance-based haptic. int16_t hapticPrevRightX = 0; int16_t hapticPrevRightY = 0; float hapticRightDistAccum = 0.0f; int16_t hapticPrevLeftX = 0; int16_t hapticPrevLeftY = 0; float hapticLeftDistAccum = 0.0f; // Grace period after first touch — suppresses movement ticks while the press // gesture completes so it can't bleed a TICK in just before the CLICK fires. // Counts down from TOUCH_GRACE_FRAMES to 0; ticks only fire when it is 0. static constexpr int kTouchGraceFrames = 12; // ~48ms at 250Hz int hapticRightTouchGrace = 0; int hapticLeftTouchGrace = 0; // Click haptic latch. Press: the firmware click bit must hold true for a // couple frames (filters single-frame glitches), then the press haptic // fires. Release: the click bit is IGNORED from then on — chatter around // the firmware's force threshold can't re-fire anything. The latch only // releases (and the release haptic fires) once contact area falls back // to genuinely idle — a light resting touch (~300-600), far below any // pressing level (~1400+ even when easing off mid-hold, ~4000 at click). // An absolute threshold, NOT a fraction of press area: grinding a hard // press dips area to 1000-2000, which a relative threshold read as a // release, firing press/release pairs in a crunchy stream. static constexpr int kPressConfirmFrames = 2; // ~8ms at 250Hz static constexpr float kReleaseIdleArea = 1000.0f; static constexpr int kAreaLowConfirmFrames = 8; // ~32ms at 250Hz int hapticRightClickTrueFrames = 0; int hapticLeftClickTrueFrames = 0; int hapticRightAreaLowFrames = 0; int hapticLeftAreaLowFrames = 0; // Movement-accumulator idle reset: if the finger stays still (below the // motion deadzone) this many frames, discard accumulated travel so a // primed accumulator can't discharge a spurious TICK from press wobble. static constexpr int kIdleResetFrames = 30; // ~120ms at 250Hz int hapticRightIdleFrames = 0; int hapticLeftIdleFrames = 0; // Post-release grace — after the release haptic fires, the finger is // still peeling off / settling; the centroid jumps hundreds of units per // frame during that window, which reads as motion but isn't swiping. static constexpr int kPostReleaseGraceFrames = 45; // ~180ms at 250Hz int hapticRightReleaseGrace = 0; int hapticLeftReleaseGrace = 0; Slot() = default; Slot(const Slot&) = delete; Slot& operator=(const Slot&) = delete; }; // --------------------------------------------------------------------------- // Rising-edge capture: returns true (and sets 'out') when any of the 16 // mappable XInput buttons transitions from not-pressed to pressed. // Back paddles (L4/L5/R4/R5) are excluded — they are what we are remapping, // not valid capture targets. // --------------------------------------------------------------------------- static bool DetectCapture(const uint8_t* cur, const uint8_t* prev, BackButtonAction& out) { // Controller input only ever yields a built-in action; the caller wraps the // result. Keyboard and mouse capture arrive through the UI, not this loop. using BA = BackButtonAction; // Helper: rising edge on a single bit in a byte. auto rose = [&](int byteIdx, uint8_t mask) -> bool { return (cur[byteIdx] & mask) && !(prev[byteIdx] & mask); }; // Triggers use 16-bit analog — fire on crossing 25% (0x2000). auto trigRose = [&](int byteIdx) -> bool { int16_t c, p; memcpy(&c, cur + byteIdx, 2); memcpy(&p, prev + byteIdx, 2); return c > 0x2000 && p <= 0x2000; }; // Check each mappable button in priority order. // Byte offsets match SteamController.h for the 0x45 report. if (rose(2, SteamController::BTN_A)) { out = BA::A; return true; } if (rose(2, SteamController::BTN_B)) { out = BA::B; return true; } if (rose(2, SteamController::BTN_X)) { out = BA::X; return true; } if (rose(2, SteamController::BTN_Y)) { out = BA::Y; return true; } if (rose(4, SteamController::BTN_LB)) { out = BA::LB; return true; } if (rose(3, SteamController::BTN_RB)) { out = BA::RB; return true; } if (trigRose(6)) { out = BA::LT; return true; } if (trigRose(8)) { out = BA::RT; return true; } if (rose(3, SteamController::BTN_DPAD_UP)) { out = BA::DPadUp; return true; } if (rose(3, SteamController::BTN_DPAD_DN)) { out = BA::DPadDown; return true; } if (rose(3, SteamController::BTN_DPAD_LT)) { out = BA::DPadLeft; return true; } if (rose(3, SteamController::BTN_DPAD_RT)) { out = BA::DPadRight; return true; } if (rose(2, SteamController::BTN_MENU)) { out = BA::Menu; return true; } if (rose(3, SteamController::BTN_VIEW)) { out = BA::View; return true; } if (rose(3, SteamController::BTN_LS)) { out = BA::L3; return true; } if (rose(2, SteamController::BTN_RS)) { out = BA::R3; return true; } return false; } // --------------------------------------------------------------------------- // Crash-path lizard restore // // If the process dies with the controller in game mode, the user is left // without input until the firmware's own lizard revert timeout kicks in // (several seconds). The unhandled-exception filter shortens that window by // best-effort sending the lizard restore reports on the way down. It must not // take locks or join threads — another thread may be wedged holding them. // --------------------------------------------------------------------------- static ControllerManager* g_crashRestoreInstance = nullptr; static LONG WINAPI CrashRestoreFilter(EXCEPTION_POINTERS*) { if (g_crashRestoreInstance) g_crashRestoreInstance->EmergencyRestoreAll(); // Let Windows Error Reporting / debuggers see the crash as usual. return EXCEPTION_CONTINUE_SEARCH; } void ControllerManager::EmergencyRestoreAll() noexcept { for (auto& slot : m_slots) { if (!slot || !slot->sc) continue; // Stop the read loop cooperatively (no join) so its keepalive can't // re-clear the mappings we are about to restore. slot->readRunning = false; slot->sc->EmergencyLizardRestore(); } } // --------------------------------------------------------------------------- // Lifecycle // --------------------------------------------------------------------------- ControllerManager::ControllerManager(StateChangedFn onStateChanged) : m_onStateChanged(std::move(onStateChanged)) { g_crashRestoreInstance = this; SetUnhandledExceptionFilter(CrashRestoreFilter); SyncDevices(); } ControllerManager::~ControllerManager() { g_crashRestoreInstance = nullptr; for (auto& slot : m_slots) { // Stop the read loop and virtual controller first (same as DisableGameModeSlot // but without the gameModeActive guard — we always want to restore lizard mode). if (slot->gameModeActive) { StopReadLoop(*slot); slot->trackpad.Reset(); ReleaseHeldPaddleInputs(*slot); slot->vc.reset(); slot->gameModeActive = false; } slot->sc->EnableLizardMode(); // always restore, even if game mode was never active } } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- bool ControllerManager::IsGameModeActive() const { return std::any_of(m_slots.begin(), m_slots.end(), [](const auto& s) { return s->gameModeActive; }); } void ControllerManager::OnDeviceChange() { SyncDevices(); } 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, 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() { for (auto& slot : m_slots) DisableGameModeSlot(*slot); NotifyStateChanged(); } void ControllerManager::ReleaseDevices() { if (!m_slots.empty()) EventLog::Write("RELEASE: closing all device handles (intentional handoff)"); // Disable game mode first — enables lizard mode and tears down ViGEm while // we still hold write access to the device. DisableGameMode(); // Close all device handles. Slot destructors call SteamController::Close() // which closes the HID handle, allowing another process to open it. m_slots.clear(); NotifyStateChanged(); } void ControllerManager::SetTrackpadMouseEnabled(bool enabled) { m_trackpadMouseEnabled = enabled; for (auto& slot : m_slots) { slot->trackpad.SetTrackpadEnabled(enabled); if (slot->vc) slot->vc->SetTrackpadMouseClaim(enabled, m_useLeftTrackpad); } } void ControllerManager::SetUseLeftTrackpad(bool enabled) { m_useLeftTrackpad = enabled; for (auto& slot : m_slots) { slot->trackpad.SetUseLeftTrackpad(enabled); if (slot->vc) slot->vc->SetTrackpadMouseClaim(m_trackpadMouseEnabled, enabled); } } void ControllerManager::SetBackButtonConfig(const BackButtonConfig& cfg) { m_backConfig = cfg; // Live update: the ReadLoop reads m_backConfig on every frame, so the // change takes effect on the very next report. } // Sends the key or mouse event a binding stands for. Gamepad actions reach the // virtual pad instead and are ignored here. Returns whether anything was sent. static bool SendPaddleInput(const BackButtonBinding& binding, bool down) { auto sendMouse = [](DWORD flags) { INPUT inp{}; inp.type = INPUT_MOUSE; inp.mi.dwFlags = flags; SendInput(1, &inp, sizeof(INPUT)); }; switch (binding.kind) { case BackButtonBinding::Kind::Key: SendKeyInput(binding.code, down); return true; case BackButtonBinding::Kind::MouseButton: { INPUT inp{}; inp.type = INPUT_MOUSE; switch (static_cast(binding.code)) { case BackButtonBinding::MouseButtonCode::Middle: inp.mi.dwFlags = down ? MOUSEEVENTF_MIDDLEDOWN : MOUSEEVENTF_MIDDLEUP; break; // The thumb buttons share one event pair and are told apart by mouseData. case BackButtonBinding::MouseButtonCode::X1: inp.mi.dwFlags = down ? MOUSEEVENTF_XDOWN : MOUSEEVENTF_XUP; inp.mi.mouseData = XBUTTON1; break; case BackButtonBinding::MouseButtonCode::X2: inp.mi.dwFlags = down ? MOUSEEVENTF_XDOWN : MOUSEEVENTF_XUP; inp.mi.mouseData = XBUTTON2; break; default: return false; } SendInput(1, &inp, sizeof(INPUT)); return true; } case BackButtonBinding::Kind::Action: if (binding.IsAction(BackButtonAction::LeftMouseButton)) { sendMouse(down ? MOUSEEVENTF_LEFTDOWN : MOUSEEVENTF_LEFTUP); return true; } if (binding.IsAction(BackButtonAction::RightMouseButton)) { sendMouse(down ? MOUSEEVENTF_RIGHTDOWN : MOUSEEVENTF_RIGHTUP); return true; } return false; } return false; } void ControllerManager::ReleaseHeldPaddleInputs(Slot& slot) { for (auto& held : slot.paddleHeld) { SendPaddleInput(held, false); held = BackButtonBinding{}; } } void ControllerManager::SetControllerPlatform(ControllerPlatform platform) { if (m_controllerPlatform == platform) return; m_controllerPlatform = platform; if (IsGameModeActive()) { DisableGameMode(); EnableGameMode(); } } void ControllerManager::StartButtonCapture(std::function callback) { std::lock_guard lk(m_captureMutex); m_captureCallback = std::move(callback); m_capturing = true; } void ControllerManager::StopButtonCapture() { m_capturing = false; std::lock_guard lk(m_captureMutex); m_captureCallback = nullptr; } // --------------------------------------------------------------------------- // Device management // --------------------------------------------------------------------------- void ControllerManager::SyncDevices() { auto livePaths = SteamController::EnumerateAll(); auto it = m_slots.begin(); while (it != m_slots.end()) { bool alive = std::any_of(livePaths.begin(), livePaths.end(), [&](const auto& p) { return p == (*it)->path; }); if (!alive) { EventLog::Write("DISCONNECT: OS removed device (transport=%s, gameMode=%d, lastBattery=%d%%) %ls", SteamController::TransportName((*it)->transport), (*it)->gameModeActive ? 1 : 0, (*it)->lastBatteryPercent, (*it)->path.c_str()); // Alert only when the controller was in active use; deliberate // unplugs while idle (and our own device cycles, which release // slots before cycling) stay quiet. if ((*it)->gameModeActive && m_alertFn) { if ((*it)->transport == SteamController::Transport::Bluetooth) m_alertFn(L"Controller disconnected", L"The Bluetooth connection dropped — the controller " L"powered off, went to sleep, or is out of range. " L"Press the Steam button to reconnect."); else m_alertFn(L"Controller disconnected", L"The USB device was removed. Check the cable or dongle — " L"if this happens randomly, Windows USB power saving may be " L"suspending the port."); } DisableGameModeSlot(**it); it = m_slots.erase(it); } else { ++it; } } for (auto const& path : livePaths) { bool already = std::any_of(m_slots.begin(), m_slots.end(), [&](const auto& s) { return s->path == path; }); if (!already) OpenSlot(path); } NotifyStateChanged(); } void ControllerManager::OpenSlot(const std::wstring& path) { auto slot = std::make_unique(); slot->path = path; slot->transport = SteamController::TransportFromPath(path); slot->sc = std::make_unique(); if (!slot->sc->Open(path)) return; EventLog::Write("CONNECT: transport=%s %ls", SteamController::TransportName(slot->transport), path.c_str()); m_slots.push_back(std::move(slot)); if (IsGameModeActive()) { bool dummy = false; EnableGameModeSlot(*m_slots.back(), dummy, 250); } else { // Restore lizard mode in case a previous session crashed without cleaning up. m_slots.back()->sc->EnableLizardMode(); } } // --------------------------------------------------------------------------- // Game mode per-slot helpers // --------------------------------------------------------------------------- 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()); } if (!slot.sc->DisableLizardMode()) { EventLog::Write("GAMEMODE: DisableLizardMode failed %ls", slot.path.c_str()); slot.sc->ReleaseToShared(); return GameModeOutcome::Blocked; } slot.vc = std::make_unique( m_controllerPlatform, [sc = slot.sc.get()](uint8_t largeMotor, uint8_t smallMotor) { if (sc->IsOpen()) sc->SetRumble(largeMotor, smallMotor); }); if (!slot.vc->IsValid()) { EventLog::Write("GAMEMODE: ViGEm virtual controller failed (stage=%s, err=0x%08X, driverMissing=%d)", slot.vc->FailStage(), slot.vc->LastError(), slot.vc->IsDriverMissing() ? 1 : 0); if (slot.vc->IsDriverMissing()) vigemMissingOut = true; slot.vc.reset(); slot.sc->EnableLizardMode(); return GameModeOutcome::Blocked; } if (m_controllerPlatform == ControllerPlatform::PlayStation) slot.sc->SetImuEnabled(true); slot.vc->SetTrackpadMouseClaim(m_trackpadMouseEnabled, m_useLeftTrackpad); EventLog::Write("GAMEMODE: enabled %ls", slot.path.c_str()); slot.gameModeActive = true; slot.trackpad.Reset(); ReleaseHeldPaddleInputs(slot); slot.trackpad.SetTrackpadEnabled(m_trackpadMouseEnabled); slot.trackpad.SetUseLeftTrackpad(m_useLeftTrackpad); StartReadLoop(slot); return GameModeOutcome::Enabled; } void ControllerManager::DisableGameModeSlot(Slot& slot) { if (!slot.gameModeActive) return; EventLog::Write("GAMEMODE: disabled %ls", slot.path.c_str()); StopReadLoop(slot); if (slot.sc->IsOpen()) slot.sc->SetRumble(0, 0); slot.trackpad.Reset(); ReleaseHeldPaddleInputs(slot); slot.vc.reset(); slot.sc->EnableLizardMode(); // Reopen shared so Steam can obtain write access — game mode is no longer active. slot.sc->ReleaseToShared(); slot.gameModeActive = false; } // --------------------------------------------------------------------------- // Read loop // --------------------------------------------------------------------------- void ControllerManager::StartReadLoop(Slot& slot) { slot.readRunning = true; slot.readThread = std::thread(&ControllerManager::ReadLoop, this, &slot); } void ControllerManager::StopReadLoop(Slot& slot) { slot.readRunning = false; if (slot.readThread.joinable()) slot.readThread.join(); } void ControllerManager::ReadLoop(Slot* slot) { uint8_t buf[64]; uint8_t prevBuf[64] = {}; bool hasPrev = false; auto lastKeepalive = std::chrono::steady_clock::now(); auto lastReport = std::chrono::steady_clock::now(); bool stalled = false; bool keepaliveFailing = false; // No reports for this long while the device is still enumerated means the // controller died silently: battery empty, auto-sleep, or wireless drop. static constexpr auto kStallThreshold = std::chrono::seconds(4); while (slot->readRunning) { // Keepalive — the firmware silently reverts to lizard mode (and its // autonomous click haptics, which double up with ours as a crunchy // burst) after a period without host feature reports. Re-assert the // lizard-off state every couple of seconds. Must run before the // read-timeout continue below, or an idle controller would starve it. const auto nowKa = std::chrono::steady_clock::now(); if (nowKa - lastKeepalive >= std::chrono::seconds(2)) { lastKeepalive = nowKa; const bool ok = slot->sc->SendKeepalive(); if (!ok && !keepaliveFailing) { keepaliveFailing = true; EventLog::Write("KEEPALIVE: send failed (device write error)"); } else if (ok && keepaliveFailing) { keepaliveFailing = false; EventLog::Write("KEEPALIVE: recovered"); } } size_t n = slot->sc->ReadReport(buf, sizeof(buf), /*timeoutMs=*/32); if (n == 0) { if (!stalled && std::chrono::steady_clock::now() - lastReport > kStallThreshold) { stalled = true; EventLog::Write("STALL: no reports for 4s (lastBattery=%d%%) %ls", slot->lastBatteryPercent, slot->path.c_str()); if (m_alertFn) { wchar_t text[256]; if (slot->lastBatteryPercent >= 0) swprintf_s(text, L"No input received for several seconds — the battery may " L"be dead, the controller may have gone to sleep, or the " L"wireless signal dropped. Last battery report: %d%%.", slot->lastBatteryPercent); else swprintf_s(text, L"No input received for several seconds — the battery may " L"be dead, the controller may have gone to sleep, or the " L"wireless signal dropped."); m_alertFn(L"Controller not responding", text); } } continue; } lastReport = std::chrono::steady_clock::now(); if (stalled) { stalled = false; EventLog::Write("STALL: reports resumed"); } // Battery status — update DS4 battery level; no further processing needed. if (buf[0] == SteamController::REPORT_BATTERY_STATUS) { if (n >= 3) { // 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)) { EventLog::Write("BATTERY: %u%% (chargeState=%u)", percent, chargeState); slot->lastBatteryPercent = static_cast(percent); } } continue; } if (!SteamController::IsStateReportId(buf[0])) continue; if (slot->vc) slot->vc->Update(buf, n, m_backConfig); slot->trackpad.Update(buf, n); // Trackpad haptics. { // Distance of deliberate travel between movement ticks. A full // top-to-bottom swipe (~65k units) should produce roughly 10 ticks. static constexpr float TRACKPAD_HAPTIC_TICK_DISTANCE = 6500.0f; // Per-frame motion deadzone: deltas smaller than this are sensor // jitter or fingertip wobble from pressing, not deliberate motion. // Discarding them keeps the accumulator from priming itself while // the finger rests or presses, which caused spurious ticks to fire // alongside the click haptic on the downstroke. static constexpr float TRACKPAD_HAPTIC_MOTION_DEADZONE = 45.0f; const uint8_t b2 = n > 4 ? buf[4] : 0; const uint8_t b3 = n > 5 ? buf[5] : 0; const bool rt = (b2 & SteamController::BTN_TP_RT) != 0; const bool lt = (b3 & SteamController::BTN_TP_LT) != 0; const bool rc = (b2 & SteamController::BTN_TP_RT_CLICK) != 0; const bool lc = (b3 & SteamController::BTN_TP_LT_CLICK) != 0; int16_t rx = 0, ry = 0, lx = 0, ly = 0; uint16_t rArea = 0, lArea = 0; if (n >= 28) { memcpy(&rx, buf + 24, 2); memcpy(&ry, buf + 26, 2); memcpy(&lx, buf + 18, 2); memcpy(&ly, buf + 20, 2); } if (n >= 30) { memcpy(&lArea, buf + 22, 2); memcpy(&rArea, buf + 28, 2); } // Click haptic latch — press fires off the (briefly confirmed) // firmware click bit; from then on the click bit is ignored so // threshold chatter can't re-fire. The release haptic fires once // contact area returns to idle. slot->hapticRightClickTrueFrames = rc ? slot->hapticRightClickTrueFrames + 1 : 0; slot->hapticLeftClickTrueFrames = lc ? slot->hapticLeftClickTrueFrames + 1 : 0; if (slot->hapticRightClickState == Slot::ClickState::WaitingForPress && slot->hapticRightClickTrueFrames >= Slot::kPressConfirmFrames) { slot->sc->PulseTrackpadHaptic(false, true); slot->hapticRightClickState = Slot::ClickState::WaitingForRelease; slot->hapticRightAreaLowFrames = 0; slot->hapticPrevRightX = rx; slot->hapticPrevRightY = ry; slot->hapticRightDistAccum = 0.0f; } else if (slot->hapticRightClickState == Slot::ClickState::WaitingForRelease) { if (!rc && static_cast(rArea) <= Slot::kReleaseIdleArea) { if (++slot->hapticRightAreaLowFrames >= Slot::kAreaLowConfirmFrames) { slot->sc->PulseTrackpadHaptic(false, true); slot->hapticRightClickState = Slot::ClickState::WaitingForPress; slot->hapticRightReleaseGrace = Slot::kPostReleaseGraceFrames; slot->hapticPrevRightX = rx; slot->hapticPrevRightY = ry; slot->hapticRightDistAccum = 0.0f; } } else { slot->hapticRightAreaLowFrames = 0; } } if (slot->hapticLeftClickState == Slot::ClickState::WaitingForPress && slot->hapticLeftClickTrueFrames >= Slot::kPressConfirmFrames) { slot->sc->PulseTrackpadHaptic(true, true); slot->hapticLeftClickState = Slot::ClickState::WaitingForRelease; slot->hapticLeftAreaLowFrames = 0; slot->hapticPrevLeftX = lx; slot->hapticPrevLeftY = ly; slot->hapticLeftDistAccum = 0.0f; } else if (slot->hapticLeftClickState == Slot::ClickState::WaitingForRelease) { if (!lc && static_cast(lArea) <= Slot::kReleaseIdleArea) { if (++slot->hapticLeftAreaLowFrames >= Slot::kAreaLowConfirmFrames) { slot->sc->PulseTrackpadHaptic(true, true); slot->hapticLeftClickState = Slot::ClickState::WaitingForPress; slot->hapticLeftReleaseGrace = Slot::kPostReleaseGraceFrames; slot->hapticPrevLeftX = lx; slot->hapticPrevLeftY = ly; slot->hapticLeftDistAccum = 0.0f; } } else { slot->hapticLeftAreaLowFrames = 0; } } // Movement haptic — tick once per TRACKPAD_HAPTIC_TICK_DISTANCE of travel. if (n >= 28) { // On first-touch frame, seed positions, reset accumulator, and // start grace period so press-gesture motion can't bleed a TICK // just before the CLICK fires (~48ms at 250Hz). if (rt && !slot->hapticWasRightTouching) { slot->hapticPrevRightX = rx; slot->hapticPrevRightY = ry; slot->hapticRightDistAccum = 0.0f; slot->hapticRightTouchGrace = Slot::kTouchGraceFrames; } if (lt && !slot->hapticWasLeftTouching) { slot->hapticPrevLeftX = lx; slot->hapticPrevLeftY = ly; slot->hapticLeftDistAccum = 0.0f; slot->hapticLeftTouchGrace = Slot::kTouchGraceFrames; } if (slot->hapticRightTouchGrace > 0) --slot->hapticRightTouchGrace; if (slot->hapticLeftTouchGrace > 0) --slot->hapticLeftTouchGrace; if (slot->hapticRightReleaseGrace > 0) --slot->hapticRightReleaseGrace; if (slot->hapticLeftReleaseGrace > 0) --slot->hapticLeftReleaseGrace; // No ticks while the finger is pressing down (contact area // balloons far past light-touch levels) and no single-frame // centroid teleports (partial-contact artifacts) — neither is // deliberate swiping, however much distance they report. // NOTE: no minimum-area gate — a light gliding finger reads // near-zero contact area on this pad, indistinguishable from // post-lift ghost drift. Ghosts near clicks are covered by the // post-release grace instead. static constexpr float TRACKPAD_TICK_MAX_AREA = 900.0f; static constexpr float TRACKPAD_TICK_MAX_STEP = 4000.0f; // Gate on the latch state, not the raw click bit — while the // click is held (even if the bit chatters low), no ticks. if (rt && slot->hapticWasRightTouching && !rc && slot->hapticRightClickState == Slot::ClickState::WaitingForPress && slot->hapticRightTouchGrace == 0) { const float dx = static_cast(rx - slot->hapticPrevRightX); const float dy = static_cast(ry - slot->hapticPrevRightY); const float dist = std::hypot(dx, dy); if (slot->hapticRightReleaseGrace > 0 || static_cast(rArea) > TRACKPAD_TICK_MAX_AREA || dist > TRACKPAD_TICK_MAX_STEP) { // Settling after a release, pressing down, or a centroid // teleport — pause, don't punish: skip the frame without // accumulating it, but keep travel already earned so a // brief excursion mid-swipe doesn't zero the progress. // No tick can fire from a gated frame, and the press // haptic still resets the accumulator when a click lands. slot->hapticPrevRightX = rx; slot->hapticPrevRightY = ry; } else if (dist >= TRACKPAD_HAPTIC_MOTION_DEADZONE) { // Below the deadzone: don't accumulate, and don't advance // the reference point — slow creep past the deadzone still // counts. If the finger stays still long enough, discard // accumulated travel (idle reset below). slot->hapticRightIdleFrames = 0; slot->hapticRightDistAccum += dist; if (slot->hapticRightDistAccum >= TRACKPAD_HAPTIC_TICK_DISTANCE) { if (slot->sc->TickTrackpadMovement(false)) { slot->hapticRightDistAccum = 0.0f; } else { // Rate-limited: hold at threshold so the tick // fires as soon as the limiter reopens. slot->hapticRightDistAccum = TRACKPAD_HAPTIC_TICK_DISTANCE; } } slot->hapticPrevRightX = rx; slot->hapticPrevRightY = ry; } else if (++slot->hapticRightIdleFrames >= Slot::kIdleResetFrames) { slot->hapticRightDistAccum = 0.0f; slot->hapticPrevRightX = rx; slot->hapticPrevRightY = ry; } } if (lt && slot->hapticWasLeftTouching && !lc && slot->hapticLeftClickState == Slot::ClickState::WaitingForPress && slot->hapticLeftTouchGrace == 0) { const float dx = static_cast(lx - slot->hapticPrevLeftX); const float dy = static_cast(ly - slot->hapticPrevLeftY); const float dist = std::hypot(dx, dy); if (slot->hapticLeftReleaseGrace > 0 || static_cast(lArea) > TRACKPAD_TICK_MAX_AREA || dist > TRACKPAD_TICK_MAX_STEP) { slot->hapticPrevLeftX = lx; slot->hapticPrevLeftY = ly; } else if (dist >= TRACKPAD_HAPTIC_MOTION_DEADZONE) { slot->hapticLeftIdleFrames = 0; slot->hapticLeftDistAccum += dist; if (slot->hapticLeftDistAccum >= TRACKPAD_HAPTIC_TICK_DISTANCE) { if (slot->sc->TickTrackpadMovement(true)) slot->hapticLeftDistAccum = 0.0f; else slot->hapticLeftDistAccum = TRACKPAD_HAPTIC_TICK_DISTANCE; } slot->hapticPrevLeftX = lx; slot->hapticPrevLeftY = ly; } else if (++slot->hapticLeftIdleFrames >= Slot::kIdleResetFrames) { slot->hapticLeftDistAccum = 0.0f; slot->hapticPrevLeftX = lx; slot->hapticPrevLeftY = ly; } } } slot->hapticWasRightTouching = rt; slot->hapticWasLeftTouching = lt; } // Deliver back paddles bound to a key or a mouse button. These go out // through SendInput rather than the virtual pad. Edge-detected so each // press sends exactly one down and each release exactly one up. if (hasPrev) { struct PaddleEdge { bool cur; bool prev; const BackButtonBinding& binding; }; const PaddleEdge edges[] = { { n>4 && (buf[4]&SteamController::BTN_L4)!=0, (prevBuf[4]&SteamController::BTN_L4)!=0, m_backConfig.l4 }, { n>4 && (buf[4]&SteamController::BTN_L5)!=0, (prevBuf[4]&SteamController::BTN_L5)!=0, m_backConfig.l5 }, { n>2 && (buf[2]&SteamController::BTN_R4)!=0, (prevBuf[2]&SteamController::BTN_R4)!=0, m_backConfig.r4 }, { n>3 && (buf[3]&SteamController::BTN_R5)!=0, (prevBuf[3]&SteamController::BTN_R5)!=0, m_backConfig.r5 }, }; for (size_t i = 0; i < std::size(edges); ++i) { const auto& e = edges[i]; if (e.cur == e.prev) continue; if (e.cur) { if (SendPaddleInput(e.binding, true)) { slot->paddleHeld[i] = e.binding; slot->paddleRepeatGap[i] = KeyRepeatInterval(); slot->paddleRepeatAt[i] = std::chrono::steady_clock::now() + KeyRepeatDelay(); } } else { // Release what the press sent, not what the binding says // now — the two differ if the user rebound mid-hold. SendPaddleInput(slot->paddleHeld[i], false); slot->paddleHeld[i] = BackButtonBinding{}; } } } // Auto-repeat held keys. A physical keyboard repeats because its own // firmware keeps sending the key, not because Windows tracks the key as // down, so injected input has to reproduce that itself. Keys only — // mice do not repeat when held, and gamepad bindings are held state in // the pad report, where a repeat would be meaningless. for (size_t i = 0; i < std::size(slot->paddleHeld); ++i) { const auto& held = slot->paddleHeld[i]; if (held.kind != BackButtonBinding::Kind::Key) continue; const auto now = std::chrono::steady_clock::now(); if (now < slot->paddleRepeatAt[i]) continue; SendKeyInput(held.code, true); slot->paddleRepeatAt[i] = now + slot->paddleRepeatGap[i]; } // Button capture for the remap window (press-to-bind). // Only fires on a rising edge to avoid repeat-triggering on hold. if (m_capturing.load() && hasPrev) { BackButtonAction captured; if (DetectCapture(buf, prevBuf, captured)) { m_capturing = false; std::lock_guard lk(m_captureMutex); if (m_captureCallback) m_captureCallback(BackButtonBinding::FromAction(captured)); } } memcpy(prevBuf, buf, n); hasPrev = true; } } void ControllerManager::NotifyStateChanged(bool vigemMissing) { m_onStateChanged(!m_slots.empty(), IsGameModeActive(), vigemMissing); }