From 5d4145b4bf46e7474e907c3deb647bf304d1c5e7 Mon Sep 17 00:00:00 2001 From: Onyx Date: Sun, 13 Sep 2026 14:58:57 +0200 Subject: [PATCH 1/4] glorious: detect the classic-line feature-report id instead of assuming 0 A real 0x320f:0x823a (Model O V2 Wired) carried its config channel as numbered report 7, not the unnumbered report every other classic-family device uses - isSupported() rejected it outright since the check was hardcoded to report id 0. Now discovered per device at connect time and used for every send/ receive call. WebHID takes the report id separately from the payload bytes, so existing devices (report id 0) are unaffected. --- src/drivers/glorious/classic-hid.test.ts | 22 +++++++++ src/drivers/glorious/classic-hid.ts | 57 +++++++++++++++++------- 2 files changed, 64 insertions(+), 15 deletions(-) diff --git a/src/drivers/glorious/classic-hid.test.ts b/src/drivers/glorious/classic-hid.test.ts index d95acff..125bf30 100644 --- a/src/drivers/glorious/classic-hid.test.ts +++ b/src/drivers/glorious/classic-hid.test.ts @@ -58,6 +58,28 @@ test("recognizes a Model D Wireless whose config lives on the 0xffff:0 collectio assert.equal(GloriousClassicHidClient.isSupported(device), true); }); +test("recognizes a Model O V2 Wired whose config channel is numbered report 7", () => { + // A real 0x320f:0x823a rejected connection entirely: its `usage 0xff01:1` + // collection carries feature report id 7, not the unnumbered report (0) + // every other classic-family device uses. + const { device } = fakeDevice(VENDOR_ID.gloriousClassicIWired, 0x823a, "Model O 2 Wired Mouse"); + (device.collections[0].featureReports[0] as { reportId: number }).reportId = 7; + assert.equal(GloriousClassicHidClient.isSupported(device), true); +}); + +test("a numbered config report is used for every write, not the unnumbered default", async () => { + const { device, sent } = fakeDevice(VENDOR_ID.gloriousClassicIWired, 0x823a); + (device.collections[0].featureReports[0] as { reportId: number }).reportId = 7; + const client = new GloriousClassicHidClient(device); + + await client.setDpi(1600); + await client.setPollingRate(500); + await client.setLiftOffDistance("High"); + + assert.ok(sent.length >= 3); + for (const report of sent) assert.equal(report.reportId, 7, "every write should use the discovered report id"); +}); + test("rejects an unrecognized VID/PID pair", () => { const { device } = fakeDevice(VENDOR_ID.gloriousO3, 0x1234); assert.equal(GloriousClassicHidClient.isSupported(device), false); diff --git a/src/drivers/glorious/classic-hid.ts b/src/drivers/glorious/classic-hid.ts index cd70169..37a2355 100644 --- a/src/drivers/glorious/classic-hid.ts +++ b/src/drivers/glorious/classic-hid.ts @@ -32,8 +32,11 @@ import { GLORIOUS_CLASSIC_PRODUCTS, VENDOR_ID } from "../vendors.ts"; * D-, Model I, Model O V2 — plus the newer "core2" 8000Hz-class mice, Model * O3 Wireless and Model D 2 PRO 4K/8KHz Edition, on a reduced feature set — * see the `generation` doc comment on `GLORIOUS_CLASSIC_PRODUCTS` in - * vendors.ts for why). The config channel is an unnumbered 64-byte feature - * report; see ../../glorious-classic/index.ts for the payload layout. + * vendors.ts for why). The config channel is a 64-byte feature report - + * unnumbered (id 0) on most units, but a real "Model O V2 Wired" + * (0x320f:0x823a) carries it as numbered report 7 instead; the report id is + * detected per device rather than assumed. See ../../glorious-classic/index.ts + * for the payload layout, which does not depend on the report id. * * DPI, polling rate, lift-off distance, and RGB are all write-only on this * protocol (neither glorious-ctl nor mxw, the two tools this was ported @@ -82,10 +85,20 @@ const DEFAULT_STATE: GloriousClassicState = { export class GloriousClassicHidClient { readonly pollIntervalMs = 0; readonly device: HIDDevice; + /** + * The vendor collection's feature report id. Most units use the unnumbered + * report (0, GLORIOUS_CLASSIC_REPORT_ID); a real "Model O V2 Wired" + * (0x320f:0x823a) instead carries it as numbered report 7. Discovered at + * connect time rather than assumed - WebHID takes the report id separately + * from the payload bytes, so the wire encoding itself does not change, + * only which report number carries it. + */ + private readonly reportId: number; private lastRgb: GloriousClassicRgb = GLORIOUS_CLASSIC_DEFAULT_RGB; constructor(device: HIDDevice) { this.device = device; + this.reportId = GloriousClassicHidClient.findConfigReportId(device) ?? GLORIOUS_CLASSIC_REPORT_ID; } static isSupported(device: HIDDevice): boolean { @@ -94,13 +107,27 @@ export class GloriousClassicHidClient { && device.vendorId !== VENDOR_ID.gloriousClassicIWired && device.vendorId !== VENDOR_ID.gloriousO3) return false; if (!GLORIOUS_CLASSIC_PRODUCTS.has(device.productId)) return false; - return device.collections.some((collection) => this.hasConfigReport(collection)); + return this.findConfigReportId(device) !== null; } - private static hasConfigReport(collection: HIDCollectionInfo): boolean { - const matchesHere = CLASSIC_USAGE_PAGES.includes(collection.usagePage) - && collection.featureReports.some((report) => report.reportId === GLORIOUS_CLASSIC_REPORT_ID); - return matchesHere || collection.children.some((child) => this.hasConfigReport(child)); + /** The feature-report id carried by whichever collection is the config channel, or null. */ + private static findConfigReportId(device: HIDDevice): number | null { + for (const collection of device.collections) { + const found = this.findConfigReportIdIn(collection); + if (found !== null) return found; + } + return null; + } + + private static findConfigReportIdIn(collection: HIDCollectionInfo): number | null { + if (CLASSIC_USAGE_PAGES.includes(collection.usagePage) && collection.featureReports.length > 0) { + return collection.featureReports[0].reportId; + } + for (const child of collection.children) { + const found = this.findConfigReportIdIn(child); + if (found !== null) return found; + } + return null; } async open(): Promise { @@ -182,11 +209,11 @@ export class GloriousClassicHidClient { state.stageDpis[state.activeStage] = rounded; await this.open(); await this.device.sendFeatureReport( - GLORIOUS_CLASSIC_REPORT_ID, + this.reportId, buildGloriousClassicDpiStagesPayload(state.stageDpis, state.profileId), ); await this.device.sendFeatureReport( - GLORIOUS_CLASSIC_REPORT_ID, + this.reportId, buildGloriousClassicActiveStagePayload(state.activeStage + 1, state.profileId), ); this.saveState(state); @@ -201,7 +228,7 @@ export class GloriousClassicHidClient { if (intervalMs === null) throw new Error(`This mouse does not support ${pollingRateHz} Hz.`); const state = this.loadState(); await this.open(); - await this.device.sendFeatureReport(GLORIOUS_CLASSIC_REPORT_ID, buildGloriousClassicPollingRatePayload(intervalMs)); + await this.device.sendFeatureReport(this.reportId, buildGloriousClassicPollingRatePayload(intervalMs)); state.pollingIntervalMs = intervalMs; this.saveState(state); return pollingRateHz; @@ -215,7 +242,7 @@ export class GloriousClassicHidClient { if (!millimetres) throw new Error(`This mouse does not support a ${value.toLowerCase()} lift-off distance.`); const state = this.loadState(); await this.open(); - await this.device.sendFeatureReport(GLORIOUS_CLASSIC_REPORT_ID, buildGloriousClassicLiftOffPayload(millimetres)); + await this.device.sendFeatureReport(this.reportId, buildGloriousClassicLiftOffPayload(millimetres)); state.lodMm = millimetres; this.saveState(state); return value; @@ -228,7 +255,7 @@ export class GloriousClassicHidClient { const state = this.loadState(); const clamped = Math.round(milliseconds); await this.open(); - await this.device.sendFeatureReport(GLORIOUS_CLASSIC_REPORT_ID, buildGloriousClassicDebouncePayload(clamped, state.profileId)); + await this.device.sendFeatureReport(this.reportId, buildGloriousClassicDebouncePayload(clamped, state.profileId)); state.debounceMs = clamped; this.saveState(state); return clamped; @@ -240,15 +267,15 @@ export class GloriousClassicHidClient { async setRgb(rgb: GloriousClassicRgb): Promise { await this.open(); - await this.device.sendFeatureReport(GLORIOUS_CLASSIC_REPORT_ID, buildGloriousClassicRgbPayload(rgb)); + await this.device.sendFeatureReport(this.reportId, buildGloriousClassicRgbPayload(rgb)); this.lastRgb = rgb; return rgb; } private async readBattery(): Promise { - await this.device.sendFeatureReport(GLORIOUS_CLASSIC_REPORT_ID, buildGloriousClassicBatteryRequestPayload()); + await this.device.sendFeatureReport(this.reportId, buildGloriousClassicBatteryRequestPayload()); await this.delay(BATTERY_RESPONSE_DELAY_MS); - const view = await this.device.receiveFeatureReport(GLORIOUS_CLASSIC_REPORT_ID); + const view = await this.device.receiveFeatureReport(this.reportId); const body = new Uint8Array(view.buffer, view.byteOffset, Math.min(view.byteLength, GLORIOUS_CLASSIC_PACKET_LENGTH)); return parseGloriousClassicBatteryResponse(body); } From fd3ed2673fa20022018c0802c42ebd04a283a6f4 Mon Sep 17 00:00:00 2001 From: Onyx Date: Sun, 13 Sep 2026 16:25:23 +0200 Subject: [PATCH 2/4] glorious: resize feature-report payloads to the device's declared length Connecting to the real 0x320f:0x823a unit worked after the report-id fix, but every write then failed with "Failed to write the feature report.": its numbered report 7 declares a shorter byte length than the 64-byte buffer every payload builder in glorious-classic/index.ts assumes, and WebHID rejects a feature report whose length does not match the device's own descriptor. Same fix shape as the report-id discovery: read the declared length off the same collection at connect time and resize (truncate or zero-pad) every payload to fit before sending. The actual command bytes are always well under the shortest length seen in practice, so truncating never touches meaningful data. --- src/drivers/glorious/classic-hid.test.ts | 16 +++++ src/drivers/glorious/classic-hid.ts | 83 +++++++++++++++--------- 2 files changed, 67 insertions(+), 32 deletions(-) diff --git a/src/drivers/glorious/classic-hid.test.ts b/src/drivers/glorious/classic-hid.test.ts index 125bf30..2800260 100644 --- a/src/drivers/glorious/classic-hid.test.ts +++ b/src/drivers/glorious/classic-hid.test.ts @@ -80,6 +80,22 @@ test("a numbered config report is used for every write, not the unnumbered defau for (const report of sent) assert.equal(report.reportId, 7, "every write should use the discovered report id"); }); +test("a shorter declared feature report is not sent at the full 64-byte length", async () => { + // The real 0x320f:0x823a unit's numbered report 7 also declared a length + // WebHID rejected the driver's fixed 64-byte payload against, throwing + // "Failed to write the feature report." on every write. + const { device, sent } = fakeDevice(VENDOR_ID.gloriousClassicIWired, 0x823a); + const report = device.collections[0].featureReports[0] as { reportId: number; items: Array<{ reportSize: number; reportCount: number }> }; + report.reportId = 7; + report.items = [{ reportSize: 8, reportCount: 32 }]; + const client = new GloriousClassicHidClient(device); + + await client.setDpi(1600); + + assert.ok(sent.length > 0); + for (const report of sent) assert.equal(report.payload.length, 32, "payload must match the device's declared 32-byte report"); +}); + test("rejects an unrecognized VID/PID pair", () => { const { device } = fakeDevice(VENDOR_ID.gloriousO3, 0x1234); assert.equal(GloriousClassicHidClient.isSupported(device), false); diff --git a/src/drivers/glorious/classic-hid.ts b/src/drivers/glorious/classic-hid.ts index 37a2355..bb391e8 100644 --- a/src/drivers/glorious/classic-hid.ts +++ b/src/drivers/glorious/classic-hid.ts @@ -32,11 +32,14 @@ import { GLORIOUS_CLASSIC_PRODUCTS, VENDOR_ID } from "../vendors.ts"; * D-, Model I, Model O V2 — plus the newer "core2" 8000Hz-class mice, Model * O3 Wireless and Model D 2 PRO 4K/8KHz Edition, on a reduced feature set — * see the `generation` doc comment on `GLORIOUS_CLASSIC_PRODUCTS` in - * vendors.ts for why). The config channel is a 64-byte feature report - - * unnumbered (id 0) on most units, but a real "Model O V2 Wired" - * (0x320f:0x823a) carries it as numbered report 7 instead; the report id is - * detected per device rather than assumed. See ../../glorious-classic/index.ts - * for the payload layout, which does not depend on the report id. + * vendors.ts for why). The config channel is a feature report, usually 64 + * bytes and unnumbered (id 0), but a real "Model O V2 Wired" (0x320f:0x823a) + * carries it as numbered report 7 with its own, possibly shorter, declared + * length - both the report id and its byte length are read from the + * device's own descriptor rather than assumed, and every payload (always + * built at the fixed 64-byte length below) is resized to fit before it is + * sent. See ../../glorious-classic/index.ts for the payload layout, which + * does not depend on the report id or length. * * DPI, polling rate, lift-off distance, and RGB are all write-only on this * protocol (neither glorious-ctl nor mxw, the two tools this was ported @@ -86,19 +89,27 @@ export class GloriousClassicHidClient { readonly pollIntervalMs = 0; readonly device: HIDDevice; /** - * The vendor collection's feature report id. Most units use the unnumbered - * report (0, GLORIOUS_CLASSIC_REPORT_ID); a real "Model O V2 Wired" - * (0x320f:0x823a) instead carries it as numbered report 7. Discovered at - * connect time rather than assumed - WebHID takes the report id separately - * from the payload bytes, so the wire encoding itself does not change, - * only which report number carries it. + * The vendor collection's feature report id and byte length. Most units + * use the unnumbered report (id 0, GLORIOUS_CLASSIC_REPORT_ID) at the full + * 64-byte length every payload below is built at; a real "Model O V2 + * Wired" (0x320f:0x823a) instead carries it as numbered report 7, and + * sending a 64-byte report there fails outright ("failed to write the + * feature report") because the device's own descriptor declares a + * shorter length for it. Both are read from the device at connect time + * rather than assumed, and every payload is resized to fit before it is + * sent - the actual command bytes are always well under the shortest + * length seen in practice, so truncating (or zero-padding, if a unit + * somehow declares more than 64) never touches meaningful data. */ private readonly reportId: number; + private readonly reportLength: number; private lastRgb: GloriousClassicRgb = GLORIOUS_CLASSIC_DEFAULT_RGB; constructor(device: HIDDevice) { this.device = device; - this.reportId = GloriousClassicHidClient.findConfigReportId(device) ?? GLORIOUS_CLASSIC_REPORT_ID; + const config = GloriousClassicHidClient.findConfigReport(device); + this.reportId = config?.reportId ?? GLORIOUS_CLASSIC_REPORT_ID; + this.reportLength = config?.length ?? GLORIOUS_CLASSIC_PACKET_LENGTH; } static isSupported(device: HIDDevice): boolean { @@ -107,29 +118,43 @@ export class GloriousClassicHidClient { && device.vendorId !== VENDOR_ID.gloriousClassicIWired && device.vendorId !== VENDOR_ID.gloriousO3) return false; if (!GLORIOUS_CLASSIC_PRODUCTS.has(device.productId)) return false; - return this.findConfigReportId(device) !== null; + return this.findConfigReport(device) !== null; } - /** The feature-report id carried by whichever collection is the config channel, or null. */ - private static findConfigReportId(device: HIDDevice): number | null { + /** The feature-report id and byte length carried by the config channel collection, or null. */ + private static findConfigReport(device: HIDDevice): { reportId: number; length: number } | null { for (const collection of device.collections) { - const found = this.findConfigReportIdIn(collection); + const found = this.findConfigReportIn(collection); if (found !== null) return found; } return null; } - private static findConfigReportIdIn(collection: HIDCollectionInfo): number | null { + private static findConfigReportIn(collection: HIDCollectionInfo): { reportId: number; length: number } | null { if (CLASSIC_USAGE_PAGES.includes(collection.usagePage) && collection.featureReports.length > 0) { - return collection.featureReports[0].reportId; + const report = collection.featureReports[0]; + const bits = report.items.reduce((sum, item) => sum + (item.reportSize ?? 0) * (item.reportCount ?? 0), 0); + return { reportId: report.reportId, length: bits > 0 ? Math.ceil(bits / 8) : GLORIOUS_CLASSIC_PACKET_LENGTH }; } for (const child of collection.children) { - const found = this.findConfigReportIdIn(child); + const found = this.findConfigReportIn(child); if (found !== null) return found; } return null; } + /** Resizes a payload (always built at GLORIOUS_CLASSIC_PACKET_LENGTH) to the device's real declared length. */ + private fitPayload(payload: Uint8Array): Uint8Array { + if (payload.length === this.reportLength) return payload; + const resized = new Uint8Array(this.reportLength); + resized.set(payload.subarray(0, Math.min(payload.length, this.reportLength))); + return resized; + } + + private async send(payload: Uint8Array): Promise { + await this.device.sendFeatureReport(this.reportId, this.fitPayload(payload)); + } + async open(): Promise { if (!this.device.opened) await this.device.open(); } @@ -208,14 +233,8 @@ export class GloriousClassicHidClient { const rounded = Math.round(dpi); state.stageDpis[state.activeStage] = rounded; await this.open(); - await this.device.sendFeatureReport( - this.reportId, - buildGloriousClassicDpiStagesPayload(state.stageDpis, state.profileId), - ); - await this.device.sendFeatureReport( - this.reportId, - buildGloriousClassicActiveStagePayload(state.activeStage + 1, state.profileId), - ); + await this.send(buildGloriousClassicDpiStagesPayload(state.stageDpis, state.profileId)); + await this.send(buildGloriousClassicActiveStagePayload(state.activeStage + 1, state.profileId)); this.saveState(state); return rounded; } @@ -228,7 +247,7 @@ export class GloriousClassicHidClient { if (intervalMs === null) throw new Error(`This mouse does not support ${pollingRateHz} Hz.`); const state = this.loadState(); await this.open(); - await this.device.sendFeatureReport(this.reportId, buildGloriousClassicPollingRatePayload(intervalMs)); + await this.send(buildGloriousClassicPollingRatePayload(intervalMs)); state.pollingIntervalMs = intervalMs; this.saveState(state); return pollingRateHz; @@ -242,7 +261,7 @@ export class GloriousClassicHidClient { if (!millimetres) throw new Error(`This mouse does not support a ${value.toLowerCase()} lift-off distance.`); const state = this.loadState(); await this.open(); - await this.device.sendFeatureReport(this.reportId, buildGloriousClassicLiftOffPayload(millimetres)); + await this.send(buildGloriousClassicLiftOffPayload(millimetres)); state.lodMm = millimetres; this.saveState(state); return value; @@ -255,7 +274,7 @@ export class GloriousClassicHidClient { const state = this.loadState(); const clamped = Math.round(milliseconds); await this.open(); - await this.device.sendFeatureReport(this.reportId, buildGloriousClassicDebouncePayload(clamped, state.profileId)); + await this.send(buildGloriousClassicDebouncePayload(clamped, state.profileId)); state.debounceMs = clamped; this.saveState(state); return clamped; @@ -267,13 +286,13 @@ export class GloriousClassicHidClient { async setRgb(rgb: GloriousClassicRgb): Promise { await this.open(); - await this.device.sendFeatureReport(this.reportId, buildGloriousClassicRgbPayload(rgb)); + await this.send(buildGloriousClassicRgbPayload(rgb)); this.lastRgb = rgb; return rgb; } private async readBattery(): Promise { - await this.device.sendFeatureReport(this.reportId, buildGloriousClassicBatteryRequestPayload()); + await this.send(buildGloriousClassicBatteryRequestPayload()); await this.delay(BATTERY_RESPONSE_DELAY_MS); const view = await this.device.receiveFeatureReport(this.reportId); const body = new Uint8Array(view.buffer, view.byteOffset, Math.min(view.byteLength, GLORIOUS_CLASSIC_PACKET_LENGTH)); From 3a04e8019194724a8dd8443c1fcc76045ee4f189 Mon Sep 17 00:00:00 2001 From: Onyx Date: Sun, 13 Sep 2026 17:45:02 +0200 Subject: [PATCH 3/4] glorious: refuse writes on an unconfirmed feature-report length instead of guessing A diagnostic showed the previous fix was wrong in a way that looked right: resizing the driver's 64-byte payload to the real 0x320f:0x823a unit's 263-byte declared report stopped WebHID's write from erroring, but the mouse silently ignores the zero-padded result - DPI and polling rate diagnostics confirmed nothing on the mouse actually changes. Every payload in glorious-classic/index.ts is only reverse-engineered against the 64-byte case. Guessing a byte layout for 263 bytes and padding to fit was worse than doing nothing: it looked successful (no error, state cached as if applied) while doing nothing. Now any report length other than the confirmed 64 refuses every write with a clear 'not confirmed' error, the same shape already used for the core2 generation, until a real capture of the device's own official software gives an actual byte layout to build against. --- src/drivers/glorious/classic-hid.test.ts | 27 ++++--- src/drivers/glorious/classic-hid.ts | 92 +++++++++++++++--------- 2 files changed, 78 insertions(+), 41 deletions(-) diff --git a/src/drivers/glorious/classic-hid.test.ts b/src/drivers/glorious/classic-hid.test.ts index 2800260..113f2f4 100644 --- a/src/drivers/glorious/classic-hid.test.ts +++ b/src/drivers/glorious/classic-hid.test.ts @@ -80,20 +80,31 @@ test("a numbered config report is used for every write, not the unnumbered defau for (const report of sent) assert.equal(report.reportId, 7, "every write should use the discovered report id"); }); -test("a shorter declared feature report is not sent at the full 64-byte length", async () => { - // The real 0x320f:0x823a unit's numbered report 7 also declared a length - // WebHID rejected the driver's fixed 64-byte payload against, throwing - // "Failed to write the feature report." on every write. +test("a report length other than 64 bytes refuses writes instead of guessing at the layout", async () => { + // The real 0x320f:0x823a unit's numbered report 7 declares a 263-byte + // length. Resizing the driver's 64-byte payload to fit no longer errors + // at the WebHID level, but a diagnostic confirmed the mouse silently + // ignores it - the write ACKs and nothing on the mouse changes. Refuse + // instead of sending a payload with an unconfirmed byte layout. const { device, sent } = fakeDevice(VENDOR_ID.gloriousClassicIWired, 0x823a); const report = device.collections[0].featureReports[0] as { reportId: number; items: Array<{ reportSize: number; reportCount: number }> }; report.reportId = 7; - report.items = [{ reportSize: 8, reportCount: 32 }]; + report.items = [{ reportSize: 8, reportCount: 263 }]; const client = new GloriousClassicHidClient(device); - await client.setDpi(1600); + assert.deepEqual(client.getDpiOptions(), []); + assert.deepEqual(client.getSupportedPollingRates(), []); + await assert.rejects(() => client.setDpi(1600), /not confirmed/); + await assert.rejects(() => client.setPollingRate(500), /not confirmed/); + await assert.rejects(() => client.setLiftOffDistance("High"), /not confirmed/); + await assert.rejects(() => client.setDebounceTime(4), /not confirmed/); + await assert.rejects(() => client.setRgb({ effect: "solid", rate: 0, colors: ["#ff0000"] }), /not confirmed/); + assert.equal(sent.length, 0, "nothing should be sent once the report length is unconfirmed"); - assert.ok(sent.length > 0); - for (const report of sent) assert.equal(report.payload.length, 32, "payload must match the device's declared 32-byte report"); + const status = await client.readStatus(); + assert.equal(status.dpi, 0); + assert.equal(status.pollingRateHz, 0); + assert.equal(status.liftOffDistance, null); }); test("rejects an unrecognized VID/PID pair", () => { diff --git a/src/drivers/glorious/classic-hid.ts b/src/drivers/glorious/classic-hid.ts index bb391e8..395ecc5 100644 --- a/src/drivers/glorious/classic-hid.ts +++ b/src/drivers/glorious/classic-hid.ts @@ -33,13 +33,17 @@ import { GLORIOUS_CLASSIC_PRODUCTS, VENDOR_ID } from "../vendors.ts"; * O3 Wireless and Model D 2 PRO 4K/8KHz Edition, on a reduced feature set — * see the `generation` doc comment on `GLORIOUS_CLASSIC_PRODUCTS` in * vendors.ts for why). The config channel is a feature report, usually 64 - * bytes and unnumbered (id 0), but a real "Model O V2 Wired" (0x320f:0x823a) - * carries it as numbered report 7 with its own, possibly shorter, declared - * length - both the report id and its byte length are read from the - * device's own descriptor rather than assumed, and every payload (always - * built at the fixed 64-byte length below) is resized to fit before it is - * sent. See ../../glorious-classic/index.ts for the payload layout, which - * does not depend on the report id or length. + * bytes and unnumbered (id 0), but a real "Model O 2 Wired Mouse" + * (0x320f:0x823a) instead carries it as numbered report 7 at a 263-byte + * declared length. Both the report id and byte length are read from the + * device's own descriptor rather than assumed, so it connects - but every + * payload below is only reverse-engineered against the 64-byte case, and a + * diagnostic confirmed the mouse silently ignores a 64-byte payload just + * zero-padded out to 263 (WebHID's write succeeds; nothing changes on the + * mouse). See isConfirmedReportLength()'s doc comment: writes are refused + * for any length this driver hasn't confirmed a real byte layout for, + * rather than guessing again. See ../../glorious-classic/index.ts for the + * payload layout, which does not depend on the report id. * * DPI, polling rate, lift-off distance, and RGB are all write-only on this * protocol (neither glorious-ctl nor mxw, the two tools this was ported @@ -89,17 +93,9 @@ export class GloriousClassicHidClient { readonly pollIntervalMs = 0; readonly device: HIDDevice; /** - * The vendor collection's feature report id and byte length. Most units - * use the unnumbered report (id 0, GLORIOUS_CLASSIC_REPORT_ID) at the full - * 64-byte length every payload below is built at; a real "Model O V2 - * Wired" (0x320f:0x823a) instead carries it as numbered report 7, and - * sending a 64-byte report there fails outright ("failed to write the - * feature report") because the device's own descriptor declares a - * shorter length for it. Both are read from the device at connect time - * rather than assumed, and every payload is resized to fit before it is - * sent - the actual command bytes are always well under the shortest - * length seen in practice, so truncating (or zero-padding, if a unit - * somehow declares more than 64) never touches meaningful data. + * The vendor collection's feature report id and byte length, read from the + * device at connect time rather than assumed - see isConfirmedReportLength() + * for why a length other than 64 means writes get refused instead of guessed at. */ private readonly reportId: number; private readonly reportLength: number; @@ -181,15 +177,38 @@ export class GloriousClassicHidClient { return GLORIOUS_CLASSIC_PRODUCTS.get(this.device.productId)?.generation === "core2"; } + /** + * Every payload builder in glorious-classic/index.ts was reverse-engineered + * against the 64-byte feature report every classic-line unit confirmed so + * far uses. A real 0x320f:0x823a instead declares a 263-byte report; + * resizing our 64-byte payload to fit sends without a WebHID error, but a + * diagnostic confirmed the mouse ignores it outright - the firmware ACKs + * the write and changes nothing. A report length this driver hasn't seen a + * real byte layout for is therefore not writable yet, even though it + * connects and its report id is known - see [[glorious-classic-protocol]] + * in memory. Refuse instead of guessing again until a capture of the + * device's own official software gives the real layout. + */ + private isConfirmedReportLength(): boolean { + return this.reportLength === GLORIOUS_CLASSIC_PACKET_LENGTH; + } + + private assertWritable(feature: string): void { + if (this.isCore2()) throw new Error(`${feature} is not confirmed on this mouse's newer protocol generation yet.`); + if (!this.isConfirmedReportLength()) { + throw new Error(`${feature} is not confirmed on this unit's ${this.reportLength}-byte feature report yet.`); + } + } + getDpiOptions(): number[] { - if (this.isCore2()) return []; + if (this.isCore2() || !this.isConfirmedReportLength()) return []; const options: number[] = []; for (let dpi = GLORIOUS_CLASSIC_DPI_MIN; dpi <= GLORIOUS_CLASSIC_DPI_MAX; dpi += 50) options.push(dpi); return options; } getSupportedPollingRates(): number[] { - if (this.isCore2()) return []; + if (this.isCore2() || !this.isConfirmedReportLength()) return []; return GLORIOUS_CLASSIC_POLLING_RATES.map(([, hertz]) => hertz).sort((left, right) => left - right); } @@ -199,7 +218,8 @@ export class GloriousClassicHidClient { const battery = this.isWireless() ? await this.readBattery().catch(() => null) : null; const wireless = this.isWireless(); const core2 = this.isCore2(); - const liftOffDistance = core2 ? null : LIFT_OFF_DISTANCES.find(([mm]) => mm === state.lodMm)?.[1] ?? "Medium"; + const restricted = core2 || !this.isConfirmedReportLength(); + const liftOffDistance = restricted ? null : LIFT_OFF_DISTANCES.find(([mm]) => mm === state.lodMm)?.[1] ?? "Medium"; return { brand: "Glorious", name: this.displayName(), @@ -208,10 +228,10 @@ export class GloriousClassicHidClient { batteryState: battery ? (battery.state === "Normal" && battery.charging ? "Charging" : BATTERY_STATE_LABEL[battery.state]) : "Unknown", - dpi: core2 ? 0 : state.stageDpis[state.activeStage] ?? state.stageDpis[0] ?? 800, - pollingRateHz: core2 ? 0 : gloriousClassicDecodePollingRate(state.pollingIntervalMs) ?? 1000, + dpi: restricted ? 0 : state.stageDpis[state.activeStage] ?? state.stageDpis[0] ?? 800, + pollingRateHz: restricted ? 0 : gloriousClassicDecodePollingRate(state.pollingIntervalMs) ?? 1000, supportedPollingRates: this.getSupportedPollingRates(), - activeProfile: core2 ? null : state.profileId, + activeProfile: restricted ? null : state.profileId, connectionType: wireless ? "Wireless" : "Wired", connectionDetail: wireless ? "2.4 GHz / Bluetooth · settings are write-only, not read back" @@ -223,9 +243,7 @@ export class GloriousClassicHidClient { } async setDpi(dpi: number): Promise { - if (this.isCore2()) { - throw new Error("DPI is not confirmed on this mouse's newer protocol generation yet."); - } + this.assertWritable("DPI"); if (!Number.isFinite(dpi) || dpi < GLORIOUS_CLASSIC_DPI_MIN || dpi > GLORIOUS_CLASSIC_DPI_MAX) { throw new Error(`DPI must be between ${GLORIOUS_CLASSIC_DPI_MIN} and ${GLORIOUS_CLASSIC_DPI_MAX}.`); } @@ -240,9 +258,7 @@ export class GloriousClassicHidClient { } async setPollingRate(pollingRateHz: number): Promise { - if (this.isCore2()) { - throw new Error("Polling rate is not confirmed on this mouse's newer protocol generation yet."); - } + this.assertWritable("Polling rate"); const intervalMs = gloriousClassicEncodePollingRate(pollingRateHz); if (intervalMs === null) throw new Error(`This mouse does not support ${pollingRateHz} Hz.`); const state = this.loadState(); @@ -254,9 +270,7 @@ export class GloriousClassicHidClient { } async setLiftOffDistance(value: NonNullable): Promise> { - if (this.isCore2()) { - throw new Error("Lift-off distance is not confirmed on this mouse's newer protocol generation yet."); - } + this.assertWritable("Lift-off distance"); const millimetres = LIFT_OFF_DISTANCES.find(([, name]) => name === value)?.[0]; if (!millimetres) throw new Error(`This mouse does not support a ${value.toLowerCase()} lift-off distance.`); const state = this.loadState(); @@ -268,6 +282,11 @@ export class GloriousClassicHidClient { } async setDebounceTime(milliseconds: number): Promise { + // Unlike DPI/polling/LOD, core2 does write debounce - so this only gates + // on report length, not isCore2(). + if (!this.isConfirmedReportLength()) { + throw new Error(`Debounce is not confirmed on this unit's ${this.reportLength}-byte feature report yet.`); + } if (!Number.isFinite(milliseconds) || milliseconds < 0 || milliseconds > GLORIOUS_CLASSIC_DEBOUNCE_MAX_MS) { throw new Error(`Debounce must be between 0 and ${GLORIOUS_CLASSIC_DEBOUNCE_MAX_MS} ms.`); } @@ -285,6 +304,11 @@ export class GloriousClassicHidClient { } async setRgb(rgb: GloriousClassicRgb): Promise { + // Unlike DPI/polling/LOD, core2 does write RGB - so this only gates on + // report length, not isCore2(). + if (!this.isConfirmedReportLength()) { + throw new Error(`RGB is not confirmed on this unit's ${this.reportLength}-byte feature report yet.`); + } await this.open(); await this.send(buildGloriousClassicRgbPayload(rgb)); this.lastRgb = rgb; @@ -314,6 +338,8 @@ export class GloriousClassicHidClient { forceShowBattery: this.isWireless(), statusNote: this.isCore2() ? "This mouse's newer protocol generation only has confirmed commands for RGB, debounce, and battery — DPI, polling rate, and lift-off distance aren't wired in yet." + : !this.isConfirmedReportLength() + ? `This mouse connects, but its ${this.reportLength}-byte feature report uses a byte layout this driver hasn't confirmed yet — no settings can be changed until a real capture is available.` : "DPI, polling rate, lift-off distance and RGB are written to this mouse but never read back.", }; } From 7274ac7e9d96a7553ab08665bfa1793555a32fd6 Mon Sep 17 00:00:00 2001 From: Onyx Date: Sun, 13 Sep 2026 17:53:09 +0200 Subject: [PATCH 4/4] glorious: flag 0x823a/0x821a/0x831a's core1 label as unconfirmed Same VID (gloriousClassicIWired, 0x320f) as three product-table entries that share the 'core1' generation label with every other 64-byte classic-line device, despite a real 0x823a unit's feature report declaring 263 bytes and doing nothing when sent core1's 64-byte payloads. The label itself isn't what gates writes at runtime - isConfirmedReportLength() in classic-hid.ts checks the live descriptor - but leaving 'core1' undocumented here reads as confirmed when it never was; only VID-proximity reasoning put it there originally. --- src/drivers/vendors.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index d6b6651..88858e2 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -252,6 +252,15 @@ export const GLORIOUS_CLASSIC_PRODUCTS: ReadonlyMap