feat(vst): stand up VST3 instrument spike (S1) — skeleton, IPlugView↔LICE editor, REAPER bridge read

Vendor Steinberg VST3 SDK (v3.7.9_build_61); add reasampler_vst.vst3 as a second, additive build artifact with pure editor-geometry + bridge-marshal helpers under CTest.
This commit is contained in:
2026-07-26 15:29:54 -04:00
parent 5595ba42f9
commit 3c2a7c45b2
17 changed files with 1256 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
// bridge_marshal.cpp — see bridge_marshal.h. Pure; no host types.
#include "bridge_marshal.h"
namespace reasampler::vst {
std::optional<std::string> decodeGetProjExtState(int apiReturn,
const std::string& buffer) {
// REAPER returns the length of the stored value; 0 means the key is absent. Guard
// both the return AND the buffer: a caller that reused a dirty buffer must not
// surface stale bytes as a value when the API reported nothing.
if (apiReturn <= 0 || buffer.empty()) return std::nullopt;
return buffer;
}
std::optional<std::string> extractJsonStringField(const std::string& json,
const std::string& key) {
// Find the member token: "key" followed (after optional whitespace) by ':' then a
// quoted string. Scan for each candidate occurrence of the quoted key so a value
// that happens to contain the key text can't produce a false match.
const std::string needle = "\"" + key + "\"";
size_t searchFrom = 0;
while (true) {
const size_t keyPos = json.find(needle, searchFrom);
if (keyPos == std::string::npos) return std::nullopt;
size_t i = keyPos + needle.size();
searchFrom = i; // next candidate starts after this key token
// Skip whitespace to the ':'.
while (i < json.size() &&
(json[i] == ' ' || json[i] == '\t' || json[i] == '\n' ||
json[i] == '\r')) {
++i;
}
if (i >= json.size() || json[i] != ':') continue; // not a member — keep looking
++i;
// Skip whitespace to the value.
while (i < json.size() &&
(json[i] == ' ' || json[i] == '\t' || json[i] == '\n' ||
json[i] == '\r')) {
++i;
}
if (i >= json.size() || json[i] != '"') return std::nullopt; // value not a string
++i;
// Read the string body, honoring the common JSON escapes.
std::string out;
while (i < json.size()) {
const char c = json[i];
if (c == '\\') {
if (i + 1 >= json.size()) return std::nullopt; // dangling escape
const char e = json[i + 1];
switch (e) {
case '"': out.push_back('"'); break;
case '\\': out.push_back('\\'); break;
case '/': out.push_back('/'); break;
case 'n': out.push_back('\n'); break;
case 't': out.push_back('\t'); break;
case 'r': out.push_back('\r'); break;
case 'b': out.push_back('\b'); break;
case 'f': out.push_back('\f'); break;
default: out.push_back(e); break; // pass through unknown escapes
}
i += 2;
continue;
}
if (c == '"') return out; // closing quote — done
out.push_back(c);
++i;
}
return std::nullopt; // unterminated string
}
}
} // namespace reasampler::vst
+47
View File
@@ -0,0 +1,47 @@
// bridge_marshal.h — PURE marshalling helpers for the REAPER VST-host bridge read
// (Phase S1). NO VST3, NO REAPER types at the boundary.
//
// The bridge shell (reaper_bridge.cpp) resolves REAPER API functions by name over the
// host callback and invokes them; the fiddly-and-easy-to-get-wrong parts around those
// calls — interpreting GetProjExtState's int return, walking EnumProjExtState's
// index-until-false contract into a key set, and extracting a single value out of the
// "reasampler" bank JSON — are pure and unit-tested here. Mirror of capture_paths /
// wav_trim splitting the arithmetic out of a REAPER-facing shell.
//
// Verified against vendor/reaper-sdk/sdk/reaper_plugin_functions.h:
// int GetProjExtState (ReaProject*, extname, key, valOutNeedBig, valOutNeedBig_sz);
// -- returns the length written (0 when the key is absent).
// bool EnumProjExtState(ReaProject*, extname, idx, keyOut, keyOut_sz, valOut, valOut_sz);
// -- returns false when idx is past the last entry.
#pragma once
#include <optional>
#include <string>
namespace reasampler::vst {
// Interpret a GetProjExtState result: the int return value (bytes the API reports for
// the key) and the buffer it filled. Returns the value only when the API reported a
// non-empty result AND the buffer is non-empty — REAPER writes 0 and leaves the buffer
// untouched for an absent key, and we must not treat stale buffer contents as a hit.
//
// `apiReturn` is GetProjExtState's return; `buffer` is the NUL-terminated string it
// wrote (already truncated to the C string by the caller).
std::optional<std::string> decodeGetProjExtState(int apiReturn,
const std::string& buffer);
// Extract the string value for `key` out of a flat one-level JSON object — the shape
// persist.cpp writes under the "reasampler" ext-state (e.g. the bank blob's top-level
// fields). This is a deliberately small, dependency-free reader for the SPIKE's
// "read a known value" proof, NOT a general JSON parser: it finds "key" as an object
// member and returns its string value, handling the common escapes (\" \\ \n \t \/).
// Returns nullopt if the key is absent or its value is not a string.
//
// The real instrument (S4) will read the bank index through the shared bank_model JSON
// path, not this helper; this exists only to give the S1 bridge read a testable,
// REAPER-free decode step.
std::optional<std::string> extractJsonStringField(const std::string& json,
const std::string& key);
} // namespace reasampler::vst
+56
View File
@@ -0,0 +1,56 @@
// editor_geometry.cpp — see editor_geometry.h. Pure math; no host types.
#include "editor_geometry.h"
#include <algorithm>
namespace reasampler::vst {
namespace {
// Spike editor layout constants. These are the editor's fixed metrics; the real
// editor (S4/S5) will parameterize as its content demands.
constexpr int kTitleBarHeight = 28;
constexpr int kButtonMargin = 10;
constexpr int kButtonWidth = 120;
constexpr int kButtonHeight = 24;
} // namespace
bool contains(const Rect& r, int x, int y) {
if (r.width() <= 0 || r.height() <= 0) return false;
return x >= r.left && x < r.right && y >= r.top && y < r.bottom;
}
EditorLayout layoutEditor(int w, int h) {
// Clamp the surface to non-negative extents so a degenerate view can't produce
// inverted rects.
const int cw = std::max(0, w);
const int ch = std::max(0, h);
EditorLayout out;
// Title bar spans the top, clamped so it never exceeds the client height.
const int titleH = std::min(kTitleBarHeight, ch);
out.titleBar = Rect{0, 0, cw, titleH};
// Canvas is everything below the title bar.
out.canvas = Rect{0, titleH, cw, ch};
// Button sits at the top-left of the canvas, inset by a margin, and is clamped to
// fit inside the canvas so it never overhangs on a small view.
const int bx = out.canvas.left + kButtonMargin;
const int by = out.canvas.top + kButtonMargin;
const int bRight = std::min(bx + kButtonWidth, out.canvas.right);
const int bBottom = std::min(by + kButtonHeight, out.canvas.bottom);
out.button = Rect{bx, by, std::max(bx, bRight), std::max(by, bBottom)};
return out;
}
HitTarget hitTest(const EditorLayout& layout, int x, int y) {
if (contains(layout.button, x, y)) return HitTarget::kButton;
return HitTarget::kNone;
}
} // namespace reasampler::vst
+59
View File
@@ -0,0 +1,59 @@
// editor_geometry.h — PURE view geometry + hit-test for the VST3 IPlugView LICE
// editor (Phase S1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary.
//
// The IPlugView shell (reasampler_editor.cpp) owns the window/bitmap/SWELL plumbing
// and is DAW-verified; this module holds the fiddly rectangle math and hit-testing so
// it can be unit-tested outside the DAW — the mirror of how bank_grid / mode_switch /
// tab_strip split their layout math out of the panel shell.
//
// The spike's editor is deliberately trivial (a title band + one clickable button),
// enough to PROVE the host->draw/hit-test event routing works. As the real editor
// (S4/S5) grows, its layout math accretes here, not in the shell.
#pragma once
namespace reasampler::vst {
// A plain integer rectangle. left/top inclusive, right/bottom exclusive — the same
// half-open convention LICE/SWELL RECTs use, kept REAPER-free here.
struct Rect {
int left = 0;
int top = 0;
int right = 0;
int bottom = 0;
int width() const { return right - left; }
int height() const { return bottom - top; }
};
// Returns true if (x, y) falls inside r under the half-open convention
// (left <= x < right, top <= y < bottom). A zero-or-negative-area rect contains
// nothing.
bool contains(const Rect& r, int x, int y);
// The regions the spike editor draws, derived from the current view size. All are
// clamped to the client area so a degenerate (too-small) view never yields a region
// that spills outside the surface.
struct EditorLayout {
Rect titleBar; // top band: the plugin name + a live-state readout
Rect button; // a single clickable button (proves hit-test routing)
Rect canvas; // the remaining surface below the title bar
};
// Divide a (w x h) client area into the spike editor's regions. Pure: the same
// inputs always yield the same layout. Guards tiny sizes — every returned rect stays
// within [0,w] x [0,h], and the button never overhangs the canvas.
EditorLayout layoutEditor(int w, int h);
// The editor's hit-test targets. kNone means the point landed on inert surface.
enum class HitTarget {
kNone,
kButton,
};
// Classify a click at (x, y) against a layout. The button wins only when the point is
// inside the button rect; everything else (including the title bar and empty canvas)
// is kNone in the spike.
HitTarget hitTest(const EditorLayout& layout, int x, int y);
} // namespace reasampler::vst
+88
View File
@@ -0,0 +1,88 @@
// reaper_bridge.cpp — see reaper_bridge.h. The DAW-facing edge; keep it thin.
#include "reaper_bridge.h"
#include <vector>
#include "bridge_marshal.h"
// The VST3 base types must be included before REAPER's VST3 interface header, which
// uses FUnknown / CStringA / uint32 / DECLARE_CLASS_IID / PLUGIN_API from
// pluginterfaces/base — all in namespace Steinberg.
#include "pluginterfaces/base/funknown.h"
#include "pluginterfaces/base/ftypes.h"
// REAPER's VST3-side bridge interface (vendored). IReaperHostApplication is what REAPER
// passes (as an IHostApplication) to IComponent::initialize; it exposes getReaperApi
// (resolve-by-name) and getReaperParent (host context). The header uses UNQUALIFIED
// Steinberg types (FUnknown, CStringA, uint32, FUID, DECLARE_CLASS_IID, PLUGIN_API), so
// it must be pulled into the Steinberg namespace — the same way REAPER's own VST3
// examples include it.
namespace Steinberg {
#include "reaper_vst3_interfaces.h"
} // namespace Steinberg
// DECLARE_CLASS_IID in the REAPER header only DECLARES IReaperHostApplication::iid; some
// TU must DEFINE it. We do it here — this is the only place that queries for the
// interface (FUnknownPtr uses the iid), so the definition lives with its sole use.
DEF_CLASS_IID(Steinberg::IReaperHostApplication)
// The "reasampler" ext-state namespace + bank key. Kept in sync with persist.h by
// value (the extension writes them); we only READ here, so we duplicate the two string
// constants rather than pull the whole REAPER-facing persist.h into the VST artifact.
// If persist.h's kProjExtNamespace / kProjExtBanksKey ever change, these must follow —
// they are the shared wire contract between the extension (writer) and instrument
// (reader). VERIFY against persist.h.
namespace {
constexpr const char* kReasamplerNamespace = "reasampler";
}
namespace reasampler::vst {
bool ReaperBridge::connect(Steinberg::FUnknown* context) {
getProjExtState_ = nullptr;
enumProjExtState_ = nullptr;
hostApp_ = nullptr;
if (!context) return false;
// Query the host context for REAPER's bridge interface. In a non-REAPER host this
// query fails and we stay unconnected — the instrument still loads.
Steinberg::FUnknownPtr<Steinberg::IReaperHostApplication> reaper(context);
if (!reaper) return false;
hostApp_ = reaper.get();
// Resolve the ext-state functions by name. getReaperApi returns the same function
// pointers the extension resolves via rec->GetFunc; a null return means the symbol
// is unavailable (very old REAPER) — degrade gracefully.
getProjExtState_ = reinterpret_cast<GetProjExtStateFn>(
reaper->getReaperApi("GetProjExtState"));
enumProjExtState_ = reinterpret_cast<EnumProjExtStateFn>(
reaper->getReaperApi("EnumProjExtState"));
return getProjExtState_ != nullptr;
}
std::optional<std::string> ReaperBridge::readReasamplerExtState(const std::string& key) {
if (!getProjExtState_ || !hostApp_) return std::nullopt;
// Fetch the host project (getReaperParent(3) — project). Reads that live "reasampler"
// ext-state against the ACTIVE project the instrument was instantiated in, so it
// follows project switches for free (D6).
auto* reaper = static_cast<Steinberg::IReaperHostApplication*>(hostApp_);
void* proj = reaper->getReaperParent(3);
// A null project is legitimate (e.g. instantiated before a project context exists);
// REAPER treats null as the current project for these calls, so we pass it through
// rather than bailing — but if the read yields nothing the caller sees nullopt.
// GetProjExtState writes into a caller buffer; size it generously for a JSON blob
// and let the pure decoder interpret the result. The buffer is NUL-terminated by
// REAPER on success.
std::vector<char> buf(64 * 1024, '\0');
const int rv = getProjExtState_(proj, kReasamplerNamespace, key.c_str(), buf.data(),
static_cast<int>(buf.size()));
// Marshal the raw result through the pure decoder (handles the absent-key case).
return decodeGetProjExtState(rv, std::string(buf.data()));
}
} // namespace reasampler::vst
+63
View File
@@ -0,0 +1,63 @@
// reaper_bridge.h — the REAPER VST-host bridge (Phase S1 read spike). THIN shell:
// resolves REAPER API functions by name over the host context and reads the live
// "reasampler" project ext-state. The fiddly decode lives in bridge_marshal (pure).
//
// VERIFIED BRIDGE MECHANISM (corrects §1a's estimate). §1a described the VST2-style
// hostcb opcode pattern (hostcb(&effect, 0xdeadbeef, 0xdeadf00d, ...)). That is the
// VST2 path (video_processor.h documents it for a VST2 aEffect). For a VST3 plugin the
// bridge is exposed differently and more cleanly: REAPER passes an IHostApplication as
// the `context` to IComponent::initialize(FUnknown* context); querying it for
// IReaperHostApplication (vendor/reaper-sdk/sdk/reaper_vst3_interfaces.h) yields:
// * getReaperApi(funcname) -> resolve a REAPER API function pointer by name
// (the VST3 equivalent of opcode 0xdeadf00d), and
// * getReaperParent(3) -> the host ReaProject* (the VST3 equivalent of the
// 0xdeadf00e host-context fetch; 1=track, 2=take, 3=project, 4=fxdsp, 5=trackchan).
// So a VST3 uses IReaperHostApplication, not the raw hostcb opcodes. Verified against
// reaper_vst3_interfaces.h + reaper_plugin_functions.h at the spike.
#pragma once
#include <optional>
#include <string>
#include "pluginterfaces/base/funknown.h"
namespace reasampler::vst {
// Wraps the REAPER host bridge for a single plugin instance. Constructed cheaply;
// connect() must be called with the initialize() context before any read. All reads
// degrade to nullopt (never crash) when the host is not REAPER or a symbol is absent —
// the instrument must load in non-REAPER hosts too, just without live state.
class ReaperBridge {
public:
ReaperBridge() = default;
// Bind to the host. `context` is the FUnknown* REAPER hands IComponent::initialize.
// Returns true when the REAPER bridge is available (host is REAPER and the ext-state
// API resolved). Safe to call with a null or non-REAPER context — returns false.
bool connect(Steinberg::FUnknown* context);
// True once connect() found the REAPER host application AND resolved the ext-state
// functions.
bool isConnected() const { return getProjExtState_ != nullptr; }
// Read a "reasampler" ext-state value by key from the host's active project.
// Returns nullopt when unconnected, when the project can't be resolved, or when the
// key is absent. This is the S1 read-spike entry point.
std::optional<std::string> readReasamplerExtState(const std::string& key);
private:
// Resolved REAPER API function pointers (by name via getReaperApi). Signatures
// verified against reaper_plugin_functions.h.
using GetProjExtStateFn = int (*)(void* proj, const char* extname, const char* key,
char* valOutNeedBig, int valOutNeedBig_sz);
using EnumProjExtStateFn = bool (*)(void* proj, const char* extname, int idx,
char* keyOut, int keyOut_sz, char* valOut,
int valOut_sz);
void* hostApp_ = nullptr; // IReaperHostApplication* (opaque here; used in .cpp)
GetProjExtStateFn getProjExtState_ = nullptr;
EnumProjExtStateFn enumProjExtState_ = nullptr;
};
} // namespace reasampler::vst
+220
View File
@@ -0,0 +1,220 @@
// reasampler_editor.cpp — see reasampler_editor.h. The IPlugView<->LICE bridge.
// Windows-only (D5); the whole file is guarded so a non-Windows build (not a target,
// but keeps the TU honest) degrades to the CPluginView defaults.
#include "reasampler_editor.h"
#include <string>
#include "editor_geometry.h"
#include "reaper_bridge.h"
#ifdef _WIN32
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM
// LICE — the same drawing stack bank_panel uses. On Windows LICE routes through native
// GDI; no SWELL needed for the child window itself.
#include "wdltypes.h"
#include "lice/lice.h"
#endif
using namespace Steinberg;
namespace reasampler::vst {
namespace {
#ifdef _WIN32
constexpr const wchar_t* kChildClassName = L"ReaSamplerVstEditor";
// Palette — house style, mirrored from bank_panel's dark theme so the instrument reads
// as the same tool.
const LICE_pixel kColBackground = LICE_RGBA(28, 28, 30, 255);
const LICE_pixel kColTitleBg = LICE_RGBA(20, 20, 22, 255);
const LICE_pixel kColBtnBg = LICE_RGBA(44, 44, 48, 255);
const LICE_pixel kColBtnHitBg = LICE_RGBA(58, 96, 84, 255);
const LICE_pixel kColBtnBorder = LICE_RGBA(120, 200, 160, 255);
const COLORREF kRgbText = RGB(210, 230, 220);
void drawText(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col) {
// LICE has no built-in font handle here; use GDI text into the bitmap DC, matching
// bank_panel's drawCenteredText approach (SetTextColor + DrawText on getDC()).
HDC dc = bmp->getDC();
SetBkMode(dc, TRANSPARENT);
SetTextColor(dc, col);
RECT gr{r.left, r.top, r.right, r.bottom};
DrawTextA(dc, s, -1, &gr, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
}
#endif
} // namespace
ReaSamplerEditor::ReaSamplerEditor(ReaperBridge* bridge)
: CPluginView(nullptr), bridge_(bridge) {
// Default view size; the host may resize (canResize() == true).
ViewRect r(0, 0, 420, 260);
setRect(r);
}
ReaSamplerEditor::~ReaSamplerEditor() {
#ifdef _WIN32
// Defensive teardown: the host normally calls removed() (which destroys the child)
// before releasing us, but if we're destroyed while still attached, don't leak the
// window — mirror the create in attachedToParent().
if (childHwnd_) {
DestroyWindow(childHwnd_);
childHwnd_ = nullptr;
}
#endif
}
tresult PLUGIN_API ReaSamplerEditor::isPlatformTypeSupported(FIDString type) {
#ifdef _WIN32
if (type && std::string(type) == kPlatformTypeHWND) return kResultTrue;
#endif
return kResultFalse;
}
tresult PLUGIN_API ReaSamplerEditor::canResize() {
return kResultTrue;
}
#ifdef _WIN32
void ReaSamplerEditor::attachedToParent() {
// systemWindow is the parent HWND the host created (CPluginView::attached set it
// from the void* parent when the type is HWND).
HWND parent = static_cast<HWND>(systemWindow);
if (!parent) return;
HINSTANCE hInst = reinterpret_cast<HINSTANCE>(
GetWindowLongPtr(parent, GWLP_HINSTANCE));
if (!hInst) hInst = GetModuleHandle(nullptr);
// Register the child window class once per module.
static bool classRegistered = false;
if (!classRegistered) {
WNDCLASSW wc{};
wc.lpfnWndProc = &ReaSamplerEditor::wndProc;
wc.hInstance = hInst;
wc.lpszClassName = kChildClassName;
wc.hCursor = LoadCursor(nullptr, IDC_ARROW);
wc.style = CS_HREDRAW | CS_VREDRAW;
RegisterClassW(&wc);
classRegistered = true;
}
const ViewRect& r = getRect();
childHwnd_ = CreateWindowExW(
0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0, r.getWidth(),
r.getHeight(), parent, nullptr, hInst, nullptr);
if (childHwnd_) {
// Stash `this` so wndProc can route messages back to the instance.
SetWindowLongPtr(childHwnd_, GWLP_USERDATA,
reinterpret_cast<LONG_PTR>(this));
}
}
void ReaSamplerEditor::removedFromParent() {
if (childHwnd_) {
DestroyWindow(childHwnd_);
childHwnd_ = nullptr;
}
}
tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) {
// Let CPluginView latch the new rect, then resize the child window to match so the
// LICE surface fills the host-provided seat.
tresult res = CPluginView::onSize(newSize);
if (childHwnd_ && newSize) {
MoveWindow(childHwnd_, 0, 0, newSize->getWidth(), newSize->getHeight(), TRUE);
}
return res;
}
void ReaSamplerEditor::paint(HDC hdc) {
RECT cr{};
GetClientRect(childHwnd_, &cr);
const int w = cr.right - cr.left;
const int h = cr.bottom - cr.top;
if (w <= 0 || h <= 0) return;
// Same double-buffered LICE pattern as bank_panel::paintPanel: draw into a
// LICE_SysBitmap, then BitBlt to the window DC.
LICE_SysBitmap bmp(w, h);
LICE_Clear(&bmp, kColBackground);
const EditorLayout layout = layoutEditor(w, h);
// Title band.
LICE_FillRect(&bmp, layout.titleBar.left, layout.titleBar.top,
layout.titleBar.width(), layout.titleBar.height(), kColTitleBg, 1.0f,
0);
// Read a known live-state value over the bridge to prove the read spike. Show the
// raw ext-state presence (never the full blob) so the title reflects live project
// state without dumping JSON into the UI.
std::string title = "ReaSampler Instrument";
if (bridge_ && bridge_->isConnected()) {
auto banks = bridge_->readReasamplerExtState("banks");
title += banks ? " [bank: linked]" : " [bank: none]";
} else {
title += " [host: no bridge]";
}
Rect titleText{layout.titleBar.left + 8, layout.titleBar.top,
layout.titleBar.right - 8, layout.titleBar.bottom};
drawText(&bmp, titleText, title.c_str(), kRgbText);
// The clickable button — fill reflects the last hit-test (the routing proof).
LICE_FillRect(&bmp, layout.button.left, layout.button.top, layout.button.width(),
layout.button.height(), buttonHit_ ? kColBtnHitBg : kColBtnBg, 1.0f,
0);
LICE_DrawRect(&bmp, layout.button.left, layout.button.top, layout.button.width(),
layout.button.height(), kColBtnBorder, 1.0f, 0);
Rect btnText{layout.button.left + 8, layout.button.top, layout.button.right,
layout.button.bottom};
drawText(&bmp, btnText, buttonHit_ ? "clicked" : "click me", kRgbText);
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
}
void ReaSamplerEditor::onClick(int x, int y) {
RECT cr{};
GetClientRect(childHwnd_, &cr);
const EditorLayout layout = layoutEditor(cr.right - cr.left, cr.bottom - cr.top);
if (hitTest(layout, x, y) == HitTarget::kButton) {
buttonHit_ = !buttonHit_;
InvalidateRect(childHwnd_, nullptr, FALSE);
}
}
LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
LPARAM lParam) {
auto* self = reinterpret_cast<ReaSamplerEditor*>(
GetWindowLongPtr(hwnd, GWLP_USERDATA));
switch (msg) {
case WM_PAINT: {
PAINTSTRUCT ps{};
HDC hdc = BeginPaint(hwnd, &ps);
if (self) self->paint(hdc);
EndPaint(hwnd, &ps);
return 0;
}
case WM_LBUTTONDOWN:
if (self) self->onClick(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
return 0;
case WM_ERASEBKGND:
return 1; // we fully repaint in WM_PAINT; skip the flicker-inducing erase
default:
return DefWindowProcW(hwnd, msg, wParam, lParam);
}
}
#else // non-Windows: not a build target (D5), but keep the TU compilable.
void ReaSamplerEditor::attachedToParent() {}
void ReaSamplerEditor::removedFromParent() {}
tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) {
return CPluginView::onSize(newSize);
}
#endif // _WIN32
} // namespace reasampler::vst
+65
View File
@@ -0,0 +1,65 @@
// reasampler_editor.h — the VST3 IPlugView LICE editor (Phase S1 bridge spike). THIN
// shell: hosts a LICE-drawn child window inside the host's IPlugView seat and routes
// host paint/click into the pure editor_geometry hit-test. Windows-only (D5).
//
// This is the phase's ONE genuine unknown (per CONTEXT.md §Phase S / S1): wiring LICE
// into an IPlugView. It reuses the bank_panel LICE/SWELL competence — a LICE_SysBitmap
// blitted in WM_PAINT, GET_X/Y_LPARAM hit-testing in WM_LBUTTONDOWN — but hangs it off a
// child HWND created in IPlugView::attached() rather than a docked SWELL dialog.
//
// Subclasses CPluginView (public.sdk/source/common/pluginview.h) for the IPlugView
// refcount + attached/removed/getSize/onSize boilerplate; we override the attach/remove
// hooks to create/destroy the child window and onSize to resize it.
#pragma once
#include "public.sdk/source/common/pluginview.h"
#ifdef _WIN32
#include <windows.h>
#endif
namespace reasampler::vst {
class ReaperBridge;
class ReaSamplerEditor : public Steinberg::CPluginView {
public:
// `bridge` is owned by the processor and outlives the editor; the editor reads
// (never mutates) it to show a live-state readout. May be null (non-REAPER host).
explicit ReaSamplerEditor(ReaperBridge* bridge);
~ReaSamplerEditor() override;
// Accept only the Windows HWND platform type (D5: Windows-only).
Steinberg::tresult PLUGIN_API isPlatformTypeSupported(
Steinberg::FIDString type) override;
// The view is user-resizable in the spike so we exercise the onSize path.
Steinberg::tresult PLUGIN_API canResize() override;
protected:
// CPluginView hooks: systemWindow is set by the time attachedToParent() fires.
void attachedToParent() override;
void removedFromParent() override;
Steinberg::tresult PLUGIN_API onSize(Steinberg::ViewRect* newSize) override;
private:
#ifdef _WIN32
// Draw the current surface into the child window's DC via a LICE bitmap.
void paint(HDC hdc);
// Route a client-space click through editor_geometry::hitTest.
void onClick(int x, int y);
static LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);
HWND childHwnd_ = nullptr;
#endif
ReaperBridge* bridge_ = nullptr;
// Latched on click so the paint reflects the last hit-test result — the spike's
// proof that host->click->draw routing round-trips.
bool buttonHit_ = false;
};
} // namespace reasampler::vst
+82
View File
@@ -0,0 +1,82 @@
// reasampler_processor.cpp — see reasampler_processor.h.
#include "reasampler_processor.h"
#include "pluginterfaces/vst/ivstaudioprocessor.h"
#include "pluginterfaces/vst/vstspeaker.h"
#include "reasampler_editor.h"
using namespace Steinberg;
using namespace Steinberg::Vst;
namespace reasampler::vst {
FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) {
// The host owns the returned reference. Cast up to the combined interface the SDK
// exposes (IAudioProcessor) so the FUnknown refcount is correctly rooted.
return static_cast<IAudioProcessor*>(new ReaSamplerProcessor());
}
tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) {
tresult result = SingleComponentEffect::initialize(context);
if (result != kResultOk) return result;
// Connect the REAPER bridge. Non-fatal if it fails (non-REAPER host): the
// instrument still loads, the editor just shows "no bridge".
bridge_.connect(context);
// Instrument bus topology: one event input (MIDI in, 16 channels), one stereo audio
// output, no audio input. This is the standard VSTi arrangement.
addEventInput(STR16("MIDI In"), 16);
addAudioOutput(STR16("Stereo Out"), SpeakerArr::kStereo);
return kResultOk;
}
tresult PLUGIN_API ReaSamplerProcessor::terminate() {
return SingleComponentEffect::terminate();
}
tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool /*state*/) {
// Nothing to allocate/free in the silent skeleton; S4 will size voice buffers here
// against the setupProcessing block size.
return kResultOk;
}
tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) {
return SingleComponentEffect::setupProcessing(setup);
}
tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
// Silent skeleton: emit silence on the output bus so the instrument runs cleanly in
// REAPER's render/record path without a null buffer. S4 marshals MIDI->core->audio.
if (data.numOutputs > 0 && data.outputs && data.numSamples > 0) {
AudioBusBuffers& out = data.outputs[0];
for (int32 ch = 0; ch < out.numChannels; ++ch) {
if (data.symbolicSampleSize == kSample32) {
if (float* buf = out.channelBuffers32[ch]) {
for (int32 i = 0; i < data.numSamples; ++i) buf[i] = 0.f;
}
} else if (data.symbolicSampleSize == kSample64) {
if (double* buf = out.channelBuffers64[ch]) {
for (int32 i = 0; i < data.numSamples; ++i) buf[i] = 0.0;
}
}
}
// Flag output silence so the host can optimize (nothing plays yet).
out.silenceFlags = (out.numChannels >= 64)
? ~0ULL
: ((1ULL << out.numChannels) - 1);
}
return kResultOk;
}
IPlugView* PLUGIN_API ReaSamplerProcessor::createView(FIDString name) {
if (name && FIDStringsEqual(name, ViewType::kEditor)) {
return new ReaSamplerEditor(&bridge_);
}
return nullptr;
}
} // namespace reasampler::vst
+48
View File
@@ -0,0 +1,48 @@
// reasampler_processor.h — the VST3 SingleComponentEffect skeleton (Phase S1). THIN
// shell: an instrument that declares an event-input bus + a stereo audio-output bus,
// sets up processing, and runs an empty (silent) process. Nothing plays yet — S4 wires
// the pure sampler core into process(); S1 only proves REAPER hosts it.
//
// SingleComponentEffect is the SDK's combined processor+controller base — sanctioned
// for a non-distributable, REAPER-only plugin under D5/D6 (verified: SDK class
// reference). It gives us addAudioOutput/addEventInput and the IEditController seat, so
// createView() can hand the host our IPlugView LICE editor.
#pragma once
#include "public.sdk/source/vst/vstsinglecomponenteffect.h"
#include "reaper_bridge.h"
namespace reasampler::vst {
class ReaSamplerProcessor : public Steinberg::Vst::SingleComponentEffect {
public:
ReaSamplerProcessor() = default;
// The factory create function (registered in vst_entry.cpp).
static Steinberg::FUnknown* createInstance(void* /*context*/);
//--- from IComponent / IPluginBase -------------------------------------
// Connects the REAPER bridge (context is REAPER's IHostApplication) and declares
// the instrument bus topology.
Steinberg::tresult PLUGIN_API initialize(Steinberg::FUnknown* context) override;
Steinberg::tresult PLUGIN_API terminate() override;
Steinberg::tresult PLUGIN_API setActive(Steinberg::TBool state) override;
//--- from IAudioProcessor ----------------------------------------------
Steinberg::tresult PLUGIN_API setupProcessing(
Steinberg::Vst::ProcessSetup& setup) override;
// Empty in the spike: emits silence (S4 fills it).
Steinberg::tresult PLUGIN_API process(
Steinberg::Vst::ProcessData& data) override;
//--- from IEditController -----------------------------------------------
// Hands the host our LICE IPlugView editor.
Steinberg::IPlugView* PLUGIN_API createView(Steinberg::FIDString name) override;
private:
ReaperBridge bridge_;
};
} // namespace reasampler::vst
+36
View File
@@ -0,0 +1,36 @@
// reasampler_vst.h — shared identity constants for the ReaSampler VST3 instrument
// (Phase S). One place for the plugin's class UID, name, vendor, and version so the
// processor, factory, and editor agree.
//
// The class UID is FOREVER-STABLE once shipped: a REAPER project that instantiates this
// instrument records the UID, so changing it orphans every saved instance. Minted once
// for the spike; do not regenerate.
#pragma once
#include "pluginterfaces/base/funknown.h"
namespace reasampler::vst {
// Human-facing identity. "ReaSampler" is the tool; the instrument surfaces as
// "ReaSampler Instrument" in REAPER's FX browser to distinguish it from the extension.
inline constexpr const char* kPluginName = "ReaSampler Instrument";
inline constexpr const char* kVendorName = "ReaSampler";
inline constexpr const char* kVendorUrl = "https://github.com/daniel-c-harvey/reasampler";
inline constexpr const char* kVendorEmail = "mailto:the.real.daniel.harvey@gmail.com";
// The processor class UID (the SingleComponentEffect). FOREVER-STABLE once shipped —
// a saved REAPER project records it, so changing it orphans every saved instance.
// Minted once for the S1 spike (2026-07-26). Defined as four longs so the factory's
// INLINE_UID (compile-time brace init) and the runtime FUID below share one source.
#define REASAMPLER_PROC_UID_1 0x5E45A11E
#define REASAMPLER_PROC_UID_2 0x9C7B4D6A
#define REASAMPLER_PROC_UID_3 0xB1E3F208
#define REASAMPLER_PROC_UID_4 0x4A6C1D9F
static const Steinberg::FUID kReaSamplerProcessorUID(REASAMPLER_PROC_UID_1,
REASAMPLER_PROC_UID_2,
REASAMPLER_PROC_UID_3,
REASAMPLER_PROC_UID_4);
} // namespace reasampler::vst
+45
View File
@@ -0,0 +1,45 @@
// vst_entry.cpp — the VST3 module class factory (Phase S1). Enumerates the one class
// this module offers (the ReaSampler instrument) via the SDK's factory macros. The
// Windows module exports — GetPluginFactory (here, via BEGIN_FACTORY) and
// InitDll/ExitDll (from the SDK's dllmain.cpp) — are how REAPER discovers and loads a
// VST3.
//
// VERIFIED (corrects §1a's "experienced estimate" flags on export names + macros,
// against vendor/vst3sdk/public.sdk/source/main/):
// * Windows exports: InitDll / ExitDll (SMTG_EXPORT_SYMBOL, in dllmain.cpp) +
// GetPluginFactory (SMTG_EXPORT_SYMBOL IPluginFactory* PLUGIN_API, emitted by the
// BEGIN_FACTORY macro). The plug-in must provide InitModule/DeinitModule — supplied
// here by linking moduleinit.cpp (the SDK's default one-time init/term).
// * Factory macros: BEGIN_FACTORY(vendor,url,email,flags) / DEF_CLASS2(...) /
// END_FACTORY — exact spellings from pluginfactory.h.
// * Instrument subcategory string: "Instrument|Synth|Sampler"
// (PlugType::kInstrumentSynthSampler, ivstaudioprocessor.h).
// * classFlags = 0 for a SingleComponentEffect (non-distributable), matching the
// AGain example.
#include "public.sdk/source/main/pluginfactory.h"
#include "pluginterfaces/vst/ivstaudioprocessor.h" // kVstAudioEffectClass, PlugType
#include "reasampler_processor.h"
#include "reasampler_vst.h"
// A concrete version string for PClassInfo2. Phase V owns the real version scheme; the
// spike ships a fixed 0.1.0.
#define REASAMPLER_VST_VERSION "0.1.0.0"
BEGIN_FACTORY(reasampler::vst::kVendorName, reasampler::vst::kVendorUrl,
reasampler::vst::kVendorEmail, Steinberg::PFactoryInfo::kNoFlags)
DEF_CLASS2(INLINE_UID(REASAMPLER_PROC_UID_1, REASAMPLER_PROC_UID_2,
REASAMPLER_PROC_UID_3, REASAMPLER_PROC_UID_4),
Steinberg::PClassInfo::kManyInstances, // cardinality
kVstAudioEffectClass, // component category (fixed)
reasampler::vst::kPluginName, // plug-in display name
0, // single-component => 0
Steinberg::Vst::PlugType::kInstrumentSynthSampler, // subcategory
REASAMPLER_VST_VERSION, // plug-in version
kVstVersionString, // VST3 SDK version (fixed)
reasampler::vst::ReaSamplerProcessor::createInstance)
END_FACTORY