Skip to content

apm: counting APM without touching input latency

apm is my APM (actions per minute) and eAPM (effective APM) counter for RTS games: a Rust rewrite of an older Python/Tk tracker called apmpy. The one requirement is that it must not affect the game: no added input latency, no CPU time taken while the game runs, no wakeups at all when nothing is happening. This page explains how the capture is built to meet that, and what was measured on the gaming PC to check it. The code is in a private repo, so this page carries the design and the numbers rather than links into it.

Measured on 2026-11-07 on one Windows 10 22H2 machine (Ryzen 7 7800X3D, the rig in Fixing merged reports from an 8 kHz mouse on Windows) with an Endgame Gear OP1 8k v2 at 8000 and 2000 Hz. Every idle number below comes from a window in which a raw-input probe saw no input at all. Each movement condition was one 8 s run with hand-moved mouse, so only large differences count.

TL;DR:

  • Global low-level hooks (WH_MOUSE_LL, which Python’s pynput uses) sit in the input path: Windows calls the hook before any app gets the event1. The Python tracker cut a second raw-input reader’s report rate from 5078 Hz to 2872 Hz at 8 kHz and doubled its median report gap.
  • A background raw-input mouse listener does not slow other readers, but on Windows 10 it receives every report2. The Rust tracker in that mode used 5-8 % of a core while the mouse moved.
  • The default now registers no mouse listener at all. Clicks come from sampling button state at 125 Hz, and only while the target game has focus. With the game focused that is 8,394 wakeups a minute on one thread with CPU time too small to measure over 20 s; otherwise 0.
  • Everything else blocks in the OS with no timeout: 0 wakeups a minute on every thread when idle, against 536 for the Python tracker.
  • The process and its capture thread run below normal priority, so the game always wins the CPU.
Windows input(keyboard raw input,button state)capture threadbelow normalGetMessageW / timerpress edgesSPSC ring(wait-free)consumer threadengine: 60 s windows,eAPM rules, peakslatest snapshot(condvar)session log(writer thread)batchedloopback HTTPSSE, <= 4 pushes/sdashboard / OBS source /native overlay
  1. The capture thread turns OS input into press events (no releases, no key auto-repeat, no mouse movement) and timestamps them.
  2. It pushes into a wait-free single-producer ring; it never waits for the rest of the program.
  3. One consumer thread owns the engine and wakes only for a new action or the next moment a 60 s window can change.
  4. The UI gets the latest snapshot over Server-Sent Events from a loopback-only HTTP server, at most 4 times a second, and only when a number changed.
  5. A writer thread appends each session to disk in batches; the consumer never touches the disk.

The daemon links no GUI toolkit. The dashboard renders in a browser tab, the stream overlay as an OBS browser source, so drawing happens in another process.

MethodIn the input pathWakes on mouse movementMeasured costUsed
Low-level hooks (WH_KEYBOARD_LL, WH_MOUSE_LL)yes, every event waits for the hookyessecond reader 5078 -> 2872 Hz at 8 kHzno (the Python tracker did)
Raw input, background mouse listener (RIDEV_INPUTSINK)noyes, every report on Windows 105-8 % of a core while movingopt-in only (--mouse raw)
Raw input, keyboard onlynono0 wakeups when idleyes, for keys
Button-state poll, 125 Hz, while the game is focusednono8,394 wakeups/min while focused, 0 otherwiseyes, for clicks

A WH_MOUSE_LL hook runs before the system passes the event on, and Microsoft documents that a hook which takes longer than LowLevelHooksTimeout is “silently removed without being called” on Windows 7 and later1. pynput installs these hooks on Windows and its mouse handler is Python code3, so every mouse report, movement included, enters the Python interpreter and returns before any other app sees it. At 8 kHz that showed up in another app’s input:

8000 Hz, one 8 s run eachSecond reader report rateMedian gapGaps over 0.4 ms per minute
no tracker5078 Hz0.148 ms14,084
Python tracker (hooks)2872 Hz0.291 ms34,066
Rust, raw mouse listener5684 Hz0.149 ms5,437
Rust, 250 Hz button poll6602 Hz0.131 ms4,110

The second reader was latctl capture, a raw-input recorder standing in for the game. The no-tracker run already lost 10.85 % of report slots and hand speed was not controlled, so the Rust rows read as “no worse than baseline” rather than “better”. At 2000 Hz none of the three trackers changed the second reader’s rate (2000 Hz, median 0.499-0.500 ms in every row).

Raw input is a copy of the event, so it does not delay anyone else. The cost is to the tracker’s own process: Windows 10 delivers every report to a background listener2. Measured over 8 s of movement:

RateTrackerWakeups per minuteCPU time over 8 s
8000 HzPython (hooks)636,3821188 ms
8000 HzRust raw listener1,010,018672 ms
8000 HzRust 250 Hz poll45,35216 ms
2000 HzPython (hooks)251,752484 ms
2000 HzRust raw listener666,165422 ms
2000 HzRust 250 Hz poll36,20216 ms

The poll’s cost is set by its timer, so it does not grow with the mouse’s polling rate. These runs used a 250 Hz poll; the default is now 125 Hz. A raw-listener rewrite after these runs has not been re-measured.

Clicks are counted by reading the button state (GetAsyncKeyState for left, right, middle and the two side buttons) on a waitable timer and recording press edges. The timer runs only while the target program, set by its exe name, owns the foreground window. A WinEvent hook for foreground changes (out of context, so it is not in the input path) arms and disarms it. Without a target the mouse is not captured at all and the dashboard says that clicks are not counted.

The ceiling: a click shorter than one 8 ms period can be missed. A human click is held far longer than that, and the poll can be raised to 1000 Hz if needed, but the default stays at 125 Hz.

SetWaitableTimer takes the first due time in 100-nanosecond intervals and the period in milliseconds, and fails on a negative period4. An early version passed the 100 ns due time as the period, so the poll fired once and never again; the Windows integration tests now cover it.

Every thread blocks in an OS call with no timeout: GetMessageW on the capture thread, park on the consumer, a condvar for each SSE connection, accept for the HTTP listener, a channel receive on the log writer. The consumer computes the next instant a 60 s window can drop a value and sleeps exactly until then, so once the window has drained it sleeps until the next keypress.

Hands off, no input in the windowWakeups per minute
Python tracker as released536
Python tracker after fixes302
Rust, default, no target0
Rust, target set but not focused0
Rust, target focused (poll running)8,394, all on the capture thread

The first idle run read 23,000-31,000 wakeups a minute for all three trackers. Input had happened during it. Every idle number since was taken with a raw-input probe registered for keyboard, mouse and pointer in the same window, and only windows where the probe saw nothing count.

  • SetPriorityClass(BELOW_NORMAL_PRIORITY_CLASS) at start; the capture thread is THREAD_PRIORITY_BELOW_NORMAL. A late poll sample still catches a click that lasts longer than the delay.
  • No global timer-resolution change (no timeBeginPeriod).
  • UI pushes are throttled to 4 a second, and the dashboard tells the user to close the tab while playing: a visible browser tab repaints on every push.
  • The native overlay is a separate process, a click-through layered window that repaints only when a number changes. It stays the second choice until a PresentMon comparison with a game open shows the game’s present mode unchanged with the overlay visible. Until then the recommended display is the OBS browser source.

The old tracker’s eAPM model is kept exactly: a 50 ms gap between effective actions, 200 ms before a recognised hotkey sequence counts, and weights per category. The Rust engine is held to the Python one by a fixture of 5,522 recorded events: every verdict and every snapshot must match.

A second model, spam-filter-v2, comes from the replay-analyser definitions. Those need game state (selected units, command types) that a tracker reading only keys and clicks does not have, so what remains is timing: a 50 ms global gap, a 200 ms cooldown per key or button, and one exception, a double tap on a control-group key, which Sc2gears counts as effective because it centres the group5. Changing the model starts a new session, so one session’s numbers come from one model.

BugHow it showed upFix
Keyboard eAPM was always 0 in the Python trackerthe hook passed no key to the rulespass the normalised key
Python tracker peaks missed burstspeaks were sampled by the GUI every 2.5 sexact peaks on insertion
Raw-input drain lost every reportinjected q 1 w Ctrl+2 click reached the engine as nothingread the report that raised WM_INPUT with GetRawInputData before draining GetRawInputBuffer; the buffer holds only further queued reports
Poll mode never stoppedstop re-posted WM_QUIT inside the poll loopreturn on WM_QUIT
A settings request failed about 1 time in 4the server closed the socket with request bytes unread, the kernel sent a resetshut down the write side, drain the request, then close

The injection test (SendInput into the test’s own window, with the cursor restored afterwards) is skipped by default and run by hand, because it types into the live desktop.

ClaimHow it was checkedStatus
Hooks slow another reader at 8 kHzlatctl capture alongside each tracker, 8 s per conditionmeasured, one run each
Raw listener costs 5-8 % of a core while movingprocess CPU time and per-thread context switches over 8 smeasured before the raw-path rewrite
0 wakeups idleper-thread context-switch counters over 20-30 s, raw-input probe emptymeasured
8,394 wakeups/min while the target is focusedsame, with the foreground app as target, probe emptymeasured
Default capture registers no mouse listenerWindows integration test reads the thread’s raw-input registrationstested
Injected sequence reaches the engine in orderSendInput test, 6 eventsmeasured
Overlay leaves the game’s present mode alonePresentMon A/B scriptnot run yet
Linux capture (evdev, movement masked out in the kernel)unit tests; device tests need a real Linux boxdesign only
Counting clicks for one game?Need clicks shorter than 8 msor clicks outside the game?yesno target: keys only,clicks not countedno--target game.exe(125 Hz poll while focused)no--mouse raw(5-8 % of a core while moving)yes
  1. Counting one game’s clicks: set the target. The poll runs only while that game has focus.
  2. Clicks shorter than 8 ms, or clicks in every app: --mouse raw, and accept the per-report cost.
  3. No target: keys are counted, clicks are not, and nothing listens to the mouse.

The interrupt and DPC tuning that keeps the same 8 kHz mouse’s reports on time is in Fixing merged reports from an 8 kHz mouse on Windows. The subagent and worktree setup that built most of the Rust code is in Subagents, worktrees, and parallel agent work.

  1. Microsoft, “LowLevelMouseProc callback function,” Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/winmsg/lowlevelmouseproc ↩ ↩2

  2. BoosterX, “Background Raw Input listeners in Windows 10 and Windows 11,” BoosterX Wiki. https://wiki.boosterx.org/en/windows-research/input/background-raw-input-throttling/ ↩ ↩2

  3. Moses Palmer, “pynput,” PyPI. https://pypi.org/project/pynput/ ↩

  4. Microsoft, “SetWaitableTimer function (synchapi.h),” Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-setwaitabletimer ↩

  5. Sc2gears, “APM types,” Sc2gears site. https://sites.google.com/site/sc2gears/features/replay-analyzer/apm-types ↩