Skip to content

feat(razer): add Viper V3 Pro button mapping - #22

Merged
snekxs merged 2 commits into
OpenMouse-Project:mainfrom
Pochiiko:vv3p-button-mapping
Aug 16, 2026
Merged

feat(razer): add Viper V3 Pro button mapping#22
snekxs merged 2 commits into
OpenMouse-Project:mainfrom
Pochiiko:vv3p-button-mapping

Conversation

@Pochiiko

@PochiikoPochiiko commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Base:OpenMouse-Project/mouse-protocolmain
Head:Pochiiko/mouse-protocolvv3p-button-mapping

feat(razer): add Viper V3 Pro button mapping

Adds configurable button mapping for the Razer Viper V3 Pro, reverse-engineered
and verified on hardware (firmware 1.12, HyperSpeed receiver).

Protocol

Class 0x02:

Class / IDdataSizePayload
Read0x02/0x8c0x0a[0x01, controlIndex, layer]
Write0x02/0x0c0x0a[0x01, controlIndex, layer, actionType, actionLen, actionValue, 0,0,0,0]

The write silently no-ops under the shared transaction id 0x1f — status
0x02 (ok), the read-back even changes, but the physical button keeps its old
function. Confirmed by writing a plain mouse-button action, pressing the button,
and finding it unchanged with 0x1f and correct with any other id (0x02 and
0x10 both round-tripped and worked physically). Hence
RAZER_BUTTON_TRANSACTION_ID = 0x10, pinned on the command itself.
encodeRazerRequest already accepted a per-call id, so exchange() now prefers
command.transactionId over the per-product one — no signature change.

Two control types, deliberately not one

RazerButtonControl (leftClick, rightClick, mouse4, mouse5) —
cross-assignable between each other's actions or Disabled. Cross-control was
confirmed on hardware, not just identity: Mouse Button 4 written with Right
Click's index physically right-clicked. Left Click is locked on the Standard
layer, matching Synapse's own restriction.

RazerToggleControl (scrollUp, scrollDown, sensitivityButton) — only
switches between its own captured factory action and Disabled. Kept a separate
type and out of RAZER_BUTTON_MAPPINGS because cross-assigning these has never
been attempted on hardware, and because the sensitivity button's default
("Cycle Up Sensitivity Stages") is actionType 0x06 — not a plain-button action
and not decoded generally. Its enabled payload is stored verbatim from the
capture rather than constructed.

Control indices

Each settled by isolated single-variable capture or physical cross-remap test:

IndexControl
0x01Left Click
0x02Right Click
0x03Scroll Click
0x04Mouse Button 4
0x05Mouse Button 5
0x09Scroll Up
0x0aScroll Down
0x60Sensitivity button

0x060x08 were probed and never answered. The space is sparse, so
neighbours are not guessed at.

Scoping and safety

  • Gated per product by RazerProduct.buttonMapping, set only on the Viper V3
    Pro pair (0x00c0/0x00c1) — the class has only ever been exercised there.
  • Mappings are read once per connection and refreshed from each write's own
    confirmed read-back, mirroring asymmetric/asymmetricKnown, rather than
    polled on the status timer.
  • readButtonMappings returns null rather than an empty record when no
    control answers, so a transport that does not support the class hides the
    control instead of reporting an empty set. (The cable, 0x00c0, carries the
    flag but has only ever been tested on the receiver.)
  • Every write is write → read back → throw on mismatch. Not a formality here:
    under the wrong transaction id the write reports success and does nothing.

Not shipped

  • Hypershift layer. The layer byte is confirmed (0x00 Standard, 0x01
    Hypershift, seen in a real Synapse write), but the read does not honour a
    requested layer at all — every attempt to read a Hypershift-side mapping
    returned the Standard value. That breaks the read-back verification every
    write here depends on.
  • Scroll Click. Index confirmed, but this driver's own write to it has only
    been physically checked against the wrong action, so it has not cleared the
    verification bar.

Hardware verification

Razer Viper V3 Pro, product id 0x00c1 (HyperSpeed receiver), firmware 1.12,
with Synapse fully closed. Each write was applied through this driver and then
confirmed by physically using the control:

WritePhysical result
Mouse Button 4 -> Disabledbutton does nothing
Mouse Button 4 -> Right Clickbutton opens the context menu
Mouse Button 4 -> Mouse Button 4back to its normal action
Scroll Down -> Disabledwheel no longer scrolls down
Scroll Down -> Scroll Downscrolling restored

The Right Click case is the one that matters: it confirms the cross-control
action reaches the device with the correct transaction id. A read-back alone
cannot establish that, because this write's read-back also changes under the
transaction id that silently no-ops.

The cable (0x00c0) was not tested and does not carry the buttonMapping flag.

Tests

20 new tests built from the hardware captures, including one asserting the two
control families share no control names and no option labels — the thing that
would let one family's renderer read the other's state.

429/429 pass.

Reverse-engineered and hardware-verified against the Viper V3 Pro receiver
(firmware 1.12), class 0x02.
Read 0x02/0x8c, dataSize 0x0a, args [0x01, controlIndex, layer]
Write 0x02/0x0c, dataSize 0x0a,
[0x01, controlIndex, layer, actionType, actionLen, actionValue, 0,0,0,0]
The write silently no-ops under the shared transaction id 0x1f: status 0x02
(ok), the read-back even changes, but the physical button keeps its old
function. Confirmed by writing a plain mouse-button action, pressing the
button, and finding it unchanged with 0x1f and correct with any other id
(0x02 and 0x10 both round-tripped and worked physically). Hence
RAZER_BUTTON_TRANSACTION_ID = 0x10, pinned on the command itself;
encodeRazerRequest already accepted a per-call id, so exchange() now prefers
command.transactionId over the per-product one.
Two distinct control types rather than one:
RazerButtonControl (leftClick, rightClick, mouse4, mouse5) is cross-
assignable between each other's actions or Disabled -- confirmed cross-
control on hardware, not just identity: Mouse Button 4 written with Right
Click's index physically right-clicked. Left Click is locked on the
Standard layer, matching Synapse's own restriction.
RazerToggleControl (scrollUp, scrollDown, sensitivityButton) only switches
between its own captured factory action and Disabled. Kept a separate type
and out of RAZER_BUTTON_MAPPINGS because cross-assigning these has never
been attempted on hardware, and because the sensitivity button's default
("Cycle Up Sensitivity Stages") is actionType 0x06 -- not a plain-button
action and not decoded generally.
Control indices, each settled by isolated single-variable capture or physical
cross-remap test: 0x01 Left Click, 0x02 Right Click, 0x03 Scroll Click,
0x04 Mouse Button 4, 0x05 Mouse Button 5, 0x09 Scroll Up, 0x0a Scroll Down,
0x60 sensitivity button. 0x06-0x08 were probed and never answered; the space
is sparse, so neighbours are not guessed.
Gated per product by RazerProduct.buttonMapping, set only on the receiver
(0x00c1) -- class 0x02 has only ever been exercised over that connection
path. The cable (0x00c0) is the same mouse and very likely answers the
same way, but a shared protocol family is not evidence for a specific
product id, so it is left off until someone checks it. Mappings are read once
per connection and refreshed from each write's own confirmed read-back,
mirroring asymmetric/asymmetricKnown, rather than polled on the status timer.
readButtonMappings returns null rather than an empty record when no control
answers, so a transport that does not support the class hides the control
instead of reporting an empty set.
Not shipped: the Hypershift layer (the read does not honour a requested layer
at all, which breaks the read-back verification every write here depends on)
and Scroll Click (index known, but this driver's own write to it has only
been physically checked against the wrong action).
Adds 20 tests built from the hardware captures.
@Pochiiko

Copy link
Copy Markdown
ContributorAuthor

Application changes

The consuming UI is OpenMouse-Project/openmouse#101, which adds the
Buttons-tab Mapping card on top of this driver. It is blocked on this
PR — its CI cannot resolve razerButtonMappings or the RazerButton* /
RazerToggle* exports until this merges and its lockfile is bumped.

Answers a review question on the pull request. Confirmed on a Viper V3 Pro
(0x00c1): Mouse Button 4 disabled, host application closed, mouse powered off
at its switch for 10 seconds, powered back on. The button was still dead on
reconnect and the read reported Disabled, with Synapse never running -- so
both the physical behaviour and the device's own read survived, ruling out
host-side state.
Also records the leading argument byte as an explicit unknown. It is always
0x01, which is RAZER_STORAGE on this device, so a storage selector is the
obvious reading -- but the byte has never been varied on this command, so it
is documented rather than assigned that meaning.
@snekxs
snekxs merged commit 1fe5068 into OpenMouse-Project:mainAug 16, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Pochiiko@snekxs