Skip to content

VST3: only kResultFalse is end of stream, and the preset selector cannot be read before the host sends it - #544

Merged
defiantnerd merged 1 commit into
free-audio:nextfrom
defiantnerd:vst3-state-restore-followups
Sep 13, 2026
Merged

defiantnerd merged 1 commit into
free-audio:nextfrom
defiantnerd:vst3-state-restore-followups

Conversation

@defiantnerd

@defiantnerd defiantnerd commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Two corrections to #543, both found in review after it merged. They are independent; the first is the one that matters to other plugins.

1. The stream adapter reported every error as end of file

#543 changed CLAPVST3StreamAdapter::read to return 0 instead of -1 so that Cubase's kResultFalse — its way of saying "that read ran past the end of the chunk" — would not look like a broken stream. That part was needed: asking for more than is left is how a reader with no length reads, and clap_istream gives it no way to ask, so the first read of a restored project returned an error and plugins threw the whole state away.

But it applied to every non-kResultOk result, not just that one. kOutOfMemory, kInternalError, kInvalidArgument all became a clean EOF. Two ways that hurts:

  • Silent data loss. The SDK's own MemoryStream::read returns kOutOfMemory when its buffer could not be allocated. A plugin that treats "no bytes" as "no state, use defaults" then reports a successful load, and the next project save writes those defaults over the user's work.
  • A hang. A reader that loops until it has the bytes it asked for never terminates, because the call that used to break the loop now returns 0 forever. That loop is the pattern in this repository's own conformance fixtures (tests/clap-first-example/distortion_clap.cpp), so it is not a hypothetical shape.

Now only kResultFalse is treated as end of stream, per clap/stream.h: "0 indicates end of file and -1 a read error". Everything else is an error again.

The size argument is also clamped rather than cast. IBStream counts in int32, and a reader asking for more than that is not making a mistake.

2. The preset selector cannot be read before the host sends it

#543 also had setState seed _presetIndexInEffect from the selector parameter, to stop a host that streams a program list value in every process block from reloading that preset over the state just restored.

That cannot work, and it is worth writing down why, because it looks like it should. At setState the parameter still holds createPresetSelector's default of 0: a host replays a program list parameter after the component state, the selector's value is not in the state chunk, and syncParameterValuesFromClap cannot fill it either — the selector's id is a tag the wrapper invented and no CLAP plugin owns, so get_value fails for it.

So the seed was always 0, which

  • missed the case it was written for, for every preset index except 0, and
  • made index 0 itself unreachable for the life of the instance — the first entry in every program list, dead after any setState.

Replaced with what does work: adopt the first selector value the host sends after a successful setState, whatever it says, and load nothing. Armed only there — a fresh instance has no state to protect, and a load that failed leaves the host's selector the better authority — which is also why it cannot be inferred from _presetIndexInEffect being -1, a condition both cases share.

The cost is written at the guard rather than left for the next reader: on a host that sends this parameter only when it changes rather than in every process block, the user's first pick after a project load is spent on the adopt and the second one loads. That is the lesser of the two — the alternative reloads the saved preset over every restored project, on every host that streams, every time.

Testing

wrapasvst3.cpp and detail/vst3/state.h compile clean under the wrapper's own -Werror; clap-first-distortion_vst3 links and passes the VST3 validator 47/47, as does the plugin this was found with (a CLAP with a 363-entry program list).

What has been confirmed in Cubase is the problem #543 set out to fix: with the adapter change, a saved project restores — before it, setState was called and the first read returned -1, so nothing was ever restored under VST3 at all. That was verified with logging at the plugin's clap_plugin_state boundary.

Both changes here are reasoned and static-checked but not yet re-confirmed in a DAW: the narrowing in 1 is a strict reduction of what is treated as EOF and leaves Cubase's path untouched, and 2 replaces a seed that provably could not fire correctly. Independent testing welcome, particularly of a program list on a host that sends the selector only when it changes.

…be read before the host sends it

Two corrections to free-audio#543, both found in review.

The stream adapter reported EVERY non-kResultOk result as end of file, not
just the kResultFalse a host uses to say "that ran past the end of the
chunk". kOutOfMemory - which the SDK's own MemoryStream::read returns when
its buffer could not be allocated - then reads as a clean EOF: a plug-in
that treats "no bytes" as "no state" reports a successful load, and the
next save writes its defaults over the user's project. Worse, a reader that
loops until it has the bytes it asked for never terminates, because the
call that used to break the loop now returns 0 forever - and that loop is
the pattern in this repository's own conformance fixtures. A real error is
a real error again.

The size argument is also clamped rather than cast: IBStream counts in
int32, and a reader with no length asking for more than that is not making
a mistake - the extension gives it no way to ask how much there is.

setState seeded _presetIndexInEffect from the selector parameter, which
cannot work: at that point the parameter still holds createPresetSelector's
default of 0. A host replays a program list parameter AFTER the component
state, the selector's value is not in the state chunk, and
syncParameterValuesFromClap cannot fill it either - the selector's id is a
tag this wrapper invented and no CLAP plug-in owns, so get_value fails for
it. The seed was therefore always 0, which missed every preset except index
0 and made index 0 itself unreachable for the life of the instance: the
first entry in every program list, dead after any setState.

What works instead is to adopt the first value the host sends after a
state load, whatever it says, and load nothing. It is armed only by a
setState that succeeded - a fresh instance has no state to protect, and a
load that failed leaves the host's selector the better authority - which is
also why it cannot be inferred from _presetIndexInEffect being -1, a
condition both cases share. The cost is stated at the guard: on a host that
sends this parameter only when it changes, the user's first pick after a
project load is spent on the adopt and the second one loads.
@defiantnerd
defiantnerd force-pushed the vst3-state-restore-followups branch from f375b1e to b0df674 Compare September 13, 2026 14:48
@defiantnerd
defiantnerd merged commit d8a26e3 into free-audio:next Sep 13, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant