diff --git a/README.md b/README.md index 0fcd87a2..f1a03a71 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ checklist. | G-Wolves | `@openmouse/protocol/gwolves` | | GearHub (AJAZZ / Attack Shark / Lingbao) | `@openmouse/protocol/gearhub` | | Glorious | `@openmouse/protocol/glorious` | -| Glorious classic (Model O/D/I) | `@openmouse/protocol/glorious-classic` | +| Glorious classic (Model O/D/I, Series One Pro) | `@openmouse/protocol/glorious-classic` | | Glorious core2 (O2/D2 Pro 4K/8K) | `@openmouse/protocol/glorious-core2` | | Hitscan (Hyperlight web control / native Utility) | `@openmouse/protocol/hitscan` | | HyperX | `@openmouse/protocol/hyperx` | @@ -108,3 +108,11 @@ rivalcfg project, corroborated against libratbag and OpenRGB. The device is write-only — only the firmware version can be read back — and no entry is marked hardware-verified yet. See [docs/steelseries-testing.md](docs/steelseries-testing.md). + +Glorious Series One Pro Wireless (`258a:2018` wired, `258a:2031` 2.4 GHz +receiver) is listed in the Glorious classic driver. Only its firmware and +battery reads have been exercised on hardware (wired); the classic RGB, DPI, +polling, lift-off and debounce writes have not. See +[docs/glorious-series-one-pro.md](docs/glorious-series-one-pro.md) for those +replies and the resident Compx bootloader protocol (entry, read-back, +erase/program behavior). diff --git a/docs/glorious-series-one-pro.md b/docs/glorious-series-one-pro.md new file mode 100644 index 00000000..11861a5e --- /dev/null +++ b/docs/glorious-series-one-pro.md @@ -0,0 +1,90 @@ +# Glorious Forge Series One Pro Wireless (`258a:2018` / `258a:2031`) + +USB HID configuration and resident Compx nRF52840 bootloader protocol of the **Glorious Forge Series One Pro Wireless** (`FCC ID: 2AXP2-SP22`, PCB `CM2852-BAMF 20220516`). + +**Evidence.** "Verified" means observed on one retail mouse and its receiver on firmware v1.0.0.0 (Linux hidraw, mouse wired except in §2.4, 2026-11-12 and 2026-11-13). Bootloader-entry and bootloader packet layouts come from the IL of the official updater `FirmwareUpdate.exe` 1.0.8229.18185 (`C52840Dongle`, SHA-256 `37ae7be701ba36ec987ec86d58b07819834aab0385ac4345138f49e9d9511ea5`); the battery read uses the classic layout already in this module. Code addresses are in the official v1.0.0.0 application images (`CM2856_Mouse_USB_v1.00.00_20220810`, `CM2856_Receiver_USB_v1.00.00_20220810`), read by disassembly. Anything else is marked as not exercised. Full notes and tools: [mouse-firmware-re](https://github.com/0zLloen/mouse-firmware-re/tree/main/devices/glorious-series-one-pro). + +--- + +## 1. USB Identification & HID Interfaces + +| Mode | VID:PID | `bcdDevice` | iProduct | Config channel | Evidence | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **Wired mouse application** | `258a:2018` | `0x0101` | `Series One PRO Wireless` | Interface 2, usage page `0xFFFF`, usage `0x0000`, 64-byte Feature report, no report ID | Verified (descriptors 91 / 163 / 20 bytes) | +| **2.4 GHz receiver application** | `258a:2031` | `0x0100` | `Series One PRO Wireless` | Same, identical descriptors | Verified (enumeration; relay and local commands in §2.4) | +| **Wired mouse bootloader** | `258a:2103` | not recorded | `Glorious bootloader` | Same 64-byte Feature report channel | Verified | +| **Receiver bootloader** | `258a:2113` | unknown | unknown | presumably the same | PID constant in the updater only | + +--- + +## 2. Application Feature Report Frame Layout (`258a:2018`) + +All offsets below (`body[0..63]`) omit the leading report ID byte (`0x00`): `body[n] = bfr[n + 1]`. + +### 2.1 Firmware Version Read (`0x00, 0x81`) — verified + +- **Request**: `body[2] = 0x00` (target), `body[3] = 0x06` (length), `body[4] = 0x00`, `body[5] = 0x81`. The updater's own `GetFW` sends length `0x03`; that variant was not sent here. +- **Reply** (GET_REPORT after ~25 ms, stock firmware): `a1 00 00 03 00 81 01 00 00 00 20 18`, then zeros to byte 63. + ```text + body[0] = 0xa1 (status OK) + body[3] = 0x03 (reply length) + body[4..5] = 00 81 (echo) + body[6..9] = major, minor, patch, build (01 00 00 00 -> v1.0.0.0) + body[10..11] = big-endian USB product ID (20 18 -> 0x2018) + ``` +- Sent to the `258a:2031` receiver, `body[2] = 0x02` relays the query to the mouse and `body[2] = 0x00` is answered by the receiver itself (§2.4). Other `body[2]` targets are not exercised. + +### 2.2 Battery Status Read (`0x00, 0x83`) — verified wired + +- **Request**: `body[2] = target`, `body[3] = 0x02`, `body[4] = 0x00`, `body[5] = 0x83`. +- **Reply**: `a1 00 02 00 83 00 64`: `body[0]` status (`0xa1` normal, observed; `0xa4` asleep and `0xa0` waking up as for the other classic mice, not observed on this mouse), `body[6]` charging flag, `body[7]` percent. With the cable plugged in it answered `charging = 0`, `100 %` for target `0x00` and for `0x02` (echoed in `body[2]`). Read on a build of v1.0.0.0 carrying the open-directional-dpi patch, which passes these commands to the stock handler unchanged. + +### 2.3 Enter Resident USB HID Bootloader — verified + +- **Request** (`C52840Dongle::SetBLDeviceMode`, "set device mode" to `0xb0`): + ```text + body[2] = 0x00 (deviceID: 0 for the wired mouse, Config.ini [Debug] DeviceID) + body[3] = 0x01 (length) + body[4] = 0x00 + body[5] = 0x00 ("set device mode") + body[6] = 0xb0 (mode: bootloader) + every other byte 0x00 + ``` +- **Behavior**: the mouse resets (the SET_REPORT itself fails with `EPROTO` on Linux) and enumerates as `258a:2103`. Traced in the stock image: `0x14824` compares the mode byte (`body[6]`) with `0xb0` and branches to `0x1484C`, which writes `0xA55AA55A` to `0x2003FFFC` (top word of the 256 KB SRAM) and calls `NVIC_SystemReset()` at `0x71B8`. The bootloader (24 KB at `0x00000000`–`0x00005FFF`, inferred from the image base `0x6000`) has never been read, so how it uses that word is inferred. + +### 2.4 Through the 2.4 GHz Receiver (`258a:2031`) + +Same Feature report channel. Relay and status behavior verified with the mouse on the receiver (2026-11-13) and matching the receiver's disassembly; rows marked "Disassembly" are read from the receiver image only. + +- **Relay**: the receiver forwards a request to the mouse only when `body[2] = 0x02`. It sends `body[3] + 6` bytes over the radio in 8-byte chunks and copies back `body[3] + 6` bytes of the reply, the length the **request** asked for; the rest of the 64 bytes is stale. +- **Status**: `body[0]` reads `0xa0` while the request is pending and `0xa4` when the link is down. `0xa4` can be transient: while the mouse erased a flash page (a profile save on a patched build) the receiver showed `0xa4` at about 270 ms and the real reply arrived at about 770 ms. Poll until a deadline and treat only a final `0xa4` as an unreachable mouse. +- **Receiver-local handlers**: with `body[2] != 0x02`, the receiver runs its own handler only when `body[4] = 0x00`, with the command in `body[5]`. Any other `body[4]` answers `0xa3` with no handler. + +| `body[5]` | Receiver-local effect | Evidence | +| :--- | :--- | :--- | +| `0x81` | Receiver firmware version | Verified | +| `0x89` | Link state | Verified | +| `0x8a` | Reads the receiver's RF ID | Disassembly | +| `0x00` | **Dangerous.** Sets the receiver's device mode; `body[6] = 0xb0` writes `0xA55AA55A` to `0x20007FFC` and reboots the receiver into its bootloader (`258a:2113`) | Disassembly | +| `0x0a` | **Dangerous.** Rewrites the stored RF ID, which unpairs the mouse | Disassembly | +| other | `0xa3` and an echo | Disassembly | + +The bootloader-entry packet of §2.3 (`body[2] = 0x00`, `body[4] = 0x00`, `body[5] = 0x00`) is therefore not relayed: sent to `258a:2031` it acts on the receiver itself. Writes meant for the mouse must use `body[2] = 0x02`. + +--- + +## 3. Resident Compx USB HID Bootloader Protocol (`258a:2103`) + +Every command carries `body[2] = deviceID` (`0x00` for the wired mouse). All five were exercised on the wired mouse, ending in a verified flash of a patched build of the v1.0.0.0 application; the official image was then restored the same way, read back and booted. + +| Command | `body[3]` (Len) | `body[4]` | `body[5]` (Cmd) | `body[6..10]` | `body[11..63]` | Behavior / Reply | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| **`GetBLFW`** | `0x06` | `0xB0` | `0x80` | `00..` | `00..` | `body[0] = 0xA1`, version at `body[8..11]`: `b0 01 00 00`. | +| **`Erase`** | `0x08` | `0xB0` | `0x01` | `00..` | `00..` | Cleared `0x0001E000` up to at least `0x0004D7FF` but **kept `0x00006000`–`0x0001DFFF`** (the old image, vector table included). It does not reach `0x7E000`: data saved there survived two full flashes. Whether it reaches the `0x88000`+ pairing pages is unknown. Do not rely on it to blank the application. | +| **`Program`** | `len + 5` | `0xB0` | `0x02` | `body[6] = len` (`<= 32`)
`body[7..10] = addr` (BE `u32`) | `len` bytes of `data ^ 0x55` | XOR `0x55` on the wire only. **Read-modify-write**: the bytes land exactly as sent (bits can go 0 → 1) and the rest of the 4 KB page is kept, so no prior erase is needed. | +| **`VerifyByAddress`** | `0x20` | `0xB0` | `0x83` | `body[6] = len` (`<= 32`)
`body[7..10] = addr` (BE `u32`) | `00..` | `body[0] = 0xA1` and `len` bytes XOR `0x55` at `body[11..]`. Reads succeed for `0x00006000`–`0x0004D7FF`; from `0x0004D800` up the status is `0xA2`, so the pairing/profile pages cannot be read over USB. Addresses below `0x6000` were not tried. | +| **`Exit`** | `0x01` | `0xB0` | `0x04` | `body[6] = 0x01` | `00..` | Boots the application at `0x00006000` (the SET_REPORT fails with `ETIMEDOUT` as the device resets). Any validation it performs is unknown. | + +Status byte `body[0]` (the updater's result codes): `0xA1`/`0x02` success, `0xA2` fail (also: address out of the readable range), `0xA3` unknown command, `0xA4` out of range. + +The flash order used here: `Erase`, then `Program` `0xFF` over the vector table at `0x6000` and read it back, program and verify the rest of the image, write the vector table last, then `Exit`. The `0xFF` write protects against a half-written vector table only once it has landed. An interruption during or right after `Erase` leaves the old vector table with `0x6000`–`0x1DFFF` intact and the range `Erase` clears (from `0x1E000`) erased, or only partly erased if `Erase` itself was cut short: still a complete image for the stock application (it ends at `0x1CC8F`), a broken one for any old image that reached `0x1E000`. Whether the bootloader boots it, or stays resident when `0x6000` is `0xFF`, is unknown: its code has never been read and an interrupted flash has not been tried. diff --git a/src/drivers/glorious/classic-protocol.test.ts b/src/drivers/glorious/classic-protocol.test.ts index e00e7a86..a141eedc 100644 --- a/src/drivers/glorious/classic-protocol.test.ts +++ b/src/drivers/glorious/classic-protocol.test.ts @@ -5,8 +5,11 @@ import { GLORIOUS_CLASSIC_PROFILE_DEFAULT, buildGloriousClassicActiveStagePayload, buildGloriousClassicBatteryRequestPayload, + buildGloriousClassicBootloaderExitPayload, buildGloriousClassicDebouncePayload, buildGloriousClassicDpiStagesPayload, + buildGloriousClassicEnterBootloaderPayload, + buildGloriousClassicFirmwareRequestPayload, buildGloriousClassicLiftOffPayload, buildGloriousClassicPollingRatePayload, buildGloriousClassicRgbPayload, @@ -14,6 +17,7 @@ import { gloriousClassicEncodePollingRate, gloriousClassicRateByte, parseGloriousClassicBatteryResponse, + parseGloriousClassicFirmwareResponse, } from "../../glorious-classic/index.ts"; // Every assertion below is written against the source's own `bfr[n]` buffer @@ -179,3 +183,41 @@ test("lift-off distance payload encodes millimetres-1", () => { const twoMm = buildGloriousClassicLiftOffPayload(2); assert.equal(twoMm[6], 0x01); // bfr[7] = 2mm - 1 }); + +test("firmware request and response match Series One Pro hardware reply", () => { + const expectedReq = new Uint8Array(64); + expectedReq[3] = 0x06; // bfr[4] length + expectedReq[5] = 0x81; // bfr[6] firmware command + assert.deepEqual(buildGloriousClassicFirmwareRequestPayload(), expectedReq); + + // Captured reply from a wired Series One Pro on v1.0.0.0 + const reply = new Uint8Array(64); + reply.set([0xa1, 0x00, 0x00, 0x03, 0x00, 0x81, 0x01, 0x00, 0x00, 0x00, 0x20, 0x18]); + assert.deepEqual(parseGloriousClassicFirmwareResponse(reply), { + version: "1.0.0.0", + major: 1, + minor: 0, + patch: 0, + build: 0, + productId: 0x2018, + }); +}); + +test("bootloader entry payload matches C52840Dongle::SetBLDeviceMode", () => { + const expected = new Uint8Array(64); + expected[3] = 0x01; // bfr[4] length + expected[6] = 0xb0; // bfr[7] mode: bootloader (bfr[3] deviceID 0, bfr[6] "set device mode" 0) + assert.deepEqual(buildGloriousClassicEnterBootloaderPayload(), expected); +}); + +test("bootloader exit payload matches C52840Dongle Exit", () => { + const expected = new Uint8Array(64); + expected.set([0x01, 0xb0, 0x04, 0x01], 3); // bfr[4..7] + assert.deepEqual(buildGloriousClassicBootloaderExitPayload(), expected); +}); + +test("battery response decodes the Series One Pro wired reply", () => { + const reply = new Uint8Array(64); + reply.set([0xa1, 0x00, 0x02, 0x02, 0x00, 0x83, 0x00, 0x64]); + assert.deepEqual(parseGloriousClassicBatteryResponse(reply), { state: "Normal", percent: 100, charging: false }); +}); diff --git a/src/glorious-classic/index.ts b/src/glorious-classic/index.ts index 8309cadd..14cc62cd 100644 --- a/src/glorious-classic/index.ts +++ b/src/glorious-classic/index.ts @@ -267,6 +267,92 @@ export function parseGloriousClassicBatteryResponse(body: Uint8Array): GloriousC return { state, percent: percent > 0 && percent <= 100 ? percent : null, charging }; } +// --------------------------------------------------------------------------- +// Firmware version read and Compx nRF52840 USB HID bootloader entry/exit (Series One Pro). +// The entry and exit packets are C52840Dongle::SetBLDeviceMode / Exit from +// FirmwareUpdate.exe 1.0.8229.18185; the firmware read uses length 0x06 (the updater's +// GetFW sends 0x03). All are hardware-verified on a wired Series One Pro (258a:2018, +// v1.0.0.0); the receiver (258a:2031 / 258a:2113) paths are not. +// --------------------------------------------------------------------------- + +export const GLORIOUS_CLASSIC_MOUSE_BOOTLOADER_PID = 0x2103; + +export interface GloriousClassicFirmware { + version: string; + major: number; + minor: number; + patch: number; + build: number; + productId: number | null; +} + +/** + * Firmware version request (verified on a wired Series One Pro): + * bfr[3]=0x00 (target), bfr[4]=0x06 (length), bfr[5]=0x00, bfr[6]=0x81. + */ +export function buildGloriousClassicFirmwareRequestPayload(): Uint8Array { + const body = packet(); + bfr(body, 4, 0x06); + bfr(body, 6, 0x81); + return body; +} + +/** + * Parses the 64-byte feature-report reply to `buildGloriousClassicFirmwareRequestPayload()`. + * Reply layout (body[n] = bfr[n + 1]): + * body[0] = 0xa1 (status OK), body[4] = 0x00, body[5] = 0x81 (echo), + * body[6..9] = major, minor, patch, build, + * body[10..11] = big-endian uint16 USB productId (e.g. 0x2018). + */ +export function parseGloriousClassicFirmwareResponse(body: Uint8Array): GloriousClassicFirmware | null { + if (body.length < 12 || body[0] !== 0xa1 || body[5] !== 0x81) return null; + const major = body[6]!; + const minor = body[7]!; + const patch = body[8]!; + const build = body[9]!; + const productId = ((body[10]! << 8) | body[11]!) || null; + return { + version: `${major}.${minor}.${patch}.${build}`, + major, + minor, + patch, + build, + productId, + }; +} + +/** + * Builds the 64-byte feature-report body (`C52840Dongle::SetBLDeviceMode`) that + * reboots a Glorious nRF52840 mouse (`258a:2018`) into its resident Compx USB HID + * bootloader (`258a:2103`): "set device mode" (bfr[6] = 0x00, length 1) to 0xb0. + * FirmwareUpdate.exe 1.0.8229.18185 sets bfr[3] = deviceID (0, from Config.ini + * [Debug] DeviceID), bfr[4] = 0x01, bfr[7] = 0xb0 and leaves every other byte 0. + * Hardware-verified on a Series One Pro (v1.0.0.0, wired): it re-enumerates as + * 258a:2103. Do not send it to the receiver (258a:2031): with bfr[3] = 0 it is not + * relayed, and by the receiver's disassembly it reboots the receiver itself into + * its bootloader (258a:2113). That path has not been exercised. + */ +export function buildGloriousClassicEnterBootloaderPayload(): Uint8Array { + const body = packet(); + bfr(body, 4, 0x01); + bfr(body, 7, 0xb0); + return body; +} + +/** + * Bootloader `Exit` (sent to 258a:2103, C52840Dongle::Exit): bfr[4]=0x01 (length), + * bfr[5]=0xb0, bfr[6]=0x04, bfr[7]=0x01 (boot the application). Verified on a wired + * Series One Pro: it resets into the application (the SET fails as the device resets). + */ +export function buildGloriousClassicBootloaderExitPayload(): Uint8Array { + const body = packet(); + bfr(body, 4, 0x01); + bfr(body, 5, 0xb0); + bfr(body, 6, 0x04); + bfr(body, 7, 0x01); + return body; +} + // --------------------------------------------------------------------------- // DPI, polling rate, lift-off distance (korkje/mxw). None of these three has // a read command in mxw either — every write below is fire-and-forget, same diff --git a/src/index.ts b/src/index.ts index ce0629fa..c29404cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ export * as corsair from "./corsair/index.js"; export * as dareu from "./dareu/index.js"; export * as gwolves from "./gwolves/index.js"; export * as glorious from "./glorious/index.js"; +export * as gloriousClassic from "./glorious-classic/index.js"; export * as gloriousCore2 from "./glorious-core2/index.js"; export * as gloriousSinowealth from "./glorious-sinowealth/index.js"; export * as ksnake from "./ksnake/index.js";