Wherein I figure out more of the mouse's protocol by delving deep into the guts of the configuration tool, and use hidapi to write a tool that lets me poke at it. Complete with a surprising plot twist...

Previously in this Saga

In Part 1, we started this quest by reversing the simplest part of the mouse's protocol. In Part 2, we dug out the MTP file containing the firmware. In Part 3, we extracted the code from that file and wrote a primitive disassembler.

This means we've now got multiple avenues of attack when it comes to figuring out the mouse's protocol. We can look at the configuration tool's code and see what commands it's sending. We can look at the firmware to see what commands it handles -- there might be features that the configuration tool doesn't actually use, or options that it doesn't expose. Finally, we can just try throwing data at it -- albeit given the code quality in play here, fuzzing the mouse probably isn't the finest idea.

Target 1: Config Tool

When we looked at the firmware updater in Part 2, we looked at the communications with the device FF00:FF00. There's a pretty simple protocol that I'll recap here. Every command is 8 bytes, sent through a HID feature report. The first byte is a command ID -- if the highest bit is set, then we expect a reply. The last byte is used to form a rudimentary checksum. The middle six bytes are the command parameters.

We know about two commands so far, thanks to the updater. Command 0xA starts the process of switching the mouse into bootloader mode, with the parameters {0xAA, 0x55, 0xCC, 0x33, 0xBB, 0x99}. Command 0x8A is sent directly afterwards, with all-zero parameters. There's a reply (since the highest bit on the ID is set), but it's ignored.

Let's see what else we can learn from the config tool; there's bound to be more in there! There's a function in there that I named init_classes back in Part 1, which created a read/write handle to that device. We can just look at the cross-references to that handle to find out what's going on. Straightforward.

(I'm gonna switch to the 32-bit version of the executable here, purely for convenience reasons -- I'm working on a different machine right now where I installed the drivers in a crusty Windows XP VM and grabbed the EXE out of there, and it turns out it's somewhat easier to follow than the 64-bit version, reversing-wise.)

As it turns out, there's just five functions that refer to it. The first two, at 0x411820 and 0x411A00, just call CloseHandle on the device. The third is init_classes. The latter two are where the interesting stuff happens.

Extending the Protocol

The function at 0x42E4E0 is the same as what we already saw in the firmware updater. It constructs a 9-byte buffer using a zero HID report ID, 7 bytes from the supplied command, and a final byte to calculate the checksum. It calls HidD_SetFeature to send that buffer as a feature report down the FF00:FF00 device handle. Then, if the highest bit in the command ID is set, it calls HidD_GetFeature to read in an 8-byte reply.

The function at 0x42E5A0 is new and interesting: it's used to send and receive large blocks of data! Here's an annotated decompilation of it:

void __fastcall sendBulkCommand(unsigned __int8 *command, unsigned __int8 *data)
{
  unsigned __int8 *command_; // esi
  unsigned __int8 *data_; // ebx
  int v4; // edx
  char v5; // cl
  unsigned int receivingData; // edi
  int v7; // ecx
  unsigned int blockCount; // ebp
  unsigned __int8 *thisBlock; // edi
  unsigned int blockCount_; // ebp
  DWORD NumberOfBytesWritten; // [esp+10h] [ebp-64h]
  DWORD NumberOfBytesRead; // [esp+14h] [ebp-60h]
  char Buffer[80]; // [esp+18h] [ebp-5Ch]

  command_ = command;
  data_ = data;
  memset(Buffer, 0, 0x50u);
  v4 = *((_DWORD *)command_ + 1);
  *(_DWORD *)&Buffer[1] = *(_DWORD *)command_;
  v5 = -1 - command_[6];
  *(_DWORD *)&Buffer[5] = v4;
  Buffer[8] = v5 - command_[5] - v4 - command_[3] - command_[1] - Buffer[1] - command_[2];// checksum
  receivingData = (unsigned int)(unsigned __int8)Buffer[1] >> 7;
  HidD_SetFeature(vendorHandle, Buffer, (unsigned __int16)vendorFeatureReportLength);
  if ( receivingData )
  {
    memset(Buffer, 0, 0x50u);
    HidD_GetFeature(vendorHandle, Buffer, (unsigned __int16)vendorFeatureReportLength);
    v7 = *(_DWORD *)&Buffer[5];
    *(_DWORD *)command_ = *(_DWORD *)&Buffer[1];
    *((_DWORD *)command_ + 1) = v7;
    if ( ((unsigned int)command_[2] >> 6) + (command_[2] % 64 != 0) )
    {
      blockCount = ((unsigned int)command_[2] >> 6) + (command_[2] % 64 != 0);
      do
      {
        memset(Buffer, 0, 0x50u);
        ReadFile(vendorHandle, Buffer, (unsigned __int16)vendorInputReportLength, &NumberOfBytesRead, 0);
        GetLastError();
        thisBlock = data_;
        data_ += 64;
        --blockCount;
        qmemcpy(thisBlock, &Buffer[1], 0x40u);
      }
      while ( blockCount );
    }
  }
  else if ( ((unsigned int)command_[2] >> 6) + (command_[2] % 64 != 0) )
  {
    blockCount_ = ((unsigned int)command_[2] >> 6) + (command_[2] % 64 != 0);
    do
    {
      memset(Buffer, 0, 0x50u);
      qmemcpy(&Buffer[1], data_, 0x40u);
      WriteFile(vendorHandle, Buffer, 0x41u, &NumberOfBytesWritten, 0);
      Sleep(0x14u);
      data_ += 64;
      --blockCount_;
    }
    while ( blockCount_ );
  }
}

As usual, I'll describe this just so you get a better idea of what's going on. It starts off in the same way as the function to send simple commands: a 9-byte buffer is constructed with a zero report ID, the command data, and the checksum byte. This is sent to the mouse using HidD_SetFeature.

Everything changes after that. If we're expecting to see some data (the top byte of the command ID is set), then we get a reply as usual. Then, the third byte of the reply is expected to contain an amount of bytes. The function figures out how many blocks of 0x40 bytes are needed to contain this and then uses ReadFile to read them into the passed buffer, block-by-block.

If no reply is expected, then a similar trick is done to send some data to the mouse. The third byte of the command supplied to the function contains an amount of bytes. Data is sent in blocks of 0x40 bytes to the mouse using WriteFile, with a 20-milisecond wait after each block.

That completes our understanding of the low-level protocol. To sum it up, we've got four core types of communication:

  • simple write commands (6-byte arguments, no reply)
  • simple read commands (6-byte arguments, 8-byte reply)
  • bulk write commands (6-byte arguments followed by payload of up to 255 bytes, no reply)
  • bulk read commands (6-byte arguments, 8-byte reply followed by payload of up to 255 bytes).

New Commands

We can now look at the cross-references to our sendCommand and sendBulkCommand functions to figure out what commands we have available!

In the callers for sendCommand, we find the following functions:

  • 0x42E750: Sends command 1 with a single-byte parameter, pulled from a global structure
  • 0x42E7A0: Sends command 0x82 with no parameters, and writes a single-byte result into another field in that global structure
  • 0x42E7F0: Sends command 3 with two single-byte parameters, pulled from that global structure
  • 0x414910: Sends command 2 with no parameters, calls some of the other functions, writes into said global structure a lot

The first two are pretty straightforward, but we don't have enough info to figure out what they do just yet. The fourth is quite complex, so we'll leave it just now.

Static analysis like this is great for discovering what commands are available, and what format they take, but it's not always the easiest way to figure out what they do. This is a good example of that: going by the cross-references to the field that's used for command 1, it's only ever set to 0xFC, Presumably it does something, but I have no clue what, or what other possible valid values there are!

Let's look at the callers for sendBulkCommand next:

  • 0x42E850: Either reads or writes (depending on a parameter) an 0x80-length block in a global structure, using commands 0xC and 0x8C
  • 0x42E940: Writes another 0x80-length block in that structure, using command 0xD, and specifying a mystery field in a parameter
  • 0x42EA10: Writes a series of 12 0x80-length blocks from that structure, using command 0xF and specifying the block index in a parameter

A lot of this stuff seems to be working with that structure (at offset 0x4A29A0). We can start to get a vague idea of what's going on. Some of those fields are probably single configuration properties; we've just got to figure out what. There's two independent 0x80-length blocks, which are probably different structures. There's one set of 12 0x80-length blocks, so this is probably something that occurs multiple times. It could be related to buttons, as this mouse has 12 buttons, but 0x80 bytes for an individual button's configuration seems like a bit of a stretch... we'll figure it out later though, hopefully.

The Big Save Function

A good next step is to go back to the complex function at 0x414910 and try to see what's going on. There's a lot going on there, but most of it involves that structure we've seen accessed over and over again. We can flesh out a rough skeleton for it by looking at the field accesses inside that function (and the ones for sending different kinds of commands), which makes the function somewhat readable.

We've also given those blocks rudimentary names, inspired by their command IDs: blockC, blockD, blocksF. Here's what our decompiled code looks like right now:

BOOL __stdcall sub_414910(CWnd *a1)
{
  char v1; // al
  int v2; // ecx
  char v3; // al
  char v4; // al
  char v5; // dl
  char v6; // al
  char v7; // dl
  char v8; // dl
  char v9; // dl
  __int16 v10; // dx
  char v11; // al
  __int16 v12; // dx
  char v13; // al
  __int16 v14; // dx
  char v15; // al
  __int16 v16; // dx
  char v17; // al
  signed int srcIndex; // ecx
  signed int destIndex; // eax
  int generatedBlockDEntry; // edx
  int v21; // ST5C_4
  unsigned __int8 v22; // dl
  signed int v23; // ST34_4
  char v24; // dl
  int v25; // ST20_4
  int v26; // ST38_4
  int v27; // ST2C_4
  int v28; // ST30_4
  signed int v30; // [esp+14h] [ebp-5Ch]
  int v31; // [esp+30h] [ebp-40h]
  char cmd[16]; // [esp+5Ch] [ebp-14h]

  *(_DWORD *)cmd = 0;
  byte_4A2280[0] = 0;
  data.fieldFromCmd82 = 0;
  *(_DWORD *)&cmd[4] = 0;
  *(_DWORD *)&cmd[8] = 0;
  *(_DWORD *)&cmd[12] = 0;
  cmd[0] = 2;
  cmd[1] = 0;
  doCmd((unsigned __int8 *)cmd);
  Sleep(0xAu);
  data.fieldForCmd3 = byte_4A2280[5];
  writeFieldCmd3();
  readOrWriteBlockC(0);
  v1 = *((_BYTE *)&dword_49DE2C + (unsigned __int8)byte_4A2280[56] + 3);
  v2 = (unsigned __int8)byte_4A2280[0] << 7;
  data.blockC.field_2 = 1;
  *(&data.blockC.field_32 + v2) = 66;
  *(&data.blockC.field_33 + v2) = v1;
  v3 = 20 * byte_4A2280[58];
  *(&data.blockC.field_40 + v2) = 15;
  *(&data.blockC.field_4A + v2) = v3;
  *(&data.blockC.field_4B + v2) = 20 * byte_4A2280[59];
  v4 = byte_4A2280[47];
  *(&data.blockC.field_47 + v2) = byte_4A2280[47];
  if ( v4 )
  {
    switch ( v4 )
    {
      case 1:
        if ( byte_4A2280[48] == 1 )
        {
          *(&data.blockC.field_47 + v2) = 0;
        }
        else
        {
          *(&data.blockC.field_48 + v2) = 2 * byte_4A2280[48];
          *(&data.blockC.field_49 + v2) = 3;
        }
        break;
      case 2:
        *(&data.blockC.field_48 + v2) = byte_4A2280[49];
        *(&data.blockC.field_49 + v2) = 3;
        break;
      case 3:
        *(&data.blockC.field_48 + v2) = 3 * byte_4A2280[50];
        break;
    }
  }
  v5 = byte_4A2280[7];
  *(&data.blockC.field_64 + v2) = byte_4A2280[15] | 2 * (byte_4A2280[16] | 2 * (byte_4A2280[17] | 2 * byte_4A2280[18]));
  v6 = 0;
  if ( v5 == -94 )
  {
    v5 = -93;
LABEL_12:
    *(&data.blockC.field_54 + v2) = v5;
    *(&data.blockC.field_5C + v2) = v5;
    goto LABEL_13;
  }
  if ( v5 == -93 )
  {
    v5 = -92;
    goto LABEL_12;
  }
  if ( v5 != -92 )
    goto LABEL_12;
  *(&data.blockC.field_54 + v2) = -92;
  *(&data.blockC.field_5C + v2) = -92;
  v6 = 1;
LABEL_13:
  v7 = byte_4A2280[8];
  *(&data.blockC.field_53 + v2) = v6;
  if ( v7 == -94 )
  {
    v7 = -93;
    goto LABEL_23;
  }
  if ( v7 != -93 )
  {
    if ( v7 != -92 )
      goto LABEL_23;
    v6 |= 2u;
  }
  v7 = -92;
LABEL_23:
  *(&data.blockC.field_55 + v2) = v7;
  *(&data.blockC.field_5D + v2) = v7;
  v8 = byte_4A2280[9];
  *(&data.blockC.field_53 + v2) = v6;
  if ( v8 == -94 )
  {
    v8 = -93;
    goto LABEL_29;
  }
  if ( v8 != -93 )
  {
    if ( v8 != -92 )
      goto LABEL_29;
    v6 |= 4u;
  }
  v8 = -92;
LABEL_29:
  *(&data.blockC.field_56 + v2) = v8;
  *(&data.blockC.field_5E + v2) = v8;
  v9 = byte_4A2280[10];
  *(&data.blockC.field_53 + v2) = v6;
  if ( v9 != -94 )
  {
    if ( v9 != -93 )
    {
      if ( v9 != -92 )
        goto LABEL_35;
      v6 |= 8u;
    }
    v9 = -92;
    goto LABEL_35;
  }
  v9 = -93;
LABEL_35:
  *(&data.blockC.field_57 + v2) = v9;
  *(&data.blockC.field_5F + v2) = v9;
  v10 = *(_WORD *)&byte_4A2280[23];
  *(&data.blockC.field_53 + v2) = v6;
  *(int *)((char *)&data.blockC.field_68 + v2) = 0;
  *(int *)((char *)&data.blockC.field_6C + v2) = 0;
  *(int *)((char *)&data.blockC.field_70 + v2) = 0;
  *(int *)((char *)&data.blockC.field_74 + v2) = 0;
  *(int *)((char *)&data.blockC.field_78 + v2) = 0;
  *(int *)((char *)&data.blockC.field_7C + v2) = 0;
  *(int *)((char *)&data.field_91 + v2) = 0;
  v11 = byte_4A2280[25];
  *(_WORD *)((char *)&data.blockC.field_68 + v2) = v10;
  v12 = *(_WORD *)&byte_4A2280[26];
  *((_BYTE *)&data.blockC.field_68 + v2 + 2) = v11;
  v13 = byte_4A2280[28];
  *(_WORD *)((char *)&data.blockC.field_68 + v2 + 3) = v12;
  v14 = *(_WORD *)&byte_4A2280[29];
  *((_BYTE *)&data.blockC.field_6C + v2 + 1) = v13;
  v15 = byte_4A2280[31];
  *(_WORD *)((char *)&data.blockC.field_6C + v2 + 2) = v14;
  v16 = *(_WORD *)&byte_4A2280[32];
  *((_BYTE *)&data.blockC.field_70 + v2) = v15;
  v17 = byte_4A2280[34];
  *(_WORD *)((char *)&data.blockC.field_70 + v2 + 1) = v16;
  *((_BYTE *)&data.blockC.field_70 + v2 + 3) = v17;
  *(&data.blockC.field_46 + v2) = 4;
  readOrWriteBlockC(1);
  memset(&data.blockD, 0, 0x80u);
  srcIndex = 0;
  do
  {
    switch ( srcIndex )
    {
      case 3:
        destIndex = 5;
        break;
      case 4:
        destIndex = 6;
        break;
      case 5:
        destIndex = 3;
        break;
      case 6:
        destIndex = 4;
        break;
      case 7:
        destIndex = 11;
        break;
      case 8:
        destIndex = 10;
        break;
      case 10:
        destIndex = 8;
        break;
      default:
        destIndex = 7;
        if ( srcIndex != 11 )
          destIndex = srcIndex;
        break;
    }
    switch ( byte_4A2280[srcIndex + 60] )
    {
      case 0:
        generatedBlockDEntry = 0xF00001;
        break;
      case 1:
        generatedBlockDEntry = 0xF10001;
        break;
      case 2:
        generatedBlockDEntry = 0xF20001;
        break;
      case 3:
        generatedBlockDEntry = 0xF40001;
        break;
      case 4:
        generatedBlockDEntry = 0xF30001;
        break;
      case 5:
        generatedBlockDEntry = 0x21EF00A;
        break;
      case 6:
        LOWORD(v21) = 7;
        HIWORD(v21) = (unsigned __int8)byte_4A2280[srcIndex + 72];
        generatedBlockDEntry = v21;
        break;
      case 7:
        v22 = byte_4A2280[srcIndex + 96];
        LOWORD(v31) = 3;
        HIWORD(v31) = v22;
        if ( v22 == -125 )
          HIBYTE(v31) = 1;
        generatedBlockDEntry = v31;
        break;
      case 8:
        v23 = 14680064;
        HIBYTE(v23) = byte_4A2280[srcIndex + 156];
        generatedBlockDEntry = v23;
        break;
      case 9:
        *(_WORD *)((char *)&v30 + 1) = 0;
        HIBYTE(v30) = 0;
        v24 = byte_4A2280[srcIndex + 108];
        LOBYTE(v30) = 0;
        if ( v24 )
        {
          if ( v24 == 1 )
          {
            generatedBlockDEntry = 35848195;
          }
          else if ( v24 == 2 )
          {
            generatedBlockDEntry = 35717123;
          }
          else if ( v24 == 3 )
          {
            LOBYTE(v25) = 0;
            *(_WORD *)((char *)&v25 + 1) = 11268;
            HIBYTE(v25) = 27;
            generatedBlockDEntry = v25;
          }
          else if ( v24 == 4 )
          {
            generatedBlockDEntry = 288097280;
          }
          else if ( v24 == 5 )
          {
            generatedBlockDEntry = 132317184;
          }
          else
          {
            if ( v24 == 6 )
              v30 = 367198208;
            generatedBlockDEntry = v30;
          }
        }
        else
        {
          generatedBlockDEntry = 26476547;
        }
        break;
      case 0xA:
        LOWORD(v26) = 0;
        HIWORD(v26) = (unsigned __int8)byte_4A2280[srcIndex + 168];
        generatedBlockDEntry = v26;
        break;
      case 0xB:
        BYTE1(v27) = byte_4A2280[srcIndex + 180];
        BYTE2(v27) = byte_4A2280[srcIndex + 192];
        LOBYTE(v27) = 0;
        HIBYTE(v27) = byte_4A2280[srcIndex + 204];
        generatedBlockDEntry = v27;
        break;
      case 0xC:
        BYTE1(v28) = byte_4A2280[srcIndex + 276];
        LOBYTE(v28) = 9;
        HIWORD(v28) = (unsigned __int8)(byte_4A2280[srcIndex + 264] + 1);
        generatedBlockDEntry = v28;
        break;
      case 0xD:
        generatedBlockDEntry = 0;
        break;
      case 0xE:
        generatedBlockDEntry = 0xF50001;
        break;
      case 0xF:
        generatedBlockDEntry = 0xF60001;
        break;
      default:
        generatedBlockDEntry = 0;
        break;
    }
    ++srcIndex;
    data.blockD.field_0[destIndex] = generatedBlockDEntry;
  }
  while ( srcIndex < 12 );
  data.blockD.field_38 = 0xF70001;
  data.blockD.field_3C = 0xF80001;
  writeBlockCmdD();
  qmemcpy(data.blockFs, &byte_4A2280[288], sizeof(data.blockFs));
  writeBlocksCmdF();
  sub_4145E0(a1);
  return sub_413CE0((LPCWSTR *)a1);
}

As usual, let's summarise whats's going on. Command 2 is sent, with zero parameters, followed by a 10ms sleep. The field for command 3 is loaded up from another global structure, and command 3 is used to send it to the mouse.

readOrWriteBlockC(0) is called to get block C from the mouse. A bunch of data is copied into different fields in block C. readOrWriteBlockC(1) is then called to send it back to the mouse.

Block D is cleared to all zeroes, and generated using a loop. It's then written to the mouse. Blocks F are copied from another part of memory, and then also written to the mouse.

Finally, two functions are called, 0x4145E0 and 0x413CE0. The former generates the configX.bin files we saw in the configuration tool's directory, by first writing a single 01 byte and then writing 0x720 bytes worth of data from byte_4A2280. Wait, where have we seen that offset...? It's the global structure where all the fields for the various data blocks are pulled from in that large function we were just looking at. As for the latter function, that writes the strX.ini file.

We can infer a few things from this. First off, look at the high-level structure of this: this function writes a bunch of stuff to the mouse and then saves configX.bin. This is most probably what happens when you click the Save button in the tool. Second, we can map out a rough layout for that global structure, giving us a better understanding of the tool's code (and of the configX.bin files, if we want to do anything with them).

Doing this gets us something that's a bit more readable! Here's what we've got now, before we move on...

BOOL __stdcall saveMouseData(CWnd *a1)
{
  char v1; // al
  int strangeOffset; // ecx
  char v3; // al
  char v4; // al
  char v5; // dl
  char v6; // al
  char v7; // dl
  char v8; // dl
  char v9; // dl
  __int16 v10; // dx
  char v11; // al
  __int16 v12; // dx
  char v13; // al
  __int16 v14; // dx
  char v15; // al
  __int16 v16; // dx
  char v17; // al
  signed int srcIndex; // ecx
  signed int destIndex; // eax
  int generatedBlockDEntry; // edx
  int v21; // ST5C_4
  unsigned __int8 v22; // dl
  signed int v23; // ST34_4
  char v24; // dl
  int v25; // ST20_4
  int v26; // ST38_4
  int v27; // ST2C_4
  int v28; // ST30_4
  signed int v30; // [esp+14h] [ebp-5Ch]
  int v31; // [esp+30h] [ebp-40h]
  char cmd[16]; // [esp+5Ch] [ebp-14h]

  *(_DWORD *)cmd = 0;
  configData.strangeIndex = 0;
  deviceData.fieldFromCmd82 = 0;
  *(_DWORD *)&cmd[4] = 0;
  *(_DWORD *)&cmd[8] = 0;
  *(_DWORD *)&cmd[12] = 0;
  cmd[0] = 2;
  cmd[1] = 0;
  doCmd((unsigned __int8 *)cmd);
  Sleep(0xAu);
  deviceData.fieldForCmd3 = configData.fieldForCmd3;
  writeFieldCmd3();
  readOrWriteBlockC(0);
  v1 = byte_49DE2F[configData.field_38];
  strangeOffset = configData.strangeIndex << 7; // configData.strangeIndex * sizeof(BlockC)
                                                // for some reason IDA won't quite pick this up right...
  deviceData.blockC[0].field_2 = 1;
  *(&deviceData.blockC[0].field_32 + strangeOffset) = 66;
  *(&deviceData.blockC[0].field_33 + strangeOffset) = v1;
  v3 = 20 * configData.field_3A;
  *(&deviceData.blockC[0].field_40 + strangeOffset) = 15;
  *(&deviceData.blockC[0].field_4A + strangeOffset) = v3;
  *(&deviceData.blockC[0].field_4B + strangeOffset) = 20 * configData.field_3B;
  v4 = configData.field_2F;
  *(&deviceData.blockC[0].field_47 + strangeOffset) = configData.field_2F;
  if ( v4 )
  {
    switch ( v4 )
    {
      case 1:
        if ( configData.field_30 == 1 )
        {
          *(&deviceData.blockC[0].field_47 + strangeOffset) = 0;
        }
        else
        {
          *(&deviceData.blockC[0].field_48 + strangeOffset) = 2 * configData.field_30;
          *(&deviceData.blockC[0].field_49 + strangeOffset) = 3;
        }
        break;
      case 2:
        *(&deviceData.blockC[0].field_48 + strangeOffset) = configData.field_31;
        *(&deviceData.blockC[0].field_49 + strangeOffset) = 3;
        break;
      case 3:
        *(&deviceData.blockC[0].field_48 + strangeOffset) = 3 * configData.field_32;
        break;
    }
  }
  v5 = configData.field_7;
  *(&deviceData.blockC[0].field_64 + strangeOffset) = configData.field_F | 2
                                                                         * (configData.field_10 | 2
                                                                                                * (configData.field_11 | 2 * configData.field_12));
  v6 = 0;
  if ( v5 == -94 )
  {
    v5 = -93;
LABEL_12:
    *(&deviceData.blockC[0].field_54 + strangeOffset) = v5;
    *(&deviceData.blockC[0].field_5C + strangeOffset) = v5;
    goto LABEL_13;
  }
  if ( v5 == -93 )
  {
    v5 = -92;
    goto LABEL_12;
  }
  if ( v5 != -92 )
    goto LABEL_12;
  *(&deviceData.blockC[0].field_54 + strangeOffset) = -92;
  *(&deviceData.blockC[0].field_5C + strangeOffset) = -92;
  v6 = 1;
LABEL_13:
  v7 = configData.field_8;
  *(&deviceData.blockC[0].field_53 + strangeOffset) = v6;
  if ( v7 == -94 )
  {
    v7 = -93;
    goto LABEL_23;
  }
  if ( v7 != -93 )
  {
    if ( v7 != -92 )
      goto LABEL_23;
    v6 |= 2u;
  }
  v7 = -92;
LABEL_23:
  *(&deviceData.blockC[0].field_55 + strangeOffset) = v7;
  *(&deviceData.blockC[0].field_5D + strangeOffset) = v7;
  v8 = configData.field_9;
  *(&deviceData.blockC[0].field_53 + strangeOffset) = v6;
  if ( v8 == -94 )
  {
    v8 = -93;
    goto LABEL_29;
  }
  if ( v8 != -93 )
  {
    if ( v8 != -92 )
      goto LABEL_29;
    v6 |= 4u;
  }
  v8 = -92;
LABEL_29:
  *(&deviceData.blockC[0].field_56 + strangeOffset) = v8;
  *(&deviceData.blockC[0].field_5E + strangeOffset) = v8;
  v9 = configData.field_A;
  *(&deviceData.blockC[0].field_53 + strangeOffset) = v6;
  if ( v9 != -94 )
  {
    if ( v9 != -93 )
    {
      if ( v9 != -92 )
        goto LABEL_35;
      v6 |= 8u;
    }
    v9 = -92;
    goto LABEL_35;
  }
  v9 = -93;
LABEL_35:
  *(&deviceData.blockC[0].field_57 + strangeOffset) = v9;
  *(&deviceData.blockC[0].field_5F + strangeOffset) = v9;
  v10 = configData.field_17;
  *(&deviceData.blockC[0].field_53 + strangeOffset) = v6;
  *(_DWORD *)((char *)&deviceData.blockC[0].field_68 + strangeOffset) = 0;
  *(_DWORD *)((char *)&deviceData.blockC[0].field_6B + strangeOffset + 1) = 0;
  *(_DWORD *)(&deviceData.blockC[0].field_70 + strangeOffset) = 0;
  *(int *)((char *)&deviceData.blockC[0].field_74 + strangeOffset) = 0;
  *(int *)((char *)&deviceData.blockC[0].field_78 + strangeOffset) = 0;
  *(int *)((char *)&deviceData.blockC[0].field_7C + strangeOffset) = 0;
  *(_DWORD *)&deviceData.blockC[1].gap0[strangeOffset] = 0;
  v11 = configData.field_19;
  *(__int16 *)((char *)&deviceData.blockC[0].field_68 + strangeOffset) = v10;
  v12 = configData.field_1A;
  *(&deviceData.blockC[0].field_6A + strangeOffset) = v11;
  v13 = configData.field_1C;
  *(__int16 *)((char *)&deviceData.blockC[0].field_6B + strangeOffset) = v12;
  v14 = configData.field_1D;
  *(&deviceData.blockC[0].field_6D + strangeOffset) = v13;
  v15 = configData.field_1F;
  *(__int16 *)((char *)&deviceData.blockC[0].field_6E + strangeOffset) = v14;
  v16 = configData.field_20;
  *(&deviceData.blockC[0].field_70 + strangeOffset) = v15;
  v17 = configData.field_22;
  *(__int16 *)((char *)&deviceData.blockC[0].field_71 + strangeOffset) = v16;
  *(&deviceData.blockC[0].field_73 + strangeOffset) = v17;
  *(&deviceData.blockC[0].field_46 + strangeOffset) = 4;
  readOrWriteBlockC(1);
  memset(&deviceData.blockD, 0, 0x80u);
  srcIndex = 0;
  do
  {
    switch ( srcIndex )
    {
      case 3:
        destIndex = 5;
        break;
      case 4:
        destIndex = 6;
        break;
      case 5:
        destIndex = 3;
        break;
      case 6:
        destIndex = 4;
        break;
      case 7:
        destIndex = 11;
        break;
      case 8:
        destIndex = 10;
        break;
      case 10:
        destIndex = 8;
        break;
      default:
        destIndex = 7;
        if ( srcIndex != 11 )
          destIndex = srcIndex;
        break;
    }
    switch ( configData.blockDEntryModes[srcIndex] )
    {
      case 0:
        generatedBlockDEntry = 0xF00001;
        break;
      case 1:
        generatedBlockDEntry = 0xF10001;
        break;
      case 2:
        generatedBlockDEntry = 0xF20001;
        break;
      case 3:
        generatedBlockDEntry = 0xF40001;
        break;
      case 4:
        generatedBlockDEntry = 0xF30001;
        break;
      case 5:
        generatedBlockDEntry = 0x21EF00A;
        break;
      case 6:
        LOWORD(v21) = 7;
        HIWORD(v21) = (unsigned __int8)configData.blockDEntryMode6Array[srcIndex];
        generatedBlockDEntry = v21;
        break;
      case 7:
        v22 = configData.blockDEntryMode7Array[srcIndex];
        LOWORD(v31) = 3;
        HIWORD(v31) = v22;
        if ( v22 == 0x83u )
          HIBYTE(v31) = 1;
        generatedBlockDEntry = v31;
        break;
      case 8:
        v23 = 0xE00000;
        HIBYTE(v23) = configData.blockDEntryMode8Array[srcIndex];
        generatedBlockDEntry = v23;
        break;
      case 9:
        *(_WORD *)((char *)&v30 + 1) = 0;
        HIBYTE(v30) = 0;
        v24 = configData.blockDEntryMode9Array[srcIndex];
        LOBYTE(v30) = 0;
        if ( v24 )
        {
          if ( v24 == 1 )
          {
            generatedBlockDEntry = 0x2230003;
          }
          else if ( v24 == 2 )
          {
            generatedBlockDEntry = 0x2210003;
          }
          else if ( v24 == 3 )
          {
            LOBYTE(v25) = 0;
            *(_WORD *)((char *)&v25 + 1) = 0x2C04;
            HIBYTE(v25) = 27;
            generatedBlockDEntry = v25;
          }
          else if ( v24 == 4 )
          {
            generatedBlockDEntry = 0x112C0400;
          }
          else if ( v24 == 5 )
          {
            generatedBlockDEntry = 0x7E30000;
          }
          else
          {
            if ( v24 == 6 )
              v30 = 0x15E30000;
            generatedBlockDEntry = v30;
          }
        }
        else
        {
          generatedBlockDEntry = 0x1940003;
        }
        break;
      case 0xA:
        LOWORD(v26) = 0;
        HIWORD(v26) = (unsigned __int8)configData.blockDEntryModeAArray[srcIndex];
        generatedBlockDEntry = v26;
        break;
      case 0xB:
        BYTE1(v27) = configData.blockDEntryModeBArray0[srcIndex];
        BYTE2(v27) = configData.blockDEntryModeBArray1[srcIndex];
        LOBYTE(v27) = 0;
        HIBYTE(v27) = configData.blockDEntryModeBArray2[srcIndex];
        generatedBlockDEntry = v27;
        break;
      case 0xC:
        BYTE1(v28) = configData.blockDEntryModeCArray1[srcIndex];
        LOBYTE(v28) = 9;
        HIWORD(v28) = (unsigned __int8)(configData.blockDEntryModeCArray0[srcIndex] + 1);
        generatedBlockDEntry = v28;
        break;
      case 0xD:
        generatedBlockDEntry = 0;
        break;
      case 0xE:
        generatedBlockDEntry = 0xF50001;
        break;
      case 0xF:
        generatedBlockDEntry = 0xF60001;
        break;
      default:
        generatedBlockDEntry = 0;
        break;
    }
    ++srcIndex;
    deviceData.blockD.entries[destIndex] = generatedBlockDEntry;
  }
  while ( srcIndex < 12 );
  deviceData.blockD.field_38 = 0xF70001;
  deviceData.blockD.field_3C = 0xF80001;
  writeBlockCmdD();
  qmemcpy(deviceData.blockFs, configData.blockFs, sizeof(deviceData.blockFs));
  writeBlocksCmdF();
  saveConfigBin(a1);
  return saveStrIni((LPCWSTR *)a1);
}

What We've Learned

We know some commands: there are fields we can write using commands 1, 2 and 3, and read using 0x82. There might be 0x81 and 0x83 commands, but these aren't seen in the config tool, and we haven't yet developed the ability to send our own commands to the mouse.

We know there's also some opaque (for now) data blocks: one written/read with commands 0xC and 0x8C, one written with 0xD (possibly readable with 0x8D, but untested), and 12 blocks written with command 0xF (possibly readable with 0x8F, but untested). All of these blocks are 0x80 bytes long. They all seem to contain different kinds of data.

We come to a fork in the road: we could delve into the firmware disassembly, or we can write a simple tool to poke at the mouse. Let's go for the latter -- we're gonna need it eventually either way!

Poking the mouse with hidapi

Windows-specific APIs are incredibly passé. Luckily, there's a nice little library we can use: hidapi. It's simple, open-source (BSD, GPL3 or a very permissive 'keep the copyright notices' licence) and cross-platform (Windows, Linux, Mac, FreeBSD). Let's throw together an implementation of the command sending functions, and try out two of the commands that we've seen in the config tool: 0x8C (fetch block C) and 0x82 (fetch something).

#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <hidapi/hidapi.h>

#ifdef _WIN32
#include <windows.h>
void sleep_ms(int ms) {
    Sleep(ms);
}
#else
#include <unistd.h>
void sleep_ms(int ms) {
    usleep(ms * 1000);
}
#endif

void hexdump(const uint8_t *buf, int size) {
    wprintf(L"{");
    for (int i = 0; i < size; i++) {
        wprintf((i == 0) ? L"%02x" : L",%02x", buf[i]);
        if ((i % 16) == 15 && i != (size - 1))
            wprintf(L"\n");
    }
    wprintf(L"}\n");
}

#define CMD_SIZE 8
#define BULK_CMD_SIZE 64
#define CONFIG_SIZE 128

hid_device *open_vendor_device(uint16_t vendor_id, uint16_t product_id) {
    hid_device_info *devices = hid_enumerate(vendor_id, product_id);
    hid_device_info *iter = devices;
    hid_device *result = NULL;

    while (iter) {
        wprintf(L"Device: %04x:%04x Ver:%04x SN:%ls - %ls %ls\n",
            iter->vendor_id, iter->product_id, iter->release_number,
            iter->serial_number, iter->manufacturer_string, iter->product_string);

        wprintf(L"Interface: %d / Usage: %04x:%04x / Path: %s\n",
            iter->interface_number, iter->usage_page, iter->usage, iter->path);

        bool match = false;

        // We get usage/usage_page on Windows and Mac, so check those
        if (iter->usage_page == 0xFF00 && iter->usage == 0xFF00)
            match = true;
        // We only get interface numbers on libusb and Linux hidraw, so use them here
        //   (Windows also supplies them in some cases, but it's not necessary
        //    as the usage_page/usage check will catch the device in that case)
        else if (iter->interface_number == 2)
            match = true;

        if (match) {
            result = hid_open_path(iter->path);
            if (result != NULL)
                break;
            else
                wprintf(L"Device open failed!\n");
        }

        iter = iter->next;
    }

    hid_free_enumeration(devices);
    return result;
}

uint8_t checksum_payload(uint8_t payload[CMD_SIZE]) {
    uint8_t work = 0xFF;
    for (int i = 0; i < (CMD_SIZE - 1); i++)
        work -= payload[i];
    return work;
}

int send_cmd(hid_device *device, uint8_t payload[CMD_SIZE]) {
    uint8_t buf[CMD_SIZE + 1] = {0};
    buf[CMD_SIZE] = checksum_payload(payload);
    memcpy(&buf[1], payload, CMD_SIZE - 1);

    wprintf(L"--> "); hexdump(buf, CMD_SIZE + 1);

    int send_result =  hid_send_feature_report(device, buf, CMD_SIZE + 1);
    if (send_result == (CMD_SIZE + 1) && payload[0] & 0x80) {
        // also read a result
        memset(buf, 0, sizeof buf);
        int get_result = hid_get_feature_report(device, buf, CMD_SIZE + 1);
        wprintf(L"<-- "); hexdump(buf, CMD_SIZE + 1);
        memcpy(payload, &buf[1], CMD_SIZE);

        return get_result;
    }

    return send_result;
}

int send_cmd_bulk(hid_device *device, uint8_t payload[CMD_SIZE], uint8_t *bulk_data, int bulk_data_buffer_size) {
    uint8_t buf[BULK_CMD_SIZE + 1] = {0};

    // Prepare the initial packet
    buf[CMD_SIZE] = checksum_payload(payload);
    memcpy(&buf[1], payload, CMD_SIZE - 1);

    wprintf(L"--> "); hexdump(buf, CMD_SIZE + 1);

    int send_result = hid_send_feature_report(device, buf, CMD_SIZE + 1);
    if (send_result == (CMD_SIZE + 1)) {
        if (payload[0] & 0x80) {
            // We expect a result
            memset(buf, 0, sizeof buf);
            int get_result = hid_get_feature_report(device, buf, CMD_SIZE + 1);
            wprintf(L"<-- "); hexdump(buf, CMD_SIZE + 1);

            if (get_result == (CMD_SIZE + 1)) {
                // give back the result to the caller
                memcpy(payload, &buf[1], CMD_SIZE);

                // read the bulk data
                int bulk_size = payload[2];
                if (bulk_size > bulk_data_buffer_size)
                    return -2;

                int block_count = bulk_size / BULK_CMD_SIZE;
                if ((bulk_size % BULK_CMD_SIZE) != 0)
                    ++block_count;

                // note: unlike the Windows HID APIs, hid_read does not include
                // the report number as part of the size!
                for (int i = 0; i < block_count; i++) {
                    memset(buf, 0, sizeof buf);
                    int read_result = hid_read(device, buf, BULK_CMD_SIZE);
                    //if (read_result != (BULK_CMD_SIZE + 1))
                    //  return -3;
                    // TODO: should make sure we cap the last block's copying size
                    // to fit within bulk_data_buffer_size.
                    memcpy(&bulk_data[i * BULK_CMD_SIZE], buf, BULK_CMD_SIZE);
                }

                return bulk_size;
            } else {
                return -1;
            }
        } else {
            // We've got some data to send over
            memset(buf, 0, sizeof buf);
            int bulk_size = payload[2];
            if (bulk_size > bulk_data_buffer_size)
                return -2;

            int block_count = bulk_size / BULK_CMD_SIZE;
            if ((bulk_size % BULK_CMD_SIZE) != 0)
                ++block_count;

            for (int i = 0; i < block_count; i++) {
                memset(buf, 0, sizeof buf);
                // TODO: should make sure we cap the last block's copying size
                // to fit within bulk_data_buffer_size.
                memcpy(&buf[1], &bulk_data[i * BULK_CMD_SIZE], BULK_CMD_SIZE);
                int write_result = hid_write(device, buf, BULK_CMD_SIZE + 1);
                if (write_result != (BULK_CMD_SIZE + 1))
                    return -3;
                sleep_ms(20);
            }

            return bulk_size;
        }
    } else {
        return -1;
    }
}

int main(int argc, char **argv) {
    int r = hid_init();
    hid_device *device = open_vendor_device(0x04D9, 0xA118);

    wprintf(L"Device handle: %p\n", device);
    if (device != NULL) {
        uint8_t cmd82[8] = {0x82, 0, 0, 0, 0, 0, 0, 0};
        r = send_cmd(device, cmd82);
        if (r == -1)
            wprintf(L"Error: %ls\n", hid_error(device));

        uint8_t cmd8C[8] = {0x8C, 0, 0, 0, 0, 0, 0, 0};
        uint8_t blockCBuffer[0x80] = {0};
        send_cmd_bulk(device, cmd8C, blockCBuffer, sizeof(blockCBuffer));

        wprintf(L"Block C:\n");
        hexdump(blockCBuffer, sizeof(blockCBuffer));
    }

    hid_exit();
    return 0;
}
$ brew install hidapi
$ cc -o hid_test hid_test.cpp -lhidapi
$ ./hid_test
Device: 04d9:a118 Ver:0101 SN: -  USB Gaming Mouse
Interface: -1 / Usage: ff00:ff00 / Path: USB_04d9_a118_14200000
Opening device
Device handle: 0x7fc0f0c07de0
--> {00,82,00,00,00,00,00,00,7d}
<-- {00,82,00,00,00,00,00,00,00}
--> {00,8c,00,00,00,00,00,00,73}
<-- {00,8c,00,80,00,00,00,00,00}
Block C:
{8d,00,01,00,33,aa,fe,0b,00,00,00,00,00,00,00,00
,08,04,02,01,03,06,0c,07,ff,00,00,00,ff,00,00,00
,ff,ff,00,ff,00,ff,ff,ff,00,ff,a0,a0,a0,ff,ff,ff
,2e,0a,42,00,00,00,00,00,00,00,00,00,00,00,00,00
,0f,04,0a,0a,19,19,04,01,04,03,64,64,01,c0,f0,03
,01,01,64,00,0a,14,28,50,78,a4,38,38,0a,14,28,50
,78,a4,38,38,0f,ff,ff,ff,ff,00,00,00,00,ff,00,ff
,00,80,00,ff,00,00,00,00,00,00,00,00,00,00,00,00}

If you want to run this on Linux, you'll need to install hidapi using your favourite package manager and link hidapi-libusb or hidapi-hidraw instead. If you don't see any devices, try running it with superuser privileges. If you want to run this on Windows, you'll need to compile hidapi and link with the .lib file.

But hey, look at that! We've got a reply to our 0x82 command (the result is 0, rather boringly), and we've got 0x80 bytes in reply to our 0x8C command! That sure looks like some configuration data...

Can we go further and poke at this a bit to make it dump out samples of Blocks D and F?

int main(int argc, char **argv) {
    int r = hid_init();
    hid_device *device = open_vendor_device(0x04D9, 0xA118);

    wprintf(L"Device handle: %p\n", device);
    if (device != NULL) {
        uint8_t blockBuffer[0x80] = {0};

        uint8_t cmd8D[8] = {0x8D, 0, 0, 0, 0, 0, 0, 0};
        send_cmd_bulk(device, cmd8D, blockBuffer, sizeof(blockBuffer));

        wprintf(L"Block D:\n");
        hexdump(blockBuffer, sizeof(blockBuffer));

        uint8_t cmd8F[8] = {0x8F, 0, 0, 0, 0, 0, 0, 0};
        send_cmd_bulk(device, cmd8F, blockBuffer, sizeof(blockBuffer));

        wprintf(L"Block F:\n");
        hexdump(blockBuffer, sizeof(blockBuffer));
    }

    hid_exit();
    return 0;
}
$ ./hid_test
./hid_test
Device: 04d9:a118 Ver:0101 SN: -  USB Gaming Mouse
Interface: -1 / Usage: ff00:ff00 / Path: USB_04d9_a118_14200000
Device handle: 0x7fae80805210
--> {00,8d,00,00,00,00,00,00,72}
<-- {00,8d,00,80,00,00,00,00,00}
Block D:
{01,00,f0,00,01,00,f1,00,01,00,f2,00,0a,f0,1e,02
,07,00,01,00,01,00,f4,00,01,00,f3,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,01,00,f7,00,01,00,f8,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,00,00,00,00,00,00,00,00,00,00,00,00,00,00}
--> {00,8f,00,00,00,00,00,00,70}
<-- {00,8f,00,80,00,00,00,00,00}
Block F:
{00,01,05,04,85,04,05,26,85,26,05,22,85,22,05,06
,85,06,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,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,00,00,00,00,00,00,00,00}

Yep - there's more data. In fact, check out these two lines of code from the save function we looked at earlier:

  deviceData.blockD.field_38 = 0xF70001;
  deviceData.blockD.field_3C = 0xF80001;

These always get executed, no matter what. Note how you can find those values (in little-endian form) in our dump of block D, on the fourth line. We've successfully managed to read configuration data from the mouse, and send it some commands, using cross-platform code. Our first step towards freeing it from the shackles of the awful configuration tool is done.

Figuring out the Fields

So, like... the configuration tool's code is incredibly unpleasant to read, for the most part. It's all MFC, which doesn't make for particularly readable disassembly/decompilation. The UI isn't built out of standard controls, it's just PNG files drawn onto a window. They figure out where you clicked by comparing coordinates against arbitrary RECT instances. There's a lot of indirection.

We could painstakingly go through the UI code to try and make sense of everything. We could go through the firmware disassembly and try to figure out what everything does. (I tried this, but it didn't get me far: I discovered a few commands that I couldn't quite get to work, and a few fields that I didn't understand. All I ultimately got out of it at this point was an unused command to toggle the side lights, and an unused command to make the RGB LED blink X times in Y colour with Z delay.)

There's always another tactic, though: We can change settings in the tool (while the mouse is hooked up to a Windows box) and then use our own code to dump the configuration blocks and see what's changed.

This trick gained me some insights pretty quickly. Block D contains all the button mappings. Blocks F contain the macros. Command 3 sets the mouse's report rate (which is read using command 0x83, just as we expected). Block C appears to contain all other configuration for the mouse.

(cue dramatic music)

I noticed that I kept on hitting a weird (and quite alarming) bug. Consider what happens when you save the mouse's configuration: the tool sends a zeroed command 2, sets the report rate, reads the configuration, writes an updated configuration, writes the button mappings, and then writes 12 macros.

Sometimes, when I hit Save, the mouse's movement stopped working. Whenever this occurred, it seemed to be because the config tool had somehow written the button mappings block (D) over the config block (C) -- I was able to get it working again by using my own code to write a "known good" block C to the mouse.

I was fiddling with the button mappings to see which button was mapped to what entry in block D when for some reason, this happened again and I stopped being able to send any commands to the mouse. I unplugged it and plugged it back in. No response. All of my commands had stopped working.

At this point, the mouse was essentially semi-bricked. I could still click on things, but I couldn't move the cursor. I could use the wheel, but for some reason, it would remain 'stuck' - if I scrolled up, it would keep on sending scroll-up events until I scrolled down, at which point it would start continuously sending scroll-down events.

I couldn't change any settings, since the mouse was straight up ignoring vendor-specific commands. I couldn't use the firmware updater to reflash it (as I'd already done once, the first time movement stopped working) as this required getting the mouse into bootloader mode using command 0xA.

This seemed like the end for my little project. I didn't want to give up though; I'd come so far... but then again, it was just a £23 mouse. Not even £23. My Amazon order history informs me I paid a mere £22.95 for it; less than half the price of my bargain mechanical keyboard.

How do I use my desktop now, anyway?

Not having a spare mouse, I was now restricted to using Remote Desktop from my trusty MacBook Pro if I wanted to do anything on my desktop.

    

I saw this SilverCrest Gaming Mouse in the "random but strangely tempting tat" section at Lidl and was slightly tempted to buy it. I didn't. Though I just got sidetracked while writing this and did some research. Its model number is SGM-4000-A1.

The config tool it comes with bears zero relation to my mouse, and references the VID/PID combos 0C45:6656, 0C45:6758 and 0C45:5104. It follows a somewhat similar design in that the UI is built out of mountains of PNG files, but with added bonuses: there's XML, and they left in an IntelliJ IDEA workspace for the skin project. Great job. Nailed it. 👌

I ended up buying a £1 optical mouse from Poundland to tide me over. You're scraping the bottom of the barrel at that point, but it's a mouse, it has buttons, it has a wheel, it has a USB plug, and it lets you move your cursor. I remember the days when you had to pay £10 for a generic PS/2 Genius mouse!

What next?

In Part 5 I'll be trying to make sense of the firmware disassembly through a couple of different methods, and then trying to reflash the mouse. I'll also be writing an IDA processor module for the mouse's microcontroller, because to be entirely honest, VSCode doesn't make for the best environment to reverse 32kb of firmware in.

Out soon on this very website!

If you've really enjoyed this series of posts and want to help fund my hot chocolate habit, feel free to send me a couple of pounds: Ko-fi | Monzo.me (UK) | PayPal.me