Merge pS-ga-dropfx: inject dropped capture via .vstpreset/TrackFX_SetPreset (fixes blank-on-drop); prefix FX hotspot; UTF-8 temp path

This commit is contained in:
2026-07-28 06:59:41 -04:00
11 changed files with 547 additions and 344 deletions
+3 -3
View File
@@ -63,7 +63,7 @@
#include "footer_bar.h" // pure footer LEFT-group layout: toggle + count + Tail button (L4)
#include "guid_diff.h" // GuidBaseline — new-content detection (D2 Wave 2)
#include "ingest.h" // ingestDroppedFiles — S8 drop-onto-panel ingest
#include "instrument_drop.h" // pure buildInstrumentDropChunk — the vst_chunk blob (S17)
#include "instrument_drop.h" // pure buildInstrumentDropPreset — the .vstpreset payload (S17)
#include "instrument_drop_win.h" // resolveFxDropTarget / performInstrumentDrop shell (S17)
#include "item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B)
#include "lane_keys.h" // managed/manual lane heuristic (D2 Wave 2)
@@ -3096,8 +3096,8 @@ void onLBtnUp(int x, int y) {
// gesture never armed for a multi payload), so dragSampleIds.front() is the capture.
if (g_panel.instrumentDropTrack && g_panel.dragSampleIds.size() == 1) {
const std::string sampleId = g_panel.dragSampleIds.front();
const std::string chunk = buildInstrumentDropChunk(sampleId);
performInstrumentDrop(g_panel.instrumentDropTrack, chunk);
performInstrumentDrop(g_panel.instrumentDropTrack,
buildInstrumentDropPreset(sampleId));
// Read-only over the bank + arrange: the ONLY mutations are the new FX instance +
// its state (both undoable in performInstrumentDrop). No book change, no ext-state,
// no dirty-mark here.
+7 -7
View File
@@ -22,8 +22,8 @@
#include "bank_model.h" // Sample, AddResult, findByHash
#include "bank_panel.h" // bankPanelRefresh
#include "capture_paths.h" // deriveBankPaths / projectDirOfRpp / hashWavContent
#include "instrument_drop.h" // pure buildInstrumentDropChunk (vst_chunk blob for a sampleId)
#include "instrument_drop_win.h" // shell loadInstrumentOntoTrack (FX add+inject, no own undo block)
#include "instrument_drop.h" // pure buildInstrumentDropPreset (.vstpreset image for a sampleId)
#include "instrument_drop_win.h" // shell loadInstrumentOntoTrack (FX add+apply, no own undo block)
#include "persist.h" // ReaSamplerSession
#include "wav_trim.h" // parseWavLayout — 32f-float WAV validator for the fast path
@@ -494,10 +494,10 @@ void doImportFromMediaExplorer() {
return;
}
// Build the pre-loaded instrument blob for the resolved sampleId. Valid for BOTH the fresh
// import and the dedup case (added == false but a real sampleId) — the user asked for a
// player, and a valid sampleId is sufficient to pre-select the sound.
const std::string chunk = buildInstrumentDropChunk(r.sampleId);
// Build the pre-loaded instrument payload (a .vstpreset image) for the resolved sampleId.
// Valid for BOTH the fresh import and the dedup case (added == false but a real sampleId)
// — the user asked for a player, and a valid sampleId is sufficient to pre-select the sound.
const std::vector<std::uint8_t> preset = buildInstrumentDropPreset(r.sampleId);
// One undo point for the whole gesture. Persist happens INSIDE the block and BEFORE the
// FX add so the new instance's setState -> reloadFromBank sees the just-persisted sample.
@@ -515,7 +515,7 @@ void doImportFromMediaExplorer() {
g_session->bumpBankGeneration();
persisted = g_session->saveToActiveProject();
}
const bool placed = loadInstrumentOntoTrack(target, chunk);
const bool placed = loadInstrumentOntoTrack(target, preset);
if (placed && persisted)
Undo_EndBlock2(nullptr, "ReaSampler: import from Media Explorer into selected track",
UNDO_STATE_MISCCFG);
+76 -88
View File
@@ -1,29 +1,79 @@
// instrument_drop — pure implementation. See instrument_drop.h.
// NO REAPER / SWELL / VST3 SDK / vendor. Reuses sample_map's ComponentState serializer.
// NO REAPER / SWELL / VST3 SDK / vendor. Reuses sample_map's ComponentState serializer and
// the SDK-free UID macros (vst/reasampler_uid.h).
#include "instrument_drop.h"
#include "vst/sample_map.h" // ComponentState + serializeComponentState (the SHARED writer)
#include <cstdio>
#include "vst/reasampler_uid.h" // REASAMPLER_ACTIVE_UID_* — the FROZEN, channel-selected class UID
#include "vst/sample_map.h" // ComponentState + serializeComponentState (the SHARED writer)
namespace reasampler {
namespace {
constexpr char kB64Alphabet[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
// Little-endian appenders — the .vstpreset container stores its integers little-endian on
// disk (public.sdk vstpresetfile.cpp swaps only on big-endian hosts).
void appendU32LE(std::vector<std::uint8_t>& out, std::uint32_t v) {
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
}
// -1 = not a base64 char; index by unsigned byte. Built once.
int b64Value(unsigned char c) {
if (c >= 'A' && c <= 'Z') return c - 'A';
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
if (c >= '0' && c <= '9') return c - '0' + 52;
if (c == '+') return 62;
if (c == '/') return 63;
return -1;
void appendU64LE(std::vector<std::uint8_t>& out, std::uint64_t v) {
for (int i = 0; i < 8; ++i)
out.push_back(static_cast<std::uint8_t>((v >> (8 * i)) & 0xFF));
}
void appendFourCC(std::vector<std::uint8_t>& out, const char id[4]) {
out.insert(out.end(), id, id + 4);
}
} // namespace
std::string vstClassIdHex() {
// FUID::toString reduces to the four INLINE_UID words as "%08X" in order on BOTH byte
// layouts (see header contract), so rendering the macros directly is the platform-stable
// derivation of the string the .vstpreset header must carry.
char buf[33];
std::snprintf(buf, sizeof(buf), "%08X%08X%08X%08X",
static_cast<unsigned>(REASAMPLER_ACTIVE_UID_1),
static_cast<unsigned>(REASAMPLER_ACTIVE_UID_2),
static_cast<unsigned>(REASAMPLER_ACTIVE_UID_3),
static_cast<unsigned>(REASAMPLER_ACTIVE_UID_4));
return std::string(buf, 32);
}
std::vector<std::uint8_t> buildVstPresetBytes(
const std::string& classIdHex32, const std::vector<std::uint8_t>& componentState) {
std::vector<std::uint8_t> out;
if (classIdHex32.size() != 32) return out; // contract violation -> empty, never throws
// Header (48 bytes): 'VST3' + int32 version + 32-char class ID + int64 list offset.
constexpr std::uint64_t kHeaderSize = 4 + 4 + 32 + 8;
const std::uint64_t compSize = componentState.size();
const std::uint64_t listOffset = kHeaderSize + compSize;
out.reserve(static_cast<std::size_t>(listOffset) + 4 + 4 + (4 + 8 + 8));
appendFourCC(out, "VST3");
appendU32LE(out, 1); // kFormatVersion
out.insert(out.end(), classIdHex32.begin(), classIdHex32.end());
appendU64LE(out, listOffset);
// Data area: the one 'Comp' chunk's bytes, at offset kHeaderSize.
out.insert(out.end(), componentState.begin(), componentState.end());
// Chunk list: 'List' + entry count + one entry {'Comp', offset, size}.
appendFourCC(out, "List");
appendU32LE(out, 1);
appendFourCC(out, "Comp");
appendU64LE(out, kHeaderSize);
appendU64LE(out, compSize);
return out;
}
std::vector<std::uint8_t> instrumentDropStateBytes(const std::string& sampleId) {
// The ONE fact the drop carries: this capture is the instance's selection. Everything
// else stays at the fresh-instance defaults (no zones, mono, generation 0) — the same
@@ -35,86 +85,24 @@ std::vector<std::uint8_t> instrumentDropStateBytes(const std::string& sampleId)
return serializeComponentState(cs);
}
std::string buildInstrumentDropChunk(const std::string& sampleId) {
return encodeBase64(instrumentDropStateBytes(sampleId));
std::vector<std::uint8_t> buildInstrumentDropPreset(const std::string& sampleId) {
return buildVstPresetBytes(vstClassIdHex(), instrumentDropStateBytes(sampleId));
}
bool infoNamesFxHotspot(const std::string& info) {
// See the header contract. Two documented FX-bearing surfaces (SDK §GetThingFromPoint):
// "fx_chain" / "fx_N" — the FX-chain and floating-FX windows.
// "tcp.fx" / "mcp.fx" — the TCP / MCP FX button specifically.
// Bare "tcp" / "mcp" and any other "tcp.*" / "mcp.*" sub-element (e.g. "tcp.mute",
// "tcp.vol") are track-panel hits that are NOT on the FX button — those must not trigger
// an instrument drop. Exact-string match for the two .fx tokens; prefix-match for fx_.
if (info == "tcp.fx" || info == "mcp.fx") return true;
// See the header contract. Prefix rule (S-GA-DropFX): "fx_" names the FX-chain /
// floating-FX windows; "tcp.fx" / "mcp.fx" prefixes name the TCP/MCP FX button and its
// sibling FX sub-elements (fxbyp/fxparm/fxlist...), tolerant of the SDK-documented "may
// append additional information". Bare "tcp"/"mcp" and non-FX sub-elements ("tcp.mute",
// "tcp.vol") must NOT trigger an instrument drop.
//
// EXCLUDE the embed-strip sub-element ("tcp.fxembed" / "mcp.fxembed"): that is the
// surface where a ReaSampler 9000 embed strip draws inside the TCP/MCP. Dropping a card
// there must NOT add a SECOND instance — the surface is the existing instance's own UI,
// not an FX-chain drop target. It starts with "tcp.fx" so it must be explicitly excluded.
auto startsWith = [&info](const char* p) { return info.rfind(p, 0) == 0; };
return startsWith("fx_");
}
std::string encodeBase64(const std::vector<std::uint8_t>& bytes) {
std::string out;
out.reserve(((bytes.size() + 2) / 3) * 4);
std::size_t i = 0;
const std::size_t n = bytes.size();
while (i + 3 <= n) {
const std::uint32_t triple = (static_cast<std::uint32_t>(bytes[i]) << 16) |
(static_cast<std::uint32_t>(bytes[i + 1]) << 8) |
static_cast<std::uint32_t>(bytes[i + 2]);
out.push_back(kB64Alphabet[(triple >> 18) & 0x3F]);
out.push_back(kB64Alphabet[(triple >> 12) & 0x3F]);
out.push_back(kB64Alphabet[(triple >> 6) & 0x3F]);
out.push_back(kB64Alphabet[triple & 0x3F]);
i += 3;
}
const std::size_t rem = n - i;
if (rem == 1) {
const std::uint32_t triple = static_cast<std::uint32_t>(bytes[i]) << 16;
out.push_back(kB64Alphabet[(triple >> 18) & 0x3F]);
out.push_back(kB64Alphabet[(triple >> 12) & 0x3F]);
out.push_back('=');
out.push_back('=');
} else if (rem == 2) {
const std::uint32_t triple = (static_cast<std::uint32_t>(bytes[i]) << 16) |
(static_cast<std::uint32_t>(bytes[i + 1]) << 8);
out.push_back(kB64Alphabet[(triple >> 18) & 0x3F]);
out.push_back(kB64Alphabet[(triple >> 12) & 0x3F]);
out.push_back(kB64Alphabet[(triple >> 6) & 0x3F]);
out.push_back('=');
}
return out;
}
std::vector<std::uint8_t> decodeBase64(const std::string& b64) {
std::vector<std::uint8_t> out;
if (b64.size() % 4 != 0) return out; // malformed length -> empty (never throws)
out.reserve((b64.size() / 4) * 3);
for (std::size_t i = 0; i < b64.size(); i += 4) {
const char c0 = b64[i], c1 = b64[i + 1], c2 = b64[i + 2], c3 = b64[i + 3];
const int v0 = b64Value(static_cast<unsigned char>(c0));
const int v1 = b64Value(static_cast<unsigned char>(c1));
if (v0 < 0 || v1 < 0) return {}; // illegal char in a non-pad position -> empty
// Padding is only legal in the last two positions of the last quad.
const bool pad2 = (c2 == '=');
const bool pad3 = (c3 == '=');
if ((pad2 || pad3) && i + 4 != b64.size()) return {}; // pad before the final quad
if (pad2 && !pad3) return {}; // "=X" is malformed
std::uint32_t triple = (static_cast<std::uint32_t>(v0) << 18) |
(static_cast<std::uint32_t>(v1) << 12);
out.push_back(static_cast<std::uint8_t>((triple >> 16) & 0xFF));
if (!pad2) {
const int v2 = b64Value(static_cast<unsigned char>(c2));
if (v2 < 0) return {};
triple |= static_cast<std::uint32_t>(v2) << 6;
out.push_back(static_cast<std::uint8_t>((triple >> 8) & 0xFF));
if (!pad3) {
const int v3 = b64Value(static_cast<unsigned char>(c3));
if (v3 < 0) return {};
triple |= static_cast<std::uint32_t>(v3);
out.push_back(static_cast<std::uint8_t>(triple & 0xFF));
}
}
}
return out;
if (startsWith("tcp.fxembed") || startsWith("mcp.fxembed")) return false;
return startsWith("fx_") || startsWith("tcp.fx") || startsWith("mcp.fx");
}
} // namespace reasampler
+87 -59
View File
@@ -1,32 +1,36 @@
#pragma once
// instrument_drop — the PURE blob-construction core of S17 drop-and-load.
// instrument_drop — the PURE payload-construction core of S17 drop-and-load.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO VST3 SDK,
// NO vendor/ includes. Standard library only (+ the pure sample_map it reuses). Unit-tested
// outside the DAW — the same "small pure builder + round-trip proof" pattern as
// assignment_request / provenance.
// NO vendor/ includes. Standard library only (+ the pure sample_map it reuses and the
// SDK-free UID macros in vst/reasampler_uid.h). Unit-tested outside the DAW — the same
// "small pure builder + round-trip proof" pattern as assignment_request / provenance.
//
// -- What it is (the S17 seam, extension side) --------------------------------
//
// S17 drops a bank capture onto a track's FX button, which instantiates ReaSampler 9000 on
// that track ALREADY PLAYING that capture. The SETTLED mechanism (PLAN.md §S17, mechanism
// (B) — VST3 component-state injection) is: after TrackFX_AddByName creates the instance, the
// extension writes the instance's component state directly via
// TrackFX_SetNamedConfigParm(track, fx, "vst_chunk", <base64 blob>)
// with the dragged capture PRE-SELECTED.
// S17 drops a bank capture onto a track's FX surface, which instantiates ReaSampler 9000 on
// that track ALREADY PLAYING that capture. The injection mechanism (S-GA-DropFX revision of
// PLAN.md §S17 mechanism (B)): after TrackFX_AddByName creates the instance, the extension
// writes a Steinberg-format .vstpreset file whose 'Comp' chunk is the instrument's own
// component state (the dragged capture pre-selected) and applies it via
// TrackFX_SetPreset(track, fx, "<absolute path>.vstpreset")
// which the SDK documents as accepting full .vstpreset paths for VST3 plug-ins.
//
// LOAD-BEARING CAVEAT (PLAN.md §S17): "vst_chunk" is the plugin's OWN base64-encoded
// serialized chunk — the exact bytes ReaSampler 9000's setState/getState round-trips — NOT a
// neutral representation REAPER re-marshals. So the extension must construct EXACTLY the
// instrument's own state-blob bytes. This module does that WITHOUT hand-rolling a parallel
// byte writer: it calls the instrument's OWN serializer, sample_map::serializeComponentState
// (the single source of truth for the byte layout — the same function the processor's
// getState calls), then base64-encodes the result. The shared-writer requirement (both
// artifacts live in this repo → reuse the exact same code) is satisfied structurally: if the
// instrument's format changes, this module changes with it because it CALLS it.
// WHY NOT vst_chunk (the S-GA-DropFX diagnosis): TrackFX_SetNamedConfigParm's "vst_chunk"
// is "base64-encoded VST-specific chunk"for a VST3 that is REAPER's OWN wrapper framing
// of the plugin state (the bytes REAPER round-trips into the RPP <VST block), NOT the raw
// IComponent::setState stream. Writing raw component-state bytes there "succeeds" (the parm
// write returns true) but REAPER's VST3 wrapper cannot apply the unframed blob, so the
// instance silently stayed at defaults — the observed blank-on-drop. The .vstpreset path
// replaces that undocumented framing with a Steinberg-DOCUMENTED container this module can
// construct byte-exactly and prove in a unit test (public.sdk/source/vst/vstpresetfile.cpp
// is the reference reader/writer; layout verified against it).
//
// The base64 encoding is what REAPER's vst_chunk write-parm documents it accepts (see
// reaper_plugin_functions.h: "vst_chunk[_program] : base64-encoded VST-specific chunk").
// The component-state bytes inside the preset are still produced by the instrument's OWN
// serializer, sample_map::serializeComponentState (the single source of truth for the byte
// layout — the same function the processor's getState calls), so the cross-artifact
// contract cannot drift: if the instrument's format changes, this module changes with it
// because it CALLS it.
#include <cstdint>
#include <string>
@@ -34,53 +38,77 @@
namespace reasampler {
// Build the base64 blob the extension writes to TrackFX_SetNamedConfigParm(..., "vst_chunk").
// `sampleId` is the dragged capture's stable bank id — the ONLY thing the drop pre-selects.
// The resulting ComponentState is the instrument's default face with just this one capture
// picked: {selectionId = sampleId, no zones, mono, lastConsumedAssignGeneration = 0} — exactly
// what a fresh instance would hold after the user clicked that capture in the browser. The
// keymap builds under the product defaults (Gate + Preserve) from the bank's own S2 intrinsics,
// so the sample plays MIDI-triggered immediately (the S17 "loaded, selected, playable" verify).
//
// An EMPTY sampleId yields the empty-state blob ({"", no zones}) — a drop of nothing selects
// nothing (the S10 silent empty state); the shell guards against this upstream, but the pure
// contract is defined.
//
// Deterministic: the same sampleId always yields the same blob (base64 of the same bytes).
std::string buildInstrumentDropChunk(const std::string& sampleId);
// The 32-char uppercase-hex class-ID string of THIS build's channel-active ReaSampler 9000
// VST3 class UID — exactly what Steinberg::FUID::toString renders and what a .vstpreset
// header carries (public.sdk vstpresetfile: "ASCII-encoded FUID"). On both COM-compatible
// (Windows GUID byte order) and plain layouts, FUID::toString reduces to the four
// INLINE_UID uint32 words printed "%08X" in order, so this derivation is platform-stable.
// Sourced from the FROZEN macros in vst/reasampler_uid.h (the same constants the factory
// registers), channel-selected by the one REASAMPLER_CHANNEL_IS_BETA bit — a beta extension
// writes presets only the beta VST class accepts, preserving the S18 pairing invariant.
std::string vstClassIdHex();
// -- FX-drop-target classification (S-VIEW-BUG-1) ------------------------------
// Build a Steinberg VST3 preset file image (the bytes of a .vstpreset) carrying exactly one
// 'Comp' chunk = `componentState`, addressed to class `classIdHex32` (32 hex chars, see
// vstClassIdHex). Layout per public.sdk/source/vst/vstpresetfile.cpp, all integers
// little-endian on disk:
// [0] 'VST3' — header magic
// [4] int32 version = 1
// [8] 32-char ASCII class ID
// [40] int64 chunk-list offset (= 48 + componentState.size())
// [48] the component-state bytes — the one 'Comp' chunk's data
// then 'List', int32 entry count = 1, then the entry: 'Comp', int64 offset 48, int64 size.
// No 'Cont' chunk is written: the instrument is a SingleComponentEffect whose whole state is
// the component stream; a controller-state chunk is optional in the container format.
// Returns an empty vector when classIdHex32 is not exactly 32 chars (contract violation).
std::vector<std::uint8_t> buildVstPresetBytes(const std::string& classIdHex32,
const std::vector<std::uint8_t>& componentState);
// The drop payload: a .vstpreset image for the channel-active class whose component state is
// the instrument's default face with just `sampleId` picked — {selectionId = sampleId, no
// zones, mono, generation 0}, exactly what a fresh instance would hold after the user
// clicked that capture in the browser. The keymap builds under the product defaults (Gate +
// Preserve) from the bank's own S2 intrinsics, so the sample plays MIDI-triggered
// immediately (the S17 "loaded, selected, playable" verify).
//
// An EMPTY sampleId yields the empty-state preset ({"", no zones}) — a drop of nothing
// selects nothing (the S10 silent empty state); the shell guards against this upstream, but
// the pure contract is defined.
//
// Deterministic: the same sampleId always yields the same bytes.
std::vector<std::uint8_t> buildInstrumentDropPreset(const std::string& sampleId);
// -- FX-drop-target classification (S-VIEW-BUG-1 / S-GA-DropFX) ----------------
//
// Pure classifier for GetThingFromPoint's info string: is the point over a surface where an
// instrument drop should instantiate ReaSampler 9000 on the resolved track? This is string
// logic (no REAPER types), so it lives here and is unit-tested outside the DAW — the shell
// (instrument_drop_win) only supplies the info bytes GetThingFromPoint filled.
//
// THE BUG (S-VIEW-BUG-1): the original shell predicate matched ONLY "fx_" — but per the SDK
// (reaper_plugin_functions.h §GetThingFromPoint) "fx_chain"/"fx_N" are the FX-CHAIN and
// FLOATING-FX windows; a hit on the TCP/MCP FX button (where the intuitive "drop onto the
// track's FX chain" gesture lands) reports a string in the "tcp.*"/"mcp.*" family —
// specifically "tcp.fx" / "mcp.fx". So dropping on the TCP FX button never armed the drop;
// the gesture fell through and the file dropped to arrange as audio.
//
// The fix: two documented FX-bearing surfaces are hotspots (SDK §GetThingFromPoint):
// * "fx_chain" / "fx_N" — the FX-chain and floating-FX windows (prefix "fx_")
// * "tcp.fx" / "mcp.fx" — the TCP / MCP FX button (exact token)
// Bare "tcp"/"mcp" and any other "tcp.*"/"mcp.*" sub-element (e.g. "tcp.mute") are track-panel
// hits on non-FX surfaces — they must NOT trigger an instrument drop. Only the FX button does.
// The SDK (reaper_plugin_functions.h §GetThingFromPoint) documents "fx_chain"/"fx_N" for
// the FX-chain and floating-FX windows, and "tcp"/"mcp"-prefixed strings with sub-element
// tokens ("tcp.mute" is the doc's example) for track-panel hits — WITH the explicit warning
// that "future versions may append additional information". The FX-button sub-token itself
// is undocumented; the WALTER element family names the TCP/MCP FX surfaces "tcp.fx",
// "tcp.fxbyp", "tcp.fxparm", "tcp.fxembed", "mcp.fxlist", ... — all beginning "tcp.fx" /
// "mcp.fx". So the hotspot rule is PREFIX-based (S-GA-DropFX: the earlier exact-token match
// on "tcp.fx"/"mcp.fx" was too strict for appended info and sibling FX elements):
// * "fx_" prefix — the FX-chain and floating-FX windows
// * "tcp.fx" / "mcp.fx" prefix — the TCP/MCP FX button + sibling FX sub-elements
// EXCEPT "tcp.fxembed" / "mcp.fxembed" — the embed-strip surface where a ReaSampler 9000
// instance draws inside the TCP/MCP. Dropping onto the existing instance's own UI must NOT
// add a second instance; the embed surface is explicitly excluded even though it starts
// with "tcp.fx". All other "tcp.fx*" / "mcp.fx*" tokens (fxbyp, fxparm, fxlist, ...) are
// hotspots — they are FX-chain controls, not a running instance's own surface.
// Bare "tcp"/"mcp" and non-FX sub-elements (e.g. "tcp.mute", "tcp.vol") are NOT hotspots.
// The exact live token over the FX button remains a DAW-only fact — confirm in REAPER (a
// deferred ReaScript around reaper.GetThingFromPoint(reaper.GetMousePosition()) prints it).
bool infoNamesFxHotspot(const std::string& info);
// The raw (pre-base64) component-state bytes — exposed so the round-trip test can decode them
// back through the instrument's OWN reader (sample_map::deserializeComponentState) and assert
// the capture is selected, proving buildInstrumentDropChunk feeds the instrument exactly what
// its setState expects. Not called by the shell (which uses the base64 form).
// The raw component-state bytes the preset carries — exposed so the round-trip test can
// decode them back through the instrument's OWN reader (sample_map::deserializeComponentState)
// and assert the capture is selected, proving the preset feeds the instrument exactly what
// its setState expects. Not called by the shell (which uses the .vstpreset image).
std::vector<std::uint8_t> instrumentDropStateBytes(const std::string& sampleId);
// Standard base64 encode/decode (RFC 4648, '+' '/' alphabet, '=' padding). Exposed so the
// round-trip test can decode buildInstrumentDropChunk's output. decodeBase64 returns the
// decoded bytes; on malformed input (bad length / illegal char) it returns an EMPTY vector
// (never throws) — the test asserts a clean decode, and the shell never decodes.
std::string encodeBase64(const std::vector<std::uint8_t>& bytes);
std::vector<std::uint8_t> decodeBase64(const std::string& b64);
} // namespace reasampler
+92 -19
View File
@@ -5,7 +5,13 @@
#include "instrument_drop_win.h"
#include <atomic>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <string>
#include <system_error>
#include <vector>
#include "app_version.h" // vstPluginName() — the CHANNEL-correct FX name (stable/beta pairing)
#include "instrument_drop.h" // infoNamesFxHotspot — the PURE, unit-tested hotspot classifier
@@ -16,13 +22,62 @@
#define REAPERAPI_WANT_GetThingFromPoint
#define REAPERAPI_WANT_TrackFX_AddByName
#define REAPERAPI_WANT_TrackFX_Delete
#define REAPERAPI_WANT_TrackFX_SetNamedConfigParm
#define REAPERAPI_WANT_TrackFX_SetPreset
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"
namespace reasampler {
namespace {
// Write `bytes` to a fresh uniquely-named .vstpreset in the OS temp dir and return its path;
// returns an empty path on any failure. The .vstpreset extension is load-bearing —
// TrackFX_SetPreset's full-path form is documented for .vstpreset files (VST3). The file is
// transient: the caller deletes it right after the SetPreset call.
//
// The temp filename embeds the process ID so two concurrent REAPER instances (e.g. stable +
// beta) cannot collide in the shared OS temp dir, and one instance's cleanup cannot
// accidentally delete another's in-flight file.
//
// Non-throwing: every std::filesystem call uses the error_code overload. The whole body is
// wrapped in try/catch to guarantee no exception crosses the REAPER C callback boundary
// (the same discipline persist.cpp uses — see its non-throwing scanPruneOrphans comment).
//
// Returns the path object (not a narrow string) so the caller can:
// (a) pass path.u8string() to TrackFX_SetPreset — UTF-8 on MSVC, not ACP-converted,
// so a temp dir with accented or CJK user-name bytes is handled correctly;
// (b) delete via the retained path object — not via re-parsing the narrow string —
// so the cleanup cannot leak if the conversion above were to round-trip incorrectly.
std::filesystem::path writeTempPreset(const std::vector<std::uint8_t>& bytes) {
try {
static std::atomic<unsigned> counter{0};
std::error_code ec;
const std::filesystem::path dir = std::filesystem::temp_directory_path(ec);
if (ec) return {};
// PID in the name keeps files from distinct REAPER instances distinct in the shared
// temp dir — prevents cross-instance collisions and spurious post-apply deletions.
const std::string name =
"reasampler_drop_" + std::to_string(GetCurrentProcessId()) +
"_" + std::to_string(counter.fetch_add(1)) + ".vstpreset";
const std::filesystem::path path = dir / name;
std::ofstream out(path, std::ios::binary | std::ios::trunc);
if (!out) return {};
out.write(reinterpret_cast<const char*>(bytes.data()),
static_cast<std::streamsize>(bytes.size()));
out.close();
if (!out) { // short write / flush failure -> don't hand REAPER a truncated preset
std::filesystem::remove(path, ec);
return {};
}
return path;
} catch (...) {
return {};
}
}
} // namespace
FxDropTarget resolveFxDropTarget(int screenX, int screenY) {
FxDropTarget out;
char info[256] = {0};
@@ -33,35 +88,53 @@ FxDropTarget resolveFxDropTarget(int screenX, int screenY) {
MediaTrack* track = GetThingFromPoint(screenX, screenY, info, sizeof(info));
out.track = track;
out.overReaperUi = (track != nullptr) || (info[0] != '\0');
// S-VIEW-BUG-1: the hotspot is either the FX chain/floating window ("fx_*") OR the track/
// mixer panel that hosts the FX button ("tcp*"/"mcp*"). The pure classifier owns the rule.
// The hotspot is either the FX chain/floating window ("fx_*") OR the FX-button family of
// the track/mixer panel ("tcp.fx*"/"mcp.fx*"). The pure classifier owns the rule.
out.overFxHotspot = (track != nullptr) && infoNamesFxHotspot(info);
return out;
}
bool loadInstrumentOntoTrack(MediaTrack* track, const std::string& chunkBase64) {
if (!track || chunkBase64.empty()) return false;
bool loadInstrumentOntoTrack(MediaTrack* track, const std::vector<std::uint8_t>& presetBytes) {
if (!track || presetBytes.empty()) return false;
// Materialize the .vstpreset FIRST so an I/O failure leaves the track untouched (no FX
// added yet — nothing to roll back).
const std::filesystem::path presetPath = writeTempPreset(presetBytes);
if (presetPath.empty()) return false;
// The CHANNEL-correct FX name: "VST3:ReaSampler 9000" on stable, "VST3:ReaSampler 9000
// beta" on beta. Sourcing it from app_version::vstPluginName() (the same accessor the VST
// factory display name derives from) keeps the pairing invariant intact — a beta extension
// drops the beta VST, a stable extension the stable VST — with no literal to drift.
// drops the beta VST, a stable extension the stable VST — with no literal to drift. (The
// preset's class ID forks by the same channel bit inside buildInstrumentDropPreset.)
const std::string fxName = "VST3:" + vstPluginName();
// Negative `instantiate` => always create a NEW instance (verified in the header). recFX
// = false: a normal track FX chain instance, not a record/monitoring FX.
const int fxIndex = TrackFX_AddByName(track, fxName.c_str(), /*recFX=*/false,
/*instantiate=*/-1);
if (fxIndex < 0) return false;
bool ok = fxIndex >= 0;
// Inject the instrument's OWN component-state blob (the dragged capture pre-selected)
// via the documented vst_chunk write-parm. The blob was built by the shared writer
// (instrument_drop::buildInstrumentDropChunk -> sample_map::serializeComponentState),
// so these bytes are exactly what ReaSampler 9000's setState accepts.
const bool ok =
TrackFX_SetNamedConfigParm(track, fxIndex, "vst_chunk", chunkBase64.c_str());
if (!ok) {
// All-or-nothing: if the chunk write fails, remove the empty FX instance we just
// Apply the dragged capture's component state through the DOCUMENTED channel: a full
// .vstpreset path handed to TrackFX_SetPreset (SDK: "Full paths to .vstpreset files are
// also supported for VST3 plug-ins"). REAPER parses the Steinberg container and feeds the
// 'Comp' chunk to the instance's setState — the same bytes the instrument's own
// serializer produced (instrument_drop::buildInstrumentDropPreset ->
// sample_map::serializeComponentState). Unlike the former "vst_chunk" named-config-parm
// write, a failure here is REPORTED (false), not silently ignored.
//
// u8string() gives UTF-8 bytes on MSVC (not ACP-converted), so a temp dir under an
// accented or CJK user-name is handled correctly by REAPER's path APIs.
if (ok) ok = TrackFX_SetPreset(track, fxIndex, presetPath.u8string().c_str());
// The preset file is transient regardless of outcome; delete via the retained path object
// (not a re-parsed narrow string) so cleanup cannot leak even if the UTF-8 conversion
// round-trip were incorrect.
std::error_code ec;
std::filesystem::remove(presetPath, ec);
if (!ok && fxIndex >= 0) {
// All-or-nothing: if the preset apply fails, remove the empty FX instance we just
// added so the track is left exactly as it was. TrackFX_Delete signature (verified
// in reaper_plugin_functions.h:7236): bool TrackFX_Delete(MediaTrack*, int fx).
TrackFX_Delete(track, fxIndex);
@@ -69,13 +142,13 @@ bool loadInstrumentOntoTrack(MediaTrack* track, const std::string& chunkBase64)
return ok;
}
bool performInstrumentDrop(MediaTrack* track, const std::string& chunkBase64) {
if (!track || chunkBase64.empty()) return false;
bool performInstrumentDrop(MediaTrack* track, const std::vector<std::uint8_t>& presetBytes) {
if (!track || presetBytes.empty()) return false;
// One undo point for the whole gesture (mirrors the bank-verb undo discipline). Both the
// FX add and the state write are REAPER-undoable, so Ctrl-Z removes the instance cleanly.
// FX add and the state apply are REAPER-undoable, so Ctrl-Z removes the instance cleanly.
Undo_BeginBlock2(nullptr);
const bool ok = loadInstrumentOntoTrack(track, chunkBase64);
const bool ok = loadInstrumentOntoTrack(track, presetBytes);
// The undo label reflects the placement-of-the-player framing (not a capture, not an insert).
Undo_EndBlock2(nullptr, "ReaSampler: drop capture onto FX chain", -1);
return ok;
+35 -31
View File
@@ -1,20 +1,24 @@
#pragma once
// instrument_drop_win — the REAPER-facing shell half of S17 drop-and-load. The pure gesture
// decision lives in drag_out (DragGesture::InstrumentDrop) and the pure blob construction in
// instrument_drop; THIS is the platform shell that (a) resolves a screen point to a track +
// its TCP FX-button hotspot via REAPER's hit-test API, and (b) on release adds a ReaSampler
// 9000 instance to that track and injects the dragged capture as its component state.
// decision lives in drag_out (DragGesture::InstrumentDrop) and the pure payload construction
// in instrument_drop; THIS is the platform shell that (a) resolves a screen point to a track
// + its FX-surface hotspot via REAPER's hit-test API, and (b) on release adds a ReaSampler
// 9000 instance to that track and applies the dragged capture as its component state via a
// temp .vstpreset + TrackFX_SetPreset (S-GA-DropFX: the earlier "vst_chunk" named-config-parm
// write was silently unappliable — see instrument_drop.h for the diagnosis).
//
// Compiled into the reaper_reasampler MODULE. REAPER-facing (GetThingFromPoint, TrackFX_*,
// Undo_*), so DAW-verified, not unit-tested; the pure decision + blob it drives are CTest'd.
// Undo_*), so DAW-verified, not unit-tested; the pure decision + preset it drives are CTest'd.
//
// LOAD-BEARING (CONTEXT.md §Drop-and-load): this is an EXPLICIT user placement-of-the-player
// gesture — it adds a READER of the bank on a track and points it at one already-captured
// sample. It NEVER captures, NEVER writes the bank, and NEVER inserts a timeline item. The
// only writes are: a new FX instance on the target track + that instance's own component
// state — both REAPER-undoable, wrapped in one undo block so the whole gesture is one Ctrl-Z.
// state — both REAPER-undoable, wrapped in one undo block so the whole gesture is one Ctrl-Z
// — plus a transient .vstpreset in the OS temp dir, deleted before returning.
#include <string>
#include <cstdint>
#include <vector>
// Opaque REAPER track handle at the boundary so includers don't need the SDK. The SDK
// declares it as a class (reaper_plugin.h) — match that spelling so the mangled name agrees.
@@ -26,40 +30,40 @@ namespace reasampler {
struct FxDropTarget {
MediaTrack* track = nullptr; // the track under the pointer (null if none / not a track)
bool overReaperUi = false; // the point is over REAPER's own window/UI at all
bool overFxHotspot = false; // specifically over this track's TCP FX-button/-chain region
bool overFxHotspot = false; // specifically over this track's FX button/chain surface
// A valid drop target: a resolved track whose FX hotspot is under the pointer.
bool valid() const { return track != nullptr && overFxHotspot; }
};
// Hit-test a screen point (REAPER screen coords) to an FX drop target. Wraps
// GetThingFromPoint, whose info string tells us what was hit ("tcp.fx"/"mcp.fx" for the TCP/MCP
// FX button; "fx_chain"/"fx_N" for the FX-chain and floating-FX windows; bare "tcp"/"mcp" or
// other "tcp.*"/"mcp.*" tokens for non-FX track-panel regions). `overReaperUi` is the
// shell-supplied predicate the pure drag_out::decideGesture consumes (true when the point is
// over REAPER's own UI — i.e. GetThingFromPoint returned a track OR a recognizable non-track
// thing, false when the pointer has left REAPER entirely). `overFxHotspot` is true only when
// the info string names a genuine FX-bearing surface ("tcp.fx", "mcp.fx", or "fx_*") —
// decided by instrument_drop::infoNamesFxHotspot from the SDK's own hit-test string.
// GetThingFromPoint, whose info string tells us what was hit ("tcp.fx*"/"mcp.fx*" for the
// TCP/MCP FX button family; "fx_chain"/"fx_N" for the FX-chain and floating-FX windows; bare
// "tcp"/"mcp" or other sub-element tokens for non-FX track-panel regions). `overReaperUi` is
// the shell-supplied predicate the pure drag_out::decideGesture consumes (true when the point
// is over REAPER's own UI — i.e. GetThingFromPoint returned a track OR a recognizable
// non-track thing, false when the pointer has left REAPER entirely). `overFxHotspot` is true
// only when the info string names a genuine FX-bearing surface — decided by the pure
// instrument_drop::infoNamesFxHotspot from the SDK's own hit-test string.
FxDropTarget resolveFxDropTarget(int screenX, int screenY);
// Perform the drop on `track`: add a fresh ReaSampler 9000 instance and inject `chunkBase64`
// (the instrument_drop::buildInstrumentDropChunk output) as its component state so it plays
// the dragged capture. `chunkBase64` is the base64 vst_chunk. Wraps the add + inject in one
// REAPER undo block (mirrors the bank-verb undo discipline). Returns true on success (the FX
// was added and the chunk written), false on any failure. All-or-nothing: if the chunk write
// fails after a successful add, the freshly-added FX instance is removed via TrackFX_Delete
// before returning false, leaving the track exactly as it was (no orphaned empty-state FX).
// NEVER inserts a timeline item; the ONLY mutations are the FX instance + its state, both
// undoable.
bool performInstrumentDrop(MediaTrack* track, const std::string& chunkBase64);
// Perform the drop on `track`: add a fresh ReaSampler 9000 instance and apply `presetBytes`
// (the instrument_drop::buildInstrumentDropPreset output — a .vstpreset image) as its
// component state so it plays the dragged capture. Wraps the add + apply in one REAPER undo
// block (mirrors the bank-verb undo discipline). Returns true on success (the FX was added
// and the preset applied), false on any failure. All-or-nothing: if the preset apply fails
// after a successful add, the freshly-added FX instance is removed via TrackFX_Delete before
// returning false, leaving the track exactly as it was (no orphaned empty-state FX).
// NEVER inserts a timeline item; the ONLY persistent mutations are the FX instance + its
// state, both undoable.
bool performInstrumentDrop(MediaTrack* track, const std::vector<std::uint8_t>& presetBytes);
// Add a fresh ReaSampler 9000 instance to `track` and inject `chunkBase64` as its component
// state. Same all-or-nothing add+inject contract as performInstrumentDrop (rolls the FX back
// via TrackFX_Delete on inject failure), but does NOT open its own undo block — the caller owns
// the undo grouping so the whole gesture (persist + FX-add + inject) collapses to
// Add a fresh ReaSampler 9000 instance to `track` and apply `presetBytes` as its component
// state. Same all-or-nothing add+apply contract as performInstrumentDrop (rolls the FX back
// via TrackFX_Delete on apply failure), but does NOT open its own undo block — the caller owns
// the undo grouping so the whole gesture (persist + FX-add + apply) collapses to
// one Ctrl-Z. This is the shared inner half performInstrumentDrop wraps in its own block.
// Returns true on success, false on any failure. NEVER inserts a timeline item.
bool loadInstrumentOntoTrack(MediaTrack* track, const std::string& chunkBase64);
bool loadInstrumentOntoTrack(MediaTrack* track, const std::vector<std::uint8_t>& presetBytes);
} // namespace reasampler
+46
View File
@@ -0,0 +1,46 @@
#pragma once
// reasampler_uid.h — the FOREVER-FROZEN VST3 class-UID constants, SDK-FREE.
//
// Split out of reasampler_vst.h (S-GA-DropFX) so the PURE extension side can derive the
// class-ID string a .vstpreset file carries (instrument_drop::vstClassIdHex) WITHOUT
// including the VST3 SDK: reasampler_vst.h needs Steinberg::FUID (SDK), but the UID VALUES
// are plain integer macros. This header owns the values + the channel selection; nothing
// else. reasampler_vst.h includes it to build the runtime FUID; instrument_drop includes it
// to render the 32-char hex string. ONE source of truth — the frozen constants are written
// exactly once, here.
//
// A class UID is FOREVER-STABLE once shipped: a REAPER project that instantiates the
// instrument records the UID, so changing it orphans every saved instance. Minted once;
// do not regenerate. See reasampler_vst.h for the full channel-isolation story (S18).
#include "version_generated.h" // REASAMPLER_CHANNEL_IS_BETA — the one channel bit
// STABLE class UID (S-NAME-1). Minted at the S1 spike (2026-07-26), locked. FROZEN FOREVER.
#define REASAMPLER_PROC_UID_1 0x5E45A11E
#define REASAMPLER_PROC_UID_2 0x9C7B4D6A
#define REASAMPLER_PROC_UID_3 0xB1E3F208
#define REASAMPLER_PROC_UID_4 0x4A6C1D9F
// BETA class UID (S18). Minted once (2026-07-26), locked FROM THIS WAVE per Daniel's
// fast-track (fork S18-F1: mint now, not at first beta release). FROZEN FOREVER — the same
// permanent lock as the stable UID; do not regenerate even though no beta VST has shipped.
#define REASAMPLER_PROC_UID_BETA_1 0xCCFFEB3A
#define REASAMPLER_PROC_UID_BETA_2 0x4FF532A6
#define REASAMPLER_PROC_UID_BETA_3 0x9E181798
#define REASAMPLER_PROC_UID_BETA_4 0x4256955F
// The channel-selected UID macros — exactly one class UID per binary. The factory's
// INLINE_UID (compile-time brace init) and the runtime FUID in reasampler_vst.h both source
// these, as does the extension's vstClassIdHex (the .vstpreset class-ID string), so the
// binary identity and the preset-file identity cannot diverge.
#if REASAMPLER_CHANNEL_IS_BETA
#define REASAMPLER_ACTIVE_UID_1 REASAMPLER_PROC_UID_BETA_1
#define REASAMPLER_ACTIVE_UID_2 REASAMPLER_PROC_UID_BETA_2
#define REASAMPLER_ACTIVE_UID_3 REASAMPLER_PROC_UID_BETA_3
#define REASAMPLER_ACTIVE_UID_4 REASAMPLER_PROC_UID_BETA_4
#else
#define REASAMPLER_ACTIVE_UID_1 REASAMPLER_PROC_UID_1
#define REASAMPLER_ACTIVE_UID_2 REASAMPLER_PROC_UID_2
#define REASAMPLER_ACTIVE_UID_3 REASAMPLER_PROC_UID_3
#define REASAMPLER_ACTIVE_UID_4 REASAMPLER_PROC_UID_4
#endif
+11 -38
View File
@@ -21,7 +21,7 @@
#include "pluginterfaces/base/funknown.h"
#include "version_generated.h" // REASAMPLER_CHANNEL_IS_BETA — the one channel bit
#include "reasampler_uid.h" // the FROZEN UID macros + channel selection (SDK-free values)
namespace reasampler::vst {
@@ -33,44 +33,17 @@ inline constexpr const char* kVendorUrl = "https://github.com/daniel-c-harvey/re
inline constexpr const char* kVendorEmail = "mailto:the.real.daniel.harvey@gmail.com";
// -----------------------------------------------------------------------------------------
// The two FOREVER-FROZEN VST3 class UIDs — one per channel. A saved REAPER project records
// the UID of the instance it instantiated and rebinds by it on reopen, so EACH is a
// permanent commitment: changing either orphans every saved instance of that channel. The
// channel bit selects which one this binary's factory registers (below) — one class per
// binary, never both. Documented with the SAME gravity: neither may EVER be regenerated.
// The two FOREVER-FROZEN VST3 class UIDs — one per channel — live in reasampler_uid.h
// (SDK-free, so the extension's pure instrument_drop can render the .vstpreset class-ID
// string from the SAME constants without pulling the VST3 SDK). A saved REAPER project
// records the UID of the instance it instantiated and rebinds by it on reopen, so each is
// a permanent commitment. The channel bit selects which one this binary's factory registers
// — one class per binary, never both. The UID selection is the ONLY channel #ifdef in the
// VST shell (an INLINE_UID needs literal brace-init tokens, so it cannot route through
// app_version's runtime string accessors — reasampler_uid.h owns the binary UID fork,
// app_version owns the string fork).
// STABLE class UID (S-NAME-1). Minted at the S1 spike (2026-07-26), locked. FROZEN FOREVER.
#define REASAMPLER_PROC_UID_1 0x5E45A11E
#define REASAMPLER_PROC_UID_2 0x9C7B4D6A
#define REASAMPLER_PROC_UID_3 0xB1E3F208
#define REASAMPLER_PROC_UID_4 0x4A6C1D9F
// BETA class UID (S18). Minted once (2026-07-26), locked FROM THIS WAVE per Daniel's
// fast-track (fork S18-F1: mint now, not at first beta release). FROZEN FOREVER — the same
// permanent lock as the stable UID; do not regenerate even though no beta VST has shipped.
#define REASAMPLER_PROC_UID_BETA_1 0xCCFFEB3A
#define REASAMPLER_PROC_UID_BETA_2 0x4FF532A6
#define REASAMPLER_PROC_UID_BETA_3 0x9E181798
#define REASAMPLER_PROC_UID_BETA_4 0x4256955F
// The channel-selected UID macros the factory's INLINE_UID (compile-time brace init) and the
// runtime FUID below both source, so exactly one class UID is compiled into this binary. This
// is the ONLY channel #ifdef in the VST shell (an INLINE_UID needs literal brace-init tokens,
// so it cannot route through app_version's runtime string accessors — the header owns the
// binary UID fork, app_version owns the string fork).
#if REASAMPLER_CHANNEL_IS_BETA
#define REASAMPLER_ACTIVE_UID_1 REASAMPLER_PROC_UID_BETA_1
#define REASAMPLER_ACTIVE_UID_2 REASAMPLER_PROC_UID_BETA_2
#define REASAMPLER_ACTIVE_UID_3 REASAMPLER_PROC_UID_BETA_3
#define REASAMPLER_ACTIVE_UID_4 REASAMPLER_PROC_UID_BETA_4
#else
#define REASAMPLER_ACTIVE_UID_1 REASAMPLER_PROC_UID_1
#define REASAMPLER_ACTIVE_UID_2 REASAMPLER_PROC_UID_2
#define REASAMPLER_ACTIVE_UID_3 REASAMPLER_PROC_UID_3
#define REASAMPLER_ACTIVE_UID_4 REASAMPLER_PROC_UID_4
#endif
// The runtime FUID for the class this binary registers — the channel-selected UID above.
// The runtime FUID for the class this binary registers — the channel-selected UID.
static const Steinberg::FUID kReaSamplerProcessorUID(REASAMPLER_ACTIVE_UID_1,
REASAMPLER_ACTIVE_UID_2,
REASAMPLER_ACTIVE_UID_3,