Skip to content
Back to skills

Clap

ASecurity

CLAP format adapter for Pulp — how Processor bridges to clap_plugin_t, how parameters / modulation / sidechain / MPE / UMP / sysex flow, and the pitfalls discovered while wiring the adapter.

  • 22 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added June 11, 2026
toolsgoc++bashreactnodeexpressawsdebugginggitapi

Works with

  • cursor
  • cli
  • api

Security analysis

A100/100

Scanned September 24, 2026

npx -y skills add danielraffel/pulp --skill clap --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Clap?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Clap
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/danielraffel-clap/badge)](https://www.skillsdirectory.com/skills/danielraffel-clap)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: clap
description: CLAP format adapter for Pulp — how Processor bridges to clap_plugin_t, how parameters / modulation / sidechain / MPE / UMP / sysex flow, and the pitfalls discovered while wiring the adapter.
---

# CLAP Skill

Use this skill when touching Pulp's CLAP adapter, when answering
questions about how a Pulp plugin appears to a CLAP host, or when a
CLAP validator run surfaces something odd. CLAP is Pulp's first-class,
MIT-safe plugin format — every plugin built with Pulp ships a CLAP
binary and CLAP is the fastest iteration lane because the
`clap-validator` runs without a DAW.

## When to use

- Editing `core/format/src/clap_adapter.cpp` or the generated entry header
  `core/format/include/pulp/format/clap_entry.hpp` (the boilerplate-
  generator macro `PULP_CLAP_PLUGIN(…)`).
- Adding or changing a CLAP extension (`audio-ports`, `note-ports`,
  `params`, `state`, `gui`, `preset-load`, ARA companion factory, …).
- A CLAP host reports a behaviour issue — sidechain missing, MIDI
  events dropped, presets not loading, GUI refusing to attach.
- A `clap-validator` pass regresses.
- Working on the MPE or UMP sidecar as it flows through the CLAP path
  — the CLAP adapter is currently the canonical consumer of
  `set_mpe_input` / `set_ump_input`.
- Cross-referencing CLAP behaviour against VST3 / AU when debugging
  host-specific regressions — use the three adapters as each other's
  "oracle" during parity fixes.

## Files and entry points

| Role | Path |
|---|---|
| Core adapter (C++) | `core/format/src/clap_adapter.cpp` |
| Adapter header / `PulpClapPlugin` | `core/format/include/pulp/format/clap_adapter.hpp` |
| Entry-point generator macro | `core/format/include/pulp/format/clap_entry.hpp` |
| CLAP module (FetchContent) | declared in `CMakeLists.txt`; CLAP headers are MIT and fetched at configure time — there is no hand-written `PulpClap.cmake` |
| WebAssembly compact variant (wclap) | `tools/cmake/PulpWclap.cmake`, `core/format/src/wasm/` |
| Remote-control page builder | `core/format/src/clap_remote_controls.cpp` |
| CLAP+ARA surface | `core/format/src/ara*`, see the `ara` skill |
| Tests | `test/test_clap_entry.cpp` (dlopen + descriptor), `test/test_clap_ara_extension.cpp` (ARA companion factory), `test/test_clap_webview.cpp` (WebView bridge) |
| CLI validator invocation | `tools/cli/cmd_validate.cpp` (`clap-validator validate …` with dlopen-only fallback) |

The `PULP_CLAP_PLUGIN(factory_fn)` macro (bottom of `clap_entry.hpp`) is the
single-plugin developer surface. It registers one plugin *record*, keeps the
legacy global factory slot (`register_plugin(factory_fn)`), and defines the
`clap_entry` exported symbol. There is no separate "factory" TU — the macro is
the factory.

### Multi-plugin bundle — one CLAP binary, many plugins

A CLAP module has exactly ONE `clap_entry` symbol but its factory may list many
plugins. `clap_entry.hpp` supports this via a per-plugin **record** array
(`g_clap_records`) instead of the old single `g_factory`/`g_desc`/`g_clap_desc`
globals:

```cpp
PULP_CLAP_BUNDLE_PLUGIN(Foo, create_foo)   // register a plugin (repeat, distinct Ident)
PULP_CLAP_BUNDLE_PLUGIN(Bar, create_bar)
PULP_CLAP_BUNDLE_ENTRY()                    // the ONE clap_entry symbol
```

Load-bearing facts, each a trap if forgotten:

- **Descriptors are built in `entry_init()`, NOT at static init.** Building a
  descriptor calls the factory → constructs a `Processor`, which is unsafe during
  C++ static initialization (global/`std::string` order). `register_clap_record`
  stores only the factory at static init; `init_all_records()` (invoked from the
  host's `clap_entry.init()`) fills each record's descriptor. A test that queries
  the factory MUST call `clap_entry.init(...)` first — the old
  `init_descriptor()` free function is gone.
- **`get_plugin_count()` returns the count of uniquely-addressable plugins**
  (the deduplicated index `init_all_records()` builds — a record whose
  `bundle_id` collides with an earlier one, or whose factory returned null, is
  excluded so the host is never handed an advertised-but-unreachable
  descriptor). **`create_plugin(id)` resolves BY ID** (via `find_clap_record`),
  not by a single hardcoded descriptor. Every plugin needs a unique `bundle_id`.
- **Extension callbacks read per-instance metadata, never a global.** Before
  `clap_init()` constructs `processor`, `audio_ports` / `note_ports` read
  `PulpClapPlugin::descriptor_snapshot` (cached from the record at
  `create_plugin()`); after, they prefer the live `processor->descriptor()`. When
  adding a new extension that needs bus/MIDI metadata, follow this pattern — do
  NOT reintroduce a file-scope descriptor global.
- Each record is also published to the shared keyed registry (`registry.hpp`,
  see the `auv2` skill) at init for cross-format enumeration / editor assets.
- Single-plugin `PULP_CLAP_PLUGIN` is unchanged behaviourally; a bundle is opt-in.

Tests: `test/test_clap_bundle_entry.cpp` (two plugins, one entry) and the
migrated `test/test_clap_entry.cpp` (single-plugin contract + per-instance
`descriptor_snapshot` fallback).

## Core conventions

### The shape of a CLAP instance

`PulpClapPlugin` (in `clap_adapter.hpp`) is the shared per-instance
struct. It owns:

- `std::unique_ptr<Processor> processor` — the user's DSP.
- `state::StateStore store` — parameter state, wired to the processor
  via `set_state_store(&store)` during `clap_init`.
- Pre-allocated `input_ptrs` / `output_ptrs` / `sidechain_ptrs` arrays
  sized to `kMaxChannels = 8`. **Process must not allocate** — these
  pointer fan-outs are static across calls.
- `param_snapshot` for detecting plugin-side parameter edits during
  `process()`. After `processor->process()`, the adapter compares each
  param to its snapshot and emits `CLAP_EVENT_PARAM_VALUE` out-events
  so the host can record automation.
  - **RT-safety invariant:** every scratch vector `process()` sizes to
    `all_params().size()` (via `resize`/`assign`) runs on the audio thread,
    so each MUST be `reserve()`d to that size in `clap_activate()` — otherwise
    the first block grows-and-allocates. `param_snapshot` **and**
    `output_param_has_event` (the sample-accurate-output skip-set) are both
    reserved there. Adding a new per-param scratch means adding its reserve;
    the `ClapSlot::process is allocation-free` rt-safety test catches a miss
    (it measures the first block, where the missing reserve shows up).
- `param_events` (`state::ParameterEventQueue`) for the current block's
  inbound `CLAP_EVENT_PARAM_VALUE` events. It preserves every host
  automation point with `header.time` before the StateStore dual-write
  lands the last value for ordinary parameter reads.
- `mpe_tracker` + `mpe_buffer` + `mpe_enabled` — MPE sidecar populated
  only if `PluginDescriptor::effective_capabilities().supports_mpe`
  is true. The effective value ORs the legacy descriptor flag with the
  node ABI capability field. `clap_activate()` reserves and capacity-limits
  the sidecar too; one MIDI event can fan out to many MPE callbacks.
- `ump_buffer` + `ump_enabled` — UMP sidecar. Cleared at the top of
  every block, then filled from BOTH sources every block: native
  `CLAP_EVENT_MIDI2` packets append directly during the event loop,
  and after decode `midi1_to_ump(midi_in, ump_buffer)` always runs
  (synthesises UMP from the MIDI 1.0 stream). Both paths run
  unconditionally because real hosts mix transports — notes via
  `CLAP_EVENT_NOTE_*` and CCs via `CLAP_EVENT_MIDI2` is common, and
  skipping the synthesis when MIDI2 is present silently drops the
  note half from the UMP buffer. See Gotchas. `clap_activate()` reserves
  and capacity-limits this sidecar.
- `native_f64_enabled` plus the `data64` scratch/pointer arrays — CLAP hosts
  may provide double-precision audio buffers. If
  `PluginDescriptor::effective_capabilities().supports_f64_audio` is false,
  the adapter converts host `data64` to the normal f32 `process(...)` path and
  converts f32 outputs back to `data64`. If the plugin opts in, every active
  routed bus for the block must be f64 before the adapter calls
  `Processor::process_f64(ProcessBuffers64&, ...)`; mixed f32/f64 blocks stay
  on the compatibility path. Every audio port advertises
  `CLAP_AUDIO_PORT_SUPPORTS_64BITS` (in `audio_ports_get`), and native-f64
  descriptors additionally advertise `CLAP_AUDIO_PORT_PREFERS_64BITS` — a
  spec-compliant host only sends `data64` to ports carrying the SUPPORTS
  flag, so dropping it silently kills the whole f64 path. The boundary
  f64→f32 demotion and output-scratch pre-zero are deferred until after the
  native-f64 decision inside `clap_process` (native blocks read the host's
  double buffers directly and skip the conversion); keep any new bus wiring
  consistent with that ordering.
- `ara_controller` — lazily created on the first host query for the
  ARA companion-factory extension.
- `bridge` + `editor_host` + `editor_visible` — gated on
  `PULP_CLAP_GUI`. Editor lifecycle flows through `ViewBridge`; see the
  `view-bridge` skill for the open/attach/close protocol.

`PulpClapPlugin` is consumed from two translation units: the per-plugin
`clap_entry.cpp` and the shared `clap_adapter.cpp` in `pulp-format`.
They must see the same GUI define. Desktop plugin builds compile both
with `PULP_CLAP_GUI=1`; WCLAP/non-GUI builds compile both with
`PULP_CLAP_GUI=0`. Do not add GUI-only fields under a preprocessor
condition unless the shared adapter target receives the same condition.
A layout mismatch presents as random CLAP lifecycle corruption, often a
REAPER crash in `gui_create()` or `clap_activate()` touching a bogus
`bridge`/`editor_host` pointer.

### Parameters

Parameter semantics come from `ParamInfo::kind`, not heuristics over step/range.
`value_labels` are the single display and parse table; toggle/enum host text does
not accept arbitrary numeric fallback. Route conversion through
`parameter_text.hpp`, which also contains author exceptions.

Parameters are defined by the Processor during `define_parameters(store)`
and enumerated to the host by the `params` extension in
`clap_entry.hpp`:

- `params_count` → `store.param_count()`.
- `params_get_info` → builds a `clap_param_info_t` from the stored
  `ParamInfo`. A valid `StateStore` group supplies its full root-to-leaf path
  in `module`, using `/` as CLAP's hierarchy separator; invalid, unknown, and
  ungrouped parameters leave `module` empty. The fixed `CLAP_PATH_SIZE` buffer
  is safely truncated and NUL-terminated. `CLAP_PARAM_IS_AUTOMATABLE` is set by
  `state::is_automatable_param` — for an ordinary parameter that is always,
  because the attribute defaults to automatable.
  `CLAP_PARAM_IS_HIDDEN` and `CLAP_PARAM_IS_READONLY` come from
  `state::is_hidden_param` / `state::is_read_only_param`.
  `CLAP_PARAM_IS_STEPPED` is set for a discrete parameter — `ParamKind` other
  than `Continuous` (`state::is_discrete_param`) — or a bypass. It is NOT
  derived from `range.step`/range width; a continuous parameter may quantize
  its plain value without becoming an indexed control.
  `CLAP_PARAM_IS_MODULATABLE` is set by `state::is_modulatable_param`.
- `params_get_value` returns the current **base** value (without
  modulation).
- `params_value_to_text` uses `ParamInfo::to_string` when provided,
  otherwise falls back to `"%.2f %s"` with the unit.

**Modulation is advertised, not just decoded.** A compliant host (Bitwig) only
sends `CLAP_EVENT_PARAM_MOD` to a parameter whose info carries
`CLAP_PARAM_IS_MODULATABLE`, so the whole PARAM_MOD lane —
`clap_param_modulation_lane` → `ModulationEventQueue` /
`store.set_mod_offset` → `Processor::get_modulated()` — is dead in a real host
without the flag. `state::is_modulatable_param` decides it:
`ParamInfo::modulatable` (author opt-out, defaults **true**), then three rules
an author cannot override — not a bypass, not a discrete parameter, not an
auto-resetting trigger. `params_flags` and `clap_param_modulation_lane` both
call that one predicate, so the adapter can never invite modulation it then
drops.

**Hidden / read-only / non-automatable are a SHARED-MODEL concern, not a CLAP
one.** When CLAP could not advertise `CLAP_PARAM_IS_HIDDEN` or
`CLAP_PARAM_IS_READONLY`, the tempting read was "a CLAP adapter gap". It was
not: VST3 and AU could not express them either, because `ParamInfo` had no
field to express. The same hole appeared in three adapters at once because all
three project one model. The tell was that Pulp's own CLAP *host*
(`plugin_slot_clap.cpp`) had been reading those two flags off hosted plugins
for a long time — Pulp consumed a concept it could not produce. **Before adding
a per-format flag, check whether the neighbouring formats are equally blind; if
they are, the fix belongs in `core/state`, not here.**

`ParamInfo::hidden` / `read_only` / `automatable` now carry it, read through
`is_hidden_param` / `is_read_only_param` / `is_automatable_param` — the same
one-predicate-per-attribute shape as `is_modulatable_param`, for the same
reason. Two consequences that are easy to get wrong:

- **Read-only implies not automatable and not modulatable.** Automation and
  modulation are both host writes, so advertising `READONLY | AUTOMATABLE`
  would be self-contradictory. `is_automatable_param` and
  `is_modulatable_param` both refuse a read-only parameter; do not re-derive
  either from the raw field.
- **A bypass control can never be hidden or frozen**, whatever the author
  declares — a host surfaces bypass in its own chrome, and hiding it makes the
  plugin un-bypassable. `is_hidden_param` / `is_read_only_param` enforce that.

During `clap_process`, the adapter routes host events into the store:

```
CLAP_EVENT_PARAM_VALUE   → store.set_value_rt(id, value)   ← RT-safe
CLAP_EVENT_PARAM_MOD     → store.set_mod_offset(id, amount)
CLAP_EVENT_PARAM_GESTURE_BEGIN / _END → store.begin_gesture / end_gesture
```

**Use `set_value_rt`, not `set_value`, on the audio thread.** The generic
`set_value()` path dispatches `ListenerThread::Main` listeners through
the installed `EventLoop`, and that dispatch lambda allocates on the
firing thread — fatal for the audio thread. `set_value_rt()` writes the
atomic + pushes an event on a non-allocating SPSC queue; the editor's
UI tick drains via `store.pump_listeners()`. Audio listeners still fire
inline (caller asserts RT-safety), so audio-thread listeners must be
trivial, non-allocating, and bounded.

Do not collapse inbound CLAP parameter automation to a single last point.
`clap_process` appends every `CLAP_EVENT_PARAM_VALUE` to
`PulpClapPlugin::param_events`, sorts by sample offset, and still calls
`store.set_value_rt(...)` for the same events so legacy block-level reads
observe the final value.
Before calling `Processor::process()`, the adapter attaches that queue via
`processor->set_param_events(&param_events)`, so sample-accurate processors
read the same sorted events through `Processor::param_events()`.

Editor-originated automation takes a separate bounded path. `clap_init()`
installs the store's gesture callbacks plus an inline value listener; they push
`BEGIN`, `VALUE`, and `END` records, in call order, into the per-instance
`outbound_param_events` SPSC queue and call the host params extension's
`request_flush()`. Both `clap_process()` and `params.flush()` drain that queue,
so a drag remains visible to the host when transport is either running or
stopped. CLAP guarantees those two consumers do not run concurrently. Keep
these invariants when changing the path:

- The sole producer is the CLAP init/main thread. Audio/background store writes
  stay on the existing explicit-event or snapshot-diff path; letting them push
  here turns the SPSC queue into an unsafe multi-producer queue.
- Host-originated writes are guarded while `params.flush()` applies them, so
  they are never echoed back as plugin automation.
- Every emitted editor record uses `header.time = 0` and drains before the
  snapshot fallback and sample-offset merge, preserving global ascending time.
- If `out_events->try_push()` rejects a record, retain that exact head record
  and retry it before later records. Dropping a rejected `BEGIN` or `END` can
  leave host automation permanently unbalanced.
- The queue is allocated/bounded before processing and must remain
  allocation-free at both drains. A queued value marks the snapshot skip-set
  only after the host accepts it, preventing duplicate `PARAM_VALUE` output.

The **modulation offset is per-buffer**: `store.reset_all_mod()` runs
at the top of every `process()` before applying new `PARAM_MOD` events.
DSP reads modulated values via `store.get_modulated(id)` = base +
current mod offset. Plugins that only read `store.get_value(id)` do
**not** see host modulation.

### Audio buses (incl. sidechain)

When `supported_bus_layouts` is non-empty, expose
`CLAP_EXT_AUDIO_PORTS_CONFIG`. `audio_ports_get` and `clap_activate` must both
use the selected widths so `PrepareContext` matches host metadata.

`audio_ports` enumeration in `clap_entry.hpp` is descriptor-driven:
`desc.input_buses` / `desc.output_buses`. **Bus 0 is always the main
bus** (flag `CLAP_AUDIO_PORT_IS_MAIN`); **bus 1 (when present) is the
sidechain** and is routed via `Processor::set_sidechain(&view)` before
`process()`. Additional input buses beyond index 1 are ignored — the
Processor API exposes a single sidechain slot.

**Secondary (aux) output buses ARE routed** to the richer
`Processor::process(ProcessBuffers&)` surface (role `Aux`, index ≥1) for
multi-out instruments (drum machines, multitimbral, stem renderers).
`clap_process` builds one `ProcessBusBufferView<float>` per host output
bus from pre-allocated `aux_output_ptrs[kMaxOutputBuses-1][kMaxChannels]`
storage (row `b-1` backs host bus `b`; the main bus uses `output_ptrs`),
so the routing path never allocates on the audio thread. Each aux bus is
**pre-zeroed before `process()`**, so a single-output processor (whose
default `process(ProcessBuffers&)` writes only the main bus) leaves aux
buses silent — no uninitialised memory, no behavioural change. A
multi-out processor overrides `process(ProcessBuffers&)` and writes each
aux bus. The aux view's `declared_channels` is the descriptor's declared
count (cached in `clap_activate` — never call `descriptor()` on the audio
thread; it allocates), while `buffer.num_channels()` carries the actual
routed count, so `matches_declared_layout()` can detect a host-vs-declared
mismatch. Host output buses beyond `kMaxOutputBuses` are zero-filled but
not routed.

### MIDI: short messages, sysex, note-expression, UMP

Inbound event decode in `clap_process()`:

```
CLAP_EVENT_NOTE_ON / _NOTE_OFF → MidiEvent::note_on / note_off
CLAP_EVENT_MIDI                → MidiEvent::from_bytes(data[0..2])
                                 — CC, pitch bend, channel AT,
                                   poly AT, program change
CLAP_EVENT_MIDI_SYSEX          → midi_in.add_sysex_copy(bytes, time, 0.0)
                                 backed by a preallocated payload pool
CLAP_EVENT_NOTE_EXPRESSION     → synthesised MIDI 1.0 (see table)
CLAP_EVENT_NOTE_CHOKE          → note_off(channel, key, velocity=0)
CLAP_EVENT_MIDI2               → self->ump_buffer.add(packet)
                                 (guarded by CLAP_VERSION_GE(1,1,0) —
                                  the event is an enumerator, NOT a
                                  preprocessor macro; see Gotchas)
```

**Note-expression → MIDI 1.0 mapping.** `MpeVoiceTracker` only ingests
MIDI 1.0, so per-note expressions are synthesised to channel-wide
equivalents and narrowed back per-voice by the tracker:

| CLAP expression id | Synthesised MIDI 1.0 |
|---|---|
| `PRESSURE` | channel aftertouch `0xDn` |
| `TUNING` | 14-bit pitch bend (normalised to ±48st member range) |
| `BRIGHTNESS` | CC 74 |
| `VOLUME` | CC 7 (0..4 → 0..127 log-domain scale) |
| `PAN` | CC 10 |
| `VIBRATO`, `EXPRESSION` | dropped — no unambiguous MIDI 1.0 equivalent; UMP-aware plug-ins should consume via the `CLAP_EVENT_MIDI2` path |

Non-MPE descriptors drop note-expression events with a one-time
debug log. See the `mpe` skill for tracker details.

**Outbound MIDI**: the processor's `midi_out` emits short messages as
`CLAP_EVENT_MIDI` and sysex entries as
`CLAP_EVENT_MIDI_SYSEX`, both via `out_events->try_push`.
`sample_offset` carries through to `header.time` via the shared cross-format
helper `detail::clap_output_offset(sample_offset)` (clamps a negative offset up
to 0, since `header.time` is unsigned) — the same "offset N in → offset N out"
contract AU v2 and VST3 share, defined once in
`core/format/include/pulp/format/detail/midi_out_offset.hpp` and pinned by
`test/test_midi_out_offset_parity.cpp`. Do NOT re-open-code the offset clamp.
The sysex
`clap_event_midi_sysex_t.buffer` field is non-owning — the backing
vector is alive for the duration of `clap_process()`, which is all
CLAP's push contract requires (the host copies before returning).

`midi_in`, `midi_out`, `mpe_buffer`, and `ump_buffer` are per-instance
buffers on `PulpClapPlugin`, not fresh locals inside `clap_process()`.
`clap_activate()` reserves their storage and enables realtime capacity
limits, then `clap_process()` clears and reuses them every block. Past
the reserved capacity, appends must drop and increment the relevant drop
counters; they must not grow vectors under the process no-allocation
guard.

Inbound CLAP SysEx is the exception to the move-based adapter pattern:
the host gives the adapter a non-owning payload pointer, so accepting it
requires copying bytes into owned storage. `clap_activate()` reserves a
bounded payload pool on `midi_in`; `MidiBuffer::add_sysex_copy()` copies
into that pool on the realtime-limited process path and drops only when
the sidecar count or per-payload capacity is exceeded. Keep the happy
path and overflow case covered in `test_clap_midi_events.cpp`.

If you add a new inbound/outbound MIDI path, cover the overflow case in
`test_clap_midi_events.cpp`. If you add a test processor that
captures/forwards sysex while behind the CLAP no-alloc guard, preallocate
destination SysEx payload storage in `prepare()` and call
`MidiBuffer::add_sysex_copy()` for explicit captures. Whole-event
forwarding with `midi_out.add_sysex(std::move(sx))` is supported because
pool-backed input events copy into the destination's prepared payload
pool; moving only `sx.data` out of `midi_in` is intentionally not
supported. Copying a vector payload inside `process()` will trip the RT
allocation trap under ASan/TSan/debug test builds.

### Note-port dialects and voice-info

`note_ports_get` advertises what the adapter can **decode**. Every plugin gets
`CLAP_NOTE_DIALECT_CLAP | CLAP_NOTE_DIALECT_MIDI`; the input port additionally
gets `CLAP_NOTE_DIALECT_MIDI_MPE` when `effective_capabilities().supports_mpe`
and `CLAP_NOTE_DIALECT_MIDI2` when `supports_ump`. Read the capabilities
through `effective_capabilities()`, never the bare legacy fields — the node
capability sidecar carries the same claim and is OR'd there.

The added dialects are input-only on purpose: `push_outbound_short` emits
`CLAP_EVENT_MIDI` exclusively, so claiming them on the output port would
promise packets Pulp never sends.

`CLAP_EXT_VOICE_INFO` is published only when `PluginDescriptor::voice_count`
is non-zero, and reports that count as both `voice_count` and
`voice_capacity`. A host told nothing assumes mono and collapses per-voice
modulation to a global mapping, so a polyphonic instrument that leaves the
descriptor field at its 0 default silently loses per-voice modulation in
Bitwig. `CLAP_VOICE_INFO_SUPPORTS_OVERLAPPING_NOTES` is deliberately NOT set:
that bit promises the plugin separates overlapping notes by `note_id`, and the
adapter lowers CLAP note events to MIDI 1.0 keyed on channel + key, discarding
`note_id` (`ev.note_id = -1` on the outbound side). Set it only if that
lowering changes.

### State save / restore

Serialisation goes through the single `StateStore::serialize()` /
`deserialize(bytes)` path (in `clap_entry.hpp` `state_ext`). Format is
the Pulp binary blob — identical bytes across CLAP / VST3 / AU, so
round-trip parity is trivial to test.

### Transport context

`clap_process()` maps `process->transport` into `ProcessContext` when
the host supplies it:

- `is_playing` / `is_recording` from the CLAP transport flags.
- `tempo_bpm` only when `CLAP_TRANSPORT_HAS_TEMPO` is set.
- `position_beats` from `song_pos_beats / CLAP_BEATTIME_FACTOR` when
  `CLAP_TRANSPORT_HAS_BEATS_TIMELINE` is set.
- `position_samples` from `song_pos_seconds / CLAP_SECTIME_FACTOR *
  sample_rate` when `CLAP_TRANSPORT_HAS_SECONDS_TIMELINE` is set.
  Leave it at 0 when the host omits the seconds timeline; deriving an
  absolute sample position from beats + current tempo is not authoritative
  in tempo-mapped projects and can create false transport jumps.
- time signature, loop range, and bar from the matching CLAP fields.

Keep `position_samples` non-zero when the host provides a seconds timeline;
native-core processors forward it as `playhead_frames`, so leaving it at the
default 0 makes CLAP-only playhead-sensitive processors think every block
starts at the song origin.

### Editor

Gated on `PULP_CLAP_GUI`; for desktop CLAP, both the shared
`pulp-format` adapter TU and the per-plugin entry TU must compile with
the same value. Lifecycle flows through
`pulp::format::ViewBridge`, constructed from
`ViewBridge::Options::hosted_editor()` (never a hand-assembled `Options` — see
the `view-bridge` skill for why, and for the test that enforces it):
`gui_create` → `bridge->open()`, the host
then calls `gui_set_parent(window)` → `editor_host->attach_to_parent` +
`bridge->notify_attached()`, `gui_destroy` → `bridge->close()`. See the
`view-bridge` skill for the full contract — the CLAP adapter is the
reference implementation for the "open, then notify_attached after
host has attached" protocol.

Window API negotiation is compile-time platform-switched to Cocoa /
Win32 / X11.

### Proportional resize with aspect lock

`gui_can_resize` returns `true`. `gui_get_resize_hints` advertises
`preserve_aspect_ratio=true` with `aspect_ratio_{width,height}` set to
the editor's preferred design size, so DAWs (Bitwig, Reaper, Live, …)
lock the corner-drag to the design aspect. `gui_adjust_size` snaps the
requested rectangle to the design aspect (largest box at the design
aspect that fits within the request), then clamps to plugin min/max
constraints.

`gui_create` calls `host->set_design_viewport(design_w, design_h)` so
the host scales content to fit the resized window via a paint-time
canvas transform — the JS/Yoga tree still thinks it's at design size,
and the existing `gui_set_size` → `host->set_size(...)` path resizes
the surfaces without re-laying out. This is the proportional+locked
behavior the standalone host already had (same design-viewport contract as WindowHost); AU v2
cannot offer it because the DAW resizes the returned NSView directly
with no host-side `gui_can_resize` analogue. Cross-format design lives
in the `view-bridge` skill.

`gui_create` calls
`pulp::format::decide_gpu_host(*bridge)` so a Skia/Dawn/scripted editor
auto-selects the GPU `PluginViewHost`, wires the per-vsync editor idle pump
(`make_editor_idle_pump`), and screams via `warn_if_unexpected_cpu_fallback`
on a silent CPU fallback. CLAP's `gui_set_size` already resizes the bridge +
host, so no extra resize seam is needed (unlike AU v2). Full contract: the
`view-bridge` skill's "GPU view host auto-selection" section.

### ARA companion factory

`clap_get_extension(kClapAraFactoryExtension)` lazily creates the
plugin's `AraDocumentController` on first query, then returns the
companion factory pointer. Only instantiates when the Processor
overrode `create_ara_document_controller()` — plugins that don't
participate in ARA return `nullptr` naturally. See the `ara` skill.

### Bypass routing — auto-detected

CLAP doesn't model "bypass" as a first-class extension the way VST3
(`kIsBypass`) or AU v3 (`AUAudioUnitBypass`) do — hosts treat a
plugin-declared `"Bypass"` parameter as the on/off lane. The adapter
auto-detects that parameter at `clap_init` and short-circuits
`clap_process` to pass-through (in→out for effects, zero-fill for
instruments) when the cached parameter's current value is `>= 0.5`,
without invoking `Processor::process`. MIDI output stays empty so
bypassed MIDI FX don't leak notes — same contract the VST3 and AU v3
adapters honour.

**Param designation (declared bypass) + trigger params.** The bypass
parameter is found through the shared `pulp::state::is_bypass_param`
contract, not a re-implemented name/range check. A Processor author can
declare `ParamInfo::designation = ParamDesignation::Bypass` to mark a
param as the bypass control *independently of its name* — the legacy
boolean-`"Bypass"` name/range heuristic remains the fallback when no
designation is declared, so existing plugins are unchanged. The adapter
also calls `StateStore::reset_triggers_rt()` to auto-reset trigger /
momentary params (`is_trigger`, or a `ParamDesignation::Reset`
"reset/panic" control) back to their default. The call sits AFTER the
bypass if/else (not inside the non-bypass branch), so it is a
**single-exit invariant**: a trigger raised while bypassed still settles
this block instead of firing late. It runs before the `out_events` scan,
so the host records the settle. Input param events are applied to the
store before the bypass check, so a host-raised trigger is observed-then-
settled within the same block whether or not the plugin is bypassed.

### Latency / tail change notifications

A Processor flags a mid-render latency or tail change via
`flag_latency_changed()` / `flag_tail_changed()` (RT-safe atomic
store-release). Don't call `clap_host_latency->changed()` from
`process()` directly — the spec requires that on the main thread.

CLAP wiring (the most involved of the four adapters):

1. `create_plugin()` captures the `clap_host_t*` pointer for later
   `request_callback()` use.
2. `process()` peeks via `latency_change_pending()` /
   `tail_change_pending()` (non-mutating — does NOT drain the edge)
   and, if either is set, calls `host->request_callback()` to ask
   the host for a main-thread callback.
3. `clap_on_main_thread()` then drains via
   `consume_latency_changed_flag()` / `consume_tail_changed_flag()`.
   Tail publishes straight away as `clap_host_tail->changed()`.
   **Latency does not** — see below.

The peek-vs-consume split exists specifically for CLAP — VST3 / AU
v3 / AU v2 drain in-line because their host APIs are safe from the
audio callback path. Don't collapse the two helpers into one if you
add another adapter that needs the same edge.

#### Latency needs a RESTART, not just `changed()`

`clap/ext/latency.h` carries two requirements, and only reading the
first line of the comment gets you one of them:

```c
// Tell the host that the latency changed.
// The latency is only allowed to change during plugin->activate.
// If the plugin is activated, call host->request_restart()
// [main-thread & being-activated]
void(CLAP_ABI *changed)(const clap_host_t *host);
```

So `changed()` is **not** a general-purpose "my latency moved" push.
It is annotated `[main-thread & being-activated]` — legal only from
inside `clap_activate()` — and the reported latency may only move in
that same window. An **active** plugin that merely calls `changed()`
is telling the host a number the host is not sanctioned to act on: a
strict host ignores it and stays on the stale value forever, a lenient
one re-compensates mid-session against a latency the plugin is not yet
allowed to have. Both misalign plugin-delay compensation silently.

The adapter therefore branches on activation state:

| State | What happens |
|---|---|
| **Active** | `clap_host->request_restart()`, then publish `changed()` from inside the `clap_activate()` that results |
| **Inactive** | Publish `changed()` immediately — nothing is rendering, no compensation is in force, and the next activate re-reads `get()` regardless |

Three invariants that are easy to break:

- **`is_active` is adapter state, not processor state.** `PulpClapPlugin::is_active`
  is set at the end of `clap_activate()` and cleared first thing in
  `clap_deactivate()`. There is no processor-side equivalent.
- **`latency_restart_pending` coalesces AND defers, and must survive
  `clap_deactivate()`** — the deactivate it lives across is the one it
  asked for. A host may take arbitrarily long to honour a restart or
  decline it entirely, so one outstanding request has to cover any
  number of further edges; re-asking per edge is a request storm.
- **Publish after `prepare()`, at the END of `clap_activate()`.** The
  host answers `changed()` by calling `clap_plugin_latency->get()`;
  publish before the processor is prepared and it reads the stale value
  back, which is the original bug wearing a restart.

`clap_host->request_restart` is `[thread-safe]` per `clap/host.h`, so a
direct audio-thread call would be legal — but the edge is routed through
the existing `request_callback` → `clap_on_main_thread()` hand-off anyway,
because `changed()` is *not* thread-safe and keeping both halves on one
path keeps the ordering obvious.

**The other three formats have no equivalent gap.** VST3 already calls
`restartComponent(kLatencyChanged)`, which is that API's deactivate/
reactivate request. AU v2 (`PropertyChanged(kAudioUnitProperty_Latency)`)
and AU v3 (KVO on `latency`) expose latency as an observable property
with no restart precondition, so an in-line notification is complete
there. CLAP is the only one of the four whose spec names a restart.

### Preset loading

`clap_plugin_preset_load` is exposed only when the Processor builds a
`PresetManager` during `clap_init` (driven by
`desc.manufacturer`/`desc.name`). Today only
`CLAP_PRESET_DISCOVERY_LOCATION_FILE` is honoured; bundle- and plugin-
internal preset sources are ignored and return false.

### Remote controls and dynamic extension routing

`clap_entry.hpp::get_extension(plugin, id)` handles static extensions first,
then delegates to `clap_adapter::clap_get_extension(plugin, id)` for
instance-owned extensions such as `preset-load`, ARA, and
`clap.remote-controls/2`. Any new adapter-owned extension must be reachable
through the real host callback path, not only through direct unit-test calls
to the adapter helper.

Remote-control page generation lives in `clap_remote_controls.cpp`. It is a
main-thread, metadata-only extension: build pages from `StateStore` parameter
and group metadata, do not touch DSP state, and do not add audio-thread work
to `clap_process()`. The current pages expose ungrouped params as `Main`,
then grouped params in registered group order, eight params per page, with
unused slots set to `CLAP_INVALID_ID`. Keep the stable
`CLAP_EXT_REMOTE_CONTROLS` id and the compatibility id routed together unless
the CLAP headers remove the older spelling.

## Gotchas

### Decoding an event is not the same as advertising that you accept it

The adapter's decode paths are written before, and independently of, the
extension metadata that tells a host to use them — so a lane can be complete,
tested, and still dead in every real host. Three of these shipped at once and
none was visible from the decode side: `CLAP_EVENT_PARAM_MOD` was fully
decoded while `params_flags` never set `CLAP_PARAM_IS_MODULATABLE`;
`CLAP_EVENT_MIDI2` was decoded and routed to the UMP sidecar while
`note_ports_get` advertised a hardcoded `CLAP | MIDI`; and a polyphonic
instrument published no `voice-info` at all. In each case a unit test that
drove the adapter helper directly passed, because a test supplies the event
itself and a host does not.

When you add or change a decode path, ask what makes a host send it, and pin
BOTH halves in one test: read the advertisement through the extension the host
reads, then send only the event that advertisement licenses. The reachability
tests in `test_clap_midi_events.cpp` and `test_clap_param_flags.cpp` are
written in that order on purpose.

### Sidechain `data32` can be null — guard before routing

A host may report `audio_inputs_count > 1` but hand the adapter a null
`data32` pointer (bus deactivated). A loose translation of "bus exists
→ publish sidechain" hands the Processor a `BufferView` over garbage.
The guard in `clap_process` demotes the whole sidechain bus to "not
supplied" if any per-channel pointer is null — do not remove it.

```cpp
if (sc_bus.data32) {
    sc_channels = std::min(static_cast<int>(sc_bus.channel_count), kMaxChannels);
    for (int ch = 0; ch < sc_channels; ++ch) {
        self->sidechain_ptrs[ch] = sc_bus.data32[ch];
        if (!self->sidechain_ptrs[ch]) { sc_channels = 0; break; }
    }
}
```

The VST3 adapter carries the same guard for null bus channel pointers.
Mirror both whenever reshaping the sidechain path.

### Aux output bus `data32` can be null too — guard the pre-zero loop

A deactivated **secondary output** bus can report `channel_count > 0`
while `data32 == nullptr`, exactly like the sidechain case. The aux
pre-zero loop in `clap_process` runs *before* the routing loop's own
`data32` guard, so it must `if (!bus.data32) continue;` before indexing
`bus.data32[ch]` — otherwise a host that presents an inactive aux output
null-derefs on the audio thread. Both the pre-zero loop and the routing
loop guard the bus pointer; keep both.

### Reset modulation offsets **every** buffer

`store.reset_all_mod()` is the first line of `clap_process()`. If you
refactor the process prologue, keep it first — otherwise stale
`PARAM_MOD` offsets from a previous block leak into the next one and
the plugin's DSP drifts away from the host's expected modulated value.
Found during CLAP modulation bring-up.

### `param_snapshot` is **per-buffer**, not cached

The snapshot is taken after host events are applied but before
`processor->process()`. The diff compared against current values at
the end is what the adapter emits as `PARAM_VALUE` out-events. If you
optimise this into a persisted snapshot you will drop plugin-side
param edits that happen at block boundaries.

### Secondary output buses must be zero-filled

Multi-out instruments that don't route to bus ≥ 1 leave those output
buffers whatever the host's last tenant wrote. The adapter zeroes
every secondary output channel every block — do not skip this even for
"only bus 0 used" plugins; some hosts reuse memory across plugin
slots.

### `constant_mask` on an output bus is inherited, not given to you clean

`clap_audio_buffer_t::constant_mask` is the *plugin's* promise about the
block it just wrote: bit N set means "every frame of channel N equals
sample 0", and a host may act on it by reading one sample instead of the
block. The host never clears it, and CLAP explicitly permits in-place
buffers — so an output bus can arrive still carrying the mask an upstream
plugin set on the input it aliases. An adapter that never writes the mask
inherits that lie, and a plugin whose output varies gets read back as one
held sample.

`clap_process` therefore clears `constant_mask` on **every** output bus
(not just the routed ones) after the Processor call and after the bypass
short-circuit. Zero means "no channel is known constant", which is always
true and always safe. Never set a bit speculatively: a set bit obliges you
to have actually filled the channel with that constant.

The symptom is loudest on CV-rate outputs — where the variation *is* the
signal, so the whole plug-in reads as a frozen DC level — and can be
invisible on audio, because hosts are free to ignore the mask entirely.
Never touch the *input* bus's mask; that one belongs to the host.

### ARA companion factory is returned **only after Processor exists**

`clap_get_extension` may be called before `clap_init` populates
`self->processor`. The current impl returns the static companion
factory pointer early; it only lazily instantiates the
`AraDocumentController` once `self->processor != nullptr`. If you
refactor this path, preserve that ordering — eagerly constructing the
controller at extension-query time triggers the
`create_ara_document_controller()` virtual before the Processor is
alive.

### UMP sidecar: native + synthesised, both always run

The adapter handles every host shape: pure MIDI 1.0 (`CLAP_EVENT_NOTE_*`
+ `CLAP_EVENT_MIDI`), pure MIDI 2.0 (`CLAP_EVENT_MIDI2`), and mixed
(notes via NOTE_*, CCs via MIDI2 — common in real DAWs).

1. At the top of every `clap_process()` block, `ump_buffer.clear()`
   runs when `ump_enabled`. This is load-bearing — keep the clear
   up-front so the buffer reflects only the current block.
2. During event decode, `CLAP_EVENT_MIDI2` packets are appended
   directly to `self->ump_buffer`. `host_delivered_ump` is retained as
   observability only; it must not gate MIDI 1.0 synthesis.
3. After the decode loop, `midi1_to_ump(midi_in, self->ump_buffer)`
   ALWAYS runs when `ump_enabled`. Skipping synthesis when the host
   delivered any MIDI2 silently drops the note half of mixed streams from
   the UMP buffer. CLAP guarantees a spec-conformant host won't redundantly
   encode the same logical event in two transports, so unconditional
   synthesis doesn't double-deliver.

The UMP buffer shape lives in `core/midi/include/pulp/midi/ump_buffer.hpp`
and the CLAP adapter's `ump_buffer` sidecar.

### MPE reset ownership spans adapter and Processor

`clap_reset()` clears the adapter-owned MPE tracker immediately and carries
`ProcessContext::reset_requested` to the next block that actually reaches
`Processor::process()`. A bypassed block or a block that loses the
state-restore gate must not prepare the MPE sidecar or consume that request:
doing either advances tracker identity while the Processor's voice allocator
did not run. On deactivation, call `Processor::release()` before resetting the
sidecar so an MPE Processor can clear its allocator while the corresponding
adapter identities are still at the same lifecycle boundary. See the `mpe`
skill for the processor-side `release()` and reset contract.

### CLAP event types are enumerators, not preprocessor macros

When gating on a new CLAP event type, **do not** write
`#ifdef CLAP_EVENT_MIDI2` — `CLAP_EVENT_MIDI2` is a C enumerator value,
and `#ifdef` on an enum always evaluates false. Use
`#if defined(CLAP_VERSION_GE) && CLAP_VERSION_GE(1, 1, 0)` (or the
release that introduced the event) instead. Same trap applies to any
future `CLAP_EVENT_*` additions — the CLAP header does not define
them as macros. Use the guard shape in `core/format/src/clap_adapter.cpp`.

### Param text parsing must be locale-independent — but not via `std::from_chars<float>`

`params_text_to_value` and `params_value_to_text` must be immune to a
comma-decimal global host locale (a DAW that called `setlocale`): a typed-in
`"0.5"` must never parse as `0.0`, and a formatted value must never emit
`"0,5"`. Use `std::to_chars` for formatting (always C-locale). For *parsing*,
the obvious choice — `std::from_chars` — is a **trap for floats**: libc++
leaves the floating-point `from_chars` overloads `=delete`d on some toolchains
(notably the github-hosted `macos-15` sanitizer image), so
`std::from_chars(first, last, a_double)` hard-fails the **Sanitizer Tests**
build with "call to deleted function 'from_chars'" while the Mac Studio
`macos` gate (which *has* the overload) stays green — the break hides on the
advisory lane. Integer `from_chars` is fine everywhere. For the float value,
parse through `pulp::format::detail::parse_double_c_locale`
(`core/format/include/pulp/format/detail/locale_independent_float.hpp`), a
C-locale `strtod` wrapper shared with the `.pulpset` parser.

### GUI layout must match across CLAP TUs

Do not use `#ifdef PULP_CLAP_GUI` for CLAP GUI fields or extension
dispatch; use `#if defined(PULP_CLAP_GUI) && PULP_CLAP_GUI`. The WCLAP
path may define `PULP_CLAP_GUI=0`, and `#ifdef` treats that as enabled.
The desktop shared adapter and the per-plugin entry must agree on
whether GUI fields exist in `PulpClapPlugin`, or later lifecycle fields
shift and hosts crash when opening or activating the editor.

### ARA CLAP lives outside `CLAP_EXT_*`

The ARA companion factory is keyed on
`kClapAraFactoryExtension` (Pulp-private identifier), not one of CLAP's
reserved `CLAP_EXT_*` strings. Don't rename it; other Pulp + ARA hosts
already search for that exact key. Defined in `pulp/format/ara.hpp`.

### `clap-validator` is optional — fallback is dlopen

`pulp validate` (`tools/cli/cmd_validate.cpp`) runs
`clap-validator validate …` when installed, otherwise falls back to a
plain `dlopen` check. CI lanes without `clap-validator` still exercise
the "plugin loads" path; full spec conformance requires the validator
binary.

### AAX-parity sweep

AAX and CLAP share the same sysex-sidecar pattern. When you change the
CLAP sysex accumulator, the AAX adapter
(`core/format/src/aax_runtime.cpp`) and the VST3 / AU halves need to
stay in sync — see the memory note on AAX-parity.

### Filter in-events by `space_id` in every dispatch loop

Every `clap_input_events` dispatch loop in the adapter MUST check
`hdr->space_id == CLAP_CORE_EVENT_SPACE_ID` at the top and `continue`
on mismatch. Non-zero namespaces belong to third-party extensions
Pulp doesn't implement, and their type IDs may alias core type IDs
(e.g. a fictional extension's event type `5` could be mistaken for
`CLAP_EVENT_PARAM_VALUE` and mutate the param store). clap-validator
`param-set-wrong-namespace` exercises this with `space_id = 0xb33f`.

Covered sites today:

- `clap_adapter.cpp` process() param/gesture loop
- `clap_adapter.cpp` process() note/MIDI loop
- `clap_entry.hpp` `params_flush()` path

If you add a third in-events dispatch (e.g. a transport-event loop,
or a new extension's callback), add the same guard. Test pattern:
`test_clap_entry.cpp` → "CLAP params_flush ignores events outside
the core namespace [issue-743]" (the bracketed token is the Catch2 test tag).

### `clap_ostream::write` may short-write — loop state_save

`state_save` (in `clap_entry.hpp`) MUST loop on `stream->write()`
until the full payload is delivered. Per CLAP spec, a single `write`
call may return fewer bytes than requested even on success; only
negative or zero returns are errors. clap-validator's
`state-reproducibility-flush` exercises this by capping every write
at 23 bytes.

Symmetric note: `state_load`'s `stream->read` loop was already
correct; the bug was only on the write side.

### `on_non_realtime_tick()` fires in **native** CLAP, not just WebCLAP

`Processor::on_non_realtime_tick()` / `non_realtime_tick_pending()` exist so a
processor with **no worker thread of its own** can get expensive control-driven
work (decode, resample, FFT-plan, allocate) off the audio thread. The motivating
host is the browser — a WAM lives entirely inside an AudioWorklet and a WebCLAP
module has no `std::thread` — but the CLAP adapter calls the hook
**unconditionally**, so a *native* CLAP plugin gets it too:

- `clap_on_main_thread()` calls `on_non_realtime_tick()` every time.
- `state_load()` calls it after `deserialize()` — a restored state can name a
  different derived source than the live one, and a worker-less processor has no
  thread to notice; without this the audio thread renders the OLD derived state
  for the rest of the session.
- `clap_process()` **peeks** `non_realtime_tick_pending()` each block and calls
  `host->request_callback()` when set — the same mechanism already used for
  latency/tail changes. This is the only way out of the audio thread in CLAP,
  because CLAP delivers parameter changes as events *inside* `process()`.

Two things follow that are easy to get wrong:

1. **Do not write `on_non_realtime_tick()` assuming "this only runs in a
   browser."** It runs in Bitwig and Reaper. A processor that already has a real
   worker thread should do **nothing** here, or the worker and the host race.
2. **`non_realtime_tick_pending()` must be realtime-safe** — it is called from
   `process()` every block. Read atomics and compare. No locks, no allocation.

VST3, AU, and the standalone host do **not** call the hook at all. A processor
that depends on it for correctness must *also* have a worker (or do the work in
`prepare()`), or it will never reconcile in those formats. Both methods are
appended at the **end** of the `Processor` vtable to keep vtable ordering
additive-only (`node_abi_gate`) — keep it that way when adding more.

## Validation recipes

Build and smoke a CLAP bundle with the Pulp CLI:

```bash
# Build everything, then validate
./build/pulp build
./build/pulp validate          # runs clap-validator if installed
```

Direct `clap-validator` usage (matches what `cmd_validate.cpp` invokes):

```bash
# macOS / Linux
clap-validator validate "$(pwd)/build/path/to/MyPlugin.clap"

# Install if missing
cargo install clap-validator
```

CI's fallback when `clap-validator` is not on the path is a dlopen
check — load the bundle's entry symbol (`clap_entry`) and verify the
factory hands back a valid descriptor. See
`test/test_clap_entry.cpp` for the in-repo equivalent.

`pulp build --test` runs validation before allowing
`pulp build --install` to write into
`~/Library/Audio/Plug-Ins/CLAP/`. Do not `--skip-validation` a CLAP
build before a DAW scan — a crashing entry point takes the DAW down
with it.

### Validator runs must disable editor creation

`clap-validator` can query `CLAP_EXT_GUI` and call GUI callbacks even
when the test's intent is non-visual validation. Run validator automation
with `PULP_DISABLE_PLUGIN_EDITOR=1 PULP_HEADLESS=1 PULP_TEST_MODE=1`.
Under those guards, Pulp hides `CLAP_EXT_GUI` from `get_extension()` and
the GUI callbacks fail closed if a host cached the extension pointer.

## Host-API contract pinned by `test/test_clap_host_validation.cpp`

Real-DAW validation (Bitwig, Reaper, FL Studio, Studio One) requires
a license + manual install, so the CI proxy is
`test/test_clap_host_validation.cpp`. It pins the four contracts hosts
have historically broken on:

1. Plugin id + parameter id + range stability across instances.
2. `CLAP_EVENT_PARAM_MOD` does NOT bleed across blocks — the adapter
   calls `store.reset_all_mod()` at the top of every `process()`.
3. Non-core event spaces (`hdr->space_id != CLAP_CORE_EVENT_SPACE_ID`)
   are ignored, matching `clap-validator`'s
   `param-set-wrong-namespace` expectation.
4. `state.save` → `state.load` → `state.save` produces byte-equivalent
   output (Studio One project-recall determinism).

When changing the adapter's event dispatch or param surface, run
`pulp-test-clap-host-validation` first — it will catch the regression
before a host scan does.

## Cross-references

- `.agents/skills/view-bridge/SKILL.md` — editor open / attach /
  close protocol; CLAP is the reference wiring for this adapter family.
- `.agents/skills/mpe/SKILL.md` — MPE sidecar contract. CLAP is the
  canonical consumer.
- `.agents/skills/ara/SKILL.md` — ARA SDK setup and companion-factory
  lifecycle.
- `.agents/skills/vst3/SKILL.md` and `.agents/skills/auv3/SKILL.md` —
  cross-format parity table when triaging host-specific bugs.
- `docs/guides/formats.md` — user-facing format overview.
- `docs/guides/host-matrix.md` — per-host ARA / CLAP compatibility
  notes.
- Memory note: CHOC-first policy — prefer `choc::midi` helpers over
  hand-rolled MIDI decode when touching the adapter.

### CLAP editor hands GpuSurface to ScriptedUiSession

`clap_entry.hpp::gui_create` calls
`p->bridge->scripted_ui()->attach_gpu_surface(p->editor_host->gpu_surface())`
right after `PluginViewHost::create()` succeeds. Without this, a
CLAP plugin whose UI uses Three.js or raw WebGPU JS renders black —
the JS shim silently falls back to mocks. See the `view-bridge` skill's
"GpuSurface plumbing into WidgetBridge" section.

**Updated (WAH-1): subscribe, do not sample.** The one-shot
`attach_gpu_surface(host->gpu_surface())` read this section used to
describe is GONE. It only worked on hosts that build their surface in
the constructor; the Windows host creates its Dawn surface inside
`attach_to_parent()`, so the read returned `nullptr` forever and every
Windows editor fell back to mock WebGPU. Adapters now call the shared
helper once:

```cpp
gpu_surface_binding_ = bind_gpu_surface(*host, bridge->scripted_ui(),
                                        gpu_decision, "CLAP");
```

It follows `PluginViewHost::observe_gpu_surface()`, forwards creation
AND teardown into the session, and owns the CPU-fallback diagnostic
(which no longer fires on a pre-attach `pending` state). Reset the
returned subscription in the editor-close path, before the bridge that
owns the session is destroyed.

## Host-quirks consumption

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`.

## synthesize_bypass_parameter pass-through

At init (clap_init / PulpAUEffect ctor), the adapter calls
`pulp::format::maybe_synthesize_bypass(store, host_quirks)` then detects the
"Bypass" param (shared boolean-range heuristic: name=="Bypass", step>=1,
0..1) into a cached `bypass_param_id`. In the audio callback (clap_process /
ProcessBufferLists) it short-circuits to a **null-guarded pass-through**
(copy main input → output, zero any output channel without a matching input)
and skips the Processor when the param value is >= 0.5 — mirroring the VST3
processBlockBypassed path. `PULP_HOST_QUIRKS=off` synthesizes nothing
(bypass_param_id stays 0). The pass-through MUST null-check each destination
channel pointer because a bus can report channels with null buffers.

## Sample-accurate parameter output

CLAP's `out_events` requires **globally ascending time across ALL event types**.
The param-output drain (`clap_adapter.cpp`) therefore does a **three-cursor
merge** of output param events + MIDI shorts + sysex by ascending sample offset,
not "params then MIDI". Snapshot-diff fallback params (all at offset 0) emit
first; a per-param skip-set stops them double-reporting a param that pushed
explicit `push_output_param_event()` events. CLAP param values are **plain
domain** (`double`), unlike VST3 which normalizes. `test_clap_midi_events.cpp`
asserts the `param@0, midi@8, param@16` interleave stays non-decreasing in time.

## `clap_process()` is phase-split — the phase ORDER is the contract

`clap_process()` is a sequence of named phase helpers rather than one
megafunction. The split is presentational: the phases must run in exactly the
order they do, because the CLAP boundary is order-sensitive in ways the function
names do not advertise — input events must be drained before the block is
processed, and the `out_events` drain must stay last so the three-cursor merge
above sees every event the block produced. Reordering two phases compiles
cleanly and passes any test that only checks a single-block render.

So when adding work, add it *inside* the phase that owns it; do not add a new
phase between existing ones without re-reading the merge contract above. The
guard is `test/test_adapter_audio_parity.cpp`, which renders through the real
adapter and nulls it against the direct render — a phase-order regression shows
up there as a block-boundary difference, not as a crash.

`find_param_index()` in `adapter_boundary.hpp` is shared by the CLAP and VST3
output-param publication paths so the two cannot drift. Note it is a **linear
scan**, not an index — param counts are small and the scan is what both adapters
already did. If it ever becomes hot, index it for both adapters at once; a
one-sided optimization is how these two paths drifted before.

## The StateStore must outlive the Processor

`Processor::state()` dereferences a pointer the host installs. A Processor may
follow it for its whole lifetime — from `process()`, from its destructor, and from
any worker thread that destructor is about to `join()`. So the host has to keep the
store alive until the Processor is gone.

In practice that is one rule about member order: **declare the `state::StateStore`
before the `std::unique_ptr<Processor>`.** Members are destroyed in reverse
declaration order, so the store then dies last. Every host in `core/format` had it
backwards until 2026-07; the effect is nothing at all for a Processor with no
threads, and a use-after-free on plug-in close for one with a background thread that
reads `state().get_value()` while the destructor walks to its `join()`.

It crashes only on close, only sometimes, and the DAW gets the blame. The regression
test is `test/test_store_lifetime.cpp`; it observes the store's destruction through a
sentinel owned by a parameter's `to_string` closure rather than reading freed memory
and hoping the result looks wrong.

A Processor should not *rely* on this either: a worker thread that reads the store on
every tick is one host away from the same crash. Publish what the thread needs to
atomics from `process()` instead.

## Param text entry + gesture threading (PARAMS region)

- `params_text_to_value` must try `ParamInfo::from_string` BEFORE the generic
  numeric parse. from_string is the inverse of the `to_string` used by
  `value_to_text`, so a custom rendering ("quality=0.75", an enum label)
  round-trips; a bare strtod would reject it. CLAP values are plain (min..max),
  the same domain from_string returns — no normalization step. Guard the result
  with `std::isfinite` and fall through to the locale-independent strtod path on
  a non-finite parse. Keep the locale-independent fallback (`parse_double_c_locale`)
  for plain-numeric params. Test: `test/test_clap_entry.cpp`
  ("text_to_value routes through ParamInfo::from_string").
- CLAP delivers `CLAP_EVENT_PARAM_GESTURE_BEGIN/END` on the process/flush path
  and calls `store.begin_gesture/end_gesture` directly. Those StateStore entry
  points are main-thread-only (they forward to host undo grouping). A background
  writer must use `StateStore::run_gesture_on_main()` — see the state notes in
  `binding.hpp`.
## Editor resize contract (aspect-lock vs free reflow)

`gui_can_resize` returns resizable **iff `view_size().min_width>0 && min_height>0`** —
a plugin on the base-class default (`ViewSize{w,h,0,0,0,0}`) is fixed-size. Do NOT
naively "honor `aspect_ratio==0`" everywhere: that default returns `aspect_ratio==0`,
so a naive read flips *every* hand-authored plugin to free-reflow. The three cases the
GUI region dispatches (`gui_create`, `gui_get_resize_hints`, `gui_adjust_size`):

- **min==0 (not resizable):** keep the design-viewport pin at preferred (letterbox
  backstop for off-size panes). `gui_can_resize` already returns false.
- **resizable + `aspect_ratio>0`:** viewport + aspect lock; `preserve_aspect_ratio=true`;
  `gui_adjust_size` snaps to the design aspect (design-import plugins live here).
- **resizable + `aspect_ratio==0`:** free drag — **no** `set_design_viewport`, **no**
  `set_fixed_aspect_ratio`; `preserve_aspect_ratio=false`; `gui_adjust_size` clamps each
  axis to `[min,max]` independently with **no aspect snap**; the root reflows via Yoga at
  the host size.

The rule is `free = (min>0 && aspect_ratio==0)`. VST3's `PulpPlugView` mirrors it exactly
(`canResize`/`checkSizeConstraint`). Tests: the `[resize]` cases in
`test/test_clap_entry.cpp` build a `PulpClapPlugin` whose `bridge` is constructed
directly from a controlled `view_size()` (the `ViewBridge` ctor copies `view_size()` into
`size_hints_`, so no `gui_create`/attach is needed to exercise the negotiation math).

## Editor-INITIATED resize (`request_editor_resize` → `gui.request_resize`)

The resize contract above is HOST→plugin (the DAW dragged the window). The
opposite direction — the editor asking the DAW to resize the plugin window —
runs through `Processor::request_editor_resize(w, h)`. `gui_create` installs the
handler under the CLAP editor instance's owner key (and `gui_destroy` clears
that same owner BEFORE resetting
`editor_host`/`bridge`, so a late call never touches freed state). The handler:
`bridge->set_preferred_size(w,h)` (so `gui_get_size`/`gui_get_resize_hints`
report the new shape), `editor_host->set_design_viewport(w,h)` +
`set_fixed_aspect_ratio(w/h)`, then
`host->get_extension(host, CLAP_EXT_GUI)->request_resize(host, w, h)`. Returns
that call's bool (a host may refuse). This is what lets a mode switch (e.g. a
compact "player" view) shrink the window and change its aspect at runtime — the
design-viewport re-pin keeps content filling the new size with no letterbox. See
the `view-bridge` skill for the cross-format seam.

## `state.load` runs under the state-restore gate

`Processor::deserialize_plugin_state()` is documented as running "with the
audio thread stopped", but CLAP hosts call `state.load` on the main thread
while the plug-in is active and rendering. `clap_entry.hpp`'s `state_load`
therefore holds `PulpClapPlugin::state_restore_gate` across the deserialize;
acquiring it proves no audio callback is inside `clap_process()`.

Two consequences when working in this adapter:

* The render side takes the gate BEFORE `clap_phase_prepare_sidecars`, not just
  around the `processor->process()` call, because the sidecar phase already
  reaches into the Processor (`mpe.run(*self->processor, …)`). A gate taken only
  around the process call would leave that phase racing a restore.
* On contention the block routes through `clap_phase_bypass_passthrough` — the
  same degradation as an engaged Bypass, since both mean "the Processor did not
  run this block". If you add a phase that touches the Processor, put it inside
  the gated region or it will run against a half-restored plug-in.

The gate is released before `on_non_realtime_tick()` so a processor that
reconciles derived state there cannot stall the audio thread for the length of
that work.

## Note names (`clap.note-name`)

`Processor::note_names()` is published through `clap_note_name.cpp`. The
extension is offered from `clap_get_extension` UNCONDITIONALLY — do not gate it
on the list being non-empty. Hosts query `get_extension` once at init, so a
sampler that names notes only after loading a kit would be locked out for the
whole session if the empty-list case refused the extension.

Both entry points are `[main-thread]` per the extension, and `note_names()` is a
main-thread call, so re-reading the processor there needs no gate.

`flag_note_names_changed()` rides the existing `request_callback` set alongside
the latency / tail pending flags, and the `clap_host_note_name->changed()` push
happens in `clap_on_main_thread()` — never from `process()`.

### Tracing attaches for this format now (WAH-4)

Perfetto tracing used to be wired into **VST3 only**. A capture of a CLAP
session recorded nothing while `Tracing`'s API described itself as
process-global — so an empty `.pftrace` looked like an environment problem
rather than a missing call.

This adapter now holds a `runtime::ScopedTracingAttachment` (`PulpClapPlugin::tracing`). Two
things follow:

- **It is RAII, not a hand-balanced attach/detach pair.** A leaked attachment
  is not benign: the `.pftrace` is only written by the FINAL detach, so one
  unbalanced instance means the capture silently produces nothing.
- **Declaration order is load-bearing.** It must outlive every span this
  instance can emit, so it is declared to destroy LAST. The final detach also
  cancels and JOINS the auto-flush timer, which is what makes plug-in module
  unload safe — a detached timer thread that wakes after `FreeLibrary` /
  `dlclose` runs freed code.

No-op unless the build is configured `PULP_TRACING=ON`.

## The bundle carries its own icon, and the plist key is what makes it work

`pulp_app_icon(<target>_CLAP ...)` brands `.clap`: it copies the
`.icns` into `Contents/Resources/` and sets `MACOSX_BUNDLE_ICON_FILE`, which
CMake substitutes into the bundle's `Info.plist` at generate time.

The load-bearing half is easy to miss. That substitution needs a
`CFBundleIconFile` key in `tools/cmake/PulpInfoPlist.clap.in` to land in.
Without it the copy still happens and the property is still set, so nothing
errors — the bundle just comes out unbranded. If an icon does not appear,
check the template for the key before suspecting the helper.

Prefer `ICNS` over `SOURCE` for a mark with fine detail. `SOURCE` derives
every size from one PNG with `sips`, whose Lanczos kernel overshoots on hard
edges: a feature one or two device pixels wide at 16x16 smears into its
neighbours and the bundle edge picks up a bright halo. Render each size on
its own pixel grid and pass the finished `.icns`.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.