Keyboard, mouse and gamepad test suite covering all four Windows gamepad
paths — XInput, DirectInput8, Windows.Gaming.Input and raw HID — in four tests.
Single portable .exe, no installer, no runtime dependencies beyond core Windows
DLLs.
The two newer paths are not tests of their own. Each is folded into the test it belongs with, because neither is a separate device: they are different windows onto the same pad. Windows.Gaming.Input sits in the XInput test, HID sits in the DirectInput test.
| File | Arch |
|---|---|
bin/InputControl32.exe |
x86 — runs in win32 and WoW64 prefixes, start here |
bin/InputControl64.exe |
x86-64 — use if the container is arm64-only with no 32-bit x86 runtime |
Writes nothing to disk unless you use File > Save Report.
Pick one from the home screen, or switch any time from the Tests menu.
The 104-key map. Keys are identified by scan code + extended flag, not by
virtual key, so keys that share one are told apart correctly — Home vs numpad
7, LCtrl vs RCtrl, Enter vs NumEnter. See
scan-codes.md.
The Unmapped counter catches scan codes matching no known key, which is what an emulator inventing or mangling codes looks like.
Rollover shows how many keys are held right now and the most ever held at
once. That is a real limit worth knowing: cheap keyboards stop registering past
two or three simultaneous keys, and some input layers cap it lower still.
Auto-repeat re-sends WM_KEYDOWN for a key already down, so repeats are ignored
rather than inflating the count.
Held keys are listed with how long each has been down. Anything past five seconds with no key-up turns amber — if you are not holding it, the key-up was lost, which is a common emulator fault and otherwise invisible.
The same scheme everywhere — keys, mouse buttons, pad buttons, trigger labels:
| grey | never seen |
| cyan | held / happening right now |
| dark blue | already tested |
Mouse direction arrows are the exception: they only light while the mouse is actually moving that way and go out again, rather than staying marked.
A wireless mouse with eight arrows around it. Buttons, scroll wheel and both side buttons highlight when pressed; the arrows light in whichever direction the mouse is moving.
Motion comes from raw input (WM_INPUT), not WM_MOUSEMOVE. That matters:
cursor-based movement stops reporting once the pointer is clamped at a screen
edge, so a cursor-based test can look like it lost the mouse when the mouse is
fine. Raw deltas keep arriving regardless.
Readout covers cursor position, last raw delta, event count, distance, both wheel axes and the last notch value.
Motion analysis compares what the device reported against what the pointer actually did:
- Raw rate — how fast raw input events arrive, with mean, min and max interval. A mouse reporting 125 Hz when it should be 1000 Hz shows up here.
- Raw travel vs cursor travel — sum both and the ratio tells you whether
anything is rescaling your movement.
1.00means the cursor tracks the device exactly. Above 1 means acceleration or upscaling; below 1 means it is being scaled down. Both sides start counting on the same event, since cursor travel needs a previous position to measure from. - Edge clamping — at a screen edge the cursor stops but the device keeps reporting. That is counted per axis, and it is why a cursor-based test can look like it lost the mouse when the mouse is fine.
The comparison is suspended while Mouse Capture mode is on, because capture confines the pointer deliberately and the ratio would be meaningless.
Xbox-style pad rendering traced from a real controller outline. Buttons illuminate, sticks show true deflection with a travel vector, triggers fill proportionally with pressure and show a live percentage.
Calibration graphs — an X/Y plot per stick with the last 192 samples traced. Roll a stick to the rim: a round trace means good range, a flat side means the stick is not reaching full deflection there, and a resting dot off centre is drift.
Deadzone in this view is not measured — it is drawn as a fixed 24% amber
ring, matching XInput's own XINPUT_GAMEPAD_LEFT_THUMB_DEADZONE. Raw values on
the readout line stay untouched, so nothing is hidden. To measure what your pad
actually does, use the Analog & Polling test below.
Vibration test — independent motors, not just a single buzz: Left/Right at 50% and 100%, Both, a Sweep that ramps the left motor up and down then the right, and Stop. Live motor values shown as bars.
The WinRT path, Windows.Gaming.Input.Gamepad, sits in the bottom right of this
view rather than in a test of its own — it is the same pad through a different
API, so comparing it here is the point.
It reports how many gamepads WinRT sees and whether its button state matches XInput's; a mismatch is called out, since that is the interesting case.
L Trig / R Trig drive the impulse triggers — motors inside the trigger levers. XInput has no call for them at all, which is the practical reason this path is worth having. On Wine it is also the vibration route most likely to work, because it rides the same haptics path as XInput rather than DirectInput's PID effects.
mingw ships no windows.gaming.input.h, so IGamepadStatics and IGamepad are
declared directly as C vtables in src/wgi.c and combase.dll is
loaded at runtime. GamepadReading and GamepadVibration cross an ABI boundary
by value, so their sizes are asserted at compile time — a layout change breaks the
build rather than the stack.
Tests > Next controller cycles slots 0-3.
XInput is loaded at runtime, trying xinput1_4 → 1_3 → 9_1_0 → 1_2 →
1_1. A container with none of them shows "XInput is not available" instead of
failing to start.
The raw readout line shows the untouched backend values (raw L +12345,-06789 of 32767), and View > Event log records every stick move with its packet
number — between them you can tell whether coarse or jumpy input is coming from
the driver or from this program.
The same pad through DirectInput8, so the two paths can be compared directly — useful when a pad works in one API and not the other.
Buttons 0-9 are mapped to the standard layout XInput-compatible pads expose
through DirectInput (A B X Y LB RB Back Start LS RS), the POV hat drives the
D-pad, and axes map lX/lY to the left stick and lRx/lRy to the right.
That mapping is a convention, not a guarantee. DirectInput devices are free to report buttons and axes in any order, so the view also shows the raw button grid and all eight raw axis values. If the Xbox rendering looks wrong for your pad, the raw panel is the truth.
Force feedback test — Constant 50%, Constant 100%, Sine, Square, Stop. Each plays an 800 ms burst on every force feedback actuator axis the device exposes.
This is not the same mechanism as XInput vibration. DirectInput has no
"set motor speed" call: it builds an IDirectInputEffect object, and creating
one requires the device to be held at DISCL_EXCLUSIVE. That exclusive grab is
taken on the first force feedback click, never at open — so the input half of
this view behaves exactly as it did before force feedback existed, and a driver
that dislikes exclusive mode cannot cost you the test you actually came for. If
exclusive is refused, the device is put straight back to non-exclusive and
re-acquired.
Availability is decided by enumerating DIDFT_FFACTUATOR axes, not by
trusting the DIDC_FORCEFEEDBACK capability bit. An effect is built out of
actuator axis ids, so those axes are the thing that actually has to exist; the
caps bit is only used to explain the result.
The status line under the buttons names the exact step that failed:
| Line | Meaning |
|---|---|
ready - N actuator axis(es) |
effects can be created |
ready - N actuator axis(es) (caps flag absent) |
actuators exist though DIDC_FORCEFEEDBACK was not set — the caps bit was wrong, and it is ignored |
no device |
nothing enumerated |
driver exposes no actuators - caps 0x... |
no DIDFT_FFACTUATOR axis; raw dwFlags shown so the caps bit can be read directly |
caps claim force feedback but no actuator axis |
the caps bit is set but nothing is drivable |
capability read failed (0x...) |
GetCapabilities itself failed — a device fault, not a "no force feedback" answer |
exclusive mode refused by driver |
SetCooperativeLevel(DISCL_EXCLUSIVE) was rejected |
exclusive acquire failed |
exclusive was granted but Acquire then failed |
CreateEffect failed (0x...) / effect Start failed (0x...) |
the driver rejected the effect, HRESULT shown |
A pad that rumbles under XInput will not necessarily rumble here, and that is usually not a fault in the pad. Force feedback has to be plumbed all the way through the driver stack before DirectInput can see it:
- Wine's evdev path needs
/dev/input/eventNopened read-write. Read-only access disables force feedback for that device outright, and the actuator axes never appear. - Wine's newer
winebus/SDL path needsSDL_JoystickIsHapticandSDL_HapticOpenFromJoystick. Android exposes controller rumble throughInputDevice.getVibrator(), which SDL surfaces asSDL_GameControllerRumbleand not throughSDL_Haptic— so no haptic device is created.
In both cases the decision is made below DirectInput. This view reports what it
is told; driver exposes no actuators is that answer, not a failure to ask.
Concretely, on Wine 10 the capability is set from one condition: the device's HID
report descriptor must contain a PID (Physical Interface Device, usage page
0x0F) collection. Simple rumble arrives on a different report and does not
create one, which is exactly why a pad can vibrate under XInput and
Windows.Gaming.Input while DirectInput reports nothing to drive.
DirectInput is built on HID, so the raw layer is shown here rather than as a test
of its own. The panel on the right identifies the pad's underlying HID device —
matched by the VID:PID carried in the DirectInput guidProduct — and reports its
usage page/usage, input/output/feature report lengths, and live report count from
raw input on usage 04 (joystick) and 05 (gamepad).
Devices are opened with zero access rights: no read, no write. The handle is only good for querying the descriptor, so nothing is taken away from whatever else is reading that pad.
The line that matters is PID collection: yes/no, read from
HidP_GetLinkCollectionNodes. That single fact decides whether DirectInput force
feedback can exist for this device at all, and when it says no the force
feedback panel says so directly instead of leaving you to guess.
On HIDAPI: that is a third-party cross-platform library, not a Windows API. On
Windows it wraps this same hid.dll + SetupAPI path, so this panel covers what it
would show, without the dependency.
Reached from the Analog & Polling box in the top right of either gamepad test, and it reads whichever backend you came from. Three independent tests share the screen; they have nothing in common but the pad they read.
Polling — live, with its own Reset. Update rate in Hz, mean interval, min/max, and how many polls returned a changed value. The line plots updates counted into fixed 100 ms buckets, about 15 seconds of history.
Bucketing matters. Plotting the gap between individual updates instead produces a zigzag that means nothing, because those gaps are quantised by this program's own poll interval and alias against it. Plotting this program's loop interval is worse still — its own repaint lands in the trace as a periodic spike whether or not the pad did anything. Counted buckets have neither artefact: a steady pad is a flat line and a stall is a visible dip.
Magnitude — live, no controls, nothing to start. Five bars per stick: Up, Down, Left, Right and total distance from centre. A bar grows with how far that direction is pushed and drops back to zero the moment you let go. The green mark on each bar is the peak of the last movement, held on screen until you return to centre and start a new one, so you can push, release, and still read what you just did.
Deadzone sampling — an explicit run with Start and Reset. Sweep each stick through centre several times; it reports one of:
| Reading | Meaning |
|---|---|
not sampled yet |
nothing collected |
drift x% - never reads centre (n samples) |
the stick never once read dead centre; that is drift, not a deadzone |
sweep through centre - n crossings so far |
not enough centre crossings yet to state a number |
deadzone x% (n crossings, n samples, reach x%) |
nothing is ever reported between centre and x% |
no deadzone - values down to x% |
small values do arrive |
Why it refuses to guess. An earlier version inferred a deadzone from whatever data happened to arrive, which cannot tell a drifting stick from a deadzoned one — a stick resting off-centre and a stick with a dead region both produce "smallest non-zero value seen". This one states no number until it has counted real centre crossings, checks the never-centres case first because such a stick can never score a crossing at all, and prints the counts behind every figure.
The measured figure stays in this test. The XInput and DirectInput views keep their fixed 24% — this tells you what your pad really does, it does not retune the other tests behind your back.
File > Save Report... every test's results + system info + event log, as .txt
> Exit
Tests > Home / Keyboard / Mouse / Gamepad XInput / Gamepad DirectInput
> Next controller, Test rumble
> Clear results
View > Event log window separate resizable window, does not shrink the tests
> Full Capture Log... every event from every source, as CSV
> What the log lines mean... reference for every event log prefix
> Always on top
Help > System info arch, WoW64, Wine/Proton version, layout, mouse caps
> About
The menu has no keyboard shortcuts and no Alt access, deliberately. Alt,
F10 and every other key are swallowed and fed to the keyboard test, so all 104
keys stay testable. Use the mouse for the menu.
Returning to the menu — Tests > Home or the Back to tests button — resets every test: tested keys, mouse buttons and arrows, pad button memory, stick traces, deadzone samples and HID report counters. Each test therefore starts clean rather than being read through the previous run's results. The event log is kept, since it is the running diagnostic record; Tests > Clear results wipes that too.
View > Event log window opens a scrolling record of everything the program observed, in order. It follows the tail, stops following when you scroll up, and resumes when you scroll back down. It never takes focus, so the keyboard test keeps receiving keys while it streams.
The startup block is pinned — shown in green at the top and never recycled.
Stick movement alone emits lines faster than anything else in the program, so
without a protected prefix it would evict the machine and device information
within seconds and the log would look like it had lost it. A
---- startup complete ---- line separates it from live events, and the log
holds 5000 lines behind it.
Every line is stamped with seconds since start, and event lines also carry
+Nms — the gap since the previous event of that kind. Auto-repeat delay and
rate, button cadence and key timing can all be read straight off the log without
opening the CSV.
Lines are prefixed by which part of the program reported them:
| Prefix | Source |
|---|---|
SYS |
the machine, logged once at startup — arch, WoW64, Wine/Proton version, keyboard layout and type, mouse buttons, screen size |
KBD |
DN/UP/RPT, key name, virtual key, scan code, extended flag, and rollover |
MSE |
buttons, wheel, capture mode, and motion summarised per burst — totals, heading, event count and raw rate. Motion is only recorded while the mouse test is open |
XI |
XInput: which DLL loaded, slot connect/disconnect, capabilities, and one line per button change giving the names that went down and came up, the held mask, the interval and the packet number. Triggers log the start and end of each pull with the peak reached |
DI |
DirectInput: devices enumerated, what opened, force feedback capability |
WGI |
Windows.Gaming.Input activation and impulse triggers |
HID |
every HID device with VID:PID, usage, report lengths and whether it has a PID collection |
ANA |
polling rate and jitter, and the deadzone verdict per stick as it settles |
CAP |
capture log started and stopped |
View > What the log lines mean... opens a reference explaining every prefix and every field — what a scan code is versus a virtual key, why the extended flag matters, what an XInput packet number tells you, what a PID collection decides. It scrolls, follows the theme, and can stay open beside the log.
View > Full Capture Log... asks for a path and then records every event from every source into one CSV until you switch it off. One row per event, one shared timebase, so a key press and a stick move can be lined up afterwards.
Columns are fixed and mostly empty per row, which is what makes the file loadable by anything without a parser per source. Every row has the same 33 columns.
type |
What it records |
|---|---|
kbd |
virtual key, scan code, extended flag, up/down, and rollover at that moment. detail is down, up or repeat — auto-repeat is captured but marked, so it can be filtered out or measured |
mouse |
raw delta, cursor position, running raw and cursor travel, clamp counts. detail names the event: raw, Left down, wheel vert and so on |
pad |
backend, connection, button mask, both sticks, both triggers, raw button bits — written only when something actually changed, not once per poll |
poll |
once a second: update rate, mean/min/max interval, total samples and changes |
mark |
a view change, so the capture can be segmented by test |
Because kbd rows carry timestamps and a repeat marker, auto-repeat delay and
rate fall straight out of the file. Because pad rows are written per change
rather than per poll, the file stays a record of what the device did rather than
of how often this program asked.
Imports only GDI32, KERNEL32, USER32, COMDLG32 and msvcrt.
dinput8.dll, the XInput DLLs, combase.dll, hid.dll and setupapi.dll are
all resolved with LoadLibrary at runtime and are deliberately not hard
imports — a container missing any of them shows that test as unavailable rather
than preventing the whole program from starting. build.sh asserts this after
every build.
sudo apt-get install mingw-w64 osslsigncode
./build.shSigning is skipped unless signing/inputcontrol-key.pem exists; the private key
is not in this repo. See the comment block in build.sh to generate
your own.