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
+3
View File
@@ -4,3 +4,6 @@
[submodule "vendor/WDL"] [submodule "vendor/WDL"]
path = vendor/WDL path = vendor/WDL
url = https://github.com/justinfrankel/WDL url = https://github.com/justinfrankel/WDL
[submodule "vendor/vst3sdk"]
path = vendor/vst3sdk
url = https://github.com/steinbergmedia/vst3sdk
+102
View File
@@ -584,6 +584,28 @@ add_executable(card_drag_tests tests/test_card_drag.cpp)
target_link_libraries(card_drag_tests PRIVATE card_drag) target_link_libraries(card_drag_tests PRIVATE card_drag)
add_test(NAME card_drag_tests COMMAND card_drag_tests) add_test(NAME card_drag_tests COMMAND card_drag_tests)
# ---------------------------------------------------------------------------
# 2i) Pure VST3-instrument helpers (Phase S1) — NO VST3, NO REAPER, NO SWELL/LICE.
# editor_geometry: the IPlugView LICE editor's rectangle layout + hit-test math
# (mirror of mode_switch/bank_grid). bridge_marshal: the REAPER VST-host bridge
# read marshalling — GetProjExtState result decode + a small JSON string-field
# reader (mirror of capture_paths/wav_trim). Both are unit-tested outside the DAW;
# the VST3 shell (src/vst/*) that draws/routes/invokes is DAW-verified.
# ---------------------------------------------------------------------------
add_library(editor_geometry STATIC src/vst/editor_geometry.cpp)
target_include_directories(editor_geometry PUBLIC src/vst)
add_library(bridge_marshal STATIC src/vst/bridge_marshal.cpp)
target_include_directories(bridge_marshal PUBLIC src/vst)
add_executable(editor_geometry_tests tests/test_editor_geometry.cpp)
target_link_libraries(editor_geometry_tests PRIVATE editor_geometry)
add_test(NAME editor_geometry_tests COMMAND editor_geometry_tests)
add_executable(bridge_marshal_tests tests/test_bridge_marshal.cpp)
target_link_libraries(bridge_marshal_tests PRIVATE bridge_marshal)
add_test(NAME bridge_marshal_tests COMMAND bridge_marshal_tests)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -671,3 +693,83 @@ else()
# php ${WDL_INC}/swell/mac_resgen.php src/resource.rc # php ${WDL_INC}/swell/mac_resgen.php src/resource.rc
# target_sources(reaper_reasampler PRIVATE src/resource.rc_mac_dlg.h) # generated # target_sources(reaper_reasampler PRIVATE src/resource.rc_mac_dlg.h) # generated
endif() endif()
# ===========================================================================
# 5) The ReaSampler VST3 instrument — the SECOND build artifact (Phase S1).
#
# Windows-only, VST3-only, REAPER-only (D5). A separate native VST3 plugin the user
# instantiates on an instrument track. Additive: the reaper_reasampler target above
# builds unchanged. This is the S1 opening spike — a silent-but-loading
# SingleComponentEffect skeleton, an IPlugView<->LICE editor, and the REAPER VST-host
# bridge read — not yet a sampler.
#
# ONE-TIME SDK SUBMODULE SETUP (see README / .gitmodules): the vst3sdk superproject is
# vendored pinned to tag v3.7.9_build_61; only three of its sub-submodules are needed
# (VSTGUI/examples/tests are NOT). After `git submodule update --init vendor/vst3sdk`:
# cd vendor/vst3sdk && git submodule update --init pluginterfaces base public.sdk
# ===========================================================================
if(WIN32)
set(VST3_SDK ${CMAKE_CURRENT_SOURCE_DIR}/vendor/vst3sdk)
# --- 5a) The bounded slice of the Steinberg VST3 SDK this spike needs. --------
# Enumerated (not add_subdirectory of the whole SDK) to keep the build hermetic and
# lean, matching the project's two-submodule discipline: no VSTGUI, no examples, no
# SDK-global CMake helpers/install machinery. Pinned to tag v3.7.9_build_61, so the
# list is fixed. If the SDK tag is bumped, re-verify this set.
add_library(vst3_sdk STATIC
# pluginterfaces/base — FUnknown, IIDs, string table, ustring.
${VST3_SDK}/pluginterfaces/base/funknown.cpp
${VST3_SDK}/pluginterfaces/base/coreiids.cpp
${VST3_SDK}/pluginterfaces/base/conststringtable.cpp
${VST3_SDK}/pluginterfaces/base/ustring.cpp
# base/source — FObject, strings, buffers, streamer, debug, IIDs, update handler.
${VST3_SDK}/base/source/fobject.cpp
${VST3_SDK}/base/source/fstring.cpp
${VST3_SDK}/base/source/fbuffer.cpp
${VST3_SDK}/base/source/fstreamer.cpp
${VST3_SDK}/base/source/fdebug.cpp
${VST3_SDK}/base/source/baseiids.cpp
${VST3_SDK}/base/source/updatehandler.cpp
${VST3_SDK}/base/thread/source/flock.cpp
# public.sdk/source/vst — the SingleComponentEffect base + its deps. NOTE:
# vstsinglecomponenteffect.cpp #includes vsteditcontroller.cpp (unity-style), so
# vsteditcontroller.cpp must NOT be listed separately (double definition).
${VST3_SDK}/public.sdk/source/vst/vstsinglecomponenteffect.cpp
${VST3_SDK}/public.sdk/source/vst/vstcomponentbase.cpp
${VST3_SDK}/public.sdk/source/vst/vstbus.cpp
${VST3_SDK}/public.sdk/source/vst/vstparameters.cpp
${VST3_SDK}/public.sdk/source/vst/vstinitiids.cpp
# public.sdk/source/common — CPluginView (IPlugView base) + IIDs.
${VST3_SDK}/public.sdk/source/common/pluginview.cpp
${VST3_SDK}/public.sdk/source/common/commoniids.cpp
# public.sdk/source/main — the class-factory (GetPluginFactory) support. NOTE:
# dllmain.cpp + moduleinit.cpp (which carry the InitDll/ExitDll dll exports) are
# compiled into the MODULE target directly, NOT here: their SMTG_EXPORT_SYMBOL
# functions have no internal referrer, so the linker strips them from a static
# lib. Compiling them into the module keeps the exports.
${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp
)
target_include_directories(vst3_sdk PUBLIC ${VST3_SDK})
# The SDK requires exactly one of RELEASE / DEVELOPMENT (fdebug.cpp keys off it).
target_compile_definitions(vst3_sdk PUBLIC $<IF:$<CONFIG:Debug>,DEVELOPMENT=1,RELEASE=1>)
# --- 5b) The VST3 module (loadable .vst3 DLL). -------------------------------
add_library(reasampler_vst MODULE
src/vst/vst_entry.cpp
src/vst/reasampler_processor.cpp
src/vst/reasampler_editor.cpp
src/vst/reaper_bridge.cpp
# SDK module entry — compiled into the module (not the static lib) so the
# InitDll/ExitDll dll exports survive the link (see vst3_sdk note above).
${VST3_SDK}/public.sdk/source/main/dllmain.cpp
${VST3_SDK}/public.sdk/source/main/moduleinit.cpp
${LICE_SRC}
)
target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal)
# SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge;
# WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC.
target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC})
# A .vst3 is a DLL with a .vst3 extension and no lib-prefix.
set_target_properties(reasampler_vst PROPERTIES PREFIX "" SUFFIX ".vst3"
OUTPUT_NAME "reasampler_vst")
endif()
+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
+116
View File
@@ -0,0 +1,116 @@
// Standalone tests for reasampler::vst::bridge_marshal — no VST3, no REAPER, no test
// framework. Same fast assert loop as the sibling pure tests: assert the REAPER
// bridge-read marshalling (GetProjExtState result decode + a small JSON string-field
// reader) directly, so the DAW-facing shell only has to invoke the API.
//
// Covers: decodeGetProjExtState hit/absent/zero-return/empty-buffer (the stale-buffer
// guard); extractJsonStringField present/absent/escapes/whitespace/value-vs-key
// disambiguation/non-string-value/malformed.
#include "../src/vst/bridge_marshal.h"
#include <cstdio>
using namespace reasampler::vst;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// --- decodeGetProjExtState ----------------------------------------------------
static void testDecodeHit() {
// REAPER reports a non-zero length and filled the buffer: that IS the value.
auto v = decodeGetProjExtState(5, "hello");
CHECK(v.has_value());
CHECK(v && *v == "hello");
}
static void testDecodeAbsentKey() {
// REAPER returns 0 for an absent key. Even if a caller passed a dirty buffer, the
// decoder must NOT surface it — the zero return means "no value".
auto v = decodeGetProjExtState(0, "stale-bytes-from-a-prior-read");
CHECK(!v.has_value());
}
static void testDecodeNegativeReturn() {
auto v = decodeGetProjExtState(-1, "whatever");
CHECK(!v.has_value());
}
static void testDecodeEmptyBuffer() {
// Positive return but empty buffer — treat as no value (defensive).
auto v = decodeGetProjExtState(3, "");
CHECK(!v.has_value());
}
// --- extractJsonStringField ---------------------------------------------------
static void testExtractPresent() {
const std::string json = R"({"guid":"ABC-123","name":"kick"})";
auto g = extractJsonStringField(json, "guid");
CHECK(g && *g == "ABC-123");
auto n = extractJsonStringField(json, "name");
CHECK(n && *n == "kick");
}
static void testExtractAbsent() {
const std::string json = R"({"guid":"ABC-123"})";
CHECK(!extractJsonStringField(json, "missing").has_value());
}
static void testExtractWhitespaceTolerant() {
const std::string json = "{ \"guid\" : \"X\" , \"n\":\"y\" }";
auto g = extractJsonStringField(json, "guid");
CHECK(g && *g == "X");
}
static void testExtractEscapes() {
// \" \\ \/ \n \t all decode.
const std::string json = R"({"path":"a\\b\/c\"d\ne"})";
auto p = extractJsonStringField(json, "path");
CHECK(p && *p == "a\\b/c\"d\ne");
}
static void testExtractValueContainingKeyText() {
// A VALUE that contains the key text must not be mistaken for the member. Here the
// first "guid" occurrence is inside another value; the real member comes later.
const std::string json = R"({"note":"the guid is here","guid":"REAL"})";
auto g = extractJsonStringField(json, "guid");
CHECK(g && *g == "REAL");
}
static void testExtractNonStringValue() {
// A numeric/object value is not a string — return nullopt rather than garbage.
const std::string json = R"({"count":42,"name":"ok"})";
CHECK(!extractJsonStringField(json, "count").has_value());
// The sibling string field still reads.
auto n = extractJsonStringField(json, "name");
CHECK(n && *n == "ok");
}
static void testExtractMalformed() {
CHECK(!extractJsonStringField(R"({"guid":"unterminated)", "guid").has_value());
CHECK(!extractJsonStringField(R"({"guid":)", "guid").has_value());
CHECK(!extractJsonStringField(R"({"guid")", "guid").has_value());
CHECK(!extractJsonStringField("", "guid").has_value());
// Dangling escape at end of string.
CHECK(!extractJsonStringField(R"({"guid":"abc\)", "guid").has_value());
}
int main() {
testDecodeHit();
testDecodeAbsentKey();
testDecodeNegativeReturn();
testDecodeEmptyBuffer();
testExtractPresent();
testExtractAbsent();
testExtractWhitespaceTolerant();
testExtractEscapes();
testExtractValueContainingKeyText();
testExtractNonStringValue();
testExtractMalformed();
if (g_fail == 0) std::printf("bridge_marshal: all tests passed\n");
return g_fail != 0;
}
+147
View File
@@ -0,0 +1,147 @@
// Standalone tests for reasampler::vst::editor_geometry — no VST3, no REAPER, no test
// framework. Same fast assert loop as the sibling pure tests (mode_switch et al.):
// assert the IPlugView LICE editor's layout math + hit-testing directly.
//
// Covers: contains() half-open convention + degenerate rects; layoutEditor regions on a
// normal view (title band + button + canvas), a tiny view (button clamped to canvas,
// never overhanging), and a zero view (all rects empty, no inversion); hitTest hitting
// the button, missing on the title/canvas, missing outside the surface, and boundary
// pixels; layout<->hit-test agreement (a click on the drawn button rect hits it).
#include "../src/vst/editor_geometry.h"
#include <cstdio>
using namespace reasampler::vst;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// --- contains() ---------------------------------------------------------------
static void testContainsHalfOpen() {
Rect r{10, 20, 50, 40}; // [10,50) x [20,40)
CHECK(contains(r, 10, 20)); // top-left inclusive
CHECK(contains(r, 49, 39)); // bottom-right exclusive edge, inside
CHECK(!contains(r, 50, 30)); // right edge excluded
CHECK(!contains(r, 30, 40)); // bottom edge excluded
CHECK(!contains(r, 9, 30)); // left of rect
CHECK(!contains(r, 30, 19)); // above rect
}
static void testContainsDegenerate() {
CHECK(!contains(Rect{10, 10, 10, 20}, 10, 15)); // zero width
CHECK(!contains(Rect{10, 10, 20, 10}, 15, 10)); // zero height
CHECK(!contains(Rect{20, 10, 10, 20}, 15, 15)); // inverted (right < left)
}
// --- layoutEditor: normal view ------------------------------------------------
static void testLayoutNormalView() {
// A comfortable 400x260 view: title band spans the top full width; canvas is the
// rest; button sits inside the canvas, inset by the margin.
const EditorLayout L = layoutEditor(400, 260);
CHECK(L.titleBar.left == 0 && L.titleBar.top == 0);
CHECK(L.titleBar.right == 400);
CHECK(L.titleBar.height() > 0 && L.titleBar.height() <= 260);
// Canvas begins right below the title bar and reaches the bottom-right.
CHECK(L.canvas.top == L.titleBar.bottom);
CHECK(L.canvas.right == 400 && L.canvas.bottom == 260);
// Button is inside the canvas (does not overhang any edge).
CHECK(L.button.left >= L.canvas.left);
CHECK(L.button.top >= L.canvas.top);
CHECK(L.button.right <= L.canvas.right);
CHECK(L.button.bottom <= L.canvas.bottom);
CHECK(L.button.width() > 0 && L.button.height() > 0);
}
// --- layoutEditor: tiny view (clamping) ---------------------------------------
static void testLayoutTinyViewClampsButton() {
// A view narrower/shorter than the button's natural size: the button must clamp to
// the canvas and never produce an inverted or overhanging rect.
const EditorLayout L = layoutEditor(40, 40);
CHECK(L.button.right <= L.canvas.right);
CHECK(L.button.bottom <= L.canvas.bottom);
CHECK(L.button.right >= L.button.left); // never inverted
CHECK(L.button.bottom >= L.button.top);
// Title bar clamps to the client height when the view is shorter than its height.
CHECK(L.titleBar.bottom <= 40);
}
// --- layoutEditor: zero view (all empty, no inversion) ------------------------
static void testLayoutZeroView() {
const EditorLayout L = layoutEditor(0, 0);
CHECK(L.titleBar.width() <= 0 || L.titleBar.height() <= 0);
CHECK(L.canvas.width() <= 0 || L.canvas.height() <= 0);
// No rect is inverted.
CHECK(L.button.right >= L.button.left);
CHECK(L.button.bottom >= L.button.top);
CHECK(L.canvas.right >= L.canvas.left);
CHECK(L.canvas.bottom >= L.canvas.top);
// A click anywhere on an empty layout hits nothing.
CHECK(hitTest(L, 0, 0) == HitTarget::kNone);
CHECK(hitTest(L, 5, 5) == HitTarget::kNone);
}
// --- hitTest ------------------------------------------------------------------
static void testHitTestButton() {
const EditorLayout L = layoutEditor(400, 260);
// Center of the button hits it.
const int cx = (L.button.left + L.button.right) / 2;
const int cy = (L.button.top + L.button.bottom) / 2;
CHECK(hitTest(L, cx, cy) == HitTarget::kButton);
}
static void testHitTestMissesNonButton() {
const EditorLayout L = layoutEditor(400, 260);
// Title bar is inert in the spike.
CHECK(hitTest(L, 200, L.titleBar.top + 1) == HitTarget::kNone);
// Empty canvas away from the button.
CHECK(hitTest(L, 380, 240) == HitTarget::kNone);
// Outside the surface entirely.
CHECK(hitTest(L, -5, -5) == HitTarget::kNone);
CHECK(hitTest(L, 500, 500) == HitTarget::kNone);
}
static void testHitTestButtonBoundary() {
const EditorLayout L = layoutEditor(400, 260);
// Top-left corner of the button is inclusive; the right/bottom edges are excluded.
CHECK(hitTest(L, L.button.left, L.button.top) == HitTarget::kButton);
CHECK(hitTest(L, L.button.right, L.button.top) == HitTarget::kNone);
CHECK(hitTest(L, L.button.left, L.button.bottom) == HitTarget::kNone);
}
// --- layout<->hit-test agreement ----------------------------------------------
// Every pixel inside the drawn button rect must hit the button; this is the
// load-bearing consistency invariant between what the shell draws and what it routes.
static void testHitTestMatchesDrawnButton() {
const EditorLayout L = layoutEditor(320, 200);
for (int y = L.button.top; y < L.button.bottom; ++y) {
for (int x = L.button.left; x < L.button.right; ++x) {
CHECK(hitTest(L, x, y) == HitTarget::kButton);
}
}
}
int main() {
testContainsHalfOpen();
testContainsDegenerate();
testLayoutNormalView();
testLayoutTinyViewClampsButton();
testLayoutZeroView();
testHitTestButton();
testHitTestMissesNonButton();
testHitTestButtonBoundary();
testHitTestMatchesDrawnButton();
if (g_fail == 0) std::printf("editor_geometry: all tests passed\n");
return g_fail != 0;
}
Vendored Submodule
+1
Submodule vendor/vst3sdk added at dfff2e399c