reference · file formats, disassembly & generated patches
VCV Rack saves your rack as compressed JSON. Once you can read it you can write it — and a synthesizer patch becomes something you can generate, diff and validate. This is how far that goes: 314 modules mapped across ten plugins, two of them reverse engineered out of a closed binary, and the patches that came out the other side.
A .vcv looks like a binary blob. It is three ordinary layers stacked: Zstandard compression, a tar archive, and JSON.
# .vcv = zstd( tar( patch.json + modules/ ) )
import zlib, subprocess, tarfile
subprocess.run(["zstd","-d","-o","patch.tar","rack.vcv"])
tarfile.open("patch.tar").extractall() # -> patch.json
Inside, a rack is two lists — what is on the rails, and what is plugged into what:
{"modules":[{"plugin":"Fundamental","model":"VCF",
"params":[{"id":0,"value":0.55}], "pos":[42,0]}],
"cables" :[{"outputModuleId":…,"outputId":0,
"inputModuleId" :…,"inputId" :3}]}
Every param and every port is a bare integer. Nothing in the file says that output 0 is the sine and input 3 is the audio in. The file records positions in a list whose meaning lives only in the plugin's source code. Reading a patch is easy. Writing one that means something is the hard part.
Every Rack module declares its controls twice: an enum whose order defines the indices, and a set of config calls that give each one a label and a range. Both are in the source, and most plugins ship a link to it.
enum ParamIds { FREQ_PARAM, FINE_PARAM, RES_PARAM, … };
configParam(FREQ_PARAM, -54.f, 54.f, 0.f, "Frequency", " Hz");
// ^ index 0 ^ min ^ max ^ default ^ the label
Each plugin's plugin.json carries a sourceUrl, so the whole thing generalises: read the manifest, fetch the repo, parse the enums, keep only the modules the manifest actually declares. Ten plugins went through that unchanged.
Plugins mix two enum conventions — ParamIds/NUM_PARAMS and ParamId/PARAMS_LEN — sometimes within one codebase. And Rack's ENUMS(NAME, n) macro declares a whole bank in one line. Miss that and a sequencer with 39 params reads as 7, quietly putting every index past the fourth knob in the wrong place.
One plugin in the rack had 149 modules and no source at all. Its repository holds screenshots and a changelog. That is a third of the library, and it needed a different approach — two of them.
Rack writes out every param of every module when it saves. Drop all 149 modules into an empty rack, save once, and read the file back: 3,320 params with their defaults, for free.
Every Rack module must call config(params, inputs, outputs, lights) in its constructor. Those four numbers are immediates in the compiled code — 153 call sites, all recoverable.
Class names are not slugs. DueMani is 4Hands; OctoAD is 8AttackDecay. The createModel<Struct, Widget> template instantiations give the class list; the param counts from the save confirm each pairing.
Name similarity plus a verified param count resolved 78. Constraint propagation — remove what is claimed, re-check what is left — reached 107. The last 42 collide, and stay marked unknown rather than guessed.
; every module's constructor, in the compiled plugin
mov w1, #0x5 ; params
mov w2, #0x7 ; inputs
mov w3, #0x15 ; outputs
mov w4, #0x3 ; lights
bl _ZN4rack6engine6Module6configEiiii
Knowing how many ports a module has sounds like a convenience. It is closer to a seatbelt.
// Rack, Engine::addCable_NoLock
Input& input = cable->inputModule->inputs[cable->inputId];
Output& output = cable->outputModule->outputs[cable->outputId];
// … later …
input.channels = 1; // a write, through an unchecked index
There is no bounds check. A cable pointing at a port that does not exist is not a caught error or a dropped connection — it reads past the end of a vector and then writes to it. An early plan here was to discover port counts by cabling every index and seeing which survived a save. That plan would have corrupted memory a few thousand times.
Every generated patch is checked before it reaches disk — each param id inside the module's real param count, each value inside its declared min and max, each cable endpoint inside the real port count. A patch that cannot be verified does not get written. The reward for the disassembly is not convenience, it is being able to make that promise for two thirds of the library.
Two patches, both written by script and validated before saving, both opened in Rack and played without a single dropped cable.
Five eighth-notes against four quarters, sharing one clock, so the line lands on the downbeat every five bars. The pitch sequence is deliberately off the semitone grid — a semitone is 1/12 V and none of the five values are a multiple of it, so these are notes a keyboard cannot play.
The drone came back changed. Two oscillators swapped for additive ones, three filters added, and several decisions better than the ones they replaced.
| the change | what it does |
|---|---|
| An LFO set to 1024 Hz | Not an LFO any more — an audio-rate FM source. The module was used for what it emits rather than what it is called. |
| An oscillator at −54 semitones | ≈11.6 Hz, below hearing. The reverse move: an oscillator used as a modulator. |
| Harmonics 1 and 3 only | A hollow, clarinet-like tone, with a 29-second sine sweeping which harmonic is emphasised. |
| Randomisation slowed to 256s | The generative element went from once a minute to once every 4.3 minutes. Better judgement about how a sound bath should breathe. |
Reading it back, one thing was wrong — and it was the kind of thing reading catches and listening does not. Two cables ran into a single mono input, so the right channel was silent and which of the two you heard depended on pointer ordering inside the engine. A stereo effect was doing stereo work that never reached the speakers.
Structure is checkable: port counts, value ranges, duplicated inputs, dangling cables. None of that requires ears. Whether a patch is good is not checkable at all, and no amount of validation gets you there. The file format is what lets both halves work on the same object — one side writes and verifies, the other side listens and rewrites, and the diff between them is legible to both.
enharmonic · lab
Written while building the tools it describes. The 42 unresolved modules are still unresolved, and the two patches are on the workbench rather than on a record.