From 571b0586a98ed8c76a48578612f046cb11f860e1 Mon Sep 17 00:00:00 2001 From: defendercf Date: Fri, 21 Aug 2026 02:38:19 +0700 Subject: [PATCH] feat(gwolves): add HTX Ultra driver (wired + wireless) --- package.json | 4 + src/drivers/gwolves/hid.test.ts | 67 +++++++ src/drivers/gwolves/hid.ts | 256 +++++++++++++++++++++++++++ src/drivers/gwolves/products.ts | 28 +++ src/drivers/gwolves/protocol.test.ts | 88 +++++++++ src/drivers/registry.ts | 4 +- src/drivers/vendors.ts | 3 + src/gwolves/index.ts | 221 +++++++++++++++++++++++ src/index.ts | 1 + 9 files changed, 671 insertions(+), 1 deletion(-) create mode 100644 src/drivers/gwolves/hid.test.ts create mode 100644 src/drivers/gwolves/hid.ts create mode 100644 src/drivers/gwolves/products.ts create mode 100644 src/drivers/gwolves/protocol.test.ts create mode 100644 src/gwolves/index.ts diff --git a/package.json b/package.json index 9685fa2..bf45cd5 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,10 @@ "./drivers/*": { "types": "./dist/drivers/*.d.ts", "import": "./dist/drivers/*.js" + }, + "./gwolves": { + "types": "./dist/gwolves/index.d.ts", + "import": "./dist/gwolves/index.js" } }, "scripts": { diff --git a/src/drivers/gwolves/hid.test.ts b/src/drivers/gwolves/hid.test.ts new file mode 100644 index 0000000..98da72f --- /dev/null +++ b/src/drivers/gwolves/hid.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { GWolvesHidClient } from "./hid.ts"; + +function device(productId: number, reportId = 0x08, reportCount = 16): HIDDevice { + return { + vendorId: 0x33e4, + productId, + productName: "G-Wolves HTX Ultra 8K Wireless Mouse-RS", + collections: [{ + usagePage: 0xff02, + usage: 2, + children: [], + featureReports: [], + inputReports: [{ reportId, items: [{ reportCount, reportSize: 8 }] }], + outputReports: [{ reportId, items: [{ reportCount, reportSize: 8 }] }], + }], + } as unknown as HIDDevice; +} + +test("support is driven by the product catalog, not hardcoded product ids", () => { + // Arrange + const wired = device(0x5618); + const receiver = device(0x3854); + const wrongVendor = { ...device(0x5618), vendorId: 0x3554 } as HIDDevice; + const unknownModel = device(0x1234); + const wrongReport = device(0x5618, 0x09, 48); + + // Act / Assert + assert.equal(GWolvesHidClient.isSupported(wired), true); + assert.equal(GWolvesHidClient.isSupported(receiver), true); + assert.equal(GWolvesHidClient.isSupported(wrongVendor), false); + assert.equal(GWolvesHidClient.isSupported(unknownModel), false); + assert.equal(GWolvesHidClient.isSupported(wrongReport), false); +}); + +test("transport metadata distinguishes receiver from cable via the catalog", () => { + // Arrange / Act + const wired = new GWolvesHidClient(device(0x5618)); + const receiver = new GWolvesHidClient(device(0x3854)); + + // Assert + assert.equal(wired.isWirelessPath(), false); + assert.equal(receiver.isWirelessPath(), true); +}); + +test("poll interval is shorter over the wireless receiver", () => { + // Arrange / Act + const wired = new GWolvesHidClient(device(0x5618)); + const receiver = new GWolvesHidClient(device(0x3854)); + + // Assert + assert.equal(wired.pollIntervalMs, 30_000); + assert.equal(receiver.pollIntervalMs, 10_000); +}); + +test("DPI options follow the shared VGN-family 50-step range", () => { + // Arrange / Act + const client = new GWolvesHidClient(device(0x5618)); + const options = client.getDpiOptions(); + + // Assert + assert.equal(options[0], 50); + assert.equal(options[options.length - 1], 26_000); + assert.equal(options.every((dpi) => dpi % 50 === 0), true); +}); diff --git a/src/drivers/gwolves/hid.ts b/src/drivers/gwolves/hid.ts new file mode 100644 index 0000000..09dddb6 --- /dev/null +++ b/src/drivers/gwolves/hid.ts @@ -0,0 +1,256 @@ +import type { MouseStatus } from "../mouse-types.ts"; +import { + GWOLVES_ADDRESS, + GWOLVES_COMMAND, + GWOLVES_REPORT_ID, + gwolvesBuildReadPayload, + gwolvesBuildSimplePayload, + gwolvesBuildWritePayload, + gwolvesBuildWriteScalarPayload, + gwolvesDecodeProfile, + gwolvesEncodeDpi, + gwolvesEncodePollingRate, + gwolvesParseBattery, + gwolvesParseReadResponse, + gwolvesReportChecksumIsValid, +} from "@openmouse/protocol/gwolves"; +import { GWOLVES_PRODUCTS, GWOLVES_VENDOR_ID, type GWolvesProduct } from "./products.ts"; + +// G-Wolves mice enumerate under their own vendor id (0x33e4) but speak the +// exact same shared VGN-family wire protocol already implemented +// independently in this repo for the VGN Dragonfly F2 Master+ and the +// Pulsar 4K Wireless Receiver — identical opcodes, checksums, EEPROM +// address map, DPI encoding, and polling-rate encoding. This driver uses +// its own protocol module (../../gwolves/index.ts) rather than importing +// vgn's, matching the pattern already established for the Pulsar/VGN case: +// independent per-brand implementation of a shared algorithm, so a future +// G-Wolves-specific quirk can diverge without touching another brand's +// tested code. Confirmed against real hardware 2026-08-21 via a live HID +// capture (browser sendReport/inputreport patch) while changing DPI, LOD, +// and polling rate on the official G-Wolves web driver at mouse.fit, then +// independently reproduced with hidapitester with no browser involved at +// all. See PROTOCOL-NOTES.md in the PR for the raw captured packets this +// was verified against. +// +// This class is intentionally model-agnostic: per-product identity (name, +// wireless/wired, verified-on-hardware status) lives in ./products.ts, not +// here. Adding support for another G-Wolves model that turns out to share +// this same protocol should just mean a new entry in that catalog. +const RESPONSE_TIMEOUT_MS = 700; +const SUPPORTED_POLLING_RATES = [125, 250, 500, 1000, 2000, 4000, 8000]; + +export class GWolvesHidClient { + readonly device: HIDDevice; + private waiter: { + command: number; + resolve: (response: Uint8Array) => void; + reject: (error: Error) => void; + timer: number; + } | null = null; + + private readonly onInputReport = (event: HIDInputReportEvent): void => { + if (event.reportId !== GWOLVES_REPORT_ID) return; + const response = new Uint8Array( + event.data.buffer.slice(event.data.byteOffset, event.data.byteOffset + event.data.byteLength), + ); + const waiter = this.waiter; + if (!waiter || response[0] !== waiter.command) return; + window.clearTimeout(waiter.timer); + this.waiter = null; + waiter.resolve(response); + }; + + constructor(device: HIDDevice) { + this.device = device; + } + + // Only product ids present in GWOLVES_PRODUCTS (see ./products.ts) are + // accepted — this is the single place that catalog is consulted for + // device recognition, so adding a model is purely a data change there. + static isSupported(device: HIDDevice): boolean { + if (device.vendorId !== GWOLVES_VENDOR_ID) return false; + if (!GWOLVES_PRODUCTS.has(device.productId)) return false; + return device.collections.some((collection) => + collection.usagePage === 0xff02 + && collection.inputReports.some((report) => report.reportId === GWOLVES_REPORT_ID && this.reportLength(report) === 16) + && collection.outputReports.some((report) => report.reportId === GWOLVES_REPORT_ID && this.reportLength(report) === 16)); + } + + private get product(): GWolvesProduct { + // isSupported() is always checked before a client is constructed (see + // registry.ts), so an unknown product id here means a caller bypassed + // that check rather than a real runtime case to handle gracefully. + const product = GWOLVES_PRODUCTS.get(this.device.productId); + if (!product) throw new Error(`Unrecognized G-Wolves product id 0x${this.device.productId.toString(16)}.`); + return product; + } + + isWirelessPath(): boolean { + return this.product.wireless; + } + + get pollIntervalMs(): number { + return this.isWirelessPath() ? 10_000 : 30_000; + } + + getDpiOptions(): number[] { + const values: number[] = []; + for (let dpi = 50; dpi <= 26_000; dpi += 50) values.push(dpi); + return values; + } + + async open(): Promise { + if (!this.device.opened) await this.device.open(); + this.device.removeEventListener("inputreport", this.onInputReport); + this.device.addEventListener("inputreport", this.onInputReport); + } + + async readStatus(): Promise { + await this.open(); + const { model, wireless } = this.product; + const batteryResponse = await this.transact(gwolvesBuildSimplePayload(GWOLVES_COMMAND.battery)); + const firmwareResponse = await this.transact(gwolvesBuildSimplePayload(GWOLVES_COMMAND.firmware)); + const profile = await this.readProfile(); + const battery = gwolvesParseBattery(batteryResponse); + const settings = gwolvesDecodeProfile(profile); + const firmware = this.version(firmwareResponse); + + return { + brand: "G-Wolves", + name: `G-Wolves ${model}`, + batteryPercent: battery?.percent ?? null, + batteryVoltageMv: battery?.voltageMv ?? null, + batteryState: battery + ? battery.charging ? (battery.percent >= 99 ? "Full" : "Charging") : "Discharging" + : "Unknown", + dpi: settings.dpi, + pollingRateHz: settings.pollingRateHz, + supportedPollingRates: SUPPORTED_POLLING_RATES, + activeProfile: null, + connectionType: wireless ? "Wireless" : "Wired", + connectionDetail: wireless ? "2.4 GHz receiver · 8K protocol" : "USB · 8K protocol", + motionSync: settings.motionSync, + debounceMs: settings.debounceMs, + sleepTimeout: settings.sleepTimeout === null ? null : settings.sleepTimeout / 10, + angleSnapping: settings.angleSnapping, + rippleControl: settings.rippleControl, + performanceMode: settings.performanceMode, + liftOffDistance: settings.liftOffDistance, + firmware: firmware ? [`Mouse ${firmware}`] : [], + ui: { + family: "vgn-f2", + hideUnsupportedPollingRates: true, + forceShowBattery: false, + pollingNote: `${model} supports 125 Hz through 8,000 Hz over its shared VGN-family protocol.`, + defaultDisplayName: `G-Wolves ${model}`, + }, + }; + } + + async setDpi(dpi: number): Promise { + const profile = gwolvesDecodeProfile(await this.readProfile()); + const address = GWOLVES_ADDRESS.dpiStages + profile.activeDpiStage * 4; + await this.write(address, [...gwolvesEncodeDpi(dpi)]); + const confirmed = gwolvesDecodeProfile(await this.readProfile()).dpi; + if (confirmed !== dpi) throw new Error(`The ${this.product.model} kept ${confirmed} DPI instead of ${dpi} DPI.`); + return confirmed; + } + + async setPollingRate(rate: number): Promise { + await this.writeScalar(GWOLVES_ADDRESS.pollingRate, gwolvesEncodePollingRate(rate)); + const confirmed = gwolvesDecodeProfile(await this.readProfile()).pollingRateHz; + if (confirmed !== rate) { + const hint = this.isWirelessPath() + ? " (on the wireless path, the mouse must be actively awake — try moving it first)" + : ""; + throw new Error(`The ${this.product.model} kept ${confirmed} Hz instead of ${rate} Hz.${hint}`); + } + return confirmed; + } + + async setLiftOffDistance(value: NonNullable): Promise> { + const raw = value === "Low" ? 3 : value === "Medium" ? 1 : 2; + await this.writeScalar(GWOLVES_ADDRESS.lod, raw); + const confirmed = gwolvesDecodeProfile(await this.readProfile()).liftOffDistance; + if (confirmed !== value) throw new Error(`The ${this.product.model} kept ${confirmed ?? "unknown"} LOD instead of ${value}.`); + return confirmed; + } + + async close(): Promise { + this.failWaiter(new Error("The G-Wolves device was closed.")); + this.device.removeEventListener("inputreport", this.onInputReport); + if (this.device.opened) await this.device.close(); + } + + private async readProfile(): Promise { + const profile = new Uint8Array(0xb7); + for (const [start, end] of [[0, 0x2c], [0xa8, 0xb7]] as const) { + for (let address = start; address < end; address += 10) { + const length = Math.min(10, end - address); + profile.set(await this.read(address, length), address); + } + } + return profile; + } + + private async read(address: number, length: number): Promise { + const response = await this.transact(gwolvesBuildReadPayload(address, length)); + const data = gwolvesParseReadResponse(response, address, length); + if (!data) throw new Error(`${this.product.model} flash read failed at 0x${address.toString(16)}.`); + return data; + } + + private async writeScalar(address: number, value: number): Promise { + const response = await this.transact(gwolvesBuildWriteScalarPayload(address, value)); + if (!gwolvesReportChecksumIsValid(response) || response[0] !== GWOLVES_COMMAND.write || response[1] !== 0) { + throw new Error(`${this.product.model} flash write failed at 0x${address.toString(16)}.`); + } + const confirmed = await this.read(address, 2); + if (confirmed[0] !== value || ((confirmed[0]! + confirmed[1]!) & 0xff) !== 0x55) { + throw new Error(`The ${this.product.model} did not retain the value written at 0x${address.toString(16)}.`); + } + } + + private async write(address: number, data: readonly number[]): Promise { + const response = await this.transact(gwolvesBuildWritePayload(address, data)); + if (!gwolvesReportChecksumIsValid(response) || response[0] !== GWOLVES_COMMAND.write || response[1] !== 0) { + throw new Error(`${this.product.model} flash write failed at 0x${address.toString(16)}.`); + } + } + + private async transact(payload: Uint8Array): Promise { + await this.open(); + this.failWaiter(new Error("Superseded by another G-Wolves request.")); + const command = payload[0]!; + const response = new Promise((resolve, reject) => { + const timer = window.setTimeout(() => { + if (this.waiter?.resolve === resolve) this.waiter = null; + reject(new Error(`Timed out waiting for G-Wolves command 0x${command.toString(16)}.`)); + }, RESPONSE_TIMEOUT_MS); + this.waiter = { command, resolve, reject, timer }; + }); + try { + await this.device.sendReport(GWOLVES_REPORT_ID, new Uint8Array(payload).buffer); + } catch (error) { + this.failWaiter(error instanceof Error ? error : new Error(String(error))); + } + return response; + } + + private version(response: Uint8Array): string | null { + if (!gwolvesReportChecksumIsValid(response) || response[0] !== GWOLVES_COMMAND.firmware || response[1] !== 0) return null; + return `v${response[5] ?? 0}.${(response[6] ?? 0).toString(16).padStart(2, "0")}`; + } + + private failWaiter(error: Error): void { + const waiter = this.waiter; + if (!waiter) return; + window.clearTimeout(waiter.timer); + this.waiter = null; + waiter.reject(error); + } + + private static reportLength(report: HIDReportInfo): number { + return report.items.reduce((sum, item) => sum + item.reportSize * item.reportCount, 0) / 8; + } +} diff --git a/src/drivers/gwolves/products.ts b/src/drivers/gwolves/products.ts new file mode 100644 index 0000000..15f99d8 --- /dev/null +++ b/src/drivers/gwolves/products.ts @@ -0,0 +1,28 @@ +export interface GWolvesProduct { + model: string; + wireless: boolean; + /** + * Only `verified: true` entries have been exercised against real hardware + * by this project. Everything else would be a guess based on "probably + * the same shared VGN-family protocol as the HTX Ultra" and should not be + * assumed correct until actually tested — see PROTOCOL-NOTES.md in the + * HTX Ultra PR for how the verified entries were confirmed (live HID + * capture while using the official web driver at mouse.fit, cross-checked + * independently with hidapitester). + */ + verified: boolean; +} + +export const GWOLVES_VENDOR_ID = 0x33e4; + +export const GWOLVES_PRODUCTS: ReadonlyMap = new Map([ + [0x5618, { model: "HTX Ultra", wireless: false, verified: true }], + [0x3854, { model: "HTX Ultra", wireless: true, verified: true }], + // Add further G-Wolves models here as they're captured/verified. Every + // G-Wolves mouse checked so far speaks the exact same shared VGN-family + // wire protocol (same opcodes/checksums/EEPROM map as the HTX Ultra), so + // a new model is very likely just a new product-id entry here — but + // confirm with a real capture before setting verified: true, since a + // wrong address/encoding on an unverified model could silently write the + // wrong value rather than fail loudly. +]); diff --git a/src/drivers/gwolves/protocol.test.ts b/src/drivers/gwolves/protocol.test.ts new file mode 100644 index 0000000..93c6faa --- /dev/null +++ b/src/drivers/gwolves/protocol.test.ts @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + gwolvesBuildReadPayload, + gwolvesBuildWritePayload, + gwolvesBuildWriteScalarPayload, + gwolvesDecodeDpi, + gwolvesDecodePollingRate, + gwolvesEncodeDpi, + gwolvesEncodePollingRate, + gwolvesReportChecksumIsValid, +} from "@openmouse/protocol/gwolves"; + +test("captured DPI write (1650 -> 1600) round-trips through encode/decode", () => { + // Real packets captured live via browser sendReport/inputreport patch + // while using the official G-Wolves web driver at mouse.fit — see + // PROTOCOL-NOTES.md in the PR. + const captured1650 = [7, 0, 0, 12, 4, 32, 32, 0, 21, 0, 0, 0, 0, 0, 0, 225]; + const captured1600 = [7, 0, 0, 12, 4, 31, 31, 0, 23, 0, 0, 0, 0, 0, 0, 225]; + + assert.equal(gwolvesReportChecksumIsValid(captured1650), true); + assert.equal(gwolvesReportChecksumIsValid(captured1600), true); + + const stage1650 = captured1650.slice(5, 9); + const stage1600 = captured1600.slice(5, 9); + assert.equal(gwolvesDecodeDpi(stage1650), 1650); + assert.equal(gwolvesDecodeDpi(stage1600), 1600); + + assert.deepEqual([...gwolvesEncodeDpi(1650)], stage1650); + assert.deepEqual([...gwolvesEncodeDpi(1600)], stage1600); +}); + +test("captured LOD writes (Low/Medium/High) match the firmware's own enum order", () => { + // Not sequential (0/1/2) — this is the firmware's internal ordering, + // confirmed by capturing all three levels against real hardware. + const low = [7, 0, 0, 10, 2, 3, 82, 0, 0, 0, 0, 0, 0, 0, 0, 229]; + const medium = [7, 0, 0, 10, 2, 1, 84, 0, 0, 0, 0, 0, 0, 0, 0, 229]; + const high = [7, 0, 0, 10, 2, 2, 83, 0, 0, 0, 0, 0, 0, 0, 0, 229]; + + for (const packet of [low, medium, high]) { + assert.equal(gwolvesReportChecksumIsValid(packet), true); + } + assert.deepEqual([...gwolvesBuildWriteScalarPayload(0x0a, 3)], low); + assert.deepEqual([...gwolvesBuildWriteScalarPayload(0x0a, 1)], medium); + assert.deepEqual([...gwolvesBuildWriteScalarPayload(0x0a, 2)], high); +}); + +test("captured polling rate writes cover all 7 supported rates", () => { + const captured: Record = { + 125: [7, 0, 0, 0, 2, 8, 77, 0, 0, 0, 0, 0, 0, 0, 0, 239], + 250: [7, 0, 0, 0, 2, 4, 81, 0, 0, 0, 0, 0, 0, 0, 0, 239], + 500: [7, 0, 0, 0, 2, 2, 83, 0, 0, 0, 0, 0, 0, 0, 0, 239], + 1000: [7, 0, 0, 0, 2, 1, 84, 0, 0, 0, 0, 0, 0, 0, 0, 239], + 2000: [7, 0, 0, 0, 2, 16, 69, 0, 0, 0, 0, 0, 0, 0, 0, 239], + 4000: [7, 0, 0, 0, 2, 32, 53, 0, 0, 0, 0, 0, 0, 0, 0, 239], + 8000: [7, 0, 0, 0, 2, 64, 21, 0, 0, 0, 0, 0, 0, 0, 0, 239], + }; + + for (const [rate, packet] of Object.entries(captured)) { + assert.equal(gwolvesReportChecksumIsValid(packet), true); + const encoded = gwolvesEncodePollingRate(Number(rate)); + assert.equal(encoded, packet[5]); + assert.equal(gwolvesDecodePollingRate(encoded), Number(rate)); + assert.deepEqual([...gwolvesBuildWriteScalarPayload(0x00, encoded)], packet); + } +}); + +test("read payload matches the captured polling-rate read probe", () => { + // [8, 0, 0, 0, 2, ...zeros..., 67] — read 2 bytes at address 0. This + // exact packet was confirmed working against real hardware via + // hidapitester (see PROTOCOL-NOTES.md). + const expected = [8, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 67]; + assert.deepEqual([...gwolvesBuildReadPayload(0, 2)], expected); +}); + +test("write payload rejects out-of-range addresses and oversized chunks", () => { + assert.throws(() => gwolvesBuildWritePayload(-1, [1])); + assert.throws(() => gwolvesBuildWritePayload(0x10000, [1])); + assert.throws(() => gwolvesBuildWritePayload(0, new Array(11).fill(0))); +}); + +test("DPI encode rejects values outside the confirmed 50-26000 step range", () => { + assert.throws(() => gwolvesEncodeDpi(0)); + assert.throws(() => gwolvesEncodeDpi(49)); + assert.throws(() => gwolvesEncodeDpi(26050)); + assert.throws(() => gwolvesEncodeDpi(1625)); // not a multiple of 50 +}); diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 0e2ae28..c345651 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -25,9 +25,10 @@ import { WallhackMouseHidClient } from "./wallhack/mouse-hid.ts"; import { WLMouseHidClient } from "./wlmouse/hid.ts"; import { WootingHidClient } from "./wooting/hid.ts"; import { ZaunkoenigHidClient } from "./zaunkoenig/hid.ts"; +import { GWolvesHidClient } from "./gwolves/hid.ts"; export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient; -export type SupportedClient = LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | LamzuHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | VgnF2HidClient | KeychronHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | AttackSharkHidClient | FantechHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient; +export type SupportedClient = LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | LamzuHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | VgnF2HidClient | KeychronHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | AttackSharkHidClient | FantechHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient; export interface DeviceDriver { brand: string; @@ -64,6 +65,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "Wooting", supports: (device) => WootingHidClient.isSupported(device), create: (device) => new WootingHidClient(device), score: () => 6 }, { brand: "WALLHACK", supports: (device) => WallhackMouseHidClient.isSupported(device), create: (device) => new WallhackMouseHidClient(device), score: () => 8 }, { brand: "WALLHACK", supports: (device) => WallhackKeyboardHidClient.isSupported(device), create: (device) => new WallhackKeyboardHidClient(device), score: () => 8 }, + { brand: "G-Wolves", supports: (device) => GWolvesHidClient.isSupported(device), create: (device) => new GWolvesHidClient(device), score: () => 7 }, ]; function driverFor(device: HIDDevice): DeviceDriver | undefined { diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index e74903d..cc1ba3a 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -59,6 +59,7 @@ export const VENDOR_ID = { wooting: WOOTING_VENDOR_ID, wallhack: WALLHACK_VENDOR_ID, wallhackKeyboardAlt: WALLHACK_KEYBOARD_ALT_VENDOR_ID, + gwolves: 0x33e4, } as const; // Keychron VIA raw HID. 0x0440 is Nape Pro wired; 0xd026/0xd029 are shared Link-KM receivers. @@ -297,4 +298,6 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ // Fantech mice use vendor usage page 0xFFFF, usage 0x02 for configuration. { vendorId: VENDOR_ID.fantech, usagePage: 0xffff, usage: 0x02 }, ...WALLHACK_HID_FILTERS, + { vendorId: VENDOR_ID.gwolves, productId: 0x5618, usagePage: 0xff02 }, + { vendorId: VENDOR_ID.gwolves, productId: 0x3854, usagePage: 0xff02 }, ]; diff --git a/src/gwolves/index.ts b/src/gwolves/index.ts new file mode 100644 index 0000000..3c5f46c --- /dev/null +++ b/src/gwolves/index.ts @@ -0,0 +1,221 @@ +/** + * G-Wolves HTX Ultra protocol, pure functions, unit-tested without WebHID. + * + * G-Wolves mice enumerate under G-Wolves' own vendor id (0x33e4) but speak + * the exact same wire protocol as the shared VGN-family reference design + * already implemented independently in this repo for the VGN Dragonfly F2 + * Master+ and (as `pulsarVgn*` in pulsar/index.ts) the Pulsar 4K Wireless + * Receiver: 16-byte packets on report id 8, opcode 0x07 writes an EEPROM + * address and 0x08 reads one, and every packet's bytes (plus the report id) + * sum to 0x55. Following the pattern already established for the Pulsar/VGN + * case, this is an independent implementation rather than an import of + * vgn/index.ts's functions — same algorithm, kept as its own module so a + * G-Wolves-specific quirk (like ATK's differing DPI stage encoding + * relative to Endgame Gear WE) can diverge cleanly later without touching + * code another brand's driver depends on. + * + * Confirmed against real hardware 2026-08-21: a live HID capture (browser + * sendReport/inputreport patch) while changing DPI, LOD, and polling rate + * on the official G-Wolves web driver at mouse.fit, then independently + * reproduced with hidapitester with no browser involved at all. See + * PROTOCOL-NOTES.md in the PR for the raw captured packets this was + * verified against. + */ + +export const GWOLVES_REPORT_ID = 0x08; +export const GWOLVES_PAYLOAD_LENGTH = 16; +export const GWOLVES_MAX_CHUNK = 10; + +export const GWOLVES_COMMAND = { + handshake: 0x01, + online: 0x03, + battery: 0x04, + write: 0x07, + read: 0x08, + profile: 0x0e, + firmware: 0x12, + dongleFirmware: 0x1d, +} as const; + +export const GWOLVES_ADDRESS = { + pollingRate: 0x00, + dpiStageCount: 0x02, + activeDpiStage: 0x04, + lod: 0x0a, + dpiStages: 0x0c, + debounce: 0xa9, + motionSync: 0xab, + sleep: 0xad, + angleSnapping: 0xaf, + rippleControl: 0xb1, + performanceMode: 0xb5, +} as const; + +export interface GWolvesBattery { + percent: number; + charging: boolean; + voltageMv: number; +} + +export interface GWolvesProfile { + dpi: number; + dpiStageCount: number; + activeDpiStage: number; + pollingRateHz: number; + liftOffDistance: "Low" | "Medium" | "High" | null; + debounceMs: number | null; + sleepTimeout: number | null; + motionSync: boolean | null; + angleSnapping: boolean | null; + rippleControl: boolean | null; + performanceMode: boolean | null; +} + +function assertByte(value: number, label: string): void { + if (!Number.isInteger(value) || value < 0 || value > 0xff) { + throw new Error(`${label} must be one byte.`); + } +} + +export function gwolvesReportChecksum(data15: Iterable): number { + let sum = GWOLVES_REPORT_ID; + for (const byte of data15) sum += byte & 0xff; + return (0x55 - (sum & 0xff)) & 0xff; +} + +export function gwolvesReportChecksumIsValid(payload: Uint8Array | readonly number[]): boolean { + if (payload.length !== GWOLVES_PAYLOAD_LENGTH) return false; + return ((GWOLVES_REPORT_ID + [...payload].reduce((sum, byte) => sum + (byte & 0xff), 0)) & 0xff) === 0x55; +} + +function finalize(payload: Uint8Array): Uint8Array { + payload[15] = gwolvesReportChecksum(payload.subarray(0, 15)); + return payload; +} + +export function gwolvesBuildSimplePayload(command: number): Uint8Array { + assertByte(command, "G-Wolves command"); + const payload = new Uint8Array(GWOLVES_PAYLOAD_LENGTH); + payload[0] = command; + return finalize(payload); +} + +export function gwolvesBuildReadPayload(address: number, length: number): Uint8Array { + if (!Number.isInteger(address) || address < 0 || address > 0xffff) throw new Error("G-Wolves address is out of range."); + if (!Number.isInteger(length) || length < 1 || length > GWOLVES_MAX_CHUNK) throw new Error("G-Wolves read length must be 1–10 bytes."); + const payload = gwolvesBuildSimplePayload(GWOLVES_COMMAND.read); + payload[2] = address >> 8; + payload[3] = address & 0xff; + payload[4] = length; + return finalize(payload); +} + +export function gwolvesBuildWritePayload(address: number, data: readonly number[]): Uint8Array { + if (!Number.isInteger(address) || address < 0 || address > 0xffff) throw new Error("G-Wolves address is out of range."); + if (data.length < 1 || data.length > GWOLVES_MAX_CHUNK) throw new Error("G-Wolves write length must be 1–10 bytes."); + const payload = gwolvesBuildSimplePayload(GWOLVES_COMMAND.write); + payload[2] = address >> 8; + payload[3] = address & 0xff; + payload[4] = data.length; + payload.set(data.map((byte) => byte & 0xff), 5); + return finalize(payload); +} + +export function gwolvesBuildWriteScalarPayload(address: number, value: number): Uint8Array { + assertByte(value, "G-Wolves scalar"); + return gwolvesBuildWritePayload(address, [value, (0x55 - value) & 0xff]); +} + +export function gwolvesParseReadResponse(response: Uint8Array, address: number, length: number): Uint8Array | null { + if (!gwolvesReportChecksumIsValid(response)) return null; + if (response[0] !== GWOLVES_COMMAND.read || response[1] !== 0) return null; + if (response[2] !== ((address >> 8) & 0xff) || response[3] !== (address & 0xff)) return null; + if (response[4] !== length || response.length < 5 + length) return null; + return response.slice(5, 5 + length); +} + +export function gwolvesParseBattery(response: Uint8Array): GWolvesBattery | null { + if (!gwolvesReportChecksumIsValid(response) || response[0] !== GWOLVES_COMMAND.battery || response[1] !== 0) return null; + const percent = response[5]; + if (percent === undefined || percent > 100) return null; + return { + percent, + charging: response[6] === 1, + voltageMv: ((response[7] ?? 0) << 8) | (response[8] ?? 0), + }; +} + +// Confirmed on real hardware across all 7 rates: rates <=1000Hz encode as the +// polling period in whole milliseconds (1000/rate); rates above 1000Hz can't +// be a whole-ms period, so the firmware switches to a one-hot bit flag +// instead (bit 4/5/6 for 2000/4000/8000Hz respectively). +export function gwolvesEncodePollingRate(rate: number): number { + const raw: Record = { 125: 8, 250: 4, 500: 2, 1000: 1, 2000: 16, 4000: 32, 8000: 64 }; + const value = raw[rate]; + if (value === undefined) throw new Error("G-Wolves polling rate must be 125, 250, 500, 1000, 2000, 4000, or 8000 Hz."); + return value; +} + +export function gwolvesDecodePollingRate(raw: number): number | null { + return ({ 8: 125, 4: 250, 2: 500, 1: 1000, 16: 2000, 32: 4000, 64: 8000 } as Record)[raw] ?? null; +} + +// Confirmed on real hardware (1600 <-> 1650 DPI captures): 4-byte stage +// entries, [xStep, xStep, flags, checksum], flat 50-DPI steps encoded as +// step-1, with the checksum making all 4 payload bytes sum to 0x55. +export function gwolvesEncodeDpi(dpi: number): Uint8Array { + if (!Number.isInteger(dpi) || dpi < 50 || dpi > 26000 || dpi % 50 !== 0) { + throw new Error("G-Wolves DPI must be 50–26,000 in 50 DPI steps."); + } + const encoded = dpi / 50 - 1; + const low = encoded & 0xff; + const high = (encoded >> 8) & 0x03; + const flags = (high << 2) | (high << 6); + return new Uint8Array([low, low, flags, (0x55 - low - low - flags) & 0xff]); +} + +export function gwolvesDecodeDpi(stage: Uint8Array | readonly number[]): number | null { + if (stage.length < 4) return null; + const low = stage[0]! & 0xff; + const duplicate = stage[1]! & 0xff; + const flags = stage[2]! & 0xff; + const checksum = stage[3]! & 0xff; + if (low !== duplicate || ((low + duplicate + flags + checksum) & 0xff) !== 0x55) return null; + return ((((flags >> 2) & 0x03) << 8) + low + 1) * 50; +} + +export function gwolvesUnpackScalar(value: number, parity: number): number | null { + return ((value + parity) & 0xff) === 0x55 ? value : null; +} + +// Confirmed on real hardware: LOD Low=3, Medium=1, High=2 (not sequential — +// this is the firmware's own internal ordering). +export function gwolvesDecodeProfile(profile: Uint8Array): GWolvesProfile { + const scalar = (address: number): number | null => profile.length > address + 1 + ? gwolvesUnpackScalar(profile[address]!, profile[address + 1]!) + : null; + const stageCount = Math.min(Math.max(scalar(GWOLVES_ADDRESS.dpiStageCount) ?? 1, 1), 8); + const activeStage = Math.min(Math.max(scalar(GWOLVES_ADDRESS.activeDpiStage) ?? 0, 0), stageCount - 1); + const stageOffset = GWOLVES_ADDRESS.dpiStages + activeStage * 4; + const dpi = gwolvesDecodeDpi(profile.subarray(stageOffset, stageOffset + 4)) ?? 800; + const lodRaw = scalar(GWOLVES_ADDRESS.lod); + const pollingRaw = scalar(GWOLVES_ADDRESS.pollingRate); + const boolean = (address: number): boolean | null => { + const value = scalar(address); + return value === null ? null : value !== 0; + }; + + return { + dpi, + dpiStageCount: stageCount, + activeDpiStage: activeStage, + pollingRateHz: pollingRaw === null ? 1000 : (gwolvesDecodePollingRate(pollingRaw) ?? 1000), + liftOffDistance: lodRaw === 3 ? "Low" : lodRaw === 1 ? "Medium" : lodRaw === 2 ? "High" : null, + debounceMs: scalar(GWOLVES_ADDRESS.debounce), + sleepTimeout: (scalar(GWOLVES_ADDRESS.sleep) ?? 0) * 10 || null, + motionSync: boolean(GWOLVES_ADDRESS.motionSync), + angleSnapping: boolean(GWOLVES_ADDRESS.angleSnapping), + rippleControl: boolean(GWOLVES_ADDRESS.rippleControl), + performanceMode: boolean(GWOLVES_ADDRESS.performanceMode), + }; +} diff --git a/src/index.ts b/src/index.ts index d71ebce..d325b8b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,3 +16,4 @@ export * as teevolution from "./teevolution/index.js"; export * as vgn from "./vgn/index.js"; export * as wlmouse from "./wlmouse/index.js"; export * as zaunkoenig from "./zaunkoenig/index.js"; +export * as gwolves from "./gwolves/index.js";