Skip to content

Instantly share code, notes, and snippets.

@userx14
Last active September 14, 2025 12:15
Show Gist options
  • Save userx14/664f5e74cc7ced8c29d4a0434ab7be98 to your computer and use it in GitHub Desktop.
Save userx14/664f5e74cc7ced8c29d4a0434ab7be98 to your computer and use it in GitHub Desktop.
Circuit tracks firmware reverse engineering
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]))
@Ondrysak
Copy link

Ondrysak commented Sep 4, 2025

Good finds :)

Which binary firmware file version are you analyzing? When I look at the firmware file for most recent "1.2.1" / 4486 for circuit tracks,

Using the same version

the addresses 0x08051B8C where you find the max sample size check,

honestly I dont know why, but seems like when loaded in ida base address is actually 0x08010000 not 0x08000000

Also I'm really unsure about the conclusion here, for the behavior of the pitch shifting of the drum tracks. From hardware testing with a single sample, when adjusting the pitch with midi cc command or with the knob, the samples are not chromatically played and do not fall onto semitones. So either this behavior is non used code or something funky is going on when it is played with the DSP.

Let me check again :)

@userx14
Copy link
Author

userx14 commented Sep 6, 2025

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.

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 0x0805a4c4 and 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.

@userx14
Copy link
Author

userx14 commented Sep 6, 2025

@Ondrysak
Copy link

Ondrysak commented Sep 6, 2025

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