diff --git a/docs/logitech-onboard-profiles.md b/docs/logitech-onboard-profiles.md index 4134811..f77ff28 100644 --- a/docs/logitech-onboard-profiles.md +++ b/docs/logitech-onboard-profiles.md @@ -583,24 +583,20 @@ layout, so it is the Superstrike; a Superlight 2 is most likely 7, but that is a guess until read from hardware. **The original G Pro X Superlight (PID `0xc094`, wpid `4093` — "PRO X Wireless" -in Logitech's/Solaar's naming) reports format 7 but is not the Superlight 2 this -layout was verified on.** Live reports show it returning HID++ error `0x05` -("Logitech internal error") specifically when the format-7 per-stage lift-off -byte is written — the device rejects a lift-off offset that is valid on the -Superlight 2, most likely because this older board predates per-stage -lift-off entirely rather than storing it at a different offset. This matches -the unresolved DPI-stage-offset report below. Neither Solaar nor libratbag -implements per-stage lift-off or debounce for any Logitech mouse, onboard or -otherwise, so there is no reference layout to check this against. - -OpenMouse's model is to support what a device can actually do rather than -gate features off by device, so `onboard-profiles.ts` does not refuse the -whole profile write for this PID: `isLodWritableForProduct` scopes the guard -to the one unverified field, and `encodeDpiStages`'s `writeLod` flag leaves -each stage's existing lift-off byte untouched while still writing its DPI -x/y — report rate, angle-snap, name and buttons are all unaffected and stay -writable. Lift the guard once a profile dump from a real `0xc094` device -confirms it has per-stage lift-off storage (and at what offset). +in Logitech's/Solaar's naming) reports format 4, not the format 7 this layout +was verified on.** A sector-1 dump from a real unit (main app +`MPM25.01_B0018`) answered `getOnboardProfilesInfo` with format 4, five +profiles, 16 sectors of 255 bytes, and a CRC-valid base-v1 sector 1: the +scalar table at `0x03`, 800 DPI in the first slot, report interval `0x01` at +`0x00`. Base v1 stores one scalar DPI per slot and has no per-stage lift-off +byte at all, so the format-7 `0x05` ("Logitech internal error") report that +originally motivated a product guard does not describe this layout. The +captured geometry is pinned by the "PRO X Wireless dump" test in +`onboard-profiles.test.ts`. `isLodWritableForProduct` keeps the product guard +only for a unit that ever reports a v6 format, where a per-stage lift-off byte +exists and was reported to be rejected; on the actual base-v1 device there is +nothing to refuse. Everything else a profile carries — DPI, report rate, +angle-snap, name and buttons — is writable. **Names for format ids 7 and 8**, and the meaning of the extra component-specific prototype fields (for example `dpi_v6` carries diff --git a/src/drivers/logitech/onboard-profiles.test.ts b/src/drivers/logitech/onboard-profiles.test.ts index c3fc9b1..2c2f19d 100644 --- a/src/drivers/logitech/onboard-profiles.test.ts +++ b/src/drivers/logitech/onboard-profiles.test.ts @@ -1212,13 +1212,56 @@ test("G502 keyboard shortcuts and consumer keys use direct four-byte HID binding assert.equal(profileCrc(media), storedCrc(media)); }); +/** Issue #159: a PRO X Wireless (PID 0xc094, main app MPM25.01_B0018). */ +const SUPERLIGHT_C094_INFO_REPLY = bytes("01 0d 00 01 04 01 05 01 05 10 00 ff 0a 04 00 00"); +/** Its sector 1, untouched from the factory, CRC `dd 75` intact. */ +const SUPERLIGHT_C094_SECTOR_1 = (() => { + const sector = new Uint8Array(255).fill(0xff); + sector.set(bytes("01 00 00 20 03 00 00 00 00 00 00 00 00 ff ff ff"), 0x00); + sector.set(bytes("ff 00 ff ff ff ff ff ff ff ff ff ff ff ff ff ff"), 0x10); + sector.set(bytes("80 01 00 01 80 01 00 02 80 01 00 04 80 01 00 08"), 0x20); + sector.set(bytes("80 01 00 10 ff ff ff ff ff ff ff ff ff ff ff ff"), 0x30); + sector.set(bytes("00 00 00 00 00 00 1f 40 00 00 00 00 00 00 00 00"), 0xd0); + sector.set(bytes("00 1f 40 00 00 00 ff ff ff ff ff ff ff ff ff ff"), 0xe0); + sector.set(bytes("ff ff ff ff ff ff ff ff ff ff ff ff ff dd 75"), 0xf0); + return sector; +})(); + +test("the PRO X Wireless (0xc094) reports format 4, a base-v1 scalar table", () => { + // Issue #159: this exact PID was assumed to be format 7 like the Superlight + // 2, but a real dump reports format 4 — v1 storage, no per-stage lift-off. + assert.deepEqual(parseProfilesInfo(SUPERLIGHT_C094_INFO_REPLY), { + memoryModelId: 1, + profileFormatId: 4, + macroFormatId: 1, + profileCount: 5, + buttonCount: 5, + sectorCount: 16, + sectorSize: 255, + }); + const profile = decodeOnboardProfile( + SUPERLIGHT_C094_SECTOR_1, + 4, + { sector: 1, enabled: true }, + true, + ); + assert.equal(profile.crcValid, true); + assert.equal(profile.reportRateWireless, 1000); + assert.deepEqual(profile.dpiStages, [{ x: 800, y: 800, lod: 0 }]); + assert.equal(profile.defaultDpiIndex, 0); + // There is no lift-off byte in a base-v1 layout, so the format-7 product + // guard has nothing to refuse on this device. + assert.equal(isLodWritableForProduct(4, 0xc094), true); +}); + test("only the per-stage lift-off byte is refused on the original G Pro X Superlight (PID 0xc094)", () => { // Format 7's DPI-stage triplet layout (x/y/lift-off) was only confirmed - // against a Pro X Superlight 2 dump; the original Superlight reports the - // same format id on an older board that likely predates per-stage - // lift-off, and rejects the write on hardware (HID++ error 0x05) when that - // byte is touched. Everything else format 7 carries stays writable — the - // guard is scoped to the one unverified field, not the whole profile. + // against a Pro X Superlight 2 dump. A 0xc094 was reported to reject the + // lift-off byte on hardware (HID++ error 0x05) as if it were that older + // board; the dump above shows a 0xc094 actually reports format 4, so the + // guard only stays for the case where one ever reports a v6 format. + // Everything else format 7 carries stays writable — the guard is scoped to + // the one unverified field, not the whole profile. assert.equal(describeProfileFormat(7).writable, true); assert.equal(isProfileWritable(7), true); assert.equal(isLodWritableForProduct(7, 0xc094), false); diff --git a/src/drivers/logitech/onboard-profiles.ts b/src/drivers/logitech/onboard-profiles.ts index 30ff43a..295a239 100644 --- a/src/drivers/logitech/onboard-profiles.ts +++ b/src/drivers/logitech/onboard-profiles.ts @@ -80,16 +80,21 @@ export function isProfileWritable(profileFormatId: number | null | undefined): b /** * Format 7's per-stage lift-off byte (and the rest of its DPI-stage triplet - * layout) was recovered from a Pro X Superlight 2 dump. The original G Pro X - * Superlight (PID 0xc094, wpid 4093 — Logitech's "PRO X Wireless" in its own - * tooling) reports the same format id but is an older board that predates - * per-stage lift-off entirely; live reports show it returning HID++ error - * 0x05 ("Logitech internal error") specifically when a stage's lift-off byte - * is written, matching an unresolved report of a differently-shifted DPI - * stage layout on what is likely this device. Until a dump from this - * specific PID confirms it has lift-off storage to write to, everything else - * format 7 carries — DPI x/y, report rate, angle-snap, name, buttons — stays - * writable, and only the lift-off byte for each stage is left untouched. + * layout) was recovered from a Pro X Superlight 2 dump. A PID 0xc094 (the + * original G Pro X Superlight, wpid 4093 — Logitech's "PRO X Wireless" in its + * own tooling) was then reported to return HID++ error 0x05 ("Logitech + * internal error") when a stage's lift-off byte is written, matching an + * unresolved report of a differently-shifted DPI stage layout. + * + * That report was later pinned to format 7: a sector-1 dump from a real + * 0xc094 (main app MPM25.01_B0018) reports profile format 4, i.e. the base v1 + * scalar table, which has no per-stage lift-off field at all — see the "PRO X + * Wireless dump" test. The guard therefore cannot fire against a base-v1 + * layout and only matters if a unit ever reports a v6 format; it is kept for + * that case, because lifting it on a real format-7 board would bring the 0x05 + * back. Everything else a format-7 profile carries — DPI x/y, report rate, + * angle-snap, name, buttons — stays writable, and only the lift-off byte for + * each stage is left untouched. */ const UNVERIFIED_LOD_PRODUCT_IDS = new Set([0xc094]); @@ -99,6 +104,9 @@ export function isLodWritableForProduct( productId: number | null | undefined, ): boolean { if (!isProfileWritable(profileFormatId)) return false; + // Base v1 (formats 1-5) stores one scalar DPI per slot and has no lift-off + // byte, so there is nothing for the product guard to refuse. + if ((profileFormatId ?? 0) < 6) return true; if (productId !== null && productId !== undefined && UNVERIFIED_LOD_PRODUCT_IDS.has(productId)) return false; return true; }