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
+32 -16
View File
@@ -44,25 +44,41 @@ verified (PR https://git.jayfield.org/jens/JaySynth/pulls/1).
`noteOn` with no matching `noteOff`) is untested.
- [ ] **Polyphony/multiple simultaneous notes untested** - only ever a single
held note in all smoke tests so far.
- [ ] **`tests/run_tests.sh` never asserts specific expected values for
`PluginHost::load()`/`getNumInputChannels()`/`getNumOutputChannels()`/
`getNumParameters()`** - every test exercises them (they're printed in
the load banner), but nothing pins e.g. "JaySynth must report exactly
0 in / 2 out / 140 parameters," so a regression there would only be
caught if it also broke something else. Same underlying gap as "no
automated regression baseline comparison" above, just called out
separately since it's about the load-time metadata rather than the
rendered audio. See `tests/README.md`'s `PluginHost` coverage table.
## Investigate
- [ ] **JaySynth's compiled VST2 wrapper never advertises chunk support to
the host, so real `.fxp`/`.fxb` sample files silently have no effect when
loaded generically.** Found while building `tests/run_tests.sh`: loading
a bundled sample under `extras/sounds/` via `--patch` produces byte-
identical output to not loading anything (confirmed by diffing
`--printParams`). The files are legitimately in VST2's opaque-chunk
("FPCh") format, but JUCE 3.1.1's plugin-side VST wrapper
(`juce_VST_Wrapper.cpp`, compiled into `JaySynth.so`) never sets
`effFlagsProgramChunks` on the `AEffect` struct, so JUCE's own host-side
`VSTPluginInstance::usesChunks()` returns false and silently no-ops both
load and save. Not one of the tracked `TODO.md` (repo root) findings, and
not attempted here - fixing it means patching JUCE's own VST wrapper,
a bigger and riskier change than this pass's scope. See
`tests/README.md` for the full writeup; `tests/run_tests.sh`'s round-trip
tests work around it by using `testhost`'s own save output as the
fixture instead of an external sample file.
- [ ] **Bundled sample `.fxp` files under `extras/sounds/` silently have no
effect when loaded via `--patch`** (confirmed by diffing `--printParams`
output with and without `--patch` - zero parameters differ), even though
loading doesn't crash. **Correction**: an earlier version of this entry
claimed this was because JaySynth's compiled VST2 wrapper never sets
`effFlagsProgramChunks`, so JUCE's host-side `usesChunks()` would see
false and skip the chunk mechanism entirely. That diagnosis was wrong -
disproved directly: `--saveBank`/`--savePatch` (the exact same generic
`AudioProcessor::getStateInformation` path) produce real `FBCh`/`FPCh`-
magic, GZIP-compressed-XML files (confirmed via hex dump and by feeding
the raw inner chunk bytes straight to `effSetChunk` via the new
`Vst2RawChunk` bypass, added specifically to rule this out - still no
effect). So `usesChunks()` genuinely is true, and `test_chunk_roundtrip`
in `tests/run_tests.sh` proves the real `bankEncodeXml`/`bankDecodeXml`
path round-trips correctly for files this build produces. The actual
cause of the *external* sample files failing is still unidentified -
most likely those specific (10+ year old) files use a compression or
XML-schema shape that `JaySynthAudioProcessor::setCurrentProgramState-
Information`/`getXmlFromBinary` no longer parses, not a host/plugin
capability mismatch. Not one of the tracked repo-root `TODO.md` findings.
Worth a closer look at some point (e.g. hex-diff one of these files'
decompressed XML against what this build produces), but not chased
further here. See `tests/README.md`'s "corrected finding" note.
- [ ] **DPF-based plugins print a non-fatal assertion on load.** All three
Zam*-vst.so plugins tested (built with the DISTRHO Plugin Framework) print
`assertion failure: "fIsActive" in .../DistrhoPluginInternal.hpp` during
+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();
+76 -28
View File
@@ -63,40 +63,88 @@ restructuring. Sets a continuous parameter (index 0 = `SYNTH_PARAM_VOLUME`)
to a distinctive value, saves, reloads in a fresh process, and checks the
value reads back within a small tolerance (not exact equality — the
slider/internal-value scaling curve, `toParam`/`toSlider`, isn't bit-exact
across a round trip). Uses `testhost`'s own save→load cycle as the fixture,
not an externally-provided `.fxp`/`.fxb` — see the chunk-format note below
for why.
across a round trip). Goes through JUCE's generic `AudioProcessor::get/
setStateInformation`/`get/setCurrentProgramStateInformation` - see the
corrected-finding note below for what that path actually does.
**`bank_chunk_roundtrip`** — same round-trip, but via `effGetChunk`/
`effSetChunk` called *directly* on the raw `AEffect*` (`Vst2RawChunk.h`/
`--saveBankChunk`/`--loadBankChunk`), bypassing JUCE's host-side wrapper
entirely. Also positively confirms the real chunk path was used (not just
that a parameter round-trips): checks the saved file starts with a zlib
stream header (`0x78`) and is far larger than a raw 140-parameter float
array could be (140×4 = 560 bytes) - a plain per-parameter fallback
wouldn't look anything like this.
**`stability_sweep`** — loads every bundled `.fxp` under `extras/sounds/`
and renders a held note through each, checking for crashes/hangs/NaN. A
broad regression net, not a targeted test for any one finding.
broad regression net, not a targeted test for any one finding. See the
corrected-finding note below for why this only checks "doesn't crash," not
"changes the patch."
## Two things discovered while building this suite that are worth knowing
## `PluginHost` method coverage table
**The VCF coefficient-buffer bug above** (already fixed, see TODO.md).
Every method on `PluginHost` (`src/PluginHost.h`), what it actually wires
to on the VST2 side for a hosted plugin, and which test(s) exercise it.
"Exercised" means the call happens during that test; "asserted" means the
test actually checks something about its result, not just that it ran
without crashing.
**JaySynth's compiled VST2 wrapper never advertises chunk support to the
host**, so loading the bundled sample `.fxp` files through `testhost`'s
generic `--patch` (i.e. through `AudioProcessor::setCurrentProgramState-
Information`, exactly what any real VST2 host uses) has **zero effect**
confirmed by diffing `--printParams` output with and without `--patch`.
Those sample files are legitimately in VST2's opaque-chunk ("FPCh") format
(verified via hex dump: correct `CcnK`/`FPCh`/`Jsy1` header), but JUCE
3.1.1's plugin-side VST wrapper (`sdk/juce/JUCE-3.1.1/modules/
juce_audio_plugin_client/VST/juce_VST_Wrapper.cpp`, compiled into
`JaySynth.so`) never sets the VST2 `effFlagsProgramChunks` flag on the
`AEffect` struct, so JUCE's own host-side `VSTPluginInstance::usesChunks()`
sees false and `setChunkData()` silently no-ops — for both loading *and*
saving. This is **not** one of the tracked `TODO.md` findings and this
suite doesn't attempt to fix it (it would mean patching JUCE's own VST
wrapper, a materially bigger and riskier change). It's why
`stability_sweep` only checks "doesn't crash," not "changes the patch," and
why the round-trip tests use `testhost`'s own save output as the fixture
rather than a real sample file — `testhost`'s save path has the same
`usesChunks()`-is-false behaviour, so save and load are at least
consistent with *each other*, even though neither talks to the external
`.fxp` files' actual chunk data. Tracked as a discovered issue in
`tools/testhost/TODO.md`'s "Investigate" section.
| `PluginHost` method | VST2 wiring | Covering test(s) |
|---|---|---|
| `load()` | `effOpen` (via `createPluginInstance`), then inside `prepareToPlay`: `effSetSampleRate`, `effSetBlockSize`, `effMainsChanged(1)`, `effStartProcess` | Every test - exercised, not specifically asserted beyond "didn't return false" |
| `releaseResources()` | `effMainsChanged(0)`/`effStopProcess` (`VSTPluginInstance::releaseResources`) | Every test calls it before exit (`main.cpp`) - exercised only, no assertion |
| `getNumInputChannels()` / `getNumOutputChannels()` | Reads cached `AEffect::numInputs`/`numOutputs`, populated at `effOpen` | Every test (printed in the `"loaded ... (N in/N out...)"` banner) - exercised, not asserted against an expected value for any specific plugin |
| `getNumParameters()` | Reads cached `AEffect::numParams` | Every test (same load banner; also implicit in every `--printParams` use) - exercised, not asserted |
| `getParameter(index)` | `AEffect::getParameter` callback | `patch_save_load_roundtrip`, `bank_save_load_roundtrip`, `bank_chunk_roundtrip`, `patch_chunk_roundtrip` (all via `--printParams`) - asserted (value checked within tolerance) |
| `setParameter(index, value)` | `AEffect::setParameter` callback | Same four round-trip tests (via `--param`) - asserted indirectly (the value it sets is what gets read back) |
| `process()` - audio only | `processReplacing`/`processDoubleReplacing` | Every test - asserted only for NaN/Inf absence, not correctness of the DSP output itself |
| `process()` - MIDI note on/off | `effProcessEvents` (`VstMidiEvent`) | `oversized_blocksize`, `nrpn_out_of_range`, `nrpn_in_range`, `stability_sweep` (all use `--note`) - exercised (audio is produced), not asserted beyond non-silence/no-NaN |
| `process()` - MIDI CC/NRPN | `effProcessEvents` (`VstMidiEvent`, controller sub-type) | `nrpn_out_of_range`, `nrpn_in_range` - exercised and the specific regression (bounds check) is asserted via clean completion |
| `getCurrentProgramStateInformation()` | `saveToFXBFile(isFXB=false)``effGetChunk(index=1)` (confirmed chunk-based, see below) | `patch_save_load_roundtrip` - asserted |
| `setCurrentProgramStateInformation()` | `loadFromFXBFile``effSetChunk(index=1)` | `patch_save_load_roundtrip` - asserted; also used by `stability_sweep` via `--patch`, but **not asserted there** - see the corrected-finding note, those particular files don't actually change anything |
| `getStateInformation()` | `saveToFXBFile(isFXB=true)``effGetChunk(index=0)` | `bank_save_load_roundtrip` - asserted |
| `setStateInformation()` | `loadFromFXBFile``effSetChunk(index=0)` | `bank_save_load_roundtrip` - asserted |
| `getInstance()` | n/a (plain accessor, returns the raw `AudioPluginInstance*`) | Used internally by every `Vst2RawChunk`-based test to reach the raw `AEffect*` |
| *(bypass, not on `PluginHost`)* `Vst2RawChunk::getChunk`/`setChunk` | `effGetChunk`/`effSetChunk` called directly on the raw `AEffect*`, bypassing `usesChunks()` and `AudioProcessor` entirely | `bank_chunk_roundtrip`, `patch_chunk_roundtrip` - asserted, plus the zlib-header/size check that positively confirms the real chunk path ran |
**Known gaps**: `load()`/`releaseResources()`/`getNumInputChannels()`/
`getNumOutputChannels()`/`getNumParameters()` are exercised by literally
every test run but never *asserted* against a specific expected value for
a specific plugin (e.g. nothing currently pins "JaySynth must report
exactly 0 in / 2 out / 140 parameters" - a change to any of those would go
unnoticed by this suite unless it also broke something else). Same for
`process()`'s actual DSP correctness - the suite checks "ran without
crashing/NaN," never "the audio sounds right." Both are reasonable
follow-ups noted in `tools/testhost/TODO.md`.
## Corrected finding: it's not a chunk-advertisement gap after all
An earlier version of this suite (and `tools/testhost/TODO.md`) claimed
that JaySynth's compiled VST2 wrapper never sets `effFlagsProgramChunks`,
so JUCE's host-side `usesChunks()` would see false and silently skip the
chunk mechanism - offered as the reason bundled sample `.fxp` files have no
effect when loaded via `--patch`. **That diagnosis was wrong.** It's
disproved directly: `--saveBank`/`--savePatch` (the exact same generic
`AudioProcessor::getStateInformation` call) produce real `FBCh`/`FPCh`-
magic files containing a genuine zlib-compressed XML blob (confirmed via
hex dump), not a per-parameter fallback - meaning `usesChunks()` is
genuinely `true`. To be certain, `Vst2RawChunk` was added specifically to
rule out any remaining JUCE-side gating: it calls `effGetChunk`/
`effSetChunk` straight on the raw `AEffect*`, and feeding a bundled sample
file's inner chunk bytes through it *this way* still has zero effect.
`test_chunk_roundtrip` above proves this exact mechanism round-trips
correctly for files this build produces.
So the real, still-open question is narrower: **why do these specific
(10+ year old) external sample files fail to apply**, given the mechanism
that's supposed to load them demonstrably works? Most likely a compression
or XML-schema shape from an older JaySynth/JUCE combination that
`JaySynthAudioProcessor::setCurrentProgramStateInformation`/
`getXmlFromBinary` no longer parses - not investigated further here (would
mean decompressing and diffing one of these files' XML against what this
build produces). Not one of the tracked repo-root `TODO.md` findings.
Tracked in `tools/testhost/TODO.md`'s "Investigate" section.
## Debugging notes: how the VCF bug was actually found
+106 -14
View File
@@ -235,21 +235,108 @@ test_bank_roundtrip() {
fi
}
# --- Test 6: stability sweep over bundled sample patches ------------------
# --- Test 6: bank/patch round-trip via the raw VST2 chunk dispatcher ------
# test_bank_roundtrip/test_patch_roundtrip above go through JUCE's generic
# AudioProcessor::get/setStateInformation - which, it turns out, JaySynth's
# usesChunks() flag genuinely is true for, so those already exercise the
# real bankEncodeXml/bankDecodeXml (chunk) path, not a per-parameter
# fallback (see tests/README.md's "corrected finding" note - an earlier
# version of this comment claimed otherwise and was wrong). This test goes
# one step further and calls effGetChunk/effSetChunk *directly* via
# Vst2RawChunk (bypassing JUCE's host-side wrapper entirely), so it keeps
# testing the real chunk path even if some future JUCE version's
# usesChunks() gating ever changes. It also positively *proves* the chunk
# path was used - not just that parameters round-trip - by checking the
# saved file starts with a zlib stream header (0x78) and is far larger
# than a raw 140-parameter float array (140*4=560 bytes) could be; the
# regular/fallback format would look nothing like this.
test_chunk_roundtrip() {
# $1: "bank" or "patch"; $2: target parameter value (kept distinct
# per call so a mixup between the two wouldn't silently pass)
local kind="$1"
local target_value="$2"
local save_flag load_flag
if [ "$kind" = "bank" ]; then
save_flag="--saveBankChunk"; load_flag="--loadBankChunk"
else
save_flag="--savePatchChunk"; load_flag="--loadPatchChunk"
fi
local name="${kind}_chunk_roundtrip (raw effGetChunk/effSetChunk, index=$([ "$kind" = bank ] && echo 0 || echo 1))"
local chunk_file="$WORKDIR/roundtrip_${kind}.chunk"
local log1="$WORKDIR/roundtrip_${kind}_chunk_save.log"
local log2="$WORKDIR/roundtrip_${kind}_chunk_load.log"
local tolerance="0.02"
if ! run_testhost 10 --param 0 "$target_value" $save_flag "$chunk_file" --duration 0.05 > "$log1" 2>&1; then
fail "$name (save step failed, exit $? - see $log1)"
return
fi
if [ ! -s "$chunk_file" ]; then
fail "$name (no chunk file written)"
return
fi
local first_byte
first_byte="$(od -An -tx1 -N1 "$chunk_file" | tr -d ' ')"
local chunk_size
chunk_size="$(stat -c%s "$chunk_file" 2>/dev/null || echo 0)"
if [ "$first_byte" != "78" ]; then
fail "$name (saved file doesn't start with a zlib header (0x78) - got 0x$first_byte, this didn't go through bankEncodeXml/GZIP at all)"
return
fi
if [ "$chunk_size" -le 1000 ]; then
fail "$name (saved file is only $chunk_size bytes - too small to be the real XML/chunk format, looks like a per-parameter fallback)"
return
fi
if ! run_testhost 10 $load_flag "$chunk_file" --printParams --duration 0.01 > "$log2" 2>&1; then
fail "$name (load step failed, exit $? - see $log2)"
return
fi
local readback
readback="$(grep '^PARAM 0 ' "$log2" | awk '{print $3}')"
if [ -z "$readback" ]; then
fail "$name (PARAM 0 not found in output - see $log2)"
return
fi
local diff
diff="$(awk -v a="$readback" -v b="$target_value" 'BEGIN { d = a - b; if (d < 0) d = -d; print d }')"
local within_tolerance
within_tolerance="$(awk -v d="$diff" -v t="$tolerance" 'BEGIN { print (d <= t) ? "1" : "0" }')"
if [ "$within_tolerance" = "1" ]; then
pass "$name (chunk is $chunk_size bytes, starts 0x78; wrote $target_value, read back $readback)"
else
fail "$name (wrote $target_value, read back $readback - outside +-$tolerance tolerance - see $log1 / $log2)"
fi
}
# --- Test 7: stability sweep over bundled sample patches ------------------
# Broad regression net: load every bundled .fxp under extras/sounds/ and
# render a held note through it. NOTE: as discovered while building this
# suite, these particular sample files are in VST2's opaque-chunk ("FPCh")
# format, but JUCE 3.1.1's plugin-side VST wrapper (compiled into
# JaySynth.so) never sets effFlagsProgramChunks, so JUCE's *host*-side
# VSTPluginInstance::usesChunks() sees false and silently ignores the
# chunk data entirely - confirmed by diffing --printParams output with and
# without --patch: zero parameters differ. This is a real, separate
# finding (see tests/README.md and testhost/TODO.md) - NOT one of the
# tracked TODO.md findings, and NOT something this suite attempts to fix.
# So this test only checks "loading this file doesn't crash the process",
# not "loading this file changes the patch" - it still exercises the
# malloc/delete[] paths touched by the Memory-management fixes as a broad
# smoke test, just not the patch-content-application path.
# render a held note through it. NOTE: these particular sample files
# (10+ years old) load without crashing but - confirmed by diffing
# --printParams output with and without --patch - have no effect on the
# patch's parameters. This is NOT the effFlagsProgramChunks/usesChunks()
# gap an earlier version of this comment claimed (that was wrong - see
# tests/README.md's "corrected finding" note; usesChunks() genuinely is
# true, and test_chunk_roundtrip above proves the real chunk path works
# with files this same build produces). The actual cause is still
# unidentified - most likely these specific old files use a compression
# or XML-schema shape that JaySynthAudioProcessor::setCurrentProgramState-
# Information/getXmlFromBinary no longer parses, not a host-vs-plugin
# capability mismatch. Tracked in tools/testhost/TODO.md's Investigate
# section. So this test only checks "loading this file doesn't crash the
# process", not "loading this file changes the patch" - it still
# exercises the malloc/delete[] paths touched by the Memory-management
# fixes as a broad smoke test, just not the patch-content-application
# path.
test_stability_sweep() {
local sounds_dir="$REPO_ROOT/extras/sounds"
local any=0
@@ -298,6 +385,11 @@ test_patch_roundtrip
test_bank_roundtrip
log ""
log "Raw VST2 chunk dispatcher - proves the actual chunk-based path:"
test_chunk_roundtrip bank 0.81
test_chunk_roundtrip patch 0.44
log ""
log "General stability sweep over bundled sample patches:"
test_stability_sweep
log ""