Remap the M1–M5 macro keys on Razer keyboards without Razer Synapse. Single ~5 MB executable, no drivers, no background bloat, ~10 MB of RAM.
Built and tested on a BlackWidow Ultimate 2013 (1532:011A). Other models
that speak the same protocol should work — see Other models.
The M-keys send nothing that Windows can see. They only come alive when the keyboard is switched into driver mode, which is what Synapse does — along with an account, a launcher and a few hundred megabytes of resident services.
This is the same job in a single file: put the keyboard into driver mode,
listen for macro-key reports, inject keystrokes via SendInput.
- Grab
razer-macro-windows-x64.zipfrom Releases and unpack it anywhere —%LOCALAPPDATA%\Programs\razer-macrois a good spot. - Run
razer-macro.exe. It lands in the tray and writes aconfig.tomlnext to itself on first start. - Autostart: tick Start with Windows in the tray menu, or run
razer-macro.exe --install(--uninstallto undo).
Either way it writes one value under
HKCU\Software\Microsoft\Windows\CurrentVersion\Run — no admin rights, no
scheduled task, no startup shortcut. The tray checkbox reads the registry every
time the menu opens, so it always shows the real state.
Close or uninstall Synapse first — two programs cannot own the device at once.
Windows SmartScreen will warn about an unsigned binary; code signing certificates cost money and this project has none. Build it yourself if that matters to you.
Edit config.toml, then pick Reload config from the tray menu.
[keyboard]
vendor_id = "0x1532"
product_id = "0x011A"
[binds]
M1 = "^+{PRINTSCREEN}" # Ctrl+Shift+PrtScrn
M3 = "{PRINTSCREEN}" # PrtScrn
M5 = "#+s" # Win+Shift+S
# M4 = "run:notepad.exe" # launch a program insteadModifiers go at the front: ^ Ctrl, ! Alt, + Shift, # Win.
Named keys go in braces — {PRINTSCREEN}, {F13}, {VOLUME_UP}, {DELETE},
arrows, media keys; anything else is typed as a literal character.
The M1–M5 labels run bottom to top, but the codes the keyboard sends run top to bottom. Worth knowing before you wonder why the wrong key fired:
| Label | M5 (top) | M4 | M3 (middle) | M2 | M1 (bottom) |
|---|---|---|---|---|---|
| Code | 0x20 |
0x21 |
0x22 |
0x23 |
0x24 |
You can bind raw codes instead of labels — 0x21 = "{VOLUME_UP}".
- Enumerate the keyboard's HID interfaces through SetupAPI.
- Send a set device mode feature report (
class 0x00,id 0x04, mode0x03) and read it back with0x84. Whichever interface answers0x03is the control interface — found by probing, not hardcoded. - Read input reports from the vendor collection (
Col04). A macro report is04 <code> …, and it lists every key currently held, so presses are detected as a set difference rather than a single byte. - Inject the bound combination with
SendInput. - On exit: restore normal mode, release stuck modifiers, close handles.
The read loop blocks on overlapped I/O and a stop event, so an idle daemon costs no CPU at all — no polling.
Run razer-macro.exe --list to see every Razer HID interface with its VID/PID,
then put yours in config.toml. If your model uses a different transaction id
than 0xFF, the mode switch will fail — open an issue with the --list output.
Measured on Windows 11, Python 3.14 build:
| Build | Processes | Working set | Private |
|---|---|---|---|
folder (razer-macro-windows-x64.zip) |
1 | ~27 MB | ~15 MB |
single file (razer-macro.exe) |
2 | ~35 MB | ~16 MB |
The folder build is the lean one. A one-file PyInstaller binary unpacks itself to a temp directory and keeps the bootloader process alive alongside the interpreter — convenient, but that is the extra ~9 MB. The single exe is offered for people who want one file and don't care.
What keeps it at 27 MB rather than 60:
- No pystray, no Pillow. The tray icon is drawn straight into a DIB section
and handed to
Shell_NotifyIcon, so the whole GUI is ctypes. - No hidapi. HID goes through
setupapi/hid.dlldirectly, which also means no bundled native DLL. - Unused HID interfaces are closed once the control and macro collections are identified.
SetProcessWorkingSetSizeafter startup and every five minutes — the daemon sleeps almost always, so pages taken during init go back to the system.
The floor here is CPython itself. Getting materially below this means not being a Python program.
pip install pyinstaller
python -m PyInstaller --clean --noconfirm razer-macro.spec
Output: dist\razer-macro.exe. Tagging v* builds it on CI and attaches the
zip to a GitHub release.
Running from source needs nothing but Python 3.11+ (tomllib):
python razer-macro.py --console
Log: %LOCALAPPDATA%\razer-macro\daemon.log.
- Nothing happens. Is Synapse running? Does
--listshow the keyboard? - A hotkey with modifiers is ignored while the bare key works — the receiver
is checking for
KEYEVENTF_EXTENDEDKEY. Extended keys are listed inEXTENDED_VKS(keys.py); add yours there. - Keyboard went silent after a hard kill. Driver mode was never rolled back — replug the USB cable.
The wire format — report layout, XOR checksum, the device-mode command — was understood from the OpenRazer project. No OpenRazer code is used here; this is an independent implementation. Thanks to its authors for documenting the hardware.
This is an independent, unofficial project. It is not affiliated with, endorsed by, or supported by Razer Inc. "Razer", "BlackWidow" and "Synapse" are trademarks of Razer Inc., used here only to describe which hardware this software works with. No Razer artwork, logo or branding is included.
MIT — see LICENSE.