Patch → patchMain ownership rework
Promote the UI's patchCopy to an engine-owned patchMain that is the single
main-thread source of truth. The audio thread's patch becomes a pure realtime
working copy. The CLAP adapter must never read engine->patch.
This is the standard architecture for SideQuest plugins, not an optional upgrade. New
shells come out of the sidequest-setup skill already on it; that skill treats this
document as its specification. Use the steps below for a legacy plugin that still owns a
patchCopy in the editor.
Migrated and usable as a reference: two-filters (the original port, effect-shaped),
freaqy-verb, six-sines (instrument-shaped, the richest — it carries extra DAW session
state through SET_AUDIO_DAW_STATE and has the fullest patch_sync test suite). Read the
one closest in shape to what you are migrating; verify with
grep -rl patchCopy ../<plugin>/src coming back empty.
The model
- Engine owns two patches:
Patch patch (audio thread only) and
Patch patchMain (main thread only).
- Editor holds
Patch &patchMainRef bound to engine->patchMain — not an owned copy.
- All CLAP main-thread calls (stateSave/stateLoad/paramsValue/paramsInfo/
paramsValueToText/paramsTextToValue, and paramsFlush when inactive) read/write
patchMain.
Invariant D (establish this first — the whole design rests on it)
Every audio-thread mutation of patch emits an audioToMain message; every
UI-originated mutation is written into patchMain by the UI itself.
So draining audioToMain into patchMain fully reconstructs the audio side, and
patchMain is always authoritative on the main thread. Verify handleParamValue
(the host-automation path) already pushes UPDATE_PARAM unconditionally — it usually
does. UI edits already write patchCopy (== patchMain) directly in the data binding's
setValueFromGUI, so that half holds for free.
Threading rules
patchMain: main thread only — all CLAP main-thread calls + the editor idle
(the clap-juce-shim runs the JUCE timer on the main thread).
patch: audio thread only, except inside activate()/deactivate() where the
audio thread is guaranteed stopped.
Engine state to add
Patch patch; // audio-thread working copy
Patch patchMain; // main-thread source of truth
std::atomic<bool> editorActive{false}; // unified editor-open flag (see below)
std::atomic<bool> mainThreadDrainRequested{false}; // coalesces request_callback
std::atomic<uint32_t> uiForceRebuild{0}; // bump => open editor rebuilds from patchMain
Add #include <atomic>. Do not add a hand-rolled engineActivated flag: the clap helper's
isActive() already tells you whether the audio thread is running, and (in a clap::helpers::Plugin
subclass) it's the only thing that ever needs the answer. Branch on isActive().
Steps
Patch::copyValuesFrom(const Patch &o) — a VALUE copy, never operator=.
params/paramMap hold Param* into the owning Patch; assignment would alias
them across objects. Copy: every param value + all non-Param streamed DSP state
(whatever your additionalToStateImpl writes — e.g. two-filters' filterNodes[i] .model/.config) + name + dirty. Note params is vector<const Param*>, so
read from o.params and write via your own paramMap:
void copyValuesFrom(const Patch &o) {
for (const auto *p : o.params) paramMap.at(p->meta.id)->value = p->value;
// + per-app non-Param streamed fields (mirror additionalToStateImpl)
memcpy(name, o.name, sizeof(name));
dirty = o.dirty;
}
Factor the audioToMain handling so the editor idle and the headless drain share it.
The editor idle loop already switches on every audioToMain action; do not duplicate the
patch-model half in a second drain. Instead:
static bool Engine::handleAudioToMainMessage(Patch &dest, const AudioToMainMsg &m) —
applies the patch-model messages (UPDATE_PARAM→dest.paramMap[id]->value, patch-name,
dirty, filter/DSP-config) to dest; returns true if handled, false for UI-only
messages (VU, LFO step, sample-rate, param-rescan). Make it static — it touches
only dest, so the editor (which holds no Engine&, just references to the queues +
patchMain) can call Engine::handleAudioToMainMessage(patchCopy, *msg).
Engine::drainAudioToMainInto(Patch &dest) (main thread) just loops
while (pop) handleAudioToMainMessage(dest, *m); (UI-only messages fall through and are
discarded). Used by onMainThread, stateSave, and tests.
- Editor
idle(): if (handleAudioToMainMessage(patchCopy, *aum)) { switch on the handled actions to do the WIDGET refresh only (data is already applied) } else { UI-only cases: VU meter, LFO step display, sample-rate, param-rescan }.
Unify the editor-open flag into editorActive. Delete isEditorAttached and the
EDITOR_ATTACH_DETATCH message + handler. Audio-thread VU/LFO gates read
editorActive.load(std::memory_order_relaxed) (a relaxed atomic-bool load is
effectively free, even per-sample).
handleParamValue (after pushing UPDATE_PARAM): if no editor is draining, ask
the main thread to. Check host first so the flag isn't flipped when host is null (tests):
if (clapHost && !editorActive.load(std::memory_order_relaxed) &&
!mainThreadDrainRequested.exchange(true))
clapHost->request_callback(clapHost);
onMainThread: if (!editorActive.load()) { mainThreadDrainRequested.store(false); drainAudioToMainInto(patchMain); } (store false before draining so a message arriving
mid-drain re-arms a callback). Keep existing rescan handling.
activate: patch.copyValuesFrom(patchMain) then setSampleRate(...) (which rebuilds
filters/LFOs from patch). Nothing else is needed at activate/deactivate — isActive() is the
audio-thread-running flag.
stateSave: drop SEND_PREP_FOR_STREAM / readyForStream / the spin-wait. Then:
if (!engine->editorActive.load()) engine->drainAudioToMainInto(engine->patchMain);
return patchToOutStream(engine->patchMain, ostream);
(Only drain when the editor is closed — otherwise the idle loop is the queue's consumer
and you'd steal its messages. When open, the idle keeps patchMain current.)
stateLoad: load into a temp so a parse failure never half-writes patchMain:
auto tmp = std::make_unique<Patch>();
if (!inStreamToPatch(istream, *tmp)) return false;
engine->patchMain.copyValuesFrom(*tmp);
engine->uiForceRebuild++; // open editor rebuilds from patchMain
if (isActive()) // clap helper flag, not a hand-rolled one
Engine::sendEntirePatchToAudio(engine->patchMain, engine->mainToAudio, _host.host()); // also rescans
else if (_host.canUseParams())
_host.paramsRescan(CLAP_PARAM_RESCAN_VALUES | CLAP_PARAM_RESCAN_TEXT); // else branch: no double
Note sendEntirePatchToAudio — the funnel that pushes a loaded patch into the audio-thread
patch via the queue (STOP → SET_PARAM_WITHOUT_NOTIFYING per param → START → POST_LOAD →
filter/DSP model msgs) and then rescans the host — belongs on Engine as a static, not on
the preset manager: it encodes the engine's mainToAudio protocol, and both the preset manager
and the clap adapter call it holding only the queue + host (no Engine instance). The preset
manager keeps only file→Patch; its loadX methods call Engine::sendEntirePatchToAudio(patch, mainToAudio, clapHost). Do not thread the patch name through this funnel (see step 16):
name/dirty are main-thread-only patch state, so loadX sets them on patch (== patchMain)
directly right after fromState and this funnel only moves params + filter config.
params* reads (paramsCount/paramsInfo/paramsValue/paramsValueToText/ paramsTextToValue): switch engine->patch → engine->patchMain. But the cookie is the
exception — it must keep pointing into patch. patchParamsInfo cookies whatever patch it
read, and the host hands that cookie straight back on param events, which process() and the
active paramsFlush resolve on the AUDIO thread (paramFromClapEvent trusts a non-null cookie
and never consults the patch you pass it). Add a helper on the engine and overwrite the cookie
immediately after the info call:
// engine.h — every clap cookie we hand the host points into the audio-thread `patch`
void *clapCookieFor(uint32_t paramId)
{
auto it = patch.paramMap.find(paramId);
return it == patch.paramMap.end() ? nullptr : (void *)it->second;
}
// paramsInfo
if (!patchParamsInfo(paramIndex, info, engine->patchMain)) return false;
info->cookie = engine->clapCookieFor(info->id);
If paramsInfo does any further per-param work (six-sines rewrites the displayed name for the
primary macro param), read the param from patchMain.params[paramIndex] rather than from
info->cookie so it no longer depends on the cookie's identity. Every OTHER place that fills a
clap_event_param_value_t.cookie — processUIQueue, paramsFlushMainThread — must use the
same helper (processUIQueue already holds a patch param, so it is naturally correct;
paramsFlushMainThread is the one that will reach for its patchMain dest by mistake).
paramsFlush — branch on isActive() (audio thread when active, main thread when
not — the CLAP rule; do not assume it's always main-thread):
if (isActive()) { for (ev in in) handleEvent(ev); engine->snapAllParams();
engine->processUIQueue(out); } // routes into patch
else { engine->paramsFlushMainThread(in, out); } // patchMain only
paramsFlushMainThread: apply incoming CLAP_EVENT_PARAM_VALUE in place to
patchMain; then drain mainToAudio, applying SET_PARAM/name/dirty/config to
patchMain and emitting param-value + gesture out-events for automatable params — whose
cookie is clapCookieFor(paramId), NOT the patchMain dest in hand (step 9).
Ignore STOP/START_AUDIO/POST_LOAD/RESCAN (handled at activate or irrelevant while
inactive). Never touch patch (the cookie lookup is the one read, and it is pointer-only).
Editor: the owned Patch patchCopy; becomes a reference bound to patchMain — rename
it Patch &patchMainRef; since it is no longer a copy. Ctor also takes
std::atomic<bool> &editorActive and std::atomic<uint32_t> &uiForceRebuild (init
the reference members in the init list, patchMainRef first). Set editorActive = true
right after idleTimer->startTimer(...); in the dtor set editorActive = false; then
clapHost->request_callback(clapHost); before idleTimer->stopTimer(). Remove the
EDITOR_ATTACH_DETATCH pushes and any sneakyStartupGrabFrom (the editor now shares
patchMain, so it opens already showing correct values).
Editor idle rebuild (D4): cache uint32_t lastForceRebuild{uiForceRebuild.load()}.
At the top of idle(), if uiForceRebuild.load() != lastForceRebuild, update the cache
and call a rebuildFromPatchMain() that refreshes every widget from patchCopy — reuse
the existing "a preset was loaded" path (e.g. postPatchChange(patchCopy.name) +
dirty-state). This is the only refresh path when a host stateLoad arrives while the
engine is deactivated (no audio thread to push a full refresh).
Update the CLAP createEditor call site to pass engine->patchMain,
engine->editorActive, engine->uiForceRebuild.
Retire the full-refresh push. Once the editor shares patchMain, the old
pushFullUIRefresh (echoing every value/filter-config/name/dirty back through the queue)
is dead: the editor renders from patchMainRef on open, and the load paths refresh via
postPatchChange / uiForceRebuild. Delete pushFullUIRefresh, the doFullRefresh
flag, and its postLoad trigger. The ONLY thing it carried that isn't patch state is the
engine's sample rate (footer readout). Keep a lightweight request for exactly that: rename
the editor's REQUEST_REFRESH message to REQUEST_NON_PATCH_STATE, and have the engine
answer it by pushing only SEND_SAMPLE_RATE (still also pushed by setSampleRate for live
rate changes). Drop any duplicate refresh push in the editor ctor. If your plugin has other
engine-only (non-patch) UI state, that message is where it belongs.
A param rescan is a direct main-thread call — do not round-trip it. Telling the host
"re-read the values" (clap_host_params->rescan(VALUES|TEXT)) is a main-thread op, and its
only trigger is a bulk out-of-band load (state load / preset load) which already happens on
the main thread with patchMain updated first (the host reads values from patchMain). So
call rescan straight from the load funnel (sendEntirePatchToAudio, which holds the host)
and from stateLoad's deactivated branch. Delete the queue apparatus that bounced it
through the audio thread — a SEND_REQUEST_RESCAN mainToAudio message that set an
onMainRescanParams atomic AND pushed a DO_PARAM_RESCAN audioToMain message, so the
rescan happened twice (once in onMainThread, once in the editor idle). All of that
collapses to one direct call. rescan(VALUES|TEXT) is legal while active; only
RESCAN_ALL/info/count require an inactive plugin.
Patch name + dirty are main-thread-only state — the editor owns them; kill the echo AND the
forward message. The audio-thread patch never reads its own name/dirty (they aren't
streamed from patch — stateSave reads patchMain; copyValuesFrom overwrites them at
activate). So the whole SEND_PATCH_NAME→SET_PATCH_NAME / SET_PARAM-sets-dirty→
SET_PATCH_DIRTY_STATE/SEND_PATCH_IS_CLEAN round-trip is dead weight. Remove both the
audio→main echoes (SET_PATCH_NAME, SET_PATCH_DIRTY_STATE) and the main→audio forwards
(SEND_PATCH_NAME, SEND_PATCH_IS_CLEAN) plus every producer/handler, and have the editor
own the two directly:
- dirty: a
markPatchDirty() on the editor — if (patchMainRef.dirty) return; patchMainRef.dirty = true; presetDataBinding->setDirtyState(true); presetButton->repaint(); — called at each UI
edit site that pushes SET_PARAM (both data-binding setValueFromGUIs, setAndSendParamValue
when notifyAudio, swapFilters). This preserves the exact old trigger (dirty flips on a user
param edit; host automation via handleParamValue still never dirties). Make postPatchChange
set setDirtyState(patchMainRef.dirty) so the view always mirrors the model; clear
patchMain.dirty in the load funnel and at save.
- name:
setPatchNameTo already writes patchMainRef.name + refreshes the display directly;
just drop its SEND_PATCH_NAME push. Preset loads set patch.name (== patchMain) directly in
loadX (the name isn't streamed, so something must, and the preset manager is the natural
owner). Host stateLoad leaves the name as-is.
After this, handleAudioToMainMessage handles only UPDATE_PARAM, the editor idle's "handled"
branch collapses to the one widget refresh, and the dead struct fields (patchNamePointer,
the audio→main uintValues, the main→audio uiManagedPointer) come out too.
The payoff: sharing patchMain makes a class of round-trips vestigial — sweep for them
Steps 14–16 are not one-off cleanups; they are instances of a single principle. Before this
rework, the editor owned a separate patch, so a lot of code existed purely to ferry patch
state main → audio → main: the UI asked the audio thread to send state back, or a main-thread
event (load, rescan) was bounced through the audio thread so the queue-driven editor would see
it. Once the editor and the main-thread source of truth are the same object (patchMain),
every one of those ferries is dead weight — the editor already has the state, and main-thread
work can happen directly on the main thread.
So after the core rework, actively hunt for and delete round-trips. The test for each
candidate: "Does this exist only to move patch state the editor can now read directly, or to
bounce a main-thread action through the audio thread and back?" If yes, delete it and do the
work directly. In two-filters this swept out, in order:
sneakyStartupGrabFrom — the editor no longer needs to grab a startup snapshot (step 11).
pushFullUIRefresh + doFullRefresh — the full value/name/dirty/config echo (step 14).
- the
SEND_REQUEST_RESCAN / DO_PARAM_RESCAN / onMainRescanParams rescan bounce (step 15).
- the name + dirty echoes and forwards (
SET_PATCH_NAME/SET_PATCH_DIRTY_STATE/SEND_PATCH_NAME/
SEND_PATCH_IS_CLEAN) — name/dirty are patch state the editor sets on patchMain directly (step 16).
- a redundant
engineActivated flag — isActive() already answers "is the audio thread running?"
The survivors are messages carrying state that genuinely is NOT in the patch or that genuinely
crosses threads: audio→main VU/LFO telemetry; the audioToMain param echo that keeps patchMain
current when a host automates while the editor is closed; and the REQUEST_NON_PATCH_STATE →
SEND_SAMPLE_RATE round-trip (step 14). Keep that idiom even though two-filters' only rider
today is the sample rate — it is the channel for engine-owned, non-patch UI state, and richer
plugins (six-sines, shortcircuit) push more through it (extra DAW/engine state, meters that aren't
in the patch). It is deliberately NOT collapsed into a direct reference. Everything that touches
the queues to move patch state, though, should be suspect.
Pitfalls (each has bitten this pattern)
operator= on Patch aliases paramMap/params across objects → corruption. Value-copy
only. Add a pointer-identity test: after copyValuesFrom, dst.paramMap[id] != src.paramMap[id].
- The clap param cookie is the one main-thread read that must NOT come from patchMain (step 9).
Getting this wrong is silent and total: the host's automation writes patchMain from the audio
thread and the engine never hears it. Worse than a stale value —
handleParamValue does
lag.setTarget + paramLagSet.addToActive(p), so the audio-thread lag set ends up ticking a
patchMain param every block, racing the editor and stateSave. Sweep every .cookie = and every
patchParamsInfo call site after the rework.
params is vector<const Param*> — you cannot write through it; write via paramMap.
paramsFlush is audio-thread when ACTIVE, main-thread when INACTIVE. Branch on isActive().
- Two consumers on the SPSC
audioToMain queue steal from each other. Gate strictly:
editor idle when editorActive, else onMainThread; stateSave drains only when closed.
- Deactivated
stateLoad with the editor open has no audio thread to refresh the UI — that
is what uiForceRebuild is for.
- Same trap in
paramsFlushMainThread: when INACTIVE, a host param change lands there, not
on the audio thread, so nothing pushes UPDATE_PARAM to refresh an open editor. If any incoming
CLAP_EVENT_PARAM_VALUE was applied to patchMain, uiForceRebuild++ (same out-of-band-write
mechanism as stateLoad). Without this the widgets silently lag the model while inactive.
stateSave with the editor OPEN reads patchMain without draining audioToMain (the idle
owns the queue), so it can lag the audio thread by up to one idle tick during an automation burst.
This is a window of inconsistency, not a race, and it's acceptable — just know it's there. (When
closed, stateSave drains first, so it's exact.)
- The "non-Param streamed state" in
copyValuesFrom is app-specific. Mirror exactly what
additionalToStateImpl serializes (two-filters: filter model/config; other plugins differ).
Tests (link the impl target; no CLAP host needed)
six-sines/tests/patch_sync.cpp is a worked version of everything below — UI edit reaching
audio, automation draining back into patchMain, paramsFlushMainThread forcing a UI
rebuild, DAW state round-trip, and a clap-cookie case. Port from it rather than writing
these from scratch.
Construct Engine/Patch directly. handleParamValue only calls request_callback when
clapHost is set (null in tests). Add to the test executable the impl's PRIVATE header deps
that engine.h transitively needs — for two-filters: simde sst-cpputils sst-filters sst-filters-extras sst-plugininfra::patchbase sst-plugininfra::filesystem sst-plugininfra::tinyxml (the impl links these PRIVATE, so they don't propagate).
copyValuesFrom: value equality for every param + non-Param DSP fields + name + dirty,
plus the pointer-identity guard.
toState/fromState round-trip of values + DSP config (name is not streamed — don't assert it).
- UI→audio: write patchMain + push BEGIN/SET/END to
mainToAudio → processUIQueue(out)
with a stub clap_output_events_t that discards → lagHandler.instantlySnap() +
snapAllParams() → assert patch.
- Audio→main:
handleParamValue(nullptr, pid, v) + snapAllParams() → assert patch;
then drainAudioToMainInto(patchMain) → assert patchMain.
- Drain selectivity: interleave VU/LFO/sample-rate with one
UPDATE_PARAM; assert only the
param lands and the queue is fully consumed.
- Cookie routing:
patchParamsInfo(idx, &info, patchMain) then info.cookie = clapCookieFor(info.id); assert the cookie equals patch.paramMap.at(pid) and differs from
patchMain.paramMap.at(pid). Then build a clap_event_param_value_t carrying that cookie, run
it through paramFromClapEvent<Param>(&pevt, patch) + handleParamValue + snapAllParams(),
and assert patch moved while patchMain did NOT — then that drainAudioToMainInto catches
patchMain up. Mutate the cookie to the patchMain param to confirm the test actually bites.
Follow-up (not done here): a stubbed CLAP test host in sst-clap-helpers to drive the full
activate → stateSave/stateLoad path end-to-end.
1---2name: patch-to-main3description: The SideQuest patch/patchMain architecture — the engine owns an audio-thread `patch` and a main-thread `patchMain`, the editor binds a `Patch&` to patchMain instead of owning a copy, and every host-facing main-thread call (stateSave/stateLoad/paramsValue/paramsFlush) reads patchMain, deleting the stateSave quiesce and spin-wait. The spec for new shells, and the migration steps for legacy plugins still on patchCopy.4---56# Patch → patchMain ownership rework78Promote the UI's `patchCopy` to an engine-owned `patchMain` that is the single9main-thread source of truth. The audio thread's `patch` becomes a pure realtime10working copy. The CLAP adapter must never read `engine->patch`.1112**This is the standard architecture for SideQuest plugins, not an optional upgrade.** New13shells come out of the `sidequest-setup` skill already on it; that skill treats this14document as its specification. Use the steps below for a *legacy* plugin that still owns a15`patchCopy` in the editor.1617Migrated and usable as a reference: `two-filters` (the original port, effect-shaped),18`freaqy-verb`, `six-sines` (instrument-shaped, the richest — it carries extra DAW session19state through `SET_AUDIO_DAW_STATE` and has the fullest `patch_sync` test suite). Read the20one closest in shape to what you are migrating; verify with21`grep -rl patchCopy ../<plugin>/src` coming back empty.2223## The model2425- Engine owns **two** patches: `Patch patch` (audio thread only) and26 `Patch patchMain` (main thread only).27- Editor holds `Patch &patchMainRef` bound to `engine->patchMain` — not an owned copy.28- All CLAP main-thread calls (stateSave/stateLoad/paramsValue/paramsInfo/29 paramsValueToText/paramsTextToValue, and paramsFlush when inactive) read/write30 `patchMain`.3132## Invariant D (establish this first — the whole design rests on it)3334> Every audio-thread mutation of `patch` emits an `audioToMain` message; every35> UI-originated mutation is written into `patchMain` by the UI itself.3637So draining `audioToMain` into `patchMain` fully reconstructs the audio side, and38`patchMain` is always authoritative on the main thread. Verify `handleParamValue`39(the host-automation path) already pushes `UPDATE_PARAM` unconditionally — it usually40does. UI edits already write `patchCopy` (== patchMain) directly in the data binding's41`setValueFromGUI`, so that half holds for free.4243## Threading rules4445- `patchMain`: main thread only — all CLAP main-thread calls + the editor idle46 (the clap-juce-shim runs the JUCE timer on the main thread).47- `patch`: audio thread only, **except** inside `activate()`/`deactivate()` where the48 audio thread is guaranteed stopped.4950## Engine state to add5152```cpp53Patch patch; // audio-thread working copy54Patch patchMain; // main-thread source of truth5556std::atomic<bool> editorActive{false}; // unified editor-open flag (see below)57std::atomic<bool> mainThreadDrainRequested{false}; // coalesces request_callback58std::atomic<uint32_t> uiForceRebuild{0}; // bump => open editor rebuilds from patchMain59```6061Add `#include <atomic>`. Do **not** add a hand-rolled `engineActivated` flag: the clap helper's62`isActive()` already tells you whether the audio thread is running, and (in a `clap::helpers::Plugin`63subclass) it's the only thing that ever needs the answer. Branch on `isActive()`.6465## Steps66671. **`Patch::copyValuesFrom(const Patch &o)` — a VALUE copy, never `operator=`.**68 `params`/`paramMap` hold `Param*` into the owning Patch; assignment would alias69 them across objects. Copy: every param value + all *non-Param streamed DSP state*70 (whatever your `additionalToStateImpl` writes — e.g. two-filters' `filterNodes[i]71 .model/.config`) + `name` + `dirty`. Note `params` is `vector<const Param*>`, so72 read from `o.params` and write via your own `paramMap`:73 ```cpp74 void copyValuesFrom(const Patch &o) {75 for (const auto *p : o.params) paramMap.at(p->meta.id)->value = p->value;76 // + per-app non-Param streamed fields (mirror additionalToStateImpl)77 memcpy(name, o.name, sizeof(name));78 dirty = o.dirty;79 }80 ```81822. **Factor the audioToMain handling so the editor idle and the headless drain share it.**83 The editor idle loop already switches on every audioToMain action; do not duplicate the84 patch-model half in a second drain. Instead:85 - `static bool Engine::handleAudioToMainMessage(Patch &dest, const AudioToMainMsg &m)` —86 applies the patch-model messages (`UPDATE_PARAM`→`dest.paramMap[id]->value`, patch-name,87 dirty, filter/DSP-config) to `dest`; returns `true` if handled, `false` for UI-only88 messages (VU, LFO step, sample-rate, param-rescan). **Make it `static`** — it touches89 only `dest`, so the editor (which holds no `Engine&`, just references to the queues +90 patchMain) can call `Engine::handleAudioToMainMessage(patchCopy, *msg)`.91 - `Engine::drainAudioToMainInto(Patch &dest)` (main thread) just loops92 `while (pop) handleAudioToMainMessage(dest, *m);` (UI-only messages fall through and are93 discarded). Used by `onMainThread`, `stateSave`, and tests.94 - Editor `idle()`: `if (handleAudioToMainMessage(patchCopy, *aum)) { switch on the handled95 actions to do the WIDGET refresh only (data is already applied) } else { UI-only cases:96 VU meter, LFO step display, sample-rate, param-rescan }`.97983. **Unify the editor-open flag into `editorActive`.** Delete `isEditorAttached` and the99 `EDITOR_ATTACH_DETATCH` message + handler. Audio-thread VU/LFO gates read100 `editorActive.load(std::memory_order_relaxed)` (a relaxed atomic-bool load is101 effectively free, even per-sample).1021034. **`handleParamValue`** (after pushing `UPDATE_PARAM`): if no editor is draining, ask104 the main thread to. Check host first so the flag isn't flipped when host is null (tests):105 ```cpp106 if (clapHost && !editorActive.load(std::memory_order_relaxed) &&107 !mainThreadDrainRequested.exchange(true))108 clapHost->request_callback(clapHost);109 ```1101115. **`onMainThread`**: `if (!editorActive.load()) { mainThreadDrainRequested.store(false);112 drainAudioToMainInto(patchMain); }` (store false *before* draining so a message arriving113 mid-drain re-arms a callback). Keep existing rescan handling.1141156. **`activate`**: `patch.copyValuesFrom(patchMain)` **then** `setSampleRate(...)` (which rebuilds116 filters/LFOs from `patch`). Nothing else is needed at activate/deactivate — `isActive()` is the117 audio-thread-running flag.1181197. **`stateSave`**: drop SEND_PREP_FOR_STREAM / readyForStream / the spin-wait. Then:120 ```cpp121 if (!engine->editorActive.load()) engine->drainAudioToMainInto(engine->patchMain);122 return patchToOutStream(engine->patchMain, ostream);123 ```124 (Only drain when the editor is closed — otherwise the idle loop is the queue's consumer125 and you'd steal its messages. When open, the idle keeps patchMain current.)1261278. **`stateLoad`**: load into a temp so a parse failure never half-writes patchMain:128 ```cpp129 auto tmp = std::make_unique<Patch>();130 if (!inStreamToPatch(istream, *tmp)) return false;131 engine->patchMain.copyValuesFrom(*tmp);132 engine->uiForceRebuild++; // open editor rebuilds from patchMain133 if (isActive()) // clap helper flag, not a hand-rolled one134 Engine::sendEntirePatchToAudio(engine->patchMain, engine->mainToAudio, _host.host()); // also rescans135 else if (_host.canUseParams())136 _host.paramsRescan(CLAP_PARAM_RESCAN_VALUES | CLAP_PARAM_RESCAN_TEXT); // else branch: no double137 ```138 Note `sendEntirePatchToAudio` — the funnel that pushes a loaded patch into the audio-thread139 `patch` via the queue (STOP → `SET_PARAM_WITHOUT_NOTIFYING` per param → START → POST_LOAD →140 filter/DSP model msgs) and then rescans the host — belongs on **Engine as a static**, not on141 the preset manager: it encodes the engine's mainToAudio protocol, and both the preset manager142 and the clap adapter call it holding only the queue + host (no Engine instance). The preset143 manager keeps only file→Patch; its `loadX` methods call `Engine::sendEntirePatchToAudio(patch,144 mainToAudio, clapHost)`. Do **not** thread the patch name through this funnel (see step 16):145 name/dirty are main-thread-only patch state, so `loadX` sets them on `patch` (== patchMain)146 directly right after `fromState` and this funnel only moves params + filter config.1471489. **params\* reads** (`paramsCount/paramsInfo/paramsValue/paramsValueToText/149 paramsTextToValue`): switch `engine->patch` → `engine->patchMain`. **But the cookie is the150 exception — it must keep pointing into `patch`.** `patchParamsInfo` cookies whatever patch it151 read, and the host hands that cookie straight back on param events, which `process()` and the152 active `paramsFlush` resolve on the AUDIO thread (`paramFromClapEvent` trusts a non-null cookie153 and never consults the patch you pass it). Add a helper on the engine and overwrite the cookie154 immediately after the info call:155 ```cpp156 // engine.h — every clap cookie we hand the host points into the audio-thread `patch`157 void *clapCookieFor(uint32_t paramId)158 {159 auto it = patch.paramMap.find(paramId);160 return it == patch.paramMap.end() ? nullptr : (void *)it->second;161 }162163 // paramsInfo164 if (!patchParamsInfo(paramIndex, info, engine->patchMain)) return false;165 info->cookie = engine->clapCookieFor(info->id);166 ```167 If `paramsInfo` does any further per-param work (six-sines rewrites the displayed name for the168 primary macro param), read the param from `patchMain.params[paramIndex]` rather than from169 `info->cookie` so it no longer depends on the cookie's identity. Every OTHER place that fills a170 `clap_event_param_value_t.cookie` — `processUIQueue`, `paramsFlushMainThread` — must use the171 same helper (`processUIQueue` already holds a `patch` param, so it is naturally correct;172 `paramsFlushMainThread` is the one that will reach for its `patchMain` `dest` by mistake).17317410. **`paramsFlush` — branch on `isActive()`** (audio thread when active, main thread when175 not — the CLAP rule; do not assume it's always main-thread):176 ```cpp177 if (isActive()) { for (ev in in) handleEvent(ev); engine->snapAllParams();178 engine->processUIQueue(out); } // routes into patch179 else { engine->paramsFlushMainThread(in, out); } // patchMain only180 ```181 `paramsFlushMainThread`: apply incoming `CLAP_EVENT_PARAM_VALUE` in place to182 `patchMain`; then drain `mainToAudio`, applying SET_PARAM/name/dirty/config to183 `patchMain` and emitting param-value + gesture out-events for automatable params — whose184 `cookie` is `clapCookieFor(paramId)`, NOT the `patchMain` `dest` in hand (step 9).185 Ignore STOP/START_AUDIO/POST_LOAD/RESCAN (handled at activate or irrelevant while186 inactive). Never touch `patch` (the cookie lookup is the one read, and it is pointer-only).18718811. **Editor**: the owned `Patch patchCopy;` becomes a reference bound to patchMain — rename189 it `Patch &patchMainRef;` since it is no longer a copy. Ctor also takes190 `std::atomic<bool> &editorActive` and `std::atomic<uint32_t> &uiForceRebuild` (init191 the reference members in the init list, `patchMainRef` first). Set `editorActive = true`192 right after `idleTimer->startTimer(...)`; in the dtor set `editorActive = false;` then193 `clapHost->request_callback(clapHost);` **before** `idleTimer->stopTimer()`. Remove the194 `EDITOR_ATTACH_DETATCH` pushes and any `sneakyStartupGrabFrom` (the editor now shares195 patchMain, so it opens already showing correct values).19619712. **Editor idle rebuild (D4)**: cache `uint32_t lastForceRebuild{uiForceRebuild.load()}`.198 At the top of `idle()`, if `uiForceRebuild.load() != lastForceRebuild`, update the cache199 and call a `rebuildFromPatchMain()` that refreshes every widget from `patchCopy` — reuse200 the existing "a preset was loaded" path (e.g. `postPatchChange(patchCopy.name)` +201 dirty-state). This is the only refresh path when a host `stateLoad` arrives while the202 engine is deactivated (no audio thread to push a full refresh).20320413. Update the CLAP `createEditor` call site to pass `engine->patchMain`,205 `engine->editorActive`, `engine->uiForceRebuild`.20620714. **Retire the full-refresh push.** Once the editor shares patchMain, the old208 `pushFullUIRefresh` (echoing every value/filter-config/name/dirty back through the queue)209 is dead: the editor renders from `patchMainRef` on open, and the load paths refresh via210 `postPatchChange` / `uiForceRebuild`. Delete `pushFullUIRefresh`, the `doFullRefresh`211 flag, and its `postLoad` trigger. The ONLY thing it carried that isn't patch state is the212 engine's sample rate (footer readout). Keep a lightweight request for exactly that: rename213 the editor's `REQUEST_REFRESH` message to `REQUEST_NON_PATCH_STATE`, and have the engine214 answer it by pushing only `SEND_SAMPLE_RATE` (still also pushed by `setSampleRate` for live215 rate changes). Drop any duplicate refresh push in the editor ctor. If your plugin has other216 engine-only (non-patch) UI state, that message is where it belongs.21721815. **A param rescan is a direct main-thread call — do not round-trip it.** Telling the host219 "re-read the values" (`clap_host_params->rescan(VALUES|TEXT)`) is a main-thread op, and its220 only trigger is a bulk out-of-band load (state load / preset load) which already happens on221 the main thread with `patchMain` updated first (the host reads values from patchMain). So222 call `rescan` straight from the load funnel (`sendEntirePatchToAudio`, which holds the host)223 and from `stateLoad`'s deactivated branch. Delete the queue apparatus that bounced it224 through the audio thread — a `SEND_REQUEST_RESCAN` mainToAudio message that set an225 `onMainRescanParams` atomic AND pushed a `DO_PARAM_RESCAN` audioToMain message, so the226 rescan happened twice (once in `onMainThread`, once in the editor idle). All of that227 collapses to one direct call. `rescan(VALUES|TEXT)` is legal while active; only228 `RESCAN_ALL`/info/count require an inactive plugin.22923016. **Patch name + dirty are main-thread-only state — the editor owns them; kill the echo AND the231 forward message.** The audio-thread `patch` never reads its own `name`/`dirty` (they aren't232 streamed from `patch` — `stateSave` reads `patchMain`; `copyValuesFrom` overwrites them at233 `activate`). So the whole `SEND_PATCH_NAME`→`SET_PATCH_NAME` / `SET_PARAM`-sets-dirty→234 `SET_PATCH_DIRTY_STATE`/`SEND_PATCH_IS_CLEAN` round-trip is dead weight. Remove both the235 audio→main echoes (`SET_PATCH_NAME`, `SET_PATCH_DIRTY_STATE`) and the main→audio forwards236 (`SEND_PATCH_NAME`, `SEND_PATCH_IS_CLEAN`) plus every producer/handler, and have the editor237 own the two directly:238 - **dirty:** a `markPatchDirty()` on the editor — `if (patchMainRef.dirty) return; patchMainRef.dirty239 = true; presetDataBinding->setDirtyState(true); presetButton->repaint();` — called at each UI240 *edit* site that pushes `SET_PARAM` (both data-binding `setValueFromGUI`s, `setAndSendParamValue`241 when `notifyAudio`, `swapFilters`). This preserves the exact old trigger (dirty flips on a user242 param edit; host automation via `handleParamValue` still never dirties). Make `postPatchChange`243 set `setDirtyState(patchMainRef.dirty)` so the view always mirrors the model; clear244 `patchMain.dirty` in the load funnel and at save.245 - **name:** `setPatchNameTo` already writes `patchMainRef.name` + refreshes the display directly;246 just drop its `SEND_PATCH_NAME` push. Preset loads set `patch.name` (== patchMain) directly in247 `loadX` (the name isn't streamed, so *something* must, and the preset manager is the natural248 owner). Host `stateLoad` leaves the name as-is.249 After this, `handleAudioToMainMessage` handles only `UPDATE_PARAM`, the editor idle's "handled"250 branch collapses to the one widget refresh, and the dead struct fields (`patchNamePointer`,251 the audio→main `uintValues`, the main→audio `uiManagedPointer`) come out too.252253## The payoff: sharing patchMain makes a class of round-trips vestigial — sweep for them254255Steps 14–16 are not one-off cleanups; they are instances of a single principle. Before this256rework, the editor owned a *separate* patch, so a lot of code existed purely to **ferry patch257state main → audio → main**: the UI asked the audio thread to send state back, or a main-thread258event (load, rescan) was bounced through the audio thread so the queue-driven editor would see259it. Once the editor and the main-thread source of truth are the **same object** (`patchMain`),260every one of those ferries is dead weight — the editor already has the state, and main-thread261work can happen directly on the main thread.262263So after the core rework, **actively hunt for and delete round-trips**. The test for each264candidate: *"Does this exist only to move patch state the editor can now read directly, or to265bounce a main-thread action through the audio thread and back?"* If yes, delete it and do the266work directly. In two-filters this swept out, in order:267268- `sneakyStartupGrabFrom` — the editor no longer needs to grab a startup snapshot (step 11).269- `pushFullUIRefresh` + `doFullRefresh` — the full value/name/dirty/config echo (step 14).270- the `SEND_REQUEST_RESCAN` / `DO_PARAM_RESCAN` / `onMainRescanParams` rescan bounce (step 15).271- the name + dirty echoes and forwards (`SET_PATCH_NAME`/`SET_PATCH_DIRTY_STATE`/`SEND_PATCH_NAME`/272 `SEND_PATCH_IS_CLEAN`) — name/dirty are patch state the editor sets on patchMain directly (step 16).273- a redundant `engineActivated` flag — `isActive()` already answers "is the audio thread running?"274275The survivors are messages carrying state that genuinely is NOT in the patch or that genuinely276crosses threads: audio→main VU/LFO telemetry; the `audioToMain` param echo that keeps patchMain277current when a host automates while the editor is closed; and the **`REQUEST_NON_PATCH_STATE` →278`SEND_SAMPLE_RATE` round-trip (step 14).** Keep that idiom even though two-filters' only rider279today is the sample rate — it is the channel for *engine-owned, non-patch* UI state, and richer280plugins (six-sines, shortcircuit) push more through it (extra DAW/engine state, meters that aren't281in the patch). It is deliberately NOT collapsed into a direct reference. Everything that touches282the queues to move *patch* state, though, should be suspect.283284## Pitfalls (each has bitten this pattern)285286- `operator=` on Patch aliases `paramMap`/`params` across objects → corruption. Value-copy287 only. Add a pointer-identity test: after `copyValuesFrom`, `dst.paramMap[id] != src.paramMap[id]`.288- **The clap param cookie is the one main-thread read that must NOT come from patchMain** (step 9).289 Getting this wrong is silent and total: the host's automation writes patchMain from the audio290 thread and the engine never hears it. Worse than a stale value — `handleParamValue` does291 `lag.setTarget` + `paramLagSet.addToActive(p)`, so the audio-thread lag set ends up ticking a292 patchMain param every block, racing the editor and `stateSave`. Sweep every `.cookie =` and every293 `patchParamsInfo` call site after the rework.294- `params` is `vector<const Param*>` — you cannot write through it; write via `paramMap`.295- `paramsFlush` is audio-thread when ACTIVE, main-thread when INACTIVE. Branch on `isActive()`.296- Two consumers on the SPSC `audioToMain` queue steal from each other. Gate strictly:297 editor idle when `editorActive`, else `onMainThread`; `stateSave` drains only when closed.298- Deactivated `stateLoad` with the editor open has no audio thread to refresh the UI — that299 is what `uiForceRebuild` is for.300- **Same trap in `paramsFlushMainThread`:** when INACTIVE, a host param change lands there, not301 on the audio thread, so nothing pushes `UPDATE_PARAM` to refresh an open editor. If any incoming302 `CLAP_EVENT_PARAM_VALUE` was applied to `patchMain`, `uiForceRebuild++` (same out-of-band-write303 mechanism as `stateLoad`). Without this the widgets silently lag the model while inactive.304- `stateSave` with the editor OPEN reads `patchMain` **without draining** `audioToMain` (the idle305 owns the queue), so it can lag the audio thread by up to one idle tick during an automation burst.306 This is a window of inconsistency, not a race, and it's acceptable — just know it's there. (When307 closed, `stateSave` drains first, so it's exact.)308- The "non-Param streamed state" in `copyValuesFrom` is app-specific. Mirror exactly what309 `additionalToStateImpl` serializes (two-filters: filter model/config; other plugins differ).310311## Tests (link the impl target; no CLAP host needed)312313`six-sines/tests/patch_sync.cpp` is a worked version of everything below — UI edit reaching314audio, automation draining back into patchMain, `paramsFlushMainThread` forcing a UI315rebuild, DAW state round-trip, and a clap-cookie case. Port from it rather than writing316these from scratch.317318319Construct `Engine`/`Patch` directly. `handleParamValue` only calls `request_callback` when320`clapHost` is set (null in tests). Add to the test executable the impl's PRIVATE header deps321that `engine.h` transitively needs — for two-filters: `simde sst-cpputils sst-filters322sst-filters-extras sst-plugininfra::patchbase sst-plugininfra::filesystem323sst-plugininfra::tinyxml` (the impl links these PRIVATE, so they don't propagate).3243251. `copyValuesFrom`: value equality for every param + non-Param DSP fields + name + dirty,326 **plus** the pointer-identity guard.3272. `toState`/`fromState` round-trip of values + DSP config (name is not streamed — don't assert it).3283. UI→audio: write patchMain + push BEGIN/SET/END to `mainToAudio` → `processUIQueue(out)`329 with a stub `clap_output_events_t` that discards → `lagHandler.instantlySnap()` +330 `snapAllParams()` → assert `patch`.3314. Audio→main: `handleParamValue(nullptr, pid, v)` + `snapAllParams()` → assert `patch`;332 then `drainAudioToMainInto(patchMain)` → assert `patchMain`.3335. Drain selectivity: interleave VU/LFO/sample-rate with one `UPDATE_PARAM`; assert only the334 param lands and the queue is fully consumed.3356. Cookie routing: `patchParamsInfo(idx, &info, patchMain)` then `info.cookie =336 clapCookieFor(info.id)`; assert the cookie equals `patch.paramMap.at(pid)` and differs from337 `patchMain.paramMap.at(pid)`. Then build a `clap_event_param_value_t` carrying that cookie, run338 it through `paramFromClapEvent<Param>(&pevt, patch)` + `handleParamValue` + `snapAllParams()`,339 and assert `patch` moved while `patchMain` did NOT — then that `drainAudioToMainInto` catches340 patchMain up. Mutate the cookie to the patchMain param to confirm the test actually bites.341342Follow-up (not done here): a stubbed CLAP test host in sst-clap-helpers to drive the full343activate → stateSave/stateLoad path end-to-end.