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_*
This commit is contained in:
+155
-86
@@ -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 <cstdio>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -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<std::uint8_t>& b, std::size_t at) {
|
||||
return static_cast<std::uint32_t>(b[at]) | (static_cast<std::uint32_t>(b[at + 1]) << 8) |
|
||||
(static_cast<std::uint32_t>(b[at + 2]) << 16) |
|
||||
(static_cast<std::uint32_t>(b[at + 3]) << 24);
|
||||
}
|
||||
static std::uint64_t readU64LE(const std::vector<std::uint8_t>& b, std::size_t at) {
|
||||
std::uint64_t v = 0;
|
||||
for (int i = 7; i >= 0; --i) v = (v << 8) | b[at + static_cast<std::size_t>(i)];
|
||||
return v;
|
||||
}
|
||||
static bool fourCCAt(const std::vector<std::uint8_t>& b, std::size_t at, const char* id) {
|
||||
return b.size() >= at + 4 && b[at] == static_cast<std::uint8_t>(id[0]) &&
|
||||
b[at + 1] == static_cast<std::uint8_t>(id[1]) &&
|
||||
b[at + 2] == static_cast<std::uint8_t>(id[2]) &&
|
||||
b[at + 3] == static_cast<std::uint8_t>(id[3]);
|
||||
}
|
||||
|
||||
struct ParsedPreset {
|
||||
bool ok = false;
|
||||
std::string classId;
|
||||
std::vector<std::uint8_t> compChunk;
|
||||
};
|
||||
|
||||
static ParsedPreset parsePreset(const std::vector<std::uint8_t>& 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<std::size_t>(listOffset), "List")) return p;
|
||||
const std::uint32_t count = readU32LE(b, static_cast<std::size_t>(listOffset) + 4);
|
||||
std::size_t at = static_cast<std::size_t>(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<std::ptrdiff_t>(off),
|
||||
b.begin() + static_cast<std::ptrdiff_t>(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<std::uint8_t> preset = buildInstrumentDropPreset(id);
|
||||
CHECK(!preset.empty());
|
||||
|
||||
const std::vector<std::uint8_t> 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<std::uint8_t> state = instrumentDropStateBytes(id);
|
||||
const std::vector<std::uint8_t> 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<std::size_t>(listOffset), "List"));
|
||||
CHECK(readU32LE(preset, static_cast<std::size_t>(listOffset) + 4) == 1); // one entry
|
||||
CHECK(fourCCAt(preset, static_cast<std::size_t>(listOffset) + 8, "Comp"));
|
||||
CHECK(readU64LE(preset, static_cast<std::size_t>(listOffset) + 12) == 48);
|
||||
CHECK(readU64LE(preset, static_cast<std::size_t>(listOffset) + 20) == state.size());
|
||||
CHECK(preset.size() == static_cast<std::size_t>(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<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> b(std::initializer_list<int> v) {
|
||||
std::vector<std::uint8_t> out;
|
||||
for (int x : v) out.push_back(static_cast<std::uint8_t>(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<std::uint8_t> 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<std::uint8_t> in;
|
||||
for (int i = 0; i < len; ++i) in.push_back(static_cast<std::uint8_t>((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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user