# src/shell/instrument — ReaSampler 9000 VST3 shells

## Scope

The REAPER/VST3-facing shells for the ReaSampler 9000 instrument: the read-only bank
bridge, the processor, the editor, the embed strip, and the VST3 entry point — plus
two small identity/helper headers this directory owns outright
(`reasampler_vst.h`, `editor_internal.h`).

The pure engine/geometry core this shell wraps (`sampler_core`, `pitch_shift`,
`sample_map`, `component_state_io`, `play_params.h`, `editor_geometry`, `sample_bands`,
`sample_chrome`, `keyboard_strip`, `waveform_view`, `loop_marks`, `capture_browser`, `browser_scroll`,
`param_slider`, `param_taper`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`,
`deck_groups`, `deck_values`, `bake_hold`, `curve_popup`, `spline_edit`, `master_gain`,
`limiter`, `meter_ballistics`, `master_meter`, `reasampler_uid.h`) lives in `core/instrument/*` and
`core/wire` and is documented there — this directory consumes it but does not own it.

## Invariants

**The build shape (D-A, settled 2026-07-26 — bare Steinberg VST3 SDK + LICE editor).**
Bare Steinberg VST3 SDK, no JUCE, with the editor drawn in the same LICE/SWELL stack
`bank_panel` already uses. `SingleComponentEffect` (the SDK's combined
processor+controller base) plus the SDK's factory macros is the audio-processing
scaffolding. Drawing the editor in a VST3 `IPlugView` that hosts a LICE surface reuses
the `bank_panel` docking muscle, keeps the look house-consistent, and avoids JUCE's
AGPL-or-pay license posture. The `IPlugView`↔LICE bridge (window lifecycle, sizing,
event routing from the host into the draw/hit-test loop) is the same class of work as
docking `bank_panel`, not a new competence.

**Embedded TCP/MCP UI (D-D) — `reasampler_embed`.** A REAPER-hosted VST3 can draw its
own UI inline in the track/mixer control panel via `reaper_plugin_fx_embed.h` (the
plugin implements `IReaperUIEmbedInterface` — the same Cockos surface REAPER's own
embedded FX use). Because this uses the same LICE-class drawing as the main editor
path, it composes naturally with the bare-SDK-plus-LICE build. **Must-verify:** the
`IReaperUIEmbedInterface` contract and embed message/lifecycle against
`vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h`.

**Channel mode (D-E) — current reality.** An earlier design (D-E, settled
2026-07-26) specified a per-instance mono/stereo toggle negotiating the REAPER
audio bus via `setBusArrangements`/`getBusArrangement`, with mono-source+stereo-mode
→ dual-mono and stereo-source+mono-mode → downmix as the cross-mode policy. **This
was superseded by the GA post-launch DAW-fix pass**: the output bus is now
permanently stereo, `ChannelMode` is decode-only (the dynamic mono↔stereo bus
renegotiation from the earlier design was deleted), and channel mode auto-defaults
from the loaded capture via `ComponentState` v9's `channelModeExplicit` flag + the
pure `channelModeFor` helper. Root `CLAUDE.md` is authoritative for this behavior —
do not reintroduce per-instance bus renegotiation.

**VST3 channel identity — the UID pair + the pairing surface (S18).** A beta-built VST
pairs with the beta extension only, a stable VST with stable only, both installable
side-by-side in one REAPER — one channel per binary; all channel identity derives from
the ONE `REASAMPLER_CHANNEL_IS_BETA` bit via the pure `app_version` module (no
scattered `#ifdef`s in the VST shell, except the one described below).
- **The UID-pair invariant is a permanent commitment.** The VST3 class UID is the
  plugin's identity — a saved REAPER project records it and rebinds a saved instance
  by it. BOTH channel UIDs (`reasampler_uid.h`'s stable + beta pairs) are frozen
  forever once shipped; the channel bit selects which one is compiled into this
  binary (one `DEF_CLASS2`, one class per binary — never both classes in one binary).
  The UID selection is the ONLY channel `#ifdef` in the VST shell, because an
  `INLINE_UID` needs literal brace-init tokens and cannot route through
  `app_version`'s runtime string accessors.
- **Binary + display identity are channel-derived**, sourced from `app_version`'s
  VST-name accessors — never a literal in `reasampler_vst.h`/`vst_entry.cpp`.
- **The complete pairing surface is structural, not per-key.** Plugin identity
  (UID + filename + display) is channel-forked, and all wire keys live under the
  channel-derived ext-state namespace — the two together make pairing complete: no
  per-key or per-seam isolation work is ever needed for a new wire key.
- **Verify** all identity/factory wiring against the vendored Steinberg SDK
  (`DEF_CLASS2` / `INLINE_UID` / `FUID` from `pluginfactory.h` + `funknown.h`).

**The three commit tiers (Θ-W3).** An edit reaches the audio by exactly one of three routes, and
which route a control takes is decided once, by the pure `deckParamCommit` / `liveCommitFor` pair
(`core/instrument/ui/deck_groups`) that the editor's `dragCommitsLive` only maps onto — see
`core/instrument/CLAUDE.md`'s "Live parameter delivery" for the rule and its rationale.
1. **Full reload** — `reloadInstrument`: bridge read, WAV re-decode, fresh engine, snapshot swap.
2. **Engine rebuild** — `rebuildVoiceEngine`: same drain-slot swap around the already-decoded
   `SampleData`. Voice count / mode / mono trigger.
3. **Live** — `publishLiveParams` (and `masterGain_`, the original of the shape): a lock-free
   publish the audio thread observes at block boundaries. No rebuild, no snapshot, no disk. The
   pure predicate splits this tier by WHO READS the published value (`Live` vs `NoteOnLatched`);
   the route out of the editor is the same one either way.

The editor's `commitLive` is the tier-3 peer of `commitAndReload`; why it still writes the
parameter set is recorded at its declaration in `reasampler_editor.h`, and why `liveParams_` is
declared ahead of the instrument slots at that member in `reasampler_processor.h`.

**The VST3 parameter surface is a THIRD surface onto the one model, never a second copy.** The
pure half — the frozen id table, the exposed set, the plain-value layer, the formatter — is
`core/instrument/param` and is documented there; this directory only adapts it.

- **The blob stays authoritative.** `getState` serializes the model and nothing new is
  persisted; the controller's own value list is a cache written FROM the model and never read
  as truth. A load pushes the model into that cache through `syncParamsFromModel` WITHOUT
  notifying the host, which the SDK requires.
- **`setInstrumentParams` is the notification funnel**, for the same reason it is already the
  limiter mirror's: every writer of the parameter set — the editor's commits, `setState`, the
  bake's adopt — passes through it, so no internal write can leave the host displaying, and on
  next touch re-imposing, a superseded value. Master gain has its own funnel
  (`setMasterGainLinear`) because it is the one exposed control that does not ride the
  parameter set.
- **BOTH delivery channels are serviced, and the audio-side one is the normative one.**
  `IEditController::setParamNormalized` is the CONTROLLER channel — the SDK says a controller
  "should update the according GUI element(s) only" there, so nothing about the audio may depend
  on a host calling it. `ProcessData::inputParameterChanges` is the AUDIO channel, and the SDK's
  own single-component sample (`public.sdk/samples/vst/again/source/againsimple.cpp`) drains it
  in `process()` while also implementing `setParamNormalized`. We do both, for the same reason.
- **The audio thread is the sole writer of the block the ENGINE reads.** Two `LiveParams`
  blocks: the model's publishers (editor commits, reload, `setState`) write `liveParams_` off
  the audio thread and may allocate on the way; `process()` merges that block with the host's
  automation points into `automationLive_`, which is what `SampleData::live` points at. Two
  blocks rather than one because the seqlock's single-writer contract is load-bearing and the two
  writers genuinely differ in thread.

### THE AUTHORITY MODEL — who may write a parameter's value, and until when

Two passes got this subtly wrong in opposite directions (the first delivered automation on the
wrong channel; the second made a point's authority permanent), because the model was in nobody's
head and nowhere in the tree. It is here, and the code follows it.

**`ReaSamplerProcessor::params_` — plus the two instance scalars beside it — is THE model, and
the single authority.** Everything else that holds these values is a cache or a courier:

| Writer | Authority begins | Authority ends |
|---|---|---|
| Editor gesture (`commitLive` / `commitAndReload`) | mouse-down | the commit lands in the model |
| Host controller write (`setParamNormalized`) | the call | the call returns (it writes the model) |
| State restore (`setState`) | the call | the call returns |
| Bake reset (`adoptBakedCapture`) | the call | the call returns |
| Limiter toggle (`setLimiterEnabled`) | the call | the call returns (it writes the model too, but through neither `commitLive` nor `commitAndReload`) |
| Reload seed (`reloadInstrument`) | never — it does not write `params_` | it only republishes a live block folded from whatever the model already holds |
| **Host automation point** (`IParameterChanges`) | the block it lands in | **the UI thread has folded it into the model and republished** |

Every writer above except the last two writes the model directly, so for those "authority ends"
is just "the write happened". Reload seed is not itself a model write — `reloadInstrument` never
touches `params_`; the only write in the tree is `setInstrumentParams`'s, `processor_state.cpp:193`
— which is why its row states no authority window of its own. The automation lane is the only one
that cannot write directly: the SDK delivers it on the audio thread, where the model path
allocates (`resolvePlay` copies velocity curves and spline contours). So it patches the
engine-facing block in place and is couriered to the UI thread, which folds it into the model on
the next tick.

**The hold is the bridge across that gap, and nothing more.** Between the point landing and the
fold — at most one UI tick — the model does not yet carry the value, so a model republish in that
window (any knob move) would revert the automated parameter until the lane's next point. The hold
re-applies the point over every merge to stop that. The instant the model carries the value, the
hold has no job and is **released**; from then on every writer above reaches the audio normally.

**Contention resolves BY RULE, not by timing.** A point outranks the model while the lane is
driving and the model has not caught up — which is VST3's own authority rule (a lane in
read/write mode outranks a plug-in-side set). It does NOT outrank a later restore, bake reset or
knob move, because by then the lane is no longer driving that value; the model is.

**Where it is enforced, and what fails if it stops holding.**
- The decision is the pure `core/instrument/param/param_merge`'s `mergeAutomation`;
  `tests/test_param_merge.cpp`'s
  `testAHeldPointOutranksTheModelOnlyUntilTheModelCarriesIt` is the test — it asserts both halves,
  including that a writer AFTER the release reaches the audio. A latch with no release fails it.
- The mechanism — the per-slot sequence the audio thread stamps and the UI thread answers, and
  the acquire/release ordering that makes a release imply the publish is visible — is
  `automation_channel.h`'s, at its two methods.
- **The release is stored LAST in `drainAutomationToModel`**, after `setInstrumentParams` and
  `publishLiveParams`. Moving it earlier reintroduces a one-block revert.
- **`setState` therefore needs no ordering guarantee against the host's first parameter block.**
  A lane that is driving re-applies over the restore; a lane that merely sent a point once, and
  had it folded, does not — which is the correct reading of the SDK rule, and the one the second
  pass got wrong.

**The editor's `params_` is a CACHE of the model, authoritative for one gesture only.** A commit
writes the WHOLE set back, and `notifyParamsFromModel` diffs it — so a stale copy would
`performEdit` superseded values the user never touched, which a lane in latch or write mode
records. The sync tick re-seeds it (past the drag guard) whenever `paramsGeneration_` has moved
under it: the automation fold, the host's generic panel, a state restore.

**Two independent gates keep a value-identical point off the per-voice fan-out**, and they cover
different windows: `AutomationChannel::land` drops a repeat of a standing hold whole (the flat
read-mode segment, where a host sends one point per block), and the merge publishes only when the
merged block differs from the last (a model republish that changed nothing). Neither is measured
against a performance budget — they are there because `VoiceEngine::applyLiveToActive` runs
`voice.applyLive` over every active voice, and neither case needs it.

- **The automation values fold back into the model on the UI thread** (`drainAutomationToModel`,
  called from `getState`, the editor's sync tick, and `instrument_bake.cpp:125` — at the HEAD of
  the bake chain, before the render, not its reload tail). The blob is authoritative, so a value
  that never came back would be lost on save. The fold is suppressed from notifying the host —
  the values came FROM it, and echoing them would let a lane in write mode re-record its own
  playback.
- **`IMidiMapping` is deliberately NOT implemented** — no conventional CC names most of what
  is exposed, an invented map would hijack CCs the user's controller already sends, and
  `[verify — DAW]` REAPER's own per-parameter MIDI learn is expected to cover the case without
  freezing anything. `IParameterFunctionName` and `IAutomationState` are assessed and not
  implemented — `bake/CLAUDE.md` owns the `IAutomationState` reasoning, at its one consequence
  site.

**Non-goals / guardrails.**
- The instrument never captures and never inserts into the arrange. Playback is a
  read-only act over the bank. Any instrument path that places a timeline item, or that
  writes bank state itself, is a bug. **The resample bake is not an exception to that
  and does not widen it:** the instrument RENDERS its own sound and REQUESTS a landing;
  the extension is what captures the file into the bank and writes the index. The
  instrument's whole outbound surface is one prefix-guarded request key plus one action
  id — see `instrument_bake` and `reaper_bridge` below.
- The instrument never ingests. Capture, import, and drop-ingest are *extension*
  acts; the instrument only reads and plays. A drop onto the editor window (if ever
  shipped) is relayed to the extension as an ingest request — the instrument never
  writes the bank itself.
- No cross-platform / multi-format. Windows-only, VST3-only, REAPER-only (D5). Do not
  add an AU/AAX/VST2/CLAP wrapper, a mac/Linux build, or a standalone host target.
- The pure core stays REAPER-free *and* VST3-free — the voice engine / envelope /
  repitch module takes no VST3 or REAPER type at its boundary; the shell
  marshals. Any VST3 or REAPER type leaking into `core/instrument` is a bug.
- Verify Steinberg SDK, bridge, embed, and LICE-view surfaces against the vendored
  headers before use.

## Modules

- `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. Both PROVE the write by reading the key back (`wire::extStateWriteLanded`) — `SetProjExtState`'s own return cannot speak for one key, so testing it was a guard that could never fire, and the bake's "could not publish" refusal was consequently unreachable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`.
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no cut to ringing tails. **Activation and decoding are separate lifetimes:** `setActive(false)` parks the decoded `SampleData` and destroys the voice state (a surviving `live_` would be displaced into the drain slot and resurrect stale sustained voices), and `setActive(true)` rebuilds the voices around the parked sample through that same swap — so a host-driven cycle costs no disk read and no decode. The resume still folds the live bank blob into the refs and republishes usage (a `GetProjExtState` plus a parse each, and with no editor open the activation is the only place either happens), and hands back to the full reload when that fold moved the loaded capture's decode source. Nothing parked means nothing was decoded, which routes the activation back through the full reload too; that is also where the pre-v10 legacy lift lives. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published as relaxed atomics — per-channel peak and smallest limiter gain ACCUMULATED across every block since the UI last read, plus the latched clip (the meter Gotcha below owns why). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. The host's `restartComponent(kLatencyChanged)` is issued by `flushLatencyRestart` alone, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. Why it is split from the commit is the Gotcha below.
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
- `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius.
- `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules at the instance's PERSISTED PREVIEW VELOCITY (the velocity the user has been auditioning at — three velocity curves are live, so it is a property of the sound and not a render detail), stage the WAV OUTSIDE the bank folder, publish one `rsbake_<guid>` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. What that key holds afterwards is classified by `core/wire`'s pure `classifyBakeAnswer`, and each of its five non-answers gets its OWN sentence — a silent no-answer stays a failure, but the user is told whether nothing wrote over the key, a stale generation was answered, the answer came in a wire this build cannot read, the request was cleared, or it was refused. All five name the key, because the extension prints one console line per key it scanned and the key is what correlates the two in a multi-instance session. None of them claims the landing never ran — nothing on this side can observe that. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate. A cloned `instanceGuid` (two instances sharing one `rsbake_` key) is NOT handled here — the residual is contained by pre-existing tracking machinery instead: `planUsagePublish`'s sticky `unioned` poison plus `tiedUsageExists` (`core/tracking/tracking_authority.cpp`) force a clone's bake to `AddDistinct` rather than silently replacing a sibling's entry.
- `instrument_params` — the VST3 adapter over `core/instrument/param`: one `Parameter` subclass whose `toPlain`/`toNormalized` ARE the taper and whose `toString` calls the one formatter, the single construction of the unit and parameter lists (ascending id, which is also the presentation order), the `setParamNormalized` projection onto the model through each control's existing commit tier, the audio thread's queue drain and the UI thread's fold + release, and the `beginEdit`/`performEdit`/`endEdit` notification path every internal writer reaches through `setInstrumentParams`. Decides nothing — the pure module owns the table, the laws, the formatter and the merge.
- `automation_channel.h` — the host automation lane's per-instance state and the mechanism of its authority lifetime: the audio thread's hold, the per-slot sequence it stamps, the UI thread's release answer, and the acquire/release ordering that makes a release imply the model publish is visible. The MODEL it enforces is the Authority section above; the pure decision it feeds is `core/instrument/param/param_merge`. Internal to this TU family.
- `processor_snapshot.h` — the two namespace-scope aggregates the processor hands across its thread boundary: `LoadedInstrument` (the decoded capture plus the engine playing it, swapped through the drain slot) and `MasterBusMeter` (what the audio thread publishes per block for the editor's meter). Split out of `reasampler_processor.h` on `editor_interaction.h`'s grounds — neither is behaviour.
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
- `editor_interaction.h` — the editor's INTERACTION VOCABULARY: `DragKind` (what a gesture in flight is editing) and `HoverKind`/`HoverTarget` (what the pointer can be over). Split out of `reasampler_editor.h`, which had grown past the ~600-line ceiling with no seam — these two catalogues are produced by the input TUs and read by the paint TUs, and neither is behaviour, which is what makes them a responsibility rather than a bisection. Namespace-scope, so the editor's own members still spell them unqualified. Internal to this TU family, like `editor_internal.h`.
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, the velocity-curve box derivation, and `dragModifiers()` — THE modifier read for every drag surface and gesture resolver, so the editor cannot grow a second modifier grammar — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here.
- `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)*

## Gotchas

- **`reasampler_processor.h` no longer needs a ceiling exception, and the one it had rested on a
  false premise.** It was described as ONE class declaration; it also carried two namespace-scope
  aggregates (`MasterBusMeter`, `LoadedInstrument`) and the automation lane's own state. Both are
  now split out — `processor_snapshot.h` and `automation_channel.h`, on the same grounds
  `editor_interaction.h` was split out of `reasampler_editor.h` in this directory: neither is
  behaviour. What remains is under the ceiling. Its bulk is the drain-slot proof and the
  RT-discipline constraints, which the comment conventions name as keep-worthy.

- **The bake click only ARMS; the editor's sync tick runs it.** Calling
  `Main_OnCommandEx` inline from `WM_LBUTTONDOWN` would run the extension's whole landing
  nested inside a mouse handler with `SetCapture` held, while the invoked action re-points
  the very instance whose frame is on the stack. Deferring by one tick is same-thread and
  in-instance — it is NOT a cross-process poller/nonce handshake, and it must not grow into
  one.
- **The limiter toggle splits its commit: the sound is inline, the HOST NOTIFICATION arms.**
  Its commit needs the host's `restartComponent(kLatencyChanged)`; a host that services that
  synchronously runs `setActive(false)`/`setActive(true)`, which rebuilds this instance's voice
  state — running that inline from `WM_LBUTTONDOWN` would nest it in a mouse handler. So the
  click commits the parameter set, the audio-thread mirror and the latency reader at once, and
  `setInstrumentParams` only ARMS a pending restart that `flushLatencyRestart` delivers. The
  editor's sync tick is the general drain and sits AFTER the drag guard with the bake (the
  restart rebuilds the instance, which mid-drag would yank the edit surface exactly as a reload
  would); `setState` flushes at its own tail because it can commit with no editor open, and the
  bake's adopt does so only to save a tick — its chain runs from that same tick. The arm is
  judged against the LAST ANNOUNCED enable, so toggling back to it inside one tick costs no
  restart at all.
  **The residual:** between the commit and the flush the host's delay compensation is out of
  step with the plugin by `limiterLookaheadSamples` (2 ms — `round(0.002 · rate)`, the
  detector's 4-sample group delay INSIDE that budget, not on top), bounded by one 500 ms tick.
  Narrowing it further means a second deferral mechanism (a posted window message) rather than
  the tick — deliberately not built.
- **The MASTER meter has its OWN 16 ms timer, beside the 500 ms sync poll.** It runs outside
  that poll's in-flight-drag guard on purpose — a drag suppresses the reload poll, but the bus
  keeps sounding. Elapsed time is measured (`steady_clock`, whose resolution the rate needs —
  `GetTickCount64`'s ~15.6 ms quantized a frame's delta to 0 or 16), never assumed from the
  timer's period, a zero delta skips the advance, and the tick repaints only when
  `meterDrawEqual` says the picture changed. **The published block state is ACCUMULATED, not
  sampled**: the processor folds a per-channel max and a min limiter gain across every block
  since the last read and `masterBusMeter()` clears the accumulators as it reads. A plain
  overwriting store displayed one block per window and lost the rest — the specified "a peak
  displays on the first UI frame after it occurs" is what the fold restores. `masterBusMeter()`
  is CONSUMING, so exactly one caller may hold it — which is why the meter block MOVED to the
  meter tick rather than being copied there; the embed strip reads its own non-consuming
  `embedActivityLevel()`. **The tick's FIRST read is discarded**, because that caller is the
  only consumer: with no editor open the accumulators hold everything since the instance was
  created, and advancing off them would open the meter at the session's loudest peak. The clip
  latch is not discarded with them — it is a latch the user clears.
- **A meter frame repaints the bar FIELD, not the client area**, which is what makes the rate
  affordable: `paint` honours `ps.rcPaint` against the field rect `paintDeck` cached, composes
  into a RETAINED back buffer (so the rest of the face is still there from the last full
  compose) and blits the dirty region alone. The fast path is chosen GEOMETRICALLY, never by a
  flag — Windows unions a meter invalidate with any other pending one into a single rcPaint, so
  a flag would paint a meter frame over a face that had really changed. What stays outside the
  field stays on the full path: the static numeral gutter (AA text re-blended onto itself every
  frame thickens) and the GR lamp (drawn straight onto the deck group's gradient, which a
  sub-rect fill cannot reproduce), so a lamp transition takes a whole-client repaint. **When the
  meter is covered or absent** (Browse, the curve popup, the empty state) `invalidateMeter`
  skips invalidating anything at all rather than falling back to a whole-client repaint — the
  ballistics still advance on `onMeterTimer`'s own clock, but nothing visible changed, so a
  60 FPS whole-client repaint under a modal sheet would be pure cost for zero pixels shown.
- The bake's availability probe runs on the SAME tick that paints the button, so the
  control can never be enabled on one tick and refuse on the next. The bake Hold control's
  applicability (`resolveBakeHoldNeeded`) rides the same tick for the same reason, and
  because answering it costs a bridge read + bank parse whenever no loop override is set —
  do not move either into the paint path.
- `editor_internal.h` is include-only — it has no TU of its own and must never become
  a public seam; only the `reasampler_editor` band-axis TUs include it.
- **The editor window class carries `CS_DBLCLKS`, which REPLACES the second button-down of
  a double-click** with `WM_?BUTTONDBLCLK`. Every surface that counts two downs — Browse's
  load accelerator, the spline surfaces' right-click delete — survives only because both
  DBLCLK handlers fall through to the ordinary down handler. Adding a new double-click
  consumer means preserving that fall-through, not bypassing it.
- The two VST3 class UIDs (`core/wire/reasampler_uid.h`, consumed via
  `reasampler_vst.h`) are FOREVER-FROZEN — never regenerate an already-shipped UID.
- The UID selection `#ifdef` in `reasampler_vst.h` is the one deliberate exception to
  "channel identity derives from `app_version` accessors, no scattered `#ifdef`s" —
  `INLINE_UID` needs literal brace-init tokens, so it can't route through a runtime
  string accessor.
