Last active
September 14, 2025 12:15
-
-
Save userx14/664f5e74cc7ced8c29d4a0434ab7be98 to your computer and use it in GitHub Desktop.
Circuit tracks firmware reverse engineering
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import numpy as np | |
| from pathlib import Path | |
| import crcmod | |
| import sys | |
| def extract_sysex_messages(syx_path): | |
| commands_list = dict([ | |
| (0x71, "UPDATE_INIT"), | |
| (0x72, "UPDATE_WRITE"), | |
| (0x73, "UPDATE_FINISH"), | |
| (0x76, "UPDATE_FOOTER"), | |
| (0x7c, "UPDATE_HEADER"), | |
| ]) | |
| def parse_nibble(data): | |
| result = 0 | |
| for byte_value in data: | |
| result = result << 4 | |
| result |= byte_value | |
| return result | |
| with open(syx_path, 'rb') as syx_file: | |
| data = syx_file.read() | |
| finalFirmwareFile = np.empty([0],dtype=np.uint8) | |
| i = 0 | |
| while i < len(data): | |
| if data[i] == 0xF0: #SysEx start byte | |
| end_index = data.find(0xF7, i) #SysEx end byte | |
| if end_index == -1: | |
| raise ValueError("Missing SysEx end byte") | |
| novation_header = bytes([0x00, 0x20, 0x29, 0x00]) | |
| if data[i+1:i+5] != novation_header: | |
| raise ValueError("File is missing novation header") | |
| command = commands_list[data[i+5]] | |
| cropped_data = data[i+6:end_index] | |
| if command == "UPDATE_INIT": | |
| version = parse_nibble(cropped_data[2:8]) | |
| if cropped_data[1] == 0x64: | |
| print("target: circuit tracks") | |
| elif cropped_data[1] == 0x63: | |
| print("target: circuit rhythm") | |
| else: | |
| print(hex(cropped_data)) | |
| print(0x1d) | |
| print(f"init version: {version}") | |
| elif command == "UPDATE_HEADER": | |
| version = parse_nibble(cropped_data[1:7]) | |
| print(f"header version: {version}") | |
| filesize = parse_nibble(cropped_data[7:15]) | |
| print(f"header filesize: {hex(filesize)}") | |
| checksum = parse_nibble(cropped_data[15:23]) | |
| print(f"header checksum: {hex(checksum)}") | |
| elif command in ["UPDATE_WRITE", "UPDATE_FINISH"]: | |
| #need to tightly pack 7bit MIDI bytes into 8bit firmware file | |
| packed = np.frombuffer(cropped_data, dtype=np.uint8) | |
| unpacked = np.unpackbits(packed[:, np.newaxis], axis=1) | |
| unpacked = unpacked[:, 1:].reshape(-1) #discard first bit of each byte and make continuous array | |
| repacked = np.packbits(unpacked[:-3]) #last three bits are just padding | |
| if command == "UPDATE_FINISH": | |
| finalFirmwareFile = np.concatenate([repacked, finalFirmwareFile]) | |
| break | |
| else: | |
| finalFirmwareFile = np.concatenate([finalFirmwareFile, repacked]) | |
| i = end_index + 1 | |
| else: | |
| i += 1 | |
| finalFirmwareFile = bytes(finalFirmwareFile[:filesize]) | |
| crc32_non_reflected = crcmod.mkCrcFun(0x104C11DB7, rev=False, initCrc=0xFFFFFFFF, xorOut=0x00000000) | |
| calculated_checksum = crc32_non_reflected(finalFirmwareFile) | |
| if calculated_checksum != checksum: | |
| raise ValueError(f"File checksum {calculated_checksum} does not match header checksum {checksum}") | |
| with open(syx_path.with_suffix(".bin"), 'wb') as f: | |
| f.write(finalFirmwareFile) | |
| if len(sys.argv)!=2: | |
| print("need to give a path to a .syx file") | |
| extract_sysex_messages(Path(sys.argv[1])) |
Author
Author
Together with pr0ximaMusic, I started a github repo with a ghidra project and collected some other resources like the debug header pinout for stlink connection. If you like to be added as collaborator to this one, please let me know.
Sure add me
I did a dump of the flash memory and your offset of 0x08010000 is correct. Before that there is a bootloader, that is not included in the firmware update file. The function locations make sense with this.
I will try to look into pitch shifting again, honestly I have very little experience with all the DSP stuff so most of the heavy lifting was done by using this method https://wilgibbs.com/blog/defcon-finals-mcp/
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
I did a dump of the flash memory and your offset of
0x08010000is correct. Before that there is a bootloader, that is not included in the firmware update file. The function locations make sense with this.Is it possible that you found the place that handles the different musical scales that the synth supports, instead of some sample playback selection? You identified that the semitone offset are stored at
0x0805a4c4and beyond. If I check there I see 15 similar entries followed by an entry with just zeros, which would match nicely the 16 scales that circuit tracks supports and the zeros would be major scale.Is the order of notes maybe flipped (B A# A...) instead of (C C# D ...) for the pitch table analysis? Just from the shift in the function I would have guessed that e.g. note C corresponds to the two lowest value bits.
Also I think you are correct that Sample_LoadToDSP does not modify the pitch. My guess is that they just change the playback speed on the DSP, after upload. Unfortunately, I did not find where they do this in the firmware.
Anyway I'm impressed with how fast you did the analysis of these functions, the assembly code that does the modulo 12 part took me quiet a while to understand.
Together with pr0ximaMusic, I started a github repo with a ghidra project and collected some other resources like the debug header pinout for stlink connection. If you like to be added as collaborator to this one, please let me know.