From bd440b7603e061c9c4d5ff63fe14be005aa183fa Mon Sep 17 00:00:00 2001 From: defiantnerd <97224712+defiantnerd@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:34:26 +0200 Subject: [PATCH 1/2] VST3: a restored project is not a program change Loading a project in Cubase gave back the preset the rig had come from, not the rig the project was saved with. The state was restored correctly and then overwritten, one process block later, by the preset selector. Cubase sends a program list parameter's value in every process block, and those blocks run while the project is still being restored. So the audio thread can queue a preset-load request from a block that ran BEFORE setState, and onIdle() - which acts on the request, on the main thread, without consulting the state that has landed in the meantime - then loads the untouched preset over everything the state just restored. _presetIndexInEffect already turns a value the host merely repeats into a no-op, but only once something has established what is in effect: a load through onIdle, or preset_loaded() resolving what the plugin reports. A state restore does neither if the rig it carries never came from the preset list, and it is not consulted at all at the point the queued request is finally acted on. Three places, one rule - the state is the newer fact about what the plugin holds, and the selector is a label on where that content started: - setState() drops a request queued before it ran, - setState() takes the selector where it stands as the value in effect when nothing else has said otherwise, - onIdle() re-tests the request against it before loading, because the two are on different threads and the state can arrive between them. Nothing changes for an actual program change: the value then names something other than what is in effect, which is what the guard tests. --- src/wrapasvst3.cpp | 69 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 59 insertions(+), 10 deletions(-) diff --git a/src/wrapasvst3.cpp b/src/wrapasvst3.cpp index d1b2a742..83f20dd7 100644 --- a/src/wrapasvst3.cpp +++ b/src/wrapasvst3.cpp @@ -299,6 +299,44 @@ tresult PLUGIN_API ClapAsVst3::setState(IBStream *state) { syncParameterValuesFromClap(); } + + // A project load is not a program change, and the preset selector must not + // be allowed to undo one. A host that restores a project restores the state + // and goes on sending the selector its own value - Cubase sends a program + // list parameter in every process block - so the value that arrives next + // names the preset the state was *saved from*, not a preset to load. Loading + // it would replace everything the state just restored with the untouched + // preset it started life as, which is the whole project's worth of edits. + // + // Two things stand in the way of that, because the request and the load are + // on different threads and the state can arrive between them: + // + // - a request the audio thread queued from a block that ran BEFORE the + // state did. onIdle() acts on it without looking at the state that has + // landed in the meantime, so it has to be dropped here. + // - the -1 in _presetIndexInEffect, which makes the next arrival look like + // a change to something new. preset_loaded() will have replaced it + // already if the state named a preset, but a rig that never came from + // the preset list - dropped in, or edited from the plugin's own default + // - names none, and leaves nothing for the guard to work with. + // + // The second is why the selector's own value is read here. Where the host's + // list stands is not a request to go there - it is where the restored + // content came from - so it is what is in effect. Only when the state named + // no preset at all: if it named one, preset_loaded() has run during the load + // above and has put the right index there already. + _presetLoadRequest.store(-1); + + if (_presetParamId != Vst::kNoParamId && + _presetIndexInEffect.load(std::memory_order_relaxed) < 0) + { + if (auto *param = static_cast(parameters.getParameter(_presetParamId))) + { + const auto index = static_cast(param->asClapValue(param->getNormalized()) + 0.5); + if (index >= 0) _presetIndexInEffect.store(index, std::memory_order_relaxed); + } + } + return result; } @@ -1372,6 +1410,9 @@ void ClapAsVst3::onRequestPresetLoad(size_t presetIndex) // parameter stream carries the selector's value, not its edges, and acting // on every arrival makes loading a preset a permanent state of reloading it // (\see _presetIndexInEffect). + // A state load establishes the same thing without a preset ever being + // loaded, which is what keeps a restored project from reloading the preset + // it was saved from over itself. \see setState(). if (static_cast(presetIndex) == _presetIndexInEffect.load(std::memory_order_relaxed)) { return; @@ -1733,17 +1774,25 @@ void ClapAsVst3::onIdle() { const auto index = std::min((size_t)requested, count - 1); - // In effect from here on, whatever the load makes of it: the host is - // sending this value, and a preset that cannot be loaded has to be - // attempted once rather than once per block. A load that succeeds - // confirms the same index through preset_loaded(). - _presetIndexInEffect.store(static_cast(index), std::memory_order_relaxed); - - if (_presetIndex->presetAt(index, entry)) + // Tested again here, and not only where the request was made: the two + // are on different threads, and a state load can land between them and + // say that this preset is already what the plugin holds. Loading it + // again would put the untouched preset over the restored state. + // \see setState(). + if (static_cast(index) != _presetIndexInEffect.load(std::memory_order_relaxed)) { - _plugin->loadPresetFromLocation(entry.locationKind, - entry.location.empty() ? nullptr : entry.location.c_str(), - entry.loadKey.empty() ? nullptr : entry.loadKey.c_str()); + // In effect from here on, whatever the load makes of it: the host is + // sending this value, and a preset that cannot be loaded has to be + // attempted once rather than once per block. A load that succeeds + // confirms the same index through preset_loaded(). + _presetIndexInEffect.store(static_cast(index), std::memory_order_relaxed); + + if (_presetIndex->presetAt(index, entry)) + { + _plugin->loadPresetFromLocation(entry.locationKind, + entry.location.empty() ? nullptr : entry.location.c_str(), + entry.loadKey.empty() ? nullptr : entry.loadKey.c_str()); + } } } } From 659f89313ff8b48f78f03825a7800c7183b6ac91 Mon Sep 17 00:00:00 2001 From: defiantnerd <97224712+defiantnerd@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:59:38 +0200 Subject: [PATCH 2/2] VST3: a stream that ends is not a stream that failed Nothing a plug-in saved under VST3 was ever restored in Cubase. setState was called, the wrapper handed the plug-in a clap_istream, and the first read of it returned -1 - so every plug-in that reads its state the way clap_istream is meant to be read threw the whole project away before looking at a byte of it. The adapter reported anything but kResultOk as an error. Cubase answers a read that runs past the end of the plug-in's chunk with kResultFalse rather than with kResultOk and a short count, and asking for more than is left is how a reader with no length reads a stream - which is every reader clap_istream has, since the extension gives it no way to ask. CLAP has one code for "there is nothing more", and it is 0. A real I/O failure now reports a short read instead, so the reader gets a truncated chunk and rejects it - where it was headed anyway - while a host that phrases the end of a stream differently no longer costs a whole project. --- src/detail/vst3/state.h | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/detail/vst3/state.h b/src/detail/vst3/state.h index b60a99b1..543d6345 100644 --- a/src/detail/vst3/state.h +++ b/src/detail/vst3/state.h @@ -27,8 +27,27 @@ class CLAPVST3StreamAdapter { auto self = static_cast(stream->ctx); Steinberg::int32 bytesRead = 0; - if (kResultOk == self->vst_stream->read(buffer, (int32)size, &bytesRead)) return bytesRead; - return -1; + const auto result = self->vst_stream->read(buffer, (int32)size, &bytesRead); + if (kResultOk == result) return bytesRead; + + // Not every host reports the end of a stream the way the SDK's own + // IBStream implementations do. Cubase answers a read that runs past the + // end of the plug-in's chunk with kResultFalse rather than with kResultOk + // and a short count - and a reader asking for more than is left is the + // normal way to read a stream whose length it does not know, which is + // every reader clap_istream has. + // + // CLAP has one code for "there is nothing more": 0. Reporting -1 instead + // says "this stream is broken", and a plug-in that believes it throws away + // the project state it was in the middle of restoring - which is exactly + // what it did, on the very first read, so nothing was ever restored under + // VST3 at all. + // + // A real I/O failure ends up here too and is reported as a short read. The + // reader gets a truncated chunk and rejects it, which is where it was + // going to end up anyway; what it does not do is lose a whole project to a + // host that phrased the end of a stream differently. + return bytesRead > 0 ? bytesRead : 0; } static int64_t write(const struct clap_ostream *stream, const void *buffer, uint64_t size) {