Scroll is injected as wheel events, 120 units to a notch, and Windows
multiplies each notch by the user's lines-per-notch before anything
moves. Identical injection therefore scrolls wildly different amounts on
two machines, and a "scroll is far too fast" report gives no way to tell
that apart from a bug in our own arithmetic.
The default is 3 lines. WHEEL_PAGESCROLL makes a notch a whole page,
which turns the gentlest flick into a page jump — "I barely touch the pad
and it scrolls all the way down a page" describes that setting exactly,
and it is not something a sensitivity control could tune away, since with
a page as the smallest unit the granularity is the problem rather than
the scale.
Recorded in the injection environment line, which already fires at game
mode takeover whenever a pad is driving the desktop, so it costs nothing
and arrives attached to every future report of this kind.
The reporter's log and ours are otherwise identical — both id=0x42
len=54 — so the report layout is ruled out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>