Architecture

COM in Zig, how a plugin is registered, and the audio callback path.

COM in Zig

A C++ object with virtual functions is a pointer to a vtable followed by the object's fields. A COM interface is that, plus the convention that the first three vtable slots are queryInterface, addRef, and release.

src/vst3.zig writes this out as plain Zig:

pub const IUnknown = extern struct {
    queryInterface: *const fn (?*IUnknown, *const IID, ?*[*]?*anyopaque) callconv(.c) TResult = undefined,
    addRef: *const fn (?*IUnknown) callconv(.c) u32 = undefined,
    release: *const fn (?*IUnknown) callconv(.c) u32 = undefined,
};

Three things make this work.

extern struct guarantees C layout: fields in declaration order, C alignment rules, no reordering. This is the whole reason the trick is safe.

callconv(.c) gives each function pointer the platform C calling convention, so arguments land in the registers the host expects.

Interface inheritance becomes struct embedding. IComponent starts with an IPluginBase field, which starts with an IUnknown field. Because extern struct puts fields at ascending offsets with the first at offset zero, a *IComponent is bit-identical to a *IPluginBase and to a *IUnknown. That is exactly what single inheritance produces in C++.

pub const IComponent = extern struct {
    pluginBase: IPluginBase,      // offset 0, itself starting with IUnknown
    getControllerClassId: *const fn (?*IComponent, ?*CUID) callconv(.c) TResult = undefined,
    setIoMode: ...
};

The rest of vst3.zig is the data the ABI passes around: ProcessData, AudioBusBuffers, ProcessSetup, ParameterInfo, BusInfo, plus the TResult constants and bus and media type enums. All extern struct, all laid out to match the SDK headers.

How a plugin is registered

A VST3 binary exports one symbol. That is the entire registration mechanism.

export fn GetPluginFactory() ?*anyopaque {
    gFactory.vtbl = @ptrCast(&factoryVtable);
    return @ptrCast(&gFactory);
}

gFactory is a static whose first field is a pointer to a static vtable. The host receives the address of gFactory, reads the first word to get &factoryVtable, and calls through it. Nothing is allocated. Nothing is registered anywhere else. There is no plugin database, no manifest, and no macro.

From there the host does:

  1. countClasses() to learn how many classes the binary exports.
  2. getClassInfo(i, &info) for each, reading the class ID, category, and name.
  3. createInstance(class_id, iid, &out) to get an object implementing the requested interface.

examples/danzig-test performs exactly steps 1 through 3 against the built plugin, going through the C function pointers rather than through Zig types, so a layout change that would break a real host breaks the test first.

The audio callback path

The host owns the buffers. It hands you a ProcessData describing them and expects you to be finished by the time the callback returns.

host audio thread
  |
  +-- IAudioProcessor.setupProcessing(&setup)   once, before playback
  |      sample rate, max block size, 32- or 64-bit samples
  |
  +-- IAudioProcessor.setProcessing(true)       transport starts
  |
  +-- IAudioProcessor.process(&data)            every block, on the audio thread
  |      data.numSamples
  |      data.inputs[bus].channelBuffers32[ch]
  |      data.outputs[bus].channelBuffers32[ch]
  |
  +-- IAudioProcessor.setProcessing(false)      transport stops

Inside process the rules are the usual real-time rules. No allocation, no locks, no file or network access, no logging that touches a mutex. danzig's contribution is that the parameter path obeys them by construction: the host's UI thread writes a normalized f32 with an atomic store, and the audio thread reads it with an atomic load. There is nothing between the two that can block.

A minimal per-sample loop looks like this, from examples/danzig-minimal/root.zig:

pub fn process(
    self: *MinimalPlugin,
    input: []const []const f32,
    output: []const []f32,
    frames: usize,
) void {
    for (0..frames) |i| {
        const gain = danzig.dBToLinear(self.params.tick(ParamIndex.trim));
        for (input, output) |in_ch, out_ch| {
            out_ch[i] = in_ch[i] * gain;
        }
    }
}

tick advances the smoother by one sample and returns the plain value, so a parameter change becomes a ramp rather than a step. That is what stops a slider drag from producing clicks.