From b80c7b830ee81e627c949bdd78689130e03509db Mon Sep 17 00:00:00 2001 From: Dylan Deverill Date: Mon, 6 Jul 2026 12:50:38 -0700 Subject: [PATCH] Overhaul trackpad haptics: fix firmware lizard revert, click latch, tick gating The core fix: the SC2026 firmware silently reverts to lizard mode (including its autonomous click haptics) after a period without host feature reports, which doubled up with software haptics as a crunchy burst. A keepalive now re-asserts the lizard-off state every 2 seconds while game mode is active. Haptics reliability and feel: - Switch trackpad haptics from OUT_HAPTIC_PULSE (grip LRAs) to OUT_HAPTIC_COMMAND (trackpad LRAs) for localized feel - Click haptic latch: press fires off the debounced firmware click bit, release only fires once contact area returns to idle, so force chatter and mid-hold easing cannot re-fire - Movement ticks: distance-based with motion deadzone, post-release and first-touch grace windows, pressing/teleport gates (pause, not reset), idle decay, and a 50ms send rate limit so firmware waveforms never queue into a backlog Handoff and mouse: - EnableLizardMode now also sends LOAD_DEFAULT_SETTINGS so firmware mouse emulation resumes immediately on disable instead of waiting for the firmware revert timeout - Trackpad mouse: carry sub-pixel movement remainders between frames and retune sensitivity (0.015 -> 0.01125) Also: shared/exclusive HID reopen support, local-only HapticTester tool (gitignored, conditional CMake target), installer version bump to 1.7. Co-Authored-By: Claude Fable 5 --- .gitignore | 3 + CMakeLists.txt | 10 ++ resources/InnoInstallerScript.iss | 2 +- src/app/ControllerManager.cpp | 276 +++++++++++++++++++++++++----- src/app/ControllerManager.h | 3 + src/app/TrackpadMouse.cpp | 25 ++- src/app/TrackpadMouse.h | 4 +- src/hid/HidDevice.cpp | 36 +++- src/hid/HidDevice.h | 15 +- src/steam/SteamController.cpp | 118 ++++++++----- src/steam/SteamController.h | 36 +++- 11 files changed, 421 insertions(+), 107 deletions(-) diff --git a/.gitignore b/.gitignore index 54b2a22..47049cb 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ ClaudeDesign/ CMakeSettings.json CMakeUserPresets.json +# Local-only debug tools (not for distribution) +src/probe/HapticTester.cpp + # Visual Studio .vs/ *.user diff --git a/CMakeLists.txt b/CMakeLists.txt index b07627d..f068d44 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,6 +48,16 @@ if (MSVC) target_compile_options(RawControllerProbe PRIVATE /W4 /utf-8) endif() +# HapticTester — local-only interactive haptic tuning tool (source is gitignored) +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/src/probe/HapticTester.cpp") + add_executable(HapticTester src/probe/HapticTester.cpp) + target_link_libraries(HapticTester PRIVATE SteamController) + if (MSVC) + target_compile_options(HapticTester PRIVATE /W4 /utf-8) + set_target_properties(HapticTester PROPERTIES LINK_FLAGS_DEBUG "/SUBSYSTEM:CONSOLE") + endif() +endif() + # WebView2 — downloaded from NuGet (.nupkg is a zip; FetchContent extracts it) FetchContent_Declare(WebView2 URL https://www.nuget.org/api/v2/package/Microsoft.Web.WebView2/1.0.2849.39 diff --git a/resources/InnoInstallerScript.iss b/resources/InnoInstallerScript.iss index 6532929..b55d1ad 100644 --- a/resources/InnoInstallerScript.iss +++ b/resources/InnoInstallerScript.iss @@ -3,7 +3,7 @@ ; Non-commercial use only #define MyAppName "SteamlessController" -#define MyAppVersion "1.5" +#define MyAppVersion "1.7" #define ViGEmSetup "ViGEmBus_1.22.0_x64_x86_arm64.exe" #define MyAppPublisher "Dylan Deverill" #define MyAppURL "https://github.com/ddeverill/SteamlessController" diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp index 19b834c..fb388a7 100644 --- a/src/app/ControllerManager.cpp +++ b/src/app/ControllerManager.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -23,11 +24,20 @@ struct ControllerManager::Slot { std::atomic readRunning{false}; bool gameModeActive = false; - // Haptic edge-detection state — fire a haptic on first touch/click each gesture. + // Haptic edge-detection state for touch (movement ticks). bool hapticWasRightTouching = false; bool hapticWasLeftTouching = false; - bool hapticWasRightClicked = false; - bool hapticWasLeftClicked = 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; @@ -37,6 +47,45 @@ struct ControllerManager::Slot { 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; @@ -139,6 +188,16 @@ void ControllerManager::DisableGameMode() { NotifyStateChanged(); } +void ControllerManager::ReleaseDevices() { + // 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) { @@ -240,7 +299,11 @@ void ControllerManager::OpenSlot(const std::wstring& path) { void ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) { if (slot.gameModeActive) return; - if (!slot.sc->DisableLizardMode()) return; + if (!slot.sc->ClaimExclusive()) return; + if (!slot.sc->DisableLizardMode()) { + slot.sc->ReleaseToShared(); + return; + } slot.vc = std::make_unique( m_controllerPlatform, @@ -274,6 +337,8 @@ void ControllerManager::DisableGameModeSlot(Slot& slot) { slot.trackpad.Reset(); 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; } @@ -296,8 +361,20 @@ void ControllerManager::ReadLoop(Slot* slot) { uint8_t buf[64]; uint8_t prevBuf[64] = {}; bool hasPrev = false; + auto lastKeepalive = std::chrono::steady_clock::now(); 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; + slot->sc->SendKeepalive(); + } + size_t n = slot->sc->ReadReport(buf, sizeof(buf), /*timeoutMs=*/32); if (n == 0) continue; @@ -315,7 +392,15 @@ void ControllerManager::ReadLoop(Slot* slot) { // Trackpad haptics. { - static constexpr float TRACKPAD_HAPTIC_TICK_DISTANCE = 5000.0f; + // 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; @@ -324,74 +409,181 @@ void ControllerManager::ReadLoop(Slot* slot) { 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; + 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); } - - // Click haptic — strong pulse on press and release. - // On release, also reseed the movement baseline so distance accumulated - // while clicking doesn't bleed into a spurious movement tick. - if (rc && !slot->hapticWasRightClicked) slot->sc->PulseTrackpadHaptic(false, true); - if (lc && !slot->hapticWasLeftClicked) slot->sc->PulseTrackpadHaptic(true, true); - if (!rc && slot->hapticWasRightClicked) { - slot->sc->PulseTrackpadHaptic(false, true); - slot->hapticPrevRightX = rx; - slot->hapticPrevRightY = ry; - slot->hapticRightDistAccum = 0.0f; + if (n >= 30) { + memcpy(&lArea, buf + 22, 2); + memcpy(&rArea, buf + 28, 2); } - if (!lc && slot->hapticWasLeftClicked) { + + // 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->hapticPrevLeftX = lx; - slot->hapticPrevLeftY = ly; - slot->hapticLeftDistAccum = 0.0f; + 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 and reset accumulator. + // 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 (rt && slot->hapticWasRightTouching && !rc) { - const float dx = static_cast(rx - slot->hapticPrevRightX); - const float dy = static_cast(ry - slot->hapticPrevRightY); - slot->hapticRightDistAccum += std::hypot(dx, dy); - while (slot->hapticRightDistAccum >= TRACKPAD_HAPTIC_TICK_DISTANCE) { - slot->sc->TickTrackpadMovement(false); - slot->hapticRightDistAccum -= TRACKPAD_HAPTIC_TICK_DISTANCE; + 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; } - slot->hapticPrevRightX = rx; - slot->hapticPrevRightY = ry; } - if (lt && slot->hapticWasLeftTouching && !lc) { - const float dx = static_cast(lx - slot->hapticPrevLeftX); - const float dy = static_cast(ly - slot->hapticPrevLeftY); - slot->hapticLeftDistAccum += std::hypot(dx, dy); - while (slot->hapticLeftDistAccum >= TRACKPAD_HAPTIC_TICK_DISTANCE) { - slot->sc->TickTrackpadMovement(true); - slot->hapticLeftDistAccum -= TRACKPAD_HAPTIC_TICK_DISTANCE; + 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->hapticPrevLeftX = lx; - slot->hapticPrevLeftY = ly; } } slot->hapticWasRightTouching = rt; slot->hapticWasLeftTouching = lt; - slot->hapticWasRightClicked = rc; - slot->hapticWasLeftClicked = lc; } // Fire mouse button events for back paddles mapped to LeftMouseButton/RightMouseButton. diff --git a/src/app/ControllerManager.h b/src/app/ControllerManager.h index f2c5dd6..25fdc6b 100644 --- a/src/app/ControllerManager.h +++ b/src/app/ControllerManager.h @@ -21,6 +21,9 @@ public: void EnableGameMode(); void DisableGameMode(); + // Disables game mode then closes all device handles so another process + // (e.g. Steam) can claim the controller. Safe to call when already disabled. + void ReleaseDevices(); void SetTrackpadMouseEnabled(bool enabled); void SetUseLeftTrackpad(bool enabled); diff --git a/src/app/TrackpadMouse.cpp b/src/app/TrackpadMouse.cpp index 8db2940..6f8aa8d 100644 --- a/src/app/TrackpadMouse.cpp +++ b/src/app/TrackpadMouse.cpp @@ -21,6 +21,8 @@ void TrackpadMouse::Reset() { m_prevR4 = false; m_prevX = 0; m_prevY = 0; + m_remX = 0.0f; + m_remY = 0.0f; } void TrackpadMouse::Update(const uint8_t* buf, size_t n) { @@ -64,12 +66,23 @@ void TrackpadMouse::Update(const uint8_t* buf, size_t n) { const int dx = static_cast(x - m_prevX); const int dy = -static_cast(y - m_prevY); if (dx != 0 || dy != 0) { - INPUT input{}; - input.type = INPUT_MOUSE; - input.mi.dwFlags = MOUSEEVENTF_MOVE; - input.mi.dx = static_cast(dx * SENSITIVITY); - input.mi.dy = static_cast(dy * SENSITIVITY); - SendInput(1, &input, sizeof(INPUT)); + // Carry sub-pixel remainders between frames — per-frame deltas + // scaled by sensitivity are often below one pixel, and truncating + // them each frame would discard slow movement entirely. + const float fx = static_cast(dx) * SENSITIVITY + m_remX; + const float fy = static_cast(dy) * SENSITIVITY + m_remY; + const LONG ix = static_cast(fx); + const LONG iy = static_cast(fy); + m_remX = fx - static_cast(ix); + m_remY = fy - static_cast(iy); + if (ix != 0 || iy != 0) { + INPUT input{}; + input.type = INPUT_MOUSE; + input.mi.dwFlags = MOUSEEVENTF_MOVE; + input.mi.dx = ix; + input.mi.dy = iy; + SendInput(1, &input, sizeof(INPUT)); + } } } diff --git a/src/app/TrackpadMouse.h b/src/app/TrackpadMouse.h index 745ce8e..ae18c34 100644 --- a/src/app/TrackpadMouse.h +++ b/src/app/TrackpadMouse.h @@ -23,6 +23,8 @@ private: bool m_prevR4 = false; int16_t m_prevX = 0; int16_t m_prevY = 0; + float m_remX = 0.0f; // sub-pixel movement carry + float m_remY = 0.0f; - static constexpr float SENSITIVITY = 0.015f; + static constexpr float SENSITIVITY = 0.01125f; }; diff --git a/src/hid/HidDevice.cpp b/src/hid/HidDevice.cpp index faf3934..9bb4139 100644 --- a/src/hid/HidDevice.cpp +++ b/src/hid/HidDevice.cpp @@ -103,8 +103,9 @@ HidDevice& HidDevice::operator=(HidDevice&& o) noexcept { bool HidDevice::Open(const std::wstring& path) { Close(); - // FILE_SHARE_READ | FILE_SHARE_WRITE lets us coexist with Steam's open handle. - // Remove the share flags for exclusive access (Steam must be closed first). + // 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. m_handle = CreateFileW(path.c_str(), GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, @@ -118,6 +119,7 @@ bool HidDevice::Open(const std::wstring& path) { return false; } + m_path = path; m_event = CreateEventW(nullptr, TRUE, FALSE, nullptr); if (m_event == INVALID_HANDLE_VALUE) { CloseHandle(m_handle); @@ -149,6 +151,36 @@ void HidDevice::Close() { CloseHandle(m_event); m_event = INVALID_HANDLE_VALUE; } + m_path.clear(); +} + +bool HidDevice::Reopen(DWORD shareMode) { + if (m_path.empty()) return false; + std::wstring savedPath = m_path; + + // Cancel pending I/O before closing so the read thread's overlapped op + // drains cleanly. The caller must ensure the read thread is not running. + if (m_handle != INVALID_HANDLE_VALUE) { + CancelIo(m_handle); + CloseHandle(m_handle); + m_handle = INVALID_HANDLE_VALUE; + } + + m_handle = CreateFileW(savedPath.c_str(), + GENERIC_READ | GENERIC_WRITE, + shareMode, + nullptr, + OPEN_EXISTING, + FILE_FLAG_OVERLAPPED, + nullptr); + if (m_handle == INVALID_HANDLE_VALUE) { + wprintf(L"Reopen failed for %s (shareMode=0x%lX): error %lu\n", + savedPath.c_str(), shareMode, GetLastError()); + return false; + } + + m_path = savedPath; + return true; } // --------------------------------------------------------------------------- diff --git a/src/hid/HidDevice.h b/src/hid/HidDevice.h index 9b257d4..15b7fe0 100644 --- a/src/hid/HidDevice.h +++ b/src/hid/HidDevice.h @@ -18,8 +18,12 @@ public: HidDevice(HidDevice&& o) noexcept; HidDevice& operator=(HidDevice&& o) noexcept; - // Open with exclusive write access. Returns false if device is in use or not found. + // Open with shared read/write access for idle tracking. + // Returns false if device is not found. bool Open(const std::wstring& path); + // Close and reopen with a different share mode (e.g. to claim or release + // exclusive write access). Caller must ensure the read thread is stopped first. + bool Reopen(DWORD shareMode); void Close(); bool IsOpen() const { return m_handle != INVALID_HANDLE_VALUE; } @@ -44,8 +48,9 @@ public: size_t ReadInputReport(uint8_t* buffer, size_t size, uint32_t timeoutMs = 1000); private: - HANDLE m_handle = INVALID_HANDLE_VALUE; - HANDLE m_event = INVALID_HANDLE_VALUE; - ULONG m_outputReportLen = 64; - ULONG m_featureReportLen = 64; + HANDLE m_handle = INVALID_HANDLE_VALUE; + HANDLE m_event = INVALID_HANDLE_VALUE; + ULONG m_outputReportLen = 64; + ULONG m_featureReportLen = 64; + std::wstring m_path; }; diff --git a/src/steam/SteamController.cpp b/src/steam/SteamController.cpp index de0e36c..1a2a421 100644 --- a/src/steam/SteamController.cpp +++ b/src/steam/SteamController.cpp @@ -44,13 +44,8 @@ static uint16_t UnitToHapticSpeed(double value, uint16_t minimumSpeed) { return static_cast(std::clamp(static_cast(std::lround(speed)), 0, 0xFFFF)); } -static constexpr uint8_t HAPTIC_COMMAND_TICK = 1; -static constexpr uint8_t HAPTIC_COMMAND_CLICK = 2; -static constexpr uint16_t TRACKPAD_CLICK_PULSE_US = 5000; -static constexpr int8_t TRACKPAD_TOUCH_GAIN_DB = -45; -static constexpr int8_t TRACKPAD_MOVE_GAIN_DB = -50; -static constexpr int8_t TRACKPAD_CLICK_COMMAND_GAIN_DB = 9; -static constexpr int16_t TRACKPAD_CLICK_PULSE_GAIN_DB = 40; +static constexpr uint8_t HAPTIC_COMMAND_TICK = 1; +static constexpr uint8_t HAPTIC_COMMAND_CLICK = 2; // --------------------------------------------------------------------------- // Open / Close @@ -58,19 +53,9 @@ static constexpr int16_t TRACKPAD_CLICK_PULSE_GAIN_DB = 40; std::vector SteamController::EnumerateAll() { std::vector result; - for (uint16_t pid : { SC2026_PID, SC2026_DONGLE_PID }) { - auto paths = HidDevice::Enumerate(VALVE_VID, pid, VENDOR_USAGE_PAGE); - for (auto const& path : paths) { - HidDevice dev; - if (!dev.Open(path)) continue; - uint8_t buf[64]; - size_t n = dev.ReadInputReport(buf, sizeof(buf), /*timeoutMs=*/500); - if (n > 0 && IsStateReportId(buf[0])) - result.push_back(path); - else if (n > 0) - printf("Unexpected report ID 0x%02X on PID=%04X — skipping.\n", buf[0], pid); - } - } + for (uint16_t pid : { SC2026_PID, SC2026_DONGLE_PID }) + for (auto const& path : HidDevice::Enumerate(VALVE_VID, pid, VENDOR_USAGE_PAGE)) + result.push_back(path); return result; } @@ -91,13 +76,29 @@ bool SteamController::Open(const std::wstring& path) { } void SteamController::Close() { - if (m_running.exchange(false) && m_heartbeat.joinable()) - m_heartbeat.join(); + if (m_running.exchange(false) && m_rumbleThread.joinable()) + m_rumbleThread.join(); ClearTrackpadHaptics(); SetRumble(0, 0); m_device.Close(); } +// --------------------------------------------------------------------------- +// Exclusive access control +// --------------------------------------------------------------------------- + +bool SteamController::ClaimExclusive() { + // The read thread must already be stopped before calling Reopen — the + // caller (EnableGameModeSlot) calls this before starting the read loop. + return m_device.Reopen(FILE_SHARE_READ); +} + +void SteamController::ReleaseToShared() { + // The read thread must already be stopped before calling Reopen — the + // caller (DisableGameModeSlot) stops the read loop before calling this. + m_device.Reopen(FILE_SHARE_READ | FILE_SHARE_WRITE); +} + // --------------------------------------------------------------------------- // Lizard mode // --------------------------------------------------------------------------- @@ -139,22 +140,47 @@ bool SteamController::DisableLizardMode() { } if (!m_running.exchange(true)) - m_heartbeat = std::thread(&SteamController::HeartbeatLoop, this); + m_rumbleThread = std::thread(&SteamController::RumbleLoop, this); return true; } +bool SteamController::SendKeepalive() { + uint8_t buf[64]; + BuildCmd(buf, CMD_CLEAR_DIGITAL_MAPPINGS); + std::lock_guard lock(m_writeMutex); + if (!m_device.SendFeatureReport(buf, sizeof(buf))) + return false; + + const uint8_t settingsPayload[] = { + SETTING_LEFT_TRACKPAD_MODE, 0x00, 0x00, + SETTING_RIGHT_TRACKPAD_MODE, 0x00, 0x00, + }; + BuildCmd(buf, CMD_SET_SETTINGS, settingsPayload, sizeof(settingsPayload)); + return m_device.SendFeatureReport(buf, sizeof(buf)); +} + bool SteamController::EnableLizardMode() { - if (m_running.exchange(false) && m_heartbeat.joinable()) - m_heartbeat.join(); + if (m_running.exchange(false) && m_rumbleThread.joinable()) + m_rumbleThread.join(); ClearTrackpadHaptics(); SetRumble(0, 0); SetImuEnabled(false); + // Restore both halves of lizard mode explicitly: default digital mappings + // AND default settings. SET_DEFAULT_MAPPINGS alone leaves the trackpad + // modes we overrode (raw mode) in place, so firmware mouse emulation + // stayed dead until the firmware's own revert timeout reloaded defaults — + // which is why the system took seconds to pick the controller back up. uint8_t buf[64]; - BuildCmd(buf, CMD_SET_DEFAULT_MAPPINGS); std::lock_guard lock(m_writeMutex); + + BuildCmd(buf, CMD_SET_DEFAULT_MAPPINGS); + if (!m_device.SendFeatureReport(buf, sizeof(buf))) + return false; + + BuildCmd(buf, CMD_LOAD_DEFAULT_SETTINGS); return m_device.SendFeatureReport(buf, sizeof(buf)); } @@ -262,18 +288,29 @@ SteamController::RumbleFrame SteamController::CurrentRumbleFrameLocked( // Trackpad haptics // --------------------------------------------------------------------------- +// Minimum spacing between haptic sends to one trackpad LRA. The TICK/CLICK +// waveforms the firmware plays last tens of milliseconds and queue up if +// commands arrive faster — a drained backlog feels like a crunchy burst. +static constexpr auto kTrackpadHapticMinGap = std::chrono::milliseconds(50); + void SteamController::PulseTrackpadHaptic(bool left, bool strongClick) { const uint8_t side = left ? 0x01 : 0x02; const uint8_t command = strongClick ? HAPTIC_COMMAND_CLICK : HAPTIC_COMMAND_TICK; - const int8_t gainDb = strongClick ? TRACKPAD_CLICK_COMMAND_GAIN_DB : TRACKPAD_TOUCH_GAIN_DB; - SendTrackpadCommandOutput(side, command, gainDb); - if (strongClick) - SendTrackpadPulseOutput(side, TRACKPAD_CLICK_PULSE_US, 0, 1, TRACKPAD_CLICK_PULSE_GAIN_DB); + // Clicks always send — they're the priority event — but they still stamp + // the send time so a movement tick can't pile on right after. + auto& last = left ? m_lastTrackpadHapticLeft : m_lastTrackpadHapticRight; + last = std::chrono::steady_clock::now(); + SendTrackpadCommandOutput(side, command, 0); } -void SteamController::TickTrackpadMovement(bool left) { +bool SteamController::TickTrackpadMovement(bool left) { + const auto now = std::chrono::steady_clock::now(); + auto& last = left ? m_lastTrackpadHapticLeft : m_lastTrackpadHapticRight; + if (now - last < kTrackpadHapticMinGap) + return false; // previous waveform likely still playing — drop, don't queue + last = now; const uint8_t side = left ? 0x01 : 0x02; - SendTrackpadCommandOutput(side, HAPTIC_COMMAND_TICK, TRACKPAD_MOVE_GAIN_DB); + return SendTrackpadCommandOutput(side, HAPTIC_COMMAND_TICK, 0); } void SteamController::ClearTrackpadHaptics() { @@ -331,21 +368,13 @@ bool SteamController::SendTrackpadCommandOutput(uint8_t side, uint8_t command, i } // --------------------------------------------------------------------------- -// Heartbeat — keeps lizard mode suppressed and re-sends active rumble +// Rumble loop — re-drives the haptic actuators while rumble is active. +// The Steam Controller uses LRA actuators that need continuous output to +// sustain vibration, unlike traditional ERM motors that spin freely once set. // --------------------------------------------------------------------------- -void SteamController::HeartbeatLoop() { - uint8_t buf[64]; - BuildCmd(buf, CMD_CLEAR_DIGITAL_MAPPINGS); - int lizardTicks = 0; - +void SteamController::RumbleLoop() { while (m_running.load()) { - if (lizardTicks <= 0) { - std::lock_guard lock(m_writeMutex); - m_device.SendFeatureReport(buf, sizeof(buf)); - lizardTicks = 20; - } - RumbleFrame frame{}; { std::lock_guard lock(m_rumbleMutex); @@ -354,7 +383,6 @@ void SteamController::HeartbeatLoop() { if (frame.left || frame.right) SendRumbleOutput(frame.left, frame.right); - --lizardTicks; std::this_thread::sleep_for(std::chrono::milliseconds(40)); } } diff --git a/src/steam/SteamController.h b/src/steam/SteamController.h index 0d4ba5e..be6c445 100644 --- a/src/steam/SteamController.h +++ b/src/steam/SteamController.h @@ -40,6 +40,7 @@ public: static constexpr uint8_t CMD_SET_DEFAULT_MAPPINGS = 0x85; // ← lizard on static constexpr uint8_t CMD_SET_SETTINGS = 0x87; static constexpr uint8_t CMD_GET_SETTINGS = 0x89; + static constexpr uint8_t CMD_LOAD_DEFAULT_SETTINGS = 0x8E; // ← restore settings for lizard on // Triton output report IDs. static constexpr uint8_t OUT_HAPTIC_RUMBLE = 0x80; // continuous two-channel haptic @@ -148,12 +149,22 @@ public: } // Two-step sequence: clears digital mappings + sets trackpads to NONE. - // Starts the background heartbeat thread on first call. + // Starts the background rumble thread. bool DisableLizardMode(); // 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(); + + // Reopen the device handle with full share flags, allowing other processes + // to open the device for write. Call after EnableLizardMode() when leaving + // game mode so Steam can reclaim the controller. + void ReleaseToShared(); + // Read the next raw input report. buffer[0] = report ID on return. // Returns 0 on timeout. size_t ReadReport(uint8_t* buffer, size_t size, uint32_t timeoutMs = 16); @@ -167,12 +178,19 @@ public: // Call with (0, 0) to stop rumble. void SetRumble(uint8_t largeMotor, uint8_t smallMotor); + // Re-assert the lizard-off state (cleared mappings + raw trackpad modes). + // The firmware silently reverts to lizard mode — including its autonomous + // click haptics — after a period without host feature reports. Call this + // every couple of seconds while game mode is active, like hid-steam does. + bool SendKeepalive(); + // Fire a single haptic event on one trackpad. // strongClick = true → physical-click sensation; false → light touch-down tick. void PulseTrackpadHaptic(bool left, bool strongClick); // Fire a very light tick while the thumb moves on the trackpad. - void TickTrackpadMovement(bool left); + // Returns false when the tick was dropped by the send rate limiter. + bool TickTrackpadMovement(bool left); // Silence any in-flight trackpad haptic state (lifecycle cleanup hook). void ClearTrackpadHaptics(); @@ -183,7 +201,7 @@ private: uint16_t right = 0; }; - void HeartbeatLoop(); + void RumbleLoop(); RumbleFrame CurrentRumbleFrameLocked(std::chrono::steady_clock::time_point now) const; bool SendRumbleOutput(uint16_t leftSpeed, uint16_t rightSpeed); bool SendTrackpadPulseOutput(uint8_t side, uint16_t onUs, uint16_t offUs, @@ -191,10 +209,10 @@ private: bool SendTrackpadCommandOutput(uint8_t side, uint8_t command, int8_t gainDb); HidDevice m_device; - std::thread m_heartbeat; + std::thread m_rumbleThread; std::atomic m_running{false}; - // Shared by the heartbeat thread and callers — protect with m_writeMutex. + // Shared by the rumble thread and callers — protect with m_writeMutex. std::mutex m_writeMutex; // Rumble state — protect with m_rumbleMutex. @@ -208,4 +226,12 @@ private: uint8_t m_lastLargeMotor = 0; uint8_t m_lastSmallMotor = 0; bool m_hasRumbleState = false; + + // Last trackpad haptic send per side (touched only by the read-loop + // thread, no lock needed). The firmware queues haptic waveforms — sending + // faster than they play builds a backlog that later drains as one crunchy + // burst. Movement ticks are dropped while the previous waveform is likely + // still playing; click haptics always send. + std::chrono::steady_clock::time_point m_lastTrackpadHapticLeft{}; + std::chrono::steady_clock::time_point m_lastTrackpadHapticRight{}; };