From b4dd6dc9f1e49eab8c328d583d013ebf5dec0aba Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 06:19:29 -0400 Subject: [PATCH] fix(drop-fx): inject via .vstpreset + TrackFX_SetPreset (vst_chunk is REAPER-framed, raw bytes silently no-op); FX hotspot now prefix tcp.fx*/mcp.fx*/fx_* --- CLAUDE.md | 4 +- CMakeLists.txt | 29 ++-- src/bank_panel.cpp | 6 +- src/ingest.cpp | 14 +- src/instrument_drop.cpp | 158 ++++++++++----------- src/instrument_drop.h | 141 +++++++++++-------- src/instrument_drop_win.cpp | 84 +++++++++--- src/instrument_drop_win.h | 66 ++++----- src/vst/reasampler_uid.h | 46 +++++++ src/vst/reasampler_vst.h | 49 ++----- tests/test_instrument_drop.cpp | 241 +++++++++++++++++++++------------ 11 files changed, 494 insertions(+), 344 deletions(-) create mode 100644 src/vst/reasampler_uid.h diff --git a/CLAUDE.md b/CLAUDE.md index dd8c86d..78d962e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,7 +80,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde - `tooltip` — pure tooltip placement + prefix-strip: strips the `ReaSampler:` display prefix from the registered action phrase; width clamped to the client rect. - `card_drag` — pure drag-gesture precedence + slot hit-test: leave-client → OS drag-out; other-bank → move/copy; same-bank → reorder / Alt-over-occupied → replace. - `card_meta` — pure card-metadata formatters: bars.beats.subdivisions and seconds.milliseconds; blank when the sample is unstamped. -- `instrument_drop` — pure FX-button drop blob builder: constructs the base64-encoded vst_chunk blob needed to inject a pre-configured `reasampler_9000` instrument. All-or-nothing contract — caller rolls back via `TrackFX_Delete` on any failure. +- `instrument_drop` — pure FX-drop payload builder: constructs a Steinberg-format `.vstpreset` image (channel-active class ID + the instrument's own component state, capture pre-selected) the shell applies via `TrackFX_SetPreset`; owns the `infoNamesFxHotspot` prefix classifier for `GetThingFromPoint` tokens. All-or-nothing contract — caller rolls back via `TrackFX_Delete` on any failure. - `assignment_request` — pure ingest-assign wire: typed request record carrying the drop payload from the `ingest` shell through to the VST3 bridge. **REAPER-facing shells:** @@ -93,7 +93,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde - `provenance_shell` — FX-chain identity queries via `TrackFX_*`/`TakeFX_*` APIs; feeds the pure `provenance` fingerprint builder. Stamps `Sample.provenance` on capture; ambiguous/mixed cases record nothing conservatively. - `drag_out_win` — OS drag-out shell: Windows OLE `DoDragDrop`/`CF_HDROP`, copy-only (`DROPEFFECT_MOVE` not offered); macOS/Linux via `SWELL_InitiateDragDropOfFileList`. - `ingest` — ingest-through-the-bank shell on the EXTENSION side: three surfaces — (1) arrange capture→bank→assign (bindable action), (2) Media-Explorer import→bank→instrument on the selected track, (3) file drop onto the bank panel→bank only. Only surface (1) writes the `assignment_request` ext-state wire. **ingest NEVER inserts a timeline item.** -- `instrument_drop_win` — FX-button drop shell: resolves a screen point to a track + TCP FX-button hotspot, then adds a ReaSampler 9000 instance and injects state via `TrackFX_SetNamedConfigParm` "vst_chunk". Exposes `loadInstrumentOntoTrack` (inner half, no own undo block) and `performInstrumentDrop` (wraps in its own undo block). **Never captures, never writes the bank, never inserts a timeline item.** +- `instrument_drop_win` — FX-button drop shell: resolves a screen point to a track + FX-surface hotspot, then adds a ReaSampler 9000 instance and applies the dragged capture's state via a transient `.vstpreset` + `TrackFX_SetPreset` (the former `TrackFX_SetNamedConfigParm` "vst_chunk" write was silently unappliable for VST3). Exposes `loadInstrumentOntoTrack` (inner half, no own undo block) and `performInstrumentDrop` (wraps in its own undo block). **Never captures, never writes the bank, never inserts a timeline item.** - `draw_kit` — shared LICE draw shell: `fillSurface`, `drawButton`/`drawSlider`/`drawListRow`/`drawWaveform`, cached-font `text()`, full interaction-state model, double-buffer preserved. Consumes `theme` + `component_geometry`. - `actions` — registers the capture/placement/slot, Design View, multi-bank, and prune action families; routes each via the `command_id`/`gaccel`/`hookcommand` contract. **Every bank index verb wraps its mutation in a batched REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`, `UNDO_STATE_MISCCFG`) so one bank operation is one Ctrl-Z.** The prune action (`BANK_PRUNE_FOLDER`) is **the ONLY file-deletion authority in the system**; it opens no undo point (file deletion is not REAPER-undoable). diff --git a/CMakeLists.txt b/CMakeLists.txt index 41b55ed..f35f569 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -353,16 +353,22 @@ target_include_directories(drag_out PUBLIC src) # --------------------------------------------------------------------------- # 2l') Pure instrument_drop library — NO REAPER, NO SWELL, NO VST3 SDK. The S17 -# drop-and-load blob-construction core: turn the dragged capture id into the -# base64 "vst_chunk" the extension injects via TrackFX_SetNamedConfigParm so a -# freshly-added ReaSampler 9000 plays that capture. Reuses the instrument's OWN -# serializer (sample_map::serializeComponentState) — NOT a parallel byte writer — -# so the cross-artifact blob contract cannot drift; links sample_map (which pulls -# bank_book/wav_trim/sampler_core transitively) and NEITHER SDK. The round-trip -# test decodes back through the instrument's own reader. Mirror of assignment_request. +# drop-and-load payload-construction core (S-GA-DropFX revision): turn the dragged +# capture id into a Steinberg-format .vstpreset image the shell applies via +# TrackFX_SetPreset so a freshly-added ReaSampler 9000 plays that capture (the +# former "vst_chunk" named-config-parm write fed REAPER raw component bytes its +# VST3 wrapper framing cannot apply — the blank-on-drop regression). Reuses the +# instrument's OWN serializer (sample_map::serializeComponentState) — NOT a +# parallel byte writer — so the cross-artifact contract cannot drift; links +# sample_map (which pulls bank_book/wav_trim/sampler_core transitively) and +# NEITHER SDK. The class-ID string derives from the FROZEN UID macros +# (src/vst/reasampler_uid.h, SDK-free), channel-selected via the generated +# version header — hence the generated include dir. The round-trip test parses +# the container and decodes back through the instrument's own reader. Mirror of +# assignment_request. # --------------------------------------------------------------------------- add_library(instrument_drop STATIC src/instrument_drop.cpp) -target_include_directories(instrument_drop PUBLIC src src/vst) +target_include_directories(instrument_drop PUBLIC src src/vst ${CMAKE_CURRENT_BINARY_DIR}/generated) target_link_libraries(instrument_drop PUBLIC sample_map) # --------------------------------------------------------------------------- @@ -654,9 +660,10 @@ add_executable(drag_out_tests tests/test_drag_out.cpp) target_link_libraries(drag_out_tests PRIVATE drag_out) add_test(NAME drag_out_tests COMMAND drag_out_tests) -# instrument_drop (S17): the drop-and-load vst_chunk blob builder. The round-trip test -# decodes the base64 back through the instrument's OWN reader (deserializeComponentState) to -# prove the extension injects exactly what setState accepts — the cross-artifact contract guard. +# instrument_drop (S17): the drop-and-load .vstpreset payload builder. The round-trip test +# parses the preset container and decodes its Comp chunk back through the instrument's OWN +# reader (deserializeComponentState) to prove the extension feeds setState exactly what it +# accepts — the cross-artifact contract guard. add_executable(instrument_drop_tests tests/test_instrument_drop.cpp) target_link_libraries(instrument_drop_tests PRIVATE instrument_drop) add_test(NAME instrument_drop_tests COMMAND instrument_drop_tests) diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 64a64c7..a2cb507 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -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) @@ -3089,8 +3089,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. diff --git a/src/ingest.cpp b/src/ingest.cpp index 7ff4eae..b99f87e 100644 --- a/src/ingest.cpp +++ b/src/ingest.cpp @@ -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 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); diff --git a/src/instrument_drop.cpp b/src/instrument_drop.cpp index 6007620..01790cb 100644 --- a/src/instrument_drop.cpp +++ b/src/instrument_drop.cpp @@ -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 + +#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& out, std::uint32_t v) { + out.push_back(static_cast(v & 0xFF)); + out.push_back(static_cast((v >> 8) & 0xFF)); + out.push_back(static_cast((v >> 16) & 0xFF)); + out.push_back(static_cast((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& out, std::uint64_t v) { + for (int i = 0; i < 8; ++i) + out.push_back(static_cast((v >> (8 * i)) & 0xFF)); +} + +void appendFourCC(std::vector& 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(REASAMPLER_ACTIVE_UID_1), + static_cast(REASAMPLER_ACTIVE_UID_2), + static_cast(REASAMPLER_ACTIVE_UID_3), + static_cast(REASAMPLER_ACTIVE_UID_4)); + return std::string(buf, 32); +} + +std::vector buildVstPresetBytes( + const std::string& classIdHex32, const std::vector& componentState) { + std::vector 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(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 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,18 @@ std::vector instrumentDropStateBytes(const std::string& sampleId) return serializeComponentState(cs); } -std::string buildInstrumentDropChunk(const std::string& sampleId) { - return encodeBase64(instrumentDropStateBytes(sampleId)); +std::vector 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/fxembed/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. auto startsWith = [&info](const char* p) { return info.rfind(p, 0) == 0; }; - return startsWith("fx_"); -} - -std::string encodeBase64(const std::vector& 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(bytes[i]) << 16) | - (static_cast(bytes[i + 1]) << 8) | - static_cast(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(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(bytes[i]) << 16) | - (static_cast(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 decodeBase64(const std::string& b64) { - std::vector 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(c0)); - const int v1 = b64Value(static_cast(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(v0) << 18) | - (static_cast(v1) << 12); - out.push_back(static_cast((triple >> 16) & 0xFF)); - if (!pad2) { - const int v2 = b64Value(static_cast(c2)); - if (v2 < 0) return {}; - triple |= static_cast(v2) << 6; - out.push_back(static_cast((triple >> 8) & 0xFF)); - if (!pad3) { - const int v3 = b64Value(static_cast(c3)); - if (v3 < 0) return {}; - triple |= static_cast(v3); - out.push_back(static_cast(triple & 0xFF)); - } - } - } - return out; + return startsWith("fx_") || startsWith("tcp.fx") || startsWith("mcp.fx"); } } // namespace reasampler diff --git a/src/instrument_drop.h b/src/instrument_drop.h index b3c2a60..46d321a 100644 --- a/src/instrument_drop.h +++ b/src/instrument_drop.h @@ -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", ) -// 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, ".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 #include @@ -34,53 +38,72 @@ 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 buildVstPresetBytes(const std::string& classIdHex32, + const std::vector& 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 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" prefix / "mcp.fx" prefix — the TCP/MCP FX button + sibling FX sub-elements +// 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 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& bytes); -std::vector decodeBase64(const std::string& b64); - } // namespace reasampler diff --git a/src/instrument_drop_win.cpp b/src/instrument_drop_win.cpp index 9a1d487..4be2de3 100644 --- a/src/instrument_drop_win.cpp +++ b/src/instrument_drop_win.cpp @@ -5,7 +5,13 @@ #include "instrument_drop_win.h" +#include +#include +#include +#include #include +#include +#include #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,40 @@ #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 +// absolute path; empty string 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. +std::string writeTempPreset(const std::vector& bytes) { + static std::atomic counter{0}; + std::error_code ec; + const std::filesystem::path dir = std::filesystem::temp_directory_path(ec); + if (ec) return {}; + const std::filesystem::path path = + dir / ("reasampler_drop_" + std::to_string(counter.fetch_add(1)) + ".vstpreset"); + std::ofstream out(path, std::ios::binary | std::ios::trunc); + if (!out) return {}; + out.write(reinterpret_cast(bytes.data()), + static_cast(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.string(); +} + +} // namespace + FxDropTarget resolveFxDropTarget(int screenX, int screenY) { FxDropTarget out; char info[256] = {0}; @@ -33,35 +66,48 @@ 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& 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::string 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. + if (ok) ok = TrackFX_SetPreset(track, fxIndex, presetPath.c_str()); + + // The preset file is transient regardless of outcome; best-effort cleanup (temp dir). + 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 +115,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& 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; diff --git a/src/instrument_drop_win.h b/src/instrument_drop_win.h index 11ea7d2..20a65cb 100644 --- a/src/instrument_drop_win.h +++ b/src/instrument_drop_win.h @@ -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 +#include +#include // 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& 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& presetBytes); } // namespace reasampler diff --git a/src/vst/reasampler_uid.h b/src/vst/reasampler_uid.h new file mode 100644 index 0000000..28e9c0d --- /dev/null +++ b/src/vst/reasampler_uid.h @@ -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 diff --git a/src/vst/reasampler_vst.h b/src/vst/reasampler_vst.h index b6343f5..abd5fc8 100644 --- a/src/vst/reasampler_vst.h +++ b/src/vst/reasampler_vst.h @@ -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, diff --git a/tests/test_instrument_drop.cpp b/tests/test_instrument_drop.cpp index 6c28c48..69151f5 100644 --- a/tests/test_instrument_drop.cpp +++ b/tests/test_instrument_drop.cpp @@ -1,14 +1,18 @@ // Standalone tests for reasampler::instrument_drop — no REAPER, no VST3 SDK, no framework. -// The S17 drop-and-load blob-construction contract: the extension builds a vst_chunk blob -// whose bytes are EXACTLY what ReaSampler 9000's own setState (deserializeComponentState) -// accepts, with the dragged capture pre-selected. The round-trip proof (build -> base64 -// decode -> the instrument's OWN reader -> assert the capture selected) IS the cross-artifact -// contract guard — the same pattern assignment_request_tests uses for its wire format. +// The S17 drop-and-load payload contract (S-GA-DropFX revision): the extension builds a +// Steinberg-format .vstpreset image whose 'Comp' chunk is EXACTLY what ReaSampler 9000's own +// setState (deserializeComponentState) accepts, with the dragged capture pre-selected, and +// whose header carries the channel-active class ID. The round-trip proof (build -> parse the +// container -> the instrument's OWN reader -> assert the capture selected) IS the +// cross-artifact contract guard — the same pattern assignment_request_tests uses. #include "../src/instrument_drop.h" #include "../src/vst/sample_map.h" // deserializeComponentState — the instrument's OWN reader +#include "version_generated.h" // REASAMPLER_CHANNEL_IS_BETA — pins the per-channel class ID + #include +#include #include #include @@ -18,112 +22,176 @@ static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) -// A representative project rate for the reader (the legacy-v3 conversion parameter; our v5 -// blob never consumes it, but the reader signature requires a positive rate). +// A representative project rate for the reader (the legacy-v3 conversion parameter; our +// current-version blob never consumes it, but the reader signature requires a positive rate). static constexpr double kRate = 48000.0; -// THE contract test: a blob built for a capture id decodes — through the instrument's OWN -// reader — to a ComponentState with THAT id selected, no zones, default mono. If this fails, -// the extension would inject bytes the instrument's setState rejects and the drop would load -// a silent/wrong instance. -static void testBlobRoundTripsThroughInstrumentReader() { +// --- A tiny independent .vstpreset reader (test-local, little-endian) ---------- +// Mirrors public.sdk/source/vst/vstpresetfile.cpp's READ side so the builder is proven +// against an independent decode, not against itself. + +static std::uint32_t readU32LE(const std::vector& b, std::size_t at) { + return static_cast(b[at]) | (static_cast(b[at + 1]) << 8) | + (static_cast(b[at + 2]) << 16) | + (static_cast(b[at + 3]) << 24); +} +static std::uint64_t readU64LE(const std::vector& b, std::size_t at) { + std::uint64_t v = 0; + for (int i = 7; i >= 0; --i) v = (v << 8) | b[at + static_cast(i)]; + return v; +} +static bool fourCCAt(const std::vector& b, std::size_t at, const char* id) { + return b.size() >= at + 4 && b[at] == static_cast(id[0]) && + b[at + 1] == static_cast(id[1]) && + b[at + 2] == static_cast(id[2]) && + b[at + 3] == static_cast(id[3]); +} + +struct ParsedPreset { + bool ok = false; + std::string classId; + std::vector compChunk; +}; + +static ParsedPreset parsePreset(const std::vector& b) { + ParsedPreset p; + if (b.size() < 48) return p; + if (!fourCCAt(b, 0, "VST3")) return p; // header magic + if (readU32LE(b, 4) != 1) return p; // kFormatVersion + p.classId.assign(b.begin() + 8, b.begin() + 40); // 32-char ASCII class ID + const std::uint64_t listOffset = readU64LE(b, 40); + if (listOffset + 8 > b.size()) return p; + if (!fourCCAt(b, static_cast(listOffset), "List")) return p; + const std::uint32_t count = readU32LE(b, static_cast(listOffset) + 4); + std::size_t at = static_cast(listOffset) + 8; + for (std::uint32_t i = 0; i < count; ++i, at += 20) { + if (at + 20 > b.size()) return p; + const std::uint64_t off = readU64LE(b, at + 4); + const std::uint64_t size = readU64LE(b, at + 12); + if (off + size > b.size()) return p; + if (fourCCAt(b, at, "Comp")) { + p.compChunk.assign(b.begin() + static_cast(off), + b.begin() + static_cast(off + size)); + p.ok = true; + } + } + return p; +} + +// --- class-ID derivation ------------------------------------------------------ + +// The class-ID string is pinned PER CHANNEL to the FROZEN UID rendered as FUID::toString +// would render it (the four INLINE_UID words as %08X in order — platform-stable, see +// instrument_drop.h). These literals are derived independently from the frozen constants in +// reasampler_uid.h; if this fails, the .vstpreset would address a class the loaded VST does +// not present and TrackFX_SetPreset would refuse the drop. +static void testClassIdHexPinnedPerChannel() { +#if REASAMPLER_CHANNEL_IS_BETA + CHECK(vstClassIdHex() == "CCFFEB3A4FF532A69E1817984256955F"); +#else + CHECK(vstClassIdHex() == "5E45A11E9C7B4D6AB1E3F2084A6C1D9F"); +#endif + CHECK(vstClassIdHex().size() == 32); +} + +// --- the drop payload contract ------------------------------------------------ + +// THE contract test: a preset built for a capture id parses as a well-formed VST3 preset +// container addressed to the channel-active class, whose Comp chunk decodes — through the +// instrument's OWN reader — to a ComponentState with THAT id selected, no zones, default +// mono. If this fails, the drop would instantiate a blank/wrong instance. +static void testPresetRoundTripsThroughInstrumentReader() { const std::string id = "cap-7f3a-guid"; - const std::string b64 = buildInstrumentDropChunk(id); - CHECK(!b64.empty()); + const std::vector preset = buildInstrumentDropPreset(id); + CHECK(!preset.empty()); - const std::vector bytes = decodeBase64(b64); - CHECK(!bytes.empty()); - // The base64 must decode to EXACTLY the pre-encode state bytes (no corruption). - CHECK(bytes == instrumentDropStateBytes(id)); + const ParsedPreset p = parsePreset(preset); + CHECK(p.ok); + CHECK(p.classId == vstClassIdHex()); + // The Comp chunk must be EXACTLY the pre-wrap state bytes (no corruption, no framing + // bleed into the component stream). + CHECK(p.compChunk == instrumentDropStateBytes(id)); - const ComponentState cs = deserializeComponentState(bytes, kRate); + const ComponentState cs = deserializeComponentState(p.compChunk, kRate); CHECK(cs.selectionId == id); // the capture IS selected — the whole point CHECK(cs.map.zones.empty()); // a drop selects one capture, authors no zones CHECK(cs.channelMode == ChannelMode::Mono); // fresh-instance default CHECK(cs.lastConsumedAssignGeneration == 0); // fresh instance, no consumed assign } +// Container layout invariants pinned against the reference writer's constants +// (public.sdk vstpresetfile.cpp: kHeaderSize 48, data area first, list last). +static void testPresetLayoutInvariants() { + const std::string id = "abc"; + const std::vector state = instrumentDropStateBytes(id); + const std::vector preset = buildInstrumentDropPreset(id); + + CHECK(fourCCAt(preset, 0, "VST3")); + CHECK(readU32LE(preset, 4) == 1); + const std::uint64_t listOffset = readU64LE(preset, 40); + CHECK(listOffset == 48 + state.size()); // Comp data sits at offset 48 + CHECK(fourCCAt(preset, static_cast(listOffset), "List")); + CHECK(readU32LE(preset, static_cast(listOffset) + 4) == 1); // one entry + CHECK(fourCCAt(preset, static_cast(listOffset) + 8, "Comp")); + CHECK(readU64LE(preset, static_cast(listOffset) + 12) == 48); + CHECK(readU64LE(preset, static_cast(listOffset) + 20) == state.size()); + CHECK(preset.size() == static_cast(listOffset) + 8 + 20); // nothing trails +} + // A GUID-shaped id with bytes that would trip a naive delimiter-based encoder round-trips -// whole (the length-prefixed component-state framing + base64 carry arbitrary bytes). +// whole (the length-prefixed component-state framing carries arbitrary bytes). static void testGuidLikeIdRoundTrips() { const std::string id = "{9A2F0C11-4B6E-4D01-8F3A-0011223344FF}"; - const std::vector bytes = decodeBase64(buildInstrumentDropChunk(id)); - const ComponentState cs = deserializeComponentState(bytes, kRate); + const ParsedPreset p = parsePreset(buildInstrumentDropPreset(id)); + CHECK(p.ok); + const ComponentState cs = deserializeComponentState(p.compChunk, kRate); CHECK(cs.selectionId == id); } -// An empty id yields the empty-state blob: it still decodes cleanly to {"", no zones} — the +// An empty id yields the empty-state preset: it still parses cleanly to {"", no zones} — the // S10 silent empty state. (The shell guards against dropping nothing; the pure contract holds.) static void testEmptyIdYieldsEmptyState() { - const std::vector bytes = decodeBase64(buildInstrumentDropChunk("")); - CHECK(!bytes.empty()); // still a versioned envelope, just an empty selection - const ComponentState cs = deserializeComponentState(bytes, kRate); + const ParsedPreset p = parsePreset(buildInstrumentDropPreset("")); + CHECK(p.ok); + CHECK(!p.compChunk.empty()); // still a versioned envelope, just an empty selection + const ComponentState cs = deserializeComponentState(p.compChunk, kRate); CHECK(cs.selectionId.empty()); CHECK(cs.map.zones.empty()); } -// Deterministic: the same id always produces the same blob (no time/random in the path). +// Deterministic: the same id always produces the same bytes (no time/random in the path). static void testDeterministic() { - CHECK(buildInstrumentDropChunk("abc") == buildInstrumentDropChunk("abc")); - CHECK(buildInstrumentDropChunk("abc") != buildInstrumentDropChunk("abd")); + CHECK(buildInstrumentDropPreset("abc") == buildInstrumentDropPreset("abc")); + CHECK(buildInstrumentDropPreset("abc") != buildInstrumentDropPreset("abd")); } -// --- base64 codec unit coverage (the encode side the shell actually ships) ----- - -static std::vector b(std::initializer_list v) { - std::vector out; - for (int x : v) out.push_back(static_cast(x)); - return out; +// A malformed class ID (not exactly 32 chars) yields an empty image — contract violation, +// never a truncated/garbage preset handed to REAPER. +static void testBadClassIdRejected() { + const std::vector state = instrumentDropStateBytes("x"); + CHECK(buildVstPresetBytes("TOO-SHORT", state).empty()); + CHECK(buildVstPresetBytes(std::string(33, 'A'), state).empty()); + CHECK(!buildVstPresetBytes(std::string(32, 'A'), state).empty()); } -// Known RFC-4648 vectors, incl. every padding case (0/1/2 trailing bytes). -static void testBase64KnownVectors() { - CHECK(encodeBase64(b({})) == ""); - CHECK(encodeBase64(b({'f'})) == "Zg=="); - CHECK(encodeBase64(b({'f', 'o'})) == "Zm8="); - CHECK(encodeBase64(b({'f', 'o', 'o'})) == "Zm9v"); - CHECK(encodeBase64(b({'f', 'o', 'o', 'b'})) == "Zm9vYg=="); - CHECK(encodeBase64(b({'f', 'o', 'o', 'b', 'a'})) == "Zm9vYmE="); - CHECK(encodeBase64(b({'f', 'o', 'o', 'b', 'a', 'r'})) == "Zm9vYmFy"); -} - -// encode -> decode is identity across every residue class + all-byte values. -static void testBase64RoundTripAllBytes() { - for (int len = 0; len <= 300; ++len) { - std::vector in; - for (int i = 0; i < len; ++i) in.push_back(static_cast((i * 37 + 11) & 0xFF)); - CHECK(decodeBase64(encodeBase64(in)) == in); - } -} - -// Malformed decode inputs return empty (never throw / never UB): bad length, illegal char, -// misplaced padding. -static void testBase64DecodeRejectsMalformed() { - CHECK(decodeBase64("Zg=").empty()); // length not a multiple of 4 - CHECK(decodeBase64("Zm9v!ba=").empty()); // illegal char '!' - CHECK(decodeBase64("Z===").empty()); // illegal char in v1 position - CHECK(decodeBase64("Zg==Zg==").empty()); // interior padding (pad before the final quad) -} - -// --- FX-hotspot classification (S-VIEW-BUG-1) --------------------------------- +// --- FX-hotspot classification (S-VIEW-BUG-1 / S-GA-DropFX) ------------------- // -// THE BUG: dropping a capture onto a track's FX button (in the TCP) never armed the -// instrument drop, because the old predicate matched only "fx_" — which the SDK reserves for -// the FX CHAIN / FLOATING-FX windows. A track-panel FX-button hit reports "tcp.fx"/"mcp.fx" -// (SDK §GetThingFromPoint: "string will begin with 'tcp' or 'mcp' or 'tcp.mute' etc"). -// -// THE FIX: narrow to two documented FX-bearing surfaces: -// * "tcp.fx" / "mcp.fx" — the TCP/MCP FX button (exact token, NOT bare tcp/mcp) -// * "fx_*" — the FX-chain / floating-FX windows (prefix, as before) -// Bare "tcp"/"mcp" and any other "tcp.*"/"mcp.*" sub-element (e.g. "tcp.mute", "tcp.vol") -// are non-FX track-panel regions — an instrument drop must NOT fire there. +// THE RULE (prefix-based — see instrument_drop.h): "fx_*" names the FX-chain / floating-FX +// windows; "tcp.fx*" / "mcp.fx*" name the TCP/MCP FX button and its sibling FX sub-elements. +// The SDK warns GetThingFromPoint "may append additional information", so exact-token +// matching (the previous, DAW-falsified predicate) is wrong; the prefix family is the +// documented-adjacent surface. Bare "tcp"/"mcp" and non-FX sub-elements are NOT hotspots. -// The TCP/MCP FX button specifically — the surface that arms an instrument drop (S-VIEW-BUG-1 -// fix). Under the old "fx_"-only predicate these returned false — the exact miss that produced -// the "drops as audio to arrange" symptom on the FX button. -static void testTcpMcpFxButtonIsHotspot() { - CHECK(infoNamesFxHotspot("tcp.fx")); // TCP FX button (SDK token) - CHECK(infoNamesFxHotspot("mcp.fx")); // MCP FX area (SDK token) +// The TCP/MCP FX-button family arms an instrument drop — including sibling FX sub-elements +// and tokens with appended information. +static void testTcpMcpFxFamilyIsHotspot() { + CHECK(infoNamesFxHotspot("tcp.fx")); // TCP FX button (WALTER element name) + CHECK(infoNamesFxHotspot("mcp.fx")); // MCP FX button + CHECK(infoNamesFxHotspot("tcp.fxbyp")); // FX bypass — sibling FX element + CHECK(infoNamesFxHotspot("tcp.fxparm")); // FX param knob area — sibling FX element + CHECK(infoNamesFxHotspot("mcp.fxlist")); // MCP FX insert list + CHECK(infoNamesFxHotspot("tcp.fx.1")); // appended info (SDK: "may append...") + CHECK(infoNamesFxHotspot("tcp.fx extra")); // appended info, arbitrary form } // The FX chain / floating-FX windows (the surfaces the ORIGINAL predicate matched) still @@ -142,6 +210,7 @@ static void testNonFxSurfacesAreNotHotspot() { CHECK(!infoNamesFxHotspot("mcp")); // bare mixer control panel — NOT an FX hotspot CHECK(!infoNamesFxHotspot("tcp.mute")); // mute button — track panel, not FX CHECK(!infoNamesFxHotspot("tcp.vol")); // volume fader — track panel, not FX + CHECK(!infoNamesFxHotspot("tcp.f")); // truncated non-FX token — prefix must be whole CHECK(!infoNamesFxHotspot("arrange")); CHECK(!infoNamesFxHotspot("spacer_0")); CHECK(!infoNamesFxHotspot("")); // pointer over nothing REAPER classifies @@ -150,15 +219,15 @@ static void testNonFxSurfacesAreNotHotspot() { } int main() { - testBlobRoundTripsThroughInstrumentReader(); + testClassIdHexPinnedPerChannel(); + testPresetRoundTripsThroughInstrumentReader(); + testPresetLayoutInvariants(); testGuidLikeIdRoundTrips(); testEmptyIdYieldsEmptyState(); testDeterministic(); - testBase64KnownVectors(); - testBase64RoundTripAllBytes(); - testBase64DecodeRejectsMalformed(); + testBadClassIdRejected(); - testTcpMcpFxButtonIsHotspot(); + testTcpMcpFxFamilyIsHotspot(); testFxWindowStillHotspot(); testNonFxSurfacesAreNotHotspot();