DEV Community

Cristian Deluxe
Cristian Deluxe

Posted on Originally published at cristiandeluxe.dev

The LEDs weren't listening: a reverse-engineering story

Originally published at https://cristiandeluxe.dev/blog/reverse-engineering-mvave-smc-pad-leds/.

I wanted the buttons on my M-VAVE SMC-PAD to show what my lighting setup was doing. I use it with QLC+, and the feedback I wanted was fairly ordinary: press AUTO and have the AUTO pad glow green; while a flash is live, have its pad go white. There is an RGB LED under every button. The official app can set the colors. I wanted to control them from my own code.

I couldn't find public documentation for the LED commands, so I started with the obvious test. I swept every note across every channel and tested control-change messages. Nothing lit up. Those tests gave me no working LED command. It didn't mean the LEDs couldn't be controlled over MIDI; I hadn't found the right kind of message yet.

The app could do it, so the next useful thing to read was the app.

MidiSuite on Android is built with Flutter. I used Blutter to turn its Dart AOT snapshot into readable assembly, then followed the color path: sendColorData → writeData → makeWritePacket. It led to Bluetooth GATT. That explained why tracing the Android app wasn't producing the USB MIDI command I was looking for, but it did give me a way into the packet construction.

I was pairing with AI on the decompiling and byte-tracing work. There was plenty of mechanical reading for it to do, and it helped turn that reading into hypotheses I could test. Those hypotheses still needed checking. Later, the hardware caught mistakes in both the framing constant and the address we were using.

There was also a desktop editor: MidiSuite for macOS, also built with Flutter. That was the more useful app for following the USB path. I exported a preset from its editor. Before trying to read more assembly, I could compare the saved file with the colors visible in the editor.

Each pad's color record had this form:

09 <id> 00 7F R G B FF
Enter fullscreen mode Exit fullscreen mode

The color itself was plain 24-bit RGB. I checked the bytes against the on-screen colors, and they matched byte-for-byte. I now had a verified representation of the color in a preset. That was useful, but it wasn't yet a command the pad would accept. A saved record doesn't tell you how the editor sends it, or where the device expects it to go.

Getting the macOS app into readable form needed some work of its own. Blutter expected ELF, while the snapshot I needed to inspect was in Mach-O. I patched Blutter's experimental Mach-O branch to build a macOS-target Dart runtime. With that in place, the decompile worked, and I could read sysex_codec.dart.

Over USB, the color command is manufacturer-specific MIDI SysEx. The logical packet contains ordinary bytes, but SysEx data must stay within 7 bits, so the codec re-encodes the payload as an 8→7-bit bitstream. Knowing the RGB values was only part of the job. I also needed to reproduce that encoding and the framing around it.

I checked the decoder against the pad's own captured status frames. The test was to round-trip real data through the codec and reproduce what the hardware had sent. That gave me something firmer than an interpretation of decompiled code: either the bytes matched or they didn't.

They exposed the first mistake. The AI's initial read had given the framing constant as 0xB2. The correct constant was 0x59. Passing toMidi([00,0x59,…]) reproduced the device's real F0 00 32 … header. I corrected the constant on that evidence. Without the captured frames, I would have been debugging writes with a codec that was already wrong.

The USB color-write packets I captured began with:

F0 00 32 09 59 …
Enter fullscreen mode Exit fullscreen mode

Before the 8→7-bit encoding, the logical color-write packet is:

00 59 22 <len24> 05 <flash-addr-le32> 03 00 00 R G B <checksum>
Enter fullscreen mode Exit fullscreen mode

The checksum is checksum = ~sum(data) & 0xFF, where data is the payload starting at 05, excluding the logical header and length field. These are different views of the command: the logical bytes and their SysEx representation. Keeping them separate mattered when comparing what I had decoded with what was actually being sent over USB.

The address needed its own check. The AI pair had also confused an offset in the file with an address on the device. I checked against the config dump read live from the pad and used the actual device flash addresses. In the configuration I tested, bank A pad 1 was at 0x418, with a 26-byte stride between pads. The preset had established how the color was represented; the live dump established where it belonged on the hardware.

At that point I had checked the transport, codec, framing, checksum and addresses. My writes still did nothing.

This was the part that held me up. The desktop app could send identical bytes through the identical CoreMIDI path and light the LED immediately. My code sent them and the pad ignored them. I had corrected real mistakes, but neither correction explained that remaining difference. Looking at the color-write packet on its own wasn't getting me any further.

The comparison needed to include what had happened before the write. I used a CoreMIDI output spy to capture the app's traffic from the very first packet, including the connection sequence. Until then, I had the command I wanted to reproduce without the complete conversation that made it work.

The app began with discovery, then read the entire configuration back through a run of requests with incrementing addresses. The color write happened after that exchange. I replayed the whole connect handshake and injected my write into the session. The pad changed color the instant the write landed.

In my tests, writes were gated by the session. A one-shot send was ignored. After discovery and the full config read, the pad accepted the color command. I had been comparing identical payloads on the same transport while leaving out the state established by the earlier packets.

The fix I verified was replaying the complete session. I wouldn't shorten that to "send a connect packet first," because the working sequence included the full configuration read. That distinction matters to anyone trying to repeat it: the capture contains the exchange that worked, not just a color command extracted from the middle of it.

Once the session was replayed, I could put a rainbow across all 16 pads. That was the visible result I had been missing through all the byte comparisons. I also got it working over Bluetooth GATT, so the LED control worked wirelessly too. The working Bluetooth replay sends logical packets through service AE40, with AE41 for writes and AE42 for notifications, without the USB 7-bit transform.

Looking back, I got useful evidence whenever I made a different part of the system readable. The Android decompile showed the GATT path. The exported preset let me check RGB values against the GUI. The macOS decompile exposed the USB codec, and the output spy finally showed the session that a single captured write couldn't explain. None of those views was enough by itself.

The same applies to the AI work. It helped with the mechanical reads and proposed explanations, but I had to test those explanations against the device. The wrong constant failed the status-frame round-trip. The file-offset assumption failed the comparison with the live config addresses. Both were concrete errors I could correct because I had an independent reference.

If I hit the identical-bytes problem again, I'll capture from the beginning of the connection sooner. Here, the deciding test was replaying what the editor did before it changed a color. I kept the working code and captured session in spectalive/smc-pad, including the sequence I used to get the pad listening.

Top comments (0)