From 09fe80511941bd654e1b668ccecb7ed5a0825e3a Mon Sep 17 00:00:00 2001 From: Dylan Deverill Date: Thu, 6 Aug 2026 00:15:59 -0700 Subject: [PATCH] Tell the user when Windows is discarding the input this app injects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users reported the trackpad going dead after a takeover — haptics still firing, cursor not moving — and recovering the moment focus moved to another window or the window in front was closed. Haptics are HID writes and the virtual pad is ViGEm, so both keep working regardless; SendInput was the one stage of the pipeline that could fail and leave no trace. Reproduced on this machine with an elevated Device Manager in front: - 137 px of movement injected, cursor pinned at (910,781) for 5s - foreground mmc.exe at High integrity, this process at Medium - SendInput returned success for every one of those events That last point decides the shape of the fix. UIPI is documented as failing the call with ERROR_ACCESS_DENIED, but on Windows 11 26200 the call reports success and the input is dropped afterwards, so no return-value check can see it. The condition is only observable by asking, before injecting, whether the foreground window outranks this process. So that is what Send now does, on every event: - one check covers cursor movement, trackpad clicks, paddle buttons and keys, none of which can be judged from their own result. A blocked paddle press previously produced no evidence anywhere - the foreground lookup is cached against the window it describes; injection happens at report rate, foreground changes do not - the state changing is what gets logged, not the events: one line naming the blocker, one when it stops - the tray balloon says which application is blocking input and that switching away from it restores the controller, rate-limited to one per blocker per minute so alt-tabbing cannot spam it - the check never gates the event. It observes and reports; if it is ever wrong about a system, the input goes out exactly as before A foreground process whose token cannot be read is deliberately not named. From Low integrity, OpenProcess succeeds while OpenProcessToken fails with ERROR_ACCESS_DENIED, and protected processes refuse it too — so an unreadable level means unknown, not blocked. Guessing would put a wrong application's name in front of the user; the snapshot records "unreadable" instead. Backing this up, for the causes that produce no error and no integrity mismatch: injected movement is checked against the cursor actually moving, which is what catches a cursor clip a game left behind or a hook filtering injected events. It stays quiet whenever a blocker is already known, so it cannot contradict the better explanation. A refused call is still logged with a dropped-event count and a recovery line, as a cheap catch-all for systems where the call does fail. The read loop also logs the shape of the first state report it gets. TrackpadMouse requires 30 bytes where the haptics need 28, so a short report would produce working haptics and a dead cursor — the same symptom from a completely different cause, and not one that can be told apart from a user's description. This machine reports 0x42 at 54 bytes over USB, which rules it out here but not on transports we have less evidence for. EventLog::Write is annotated _Printf_format_string_ so a mismatched specifier is caught by /analyze. It is not enforced in the normal build, but these lines are read long after the fact by someone trying to explain a fault they cannot reproduce, and the paths that log least often are exactly the ones no test run exercises. Co-Authored-By: Claude Opus 5 --- CMakeLists.txt | 1 + src/app/ControllerManager.cpp | 27 ++- src/app/EventLog.h | 8 +- src/app/InputInjection.cpp | 362 ++++++++++++++++++++++++++++++++++ src/app/InputInjection.h | 58 ++++++ src/app/KeyInput.cpp | 3 +- src/app/TrackpadMouse.cpp | 43 +++- src/app/TrackpadMouse.h | 17 ++ src/app/TrayApp.cpp | 23 ++- 9 files changed, 527 insertions(+), 15 deletions(-) create mode 100644 src/app/InputInjection.cpp create mode 100644 src/app/InputInjection.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 9a2e9ca..b6905b7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -92,6 +92,7 @@ add_executable(SteamlessController WIN32 src/app/VirtualController.cpp src/app/TrackpadMouse.cpp src/app/KeyInput.cpp + src/app/InputInjection.cpp src/app/SteamWatcher.cpp src/app/DeviceRestart.cpp src/app/EventLog.cpp diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp index 83493c7..200a8bb 100644 --- a/src/app/ControllerManager.cpp +++ b/src/app/ControllerManager.cpp @@ -1,5 +1,6 @@ #include "ControllerManager.h" #include "EventLog.h" +#include "InputInjection.h" #include "VirtualController.h" #include "TrackpadMouse.h" #include "KeyInput.h" @@ -300,7 +301,7 @@ static bool SendPaddleInput(const BackButtonBinding& binding, bool down) { INPUT inp{}; inp.type = INPUT_MOUSE; inp.mi.dwFlags = flags; - SendInput(1, &inp, sizeof(INPUT)); + InputInjection::Send(inp, "paddle-mouse"); }; switch (binding.kind) { @@ -327,7 +328,7 @@ static bool SendPaddleInput(const BackButtonBinding& binding, bool down) { default: return false; } - SendInput(1, &inp, sizeof(INPUT)); + InputInjection::Send(inp, "paddle-mouse"); return true; } @@ -514,6 +515,11 @@ ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut, slot.vc->SetTrackpadMouseClaim(m_trackpadMouseEnabled, m_useLeftTrackpad); EventLog::Write("GAMEMODE: enabled %ls", slot.path.c_str()); + // Takeover is the moment users report the trackpad arriving dead, and what + // decides whether injection can land is whatever window happens to be in + // front right then — so record it here rather than reconstructing it later. + if (m_trackpadMouseEnabled) + InputInjection::LogEnvironment("game mode taken"); slot.gameModeActive = true; slot.trackpad.Reset(); ReleaseHeldPaddleInputs(slot); @@ -557,6 +563,7 @@ void ControllerManager::ReadLoop(Slot* slot) { uint8_t buf[64]; uint8_t prevBuf[64] = {}; bool hasPrev = false; + bool loggedShape = false; auto lastKeepalive = std::chrono::steady_clock::now(); auto lastReport = std::chrono::steady_clock::now(); bool stalled = false; @@ -637,6 +644,22 @@ void ControllerManager::ReadLoop(Slot* slot) { if (!SteamController::IsStateReportId(buf[0])) continue; + // Report shape, once per read loop. The trackpad mouse needs a longer + // report than the haptics do, so a transport that reports short loses + // cursor movement while every buzz still fires — the same thing a + // blocked SendInput looks like from the user's side, and only this + // line tells them apart. + if (!loggedShape) { + loggedShape = true; + EventLog::Write("REPORT: state id=0x%02X len=%zu (transport=%s)", + buf[0], n, + SteamController::TransportName(slot->transport)); + if (n < 30) + EventLog::Write("REPORT: %zu bytes is under the 30 the trackpad " + "mouse requires — trackpad movement is being " + "dropped before it reaches SendInput", n); + } + if (slot->vc) slot->vc->Update(buf, n, m_backConfig); slot->trackpad.Update(buf, n); diff --git a/src/app/EventLog.h b/src/app/EventLog.h index a5cee8f..61f0487 100644 --- a/src/app/EventLog.h +++ b/src/app/EventLog.h @@ -1,4 +1,5 @@ #pragma once +#include #include // Persistent diagnostic event log for support and field debugging: device @@ -9,7 +10,12 @@ // Thread-safe. printf-style formatting; use %ls for wide strings (paths). namespace EventLog { -void Write(const char* fmt, ...); +// Annotated so the compiler type-checks every format string against its +// arguments. These lines are read long after the fact, by whoever is trying to +// explain a fault they cannot reproduce; a specifier that does not match its +// argument corrupts the one record of what happened, and the paths that log +// least often — failures — are exactly the ones no test run exercises. +void Write(_Printf_format_string_ const char* fmt, ...); // Full path of the current log file (for "Open Event Log" in the tray menu). std::wstring FilePath(); diff --git a/src/app/InputInjection.cpp b/src/app/InputInjection.cpp new file mode 100644 index 0000000..cd72127 --- /dev/null +++ b/src/app/InputInjection.cpp @@ -0,0 +1,362 @@ +#include "InputInjection.h" +#include "EventLog.h" +#include +#include +#include +#include + +namespace { + +// A wedged foreground window refuses every event at report rate, so the log +// gets one line per burst rather than one per event. +constexpr uint64_t kLogGapMs = 5000; + +// The balloon is a warning, not a running commentary: alt-tabbing in and out of +// an elevated window must not produce one notification per trip. +constexpr uint64_t kAlertGapMs = 60000; + +constexpr DWORD kIntegrityUnknown = 0xFFFFFFFFu; + +std::atomic g_refused{0}; // refusals since the last summary +std::atomic g_refusedTotal{0}; // refusals since the last recovery +std::atomic g_lastRefusalLogMs{0}; +std::atomic g_lastStuckLogMs{0}; +std::atomic g_refusing{false}; + +// True at most once per kLogGapMs, tracked per call site. +bool DueForLog(std::atomic& lastMs) { + const uint64_t now = GetTickCount64(); + uint64_t last = lastMs.load(std::memory_order_relaxed); + if (last != 0 && now - last < kLogGapMs) return false; + // A racing thread that wins here logs instead of this one; either way the + // line is written once. + return lastMs.compare_exchange_strong(last, now, std::memory_order_relaxed); +} + +const wchar_t* IntegrityName(DWORD rid) { + if (rid == kIntegrityUnknown) return L"unreadable"; + if (rid >= SECURITY_MANDATORY_SYSTEM_RID) return L"System"; + if (rid >= SECURITY_MANDATORY_HIGH_RID) return L"High/elevated"; + if (rid >= SECURITY_MANDATORY_MEDIUM_RID) return L"Medium"; + if (rid >= SECURITY_MANDATORY_LOW_RID) return L"Low"; + return L"Untrusted"; +} + +DWORD ProcessIntegrity(HANDLE process) { + HANDLE token = nullptr; + if (!OpenProcessToken(process, TOKEN_QUERY, &token)) return kIntegrityUnknown; + + DWORD size = 0; + GetTokenInformation(token, TokenIntegrityLevel, nullptr, 0, &size); + + DWORD rid = kIntegrityUnknown; + if (size > 0) { + std::vector buf(size); + auto* label = reinterpret_cast(buf.data()); + if (GetTokenInformation(token, TokenIntegrityLevel, label, size, &size)) { + const UCHAR* count = GetSidSubAuthorityCount(label->Label.Sid); + if (count && *count > 0) { + rid = *GetSidSubAuthority(label->Label.Sid, + static_cast(*count - 1)); + } + } + } + CloseHandle(token); + return rid; +} + +// This process's own level never changes, so it is worth asking once. +DWORD OwnIntegrity() { + static const DWORD own = ProcessIntegrity(GetCurrentProcess()); + return own; +} + +// --------------------------------------------------------------------------- +// Blocked-state tracking +// --------------------------------------------------------------------------- + +std::mutex g_stateMutex; +InputInjection::AlertFn g_alertFn; // guarded by g_stateMutex +std::wstring g_blockerLogged; // blocker named in the log right now +std::wstring g_alertBlocker; // blocker the last balloon named +uint64_t g_lastAlertMs = 0; + +// Foreground lookups are cached against the window they describe: injection +// happens at report rate, foreground changes do not, and a process cannot +// change integrity level while it lives. +HWND g_cachedHwnd = nullptr; // guarded by g_stateMutex +DWORD g_cachedPid = 0; +bool g_cachedBlocked = false; +std::wstring g_cachedName; + +// Exe name of the foreground window's process when it outranks this one, so +// anything injected now is discarded. Empty when injection can land. +// +// A process that cannot be opened at all — protected processes, in practice +// anti-cheat — is deliberately NOT called blocked: its level is unknown, and +// guessing would put a wrong name in front of the user. The stuck-cursor check +// still catches it, and LogEnvironment records that the process was unreadable. +std::wstring ForegroundBlocker(DWORD& pidOut) { + pidOut = 0; + + const HWND fg = GetForegroundWindow(); + if (!fg) return L""; // secure desktop or lock screen; nothing to name + + DWORD pid = 0; + GetWindowThreadProcessId(fg, &pid); + if (pid == 0 || pid == GetCurrentProcessId()) return L""; + pidOut = pid; + + std::lock_guard lk(g_stateMutex); + if (fg == g_cachedHwnd && pid == g_cachedPid) + return g_cachedBlocked ? g_cachedName : std::wstring(); + + g_cachedHwnd = fg; + g_cachedPid = pid; + g_cachedBlocked = false; + g_cachedName.clear(); + + HANDLE proc = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (!proc) return L""; + + const DWORD integrity = ProcessIntegrity(proc); + + wchar_t image[MAX_PATH] = L""; + DWORD imageLen = MAX_PATH; + if (!QueryFullProcessImageNameW(proc, 0, image, &imageLen)) + wcscpy_s(image, L"(unknown)"); + CloseHandle(proc); + + const DWORD own = OwnIntegrity(); + if (integrity == kIntegrityUnknown || own == kIntegrityUnknown + || integrity <= own) + return L""; + + const wchar_t* leaf = wcsrchr(image, L'\\'); + g_cachedName = leaf ? leaf + 1 : image; + g_cachedBlocked = true; + return g_cachedName; +} + +// Records the blocked state changing: one log line per blocker rather than per +// event, and a balloon the first time each blocker gets in the way. +void NoteBlockState(const std::wstring& blocker, DWORD pid, const char* what) { + std::wstring startedBlocking, stoppedBlocking; + InputInjection::AlertFn alert; + + { + std::lock_guard lk(g_stateMutex); + if (!blocker.empty()) { + if (blocker != g_blockerLogged) { + startedBlocking = blocker; + g_blockerLogged = blocker; + + const uint64_t now = GetTickCount64(); + if (blocker != g_alertBlocker || now - g_lastAlertMs >= kAlertGapMs) { + g_alertBlocker = blocker; + g_lastAlertMs = now; + alert = g_alertFn; + } + } + } else if (!g_blockerLogged.empty()) { + stoppedBlocking = g_blockerLogged; + g_blockerLogged.clear(); + } + } + + if (!startedBlocking.empty()) { + EventLog::Write("INJECT: BLOCKED (%s) — the foreground window belongs to " + "%ls (pid=%lu), which runs at a higher integrity level; " + "Windows accepts each event and then discards it", + what, startedBlocking.c_str(), pid); + InputInjection::LogEnvironment("state while blocked"); + } + if (!stoppedBlocking.empty()) + EventLog::Write("INJECT: unblocked — %ls is no longer the foreground window", + stoppedBlocking.c_str()); + + // Outside the lock: the callback is the tray's, and it is not this + // module's business what it does before returning. + if (alert) { + std::wstring text = startedBlocking; + text += L" is running as administrator, and Windows does not let " + L"SteamlessController send input while it is the active window. " + L"The trackpad and buttons work again as soon as you switch to " + L"another window or close it."; + alert(L"Controller input is being blocked", text); + } +} + +// "explorer.exe pid=1234 integrity=Medium", or why that could not be answered. +// A process this app cannot open at all is a finding in itself: protected +// processes (anti-cheat, in practice) refuse even limited query access, and +// those are also the processes most likely to be filtering injected input. +std::wstring DescribeForeground(DWORD& integrityOut) { + integrityOut = kIntegrityUnknown; + + const HWND fg = GetForegroundWindow(); + if (!fg) { + // No foreground window at all: a UAC prompt or the lock screen has + // taken the input desktop, and nothing injected reaches the user's + // session until it goes away. + return L"(none — no foreground window; a UAC prompt, the lock screen " + L"or a desktop switch owns input)"; + } + + DWORD pid = 0; + GetWindowThreadProcessId(fg, &pid); + + HANDLE proc = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (!proc) { + wchar_t out[128]; + swprintf_s(out, L"pid=%lu (cannot open, err=%lu — protected process?)", + pid, GetLastError()); + return out; + } + + wchar_t image[MAX_PATH] = L""; + DWORD imageLen = MAX_PATH; + if (!QueryFullProcessImageNameW(proc, 0, image, &imageLen)) + wcscpy_s(image, L"(unknown)"); + + // Leaf name only — the full path adds nothing here and can carry the + // user's name in it. + const wchar_t* leaf = wcsrchr(image, L'\\'); + leaf = leaf ? leaf + 1 : image; + + integrityOut = ProcessIntegrity(proc); + CloseHandle(proc); + + wchar_t out[256]; + swprintf_s(out, L"%s pid=%lu integrity=%s", + leaf, pid, IntegrityName(integrityOut)); + return out; +} + +// The cursor clip, when a window has narrowed it. Games set this and do not +// always restore it, which pins the cursor while injection still succeeds. +std::wstring DescribeClip() { + RECT clip{}; + if (!GetClipCursor(&clip)) { + wchar_t out[64]; + swprintf_s(out, L"unreadable (err=%lu)", GetLastError()); + return out; + } + + RECT screen{}; + screen.left = GetSystemMetrics(SM_XVIRTUALSCREEN); + screen.top = GetSystemMetrics(SM_YVIRTUALSCREEN); + screen.right = screen.left + GetSystemMetrics(SM_CXVIRTUALSCREEN); + screen.bottom = screen.top + GetSystemMetrics(SM_CYVIRTUALSCREEN); + // The bounds go in either way: a cursor sitting on an edge of the desktop + // cannot move further that way, which reads as "stuck" without them. + wchar_t out[160]; + if (EqualRect(&clip, &screen)) { + swprintf_s(out, L"none (screen is (%ld,%ld)-(%ld,%ld))", + screen.left, screen.top, screen.right, screen.bottom); + } else { + swprintf_s(out, L"CONFINED to (%ld,%ld)-(%ld,%ld) of screen " + L"(%ld,%ld)-(%ld,%ld)", + clip.left, clip.top, clip.right, clip.bottom, + screen.left, screen.top, screen.right, screen.bottom); + } + return out; +} + +std::wstring DescribeCursor() { + POINT pt{}; + if (!GetCursorPos(&pt)) { + // Fails when this thread's desktop is not the input desktop — the + // session is locked, or a secure desktop is up. + wchar_t out[96]; + swprintf_s(out, L"unreadable (err=%lu — not on the input desktop)", + GetLastError()); + return out; + } + wchar_t out[64]; + swprintf_s(out, L"(%ld,%ld)", pt.x, pt.y); + return out; +} + +} // namespace + +namespace InputInjection { + +void LogEnvironment(const char* reason) { + DWORD fgIntegrity = kIntegrityUnknown; + const std::wstring fg = DescribeForeground(fgIntegrity); + const DWORD own = OwnIntegrity(); + const std::wstring clip = DescribeClip(); + const std::wstring cur = DescribeCursor(); + + const bool outranked = fgIntegrity != kIntegrityUnknown + && own != kIntegrityUnknown + && fgIntegrity > own; + + EventLog::Write("INJECT: %s — foreground=%ls, us=integrity=%ls%s, " + "cursorClip=%ls, cursor=%ls", + reason, fg.c_str(), IntegrityName(own), + outranked ? " — FOREGROUND OUTRANKS US, injected input is " + "blocked (UIPI) until focus moves or it closes" + : "", + clip.c_str(), cur.c_str()); +} + +void SetAlertCallback(AlertFn fn) { + std::lock_guard lk(g_stateMutex); + g_alertFn = std::move(fn); +} + +bool Send(const INPUT& input, const char* what) { + // Asked before sending, and sent either way: the check is what reports the + // fault, not a gate on the event. If it is ever wrong about a system, the + // input still goes out exactly as it did before. + DWORD blockerPid = 0; + const std::wstring blocker = ForegroundBlocker(blockerPid); + NoteBlockState(blocker, blockerPid, what); + + INPUT copy = input; // SendInput takes a non-const array + if (SendInput(1, ©, sizeof(INPUT)) == 1) { + if (g_refusing.exchange(false)) { + EventLog::Write("INJECT: accepted again after %llu refused event(s)", + g_refusedTotal.exchange(0)); + g_refused.store(0); + g_lastRefusalLogMs.store(0); + } + return true; + } + + const DWORD err = GetLastError(); + g_refusing.store(true); + g_refusedTotal.fetch_add(1); + const uint64_t burst = g_refused.fetch_add(1) + 1; + + if (DueForLog(g_lastRefusalLogMs)) { + g_refused.store(0); + EventLog::Write("INJECT: SendInput REFUSED (%s, err=%lu%s) — %llu event(s) " + "dropped since the last line", + what, err, + err == ERROR_ACCESS_DENIED ? " ACCESS_DENIED" : "", + burst); + LogEnvironment("state at refusal"); + } + return false; +} + +void LogCursorNotMoving(long travelPx, POINT at) { + { + // A known blocker already accounts for a still cursor, and has said so + // in better terms. What is left for this check is the causes nothing + // else can see: a cursor clip, or a hook eating injected input. + std::lock_guard lk(g_stateMutex); + if (!g_blockerLogged.empty()) return; + } + if (!DueForLog(g_lastStuckLogMs)) return; + EventLog::Write("INJECT: %ld px of cursor movement accepted but the cursor " + "has not moved from (%ld,%ld) — something is discarding " + "injected motion (cursor clip, or a low-level hook)", + travelPx, at.x, at.y); + LogEnvironment("state while cursor is stuck"); +} + +} // namespace InputInjection diff --git a/src/app/InputInjection.h b/src/app/InputInjection.h new file mode 100644 index 0000000..db2f34c --- /dev/null +++ b/src/app/InputInjection.h @@ -0,0 +1,58 @@ +#pragma once +#include +#include +#include + +// Synthetic input, with the reasons it can fail recorded. +// +// Every key, mouse button and cursor move this app produces leaves through +// SendInput, and SendInput is the one stage of the pipeline Windows can refuse +// without any other symptom: haptics and the virtual pad are HID writes and +// keep working regardless. A refused injection therefore looks exactly like +// "the trackpad stopped moving the mouse, but the pad still buzzes", with +// nothing in the log to say why. +// +// The refusal that matters is UIPI: a process may not inject input while the +// foreground window belongs to a process at a higher integrity level, so an +// elevated game, launcher or tool in the foreground silently swallows +// everything this app sends until focus moves elsewhere or that window closes. +// This app never runs elevated (see SteamlessDeviceCycle) precisely so that it +// is not the one doing the blocking, which leaves it on the receiving end. +// +// The refusal is not visible in SendInput's result: on Windows 11 the call +// reports success and the event is discarded afterwards, so the only reliable +// way to know is to ask, before injecting, whether the foreground window +// outranks this process. Send does that on every event and reports the state +// changing — which covers cursor movement, trackpad clicks, paddle buttons and +// keys alike, none of which can be checked from their own return value. +// +// Injection can also be accepted and still do nothing for reasons that are not +// UIPI — a game that left the cursor clipped, or a low-level hook dropping +// injected events — so movement is checked against the cursor actually moving +// as well. +namespace InputInjection { + +// How the app tells the user something is wrong (the tray balloon). Called from +// a controller read thread, so the callback must marshal to the UI thread — the +// tray's existing alert path already does. +using AlertFn = std::function; +void SetAlertCallback(AlertFn fn); + +// SendInput for a single event. 'what' is a short tag naming the caller +// ("trackpad-move", "trackpad-click", "paddle-mouse", "key") so a log line can +// say what was lost. Returns whether Windows accepted the event — which is not +// the same as the event arriving; see above. +bool Send(const INPUT& input, const char* what); + +// Everything that decides whether injection can land: the foreground window's +// process and integrity level, ours, any cursor clip, and whether the cursor +// can be read at all. Logged when game mode is taken, and whenever injection +// is refused. 'reason' leads the line. +void LogEnvironment(const char* reason); + +// Injection was accepted but the cursor did not move. 'travelPx' is how much +// movement was sent while the cursor sat at 'at'. Rate-limited like refusals. +void LogCursorNotMoving(long travelPx, POINT at); + +} // namespace InputInjection diff --git a/src/app/KeyInput.cpp b/src/app/KeyInput.cpp index 80fd15f..8765da9 100644 --- a/src/app/KeyInput.cpp +++ b/src/app/KeyInput.cpp @@ -1,4 +1,5 @@ #include "KeyInput.h" +#include "InputInjection.h" #include #include @@ -149,7 +150,7 @@ void SendKeyInput(uint16_t vk, bool down) { input.ki.dwFlags = KEYEVENTF_SCANCODE; if (sc.extended) input.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY; if (!down) input.ki.dwFlags |= KEYEVENTF_KEYUP; - SendInput(1, &input, sizeof(INPUT)); + InputInjection::Send(input, "key"); } std::chrono::milliseconds KeyRepeatDelay() { diff --git a/src/app/TrackpadMouse.cpp b/src/app/TrackpadMouse.cpp index 5a62d01..9c358b0 100644 --- a/src/app/TrackpadMouse.cpp +++ b/src/app/TrackpadMouse.cpp @@ -1,6 +1,8 @@ #include "TrackpadMouse.h" +#include "InputInjection.h" #include "steam/SteamController.h" #include +#include #include #include @@ -8,7 +10,7 @@ static void SendMouseButton(DWORD flags) { INPUT input{}; input.type = INPUT_MOUSE; input.mi.dwFlags = flags; - SendInput(1, &input, sizeof(INPUT)); + InputInjection::Send(input, "trackpad-click"); } void TrackpadMouse::Reset() { @@ -19,6 +21,35 @@ void TrackpadMouse::Reset() { m_prevY = 0; m_remX = 0.0f; m_remY = 0.0f; + m_haveRef = false; + m_travelSent = 0; +} + +// Tracks whether accepted movement is reaching the cursor. Re-arms whenever the +// cursor does move, so only a genuinely pinned cursor accumulates travel. +void TrackpadMouse::NoteMovementSent(long px, bool haveCursor, + long cursorX, long cursorY) { + if (!haveCursor) { + // No cursor position to compare against — GetCursorPos itself fails + // off the input desktop, and InputInjection reports that separately. + m_haveRef = false; + m_travelSent = 0; + return; + } + + if (!m_haveRef || cursorX != m_refCursorX || cursorY != m_refCursorY) { + m_refCursorX = cursorX; + m_refCursorY = cursorY; + m_haveRef = true; + m_travelSent = 0; + } + + m_travelSent += px; + if (m_travelSent >= STUCK_TRAVEL_PX) { + POINT at{ m_refCursorX, m_refCursorY }; + InputInjection::LogCursorNotMoving(m_travelSent, at); + m_travelSent = 0; // re-arm; the log call rate-limits itself + } } void TrackpadMouse::Update(const uint8_t* buf, size_t n) { @@ -64,7 +95,15 @@ void TrackpadMouse::Update(const uint8_t* buf, size_t n) { input.mi.dwFlags = MOUSEEVENTF_MOVE; input.mi.dx = ix; input.mi.dy = iy; - SendInput(1, &input, sizeof(INPUT)); + // Read the cursor before sending: SendInput queues the event + // rather than applying it, so a position read straight after + // would still be the old one. + POINT before{}; + const bool haveBefore = GetCursorPos(&before) != FALSE; + if (InputInjection::Send(input, "trackpad-move")) { + NoteMovementSent(std::labs(ix) + std::labs(iy), + haveBefore, before.x, before.y); + } } } } diff --git a/src/app/TrackpadMouse.h b/src/app/TrackpadMouse.h index 4d36809..d38cf84 100644 --- a/src/app/TrackpadMouse.h +++ b/src/app/TrackpadMouse.h @@ -21,5 +21,22 @@ private: float m_remX = 0.0f; // sub-pixel movement carry float m_remY = 0.0f; + // Movement Windows accepted, checked against the cursor actually moving. + // A refused SendInput reports itself; motion that is accepted and then + // discarded — by a cursor clip a game left behind, or a low-level hook + // filtering injected input — looks identical to the user and otherwise + // leaves no trace at all. Records where the cursor was and how much travel + // has been sent since; when the travel adds up and the position has not + // changed, that is worth a log line. + long m_refCursorX = 0; + long m_refCursorY = 0; + bool m_haveRef = false; + long m_travelSent = 0; + + // Enough sent movement that a still cursor cannot be a coincidence. + static constexpr long STUCK_TRAVEL_PX = 120; + static constexpr float SENSITIVITY = 0.01125f; + + void NoteMovementSent(long px, bool haveCursor, long cursorX, long cursorY); }; diff --git a/src/app/TrayApp.cpp b/src/app/TrayApp.cpp index 41f35d6..16c00ab 100644 --- a/src/app/TrayApp.cpp +++ b/src/app/TrayApp.cpp @@ -3,6 +3,7 @@ #include "ControllerPlatform.h" #include "DeviceRestart.h" #include "EventLog.h" +#include "InputInjection.h" #include "steam/SteamController.h" #include "resource.h" #include @@ -93,15 +94,19 @@ bool TrayApp::Init(HINSTANCE hInstance) { [this](bool connected, bool gameModeActive, bool vigemMissing) { UpdateTrayIcon(connected, gameModeActive, vigemMissing); }); - m_controller->SetAlertCallback( - [this](const std::wstring& title, const std::wstring& text) { - { - std::lock_guard lk(m_alertMutex); - m_alertTitle = title; - m_alertText = text; - } - PostMessageW(m_hwnd, WM_ALERT, 0, 0); - }); + auto raiseAlert = [this](const std::wstring& title, const std::wstring& text) { + { + std::lock_guard lk(m_alertMutex); + m_alertTitle = title; + m_alertText = text; + } + PostMessageW(m_hwnd, WM_ALERT, 0, 0); + }; + m_controller->SetAlertCallback(raiseAlert); + // Blocked input reaches the user the same way a dead controller does: it + // presents identically — nothing happens — and the cause is something only + // this app is in a position to name. + InputInjection::SetAlertCallback(raiseAlert); LoadSettings(); // Converge the startup mechanism with the loaded mode — e.g. the first