Prime (1038:182e): very basic key-mapping to mouse buttons achieved
okay, so i've been suffering with steelseries gg and a sniffer for like 8 hours and i found a way to map keyboard presses to mouse clicks.
the whole thing is barebones right now and i'm working on understanding how their macros work too.
the basic idea: steelseries gg sends SET_REPORT requests to the mouse to set what button does what (bmRequestType=0x21, bRequest=0x09, wValue=0x0200)
the whole request is only 100 bytes (with 28 byte pseudo-header included) and the structure is something like:
command 5b - set to re-map (simple)
structure:
1---------| WW 2---------| WW 3---------| WW 4---------| WW 5---------| WW ??
5b XX XX XX XX 00 YY YY YY YY 00 ZZ ZZ ZZ ZZ 00 UU UU UU UU 00 VV VV VV VV 00 30
where 1 is the button action sector (4 bytes long), XX XX XX XX is what that button shall do.
important: all sectors are sequential, so it's always in order of button 1, 2, 3, 4, 5...
WW - probably padding
example:
1---------| WW 2---------| WW 3---------| WW 4---------| WW 5---------| WW 6---------|
5b 01 00 00 00 00 02 00 00 00 00 03 00 00 00 00 04 00 00 00 00 51 14 00 00 00 30 00 00 00 00 00
button 5 is remapped to key Q (0x14 in HID scancodes) and there's also 51 for some reason (maybe it's an action hint, like "just press"?)
that's on mouse button DOWN
trigger of a key press on mouse button release is something i couldn't yet figure out. the following is where mouse 4 was bound to trigger `q` keypress upon release
5b 01 00 00 00 00 02 00 00 00 00 03 00 00 00 00 71 00 00 00 00 05 1a 00 00 00 30
ignore the `05 1a 00 00`, previously i rebound that key to press W upon mouse button being DOWN, so it was `51 1a 00 00` before i set it back to default, but i guess 1a was just leftover by steelseries engine and is safely ignored by the mouse itself
so 0x71 here wont actually do anything upon click ON ITS OWN
there's a few more payloads sent by steelseries gg every time you change config. one of them is an enormous 678 byte one (28 byte pseudoheader), and it's fully empty on stock config, so that's most likely where macros live (and then referenced somehow by the payload i described above in detail).
so whenever a keypress needs to trigger on mouse button's release, then it's stored as a macro which is then referenced as `71` in the button config
notice how it wrote `71 00` instead of just `71`, as there's no leftover junk (there used to be `51 1a`), which kind of indicates that both bytes are ALWAYS significant somehow. maybe second byte is the macros offset in the storage and 71 is a hint saying "this invokes a macro"?
you may have also noticed the 0x30 just sitting there. 30 is the "action code", so to speak. kind of like 51 or 71. all it does is rotate DPI level (that's why it's on the 6th button! it's the button on the bottom side of steelseries prime!) you can rebind it as well!
so we can construct a pretty neat python script to re-map our button 5 to keyboard's q press:
import sys
import usb.core
# hardcoded 1038:182e for steelseries prime
dev = usb.core.find(idVendor=0x1038, idProduct=0x182e)
if dev is None:
raise ValueError("Device not found")
i = dev[0].interfaces()[0].bInterfaceNumber
if dev.is_kernel_driver_active(i):
try:
dev.detach_kernel_driver(i)
except usb.core.USBError as e:
sys.exit("Could not detatch kernel driver from interface({0}): {1}".format(i, str(e)))
payload = bytes.fromhex(
"5b 01 00 00 00 00 02 00 00 00 00 03 00 00 00 00"
"04 00 00 00 00 51 14 00 00 00 30 00 00 00 00 00"
"00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00"
"00 00 00 00 00 00 00 00 00 00"
)
# send SET_REPORT (bmRequestType=0x21, bRequest=0x09, wValue=0x0200)
dev.ctrl_transfer(0x21, 0x09, 0x0200, 0, payload)
and to change it back to stock we can simply change payload to be:
payload = bytes.fromhex(
"5b 01 00 00 00 00 02 00 00 00 00 03 00 00 00 00"
"04 00 00 00 00 05 00 00 00 00 30 00 00 00 00 00"
"00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00"
"00 00 00 00 00 00 00 00 00 00"
)
so, to recap:
- a mouse action block is 4 bytes long and has 1 byte padding right after it.
- mouse action block can start with 01-05 for mouse clicks (from mouse 1 to mouse 5 respectively), or it can start with an action code:
- 30 means "rotate the DPI level"-
- 51 probably means "just click the following key on keyboard" and the next byte is the HID of the key to click (i.e. 1a for
W). - 71 probably means "just run the macros stored at the following offset" and the next byte is the offset itself (totally unconfirmed, just a hunch based on the fact that it got zeroed after setting to "click Q on release of mouse button" and the entry appearing in the macros message)
keep in mind that i'm not knowledgeable when it comes to reverse-engineering (in fact this was the first time i tried something like this), i was basing some things off educated guesses (like that button action maps are 4 bytes long with 1 byte of padding, despite me being unable to make the actions use the remaining 00 00 bytes, since it'd make no sense to use more memory on an HID's on-board memory just to store nothing)
also i only tested this on steelseries prime, but i assume it works on at least a couple other steelseries mice (it'd make more sense to not have to re-write firmware for every single mice from scratch for steelseries)
some (maybe obvious) notes:
- upon remapping, say, button 4 to click Q on keyboard, when you click on button 4 the mouse itself will eat the mouse click and only give the Q to the kernel. i don't think there's a way to make it fall-through, not sure if you'd even want that anyway.
- now that i think about it, 0x30 might be the action code for DPI switching. it would fit to be the 6th mouse button and 0x30 seems like an oddly specific number that wont clash.
Hello,
Thank you for your reverse engineering of the Prime button bindings. The packet looks similar to other SS mice like the Aerox 3 so you should have:
- 0x00 = disable the button
- 0x01 = Button1 (left click)
- 0x02 = Button 2 (right click)
- 0x03 = Button 3 (wheel)
- 0x04 = Button 4 (side-front)
- 0x05 = Button 5 (side-back)
- 0x06 = Button 6 (used as CPI button but may be handled by the OS if set to 6 (is it located under the mouse? I do not see it on the device photos))
- 0x30 = CPI switch
- 0x51 = keyboard key
- 0x61 = multimedia key (play, pause,...)
You may also be able to rebind scroll up / down (probably 0x31 for scrollup and 0x32 for scroll down).
The packet format looks like:
0x5b <BindingBtn1> <BindingBtn2> ...
Where each binding is 5 bytes:
<BindingType 1B> <Param 4B>
BindingType is one of the list above (buttonN, disable, cpi, keyboard,...) and Param is a keyboard or multimedia key (you should also be able to set multiple keys like "ctrl", "c", 0, 0 as parameter instead of one key but this is cyrrently not supported by rivalcfg)
It should be easy to add the support to rivalcfg. Here is what it looks like if we add it in the Prime profile:
"buttons_mapping": {
"label": "Buttons mapping",
"description": "Set the mapping of the buttons",
"cli": ["-b", "--buttons"],
"report_type": usbhid.HID_REPORT_TYPE_OUTPUT,
"command": [0x5B],
"value_type": "buttons",
# fmt: off
"buttons": {
"Button1": {"id": 0x01, "offset": 0x00, "default": "button1"},
"Button2": {"id": 0x02, "offset": 0x05, "default": "button2"},
"Button3": {"id": 0x03, "offset": 0x0A, "default": "button3"},
"Button4": {"id": 0x04, "offset": 0x0F, "default": "button4"},
"Button5": {"id": 0x05, "offset": 0x14, "default": "button5"},
"Button6": {"id": 0x06, "offset": 0x19, "default": "dpi"},
},
"button_field_length": 5,
"button_disable": 0x00,
"button_keyboard": 0x51,
"button_multimedia": 0x61,
"button_dpi_switch": 0x30,
# fmt: on
"default": "buttons(button1=button1; button2=button2; button3=button3; button4=button4; button5=button5; button6=dpi; layout=qwerty)",
},
i'm working on understanding how their macros work too.
You may find out that macros are implemented software-side by the GG engine :)
Hello,
Thank you for your reverse engineering of the Prime button bindings. The packet looks similar to other SS mice like the Aerox 3 so you should have:
* 0x00 = disable the button * 0x01 = Button1 (left click) * 0x02 = Button 2 (right click) * 0x03 = Button 3 (wheel) * 0x04 = Button 4 (side-front) * 0x05 = Button 5 (side-back) * 0x06 = Button 6 (used as CPI button but may be handled by the OS if set to 6 (is it located under the mouse? I do not see it on the device photos)) * 0x30 = CPI switch * 0x51 = keyboard key * 0x61 = multimedia key (play, pause,...)You may also be able to rebind scroll up / down (probably 0x31 for scrollup and 0x32 for scroll down).
The packet format looks like:
0x5b <BindingBtn1> <BindingBtn2> ...Where each binding is 5 bytes:
<BindingType 1B> <Param 4B>BindingType is one of the list above (buttonN, disable, cpi, keyboard,...) and Param is a keyboard or multimedia key (you should also be able to set multiple keys like "ctrl", "c", 0, 0 as parameter instead of one key but this is cyrrently not supported by rivalcfg)
It should be easy to add the support to rivalcfg. Here is what it looks like if we add it in the Prime profile:
"buttons_mapping": { "label": "Buttons mapping", "description": "Set the mapping of the buttons", "cli": ["-b", "--buttons"], "report_type": usbhid.HID_REPORT_TYPE_OUTPUT, "command": [0x5B], "value_type": "buttons", # fmt: off "buttons": { "Button1": {"id": 0x01, "offset": 0x00, "default": "button1"}, "Button2": {"id": 0x02, "offset": 0x05, "default": "button2"}, "Button3": {"id": 0x03, "offset": 0x0A, "default": "button3"}, "Button4": {"id": 0x04, "offset": 0x0F, "default": "button4"}, "Button5": {"id": 0x05, "offset": 0x14, "default": "button5"}, "Button6": {"id": 0x06, "offset": 0x19, "default": "dpi"}, }, "button_field_length": 5, "button_disable": 0x00, "button_keyboard": 0x51, "button_multimedia": 0x61, "button_dpi_switch": 0x30, # fmt: on "default": "buttons(button1=button1; button2=button2; button3=button3; button4=button4; button5=button5; button6=dpi; layout=qwerty)", },
Thank you for responding!
Structure of <BindingType 1B> <Param 4B> actually does make a lot more sense. Padding wouldn't really be useful here, we're not working with C char arrays after all. I had just assumed so because 4-byte long button binding just felt more appropriate than a 5-byte one...
Button 6 is indeed on the under side of SteelSeries Prime, it's circular shaped and placed right above the sensor.
About rebinding the mouse_wheel_down to something else - i'm honestly not sure.
The button 6 is the last button mapped (to 30), and if mouse_wheel_down and mouse_wheel_up could be rebound i'd assume that if it would have 31 and 32 set there as default... Still, I tried remapping the mwheeldown and mwheelup actions to other stuff:
payload = bytes.fromhex(
"5b 01 00 00 00 00 02 00 00 00 00 03 00 00 00 00"
"04 00 00 00 00 05 00 00 00 00 30 00 00 00 00 51"
"1a 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00"
"00 00 00 00 00 00 00 00 00 00"
)
It doesn't seem to change anything. I tried moving 51 1a around (in section after button 6), but it doesn't seem to affect anything at all...
Speaking of!! 31 and 32 are indeed action values for scroll up and scroll down respectively!!! It's possible to bind any mouse button to either of those action values and they'll work no problem :)
One more thing i noticed is that there's a bit of just empty space, even after the last byte describing button 6, which i find very odd. 33 whole bytes sent every single time... not sure what to make of this yet.
I'm confident there are also some other action values, usefulness of which is questionable. Like the "shortcut" ones mentioned in SteelSeries GG. Those supposedly open apps upon click, I'll look into those, but they might be hardcoded to call cmd.exe for all i know.
To be perfectly honest, the only reason this was made as an issue and not a pull request is just because I had assumed that rivalcfg just didn't support mouse button mapping period... Quite silly of me, I must admit. Still!! I'll be looking into reverse-engineering how macros are constructed and I'll report on it in the future!!
I figured something cool out!!
first of all, the macro storage is also divided into chunks of 5 bytes.
first 5 bytes is a header (the usual, 5e code for the mouse but what's next is interesting), there's 00 00 80 00 in there! and i think that it's meant to be divided into 00 00 and 80 00, where the latter, 80 00 is the max length of our macro storage page (yes this is just the first page!!) and the former 00 00 is the offset from the beginning of the global macro storage.
i think so because there's always been 2 reports for macros made by GG
notice the two 678 byte SET_REPORT messages, one after another.
keep in mind those 678 bytes contain a 28 byte pesudoheader which isn't really useful for our purposes so i'll just ignore those 28 bytes from now on. this means we have 650 byte message!!
and the only difference between the two when you have no macros on board is the first ever 5 bytes.
on the first 650-byte message: 00 00 80 00, one the second 80 00 80 00, which i think means that the first two bytes are the offset from the global macro storage, which means that both of these calls modify macros, and are just split to not have to make one single gigantic 1300 byte message at once.
also, remember 80? it's 128 in decimal, and 128*5 is 640, leaving 10 bytes to spare for each page!! not sure why there's spare, though...
then, the entries in the macro storage: i couldn't yet figure everything out, i'm still working on it, but from what i've seen the whole thing is quite clever. everything in the macro storage is a 5-byte block, and there's quite a few things to keep track of!
one of those things is MACROHEADER as i call it, it's essentially a 5-byte block screaming "WHAT YOU'RE ABOUT TO SEE IS A MACRO THAT DOES THIS GENERAL THING ON THIS SPECIFIC CONDITION!!!"
The way MACROHEADER looks is something like this: 77 VV ?? 00 WW,
where:
- 77 is the ID that screams "THIS IS A MACROHEADER!!!",
- VV is the type of macro dictating what it will do upon condition being met,
- WW is the condition to meet in order to trigger the macro
no idea yet what 01 00 in the middle there mean, it changes depending on what type of macro you have. for example, for a simple macro that triggers on release of a mouse button to trigger a single key click we get 77 01 01 00 00,
but for macro that clicks on keyboard 10 times with 15ms in-between each click upon mouse button being CLICKED, we get 77 02 03 00 01. so my next assumption is that MACROHEADER has the following structure:
[ID] [TY PE] [CO ND]
where ID is 1 byte - "77"
TYPE is 2 bytes - e.g. "02 03" for N-times click with X delay in ms in-between clicks or "01 01" for single click
COND is 2 bytes - condition to meet for the macro to trigger. yes, we already marry macro to the button in the `5b`, but that just establishes the connection between the two! we need actual activation instructions! i could only figure out that `00 01` means "activate on CLICK of the button this macro is married to" and `00 00` means "activate on RELEASE of the button this macro is married to"
and thus 5 bytes of information.
if you have multiple macros stored in the storage, then you need a way to reference each. you do so by getting the MACROHEADER's offset in terms of 5. for example, if you have the following macro storage:
5e 00 00 80 00 77 02 03 00 01 0a 0f 00 02 17 01
01 04 00 01 00 02 17 01 00 77 02 03 00 01 0a 0f
00 02 15 01 01 04 00 01 00 02 15 01 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
...
which we can break into blocks of 5 for convenience:
0: 5e 00 00 80 00
1: 77 02 03 00 01
2: 0a 0f 00 02 17
3: 01 01 04 00 01
4: 00 02 17 01 00
5: 77 02 03 00 01
6: 0a 0f 00 02 15
7: 01 01 04 00 01
8: 00 02 15 01 00
9: 00 00 00 00 00
...
and if we treat each position like a memory address (where the pointer points to the beginning of the data at that address), then we get that offset 05 is actually pointing to 77 02 03 00 01! that's the second MACROHEADER! so if we want to reference that specific macro, we give offset 05!
next, let's take a look at 5b once more. the key binding message. if we give it 71 it expects a macro offset like i speculated previously. well, as it turns out, i was completely right!
if you want your button to call that second macro that we found previously, you'd call 71 00 00 05, and wouldn't you know it, that's exactly what GG is doing! although it's a bit weird.
it actually calls 71 00 00 05 00 (because button bindings are 5-byte long), but that is a bit more odd for sure. maybe the arguments are actually broken into a pair of 2-byte blocks... i'll have to look into that in the future.
now, i'll finally finish off with some MACROHEADER thoughts.
previously i said that macroheader is BOTH something like 77 VV 01 00 WW AND [ID] [TY PE] [CO ND], but which one is it really - i'm still not sure.
every time VV changes, the byte right after it changes as well. i'd assume that can only mean that [TY PE] view of MACROHEADER is the correct one (with 2 bytes per macro type), but 2 whole bytes for small selection of predefined macros seems a bit excessive...
from what i've seen, [TY PE] can be:
05 01 - "this macro will toggle hold a key upon condition [CO ND]"
02 03 - "this macro will click specified amount of times with specified delay in between clicks upon condition [CO ND]"
01 01 - "this macro will click a single time upon condition [CO ND]"
or maybe the whole structure of MACROHEADER is actually VV ?? where VV is the actual pre-defined macro type and ?? is just some other variable and i have no idea what it does... yet.
if so, then VV can be either of the following:
05 - "this macro will toggle hold a key upon condition [CO ND]"
02 - "this macro will click specified amount of times with specified delay in between clicks upon condition [CO ND]"
01 - "this macro will click a single time upon condition [CO ND]"
there's other pre-defined macros which i'll also take note of in the near future.
now, that was just for the headers. there are also what i call ACTION blocks. those i haven't figured out yet whatsoever. example of a MACROHEADER followed by a few action blocks (each is also 5-bytes long):
77 02 03 00 01 < header
0a 0f 00 02 17 < block
01 01 04 00 01 < block
00 02 17 01 00 < block
77 02 03 00 01 < header
0a 0f 00 02 15 < block
01 01 04 00 01 < block
00 02 15 01 00 < block
you may have noticed 0a and 0f there. those are the 10 clicks with 15ms delay. i'm honestly not sure what else is happening here. 17 is the HID scancode for key T, though.
then there's another macroheader, denoting the next macro. it's pretty much the same thing but instead of key T it sends key R. my only guess so far is that it does the following, in order:
- sleep
0Fms - check if we clicked
0Atimes already, if not - continue execution - click button
but that will need more testing.
i think that'll be it for now, i'm going back to figure more of that stuff out :)
Hello,
I implemented button support for the Prime in the feat-prime-button-support branch. Are you able to test it works?
Here is an example command:
rivalcfg --buttons "buttons(button2=q, button4=scrollUp, button4=scrollDown)"
:)
For the macro, I am unsure to implement this for now: it will be a lot of work and take a lot of time I do not have currently. But any data you find may be useful one day ^^
Hello,
I implemented button support for the Prime in the
feat-prime-button-supportbranch. Are you able to test it works?Here is an example command:
rivalcfg --buttons "buttons(button2=q, button4=scrollUp, button4=scrollDown)":)
Just tested it out, works great! All buttons are able to be re-mapped!
Regarding macros stuff - no worries! I'll continue working on reverse engineering them and will publish a repo on codeberg showcasing proof of concept and providing in-depth explanation of it all eventually. I can notify when that happens by replying to this issue later on if you're interested :)
Thanks!
A little update: i found where the mousewheel actions are bound. the structure is similar to the regular mouse bindings, but it's in a separate message, for some reason..
0000 5c 31 00 00 00 00 32 00 00 00 00 00 00 00 00 00
0010 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0020 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0030 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
so yeah, the structure is identical, just a different command for some reason..
And also, I finished most of the work on the macros! https://codeberg.org/alexlnkp/steelseries-macros
A huge in-depth explanation is available in the text file HOW
I drawn the button schema for the doc. Once I finished updating the doc I will be able to merge my branch :)
Button binding released as v4.15.0 :)
Button binding released as v4.15.0 :)
Nice, thank you! :)
One thing, though: for scroll wheel actions (i.e. actions which are triggered upon scroll wheel down/up), 5c is used instead of 5b
The structure is identical, but scroll wheel has its own dedicated packet for some reason..
import sys
import usb.core
# hardcoded 1038:182e for steelseries prime
dev = usb.core.find(idVendor=0x1038, idProduct=0x182e)
if dev is None:
raise ValueError("Device not found")
i = dev[0].interfaces()[0].bInterfaceNumber
if dev.is_kernel_driver_active(i):
try:
dev.detach_kernel_driver(i)
except usb.core.USBError as e:
sys.exit("Could not detatch kernel driver from interface({0}): {1}".format(i, str(e)))
# will do a left click on scroll up and right click on scroll down
payload = bytes.fromhex(
"5c 01 00 00 00 00 02 00 00 00 00 00 00 00 00 00"
"00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00"
"00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00"
"00 00 00 00 00 00 00 00 00 00"
)
# send SET_REPORT (bmRequestType=0x21, bRequest=0x09, wValue=0x0200)
dev.ctrl_transfer(0x21, 0x09, 0x0200, 0, payload)