SetProjExtState's return describes the whole extname. The persist and the instrument's publish now re-read their key; both refusals can finally fire.
17 KiB
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, capture_browser, browser_scroll,
param_slider, trigger_seam, velocity_curve, embed_strip, knob_deck,
deck_groups, deck_values, bake_hold, curve_popup, spline_edit, master_gain, 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 #ifdefs 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 (oneDEF_CLASS2, one class per binary — never both classes in one binary). The UID selection is the ONLY channel#ifdefin the VST shell, because anINLINE_UIDneeds literal brace-init tokens and cannot route throughapp_version's runtime string accessors. - Binary + display identity are channel-derived, sourced from
app_version's VST-name accessors — never a literal inreasampler_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/FUIDfrompluginfactory.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 isLiveDeckParam / 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.
- Full reload —
reloadInstrument: bridge read, WAV re-decode, fresh engine, snapshot swap. - Engine rebuild —
rebuildVoiceEngine: same drain-slot swap around the already-decodedSampleData. Voice count / mode / mono trigger. - Live —
publishLiveParams(andmasterGain_, the original of the shape): a lock-free publish the audio thread observes at block boundaries. No rebuild, no snapshot, no disk.
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.
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_bakeandreaper_bridgebelow. - 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/instrumentis 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_) andwriteBakeExtState(rsbake_), each refusing every other key; neither weakens the read-only-bank invariant, because neither payload is bank state andbanks/view/tail/assignstay 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_OnCommandExwithgetReaperParent(3), the instance's OWN project tab, asproj— a request, not a DAW-verified guarantee; see the header) andprojectTempoBpm.reasampler_processor(shell/instrument/:reasampler_processor.cpplifecycle +process(),processor_state.cppcomponent-state I/O + UI-thread parameter accessors,processor_reload.cppthe off-audio-threadreloadInstrument/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) — VST3SingleComponentEffectshell: declares event-input bus + permanently stereo output (GA fix: dynamic mono↔stereo bus renegotiation deleted;ChannelModeis now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-threadreloadInstrument+ atomic pointer swap soprocess()does no allocation, no file I/O, no bridge calls. The instance state is{loaded capture id, one InstrumentParams}, andreloadInstrumentresolves + decodes exactly that one capture into theSampleDatathe engine plays. Self-contained playback (pS):ComponentStatev10 adds aSampleRefstable — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName);reloadInstrumentdecodes directly fromSampleRefs, 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-decodedSampleDatavia the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. FB1: applies the post-mixermasterGainLinear(fromComponentStatev8) 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:ComponentStatebumped v9→v10 (SampleRefstable); pre-v10 blobs lift to empty refs and re-save self-contained. pS-usage: publishes instance usage (heldSampleRefspaths) torsusage_<instanceGuid>at the tail ofreloadInstrument(off audio thread) viareaper_bridge::writeUsageExtState;ComponentStatebumped v10→v11 (instanceGuidfield); pre-v11 blobs mint guid on first publish.reasampler_editor— VST3IPlugViewLICE 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 puresample_bandsallocator:editor_session(session/bridge state, caches, commit-and-reload),editor_controls(the ONEfaceLayoutband 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 purecore/instrument/ui/deck_valuesmodule 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 (_browsefor the modal picker,_curvefor the velocity-curve popup) andeditor_platform(IPlugView/Win32 window plumbing). Shared internals ineditor_internal.h, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).reasampler_embed— implementsIReaperUIEmbedInterfaceso the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout toembed_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 purecore/ui/stroke_aaand 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 throughstrokeArcAA/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 touchshell/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 purecore/instrument/bakemodules 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 onersbake_<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 bycore/wire's pureclassifyBakeAnswer, 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 mirrorFxBypassGuard'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.bakeAvailableis the affordance's paint gate. A clonedinstanceGuid(two instances sharing onersbake_key) is NOT handled here — the residual is contained by pre-existing tracking machinery instead:planUsagePublish's stickyunionedpoison plustiedUsageExists(core/tracking/tracking_authority.cpp) force a clone's bake toAddDistinctrather than silently replacing a sibling's entry.vst_entry— VST3 entry point:GetPluginFactoryexport, class registration, channel-forked class UIDs.editor_internal.h— INTERNAL shared helpers for thereasampler_editorTU family, included only by the editor's own shell TUs (editor_session/editor_controls/editor_paint_*/editor_input_*/editor_platform), never a public seam: theRect↔kit adapters, small draw primitives (knob face / title band), label helpers, and the velocity-curve box derivation — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the puredeck_groupsmodule's, not this file's. The piano-strip and root-key draws live ineditor_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-selectedSteinberg::FUID, built from the FOREVER-FROZEN macros incore/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 readingsrc/shell/instrument/reasampler_vst.hdirectly.)
Gotchas
- The bake click only ARMS; the editor's sync tick runs it. Calling
Main_OnCommandExinline fromWM_LBUTTONDOWNwould run the extension's whole landing nested inside a mouse handler withSetCaptureheld, 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 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.his include-only — it has no TU of its own and must never become a public seam; only thereasampler_editorband-axis TUs include it.- The editor window class carries
CS_DBLCLKS, which REPLACES the second button-down of a double-click withWM_?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 viareasampler_vst.h) are FOREVER-FROZEN — never regenerate an already-shipped UID. - The UID selection
#ifdefinreasampler_vst.his the one deliberate exception to "channel identity derives fromapp_versionaccessors, no scattered#ifdefs" —INLINE_UIDneeds literal brace-init tokens, so it can't route through a runtime string accessor.