The 90-Byte Envelope
Every Razer config command uses the same 90-byte packet format. Once you understand this layout, controlling DPI, lighting, or polling rate is just a matter of plugging in different values.
| Byte Index | Field | Description |
|---|---|---|
| 0 | status | 0 on send, response status on reply |
| 1 | transaction_id | Device-specific magic number |
| 2-3 | remaining_packets | For multi-packet commands |
| 4 | protocol_type | Always 0x00 for these commands |
| 5 | data_size | Number of meaningful argument bytes |
| 6 | command_class | Subsystem (DPI, lighting, etc.) |
| 7 | command_id | Specific action within class |
| 8-87 | arguments | 80 bytes of command-specific data |
| 88 | CRC | XOR of bytes 2 through 87 |
| 89 | reserved | Always 0 |
When you send a command, byte 0 is 0. The device overwrites it with a status code in the response. The same buffer works for both directions.
The two bytes that select the operation are command_class (byte 6) and command_id (byte 7). Think of class as the subsystem (DPI, lighting, profiles) and id as the specific action inside it. Bytes 8 to 87 are arguments, whose meaning depends entirely on the class/id pair.
data_size (byte 5) tells the device how many of the argument bytes are significant. If your command uses 3 argument bytes, set data_size to 3. Get this wrong and the device will likely ignore you.
The CRC: Just XOR
Byte 88 is a checksum computed as the XOR of every byte from index 2 through 87 inclusive:
report[88] = report[2...87].reduce(0, ^)
No polynomial, no lookup table — just XOR. If the CRC is wrong, the device rejects the packet. You'll either get a status that isn't 0x02 or no response at all.
The critical detail: the range is 2...87, not 0...89. The status and transaction ID at the front are excluded, as are the CRC and reserved bytes at the end. The author got this wrong initially by including byte 1, wasting time debugging command bytes that were actually fine.
The Cursed Transaction ID
Byte 1 is the transaction ID. It doesn't change the command's behavior — it's a tag the device firmware checks before even looking at the rest of the packet. If it's wrong, the device acts as if you said nothing.
The catch: the correct value differs across Razer devices, and nobody documents it per model. For the DeathAdder V2 it's 0x3F. Other devices use 0xFF or 0x1F. There's no command to query the device for its transaction ID. You either know it or you guess.
report[1] = 0x3F // DeathAdder V2
If you're porting this to another Razer mouse and nothing works despite correct command bytes, change the transaction ID. Try 0xFF, then 0x1F, then 0x3F. One of them will start returning 0x02.
The author hardcoded the value. There's no reason to make it configurable — a wrong value just means a dead command.
A Real Command: Firmware Version Request
The firmware version request is the simplest useful command. It's a good first target because it returns data you can sanity-check.
var report = [UInt8](repeating: 0, count: 90)
report[1] = 0x3F // transaction id
report[5] = 0x02 // data_size
report[6] = 0x00 // command_class: standard
report[7] = 0x81 // command_id: get firmware
report[88] = report[2...87].reduce(0, ^)
Send it, wait 300ms, read the response. If the stack works, byte 0 of the response comes back 0x02, and the firmware version sits in bytes 9 and 10:
print("firmware: \(response[9]).\(response[10])")
// firmware: 2.0
Seeing "2.0" print instead of "0.0" is the moment you know the whole chain works: right interface, right transaction ID, right CRC, right delay. Everything after this is just changing class, id, and arguments.
What's Next
Part 3 will build real commands on top of this envelope: setting DPI, reading it back, and the static color command that the author got wrong twice before it worked. There's a lesson in why "command accepted" doesn't mean "command did anything."
If you're building a native HID driver for macOS, this packet format is your foundation. Start with the firmware version request, get that working, then expand. And if your commands fail silently — check the transaction ID first.



