Tell the user when Windows is discarding the input this app injects

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 <noreply@anthropic.com>
This commit is contained in:
Dylan DeverillandClaude Opus 5 committed 2026-08-06 10:49:06 -07:00
1 parent e1e252619a
commit 09fe805119
9 files changed
+527 -15

No files matched your search

+25 -2
View File
@@ -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);