diff --git a/captures/roccat-kone-pro-air/README.md b/captures/roccat-kone-pro-air/README.md new file mode 100644 index 00000000..7fb08f4c --- /dev/null +++ b/captures/roccat-kone-pro-air/README.md @@ -0,0 +1,57 @@ +# ROCCAT Kone Pro Air fixtures + +Hardware reference material for `src/roccat/` and `src/drivers/roccat/`. +Captured from this reporter's own unit — one ROCCAT Kone Pro Air on its +Stellar Wireless dongle (USB `1e7d:2c8e`), Windows. The first session +(2026-10-14) was a driverless node-hid session with ROCCAT Swarm +uninstalled; a later session (2026-10-16, the last file in the table) ran +with Swarm installed and operating the mouse. The mouse's product string +carries no serial number, the vendor frames carry no account data, and no +capture here includes personal data. + +| File | What it is | +|-|-| +| `device-enumeration.txt` | The four HID interfaces and their usage pages/uses as Windows presents them (node-hid + PnP). The config channel is interface 2, `0xFF00:0x0001`. | +| `feature-sweep.txt` | The negative result: GET for every report id 0x00–0xFF on every interface times out or answers zero. The whole protocol lives on output reports, which is why the frames are `sendReport`-shaped. | +| `session-2026-10-14.txt` | A byte-precise transcript: status probe, settings query, the vendor-replayed settings write + commit tail, a second query, the lighting write, and the echoes to each. One session earlier the query echo carried a full stage table (the mouse awake); in this session the mouse was idle and the same frame echoed an empty body — both are in the file. | +| `transcript-earlier-session.txt` | The earlier, still byte-precise outline of the same frames from the discovery session, included because it is the capture where the stage table WAS returned (`10 50 03 … 10 10 18 18 20 20 40 40 64 64 01 02 00 00` — both slot lists identical, five entries 800/1200/1600/3200/5000, which matches this unit's then-current Swarm settings rather than factory defaults). | +| `profiles-and-write-path-2026-10-16.txt` | A second session, this one with Swarm installed and driving the mouse. Identifies the five `90 00 26` reply pages as the five onboard profiles (polling at `b11`, stage table at `b14-23`, lift-off at `b24`), shows a UI polling change moving exactly that byte, aligns the read page to the plugin's write frame at +5, and records that hand-sent settings writes on interface 2 are echoed but never change the stored tables. | + +## What settles + +- **Transport.** Unnumbered 64-byte output reports on interface 2 + (`0xFF00:0x0001`; interface 1 on the wired generation). No feature report + exists on this interface — `feature-sweep.txt` proves the negative. +- **Frame shape.** `10 …` where class `0x50` is the + performance/settings group, `0x10` the lighting group, and the status + probe is the two-byte `90 0a`. Echoes come back on the same interface's + input stream and repeat byte 1 verbatim. +- **Settings write (captured, not implemented).** `10 50 14 00 01 + 00 00 80 + 00 ` followed by the commit tail `10 50 18 04` — byte-for-byte + the sequence the vendor software replays (SignalRGB's WhirlwindFX plugin is + public evidence of that replay). The frames are echoed and accepted, but no + hand-sent write ever changed a stored table read back afterwards, so the + driver ships no setter and `src/roccat/` has no encoder for them. +- **Status probe.** `90 0a` answers `90 0a 04 00 00 `: + byte 5 was 0x1e across one session and 0x14 across another. Bytes 8–9 + (0x0aa0 = 2720, later 0x0b44 = 2884) go up as well as down, so the + "estimated minutes remaining" reading stands falsified. Whether byte 5 is + the battery percent has to be settled by watching it drain. +- **Lighting write (captured, not implemented).** `10 10 0b 00 09 64 64 64 06 + ` for the two inner zones, echoed `10 10 03 …`. No read-back + is known, so nothing about lighting ships. + +## What does not settle + +- **The percent meaning of status byte 5** (see above). +- **Read-back of the settings block in the idle state.** With the mouse + idle/sleeping, the `10 50 14` query echoes with an empty body; with the + mouse awake, an earlier session returned the twenty-slot table. The decoder + treats all-zero tables as no-data instead of five zero-DPI stages. +- **Which slot list is X and which is Y.** The echo carried both lists with + identical values on the captured unit, so the assignment (even slots = + the vendor UI's stage list) is Replay-proven but sensor-axis-unproven. +- **Wired generation `2c92` parity.** The wired mode enumerates the same + interfaces one index earlier (interface 1) per the community plugin's + `Validate` guard; nothing here was captured on the cable. diff --git a/captures/roccat-kone-pro-air/device-enumeration.txt b/captures/roccat-kone-pro-air/device-enumeration.txt new file mode 100644 index 00000000..3b93f019 --- /dev/null +++ b/captures/roccat-kone-pro-air/device-enumeration.txt @@ -0,0 +1,24 @@ +# HID interface enumeration, ROCCAT Kone Pro Air Stellar Wireless dongle +# USB 1e7d:2c8e, Windows, node-hid devices() + Win32 PnP, 2026-10-14. +# The composite is "USB Composite Device"/"ROCCAT Kone Pro Air Dongle", +# four interfaces: +# +# iface 0 usage page 0x0001, usage 0x0002 -> boot mouse (pointer) +# iface 1 usage page 0x0001, usage 0x0006 -> keyboard-style interface +# (media keys? uninterpreted) +# iface 2 usage page 0xFF00, usage 0x0001 -> VENDOR CONTROL CHANNEL, +# unnumbered 64-byte output reports. No feature reports +# (every GET answer timed out or returned zero). +# iface 3 usage page 0x000C, usage 0x0001 -> consumer control, +# plus a second top-level collection on the same interface with +# usage page 0x0001, usage 0x0080 (system control). +# +# Wired generation (`1e7d:2c92`) is reported by the public SignalRGB plugin's +# Validate() guard as interface === 1 for the same collection shape; not +# captured on this unit. +# +# Win32 PnP hardware IDs (no serial suffix present): +# USB\VID_1E7D&PID_2C8E&MI_00 +# USB\VID_1E7D&PID_2C8E&MI_01 +# USB\VID_1E7D&PID_2C8E&MI_02 +# USB\VID_1E7D&PID_2C8E&MI_03 diff --git a/captures/roccat-kone-pro-air/feature-sweep.txt b/captures/roccat-kone-pro-air/feature-sweep.txt new file mode 100644 index 00000000..f316de06 --- /dev/null +++ b/captures/roccat-kone-pro-air/feature-sweep.txt @@ -0,0 +1,12 @@ +# GET (feature report) sweep — negative result +# Interface 2 (0xFF00:0x0001), node-hid, Windows, 2026-10-14. +# +# For every report id 0x00..0xFF a 64-byte getFeatureReport was issued +# (0.4 s each). Every call threw "timed out" or returned nothing readable. +# Interfaces 0 and 1 behave the same. +# +# Conclusion: the whole protocol is output-report + input-stream based. +# The WebHID driver writes sendReport(0, 64B) and matches echoes on the +# inputreport event, echoing the frame's byte 1 (class byte). +# Sweep transcript shape (all ids identical): +# GET id 0x00 .. 0xFF -> no readable data diff --git a/captures/roccat-kone-pro-air/profiles-and-write-path-2026-10-16.txt b/captures/roccat-kone-pro-air/profiles-and-write-path-2026-10-16.txt new file mode 100644 index 00000000..dad32bf5 --- /dev/null +++ b/captures/roccat-kone-pro-air/profiles-and-write-path-2026-10-16.txt @@ -0,0 +1,99 @@ +# Per-profile read pages and the write path (2026-10-16) + +Second session on the same unit, this time with ROCCAT Swarm installed and +driving the mouse, observed from a passive node-hid listener on interface 2 +while the Swarm UI was operated. It settles what the earlier captures could +not: where the settings block lives in the reply stream, and what our +user-mode writes actually do to the mouse. + +## The five `90 00 26` reply pages are the five onboard profiles + +Swarm exposes five profile slots ("配置文件组1"…"配置文件组5"). When the +device page attaches, the mouse streams five `90 00 26` replies, one per +profile. Fields, aligned on the page's own header `90 00 26 00 00 04 20 00 +92 2c`: + +```text +b10 profile index (01..05) +b11 that profile's polling code (00=1000 Hz .. 03=125 Hz, same dict as the write) +b12 00 +b13 00 empty / 01 valid / 02 the profile the UI is editing +b14-23 five DPI stages, u16 little-endian, 50/unit (e.g. 08 00 = 400 DPI) +b24 lift-off distance code (06 = Low) +b25 00 +b26 80 / 06 / 00 (profile-dependent; unresolved) +b27 LED timeout minutes (0f = 15) +``` + +Captured order (1000 Hz era, mouse awake, profiles 2..5 configured, profile 1 +blank): + +```text +90 00 26 00 00 04 20 00 92 2c 02 00 00 02 08 00 10 00 15 00 20 00 40 00 06 00 06 0f ... +90 00 26 00 00 04 20 00 92 2c 03 00 00 01 08 00 10 00 18 00 20 00 40 00 06 00 00 0f ... +90 00 26 00 00 04 20 00 92 2c 04 00 00 01 08 00 10 00 18 00 20 00 40 00 06 00 00 05 ... +90 00 26 00 00 04 20 00 92 2c 05 00 00 01 08 00 10 00 18 00 20 00 40 00 06 00 00 05 ... +90 00 26 00 00 04 20 00 92 2c 01 00 00 00 00 00 00 00 00 00 00 00 00 00 06 00 00 0f ... +``` + +The profile the UI was editing (index 2) carries the stages **400 / 800 / +1050 / 1600 / 3200** — byte-for-byte the values the UI's own "DPI 切换开关" +card displays (`400`, `800`, `1050`, `1600`, `3200`). The 1050 is the unit +owner's own value, not a side effect of any probe. + +## UI edits land in these pages (one-session differential) + +With the listener running, the UI was used to switch the edited profile's +polling rate 1000 → 125 Hz. Restarting Swarm re-triggered the stream, and the +same profile's page changed exactly one byte: + +```text +before: 90 00 26 00 00 04 20 00 92 2c 02 00 00 02 08 00 10 00 15 00 20 00 40 00 ... +after: 90 00 26 00 00 04 20 00 92 2c 02 03 00 02 08 00 10 00 15 00 20 00 40 00 ... +``` + +`b11` 00 → 03 (125 Hz). That identifies the polling field and proves the +vendor's own write path reaches the mouse. + +## Read page vs. write frame: a +5 byte shift + +Lining the page up with the SignalRGB plugin's write frame (`Plugins/Roccat/ +Roccat_Kone_Pro_Air_Mouse.js`) puts every established field at write offset ++ 5: + +```text +write frame 10 50 14 00 01 00 00 80 00 +read page 90 00 26 00 00 04 20 00 92 2c 00 00 ... +``` + +So the stored settings block is the plugin's layout, five u16 stage slots +(not two interleaved byte lists), and the page is that block shifted right by +five bytes behind a ten-byte read header. + +## What our own writes did + +- Frames sent by hand on interface 2 — the plugin layout with stage 1 set to + 500 (u16 `0a 00`) and, separately, the two-interleaved-lists shape — were + **accepted and echoed** (`50 14 03 00 ec fa`), but a fresh stream after each + showed **no profile's stage 1 at 500**. The stored tables were unchanged. +- On this unit the vendor writes are not visible on any HID interface at all: + a listener on interfaces 0/1/2/3 during UI edits captured the UI's read + polling loops (`10 71 03`, `10 72 03`, `10 11 03`, `10 50 03`) but no + settings write on any of them. +- The vendor's device DLL (`data\2C8E\2C8E.dll`) is a hidapi fork whose + `hid_write` is called in exactly four places, all in the calibration + module; there is no settings-write code in it. + +Read together: on this unit a settings write from a plain user-mode HID +handle is acknowledged but not applied, and the vendor applies settings +through a channel a user-mode capture cannot see. The driver's write frames +are therefore best described as **unverified on hardware**, not verified. + +## Backing files + +Raw listener logs from this session (not shipped here): the awake stream, the +125 Hz differential, and the status probes. The status probe while the mouse +was asleep answers `90 0a 03 00 ec fa 0d 00` with an all-zero body, versus +`90 0a 04 00 00 64 …` when awake — the same empty/no-data dichotomy the +settings echo shows, and why the Swarm device page sits on its loading +spinner and shows placeholder 0 % battery until the mouse answers. diff --git a/captures/roccat-kone-pro-air/session-2026-10-14.txt b/captures/roccat-kone-pro-air/session-2026-10-14.txt new file mode 100644 index 00000000..a53d53a1 --- /dev/null +++ b/captures/roccat-kone-pro-air/session-2026-10-14.txt @@ -0,0 +1,47 @@ +# Probe session transcript — byte-precise, 2026-10-14 (mouse idle this time) +# node-hid, dongle interface 2. OUT lines are the 64-byte output report payload +# as written (the leading 0x00 is the unnumbered report id node-hid consumes); +# IN lines are the interrupt-IN echo, trailing zero bytes trimmed. +# +# The mouse was NOT moved during this session (idle). Note the settings +# query echoing an EMPTY body — with the mouse awake an earlier session +# returned the full table (see transcript-earlier-session.txt). + +== 1. current settings query == +OUT 00 10 50 14 +IN 10 50 03 00 ec fa 04 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + +== 2. status probe == +OUT 00 90 0a +IN 90 0a 04 00 00 14 01 00 44 0b 1c 00 00 00 00 00 00 00 00 00 00 00 00 00 + +== 3. settings write (axis-slot pair: even 16/24/32/64,100 odd 8/16/24/32,64) == +OUT 00 10 50 14 00 01 00 00 00 10 08 18 10 20 18 40 20 64 40 06 00 80 0f 00 0a +IN 10 50 03 00 ec fa 04 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + +== 4. commit tail == +OUT 00 10 50 18 04 +IN 10 50 03 00 ec ac 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + +== 5. settings query after the round trip == +OUT 00 10 50 14 +IN 10 50 03 00 ec fa 04 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + +== 6. lighting write + status == +OUT 00 10 10 0b 00 09 64 64 64 06 00 00 00 00 00 00 +IN 10 10 03 00 ec ac 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +OUT 00 90 0a +IN 90 0a 04 00 00 14 01 00 45 0b 1c 00 00 00 00 00 00 00 00 00 00 00 00 + +== 7. restore (the mouse's idle state had presented an empty table, so the + restore wrote the same empty-table shape back) == +OUT 00 10 50 14 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 06 00 80 0f 00 0a +IN 10 50 03 00 ec ac 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +OUT 00 10 50 18 04 +IN 10 50 03 00 ec ac 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +OUT 00 10 50 14 +IN 10 50 03 00 ec fa 04 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + +# Post-session note: bytes 8..9 of the status echo (0x0b44 = 2884) had INCREASED +# since the discovery session (0x0aa0 = 2720), falsifying the "estimated minutes +# remaining" reading. Byte 5 moved 0x1e -> 0x14; its meaning is unestablished. diff --git a/captures/roccat-kone-pro-air/transcript-earlier-session.txt b/captures/roccat-kone-pro-air/transcript-earlier-session.txt new file mode 100644 index 00000000..0caa2317 --- /dev/null +++ b/captures/roccat-kone-pro-air/transcript-earlier-session.txt @@ -0,0 +1,29 @@ +# Discovery session outline — module-earlier capture (mouse awake) +# Same node-hid shape as session-2026-10-14.txt, from the session during which +# the settings query DID return the twenty-slot stage table. Frames trimmed the +# same way (trailing zeros cut). Numeric outline of the session, in order: +# +# OUT 00 90 0a +# IN 90 0a 04 00 00 1e 00 00 a0 0a 1e (first poll) +# IN 90 0a 04 00 00 1e 00 00 a0 0a 1e (second poll) +# OUT 00 10 50 14 00 01 ... (settings write) +# IN 10 50 03 00 ec fa 04 (write echo) +# OUT 00 10 50 18 04 (commit tail) +# IN 10 50 03 00 ec ac (commit echo) +# OUT 00 90 0a +# IN 90 0a 04 00 00 1e 00 00 9a 0a 1e (post-write poll) +# OUT 00 10 10 0b 00 09 64 64 64 06 00 00 00 00 00 00 00 +# IN 10 10 03 00 ec ac (lighting echo) +# +# The discovery session's settings QUERY (a distinct, later probe) echoed: +# IN 10 50 03 00 ec fa 0d 00 10 10 18 18 20 20 40 40 64 64 01 02 00 00 +# +# decoded: byte2=0x03, both slot lists identical — +# offsets 8..17 = 10 10 18 18 20 20 40 40 64 64 +# = five entries of 800/1200/1600/3200/5000 (dpi/50) x 2 axes, +# matching the unit's THEN-current Swarm settings, not the factory: +# evidence that the echo table reflects live on-device state. +# +# Later idle-session query echoes only returned the empty body shape +# (`10 50 03 00 ec fa 04` then zeros), so the table's appearance is +# mouse-state-dependent. diff --git a/docs/roccat-kone-pro-air-testing.md b/docs/roccat-kone-pro-air-testing.md new file mode 100644 index 00000000..b9b71f1f --- /dev/null +++ b/docs/roccat-kone-pro-air-testing.md @@ -0,0 +1,156 @@ +# ROCCAT Kone Pro Air testing + +How the `src/roccat/` protocol and the `src/drivers/roccat/` driver were put +together, what was proven on hardware, and what stays open. The captures live +in `captures/roccat-kone-pro-air/`. + +**The driver is read-only.** It reads the stored DPI stage table and reports +what it could verify; it ships no setter. The reason is in "Write path" below: +a user-mode settings write to this unit is acknowledged without being applied. +The protocol bytes for the write are transcribed here for whoever settles it, +but no encoder for them exists in `src/roccat/`. + +## Device + +One ROCCAT Kone Pro Air, firmware as shipped, wireless mode on its Stellar +Wireless dongle (`1e7d:2c8e`). The first capture session ran with ROCCAT +Swarm uninstalled, so the bytes there are probes and vendor-replayed frames. +A second session (`captures/roccat-kone-pro-air/profiles-and-write-path-2026-10-16.txt`) +ran with Swarm installed and driving the mouse, to watch the vendor path from +outside. Wired mode (`1e7d:2c92`) is mapped from the same protocol family but +was not exercised on this unit. + +## Transport + +The config channel is the composite's vendor interface — interface 2 of the +dongle (interface 1 wired), usage page `0xFF00` usage `0x0001` — and it +speaks plain **unnumbered 64-byte output reports**. Windows GET sweeps for +every report id answer nothing on any interface +(`captures/roccat-kone-pro-air/feature-sweep.txt`), so everything below is +`sendReport(0, …)`-shaped, and the mouse answers on the same interface's +interrupt-IN stream. On Windows node-hid the echo arrived on the same handle. +A WebHID `inputreport` listener on the same device object matches it the +same way. + +Frame vocabulary, all in device coordinates (the vendor's 64-byte frames, +report id excluded): + +```text +10 50 14 settings query (no args) — implemented +90 0a status probe — implemented +10 50 14 00 01 00 00 80 00 settings write — not implemented +10 50 18 04 commit tail — not implemented +10 10 0b 00 09 64 64 64 06 lighting write (two zones) — not implemented +``` + +Echoes repeat frame byte 1 (the class byte) verbatim, which is how the driver +matches replies. Byte 2 of performance echoes was `0x03` every time; the +echo's later bytes are not established. + +Polling codes are the **opposite** of the Burst Core dict +(`Roccat_Burst_Core_Mouse.js`) used on ROCCAT's older OEM mice: here +125 Hz → `0x03`, 1000 Hz → `0x00`. The second session confirmed the mapping +from the other direction: switching the edited profile's polling to 125 Hz in +Swarm moved exactly the polling byte of that profile's reply page to `0x03`. + +The settings block is the **SignalRGB plugin's layout**: polling at offset 6, +one axis of five u16 little-endian DPI slots at offsets 9–18 (50/unit), lift +off at 19, `80` at 21, LED timeout at 22, debounce at 24. The mouse's own +reply pages line up with that block shifted right five bytes — polling at the +page's `b11`, the five u16 stages at `b14-23`, lift off at `b24` — and the +five `90 00 26` pages are the five onboard profiles, `b10` being the profile +index (see the 2026-10-16 capture). The earlier guess of two interleaved byte +lists at offsets 8..17 with lift off at 18 did not reproduce against the +mouse's stored tables and is superseded. + +## What is verified on hardware + +- **The settings query reads the stored stage table** once the mouse is awake, + and an asleep mouse answers with an empty body that the decoder treats as no + data. **The settings write is not in that list.** The vendor-replayed frame + and its commit tail are accepted and echo the expected class frame + (`10 50 03 …`), but acceptance is all the echo shows: no settings write sent + by hand — this plugin layout or the earlier interleaved guess — ever changed + a stored stage table in a reply read back afterwards. See "Write path". +- **The mouse's reply pages are the five onboard profiles**, decoded in the + 2026-10-16 capture: polling at `b11`, five u16 stage slots at `b14-23`, + lift-off at `b24`, LED timeout at `b27`, profile index at `b10`, and a + status byte at `b13` (00 empty / 01 valid / 02 being edited). The UI's own + DPI card and polling radios line up with those fields byte for byte. +- **The status probe answers every poll** (`90 0a 04 00 00 00 + ` when awake; `90 0a 03 00 ec fa 0d 00` with an all-zero body + while the mouse is asleep). Byte 5 tracked Swarm's own battery log across + sessions — `0x5a` read "battery level: 90", `0x64` read "battery level: + 100" — so it is the battery percent; the trailing u16 still only moves up in + a session and has no confirmed meaning. The driver still surfaces no + battery reading: the success runs are from Swarm's log rather than from this + capture, and the asleep body must not be read as 0 %. +- **The lighting write is idempotent** on repeat (three identical writes + returned identical answers), and leaves the mouse's LEDs stable. There is no + known read-back, so nothing about lighting ships — and the 2026-10-16 reply + pages also carry per-zone lighting frames (`90 01 0b …`), which may or may + not be a usable read-back and are not decoded. + +## Deliberately not shown on the UI + +Polling rate, lift-off, angle snapping, debounce and battery are reported as +unknown rather than filled in from a cache: no echo position for the first +four is established, so there is no read to report, and the driver ships no +setter, so there is no write to remember. The polling control is marked +read-only instead of disabled-with-stale-value. + +The **current DPI** and the **active DPI stage** are unknown for the same +reason. The settings reply carries the five stored slots of each axis +(offsets 8..17) and no byte naming the stage in use, so `stages[0]` is a stored +preset, not the DPI the mouse is applying — it is only the first slot. The +driver therefore reports `dpiStages` / `dpiStagesY` as the table it read, and +leaves `dpi`, `dpiY`, `activeDpiStage` and `valuesVerified` unset. Reporting +`stages[0]` as the current DPI would show a stored preset as a measured value, +which is the same class of mistake as the setters that were held back. + +## Write path + +**Not implemented.** The frame is known — it replaces the whole block, so any +setter would have to read the mouse's current table first, patch one field, +write, commit and read the table back. That machinery is the right shape and +the codec's decoder is the read half of it, but nothing below the line was +ever observed to work, so no encoder for it ships. + +On the 2026-10-16 session, frames sent by hand on interface 2 with stage 1 set +to 500 were accepted and echoed, but the reply pages afterwards still read the +stored stage 1 at 400 in every profile, and a listener on all four interfaces +during Swarm edits saw the vendor's read polls but no settings write on any of +them. The vendor's device DLL is a hidapi fork with no settings-write code — +its `hid_write` is used only by the calibration module. The honest reading is +that a user-mode HID write to this unit is acknowledged but not applied, and +the vendor applies settings over a channel a user-mode capture cannot see. + +Two consequences shaped the ship/no-ship call: + +- A whole-block write that is not applied still costs the user their settings. + With no read-back that establishes the other fields (the query returns the + stage table only), a polling-rate change would have to send defaults for + lift-off, LED timeout, debounce and angle snapping — replacing values it + never read. +- Acceptance is not confirmation. A setter that reports success after an echo, + or worse after an unreadable read-back, tells the user their mouse changed + when the capture says it did not. + +The read side is unaffected: the query, the decoder and the DPI table are all +backed by captures, and the driver reports only those. + +DPI is capped at 12,750 (255 × 50), the largest value the single slot byte can +carry; the vendor software's higher numbers were never captured and do not fit +this byte. + +## Open questions (any owner can settle these with a replug) + +- Why a user-mode settings write is acknowledged but does not apply, and + which channel the vendor uses instead (the 2026-10-16 capture narrows it to + "not any HID interface a user-mode listener can open"). +- Which of the five u16 stage slots is sensor X and which is Y, if the unit + keeps separate axes at all — the reply pages print one list. +- `b13`'s exact states and `b26`'s values in the reply pages. +- The wired generation's parity: interface index shifts by one per the + community plugin; nothing was captured on the cable. +- Whether the per-zone lighting replies (`90 01 0b …`) are a usable read-back. diff --git a/package.json b/package.json index 63308fcd..d008961b 100644 --- a/package.json +++ b/package.json @@ -233,6 +233,10 @@ "./hitscan": { "types": "./dist/hitscan/index.d.ts", "import": "./dist/hitscan/index.js" + }, + "./roccat": { + "types": "./dist/roccat/index.d.ts", + "import": "./dist/roccat/index.js" } }, "scripts": { diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index 2ca10ea0..835f8104 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -204,7 +204,7 @@ export interface MagneticCalibrationProgress { } export interface MouseStatus { - brand: "RAWM" | "Motospeed" | "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "LunaFury" | "Vancer" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "VAXEE" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE" | "K-snake" | "Noir Gear" | "Lingbao" | "GearHub" | "AJAZZ" | "Corsair" | "Microsoft" | "Dareu" | "Redragon" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Delux" | "GravaStar" | "IPI" | "Rapoo" | "Fater" | "Cooler Master" | "AJAZZ" | "Scyrox" | "Hitscan"; + brand: "RAWM" | "Motospeed" | "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "LunaFury" | "Vancer" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "VAXEE" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE" | "K-snake" | "Noir Gear" | "Lingbao" | "GearHub" | "AJAZZ" | "Corsair" | "Microsoft" | "Dareu" | "Redragon" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Delux" | "GravaStar" | "IPI" | "Rapoo" | "Fater" | "Cooler Master" | "AJAZZ" | "Scyrox" | "Hitscan" | "ROCCAT"; name: string; /** Optional LunaFury controls, populated only after successful device reads. */ lunafury?: import("../lamzu/lunafury.js").LunaFurySettings; diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 8b84373a..8d568350 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -95,10 +95,11 @@ import { RapooHidClient } from "./rapoo/hid.ts"; import { CoolerMasterHidClient } from "./coolermaster/hid.ts"; import { AjazzHidClient } from "./ajazz/hid.ts"; import { AjazzAk820HidClient } from "./ajazz/ak820-hid.ts"; +import { RoccatHidClient } from "./roccat/hid.ts"; import { HitscanHidClient } from "./hitscan/hid.ts"; export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient; -export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggOtHidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | Keychron8kHidClient | Keychron1kHidClient | Keychron4kHidClient | Keychron8kNordicHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | CorsairBragiHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | GWolvesXviNewHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | SteelSeriesSenseiRivalProHidClient | SteelSeriesSensei310HidClient | SteelSeriesPrimeMiniHidClient | SteelSeriesRival5HidClient | SteelSeriesRival3Gen2HidClient | SteelSeriesRival3WirelessV2HidClient | SteelSeriesAerox3WirelessGen2HidClient | SteelSeriesRival600HidClient | SteelSeriesRival100HidClient | SteelSeriesRival110HidClient | SteelSeriesRival300HidClient | SteelSeriesSenseiRawHidClient | GloriousHidClient | GloriousClassicHidClient | GloriousCore2HidClient | GloriousSinowealthHidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | RedragonM690ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient | AjazzHidClient | AjazzAk820HidClient | ScyroxHidClient | HitscanHidClient; +export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggOtHidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | Keychron8kHidClient | Keychron1kHidClient | Keychron4kHidClient | Keychron8kNordicHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | CorsairBragiHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | GWolvesXviNewHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | SteelSeriesSenseiRivalProHidClient | SteelSeriesSensei310HidClient | SteelSeriesPrimeMiniHidClient | SteelSeriesRival5HidClient | SteelSeriesRival3Gen2HidClient | SteelSeriesRival3WirelessV2HidClient | SteelSeriesAerox3WirelessGen2HidClient | SteelSeriesRival600HidClient | SteelSeriesRival100HidClient | SteelSeriesRival110HidClient | SteelSeriesRival300HidClient | SteelSeriesSenseiRawHidClient | GloriousHidClient | GloriousClassicHidClient | GloriousCore2HidClient | GloriousSinowealthHidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | RedragonM690ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient | AjazzHidClient | AjazzAk820HidClient | ScyroxHidClient | HitscanHidClient | RoccatHidClient; export interface DeviceDriver { brand: string; supports(device: HIDDevice): boolean; @@ -237,6 +238,10 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "AJAZZ", supports: (device) => AjazzAk820HidClient.isSupported(device), create: (device) => new AjazzAk820HidClient(device), score: () => 7 }, // Hitscan owns vendor id 0x3770, which no other driver claims. { brand: "Hitscan", supports: (device) => HitscanHidClient.isSupported(device), create: (device) => new HitscanHidClient(device), score: () => 7 }, + // ROCCAT shares no vendor id entry with any driver above, and its 0xFF00:1 + // output channel is claimed by product id so the wired generation can add + // its own entry without contending. + { brand: "ROCCAT", supports: (device) => RoccatHidClient.isSupported(device), create: (device) => new RoccatHidClient(device), score: () => 6 }, ]; function driverFor(device: HIDDevice): DeviceDriver | undefined { diff --git a/src/drivers/roccat/hid.test.ts b/src/drivers/roccat/hid.test.ts new file mode 100644 index 00000000..a18ab6dd --- /dev/null +++ b/src/drivers/roccat/hid.test.ts @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { RoccatHidClient } from "./hid.ts"; +import { + ROCCAT_CONFIG_USAGE, + ROCCAT_CONFIG_USAGE_PAGE, + ROCCAT_KONE_PRO_AIR_WIRELESS_PID, + ROCCAT_VENDOR_ID, +} from "../../roccat/index.ts"; + +/** The stage table the unit held in the awake capture, in 50-step slots. */ +const STORED_TABLE = [16, 16, 24, 24, 32, 32, 64, 64, 100, 100]; + +interface FakeOptions { + productId?: number; + usagePage?: number; + usage?: number; + /** Initial stored stage table (X and Y interleaved); defaults to the capture. */ + table?: readonly number[]; + /** When true the mouse echoes an empty (all-zero) table, i.e. a sleeping unit. */ + silent?: boolean; +} + +function fakeDevice(options: FakeOptions = {}) { + const { + productId = ROCCAT_KONE_PRO_AIR_WIRELESS_PID, + usagePage = ROCCAT_CONFIG_USAGE_PAGE, + usage = ROCCAT_CONFIG_USAGE, + } = options; + const silent = options.silent ?? false; + const stored = Uint8Array.from(options.table ?? STORED_TABLE); + const sent: Uint8Array[] = []; + let listener: ((event: HIDInputReportEvent) => void) | null = null; + const emit = (frame: Uint8Array) => { + if (!listener) return; + listener({ data: new DataView(frame.buffer, frame.byteOffset, frame.byteLength) } as unknown as HIDInputReportEvent); + }; + const device = { + vendorId: ROCCAT_VENDOR_ID, + productId, + productName: "ROCCAT Kone Pro Air Dongle", + opened: false, + collections: [{ usagePage, usage, featureReports: [], inputReports: [], outputReports: [], children: [] }], + open: async () => { (device as { opened?: boolean }).opened = true; }, + close: async () => {}, + addEventListener: (_type: string, fn: (event: HIDInputReportEvent) => void) => { listener = fn; }, + removeEventListener: () => { listener = null; }, + sendReport: async (_id: number, data: Uint8Array) => { + sent.push(new Uint8Array(data)); + // The settings query echoes the stored table, or an empty body while the + // mouse is asleep. + if (data[0] === 0x10 && data[1] === 0x50 && data[2] === 0x14) { + const frame = new Uint8Array(24); + frame.set([0x10, 0x50, 0x03, 0x00, 0xec, 0xfa, 0x0d, 0x00]); + if (!silent) frame.set(stored, 8); + emit(frame); + } + }, + } as unknown as HIDDevice; + return { device, sent }; +} + +test("isSupported needs the ROCCAT VID, a known PID and the 0xFF00 vendor collection", () => { + assert.equal(RoccatHidClient.isSupported(fakeDevice().device), true); + assert.equal(RoccatHidClient.isSupported(fakeDevice({ productId: 0x2c92 }).device), true); + assert.equal(RoccatHidClient.isSupported(fakeDevice({ usagePage: 0x01 }).device), false); + assert.equal(RoccatHidClient.isSupported(fakeDevice({ usage: 0x11 }).device), false); + assert.equal(RoccatHidClient.isSupported(fakeDevice({ productId: 0x2de6 }).device), false); +}); + +test("readStatus surfaces the stored DPI table without claiming a current stage", async () => { + const { device } = fakeDevice(); + const status = await new RoccatHidClient(device).readStatus(); + assert.equal(status.brand, "ROCCAT"); + assert.equal(status.name, "ROCCAT Kone Pro Air"); + assert.equal(status.batteryPercent, null, "the unconfirmed status byte is never surfaced as a percent"); + assert.equal(status.batteryState, "Unknown"); + assert.equal(status.connectionType, "Wireless"); + assert.deepEqual(status.dpiStages, [800, 1200, 1600, 3200, 5000]); + assert.deepEqual(status.dpiStagesY, [800, 1200, 1600, 3200, 5000]); + assert.equal(status.dpi, 0, "the reply names no active stage, so no current DPI is claimed"); + assert.equal(status.dpiY, undefined); + assert.equal(status.activeDpiStage, undefined, "a stored preset is not the stage in use"); + assert.equal(status.ui?.valuesVerified, false, "a stored table is not a verified current reading"); +}); + +test("readStatus keeps the settings grid hidden and the polling control read-only", async () => { + const { device } = fakeDevice(); + const status = await new RoccatHidClient(device).readStatus(); + assert.equal(status.ui?.settingsReady, false, "no setter ships, so the grid must not offer one"); + assert.equal(status.ui?.pollingReadOnly, true); + assert.equal(status.pollingRateHz, 0, "the echo does not decode a rate, so none is claimed"); + assert.equal(status.debounceMs, null); + assert.equal(status.angleSnapping, null); + assert.equal(status.liftOffDistance, null); +}); + +test("readStatus degrades to nothing when the mouse answers with an empty body", async () => { + const { device } = fakeDevice({ silent: true }); + const status = await new RoccatHidClient(device).readStatus(); + assert.equal(status.batteryPercent, null); + assert.equal(status.dpi, 0); + assert.equal(status.ui?.settingsReady, false); + assert.equal(status.ui?.valuesVerified, false); + assert.equal(status.dpiStages, undefined, "an all-zero echo is no data, not five stages of 0 DPI"); + assert.match(status.ui?.statusNote ?? "", /did not answer/); +}); + +test("the driver sends only the settings query and exposes no setter", async () => { + const { device, sent } = fakeDevice(); + const client = new RoccatHidClient(device); + await client.readStatus(); + assert.deepEqual(sent.map((frame) => [...frame.subarray(0, 3)]), [[0x10, 0x50, 0x14]], "one query, nothing else"); + for (const setter of [ + "setDpiStages", + "setPollingRateHz", + "setAngleSnapping", + "setLiftOffDistance", + "setDebounceMs", + "setLedTimeout", + "setLighting", + ]) { + assert.equal(setter in client, false, `${setter} must not ship while the write path is unverified`); + } +}); + +test("reads are serialized through one queue", async () => { + const { device, sent } = fakeDevice(); + const client = new RoccatHidClient(device); + await Promise.all([client.readDpiTable(), client.readDpiTable(), client.readDpiTable()]); + assert.equal(sent.length, 3); +}); + +test("wired mode reports the wired connection and still no battery", async () => { + const { device } = fakeDevice({ productId: 0x2c92 }); + const status = await new RoccatHidClient(device).readStatus(); + assert.equal(status.connectionType, "Wired"); + assert.equal(status.batteryState, "Unknown"); + assert.equal(status.connectionDetail, "Wired USB"); +}); diff --git a/src/drivers/roccat/hid.ts b/src/drivers/roccat/hid.ts new file mode 100644 index 00000000..ffeef3bd --- /dev/null +++ b/src/drivers/roccat/hid.ts @@ -0,0 +1,207 @@ +import type { MouseStatus } from "../mouse-types.ts"; +import { + ROCCAT_CONFIG_USAGE, + ROCCAT_CONFIG_USAGE_PAGE, + ROCCAT_HZ_TO_POLLING_CODE, + ROCCAT_KONE_PRO_AIR_WIRELESS_PID, + ROCCAT_PACKET_LENGTH, + ROCCAT_PRODUCT_IDS, + ROCCAT_PRODUCT_NAMES, + ROCCAT_REPORT_ID, + ROCCAT_VENDOR_ID, + roccatDecodeSettingsReply, + roccatEncodeSettingsQuery, + type RoccatSettingsReply, +} from "../../roccat/index.ts"; + +/** + * ROCCAT Kone Pro Air and Kone Pro over their vendor 0xFF00:0x0001 output + * channel (see `@openmouse/protocol/roccat`). Frames are 64-byte unnumbered + * output reports; the mouse answers on the same interface's input stream, + * echoing the frame's class byte 1. + * + * **Read-only.** The driver reads the stored DPI stage table and reports what + * it could verify; it ships no setter. The reason is on the wire, not in the + * code: on 2026-10-16 the vendor's own settings frame was sent by hand, the + * mouse echoed it, and its reply pages still read the previously stored stage + * table, while a passive listener saw the vendor software polling and no + * settings write on any of the four interfaces. A user-mode write to this unit + * is acknowledged without being applied, and a whole-block write that is not + * applied still costs the user their settings, so the write path stays out + * until a capture shows one landing. `docs/roccat-kone-pro-air-testing.md` + * records the session. + * + * What is verified on hardware (see `captures/roccat-kone-pro-air/`): the + * settings query answers with a full stage table once the mouse is awake, the + * status stream answers `90 0a 04 …`, and an asleep mouse echoes an empty body + * that the decoder treats as no data rather than as five stages of 0 DPI. + */ +export class RoccatHidClient { + readonly device: HIDDevice; + private queue: Promise = Promise.resolve(); + + /** Wraps a device the registry has already matched. */ + constructor(device: HIDDevice) { + this.device = device; + } + + /** Matches a Kone Pro (Air) that exposes the 0xFF00:0x0001 vendor collection. */ + static isSupported(device: HIDDevice): boolean { + if (device.vendorId !== ROCCAT_VENDOR_ID) return false; + if (!ROCCAT_PRODUCT_IDS.includes(device.productId)) return false; + const search = (collection: HIDCollectionInfo): boolean => + (collection.usagePage === ROCCAT_CONFIG_USAGE_PAGE && collection.usage === ROCCAT_CONFIG_USAGE) + || collection.children.some(search); + return device.collections.some(search); + } + + /** No discrete list: the five-stage table drives the DPI card instead. */ + getDpiOptions(): number[] { + return []; + } + + /** Opens the device if the vendor link is still closed. */ + async open(): Promise { + if (!this.device.opened) await this.device.open(); + } + + /** Closes the vendor link. */ + async close(): Promise { + if (this.device.opened) await this.device.close(); + } + + /** Product label for the UI. */ + displayName(): string { + return ROCCAT_PRODUCT_NAMES.get(this.device.productId) ?? "ROCCAT mouse"; + } + + /** Brand tag reported in MouseStatus. */ + deviceBrand(): MouseStatus["brand"] { + return "ROCCAT"; + } + + /** Wireless on the dongle pid, wired otherwise. */ + connectionType(): "Wired" | "Wireless" { + return this.device.productId === ROCCAT_KONE_PRO_AIR_WIRELESS_PID ? "Wireless" : "Wired"; + } + + /** The four rates the polling byte can encode, for the read-only report. */ + getSupportedPollingRates(): number[] { + return [...ROCCAT_HZ_TO_POLLING_CODE.keys()]; + } + + /** Serializes device work so transactions cannot interleave. */ + private run(task: () => Promise): Promise { + const next = this.queue.then(task, task); + this.queue = next.catch(() => undefined); + return next; + } + + /** Sleeps for the echo window. */ + private delay(ms: number): Promise { + return new Promise((resolve) => { setTimeout(resolve, ms); }); + } + + /** + * Sends one unnumbered 64-byte output report on the vendor interface. All + * writes here are queries; callers serialize. + */ + private async write(payload: Uint8Array): Promise { + await this.open(); + const frame = new Uint8Array(ROCCAT_PACKET_LENGTH); + frame.set(payload.subarray(0, ROCCAT_PACKET_LENGTH)); + await this.device.sendReport(ROCCAT_REPORT_ID, frame as BufferSource); + } + + /** + * Sends a frame and collects the echo the mouse answers with on the input + * stream. The echo matches on frame byte 1 (the report's class byte), the + * one byte every captured variant echoes verbatim. + */ + private async transact(payload: Uint8Array, waitMs = 250): Promise { + await this.open(); + let echo: Uint8Array | null = null; + const listener = (event: HIDInputReportEvent): void => { + const data = new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength); + if (data.length >= 3 && data[1] === payload[1] && !echo) echo = data.subarray(0, 24); + }; + this.device.addEventListener("inputreport", listener); + try { + await this.write(payload); + await this.delay(waitMs); + } finally { + this.device.removeEventListener("inputreport", listener); + } + return echo; + } + + /** + * The DPI table read. Null when the mouse never answers the query, or when it + * answers with the empty body an asleep mouse sends. + */ + async readDpiTable(): Promise { + return this.run(() => this.readTable()); + } + + /** Sends the settings query and decodes the echo, without queueing. */ + private async readTable(): Promise { + const echo = await this.transact(roccatEncodeSettingsQuery()); + return echo ? roccatDecodeSettingsReply(echo) : null; + } + + /** + * Status snapshot. The stored DPI table is the only field read from the + * mouse. Which stage the mouse is currently using is not in the reply, so + * the current DPI, the active stage, the polling rate, LOD, angle snapping, + * debounce and battery all stay unknown: the echo carries no established + * position for the first six, and the status byte's meaning is unresolved + * for the last. + */ + async readStatus(): Promise { + const dpiTable = await this.readDpiTable(); + const name = this.displayName(); + const connection = this.connectionType(); + return { + brand: "ROCCAT", + name, + ui: { + family: "roccat", + settingsReady: false, + // Neither the current DPI nor the polling rate is read, so nothing here + // is worth reporting as verified. The stage table is reported through + // `dpiStages`, which is a list of what the mouse stores, not of what it + // is using. + valuesVerified: false, + pollingReadOnly: true, + hideProcessingCard: true, + hideSleepCard: true, + hideSignalCard: true, + pollingNote: "This driver cannot report the polling rate: no capture has shown a settings write landing on this mouse.", + statusNote: dpiTable === null + ? "The mouse did not answer its settings query, so the DPI stages could not be read." + : "Read-only for now: the five stored DPI stages read back, but the mouse does not report which one is active, so the current DPI stays unknown. No settings write has been observed taking effect on this model.", + }, + batteryPercent: null, + batteryState: "Unknown", + // The reply has no byte for the active stage, so stages[0] is a stored + // preset, not the DPI in use. Claiming it would show a stored value as + // the current one. + dpi: 0, + supportsSeparateDpiAxes: true, + pollingRateHz: 0, + supportedPollingRates: this.getSupportedPollingRates(), + activeProfile: null, + debounceMs: null, + sleepTimeout: null, + angleSnapping: null, + liftOffDistance: null, + connectionType: connection, + connectionDetail: connection === "Wireless" ? "2.4 GHz (Stellar Wireless)" : "Wired USB", + motionSync: null, + rippleControl: null, + dpiStages: dpiTable?.dpi.stages ? [...dpiTable.dpi.stages] : undefined, + dpiStagesY: dpiTable?.dpi.stagesY ? [...dpiTable.dpi.stagesY] : undefined, + firmware: [], + }; + } +} diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 305f1880..06d280c8 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -10,6 +10,13 @@ import { ATK_COMPX_PRODUCT_IDS } from "./atk/products.ts"; import { MICROSOFT_PRODUCT_CLASSIC, MICROSOFT_PRODUCT_PRO, MICROSOFT_VENDOR_ID, MICROSOFT_CLASSIC_USAGE_PAGE, MICROSOFT_CLASSIC_USAGE, MICROSOFT_PRO_USAGE_PAGE, MICROSOFT_PRO_USAGE } from "../microsoft/index.ts"; import { FINALMOUSE_SLX_DONGLE_PRODUCT_IDS } from "../finalmouse/index.ts"; import { INCOTT_PRODUCT_IDS, INCOTT_USAGE_PAGE, INCOTT_VENDOR_ID } from "../incott/index.ts"; +import { + ROCCAT_CONFIG_USAGE, + ROCCAT_CONFIG_USAGE_PAGE, + ROCCAT_KONE_PRO_AIR_WIRELESS_PID, + ROCCAT_KONE_PRO_AIR_WIRED_PID, + ROCCAT_VENDOR_ID, +} from "../roccat/index.ts"; import { GLORIOUS_CORE2_PRODUCTS, GLORIOUS_CORE2_USAGE_PAGE, GLORIOUS_CORE2_VENDOR_ID } from "../glorious-core2/index.ts"; import { GLORIOUS_SINOWEALTH_PRODUCTS } from "../glorious-sinowealth/index.ts"; import { EGG_WE_HID_FILTERS } from "./endgame/egg-we-control.ts"; @@ -212,6 +219,7 @@ export const VENDOR_ID = { hyperxKingston: HYPERX_VENDOR_ID_KINGSTON, hyperxHp: HYPERX_VENDOR_ID_HP, hitscan: HITSCAN_VENDOR_ID, + roccat: ROCCAT_VENDOR_ID, } as const; /** @@ -856,6 +864,14 @@ export const HYPERX_HID_FILTERS: HIDDeviceFilter[] = [ ...HYPERX_KINGSTON_HID_FILTERS, ...HYPERX_HP_HID_FILTERS, ]; + +// The Kone Pro family exposes one 0xFF00:0x0001 vendor interface across the +// whole composite (interface 2 of the dongle, interface 1 wired). There is no +// feature report on it, so the filter cannot pin a report id. +export const ROCCAT_HID_FILTERS: HIDDeviceFilter[] = [ + { vendorId: ROCCAT_VENDOR_ID, productId: ROCCAT_KONE_PRO_AIR_WIRELESS_PID, usagePage: ROCCAT_CONFIG_USAGE_PAGE, usage: ROCCAT_CONFIG_USAGE }, + { vendorId: ROCCAT_VENDOR_ID, productId: ROCCAT_KONE_PRO_AIR_WIRED_PID, usagePage: ROCCAT_CONFIG_USAGE_PAGE, usage: ROCCAT_CONFIG_USAGE }, +]; export const RYUNIX_HID_FILTERS: HIDDeviceFilter[] = [...RYUNIX_PRODUCT_IDS].map((productId) => ({ vendorId: RYUNIX_VENDOR_ID, productId, @@ -1007,6 +1023,7 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ ...MICROSOFT_HID_FILTERS, ...INCOTT_HID_FILTERS, ...HYPERX_HID_FILTERS, + ...ROCCAT_HID_FILTERS, ...RYUNIX_HID_FILTERS, ...RAPOO_HID_FILTERS, { vendorId: VENDOR_ID.coolermaster, usagePage: COOLERMASTER_USAGE_PAGE, usage: COOLERMASTER_USAGE }, diff --git a/src/index.ts b/src/index.ts index f11ccf89..fed90a83 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,3 +42,4 @@ export * as fater from "./fater/index.js"; export * as coolermaster from "./coolermaster/index.js"; export * as ajazz from "./ajazz/index.js"; export * as scyrox from "./scyrox/index.js"; +export * as roccat from "./roccat/index.js"; diff --git a/src/roccat/index.test.ts b/src/roccat/index.test.ts new file mode 100644 index 00000000..5ac9f00d --- /dev/null +++ b/src/roccat/index.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + ROCCAT_DPI_MAX, + ROCCAT_DPI_MIN, + ROCCAT_DPI_STEP, + ROCCAT_PACKET_LENGTH, + ROCCAT_STATUS_COMMAND_ID, + roccatDecodeSettingsReply, + roccatEncodeSettingsQuery, + roccatEncodeStatus, + roccatIsStatusReply, +} from "./index.ts"; + +const hex = (bytes: Uint8Array, count: number) => [...bytes.subarray(0, count)].map((b) => b.toString(16).padStart(2, "0")).join(" "); + +test("the query frames are the recorded bytes", () => { + assert.equal(hex(roccatEncodeSettingsQuery(), 3), "10 50 14"); + assert.equal(roccatEncodeSettingsQuery().length, ROCCAT_PACKET_LENGTH); + assert.equal(hex(roccatEncodeStatus(), 2), "90 0a"); + assert.equal(roccatEncodeStatus().length, ROCCAT_PACKET_LENGTH); +}); + +test("the stored table lands on a 50-DPI grid that fits the slot byte", () => { + assert.equal(ROCCAT_DPI_MIN, 50); + assert.equal(ROCCAT_DPI_MAX / ROCCAT_DPI_STEP, 255, "the ceiling fills the slot byte and no vendor number above it fits"); +}); + +test("the settings echo decoder accepts the recorded factory frame", () => { + // The mouse's interrupt-IN reply to a settings query on this unit (this + // unit's stage table had five entries, both axes identical). Reported as + // `10 50 03 00 ec fa 0d 00 10 10 18 18 20 20 40 40 64 64 01 02 00 00`. + const echo = new Uint8Array([ + 0x10, 0x50, 0x03, 0x00, 0xec, 0xfa, 0x0d, 0x00, + 0x10, 0x10, 0x18, 0x18, 0x20, 0x20, 0x40, 0x40, 0x64, 0x64, + 0x01, 0x02, 0x00, 0x00, + ]); + const decoded = roccatDecodeSettingsReply(echo); + assert.deepEqual(decoded?.dpi.stages, [800, 1200, 1600, 3200, 5000]); + assert.deepEqual(decoded?.dpi.stagesY, [800, 1200, 1600, 3200, 5000]); +}); + +test("the two neighbouring byte lists decode as the two axes", () => { + const echo = new Uint8Array([ + 0x10, 0x50, 0x03, 0x00, 0xec, 0xfa, 0x0d, 0x00, + 16, 8, 24, 16, 32, 24, 64, 32, 100, 64, + ]); + const decoded = roccatDecodeSettingsReply(echo)!; + assert.deepEqual(decoded.dpi.stages, [800, 1200, 1600, 3200, 5000]); + assert.deepEqual(decoded.dpi.stagesY, [400, 800, 1200, 1600, 3200]); +}); + +test("the settings echo decoder refuses foreign frames", () => { + assert.equal(roccatDecodeSettingsReply(new Uint8Array([0x90, 0x0a, 0x04])), null); + assert.equal(roccatDecodeSettingsReply(new Uint8Array([0x10, 0x10, 0x0b])), null); + assert.equal(roccatDecodeSettingsReply(new Uint8Array([0x10, 0x50, 0x51])), null); + assert.equal(roccatDecodeSettingsReply(new Uint8Array(10)), null); +}); + +test("an all-zero stage table decodes as no-data, the sleeper echo", () => { + // What an idle/sleeping mouse returns per poll in the second capture: the + // echo of `10 50 03 00 ec fa 04` with an empty body. + const sleeper = new Uint8Array([ + 0x10, 0x50, 0x03, 0x00, 0xec, 0xfa, 0x04, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]); + assert.equal(roccatDecodeSettingsReply(sleeper), null); + // One non-zero slot is a real table again. + const one = new Uint8Array([...sleeper]); + one[8] = 0x10; + assert.deepEqual(roccatDecodeSettingsReply(one)?.dpi.stages, [800, 0, 0, 0, 0]); +}); + +test("the status probe reply is recognized but never decoded as a battery percent", () => { + assert.equal(roccatIsStatusReply(new Uint8Array([0x90, 0x0a])), true); + assert.equal(roccatIsStatusReply(new Uint8Array([0x90, 0x0a, 0x04, 0x00, 0x00, 0x1e])), true); + assert.equal(roccatIsStatusReply(new Uint8Array([0x10, 0x50, 0x03])), false); + assert.equal(ROCCAT_STATUS_COMMAND_ID, 0x90); +}); diff --git a/src/roccat/index.ts b/src/roccat/index.ts new file mode 100644 index 00000000..26538b2c --- /dev/null +++ b/src/roccat/index.ts @@ -0,0 +1,185 @@ +/** + * ROCCAT Kone Pro Air (`1e7d:2c8e` via the Stellar Wireless dongle, `1e7d:2c92` + * wired) and sibling Kone Pro firmware — the read side. + * + * Transport: the mouse speaks on the composite's vendor interface (usage page + * 0xFF00, usage 0x0001 — interface 2 of the dongle, interface 1 wired) with + * plain unnumbered output reports. WebHID callers send + * `sendReport(0, payload64)`; the mouse answers on the same interface's + * interrupt-IN stream. There is no feature report here — a full 0x00–0xFF + * GET sweep on this unit answered nothing (2026-10-14, Windows node-hid, + * `captures/roccat-kone-pro-air/`). + * + * Payload frame layout (device coordinates, ahead of no report id): + * + * - `0x10 0x50 ` — performance class: the settings query (0x14) and the + * `0x18` apply tail the vendor software sends after a write; + * - `0x90 0x0a` — the unnamed status probe, answered `0x90 0x0a 04 00 00 — ` + * with a body whose meaning is unresolved (see the note by + * `roccatIsStatusReply`). + * + * **This module decodes, it does not encode settings writes.** No setter ships + * until a capture shows one landing: the 2026-10-16 session sent the vendor's + * own settings frame by hand, got the expected echo, and the reply pages still + * read the previously stored stage table, while a passive listener saw the + * vendor software's reads and no write on any of the four interfaces. Frames + * being accepted is not the same as frames being applied, and a whole-block + * write that is not applied still costs the user their settings. The protocol + * bytes below are kept because they are what the captures establish; the + * encoders are not, because nothing establishes them. + */ + +export const ROCCAT_VENDOR_ID = 0x1e7d; // ROCCAT GmbH (now Turtle Beach) + +export const ROCCAT_KONE_PRO_AIR_WIRELESS_PID = 0x2c8e; // Stellar Wireless dongle +export const ROCCAT_KONE_PRO_AIR_WIRED_PID = 0x2c92; // wired generation +export const ROCCAT_KONE_PRO_PID = 0x2c8f; // wired Kone Pro, same Swarm family + +export const ROCCAT_PRODUCT_NAMES: ReadonlyMap = new Map([ + [ROCCAT_KONE_PRO_AIR_WIRELESS_PID, "ROCCAT Kone Pro Air"], + [ROCCAT_KONE_PRO_AIR_WIRED_PID, "ROCCAT Kone Pro Air (Wired)"], +]); + +export const ROCCAT_PRODUCT_IDS: readonly number[] = [ + ROCCAT_KONE_PRO_AIR_WIRELESS_PID, + ROCCAT_KONE_PRO_AIR_WIRED_PID, +]; + +/** Vendor collection on the dongle's interface 2 (interface 1 when wired). */ +export const ROCCAT_CONFIG_USAGE_PAGE = 0xff00; +export const ROCCAT_CONFIG_USAGE = 0x0001; + +/** Payload size of every config frame; unnumbered report id 0. */ +export const ROCCAT_PACKET_LENGTH = 64; +export const ROCCAT_REPORT_ID = 0x00; + +/** Byte 0 of a 64-byte config frame: carries no discovered meaning. */ +export const ROCCAT_FRAME_PREFIX = 0x10; + +export const ROCCAT_PERFORMANCE_CLASS = 0x50; +export const ROCCAT_STATUS_COMMAND_ID = 0x90; + +/** + * Sub-commands of the performance class. `settingsGroup` is the settings + * query this module sends; `applyGroup` is the `10 50 18 04` tail the vendor + * software sends after a settings write, and appears here only because the + * decoder has to recognise an echo of it. + */ +export const ROCCAT_PERFORMANCE_COMMAND = { + settingsGroup: 0x14, + applyGroup: 0x18, +} as const; + +/** Unnamed status probe SignalRGB ends its setup with: `90 0a`. */ +export const ROCCAT_STATUS_COMMAND: readonly number[] = [ + ROCCAT_STATUS_COMMAND_ID, + 0x0a, +]; + +export const ROCCAT_POLLING_TABLE = [125, 250, 500, 1000] as const; +/** Opposite of the Burst Core polling dict (125 -> 0x03 here). */ +export const ROCCAT_HZ_TO_POLLING_CODE: ReadonlyMap = new Map([ + [1000, 0x00], + [500, 0x01], + [250, 0x02], + [125, 0x03], +]); + +/** + * DPI is stored per 50 in a single byte slot, so the encodable ceiling is + * 255 x 50 = 12,750. The vendor software advertises higher numbers, but that + * encoding was never captured and does not fit this byte, so it is not + * claimed here. + */ +export const ROCCAT_DPI_MIN = 50; +export const ROCCAT_DPI_MAX = 12750; +export const ROCCAT_DPI_STEP = 50; +/** The stored table carries five stages of each axis. */ +export const ROCCAT_DPI_STAGE_COUNT = 5; + +/** LOD byte in the settings block: 0x06 = low, 0x07 = high. */ +export const ROCCAT_LOD = { + low: 0x06, + high: 0x07, +} as const; + +export interface RoccatDpiStages { + /** The five 50-step slots of one axis. */ + stages: readonly number[]; + /** The counterpart five slots carried in the neighbouring byte list. */ + stagesY: readonly number[]; +} + +/** The empty settings query — a 0x14 frame with no arguments. */ +export function roccatEncodeSettingsQuery(): Uint8Array { + const view = new Uint8Array(ROCCAT_PACKET_LENGTH); + view[0] = ROCCAT_FRAME_PREFIX; + view[1] = ROCCAT_PERFORMANCE_CLASS; + view[2] = ROCCAT_PERFORMANCE_COMMAND.settingsGroup; + return view; +} + +/** + * Reply to a settings query, on this unit: + * `10 50 03 00 00 …`. The class + * byte 2 was 0x03 on every observed variant; frames that echo the driven + * command (`0x14` writes) carry it at byte 2 as well. Only the frame prefix + * and the DPI table are decoded — the remaining bytes (the device word, the + * sequence marker, LOD/angle positions in the echo) are not established. + */ +export interface RoccatSettingsReply { + dpi: RoccatDpiStages; +} + +/** Decodes a settings echo into its DPI table, or null when the reply is not a settings frame or carries an empty (asleep) body. */ +export function roccatDecodeSettingsReply(reply: Uint8Array): RoccatSettingsReply | null { + if ( + reply.length < 18 + || reply[0] !== ROCCAT_FRAME_PREFIX + || reply[1] !== ROCCAT_PERFORMANCE_CLASS + || reply[2] === undefined + ) { + return null; + } + if (reply[2] !== 0x03 && reply[2] !== ROCCAT_PERFORMANCE_COMMAND.settingsGroup + && reply[2] !== ROCCAT_PERFORMANCE_COMMAND.applyGroup) { + return null; + } + const stageSlots: number[] = []; + const stageSlotsY: number[] = []; + for (let index = 0; index < ROCCAT_DPI_STAGE_COUNT; index += 1) { + stageSlots.push(reply[8 + index * 2]!); + stageSlotsY.push(reply[9 + index * 2]!); + } + // An asleep mouse echoes the frame with an empty body (all twenty slots + // zero). Reporting that as five stages of 0 DPI would invent a state, so an + // all-zero table means "no data" exactly like a missed echo does. + if (stageSlots.every((slot) => slot === 0) && stageSlotsY.every((slot) => slot === 0)) return null; + return { + dpi: { + stages: stageSlots.map((slot) => slot * ROCCAT_DPI_STEP), + stagesY: stageSlotsY.map((slot) => slot * ROCCAT_DPI_STEP), + }, + }; +} + +/** The status probe, answered `90 0a …` on the input stream. */ +export function roccatEncodeStatus(): Uint8Array { + const view = new Uint8Array(ROCCAT_PACKET_LENGTH); + view[0] = ROCCAT_STATUS_COMMAND[0]!; + view[1] = ROCCAT_STATUS_COMMAND[1]!; + return view; +} + +/** Reply to the status probe recorded on this unit: `90 0a 04 00 00 = 2 && reply[0] === ROCCAT_STATUS_COMMAND[0] && reply[1] === ROCCAT_STATUS_COMMAND[1]; +} + +// The status echo `90 0a 04 00 00 1e 00 00 1e` was recorded on this +// unit, but byte 5's meaning is unresolved here: it moved 0x1e -> 0x14 across +// sessions while the trailing u16 increased (2720 -> 2884), so a battery +// percent, a signal strength and an elapsed counter all fit. The 2026-10-16 +// capture later lined byte 5 up with the vendor software's own battery log, +// but that is a reading from Swarm, not from this driver, so no battery value +// is surfaced until a capture establishes it from the reply itself.