testhost: prove the actual chunk-based state path via raw VST2 dispatcher

Add Vst2RawChunk, a raw effGetChunk/effSetChunk bypass around JUCE's
VSTPluginInstance::usesChunks() gate, plus --loadBankChunk/--saveBankChunk/
--loadPatchChunk/--savePatchChunk CLI flags and a new test_chunk_roundtrip
regression test asserting the saved file is genuinely zlib/XML (0x78 header,
>1000 bytes), not a per-parameter fallback.

Building and using this bypass disproved an earlier documented finding: the
compiled VST2 wrapper does set effFlagsProgramChunks and usesChunks() is
genuinely true, so the previous "chunk-advertisement gap" theory in TODO.md
and tests/README.md was wrong. Corrected in both places. The real cause of
old bundled .fxp sample files having no effect remains open and is now
narrowed to a JaySynth-side XML/schema parsing question, not a host/plugin
capability mismatch.

Also adds a PluginHost-method-to-VST2-wiring-to-test coverage table in
tests/README.md, and documents the remaining "exercised but not asserted"
gaps (channel/param counts, DSP correctness) in TODO.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dhtwRLARk4eiPngcQykLJ
This commit is contained in:
2026-07-27 21:22:24 +02:00
co-authored by Claude Sonnet 5
parent 2a0ea726f3
commit 8194bda9c8
7 changed files with 363 additions and 58 deletions
+50
View File
@@ -0,0 +1,50 @@
#include "Vst2RawChunk.h"
// Only needed here - everywhere else in this tool talks to the
// format-agnostic AudioPluginInstance/AudioProcessor API, not the raw VST2
// SDK structs.
#include <pluginterfaces/vst2.x/aeffectx.h>
namespace Vst2RawChunk
{
void *getRawAEffect (AudioPluginInstance *instance)
{
if (instance == nullptr)
return nullptr;
AEffect *effect = (AEffect *) instance->getPlatformSpecificData();
if (effect == nullptr || effect->magic != kEffectMagic)
return nullptr;
return effect;
}
bool getChunk (AudioPluginInstance *instance, bool isPreset, MemoryBlock &outData)
{
AEffect *effect = (AEffect *) getRawAEffect (instance);
if (effect == nullptr)
return false;
void *chunkPtr = nullptr;
const VstIntPtr bytes = effect->dispatcher (effect, effGetChunk, isPreset ? 1 : 0, 0, &chunkPtr, 0.0f);
if (bytes <= 0 || chunkPtr == nullptr)
return false;
// The plugin owns chunkPtr's memory (valid only until the next call
// into it) - copy it out immediately.
outData.replaceWith (chunkPtr, (size_t) bytes);
return true;
}
bool setChunk (AudioPluginInstance *instance, bool isPreset, const void *data, int size)
{
AEffect *effect = (AEffect *) getRawAEffect (instance);
if (effect == nullptr || size <= 0)
return false;
effect->dispatcher (effect, effSetChunk, isPreset ? 1 : 0, size, (void *) data, 0.0f);
return true;
}
}
+38
View File
@@ -0,0 +1,38 @@
#ifndef TESTHOST_VST2RAWCHUNK_H_INCLUDED
#define TESTHOST_VST2RAWCHUNK_H_INCLUDED
#include <JuceHeader.h>
/** Deliberately VST2-specific (unlike everything else in this tool): talks
to the raw AEffect dispatcher directly, bypassing JUCE's own
VSTPluginInstance::usesChunks() gate.
Why this exists: JUCE's generic AudioProcessor::getStateInformation()/
setStateInformation() only reach a VST2 plugin's real effGetChunk/
effSetChunk implementation if the plugin's AEffect.flags advertises
effFlagsProgramChunks. JaySynth's compiled JUCE 3.1.1 VST wrapper never
sets that flag (see tools/testhost/TODO.md's Investigate section), so
the generic path silently falls back to JUCE's own per-parameter
"regular" fxb/fxp format instead - which never touches JaySynth's own
bankEncodeXml/bankDecodeXml or patchEncodeXml/patchDecodeXml at all.
Many real-world VST2 hosts don't strictly gate on that flag either -
they just call effGetChunk/effSetChunk and see what comes back. This
does the same thing on purpose, to reach and test that actual code
path.
*/
namespace Vst2RawChunk
{
/** Returns nullptr if instance isn't hosted via VSTPluginFormat (or the
AEffect's magic number doesn't check out). */
void *getRawAEffect (AudioPluginInstance *instance);
/** isPreset: true = patch (single program) chunk, false = bank (whole
plugin) chunk - matches VST2's effGetChunk/effSetChunk "index"
argument (see aeffect.h). Returns false if the plugin isn't VST2,
or the dispatcher call returned no data. */
bool getChunk (AudioPluginInstance *instance, bool isPreset, MemoryBlock &outData);
bool setChunk (AudioPluginInstance *instance, bool isPreset, const void *data, int size);
}
#endif // TESTHOST_VST2RAWCHUNK_H_INCLUDED
+1
View File
@@ -5,3 +5,4 @@ CXX_SRCS += PluginHost.cpp
CXX_SRCS += AudioInputSource.cpp
CXX_SRCS += WavRecorder.cpp
CXX_SRCS += TestScenario.cpp
CXX_SRCS += Vst2RawChunk.cpp
+60
View File
@@ -8,6 +8,7 @@
#include "WavRecorder.h"
#include "TestScenario.h"
#include "PerformanceStats.h"
#include "Vst2RawChunk.h"
namespace
{
@@ -46,6 +47,12 @@ namespace
"Patch/bank state is exactly what JUCE's AudioPluginInstance::get/setCurrentProgram-\n"
"StateInformation and get/setStateInformation read and write for a hosted VST2\n"
"plugin - i.e. real .fxp/.fxb file bytes.\n"
"\n"
"Raw VST2 chunk options (bypass JUCE's usesChunks() gate - see Vst2RawChunk.h):\n"
" --loadBankChunk <file> effSetChunk(index=0) directly with the file's raw bytes\n"
" --saveBankChunk <file> effGetChunk(index=0) directly, write the raw bytes out\n"
" --loadPatchChunk <file> effSetChunk(index=1) directly with the file's raw bytes\n"
" --savePatchChunk <file> effGetChunk(index=1) directly, write the raw bytes out\n"
);
}
@@ -56,6 +63,7 @@ namespace
// Ad-hoc mode only (ignored if scenarioFile is set):
String patchFile, bankFile, savePatchFile, saveBankFile, wavFile;
String loadBankChunkFile, saveBankChunkFile, loadPatchChunkFile, savePatchChunkFile;
double durationSeconds = 2.0;
double sampleRate = 44100.0;
int blockSize = 512;
@@ -90,6 +98,14 @@ namespace
args.savePatchFile = argv[++i];
else if (arg == "--saveBank" && i + 1 < argc)
args.saveBankFile = argv[++i];
else if (arg == "--loadBankChunk" && i + 1 < argc)
args.loadBankChunkFile = argv[++i];
else if (arg == "--saveBankChunk" && i + 1 < argc)
args.saveBankChunkFile = argv[++i];
else if (arg == "--loadPatchChunk" && i + 1 < argc)
args.loadPatchChunkFile = argv[++i];
else if (arg == "--savePatchChunk" && i + 1 < argc)
args.savePatchChunkFile = argv[++i];
else if (arg == "--wav" && i + 1 < argc)
args.wavFile = argv[++i];
else if (arg == "--duration" && i + 1 < argc)
@@ -227,6 +243,40 @@ namespace
fprintf (stderr, "testhost: could not write '%s'\n", path.toRawUTF8());
}
// isPreset: true = patch chunk, false = bank chunk (matches VST2's
// effGetChunk/effSetChunk index argument). See Vst2RawChunk.h for why
// this bypasses the generic AudioProcessor state calls above.
bool loadRawChunkFile (const String &path, bool isPreset, PluginHost &host)
{
MemoryBlock data;
if (! File (path).loadFileAsData (data) || data.getSize() == 0)
{
fprintf (stderr, "testhost: could not read '%s'\n", path.toRawUTF8());
return false;
}
if (! Vst2RawChunk::setChunk (host.getInstance(), isPreset, data.getData(), (int) data.getSize()))
{
fprintf (stderr, "testhost: raw VST2 setChunk failed (not a VST2 plugin?)\n");
return false;
}
return true;
}
void saveRawChunkFile (const String &path, bool isPreset, PluginHost &host)
{
MemoryBlock data;
if (! Vst2RawChunk::getChunk (host.getInstance(), isPreset, data))
{
fprintf (stderr, "testhost: raw VST2 getChunk failed (not a VST2 plugin?)\n");
return;
}
if (! File (path).replaceWithData (data.getData(), data.getSize()))
fprintf (stderr, "testhost: could not write '%s'\n", path.toRawUTF8());
}
void applyEvent (PluginHost &host, const TestScenario::Event &event, MidiBuffer &midi, int sampleOffsetInBlock)
{
switch (event.type)
@@ -300,6 +350,12 @@ int main (int argc, char *argv[])
if (scenario.initialLoadPatchFile.isNotEmpty() && ! loadStateFile (scenario.initialLoadPatchFile, false, host))
return 1;
if (args.loadBankChunkFile.isNotEmpty() && ! loadRawChunkFile (args.loadBankChunkFile, false, host))
return 1;
if (args.loadPatchChunkFile.isNotEmpty() && ! loadRawChunkFile (args.loadPatchChunkFile, true, host))
return 1;
if (args.printParams)
{
for (int i = 0; i < host.getNumParameters(); ++i)
@@ -394,6 +450,10 @@ int main (int argc, char *argv[])
saveStateFile (args.savePatchFile, false, host);
if (args.saveBankFile.isNotEmpty())
saveStateFile (args.saveBankFile, true, host);
if (args.saveBankChunkFile.isNotEmpty())
saveRawChunkFile (args.saveBankChunkFile, false, host);
if (args.savePatchChunkFile.isNotEmpty())
saveRawChunkFile (args.savePatchChunkFile, true, host);
host.releaseResources();