I bought a Razer Viper V3 Pro and found out that changing its DPI meant installing Razer Synapse 4. On my PC that is 11 Electron processes, two Windows services, about 1.5 GB of memory, a Razer account, and a renderer that polls the device and runs a game-detection engine all day.
The thing is, the mouse does not need any of that. Every setting it has lives in its own onboard memory. Software is only needed for the few hundred milliseconds it takes to write them.
So I wrote freenapse: one Windows executable, about 1 MB, no installer, no runtime, no account. It writes the settings into the mouse and then does not need to exist. Here is how it works, and the parts I had to learn from the mouse itself because no document had them.
How a Razer mouse takes commands
Razer devices accept a 90-byte HID feature report on their first interface. The layout is public knowledge thanks to the GPL OpenRazer Linux driver and the razerqdhid protocol notes:
offset 0 status request: 0x00. response: 01 busy, 02 ok, 03 failure, 04 timeout, 05 not supported
1 transaction id 0x1f = the mouse (through the dongle), 0xff = the dongle itself
2 remaining packets u16 big-endian, 0
4 protocol type 0
5 data size number of argument bytes
6 command class
7 command id bit 7 set = read
8 arguments[80]
88 crc XOR of bytes 2..87
89 reserved
On Windows the buffer is 91 bytes, because HidD_SetFeature wants the report id in front. Serialising it is a few lines:
pub fn to_hid_buffer(&self) -> [u8; 91] {
let mut b = [0u8; 91];
let r = &mut b[1..];
r[0] = self.status;
r[1] = self.transaction_id;
r[2..4].copy_from_slice(&self.remaining_packets.to_be_bytes());
r[4] = self.protocol_type;
r[5] = self.data_size;
r[6] = self.command_class;
r[7] = self.command_id;
r[8..88].copy_from_slice(&self.arguments);
r[88] = r[2..88].iter().fold(0u8, |acc, b| acc ^ b);
b
}
A command is a class, an id, a size and some argument bytes. Battery level is class 0x07, id 0x80, two bytes back. DPI stages are 0x04/0x06 to set and 0x04/0x86 to read, with seven bytes per stage. The polling rate on the HyperPolling dongle is 0x00/0x40, sent twice with a different first argument, which is one of those things you only learn by watching what the official software does.
You write the request with HidD_SetFeature, wait, read the response with HidD_GetFeature, and check that the class and id in the response are the ones you asked for. There is no hidapi in the project and no GUI framework: SetupAPI to find the device, two HID calls, Shell_NotifyIcon for the tray, GDI for the window.
The three things the documents did not say
The protocol layouts got me to a program that compiled and sent valid packets. The mouse still ignored it half the time. Three facts cost most of the project.
1. Wait 150 ms, not 60. OpenRazer picks its wait per product id: a generic mouse gets 600 microseconds, the wired Viper V3 Pro 31 ms, and the Viper receivers RAZER_VIPER_MOUSE_RECEIVER_WAIT_US, which is 59,900 microseconds. That works on Linux. Through the HyperPolling dongle on Windows, reading at 60 ms returns status 0x04 (timeout) and the exchange is lost. The threshold I measured is around 150 ms. Below it, nothing works; above it, everything does. The transact loop ended up like this:
fn transact_inner(&self, request: &Report) -> Result<Report> {
let mut last = HidError::NoResponse;
for attempt in 0..4 {
if attempt > 0 {
thread::sleep(Duration::from_millis(80 * attempt as u64));
}
self.set_feature(&request.to_hid_buffer())?;
thread::sleep(self.wait); // 150 ms
for _ in 0..5 {
let resp = Report::from_hid_buffer(&self.get_feature()?);
if !resp.matches(request) || resp.transaction_id != request.transaction_id {
// Someone else's exchange (Synapse shares the dongle); wait and resend.
thread::sleep(Duration::from_millis(30));
continue;
}
match resp.status() {
Some(Status::Successful) => return Ok(resp),
Some(Status::Busy) | Some(Status::New) => {
thread::sleep(Duration::from_millis(30));
continue;
}
Some(Status::Timeout) => { last = HidError::Status(Status::Timeout); break; }
Some(s) => return Err(HidError::Status(s)),
None => return Err(HidError::BadStatus(resp.status)),
}
}
}
Err(last)
}
The dongle has one response slot, so every exchange in the process goes through a mutex, and a response is matched against its request before it is trusted. If Synapse is running at the same time, its replies show up in that slot too.
2. The mouse goes to sleep while you are reading the docs. A wireless Viper V3 Pro stops answering the dongle a few seconds after the last movement. Sometimes within two or three seconds, sometimes after thirty. The dongle still enumerates, the official software still shows the mouse as connected, and every command gets 0x04 back. Synapse does not notice because it gets its updates from input reports rather than polling.
Once I knew, it was easy: treat 0x04 as "asleep", keep the last known values, and retry writes when GetLastInputInfo shows the user has touched something in the last 1.5 seconds. The settings window shows the change as pending and writes it when the mouse wakes. But before I knew, I spent an evening convinced the CRC was wrong, because the commands that worked were the ones I typed quickly after moving the mouse.
3. Some getters simply answer "failure". OpenRazer documents getters for the lift-off and sensor parameters. On this mouse and firmware 1.11 they answer status 0x03. The setters work. So the asymmetric cut-off levels can be written but never read back, and freenapse import has to say "keep" for that one setting instead of guessing. The onboard profile commands are similar: the mouse reports one profile and a maximum of one, and answers failure to create, activate or even query the active profile. It has a single profile and that is that.
Things that are not stored in the mouse
The onboard memory holds DPI stages, polling rate, the button map on both layers, the Dynamic Sensitivity curve, lift-off, idle sleep and the low power threshold. Three things people assume are onboard are not.
Macros: this mouse has no macro memory. Every class 0x06 command answers "not supported", and the official software's own device spec says so. Synapse plays macros in software. freenapse does the same: each macro binding gets an unused key, F13 to F24, written into the mouse, and a low-level keyboard hook in the tray app swallows that key and plays the macro with SendInput.
Per-game profiles and the in-game polling rate: also software. The tray app watches the foreground window and switches DPI stage, polling rate and buttons while a game you picked is in front. The list of games comes from the launchers' own files on disk (Steam's libraryfolders.vdf, Epic's manifests, GOG's registry keys, Xbox's MicrosoftGame.config), so no account is needed for that either.
Everything else survives with nothing running. Which leads to the architecture.
Set once, then nothing
freenapse.exe is the tray app when started without arguments, a settings window with freenapse ui, and a command-line tool with any other verb:
freenapse status what the mouse has stored right now
freenapse config a config file built from those settings
freenapse apply write your edits to the mouse
freenapse autostart on the tray, if you want battery and per-game switching
The first config file is built from what the mouse already has, so nothing changes until you edit it. Run apply once, uninstall Synapse, and the mouse keeps working on any PC. The tray is optional. When it does run, it is one process with a 10 MB working set that reads the battery once a minute.
The settings window is plain Win32 and GDI, inside the same executable: cards, toggles, sliders and the mouse drawing are painted by hand, icons are glyphs from Segoe Fluent Icons, and there is no apply button. Each control writes its setting to the mouse the moment it changes and reads it back.
Measured on the same PC with the same mouse:
| Razer Synapse 4 | freenapse | |
|---|---|---|
| Processes | 11 Electron processes and 2 services | 1 tray process, or none |
| Memory | about 1.5 GB | 10 MB working set |
| Background CPU | constant | idle, one battery read a minute |
| On disk | 220 MB plus per-device modules | one file, 1 MB |
| Account | required | none |
What I did not do, on purpose
The protocol knowledge comes from the OpenRazer driver, the razerqdhid notes, and watching what Synapse 4 sends to this exact mouse. The project contains no Razer code, files or assets; NOTICE.md in the repo says what was and was not used. It cannot read or write firmware. Every setting it writes can be written again, and reinstalling Synapse puts Razer's own profile back. Still: use at your own risk, Razer will not support a mouse configured this way, and I am not affiliated with them.
The honest limitation, and how to help
freenapse is verified on one mouse: mine, a Viper V3 Pro, wired and through its dongle. Those are the only two product ids it writes to. Other Razer mice are detected and opened read-only, because the device table has rows for them taken from OpenRazer's list but nobody has confirmed what they accept.
If you own one, this prints what it answers, with serial numbers masked:
freenapse probe
Paste that into an issue, or open a pull request adding your row to src/device.rs once you have tested a write. Each row is a few fields: product id, transaction id, which polling command it takes, the DPI maximum, whether it has the lift-off page. CONTRIBUTING.md has the four steps.
The code is MIT at github.com/MatthewSaintBull/freenapse. The site, with the download and a changelog that builds itself from the GitHub releases, is freenapse.app. Questions about the protocol are welcome in the comments; questions that start with "does it work on my DeathAdder" have the same answer for now: run probe and tell me.
Top comments (0)