mirror of
https://github.com/ddeverill/SteamlessController.git
synced 2026-10-23 07:02:16 -05:00
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 <noreply@anthropic.com>
628 lines
29 KiB
C++
628 lines
29 KiB
C++
#include "ControllerManager.h"
|
|
#include "VirtualController.h"
|
|
#include "TrackpadMouse.h"
|
|
#include "steam/SteamController.h"
|
|
#include <Windows.h>
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstring>
|
|
#include <memory>
|
|
#include <thread>
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Slot
|
|
// ---------------------------------------------------------------------------
|
|
|
|
struct ControllerManager::Slot {
|
|
std::wstring path;
|
|
std::unique_ptr<SteamController> sc;
|
|
std::unique_ptr<VirtualController> vc;
|
|
TrackpadMouse trackpad;
|
|
std::thread readThread;
|
|
std::atomic<bool> readRunning{false};
|
|
bool gameModeActive = false;
|
|
|
|
// 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)
|
|
{
|
|
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;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lifecycle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
ControllerManager::ControllerManager(StateChangedFn onStateChanged)
|
|
: m_onStateChanged(std::move(onStateChanged))
|
|
{
|
|
SyncDevices();
|
|
}
|
|
|
|
ControllerManager::~ControllerManager() {
|
|
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();
|
|
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();
|
|
}
|
|
|
|
void ControllerManager::EnableGameMode() {
|
|
bool anyVigemMissing = false;
|
|
for (auto& slot : m_slots)
|
|
EnableGameModeSlot(*slot, anyVigemMissing);
|
|
NotifyStateChanged(anyVigemMissing);
|
|
}
|
|
|
|
void ControllerManager::DisableGameMode() {
|
|
for (auto& slot : m_slots)
|
|
DisableGameModeSlot(*slot);
|
|
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) {
|
|
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.
|
|
}
|
|
|
|
void ControllerManager::SetBackButtonsEnabled(bool enabled) {
|
|
m_backButtonsEnabled = enabled;
|
|
for (auto& slot : m_slots)
|
|
slot->trackpad.SetBackButtonsEnabled(enabled);
|
|
}
|
|
|
|
void ControllerManager::SetControllerPlatform(ControllerPlatform platform) {
|
|
if (m_controllerPlatform == platform) return;
|
|
m_controllerPlatform = platform;
|
|
if (IsGameModeActive()) {
|
|
DisableGameMode();
|
|
EnableGameMode();
|
|
}
|
|
}
|
|
|
|
void ControllerManager::StartButtonCapture(std::function<void(BackButtonAction)> callback) {
|
|
std::lock_guard<std::mutex> lk(m_captureMutex);
|
|
m_captureCallback = std::move(callback);
|
|
m_capturing = true;
|
|
}
|
|
|
|
void ControllerManager::StopButtonCapture() {
|
|
m_capturing = false;
|
|
std::lock_guard<std::mutex> 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) {
|
|
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>();
|
|
slot->path = path;
|
|
slot->sc = std::make_unique<SteamController>();
|
|
if (!slot->sc->Open(path)) return;
|
|
|
|
m_slots.push_back(std::move(slot));
|
|
|
|
if (IsGameModeActive()) {
|
|
bool dummy = false;
|
|
EnableGameModeSlot(*m_slots.back(), dummy);
|
|
} else {
|
|
// Restore lizard mode in case a previous session crashed without cleaning up.
|
|
m_slots.back()->sc->EnableLizardMode();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Game mode per-slot helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) {
|
|
if (slot.gameModeActive) return;
|
|
if (!slot.sc->ClaimExclusive()) return;
|
|
if (!slot.sc->DisableLizardMode()) {
|
|
slot.sc->ReleaseToShared();
|
|
return;
|
|
}
|
|
|
|
slot.vc = std::make_unique<VirtualController>(
|
|
m_controllerPlatform,
|
|
[sc = slot.sc.get()](uint8_t largeMotor, uint8_t smallMotor) {
|
|
if (sc->IsOpen()) sc->SetRumble(largeMotor, smallMotor);
|
|
});
|
|
if (!slot.vc->IsValid()) {
|
|
if (slot.vc->IsDriverMissing()) vigemMissingOut = true;
|
|
slot.vc.reset();
|
|
slot.sc->EnableLizardMode();
|
|
return;
|
|
}
|
|
|
|
if (m_controllerPlatform == ControllerPlatform::PlayStation)
|
|
slot.sc->SetImuEnabled(true);
|
|
slot.vc->SetTrackpadMouseClaim(m_trackpadMouseEnabled, m_useLeftTrackpad);
|
|
|
|
slot.gameModeActive = true;
|
|
slot.trackpad.Reset();
|
|
slot.trackpad.SetTrackpadEnabled(m_trackpadMouseEnabled);
|
|
slot.trackpad.SetUseLeftTrackpad(m_useLeftTrackpad);
|
|
slot.trackpad.SetBackButtonsEnabled(m_backButtonsEnabled);
|
|
StartReadLoop(slot);
|
|
}
|
|
|
|
void ControllerManager::DisableGameModeSlot(Slot& slot) {
|
|
if (!slot.gameModeActive) return;
|
|
StopReadLoop(slot);
|
|
if (slot.sc->IsOpen())
|
|
slot.sc->SetRumble(0, 0);
|
|
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;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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();
|
|
|
|
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;
|
|
|
|
// Battery status — update DS4 battery level; no further processing needed.
|
|
if (buf[0] == SteamController::REPORT_BATTERY_STATUS) {
|
|
if (n >= 3 && slot->vc)
|
|
slot->vc->SetBatteryState(buf[1], buf[2]);
|
|
continue;
|
|
}
|
|
|
|
if (!SteamController::IsStateReportId(buf[0])) continue;
|
|
|
|
if (slot->vc) slot->vc->Update(buf, n, m_backConfig, m_backButtonsEnabled);
|
|
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<float>(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<float>(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<float>(rx - slot->hapticPrevRightX);
|
|
const float dy = static_cast<float>(ry - slot->hapticPrevRightY);
|
|
const float dist = std::hypot(dx, dy);
|
|
if (slot->hapticRightReleaseGrace > 0
|
|
|| static_cast<float>(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<float>(lx - slot->hapticPrevLeftX);
|
|
const float dy = static_cast<float>(ly - slot->hapticPrevLeftY);
|
|
const float dist = std::hypot(dx, dy);
|
|
if (slot->hapticLeftReleaseGrace > 0
|
|
|| static_cast<float>(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;
|
|
}
|
|
|
|
// Fire mouse button events for back paddles mapped to LeftMouseButton/RightMouseButton.
|
|
// Uses edge detection so we only send DOWN on press and UP on release.
|
|
// Skipped when backButtonsEnabled — TrackpadMouse already handles L4/R4 in that mode.
|
|
if (!m_backButtonsEnabled && hasPrev) {
|
|
struct PaddleEdge { bool cur; bool prev; BackButtonAction action; };
|
|
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 (const auto& e : edges) {
|
|
if (e.cur == e.prev) continue;
|
|
DWORD flag = 0;
|
|
if (e.action == BackButtonAction::LeftMouseButton) flag = e.cur ? MOUSEEVENTF_LEFTDOWN : MOUSEEVENTF_LEFTUP;
|
|
else if (e.action == BackButtonAction::RightMouseButton) flag = e.cur ? MOUSEEVENTF_RIGHTDOWN : MOUSEEVENTF_RIGHTUP;
|
|
if (flag) { INPUT inp{}; inp.type = INPUT_MOUSE; inp.mi.dwFlags = flag; SendInput(1, &inp, sizeof(INPUT)); }
|
|
}
|
|
}
|
|
|
|
// 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<std::mutex> lk(m_captureMutex);
|
|
if (m_captureCallback) m_captureCallback(captured);
|
|
}
|
|
}
|
|
|
|
memcpy(prevBuf, buf, n);
|
|
hasPrev = true;
|
|
}
|
|
}
|
|
|
|
void ControllerManager::NotifyStateChanged(bool vigemMissing) {
|
|
m_onStateChanged(!m_slots.empty(), IsGameModeActive(), vigemMissing);
|
|
}
|