Reverse Engineering the E-YOOSO X-33
Reverse Engineering the E-YOOSO X-33: From Windows Driver to Linux Control App
A journey through USB HID, Ghidra, and undocumented protocols
I bought an E-YOOSO X-33 programmable gaming mouse. It’s a respectable piece of hardware. PMW3335 sensor, 16 programmable buttons, RGB lighting, 5 DPI levels, macro recording, the works. But like most of these Chinese-manufactured peripherals, the configuration software only runs on Windows.
I use Linux (CachyOS, an Arch derivative). So I did what any self-respecting Linux user would do: I decided to build a Linux configuration tool from scratch. What followed was a deep dive into USB HID, Windows PE reverse engineering, EEPROM protocols, and GTK4 development.
This post documents the journey.
The Target
The official Windows software is distributed as an Inno Setup installer: E-YOOSO X-33 Setup v3.1 20231115(2).exe. Inside:
OemDrv.exe: the main configuration applicationLowerdev.dll: a thin HID communication layerInitSetup.dll,MenuEx.dll: supporting UI librariesCfg.ini: configuration storageText/: multilingual string tables (English, Chinese, Russian, Portuguese)
The Cfg.ini gave me the first clues:
VID=0x25A7
PID=0xFA08
PID2=0xFA07
Sensor=0x3335
DM=5
DPI=1000,2000,4000,8000,16000
K1_1=0x02,0x31,0x00,0x01
K2_1=0x02,0x32,0x00,0x02
...
K16_1=0x01,0x13,0x00,0x07
Two PIDs: 0xFA08 for wired mode, 0xFA07 for the wireless 2.4GHz receiver. The K{n}_{m} entries encode 16 button mappings in format type, keycode, extended_param, hardware_idx. The sensor is PMW3335.
Extracting the Windows Driver
Unpacking Inno Setup installers on Linux is straightforward with innoextract:
innoextract "E-YOOSO X-33 Setup v3.1 20231115(2).exe"
This gave me the full application tree: DLLs, the main executable, skin images, and the text files. I converted the UTF-16 LE XML files to readable text and had a complete map of every UI string the Windows app uses.
First Contact: USB Enumeration
The mouse appears on the USB bus as two different devices depending on connection mode:
Wired: ID 25a7:fa08 "2.4G Dual Mode Mouse"
Wireless: ID 25a7:fa07 "Compx 2.4G Wireless Receiver"
Both expose two HID interfaces:
- Interface 0: standard HID mouse (5 buttons + wheel + horizontal scroll)
- Interface 1: HID keyboard + vendor-specific configuration
The configuration interface’s HID report descriptor revealed the communication channels:
Report 0x01: INPUT (7B) keyboard modifier + key codes
Report 0x02: INPUT (7B) vendor status / battery
Report 0x03: INPUT (1B) system control
Report 0x05: INPUT (2B) consumer control
Report 0x06: FEATURE(7B) general config (profile / polling)
Report 0x08: FEATURE(16B) vendor data (initially thought to be buttons)
Report 0x09: INPUT (16B) vendor input
My first assumption was that SET_REPORT on report 0x08 would configure button mappings. I spent two days testing every combination of buffer sizes, report IDs, and transfer types. The writes always returned success at the USB level. But the values never stuck. Readback always returned all zeros.
The @property That Cost Me Hours
During development, I accidentally dropped the @property decorator from is_connected during a refactor. This meant self._mouse.is_connected returned a bound method object instead of True/False. The _scan_devices timer never detected the mouse, showing “Disconnected” even with the device plugged in and the CLI proving communication worked.
One missing decorator. Hours of debugging. Classic.
Lowerdev.dll: A Dead End
I disassembled Lowerdev.dll and found that it’s purely a thin wrapper:
// These are literally just forwarded calls
SetFeature -> HidD_SetFeature
GetFeature -> HidD_GetFeature
SetOutputReport -> HidD_SetOutputReport
GetInputReport -> HidD_GetInputReport
Zero protocol logic. The real magic is in OemDrv.exe.
Ghidra to the Rescue
I loaded OemDrv.exe into Ghidra (with the MCP bridge for programmatic access, which was a game-changer). The binary is a Delphi-compiled Win32 GUI application. It’s huge, with spaghetti control flow typical of VCL event-driven code.
The first breakthrough came from tracing the HID wrapper loader:
void LoadHidWrappers(HMODULE hLowerdev) {
g_HidApi.OpenHidDevice = GetProcAddress(hLowerdev, "OpenHidDevice");
g_HidApi.SetFeature = GetProcAddress(hLowerdev, "SetFeature");
g_HidApi.GetFeature = GetProcAddress(hLowerdev, "GetFeature");
g_HidApi.SetOutputReport = GetProcAddress(hLowerdev, "SetOutputReport");
g_HidApi.GetInputReport = GetProcAddress(hLowerdev, "GetInputReport");
}
The application opens two HID handles, not one:
// Command handle
HANDLE hCmd = OpenHidDevice(VID, PID, usagePage=0xFF02, usage=2);
// Notify handle
HANDLE hNotify = OpenHidDevice(VID, PID, usagePage=0xFF01, usage=0);
This was key. 0xFF02 and 0xFF01 correspond to the two vendor-specific collections in the HID report descriptor. The Windows driver opens them by usage page, not by interface number. On Linux, I mapped the command node to /dev/hidraw3 (wireless) or /dev/hidraw5 (wired). Whichever hidraw node responds to a GET_FEATURE on report 0x06.
The Real Protocol: EEPROM Commands
The next breakthrough came from decompiling the command packet helper:
void SendCommandPacket(HANDLE hCmd, uint8_t cmd, int body...) {
uint8_t buf[17];
buf[0] = 0x08; // report ID
buf[1] = cmd; // command ID
// ... body bytes ...
buf[16] = 0x55 - sum(buf[0..15]); // checksum
SetFeature(hCmd, buf, 17);
}
The actual communication is a 17-byte command protocol, not standard HID feature reads:
| Byte | Purpose |
|---|---|
| 0 | Report ID (always 0x08) |
| 1 | Command ID |
| 2-15 | Command-specific payload |
| 16 | Checksum (0x55 - sum(bytes[0..15])) |
Commands I identified:
| Command | Function |
|---|---|
0x03 |
GetConnectStatus (preflight before any write) |
0x07 |
WriteEEPROM |
0x08 |
ReadEEPROM |
0x09 |
Commit / Apply |
Commands 0x01 (handshake), 0x02, and 0x04 also exist but I haven’t fully mapped them yet.
Replies come back as 0x09-prefixed packets on the same hidraw node:
09070000020253000000000000000000ee
| | | |
| | | +-- checksum
| | +----- echoed payload
| +------- status byte
+---------- reply marker (0x09)
The key insight: every EEPROM write is acknowledged by a reply packet with the same format as the request.
EEPROM Memory Map
From the main “Apply” function in OemDrv.exe, I reconstructed the EEPROM layout:
| Address | Size | Content |
|---|---|---|
0x0000 |
2B | Polling rate code + checksum |
0x000C |
4B x 5 | DPI table (5 levels, 4 bytes each) |
0x002C |
4B x 5 | DPI color table (R,G,B + checksum per level) |
0x0060 |
4B x 16 | Key matrix (16 buttons, 4 bytes each) |
0x0100+ |
variable | Per-key extra blobs (keyboard combos) |
0x0300+ |
variable | Macro storage (16 slots) |
The DPI table uses a sensor-specific lookup. I extracted the full 0x3335 DPI code table from OemDrv.exe:
SENSOR_3335_DPI_TO_CODE = {
100: 0x00, 200: 0x02, 300: 0x03, 400: 0x04, 500: 0x05,
...
4000: 0x2F, ..., 16000: 0xBD,
}
The key matrix entry format encodes the function:
// Mouse function entries
LeftButton -> 0x000101
RightButton -> 0x000201
FireKey -> 0x000401
SniperButton -> 0x000801
// Simple keyboard key
SingleKey -> 0x000005 // plus a 7-byte blob at (index+8)*0x20
The Wake Problem
Wireless mice sleep to save battery. When sleeping, the receiver accepts command packets but never sends ACKs. I discovered this empirically: writes that worked after mouse movement failed when the mouse had been idle for a few minutes.
The fix: open the mouse input node (the one that returns Broken pipe for feature reports but streams input reports when the mouse moves) and read from it before each write operation. This generates USB traffic that wakes the mouse’s radio:
def _wake_mouse(self):
"""Read from input node to wake wireless mouse."""
for _ in range(3):
try:
data = os.read(self._notify_fd, 64)
if data:
return True
except BlockingIOError:
pass
time.sleep(0.1)
return True
Building the Linux Application
The Linux tool is a GTK4 / libadwaita application in Python. Architecture:
eyooso_mouse/
├── mouse_protocol.py # HID descriptor parsing, EEPROM packet protocol,
│ # hidraw ioctl transport, DPI/sensor tables
├── config_manager.py # Cfg.ini parser, JSON profile save/load
├── main.py # GTK4 AdwApplicationWindow with tab navigation
└── ui/
├── main_page.py # Interactive mouse visual (Cairo rendering),
│ # button mapping panel
├── advanced_page.py # DPI editor (5 levels + color swatches),
│ # polling rate, sensitivity, scroll
├── lighting_page.py # LED modes (steady/breathing/neon/streaming/off),
│ # brightness, speed, color picker
└── macro_page.py # Macro recording, editing, import/export
Technical details worth noting:
- Uses raw hidraw kernel interface (
/dev/hidraw*) viafcntl.ioctlwithHIDIOCGFEATURE/HIDIOCSFEATURE, notpyusborlibusb. The hidraw path handles report IDs correctly (first byte of the buffer) and returns complete packets. - Implements the exact checksum algorithm from the disassembled binary:
0x55 - sum(bytes[0:15]). - Retry logic with adaptive timeouts for wireless ACK latency.
- 31 pytest unit tests covering packet building, checksums, DPI table lookup, button matrix encoding, and config serialization.
What Works (June 2026)
| Feature | Wireless (0xFA07) | Wired (0xFA08) |
|---|---|---|
| Profile switching | ✅ | ✅ |
| Polling rate write | ✅ | ✅ |
| DPI table write (0x3335) | ✅ | ✅ |
| DPI color write | ✅ | ✅ |
| Key matrix write (mouse fns) | ✅ | ✅ |
| Simple keyboard keys | ✅ | ✅ |
| Complex key combos | ❌ | ❌ |
| Macro recording/write | ❌ | ❌ |
| LED mode write | ❌ | ❌ |
| Battery read | ❌ | ❌ |
What’s Still Missing
The macro protocol and complex key combos use a binary blob format I haven’t fully decoded. The LED write path exists in the decompiled OemDrv.exe but references addresses I haven’t mapped. Battery and firmware version readback require the notify handle thread, which I’ve only partially implemented.
The Windows driver also implements a firmware update mechanism (ShowFWUpdate in Cfg.ini) and a sniper-button DPI menu, neither of which I’ve ported yet.
What I Learned
This project taught me more about USB HID than I ever expected to know:
- USB control transfers vs hidraw:
pyusbcontrol transfers work, but hidraw ioctls are cleaner on Linux. The kernel assembles complete HID report packets for you. - HID report descriptors tell you what’s declared, not what works: the device accepts
SET_REPORTwith report IDs that don’t appear in the descriptor, as long as the buffer format matches the transport. - Wireless receivers cache state: feature report reads return cached values, not live device state. The mouse’s actual config lives in its internal EEPROM and is forwarded wirelessly.
- Ghidra’s decompiler is remarkably good: even on Delphi-compiled binaries with messy VCL control flow, the reconstructed pseudocode was accurate enough to port packet construction logic directly to Python.
- Sleeping devices are a real problem: wireless peripherals aggressively sleep to save power, and you need to generate USB traffic on the input node to wake them before configuration commands are accepted.
Where We Stand — And Why It’s Not Done Yet
Let me be honest: this project is still experimental and not fully functional as a daily driver.
The Linux tool compiles, the code is solid, the packet protocol is correctly implemented, and the EEPROM writes are acknowledged by the mouse. But there’s a gap between “the mouse ACKs the write” and “the mouse actually changes its behavior.”
Here’s what’s still blocking us:
-
The commit condition is fragile. Sometimes the device accepts all writes and the profile switches instantly. Other times, especially when the mouse is in wireless mode and hasn’t been moved recently, the same exact packet sequence gets ACK’d at the USB level but the mouse firmware ignores the changes. The official Windows driver seems to have a more complex handshake sequence that we haven’t fully decoded, possibly involving command
0x01(which sends challenge bytes) and a response validation step. -
The mouse’s internal state machine is undocumented. After a write-commit cycle, the mouse enters a “programming mode” briefly. During that window, the receiver stops forwarding mouse input reports to the host. If you move the mouse during a config write, you get no cursor movement for about 1.5 seconds. The Windows driver accounts for this timing. Our Linux implementation doesn’t yet.
-
Wireless vs. wired behave differently. When connected via USB cable (PID
0xFA08), the mouse talks directly to the host and the command node is always responsive. But via the 2.4GHz receiver (PID0xFA07), the receiver acts as a proxy. It buffers commands and forwards them to the mouse when the radio wakes up. This buffering introduces latency and sometimes outright packet loss. We’ve added retries, but we don’t yet have the receiver’s buffer management protocol. -
The macro and LED protocols are still black boxes. We know the EEPROM addresses where macro data lives (starting at
0x0300) and we can see the blob-builder functions in the decompiled binary. But the internal format (how key events, delays, and loop controls are encoded) is still being reverse-engineered. Same for the LED mode writes.
I’m not giving up, but I also won’t pretend this is close to done. The gap between “the mouse ACKs the write” and “the button actually changes what it does” is not a small one. It’s the difference between talking to the receiver and talking to the firmware running inside the mouse itself. That firmware doesn’t have a datasheet. It doesn’t have an SDK. The only documentation is a disassembled Delphi binary and a lot of trial and error.
The missing pieces are:
- handshake validation
- internal buffer management in the receiver
- the macro encoding format
These are, individually, weeks of reverse engineering. And any one of them could easily be the piece that everything else depends on.
Next post: I’ll share whatever progress I make. No promises on timeline. This is weekend hacking, not a startup. But I’m stubborn enough to keep picking at it.
See you then. Probably not soon. But eventually.
Written by a Linux user who got tired of rebooting to Windows just to remap a mouse button.