Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ checklist.
| RAWM | `@openmouse/protocol/rawm` |
| Pulsar / GravaStar | `@openmouse/protocol/pulsar` |
| Razer legacy/current | `@openmouse/protocol/razer` |
| Rapoo VT9 Pro | `@openmouse/protocol/rapoo` |
| Razer V4 | `@openmouse/protocol/razer-v4` |
| Ryunix | `@openmouse/protocol/ryunix` |
| SteelSeries Rival 3 (Gen 1) | `@openmouse/protocol/steelseries` |
Expand Down
35 changes: 35 additions & 0 deletions captures/rapoo-vt9-pro/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Rapoo VT9 Pro fixtures

Hardware reference material for `src/rapoo/` and `src/drivers/rapoo/`.

Captured from one Rapoo VT9 Pro (1st gen) on Windows on 2026-09-29, both on its
2.4 GHz receiver `0x24AE:0x1205` and on its cable `0x24AE:0x4405`. Both paths
enumerate as "Rapoo Gaming Device" and expose the same six collections:

| Usage page:usage | Reports (byte length as Windows reports it) |
|---|---|
| `0x000C:0x0001` | input 3 |
| `0x0001:0x0080` | input 2 |
| `0xFF00:0x000E` | input 32, output 32 - the `0xBA` configuration channel |
| `0xFF00:0x0002` | input 7, output 7 |
| `0xFF00:0x0002` | input 10, output 10 |
| `0xFF0B:0x0104` | input 61, output 61, feature 61 - the `0x2A` status block |

Those lengths are what `HidP_GetCaps` returns on Windows, and it counts the
report id byte. The report *data* is one byte shorter - 2 for the mouse
collection, 31 for the `0xBA` channel, 60 for the `0x2A` block - and 31 is the
number that matters to a transport: WebHID refuses 32 (`Failed to write the
report`), which is how the frame length was pinned down.

No personal data is included: nothing here carries a serial number, and the
logs are the tool's own output.

| File | What it is |
|---|---|
| `sweep-2026-09-29.txt` | The full transcript: the interface map, the read that first proved the channel works, the register sweep on the receiver and again on the cable, the connection-byte probe (five candidates, eight attempts each), the lossy-channel measurements, and an idempotent write of the bytes just read. |

The tests inline the verified blocks: `src/rapoo/index.test.ts` for the codec
and `src/drivers/rapoo/hid.test.ts` for the driver. See
`docs/rapoo-vt9-pro-testing.md` for the write-up, including the one rule that
matters most - never read feature report `0x2B`, which drops the link until the
cable is pulled.
162 changes: 162 additions & 0 deletions captures/rapoo-vt9-pro/sweep-2026-09-29.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
Rapoo VT9 Pro (1st gen) register sweep - raw console capture
================================================================
Date 2026-09-29
Device 0x24AE:0x1205 "Rapoo Gaming Device" (2.4G receiver, mouse awake)
Tool a local PowerShell/C# probe driving HidD_GetInputReport directly
Transport HidD_GetInputReport (USB GET_REPORT(Input) control transfer)
Protocol output report 0xBA, frame [conn, cmd, len, addr u32 le, data...]

The browser cannot do this: WebHID has no GET_REPORT(Input) call, so every
0xBA write from a page succeeds and never returns data. The same USB request
is HIDIOCGINPUT on Linux hidraw, and OpenMouse Bridge exposes it as
receiveInputReport() (Windows: HidD_GetInputReport).

Interface map (report lengths INCLUDE the report id byte)
--------------------------------------------------------
pid=0x1205 usagePage=0x000c usage=0x0001 in=3 out=0 consumer control
pid=0x1205 usagePage=0x0001 usage=0x0080 in=2 out=0 system control
pid=0x1205 usagePage=0xff00 usage=0x000e in=32 out=32 <-- 0xBA config channel
pid=0x1205 usagePage=0xff00 usage=0x0002 in=7 out=7 0xBB heartbeat
pid=0x1205 usagePage=0xff00 usage=0x0002 in=10 out=10
pid=0x1205 usagePage=0xff0b usage=0x0104 in=61 out=61 feat=61 0x2A family

Decision
--------
usagePage=0xff0b usage=0x0104 out=61: SetOutputReport failed (report 0xBA is
not declared on that interface, only 0x2A is)
usagePage=0xff00 usage=0x000e out=32: ANSWER with connection byte 0xA5
(2.4G dongle). Connection byte 0xFF (wired) got no answer, which is
expected: the mouse is on the receiver right now, not on a cable.

Answer frame
------------
sent conn=0xA5 cmd=0xA4 len=4 addr=0x880
answer 01 00 00 00 82 00 82 ff 00 00 ... (32 bytes)
[0]=0x01 status OK [1]=0x00 not-a-battery-answer [4..] payload
NOTE: HidD_GetInputReport strips the report id, so the status byte is at
index 0 and the payload at index 4 - exactly the layout mousectl documents.

Register sweep
--------------
0x880 PERF (polling)
raw: 82 00 82 ff
2.4G = 0x82 -> 4000 Hz
[1] = 0x00
cable = 0x82 -> 4000 Hz
[3] = 0xff

0x884 SENSOR (lift-off, motion sync)
raw: 01 01 01 00
lift-off index = 1 (MV scale -> 1.0 mm)
motion sync = 1 (on)
[2]=0x01 [3]=0x00

0x888 DPI X stages
raw: 90 01 20 03 b0 04 40 06 80 0c 00 19 90 65 02 00
stages = [400, 800, 1200, 1600, 3200, 6400, 26000]
stage count byte [14] = 2 -> 3 enabled [15] = 0x00

0x898 DPI active stage
raw: 01 00 02 01
active stage = 1 (0-based) -> the second stage

0x8C0 TIMING (debounce, sleep, flags)
raw: 04 04 78 03
press debounce = 4 -> 16 ms
release debounce = 4 -> 16 ms
sleep timeout = 120 min
flags = 0x03 (bit0 set = angle snap OFF, bit1 set = ripple OFF)

0x8C4 ANGLE
raw: 00 00 01 00
sensor angle = 0 deg

0x8C8 DPI Y stages
raw: 90 01 20 03 b0 04 40 06 80 0c 00 19 90 65 02 00
identical to 0x888

battery query (cmd 0xAA at address 0)
01 01 62 00 00 00 00 00
[0]=0x01 status OK [1]=0x01 battery-answer marker [2]=0x62 = 98 %

What this settles
-----------------
* The 1st-gen configuration channel works. It was never a firmware dead end,
only a missing API in the browser.
* The register map is byte-for-byte the same family layout the 2nd-gen
official driver documents: profile 0 base 0x600 plus offsets
640 / 644-645 / 648-662 / 664 / 704-707 / 708 / 712.
* Polling-rate codes match: 0x08=125 0x04=250 0x02=500 0x01=1000
0x84=2000 0x82=4000 0x81=8000 Hz.
* Battery answers are distinguishable from block answers by byte[1].

Write path (-writetest, idempotent: writes back exactly the bytes just read)
--------------------------------------------------------------------------
== WRITE TEST (idempotent: writes back exactly the bytes it just read)
before : 82 00 82 ff
write : busy->OK, command 0xA5 accepted
after : 82 00 82 ff
verdict: read-back matches - the read/write channel is confirmed

So both directions work: command 0xA4 to read, 0xA5 to write, both answered
through GET_REPORT(Input) with the busy -> OK handshake. The device state is
unchanged because the written bytes were the ones already stored.

Wired pass (same day, cable plugged in, receiver still attached)
---------------------------------------------------------------
With the cable in, Windows keeps BOTH devices: 12 HID collections,
6 for pid 0x1205 and 6 for pid 0x4405, identical layout per device:

0x000C:0x0001 in=3
0x0001:0x0080 in=2
0xFF00:0x000E in=32 out=32 <-- the 0xBA config channel
0xFF00:0x0002 in=7 out=7
0xFF00:0x0002 in=10 out=10
0xFF0B:0x0104 in=61 out=61 feat=61

Once the cable takes over, the receiver (0x1205) stops answering the 0xBA
channel and the wired device (0x4405) answers it. Register values are
byte-for-byte identical to the wireless pass:

0x880 82 00 82 ff -> 4000 Hz on both slots
0x884 01 01 01 00 -> LOD index 1, motion sync on
0x888 90 01 20 03 b0 04 40 06 80 0c 00 19 90 65 02 00
0x898 01 00 02 01 -> active stage 1 (0-based)
0x8C0 04 04 78 03 -> 16/16 ms debounce, 120 min sleep, flags 0x03
0x8C4 00 00 01 00 -> 0 deg
0x8C8 same as 0x888

A 0x2A status block is readable on the 0xFF0B:0x0104 interface of BOTH
devices with HidD_GetFeature, even while the 0xBA channel is silent:

pid=0x4405 -> 2a 01 00 00 04 21 50 00 00 62 ...
pid=0x1205 -> 2a 01 00 00 04 11 20 00 00 62 ...
^^ ack ^^^^^^^^^^ differ ^^ 0x62 = 98 %

The connection byte is NOT a discriminator (measured, not assumed)
------------------------------------------------------------------
mousectl uses 0xFF for wired and 0xA5 for the dongle. On this mouse both
answer, as do 0x00 / 0x01 / 0x5A. Measured with the cable plugged in,
4-byte read at 0x898, 8 attempts per candidate:

conn 0xFF: 2/8 answered
conn 0xA5: 3/8 answered
conn 0x00: 2/8 answered
conn 0x01: 4/8 answered
conn 0x5A: 3/8 answered

So the field is not validated on this model. Use 0xA5, and never read a
failure as "wrong connection type".

The channel is lossy, and it needs an awake mouse
-------------------------------------------------
* Per-attempt success is roughly 40 %: frames are dropped without the device
ever going busy. Reads need retries - four rounds of four sends with a
120 ms gap read the whole map reliably (three wired runs + one wireless
run all produced the same table).
* Five back-to-back reads of the same register returned 1/5 and 0/5 in two
runs, and the very next sweep then read every address. "No answer" is not
"unsupported register".
* When the mouse sits idle the 0xBA channel stops answering entirely, while
HidD_GetFeature(0x2A) keeps returning the status block on both devices.
Move the mouse and re-run.
164 changes: 164 additions & 0 deletions docs/rapoo-vt9-pro-testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Rapoo VT9 Pro (1st gen) protocol

Two independent sources describe the same channel, and they agree byte for
byte:

- a capture of a real **VT9 Pro (1st gen)** on 2026-09-29, on its 2.4 GHz
receiver (`0x24AE:0x1205`) and again on the cable (`0x24AE:0x4405`). The raw
text is in `captures/rapoo-vt9-pro/sweep-2026-09-29.txt`;
- mousectl's `rapoo_vt3pro` driver, reverse engineered from Rapoo's own
`RapooGameDevDriver` **1.6.29** - the same installer build the capture was
taken with. It documents the same frame and the same offsets for the VT3 PRO.
`rapoo-software-linux`'s `PROTOCOL.md`, taken from A HUB 1.0.19, describes a
third Rapoo generation whose addresses line up with these after subtracting
the profile-0 base of `0x600`.

Everything below is **measured** on that one mouse unless it says otherwise.

## Transport

The configuration channel is the `0xFF00:0x000E` collection: a 31-byte output
report `0xBA` in both directions (`HidP_GetCaps` calls it 32 because it counts
the report id; the report *data* is what the transport is handed, and that is
31). The answer does **not** arrive as an
interrupt-IN report - it is fetched with `GET_REPORT(Input)`, which is
`HidD_GetInputReport` on Windows, `HIDIOCGINPUT` on Linux hidraw, and
`receiveInputReport` in OpenMouse Bridge.

That single detail explains the earlier impression that this mouse had no
readable protocol: **WebHID has no `GET_REPORT(Input)`**, so from a browser
every frame is delivered, the answer is dropped by the browser, and the read
looks like a timeout. Nothing about the device is broken.

```
request [0] connection byte [1] command [2] payload length
[3..6] address, u32 little endian [7..] data

answer [0] status, 0x01 when answered [1] 0 for a block, non-zero for battery
[2] battery percentage on a battery answer [4..] the block
```

Windows strips the report id from the buffer it returns and Linux hidraw keeps
it, so every consumer has to accept both - mousectl does, and so does this
repository.

Commands: `0xA4` read, `0xA5` write, `0xAA` battery. The connection byte is
*not* validated: `0xFF` and `0xA5` both answered, as did `0x00`, `0x01` and
`0x5A` (8 attempts each, 2-4 answered). A dropped frame therefore never means
"wrong connection type".

## Register map

Profile 0's base of `0x600` plus the offsets the vendor driver reads. The two
links returned identical bytes.

| Address | Bytes | Meaning |
|---|---|---|
| `0x880` | `82 00 82 ff` | polling rate: `[0]` on 2.4 GHz, `[2]` on the cable, both `0x82` = 4000 Hz |
| `0x884` | `01 01 01 00` | `[0]` lift-off selector = 1, `[1]` motion sync = on |
| `0x888` | `90 01 20 03 b0 04 40 06 80 0c 00 19 90 65 02 00` | seven little-endian DPI stages = 400/800/1200/1600/3200/6400/26000, `[14]` = 2 |
| `0x898` | `01 00 02 01` | active stage index 1 (0 based) |
| `0x8C0` | `04 04 78 03` | press/release debounce index 4 = 16 ms, sleep 120 min, flags `0x03` |
| `0x8C4` | `00 00 01 00` | sensor angle 0 degrees |
| `0x8C8` | same as `0x888` | DPI Y, byte-identical to X in both runs |

Polling-rate codes, from the vendor driver's own switch table and confirmed
against `0x880`: `0x08` 125, `0x04` 250, `0x02` 500, `0x01` 1000, `0x84` 2000,
`0x82` 4000, `0x81` 8000. The VT3 PRO profile has `support8k = false`, and the
highest rate this mouse was seen offering is 4000 Hz.

Debounce is stored as an index into `[1, 2, 4, 8, 16, 24, 32]` ms. The flag
byte at `0x8C0[3]` holds *disable* flags: bit 0 set means angle snap is off,
bit 1 set means ripple control is off.

## Battery

Two independent readings, both confirmed at 98% on a fully charged mouse:

- the `0xAA` answer, `01 01 62 ...` - byte 1 is the charge marker (1
discharging, 2 charging, per the vendor driver's table) and byte 2 the
percentage. It does not follow the busy -> OK handshake, because the value is
already sitting in the device's input buffer;
- the `0x2A` **feature** report on `0xFF0B:0x0104`, `2a 01 00 00 04 21 50 00 00 62`
on the cable and `2a 01 00 00 04 11 20 00 00 62` on the receiver - the two
links differ at byte 5, and byte 9 of the report data is the percentage. This
one is readable from a browser, because `receiveFeatureReport` does exist in
WebHID.

The mouse also pushes input report `0xBB` unprompted about every 3 seconds:
`b0 51 20 03 01 62`. Byte 4 was `0x01` on the receiver in every observation and
byte 5 tracks the percentage (`62` at 98%, `64` at 100%). The report stopped
entirely while the mouse was idle and resumed when it was moved.

## What the link is like

Three properties were measured rather than assumed, and each one had already
misled a reading taken without it:

- **the channel is lossy.** Roughly 40% of frames are dropped without the
device ever going busy. Five back-to-back reads of one register returned 1/5
and 0/5 in two runs, and the sweep immediately after read every address.
"No answer" is not "no such register";
- **an OK only counts after a busy.** The input buffer holds the previous
command's answer until the receiver picks the new frame up, so an OK that was
not preceded by a busy is stale data. The handshake is: status other than
`0x01` (busy) first, then `0x01` with fresh bytes, and a frame that produced
no busy within 120 ms was dropped and is sent again. Four rounds of four
sends, with 120 ms between rounds, read the whole map on four of four runs;
- **an idle mouse stops answering `0xBA` entirely**, while the `0x2A` feature
read keeps returning the status block. Move the mouse and the channel comes
back.

Reading feature report **`0x2B` disconnects the mouse** until the cable is
pulled and re-inserted. It is never read by this driver, and anything built on
this protocol should keep it that way.

With the cable in, Windows keeps *both* devices present - twelve HID
collections, six for each product id, with an identical layout - and the cable
takes the channel over: once it is attached, the receiver stops answering
`0xBA`.

## Not established

- the scale the lift-off selector indexes. The vendor bundle carries both a
1.0-2.0 mm and a 0.7-1.7 mm ladder, so this repository reads the code and
makes no Low/Medium/High claim;
- whether any Rapoo in this family offers 8000 Hz. The code decodes `0x81`, but
nothing offers it as a choice;
- the DPI floor and ceiling as the vendor tool enforces them - only the seven
values this mouse stores were read;
- the meaning of `0x880[1]` and `[3]`, `0x884[2..3]`, `0x8C4[1..3]`,
`0x898[1..3]`, the `0x888[15]` byte that follows the stage count, the `0x2A`
block beyond its battery byte, and `0xBB` bytes 2 and 3. All of them are
preserved untouched by the codec rather than interpreted;
- whether a write survives unplugging the mouse. Writes work - an idempotent
write of the bytes just read was accepted with the same busy -> OK handshake
and read back unchanged - but no setting has been changed and re-read across
a power cycle.

## What this repository implements

`src/rapoo/` is the transport-independent codec: frame encoding, answer and
block decoding, and the register tables above. `src/drivers/rapoo/hid.ts` is
read-only, and has two shapes on purpose:

- in a plain browser it reports the battery from the `0x2A` feature block and
says plainly that the rest needs a native transport;
- with `receiveInputReport` available it walks the register map and reports
DPI, polling rate, motion sync, debounce, sleep and the sensor flags as
verified values.

Nothing writes yet. This is a block read-modify-write protocol with no partial
update, so a setter has to read a block, edit the decoded object, write it back
and read it again - which needs the same `receiveInputReport` the reads need.

## Re-verifying

On Windows, `HidD_GetInputReport` on the `0xFF00:0x000E` handle; on Linux,
`HIDIOCGINPUT` on the hidraw node, for example with mousectl's own driver,
which speaks the same protocol to the VT3 PRO. Send
`A5 A4 04 80 08 00 00` + 24 zero bytes as output report `0xBA`, poll the input
report for a status other than `0x01` followed by `0x01`, and expect
`82 00 82 ff` from byte 4 on. The capture in `captures/rapoo-vt9-pro/` is the
full transcript of that, on both links, including the register sweep and the
idempotent write.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,10 @@
"types": "./dist/ryunix/index.d.ts",
"import": "./dist/ryunix/index.js"
},
"./rapoo": {
"types": "./dist/rapoo/index.d.ts",
"import": "./dist/rapoo/index.js"
},
"./delux": {
"types": "./dist/delux/index.d.ts",
"import": "./dist/delux/index.js"
Expand Down
2 changes: 1 addition & 1 deletion src/drivers/mouse-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ export interface AtkReceiverInfo {
}

export interface MouseStatus {
brand: "RAWM" | "Motospeed" | "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "VAXEE" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE" | "K-snake" | "Noir Gear" | "Lingbao" | "GearHub" | "Corsair" | "Microsoft" | "Dareu" | "Redragon" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Delux" | "GravaStar" | "IPI";
brand: "RAWM" | "Motospeed" | "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "VAXEE" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE" | "K-snake" | "Noir Gear" | "Lingbao" | "GearHub" | "Corsair" | "Microsoft" | "Dareu" | "Redragon" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Delux" | "GravaStar" | "IPI" | "Rapoo";
name: string;
/** Driver-supplied UI policy (optional; keeps control.ts brand-agnostic). */
ui?: MouseUiHints;
Expand Down
Loading
Loading