mirror of
https://github.com/ddeverill/SteamlessController.git
synced 2026-10-23 07:02:16 -05:00
Kind::MouseButton was reserved for these in the binding refactor but had no emission arm and no way to pick one. Wire both up: middle click, and the two thumb buttons that games label Mouse 4 and Mouse 5. They were previously unbindable by any route. Middle and the thumb buttons can be captured by pressing them, since none is needed to operate the remap window. Left and right deliberately cannot — the user has to keep clicking Rebind and Cancel while a row listens — so those stay pickable from the catalog instead, as before. Capture suppresses the browser defaults these carry: autoscroll on middle, and history navigation on the thumb buttons. The window's HTML had to be split into two concatenated literals. It crossed MSVC's ~16KB limit for a single string literal (C2026) at 17KB, which fails the build outright rather than truncating quietly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
902 lines
42 KiB
C++
902 lines
42 KiB
C++
#include "ControllerManager.h"
|
|
#include "EventLog.h"
|
|
#include "VirtualController.h"
|
|
#include "TrackpadMouse.h"
|
|
#include "KeyInput.h"
|
|
#include "steam/SteamController.h"
|
|
#include <Windows.h>
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstring>
|
|
#include <iterator>
|
|
#include <memory>
|
|
#include <thread>
|
|
#include <utility>
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Slot
|
|
// ---------------------------------------------------------------------------
|
|
|
|
struct ControllerManager::Slot {
|
|
std::wstring path;
|
|
SteamController::Transport transport = SteamController::Transport::Unknown;
|
|
std::unique_ptr<SteamController> sc;
|
|
std::unique_ptr<VirtualController> vc;
|
|
TrackpadMouse trackpad;
|
|
std::thread readThread;
|
|
std::atomic<bool> 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<BackButtonBinding::MouseButtonCode>(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<void(const BackButtonBinding&)> 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) {
|
|
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>();
|
|
slot->path = path;
|
|
slot->transport = SteamController::TransportFromPath(path);
|
|
slot->sc = std::make_unique<SteamController>();
|
|
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<VirtualController>(
|
|
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<int>(percent)) {
|
|
EventLog::Write("BATTERY: %u%% (chargeState=%u)", percent, chargeState);
|
|
slot->lastBatteryPercent = static_cast<int>(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<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;
|
|
}
|
|
|
|
// 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<std::mutex> 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);
|
|
}
|