enharmonic
/ vcv patches
rig prototyping →

reference · file formats, disassembly & generated patches

a patch is just
a data structure

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.

314
modules mapped
4,904
params
4,967
ports
10
plugins
01

opening the file

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}]}

The half that is missing

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.

02

where the names live

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.

Two traps worth naming

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.

03

the closed box

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.

Let the host tell you

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.

Then read the binary

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.

Tie them together

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.

Accept partial

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

coverage

165 full — names, ports, ranges 107 counts — params and port counts 42 params only
04

why the counts matter

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.

So: validate before writing

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.

05

what came out

Two patches, both written by script and validated before saving, both opened in Rack and played without a single dropped cable.

ZZC Clock quarters ─────────► Befaco Kickall 4/4 kick eighths ─────────► SEQ 3 (5 steps) walks against it sixteenths ──────► S&H timbre jumps SEQ 3 ──► pitch ──► VCO 1 ◄── FM ── VCO 2 ◄── pitch ── SEQ 3 └──► VCF ──► VCA ──► Delay ──► Spring Reverb ──► out

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.

06

the part that needed a person

The drone came back changed. Two oscillators swapped for additive ones, three filters added, and several decisions better than the ones they replaced.

The sound bath patch open in VCV Rack: two dBiz Verbo additive oscillators on the left with their harmonic sliders, a bank of LFOs and filters below, Befaco Iroi in the centre, and the audio output on the right.
The drone after the rework. The two panels on the left are the additive oscillators, and their vertical sliders are individual harmonics — the first has harmonics one and three raised, the second only the first. Iroi is the black panel in the middle; one of the cables reaching it carries the 256-second square wave that re-rolls its resonator and echo. The scope below shows the mix before it reaches the effects.
the changewhat it does
An LFO set to 1024 HzNot 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 onlyA hollow, clarinet-like tone, with a 29-second sine sweeping which harmonic is emphasised.
Randomisation slowed to 256sThe 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.

Where the line actually falls

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.