Releases: AmjadAAYD/x20ctl
Release list
1.1.2
A read-everything bug hunt over the whole codebase.
Download x20ctl.exe and run it. No install, no Python needed.
Fixed
- A controller that answered the stick query but not the trigger query crashed
the load withAttributeError, leaving the workspace half filled. Reachable
from a pad with no triggers and from a single read timing out, which the first
query on a fresh link is known to do, so it was intermittent. - The tray kept showing a battery reading after you left a controller, so with
the window closed it could sit there stale or belong to a pad since switched
off. It now clears. - Macro slots were asked for by count rather than by bit position, so a pad with
a gap in its macro bits would have been asked for the wrong slots. transport.pyraised aSyntaxWarningon import from an unescaped device
path in its docstring. That becomes an error in a future Python.first_hide_to_traywas created on first use rather than initialised.
Verified, not changed
- Reading macros from inside the remapping callback is safe: the link releases
before it calls back, so the chained read is not refused. VibrationPage.loadtakes a percentage andclient.vibration()already
converts from the raw 0-255 the pad stores. No unit mismatch.transport.pyalready documented the 2.4 GHz receiver as transparent, which
independently confirms this release's dongle correction.
1.1.1
Download x20ctl.exe and run it. No install, no Python needed.
Fixed
- Closing the window quit the app and took the tray icon with it. It now hides
and keeps running; the tray icon stays, clicking it reopens the window, and
Quit is on its right-click menu. - The first time the window hides, the tray says where it went.
1.1.0
Acts on the first round of user feedback against 1.0.1.
Download x20ctl.exe and run it. No install, no Python needed.
Added
- Taskbar tray icon showing battery, with Show and Quit
- Swap left and right sticks, on the Sticks page
- Swap L2 and R2, on the Triggers page
- Home button in the Test page, via XInputGetStateEx
- Macros now load from the controller on connect, all four at once
Changed
- Power moved out of Advanced: a sleep timer needs no warning
- Power page says it is reading, instead of showing a value it has not read
- Battery readings carry the raw status byte, and
report.pyprints it
Fixed
- Idle shutdown showed 10 minutes regardless of what the controller held
- Chunked
HOST_MENUrecords silently truncated: continuations were sent as a
bare index byte, which that opcode answers with silence tools/ble_enum.pyread an OTA characteristic while claiming to skip OTA
Protocol
HOST_MENUkind 6 is macro step data; this pad reports 42 steps per slot- Kind 7 is the device can-change list, and this pad does not answer it
- The full
SET_MODEpage table,05 DF AB 00 <a> <b>, including which pair
enters firmware update SET_MODE 03 DF AB 0A–0Fare a 5 ms uptime counter, not six features- The USB bootloader's command set: opcode
0xF1reads the same GUID as
HOST_GUIDdoes over BLE, and there is no bulk read at all - Lighting writes are chunkable and do land; the LEDs ignore them regardless
Known limitations
- Colour, brightness and RGB mode are on-pad only (
C+R3,C+L3).
caps.lightingis zero and KeyLinker cannot change them either. - Battery has four steps because the controller reports four; the intermediate
decode is unconfirmed against a discharging pad. - Rear buttons cannot appear to Steam Input as their own buttons: the firmware
replays them as existing buttons. - The 2.4 GHz receiver carries no configuration channel.
x20ctl 1.0.1
1.0.1 — 2026-08-16
First stable release. Verified on two EasySMX X20 controllers.
Download x20ctl.exe and run it. No install, no Python needed.
Added
- Standalone Windows executable
- Controller roster: up to four controllers, one per player slot
- Per-controller save files, keyed by Bluetooth address
- Button remapping page
- Macro editor: piano-roll grid, all four slots, per-step timing, repeat
- Macro recording from live play
- Stick direction dial for macro steps, snapping to eight headings
- Saved macros page: whole-controller setups, open in editor or send to pad
- Trigger travel zones and response curves
- Idle shutdown timer, 1–30 minutes or never
- Sensor calibration
- Factory reset, behind a confirmation
- Battery level in the header
- Live connection state per controller, with a prompt when one goes quiet
- Vibration preview: the pad buzzes at the strength being set
- Update check against GitHub releases
- Simple and Advanced modes
- CLI:
sleep,remap,macro --read,calibrate,factory-reset,
curve --gear,curve --preset
Changed
- App opens on the controller roster instead of the macro editor
- Sidebar navigation replaces the tab strip
- Every page that edits the controller has a Save button
- Vibration saves itself; no Apply needed
- Discovery returns every controller found, not just the first
- Higher-contrast text throughout
- Window opens at 1280×820
Fixed
- Macros capped at 47 steps, where the chunk index runs out, instead of failing
inside the packet builder - Macro recording captures the left stick, not only buttons
- Key lists stop at their declared count instead of overrunning a repeated
record - Recording nothing no longer crashes
- Select and Start refused as remap sources; the controller accepts and ignores
them
Protocol
Decoded and verified on hardware:
- Idle shutdown timer lives inside the motor record, as 5 ms ticks
- Trigger zones are deadzone pairs; response curves are Hermite control points
- Sensor calibration is
SET_MODE 03 DF AB 10 - Factory reset is
RECOVER 03 DF A9 02; the wrong generation byte is ignored
silently - Macros read back via
HOST_MACRO, notREAD_MACRO
(found by chriss80) - Select and Start are key codes 93 and 94, not 9 and 10
Known limitations
- Output mode (XInput / DirectInput) changes only by button combination on the
controller - Sensor calibration sends correctly; its effect is unverified
- Lighting and turbo are not configurable over this protocol
- Trigger settings are chosen by name, not drawn
- The executable is unsigned, so Windows warns that the publisher is unknown
x20ctl 0.2.1
Small update on top of 0.2.0, and the repo is public now so other people can
try it.
This is a test phase. I built this against one controller, mine. It works on
that pad. I don't know yet whether it works on anyone else's. If you have an X20
and you try it, tell me what happens either way.
It never touches firmware. Anything it changes is undone by holding C for five
seconds, which is a factory reset.
Added
- An intro animation. The logo and name fly in, hold, then leave to the left
before the interface shows. Click, press a key, or setX20CTL_NO_SPLASH=1to
skip it. - A security policy, and three ways to check the executable: the published
SHA-256 below, scanning it yourself, or skipping the binary and running from
source. tools/macro_from_video.py, which turns a stick movement in a
gamepad-overlay video into a macro.- Tests run on push, on 3.10 and 3.12, on Windows runners.
Changed
- Says it's Windows only, and why. The input tester uses XInput directly,
save files go to%APPDATA%, and the taskbar icon needs a Windows call. The
protocol layer itself is pure computation and bleak covers Linux and macOS, so
a port would mostly mean replacing the XInput reader. - Wrote down what the macro format can't do, measured against a real game
rather than guessed. Sticks store eight compass headings at full deflection
and nothing else, so a macro can say "up" or "up-right" but nothing between
them, and it can't say "half right". There's one partial workaround, and a
note on where it stops working. See
docs/01-protocol.md.
Fixed
- Closing the window during transport detection crashed a background thread.
Detection runs off the GUI thread and takes a moment. If the window went away
first, emitting the result raised "Signal source has been deleted" somewhere
nothing could catch it.
Install
Download x20ctl.exe below. It needs no Python. Save files live in
%APPDATA%\x20ctl\profiles and survive replacing the executable.
Settings travel over Bluetooth, so Bluetooth needs to be on to change anything
even if you play wired or on the receiver. Playing is unaffected, and the input
tester works without it.
From source:
pip install -e ".[gui]"
Checking the download
SHA-256 0593a3251b19f8d2cd0376456dcb691adaa7eac8c9f7d23edf6a472cb7913cb4
size 51,915,231 bytes
Get-FileHash .\x20ctl.exe -Algorithm SHA256If it doesn't match, don't run it and tell me.
Your antivirus may flag it. It's a one-file PyInstaller build, which bundles a
Python interpreter and unpacks itself to a temp folder on startup, and several
engines treat that as suspicious on its own. It's also unsigned because I don't
have a code signing certificate. Check the hash or build from source if you'd
rather not trust the binary. There's no network code in it at all.
x20ctl 0.2.0
Deadzones and response curves, decoded in 0.1.0 but unreachable, are now
editable and confirmed accepted by the hardware. Full detail in
CHANGELOG.md;
protocol notes in
docs/01-protocol.md.
Settings travel over a Bluetooth LE link the pad exposes separately from however
you play, so Bluetooth is needed to change anything even on a cable or the
2.4GHz receiver. Playing is unaffected, and the input tester works without it.
Added
- Stick and trigger deadzones and response curves. Each of the four channels
carries an inner deadzone, an outer deadzone, and two response-curve control
points. The app gives them a page where both points are dragged directly, and
x20 curvesets exact values from the command line. - Writing confirmed against hardware, on both records. A stick deadzone was
written from 8 to 10 and a trigger deadzone from 4 to 6, each read back
changed with the opposite channel untouched, and each restored byte for byte.
This is stated precisely because 0.1.0 could not establish it: the changekey
proof wrote a value already present, which demonstrates the framing is
accepted but never that a write alters anything, since an ignored write and an
accepted one leave identical records behind. tools/verify_curve_write.py, the procedure above, kept so it can be
repeated against another controller rather than taken on trust.- A version number reported by
x20 --version, byx20 statusalongside
the controller's own firmware version, in the app header and sidebar, and in
the executable's file properties. All read one value in the package, and
packaging reads it back out of the same place.
Changed
- Edits save themselves, on a short debounce, so recording, typing, clearing
and vibration changes reach disk without being told to. The Save button
remains and reports whether anything is pending. - The only interruption left is destructive. Clearing a slot that held a
macro asks first and offers to restore it, because a macro already written to
the controller cannot be read back off it. Every other edit is silent. - Battery is re-read every 20 seconds rather than once at connection, so the
gauge and the charging flag describe the controller instead of whatever was
true when the app started. A poll returning nothing leaves the previous
reading alone; a poll that fails reports the link as gone rather than leaving
a stale reading on screen. - The controller is named, mapped by vendor and product id with a fallback
to the reported name, so the header reads EasySMX X20 rather thanXpert2. - Save files are anchored to the user, not the executable, in
%APPDATA%\x20ctl\profiles. Replacingx20ctl.exewith a newer download
leaves them untouched. A test names the three paths that would break this,
because a one-file build unpacks to a temporary directory deleted on exit, and
anything stored beside the executable would appear to work and then lose
everything.
Fixed
- The header and the footer disagreed. A reconnect set the header to
"Connecting…" and left the footer reading "connected", because only one of the
two was updated. - A controller that was off or out of range printed a library traceback on
the command line. It now explains the state and what to do about it, as the
app already did. - A help string was broken by an editing slip, leaving its module unable to
import. The test suite reported it and it was committed regardless; recorded
because the process failure matters more than the typo. - The screenshot tool raced the app's auto-connect, so a controller
answering mid-render replaced the staged values and the published images
depended on whether the pad was switched on. It also still hadXpert2
written into it after the app stopped using that name, so the README showed a
name the app would never display.
Known limits
- The curve drawn between the two control points is this application's own
interpolation. The points are read and written exactly, but no available
source documents how the firmware joins them, so the drawn line is a faithful
picture of where the points sit rather than a claim about the hardware's
arithmetic. - Whether settings survive a power cycle is unverified. Vibration set to
zero read back at a different value after the controller was powered down
overnight, matching neither the written value nor the factory default. Until
that is investigated, treat any written curve as possibly not persisting
across a power cycle. - A capability read precedes every write, as it does in the vendor's own
application, so nothing here establishes whether that handshake is required.
Not available on the X20
The pad publishes a capability descriptor stating what it will accept, and an
X20 reports zero for these. They exist in the hardware but are driven by button
combinations on the pad and are not reachable through this protocol. Another
controller on the same chip may report them as available, and the library gates
on the descriptor, so it will simply work.
- RGB lighting
- Turbo
- Gyro
Deliberately out of scope
- Firmware flashing. That runs through the mass storage bootloader, which is
the only path that can destroy the controller. Use the manufacturer's updater.
x20 statusreports the installed version so you can tell when you are behind.
Install
Download x20ctl.exe below; it needs no Python. From source, pip install -e ".[gui]" puts x20ctl and x20 on your PATH.
Verifying the download
SHA-256 d5f3fb1842df7089cdaa3b6260714770ef85815f7ac0cad7a725aa3b82d8efb6
size 51,916,577 bytes
Get-FileHash .\x20ctl.exe -Algorithm SHA256If that doesn't match, don't run it. You can also scan the file on VirusTotal,
or skip the binary and run from source.
The executable is a PyInstaller one-file build, which bundles a Python
interpreter and unpacks itself to a temporary directory at startup. Several
antivirus engines flag that shape on its own, and this build is unsigned
because a code-signing certificate costs money the project doesn't have, so a
generic heuristic warning is likely and isn't evidence of much on its own. A
detection naming specific behaviour would be, and is worth reporting. The
program has no network code at all: there is no requests, urllib, http or
socket import anywhere in the package. It speaks BLE to the controller and
reads XInput, and has no path to the controller's bootloader. See
SECURITY.md.
x20ctl 0.1.0
Changelog
0.1.0
First release. The protocol was reverse engineered from scratch; no public
documentation of KeyLinker, Xpert2, or com.pulsenet.inputset appears to
exist.
The protocol
Recovered by decompiling the vendor's Android app and confirmed against
hardware. Full detail in docs/01-protocol.md.
- Transport. BLE GATT on a peripheral advertising as
Xpert2, separate from
the gamepad interface, so both are live at once. - Framing.
[opcode][length][serial][nonce][payload][crc8], capped at 20
bytes, then passed through a scrambling pass. - Checksum. Reflected CRC-8, polynomial
0xEB. The table is generated from
the polynomial and asserted equal to the one shipped in the vendor app on
every test run. - Payloads. Byte 0 is a length prefix. Records longer than one packet are
chunked and fetched by index. - Identification. The pad clones Microsoft controller ids on every link, so
nothing here matches on VID/PID.
Added
- Macros on M1 to M4. Sequences, chords, and thumbstick directions.
A,Bplays in turn,A+Bpresses together,LS_UP+Apushes the stick while
holding a button. Fourteen buttons plus both sticks. - Per-step timing.
A:150holds for 150ms,A:150/40waits 40ms
afterwards. The hardware stores a duration per step and this exposes it. - Multi-packet macros. Sequences too long for one packet are chunked
automatically. - Macro recording. Press buttons on the controller and the app captures them
with the timing you actually played. - Vibration strength, 0 to 100 percent. The pad's own controls cannot set
this, and cannot silence rumble at all. - Battery level, a four step gauge with a charging flag.
- Save files. Named sets of macros and vibration, stored as JSON. Applying
one makes the controller match it exactly. - Input tester. Every button lights while held, both sticks draw a position
trail, triggers show their analog value. - Polling rate meter. Counts the reports per second that actually reach
Windows, so comparing links is meaningful. Reads 1002 peak on a 2.4GHz
receiver, matching an independent tool on the same hardware. - Transport detection. Reports whether you are playing over Bluetooth or
USB. - Command line interface alongside the app, and a standalone executable
that needs no Python. - Explanations on every control, including why there are three millisecond
fields.
Not available on the X20
The pad publishes a capability descriptor stating what it will accept, and an
X20 reports zero for these. They exist in the hardware but are driven by button
combinations on the pad and are not reachable through this protocol. Another
controller on the same chip may report them as available, and the library gates
on the descriptor, so it will simply work.
- RGB lighting
- Turbo
- Gyro
Deliberately out of scope
- Firmware flashing. That runs through the mass storage bootloader, which is
the only path that can destroy the controller. Use the manufacturer's updater.
x20 statusreports the installed version so you can tell when you are behind.
Fixed during development
Kept because each one records something learned about the hardware.
- Macros drove both thumbsticks. An untouched analog entry encodes as
0b1000, not0b0000. A zeroed nibble is direction up, not centre, so an
early macro swept both sticks until it was interrupted. - The same fault, one line later. Fixing the mask builder was not enough;
release steps were built with a bare zero mask and recreated it. - Macros repeated forever. The header field is a loop interval, not a
duration. Zero disables looping; anything else repeats until another macro
button is pressed. - Save files did not switch. Applying left slots the file did not define
alone, so the previous file's macros stayed live while the app showed the new
one. - Recordings were lost. Recording filled the card but saved nothing, so
clicking elsewhere discarded it. - Removing a save file put it back. Deleting reloaded the list, which fired
the selection handler, which autosaved the deleted profile to disk. - The recorder discarded its own timing, averaging every step into a single
hold and gap. - The polling meter undercounted, reading about 900Hz where the true figure
was 1002. It counted whether the packet number changed rather than by how
much. - Transport detection claimed "wired" on a receiver. The receiver is
transparent: same ids, same revision, same driver as a cable. The two are
indistinguishable, and the app now says so instead of guessing. - The windowed launcher died silently. Under
pythonwthere is no
sys.stdout, and terminal code was being imported during startup. - The taskbar icon did not appear. The app identity was being set after the
icon, and Windows had already chosen by then. - Clicking a save file did nothing while the input tester was open.
- Bluetooth being off looked like a fault. Settings need it; playing does
not.