Appendix C — VST3 Preset Anatomy
The byte-level reference behind Chapter 3’s load_nam — for when you
need to set plugin state that isn’t exposed as a parameter, and the
save-a-preset-once workflow isn’t enough because you want to swap files
programmatically.
The .vstpreset container
What plugin.preset_data hands you is a VST3 preset: a container with a
header, one or more state chunks, and a trailing index.
| offset | size | field |
|---|---|---|
| 0 | 40 | header (magic VST3, version, the plugin’s class ID) |
| 40 | 8 | offset of the chunk index (i64) — everything between 48 and this offset is chunk data |
| 48 | … | chunk data — for a simple plugin, one Comp (component state) chunk |
| index offset | … | the index: ASCII List, a chunk count (i32), then one 20-byte entry per chunk |
Each 20-byte index entry: a 4-byte chunk ID (Comp = the plugin’s own
serialized state; Cont = controller/UI state), then offset (i64) and
size (i64).
The rule that bites: if you change the length of a chunk, you must rebuild the index — every offset and size must match reality, or the plugin silently ignores the entire preset. No error. Default sound. When byte surgery “doesn’t work,” check the bookkeeping before the splice.
Inside the component chunk
The layout belongs to whatever framework the plugin was built with. The two you’ll actually meet:
iPlug2 plugins (Neural Amp Modeler and family)
A marker string, then length-prefixed fields, then parameter values:
###PluginName### ASCII marker
[i32 length][version string]
[i32 length][file path 1] <- NAM: the .nam model path
[i32 length][file path 2] <- NAM: the impulse-response path
[parameter doubles...]
“Length-prefixed” = a 4-byte count followed by exactly that many bytes of
text. To swap a file: find the marker, skip fields to the one you want,
splice in your path with a corrected length prefix, rebuild the index.
Chapter 3’s load_nam is the complete worked implementation.
JUCE plugins (a large fraction of everything else)
JUCE’s convention serializes state as an XML document. Search the
component chunk for <?xml — if found, the state is readable text, often
self-describing (<PARAM id="filePath" value="/old/path"/>), and you can
edit it as a string, re-encode, and rebuild the index. Considerably
friendlier than offsets.
Cracking an unknown plugin
The universal method, no documentation required:
- Load the plugin, save
bytes(plugin.preset_data)to a file. - Change exactly one thing (via
plugin.show_editor()— usually the file picker) and save again. - Diff the two files byte by byte.
The differing region is the field you care about, and the bytes immediately around it reveal the convention — a length prefix that changed with the path length, an XML attribute, a fixed-width slot. A third save with a path of a very different length disambiguates the length-prefix question immediately.
a = open("preset_ac15.bin", "rb").read()
b = open("preset_twin.bin", "rb").read()
for i, (x, y) in enumerate(zip(a, b)):
if x != y:
print(f"first difference at byte {i}")
print(a[max(0, i-24):i+40])
print(b[max(0, i-24):i+40])
break
When not to bother
If the file choice never changes at render time, skip all of this: the
save-once workflow (Chapter 3, “the easy way”) — configure in the
editor, persist preset_data to disk, reload the bytes forever — is
robust, format-agnostic, and survives plugin updates better than offset
arithmetic. Byte surgery is for the for-loop: rendering the same
performance through six amps, which is where this studio’s whole argument
lives.