diff --git a/.gitmodules b/.gitmodules index 97b3864..25ffe5c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "vendor/WDL"] path = vendor/WDL url = https://github.com/justinfrankel/WDL +[submodule "vendor/vst3sdk"] + path = vendor/vst3sdk + url = https://github.com/steinbergmedia/vst3sdk diff --git a/CMakeLists.txt b/CMakeLists.txt index 8392ba8..3025dde 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -584,6 +584,28 @@ add_executable(card_drag_tests tests/test_card_drag.cpp) target_link_libraries(card_drag_tests PRIVATE card_drag) 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). # --------------------------------------------------------------------------- @@ -671,3 +693,83 @@ else() # php ${WDL_INC}/swell/mac_resgen.php src/resource.rc # target_sources(reaper_reasampler PRIVATE src/resource.rc_mac_dlg.h) # generated 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 $,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() diff --git a/src/vst/bridge_marshal.cpp b/src/vst/bridge_marshal.cpp new file mode 100644 index 0000000..7faf94b --- /dev/null +++ b/src/vst/bridge_marshal.cpp @@ -0,0 +1,78 @@ +// bridge_marshal.cpp — see bridge_marshal.h. Pure; no host types. + +#include "bridge_marshal.h" + +namespace reasampler::vst { + +std::optional 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 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 diff --git a/src/vst/bridge_marshal.h b/src/vst/bridge_marshal.h new file mode 100644 index 0000000..0c55b63 --- /dev/null +++ b/src/vst/bridge_marshal.h @@ -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 +#include + +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 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 extractJsonStringField(const std::string& json, + const std::string& key); + +} // namespace reasampler::vst diff --git a/src/vst/editor_geometry.cpp b/src/vst/editor_geometry.cpp new file mode 100644 index 0000000..d5babe1 --- /dev/null +++ b/src/vst/editor_geometry.cpp @@ -0,0 +1,56 @@ +// editor_geometry.cpp — see editor_geometry.h. Pure math; no host types. + +#include "editor_geometry.h" + +#include + +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 diff --git a/src/vst/editor_geometry.h b/src/vst/editor_geometry.h new file mode 100644 index 0000000..b6bb5b1 --- /dev/null +++ b/src/vst/editor_geometry.h @@ -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 diff --git a/src/vst/reaper_bridge.cpp b/src/vst/reaper_bridge.cpp new file mode 100644 index 0000000..8096a92 --- /dev/null +++ b/src/vst/reaper_bridge.cpp @@ -0,0 +1,88 @@ +// reaper_bridge.cpp — see reaper_bridge.h. The DAW-facing edge; keep it thin. + +#include "reaper_bridge.h" + +#include + +#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 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( + reaper->getReaperApi("GetProjExtState")); + enumProjExtState_ = reinterpret_cast( + reaper->getReaperApi("EnumProjExtState")); + + return getProjExtState_ != nullptr; +} + +std::optional 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(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 buf(64 * 1024, '\0'); + const int rv = getProjExtState_(proj, kReasamplerNamespace, key.c_str(), buf.data(), + static_cast(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 diff --git a/src/vst/reaper_bridge.h b/src/vst/reaper_bridge.h new file mode 100644 index 0000000..8eea7dc --- /dev/null +++ b/src/vst/reaper_bridge.h @@ -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 +#include + +#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 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 diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp new file mode 100644 index 0000000..0e68a69 --- /dev/null +++ b/src/vst/reasampler_editor.cpp @@ -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 + +#include "editor_geometry.h" +#include "reaper_bridge.h" + +#ifdef _WIN32 +#include // 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(systemWindow); + if (!parent) return; + + HINSTANCE hInst = reinterpret_cast( + 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(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( + 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 diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h new file mode 100644 index 0000000..45c4a60 --- /dev/null +++ b/src/vst/reasampler_editor.h @@ -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 +#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 diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp new file mode 100644 index 0000000..565ee10 --- /dev/null +++ b/src/vst/reasampler_processor.cpp @@ -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(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 diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h new file mode 100644 index 0000000..c189ec5 --- /dev/null +++ b/src/vst/reasampler_processor.h @@ -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 diff --git a/src/vst/reasampler_vst.h b/src/vst/reasampler_vst.h new file mode 100644 index 0000000..120311b --- /dev/null +++ b/src/vst/reasampler_vst.h @@ -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 diff --git a/src/vst/vst_entry.cpp b/src/vst/vst_entry.cpp new file mode 100644 index 0000000..d24ce1a --- /dev/null +++ b/src/vst/vst_entry.cpp @@ -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 diff --git a/tests/test_bridge_marshal.cpp b/tests/test_bridge_marshal.cpp new file mode 100644 index 0000000..e3beb6d --- /dev/null +++ b/tests/test_bridge_marshal.cpp @@ -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 + +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; +} diff --git a/tests/test_editor_geometry.cpp b/tests/test_editor_geometry.cpp new file mode 100644 index 0000000..2a18a80 --- /dev/null +++ b/tests/test_editor_geometry.cpp @@ -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 + +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; +} diff --git a/vendor/vst3sdk b/vendor/vst3sdk new file mode 160000 index 0000000..dfff2e3 --- /dev/null +++ b/vendor/vst3sdk @@ -0,0 +1 @@ +Subproject commit dfff2e399c1a638bd7f5e334440a61e7262eed3f