From 06997e461dadea0ca45d2894fcc7ebd825ad017c Mon Sep 17 00:00:00 2001 From: Pochiiko Date: Mon, 17 Aug 2026 13:13:27 +1000 Subject: [PATCH] feat(razer): offer button mapping over the Viper V3 Pro's cable Class 0x02 was gated to the receiver (0x00c1) because it had only ever been exercised there. It is now confirmed on the cable (0x00c0) too, so the flag moves to the shared VIPER_V3_PRO preset and the comment explaining the cable's exclusion goes with it. Verified on hardware, dongle unplugged and Synapse closed: - Mouse Button 4, set to Right Click over the receiver, read back as Right Click over the cable. Mappings live in device memory, so both links see one stored state. - MB4 disabled over the cable, pressed, physically dead, restored -- setButtonMapping. - Scroll Up disabled over the cable, scrolled, physically dead, restored -- setToggleControl, a separate codec path. The cross-transport read is the check that matters, because the failure mode here is silent rather than loud. razerDecodeButtonMapping reads type=0x00, len=0x00, value=0x00 as "Disabled", so a transport that does not implement the class answers all-zero and produces a full, ordinary set of controls that every read reports as Disabled and every write appears to accept. readButtonMappings' null-collapse cannot catch that, because the dict comes back populated. Only a value that all-zero cannot forge distinguishes the two. Add the allowlist test the other hardware-gated capabilities already have -- liftOff and asymmetricLiftOff each pin their product ids, and buttonMapping had no equivalent -- and record on the field itself that the gate is per connection, not per model. No change is needed on the consumer side: availability is computed from status.razerButtonMappings and never looks at the transport. --- src/drivers/razer/devices.test.ts | 14 ++++++++++++++ src/razer/devices.ts | 25 +++++++++++++++++-------- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/drivers/razer/devices.test.ts b/src/drivers/razer/devices.test.ts index c27d7f0..ef50045 100644 --- a/src/drivers/razer/devices.test.ts +++ b/src/drivers/razer/devices.test.ts @@ -247,6 +247,20 @@ test("the asymmetric lift-off write probe is only armed where it was confirmed", for (const id of armed) assert.equal(RAZER_PRODUCTS.get(id)?.verified, true); }); +test("button mapping is only offered on connections where class 0x02 answered", () => { + // Gated per product id and per connection, not per model. An all-zero reply + // decodes as "Disabled" on every control, so a transport that does not + // implement class 0x02 does not fail loudly — it produces a full, plausible + // set of controls that all read Disabled and silently do nothing. Hence an + // allowlist, and hence a hardware run per entry even for two connections of + // the same mouse. + const offered = RAZER_PRODUCT_IDS.filter((id) => RAZER_PRODUCTS.get(id)?.buttonMapping === true); + assert.deepEqual(offered.sort(), [0x00c0, 0x00c1]); + for (const id of offered) { + assert.equal(RAZER_PRODUCTS.get(id)?.verified, true, `0x${id.toString(16)} offers button mapping without being verified`); + } +}); + test("no product is claimed by both this registry and a dedicated Razer driver", () => { // `driverFor` returns the first match in DEVICE_DRIVERS, so an overlap would // silently kill whichever driver is registered later. diff --git a/src/razer/devices.ts b/src/razer/devices.ts index 5e23eec..1beea44 100644 --- a/src/razer/devices.ts +++ b/src/razer/devices.ts @@ -77,8 +77,18 @@ export interface RazerProduct { * only ever been exercised on the Viper V3 Pro. Other Razer mice may well use * a different class or a different control-index scheme, so nothing is * offered to them until it has been checked on hardware. Gates both - * `RazerButtonControl` and `RazerToggleControl` — same command, same - * per-model risk, no reason to split them. + * `RazerButtonControl` and `RazerToggleControl` — same command, same risk, + * no reason to split them. + * + * Set per product id, which means per *connection*: the cable and the + * receiver are separate entries for one mouse and were verified separately. + * That is not pedantry about provenance — the failure mode is silent. + * `razerDecodeButtonMapping` reads `type=0x00, len=0x00, value=0x00` as + * "Disabled", so a transport that does not implement the class answers + * all-zero and yields a full, ordinary-looking set of controls that every + * read reports as Disabled and every write appears to accept. + * `readButtonMappings`'s null-collapse cannot catch it, because the dict + * comes back populated. */ buttonMapping?: boolean; /** Also accept a vendor-defined collection as the control interface. */ @@ -371,6 +381,10 @@ const VIPER_V3_PRO = { hasBattery: true, liftOff: true, asymmetricLiftOff: true, + // Class 0x02 is confirmed on both of this model's connections, so it belongs + // on the shared preset rather than on one product id. It sat on `0x00c1` + // alone while only the receiver had been exercised. + buttonMapping: true, verified: true, } as const; @@ -386,12 +400,7 @@ const PRODUCT_DEFINITIONS: ReadonlyArray<[number, Omit