diff --git a/tools/testhost/TODO.md b/tools/testhost/TODO.md index 2b87932..8718a09 100644 --- a/tools/testhost/TODO.md +++ b/tools/testhost/TODO.md @@ -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 diff --git a/tools/testhost/src/Vst2RawChunk.cpp b/tools/testhost/src/Vst2RawChunk.cpp new file mode 100644 index 0000000..804e739 --- /dev/null +++ b/tools/testhost/src/Vst2RawChunk.cpp @@ -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 + +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; + } +} diff --git a/tools/testhost/src/Vst2RawChunk.h b/tools/testhost/src/Vst2RawChunk.h new file mode 100644 index 0000000..77ad49f --- /dev/null +++ b/tools/testhost/src/Vst2RawChunk.h @@ -0,0 +1,38 @@ +#ifndef TESTHOST_VST2RAWCHUNK_H_INCLUDED +#define TESTHOST_VST2RAWCHUNK_H_INCLUDED + +#include + +/** 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 diff --git a/tools/testhost/src/config.mk b/tools/testhost/src/config.mk index 39520cd..658c523 100644 --- a/tools/testhost/src/config.mk +++ b/tools/testhost/src/config.mk @@ -5,3 +5,4 @@ CXX_SRCS += PluginHost.cpp CXX_SRCS += AudioInputSource.cpp CXX_SRCS += WavRecorder.cpp CXX_SRCS += TestScenario.cpp +CXX_SRCS += Vst2RawChunk.cpp diff --git a/tools/testhost/src/main.cpp b/tools/testhost/src/main.cpp index afc8e78..bc08e01 100644 --- a/tools/testhost/src/main.cpp +++ b/tools/testhost/src/main.cpp @@ -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 effSetChunk(index=0) directly with the file's raw bytes\n" + " --saveBankChunk effGetChunk(index=0) directly, write the raw bytes out\n" + " --loadPatchChunk effSetChunk(index=1) directly with the file's raw bytes\n" + " --savePatchChunk 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(); diff --git a/tools/testhost/tests/README.md b/tools/testhost/tests/README.md index a9093bf..8f5c51a 100644 --- a/tools/testhost/tests/README.md +++ b/tools/testhost/tests/README.md @@ -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 diff --git a/tools/testhost/tests/run_tests.sh b/tools/testhost/tests/run_tests.sh index deb1b4d..85e1c66 100755 --- a/tools/testhost/tests/run_tests.sh +++ b/tools/testhost/tests/run_tests.sh @@ -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 ""