VST3 format adapter for Pulp — SingleComponentEffect wiring, bus arrangement negotiation, parameter / MIDI event routing, state round-trip, and the pitfalls discovered while wiring the adapter against Steinberg's SDK.
Scanned 6/11/2026
Install via CLI
openskills install danielraffel/pulp---
name: vst3
description: VST3 format adapter for Pulp — SingleComponentEffect wiring, bus arrangement negotiation, parameter / MIDI event routing, state round-trip, and the pitfalls discovered while wiring the adapter against Steinberg's SDK.
---
# VST3 Skill
Use this skill when touching Pulp's VST3 adapter, when answering
questions about how a Pulp plugin behaves inside Cubase / Nuendo /
Studio One / Reaper (VST3 lane) / FL Studio / Ableton Live (VST3
lane), or when a `pluginval` run surfaces something odd. VST3 is one
of Pulp's three first-class plugin formats alongside CLAP and AU v3.
## When to use
- Editing `core/format/src/vst3_adapter.cpp` or its header.
- Changing the generator macro `PULP_VST3_PLUGIN(…)` in
`core/format/include/pulp/format/vst3_entry.hpp`.
- Changing bus-arrangement negotiation, parameter registration, MIDI
event routing, state save/restore, or editor lifecycle.
- A VST3 host reports a behaviour issue — sidechain not exposed,
mono↔stereo swap crashes the track, automation not recorded,
tempo-sync glitches, preset load misbehaves.
- A `pluginval --strictness-level 5` run regresses.
- Working on the ARA VST3 surface — but first read the `ara` skill.
## Files and entry points
| Role | Path |
|---|---|
| Core adapter (C++) | `core/format/src/vst3_adapter.cpp` |
| Adapter header / `PulpVst3Processor` | `core/format/include/pulp/format/vst3_adapter.hpp` |
| Entry-point generator macro | `core/format/include/pulp/format/vst3_entry.hpp` |
| Editor `IPlugView` implementation | `core/format/src/vst3_plug_view.cpp`, `core/format/include/pulp/format/vst3_plug_view.hpp` |
| Info.plist template (macOS bundle) | `tools/cmake/PulpInfoPlist.vst3.in` |
| VST3 SDK fetch | `external/vst3sdk` — `git clone --depth 1 --branch v3.7.12 https://github.com/steinbergmedia/vst3sdk.git` (MIT) |
| CLI validator invocation | `tools/cli/cmd_validate.cpp` (`pluginval --strictness-level 5 --timeout-ms 30000 --validate …`) |
There is no hand-written `PulpVst3.cmake` helper — the VST3 target is
wired directly in the top-level `CMakeLists.txt` / `PulpPlugin` CMake
surface.
The VST3 editor lives in a sibling file (`vst3_plug_view.cpp`) that is
**owned by the `view-bridge` skill** per `skill_path_map.json`. Edits
to the editor surface trigger the `view-bridge` skill rather than
this one.
## Core conventions
### SingleComponentEffect pattern
`PulpVst3Processor` extends
`Steinberg::Vst::SingleComponentEffect` — Steinberg's combined
processor-plus-controller class. That means **no separate
`IEditController`** instance: the same C++ object both processes audio
and advertises parameters. This is deliberate; it simplifies
bidirectional parameter sync and matches how CLAP/AU handle the two
roles in one object.
The `PULP_VST3_PLUGIN(uid, name, category, vendor, version, url,
factory_fn)` macro (in `vst3_entry.hpp`) expands to a single
`BEGIN_FACTORY_DEF / DEF_CLASS2 / END_FACTORY` block that registers
the class under `kVstAudioEffectClass`. One factory per TU — do not
macro-expand it twice in the same plugin.
### `initialize` — the setup path
```cpp
SingleComponentEffect::initialize(context) // Steinberg base
processor_ = factory_() // Pulp Processor
processor_->set_state_store(&store_)
processor_->define_parameters(store_)
store_.set_gesture_callbacks(beginEdit, endEdit) // host-recorded gestures
addAudioInput / addAudioOutput // from desc.{input,output}_buses
addEventInput / addEventOutput // gated on desc.{accepts,produces}_midi
parameters.addParameter(…) // one per StateStore param
```
The `unitId` field on each VST3 `ParameterInfo` is populated from
Pulp's `ParamInfo::group_id` — that's how VST3 hosts render a
parameter tree / folder structure.
Context-aware behaviour: if `context` resolves to an
`IHostApplication`, the adapter logs `kVst3AraFactoryContextKey` for
ARA-aware VST3 hosts (Cubase, Studio One). Surfaces Pulp's ARA factory
through the companion-factory negotiation. See the `ara` skill for
the full story.
### Bus arrangement negotiation
`setBusArrangements(inputs, numIns, outputs, numOuts)` is **not** a
pass-through to the base class. As of PR #2934 (item 3.7) the adapter
delegates the accept/reject decision to
`Processor::is_bus_layout_supported(BusesLayout)` — the cross-adapter
virtual hook that lets a plugin enforce tighter layout contracts
(linked sidechain, surround, instrument-only output, etc.) without
overriding `setBusArrangements` directly. The default policy still
matches the descriptor's per-side bus count and only accepts mono /
stereo per channel.
Order matters: **call the hook before mutating `audioInputs` /
`audioOutputs`**. A rejected proposal returns `kResultFalse` and the
host falls back to the default arrangement, matching Steinberg's
spec — if you mutate first, the rejected reply leaves the bus state
diverged from `descriptor()`. The AU v3 / AU v2 / CLAP adapters carry
the same hook for the day they grow dynamic layout negotiation.
Why: hosts swap project channel layouts (load a stereo session over a
mono plugin slot) and expect the plugin's `descriptor()` view to
follow. Without the in-place update, the Pulp Processor's channel
counts diverge from the VST3 bus state. See commit
`b9cda370 vst3: dynamic bus arrangements — honor setBusArrangements
(#240)`.
### `setupProcessing` / `setActive` sequence
The Steinberg lifecycle is:
```
initialize → setBusArrangements → setupProcessing → setActive(true)
→ process loop → setActive(false) → terminate
```
`setupProcessing` calls `processor_->prepare(ctx)` with the host's
sample rate, max buffer size, and the descriptor's default channel
counts. `setActive(false)` calls `processor_->release()` so the
Processor can free prepare-time resources. Never move `prepare()` out
of `setupProcessing` — Steinberg guarantees `process()` is only called
after a successful `setupProcessing` + `setActive(true)` sequence.
### Parameters
Parameters flow both ways:
- **Host → plugin**: every block, `process()` walks
`data.inputParameterChanges`, preserves **every** point from each
`IParamValueQueue` in `param_events_`, and calls
`store_.set_normalized_rt(id, value)` for each point. VST3 values are
always normalised 0..1 — the event queue stores plain-domain values
after denormalising through `ParamInfo::range`, while `set_normalized_rt`
denormalises through the ParamInfo range, writes the atomic, and
pushes an SPSC event for `ListenerThread::Main` listeners. The editor
drains via `store.pump_listeners()` on its UI tick. The generic
`set_normalized()` path would dispatch a heap-allocated lambda through
the EventLoop — fatal on the audio thread. See Slice 2 in
`planning/2026-05-18-rt-safety-and-debug-dx.md`. Before
`Processor::process()`, `param_events_` is attached through
`processor_->set_param_events(¶m_events_)` so processors that opt
into `Processor::param_events()` see the same sorted event stream.
- **Plugin → host**: `param_snapshot_` is taken before `process()`;
after, any changed param emits a point via
`data.outputParameterChanges->addParameterData(id).addPoint(0, norm)`
**and** `setParamNormalized(id, norm)` keeps the SDK-side parameter
cache in sync. Without both, automation-recording hosts miss the
edit.
- **Gesture grouping**: `store_.set_gesture_callbacks(beginEdit,
endEdit)` forwards Pulp gesture begin/end to Steinberg's undo-group
primitives. UI code that edits params via `Binding` automatically
gets gestures.
`kIsBypass` is auto-set on any parameter named `"Bypass"` whose range
is `[0,1]` with `step >= 1` — Steinberg requires exactly one bypass
parameter per plugin for the host bypass control to work.
### Bypass routing — cached ParamID + render short-circuit (PR #2937)
`initialize()` caches the `ParamID` of the kIsBypass-tagged parameter
in `bypass_param_id_`; the value is exposed via
`bypass_parameter_id()` for tests and diagnostics. Inside `process()`,
when the cached parameter's current value is `>= 0.5`, the adapter
short-circuits to processBlockBypassed-style pass-through — copies the
main input to the main output (or zero-fills for instrument
descriptors), drops the sidechain, and returns **without invoking
`Processor::process`**.
Why cache the ID: hosts hit `process()` thousands of times per second
and walking the parameter table to find the bypass slot on every
block is a non-trivial cost. The cache is set once at `initialize()`
and never mutated. When a plugin has no Bypass parameter the adapter
falls back to "always render" — no synthetic atomic.
### Latency / tail change notifications (PR #2934, item 3.11)
A Processor can flag a mid-render latency or tail change from the
audio thread via `flag_latency_changed()` / `flag_tail_changed()`
(RT-safe atomic store-release). Never call host APIs from `process()`
directly — the format adapter owns the host-thread publish path.
VST3 wiring (post-process): the adapter checks
`consume_latency_changed_flag()` / `consume_tail_changed_flag()` and
calls `componentHandler->restartComponent(kLatencyChanged |
kReloadComponent)`. Steinberg documents `restartComponent` as safe
from the audio callback (the handler queues main-thread delivery), so
no extra dispatch is needed on the VST3 side. Tests in
`pulp-test-processor-layout-latency` pin the round-trip and the
two-thread hammer for data-race freedom.
### MIDI events
VST3 delivers note-on / note-off through `IEventList`:
```
Event::kNoteOnEvent → MidiEvent::note_on
Event::kNoteOffEvent → MidiEvent::note_off
Event::kDataEvent (type=kMidiSysEx) → midi_in.add_sysex(bytes, sampleOffset, 0.0)
```
Non-note short MIDI (CC, pitch bend, aftertouch) is **not** delivered
by Steinberg's event list — VST3 hosts translate those into parameter
automation using `kIsMidiCC`-tagged parameters. If you need them, model
them as Pulp parameters, not MIDI. See `docs/guides/formats.md`.
MIDI output mirrors the inverse: note_on / note_off in
`midi_out` are written back into `data.outputEvents`.
### Audio buses (incl. sidechain)
Same "bus 0 = main, bus 1 = sidechain" rule as CLAP/AU. The adapter
defensively guards against inactive sidechain buses:
```cpp
if (data.numInputs > 1 &&
data.inputs[1].numChannels > 0 &&
data.inputs[1].channelBuffers32 &&
data.inputs[1].channelBuffers32[0]) {
// publish sidechain
}
```
(See commit `c0f49a63 Workstream 01 slice 1.2: VST3 multi-bus +
sidechain routing` and the `#178` review.) Secondary **output** buses
are zero-filled every block — identical rationale to CLAP.
### Transport context
`ProcessContext` is populated from `data.processContext`:
- `is_playing` from `state & kPlaying`.
- `tempo_bpm` always read.
- `position_samples` always read.
- `time_sig_numerator/denominator` only when
`state & kTimeSigValid`.
No `processContextRequirements` flag is currently requested — if a
host needs opt-in declaration of which fields Pulp reads, we will add
`IProcessContextRequirements`. Today every supported host delivers all
required fields by default.
### State save / restore
`getState(stream)` serialises `store_.serialize()` bytes directly.
`setState(stream)` chunks up the stream via a 4 KiB buffer, feeds
`store_.deserialize`, and then `setParamNormalized`s every restored
param back through the Steinberg parameter cache so the host UI
re-reads the correct values. Format is identical to CLAP/AU — test
with a round-trip across all three adapters for parity regressions.
### Editor
`createView("editor")` returns a `PulpPlugView` (in
`vst3_plug_view.cpp`) when the build defines `PULP_VST3_GUI` and the
Processor `has_editor()`. The editor flows through
`pulp::format::ViewBridge` — see the `view-bridge` skill for the
lifecycle protocol. Editing `vst3_plug_view.cpp` triggers `view-bridge`,
not this skill.
## Gotchas
### Missing `vst3_entry.cpp` → no `GetPluginFactory` symbol → silent host reject
`PulpPluginFormats.cmake`'s `_pulp_add_vst3()` only links a user-side
factory file if `${CMAKE_CURRENT_SOURCE_DIR}/vst3_entry.cpp` exists.
The macro `PULP_VST3_PLUGIN(...)` (from `vst3_entry.hpp`) is what
expands to Steinberg's `BEGIN_FACTORY_DEF / END_FACTORY` block — and
that block is where `extern "C" GetPluginFactory()` is **defined**.
Without `vst3_entry.cpp`, the linked `.vst3` has `bundleEntry` /
`bundleExit` from `macmain.cpp` but **no `GetPluginFactory`** at all.
`pulp_add_plugin(... FORMATS VST3)` will still build the bundle
cleanly. CLAP / AU / AUv3 entry files have separate registration
paths (`PULP_AUV3_PLUGIN`, etc.) — adding only those does **not**
cover VST3. Hosts call `dlsym(bundle, "GetPluginFactory")`, get NULL,
and silently drop the plugin during scan. In Reaper that shows up as
a hash-only `MyPlugin.vst3=<hash>` line in
`reaper-vstplugins_arm64.ini` (no comma-separated UID/name after the
hash) — exactly the same surface symptom as the UID-collision case
below, so check both.
This bit us when porting ChainerSynth to VST3: AU/AUv3/CLAP all
loaded; VST3 disappeared from Reaper after rescan with no log.
**Diagnostic — verify the symbol exists before debugging anything
else:**
```bash
# All factory/bundle symbols (C-linkage, leading underscore on macOS)
nm -gU MyPlugin.vst3/Contents/MacOS/MyPlugin | grep -v __Z | \
grep -iE 'factory|bundle'
# Expect: _GetPluginFactory _bundleEntry _bundleExit
# If _GetPluginFactory is missing, you're hitting this gotcha.
```
A 30-line `dlopen` + `dlsym` probe is the fastest way to confirm a
silently-broken VST3; reuse the pattern in
`tools/scripts/probe_vst3_factory.c` (if absent, write a one-off — it
beats round-tripping through a DAW for every build).
**Fix:** add a `vst3_entry.cpp` next to the plugin sources:
```cpp
#include "my_plugin.hpp"
#include <pulp/format/vst3_entry.hpp>
static const Steinberg::FUID MyPluginUID(0x50554C50, 0x...);
PULP_VST3_PLUGIN(MyPluginUID, "MyPlugin",
Steinberg::Vst::PlugType::kInstrumentSynth,
"Vendor", "1.0.0", "https://example.com",
pulp::examples::create_my_plugin)
```
Then **reconfigure** (`cmake -S . -B build`) — CMake's
`if(EXISTS ...)` for `vst3_entry.cpp` is evaluated at configure time,
so a plain `cmake --build` will not pick the new file up.
**Long-term:** `pulp_add_plugin(... FORMATS VST3)` should either
fail-fast with a clear error at configure time when `vst3_entry.cpp`
is missing, or auto-synthesize a default factory from the existing
`PLUGIN_CODE`/`MANUFACTURER_CODE`/`CATEGORY` arguments. Either is
better than the current silent-drop default.
### Reaper de-dupes VST3s by VST3 UID (and silently rejects collisions)
Reaper's macOS VST3 scanner (`reaper-vstplugins_arm64.ini`) keys plugins
by VST3 UID. If you install a new `.vst3` whose UID matches an entry
already in the scan database — even from a `.vst3` no longer on disk —
Reaper marks the new bundle as **"Plug-ins that failed to scan"** with
no console output and no crash log. The default Reaper preference
"Allow multiple plug-ins with the same VST3 UID" is OFF, so two builds
of the same plugin under different paths (e.g. `ChainerSynth.vst3` and
`ChainerSynth-m149.vst3`) cannot coexist.
This bit us during the Skia m144 → m149 swap when we installed a
side-by-side `ChainerSynth-m149.vst3`: Reaper's scan DB still had a
stale `ChainerSynth.vst3=...` entry from a previous build (file gone,
entry orphaned), and the new VST3's UID collision triggered the silent
reject.
**Diagnostics (no log, no crash):**
1. `Reaper → Preferences → Plug-ins → VST → Re-scan… → "Plug-ins that
failed to scan"` shows the rejected path.
2. `grep -i <plugin-name> ~/Library/Application\ Support/REAPER/reaper-vstplugins_arm64.ini`
reveals both the orphaned entry (path that no longer exists) and the
under-scored failing-scan entry (no plugin metadata after `=`).
**Fix:** delete stale entries from Reaper's scan DB AND install the
plugin under exactly one path:
```bash
# Quit Reaper first
sed -i.bak '/^MyPlugin.*\.vst3=/d' \
~/Library/Application\ Support/REAPER/reaper-vstplugins_arm64.ini
# Install under one canonical name
rm -rf ~/Library/Audio/Plug-Ins/VST3/MyPlugin*.vst3
cp -R build/VST3/MyPlugin.vst3 ~/Library/Audio/Plug-Ins/VST3/MyPlugin.vst3
# Relaunch Reaper — it will re-scan fresh
```
Alternatively the user can flip "Allow multiple plug-ins with the same
VST3 UID" in Reaper Preferences, but the default-off behavior is the
one most VST3 hosts enforce, so the workflow above is more durable.
**Don't** ship two builds of the same plugin with the same VST3 UID —
if you need a side-by-side comparison build, bump the VST3 UID's
SubCategory bytes (last 4 bytes of the 16-byte UID, by convention) so
the two builds register as separate plugins.
### `DEVELOPMENT` / `RELEASE` macro must precede the SDK include
VST3 SDK fails to compile unless **exactly one** of `DEVELOPMENT` or
`RELEASE` is defined. `vst3_adapter.hpp` defines them from `NDEBUG` at
the top of the file. If you rearrange includes and pull an SDK header
in before that block, you get a confusing sea of SDK complaints. Keep
the define block first.
### `#include <public.sdk/source/vst/vstsinglecomponenteffect.h>` first
The VST3 SDK demands this header be the first SDK include — ordering
requirements bleed through its internal `#pragma` state. Honour that
even when IDE auto-formatting wants to reorder.
### Host reports `numChannels > 0` but buffer is null
A VST3 bus can be active per its channel count but have
`channelBuffers32 == nullptr` or `channelBuffers32[0] == nullptr` when
the host hasn't actually activated the bus. The main-input branch
doesn't guard against this today — only the sidechain branch does
(per #178 review). If you ever hit a null-deref on bus 0, the same
guard needs to apply there. See CLAP's #277 for the parallel fix.
### `setupProcessing` reuses the same `ProcessSetup` across re-activation
Steinberg's SDK does not guarantee a fresh `ProcessSetup` on each
`setActive(true)` — hosts commonly reuse the same setup. Don't rely on
`setupProcessing` being called again just because the host toggled
active — if you need to recompute anything per-activation, hook it
into `setActive` instead.
### `param_snapshot_` is **post-input-events, pre-process**
Same contract as CLAP — host events are applied first, then snapshot,
then process. If the adapter needs additional logic (e.g.
gesture-coalesced output events), insert it after `process()` and
before the snapshot diff, not before `set_normalized`.
### `getTailSamples` returns `kInfiniteTail` for `tail_samples < 0`
Pulp uses `tail_samples = -1` to mean "infinite" (reverb, delay with
feedback). The adapter converts that to Steinberg's
`Steinberg::Vst::kInfiniteTail` constant. Hosts interpret the literal
`0xFFFFFFFF` value — do not clamp the tail to any other uint32.
### `addParameter` must match the later `setParamNormalized` ID
Parameters are indexed by the `ParamID` cast from Pulp's
`ParamInfo::id`. If a plugin re-orders its parameter registration
between versions, stored automation data breaks. Do not reorder —
append only, and never reuse a retired ParamID. This is a VST3-wide
backward-compat requirement, not a Pulp quirk.
### Only mono + stereo are negotiable today
`setBusArrangements` rejects anything other than
`SpeakerArr::kMono` or `kStereo`. Surround / immersive layouts
require expanding the `supported` lambda — do not add surround without
verifying the descriptor, DSP, and `kSpeakerArr` constants align.
### VST3 SDK is MIT, fetched via `git clone` in setup.sh
`external/vst3sdk` is not checked in. `setup.sh` clones v3.7.12 by
default. If you bump the SDK version, also update the note in
`docs/guides/formats.md` and verify `public.sdk/source/...` ABI didn't
shift.
## Validation recipes
Build and validate a VST3 bundle:
```bash
./build/pulp build
./build/pulp validate # runs pluginval --strictness-level 5
```
Direct `pluginval` invocation (matches what `cmd_validate.cpp` uses):
```bash
pluginval --strictness-level 5 --timeout-ms 30000 \
--validate "$(pwd)/build/path/to/MyPlugin.vst3"
```
`pluginval` install paths:
```bash
brew install pluginval # macOS (Homebrew tap)
# Linux / Windows: download a release binary from
# https://github.com/Tracktion/pluginval/releases
```
`pluginval` returns a non-zero exit code on any strictness-5 failure.
Treat it as gating — VST3 bundles failing strict pluginval must not
ship. `pulp build --install` refuses to copy a failing VST3 into
`~/Library/Audio/Plug-Ins/VST3/`.
### Validator runs must disable editor creation
`pluginval` may ask the VST3 adapter for its editor during validation.
Build/test automation must run it with
`PULP_DISABLE_PLUGIN_EDITOR=1 PULP_HEADLESS=1 PULP_TEST_MODE=1`; the
adapter returns `nullptr` from `createView(kEditor)` under those guards.
Do not remove this environment just because `pluginval` also has
`--skip-gui-tests` -- hosts can still probe editor availability.
## Cross-references
- `.agents/skills/view-bridge/SKILL.md` — the editor contract;
`vst3_plug_view.cpp` edits route through that skill.
- `.agents/skills/ara/SKILL.md` — IHostApplication-based factory
negotiation (`kVst3AraFactoryContextKey`).
- `.agents/skills/mpe/SKILL.md` — MPE sidecar (VST3 hosts deliver MPE
as channel-per-note short MIDI; the adapter routes it through the
same `MpeVoiceTracker` path CLAP uses).
- `.agents/skills/clap/SKILL.md` and `.agents/skills/auv3/SKILL.md` —
cross-format parity for host-specific regressions.
- `docs/guides/formats.md` — user-facing format overview.
- `docs/guides/host-matrix.md` — per-host VST3 + ARA compatibility.
- Memory note: "Tests ship with fixes" — every VST3 process-path
behaviour change needs a Catch2 fixture (the #290 coverage lane
explicitly names `format` as under active hardening).
## Host-quirks consumption (P3a, 2026-05-30)
This adapter consumes the host-quirks ledger at init: it caches
`resolved_quirks(detect_host_info().type, version)` once (the runtime
policy — `PULP_HOST_QUIRKS` env / `set_host_quirk_policy()` API / compile
default — applies via `resolved_quirks()`), then gates DAW accommodations
on those flags instead of hardcoding them.
First wired flag: `clamp_latency_to_nonneg`. Latency reporting routes
through the pure helper `pulp::format::reported_latency_samples(raw, quirks)`
(in `host_quirks.hpp`): a negative `latency_samples()` clamps to 0 when the
quirk is enforced, and passes through raw (wrapping the unsigned host field)
when `PULP_HOST_QUIRKS=off`. See `docs/reference/host-quirks-policy.md`.
## silence_unsupported_bus_arrangements (host-quirks P3c, 2026-05-30)
`setBusArrangements` now gates its rejection of unsupported layouts on the
`silence_unsupported_bus_arrangements` quirk (cached in `quirks_` at init):
- Bus-COUNT mismatch still hard-rejects (structural, not an arrangement issue).
- An arrangement the processor can't natively support (non-mono/stereo, or
`is_bus_layout_supported()` says no): with the quirk enforced it is
**accepted** (`setArrangement` to the host request, `silence_unsupported_active_=true`);
with `PULP_HOST_QUIRKS=off` the original `kResultFalse` reject is preserved.
**Key invariant:** `setupProcessing` always `prepare()`s the processor with
*descriptor-default* channel counts (cached as `native_in_`/`native_out_`),
NOT the negotiated arrangement. So when `silence_unsupported_active_`,
`process()` hands the processor **clamped** views (`min(host, native)`) and
**zero-fills all of the host's main-bus output channels first** — the
processor never reads/writes past what `prepare()` allocated, and the host's
extra channels emit silence instead of uninitialised memory. Empirical proof:
`pulp-test-vst3-plugin-state` `[bus-arrangement]` drives a 5.1 output through
a stereo processor and asserts channels 2–5 are silent + the processor saw 2.
## synthesize_bypass_parameter (host-quirks P3b, 2026-05-30)
When the plugin declares no Bypass parameter and the quirk is enforced,
the adapter calls `pulp::format::maybe_synthesize_bypass(store, quirks)`
(in `quirk_apply.hpp`) right after `define_parameters` — injecting an
automatable boolean `"Bypass"` param with the reserved ID
`kSynthesizedBypassParamId` (0x70427970). The adapter's EXISTING bypass
detection (name == "Bypass", boolean range) then adopts it, so the
pass-through short-circuit honors it with no further wiring.
`PULP_HOST_QUIRKS=off` synthesizes nothing. Existing "no-bypass" tests
must set `kQuirkFilterOff` to keep that premise. (CLAP + AU v2 are NOT
wired — they have no bypass process path; injecting a param there would
appear-but-do-nothing, so they're a separate follow-up.)
## Bypass pass-through null-guard (self-sweep, 2026-05-30)
The `processBlockBypassed` short-circuit in `process()` MUST null-check
`output_ptrs_[ch]` before the memcpy/memset — a VST3 bus can report
`numChannels > 0` while an individual `channelBuffers32[ch]` is null (#178).
This was a latent RT-thread null-deref that synthesize_bypass_parameter (P3b)
widened, since the short-circuit is now reachable for plugins that never
declared a Bypass. Regression: `pulp-test-vst3-plugin-state`
`[vst3][bypass][regression]` runs the bypass path with a null channel-1
output pointer and asserts no crash + the live channel still passes through.
## silence_unsupported_bus_arrangements — honor processor vetoes (Codex #3235)
The silence accommodation applies ONLY to non-mono/stereo (exotic, e.g. 5.1)
arrangements — there it accepts + silences the extra channels. A mono/stereo
layout the processor vetoes via `is_bus_layout_supported()` is a real
contract (linked main/sidechain counts, stereo-only) with no extra channels
to silence, so `setBusArrangements` HONORS the veto (returns kResultFalse)
even with the quirk on — matching pre-P3c behavior. Regression:
`pulp-test-vst3-plugin-state` `veto_bus_layout` config + the
"honors a processor mono/stereo bus-layout veto" case.
No comments yet. Be the first to comment!