Skip to content

Version every product's saved state, and run pluginval's state restoration test again - #25

Merged
pumfleet merged 2 commits into
mainfrom
state-schema-version
Nov 2, 2026
Merged

pumfleet merged 2 commits into
mainfrom
state-schema-version

Conversation

@pumfleet

@pumfleet pumfleet commented Nov 2, 2026 •

Copy link
Copy Markdown
Owner

Summary

Every product's saved state now carries a schema version, and pluginval's "Plugin state restoration" test runs again for every product, after fixing what made a restored state differ from the saved one.

(a) State schema version

  • libs/ui/source/altvane_ui/session/state_schema.{hpp,cpp}: the shared part. libs/ui doesn't own the state envelope (each processor writes its own: an APVTS tree for De-Esser, Leveler and Dialogue, ZL's ParaState for EQ, Compressor and Limiter), so each product calls these helpers itself:
    • write/read: an integer schema_version attribute on the state's root element. A missing attribute reads as 0.
    • upgrade(state, current, migrations): the migration hook. It takes a {from, apply} table and runs each step from the saved version up to current. Version 0 (states saved before versions existed, including ZL's own releases) has version 1's layout, so it loads unchanged and the table starts at 1. Every product's table is empty for now.
    • knownParameters: used for newer states (below).
  • Each product has PluginProcessor::kStateVersion = 1. It writes the version in getStateInformation and reads and upgrades it in setStateInformation. Generated factory presets (Compressor, Limiter, De-Esser, Leveler, Dialogue) carry the version too. A generator updated for a later layout must not write states labelled 0, which a migration would then rewrite again. Factory presets already on disk have no version, so they load as version 0.
  • Policy for a state from a newer build: load what this build knows and drop the rest. That means each parameter it has, matched by ID (IDs never change meaning: the existing "parameter IDs never change" tests), plus whatever the product reads by name (the Leveler's session part). Unknown parameters, properties and children are dropped, and parameters the state lacks keep their current values, as they do for an older state. The state then saves as this build's version, so a later build sees an ordinary older state and never finds newer data under an older label. This is documented at the top of state_schema.hpp.
  • Presets go through the same setStateInformation. User presets keep the attribute through each codec: ZL's JSON keeps root properties, and xmlAsJson keeps attributes.

(b) pluginval "Plugin state restoration"

Baseline: I built each product's VST3 from main and ran CI's pluginval (ZL-Audio v1.0.4-temp, strictness 10, repeat 2) with the test enabled. All six passed on Linux. The test was disabled in the very first commit (feat: init), copied from ZL's workflows, not because of a known failure here. Under pluginval's VST3 path the random parameter writes didn't produce the failures below. I didn't confirm why.

So I added the same check to each product's tests (state_tests.cpp, "the state restores exactly after every parameter moves, as pluginval checks"). It reads the state, moves each automatable parameter except bypass to a random value, sets the state back and expects the parameter restored, then expects the state read again to be byte-identical. The tolerance is 1e-6 instead of pluginval's 0.1. It runs from defaults and from a randomised start. It failed on four products for two real causes:

  1. Switches (AudioParameterBool) were not restored: EQ (dynamic on/learn/bypass/relative, side link, bypass, ...), Compressor (External Side, Side EQ Bypass, RMS ON), Limiter (DC Filter, Delta, ...). A switch keeps the raw value a host writes (0.48 is "off"), unlike every other JUCE parameter type, which stores its snapped plain value. AudioProcessorValueTreeState::replaceState() skips a parameter whose plain value already equals the saved one. So after restoring an "off" state, the switch kept reporting 0.48 to the host. That is an error of 0.48, above pluginval's 0.1. A host that writes parameters straight into the plug-in (AU does) would see the same. Fix: state_schema::settleSwitches(*this) after replaceState() in every product puts each switch exactly on 0 or 1 when it isn't already. It runs in our wrappers; nothing in the vendored engines changed (the parameters are declared in the vendored zlp_definitions.hpp).
  2. Leveler: the rider fader didn't save back identically. It lives outside the parameter tree and was saved as ride_->get(). At its default that is exactly 0.0, but restoring 0.0 through its stepped range gives -24 + 2400 × 0.01f = -5.36e-7, so the next save differed. It is now saved as the value its normalised position gives, as APVTS stores its own parameters, and that value restores to itself.

De-Esser and Dialogue passed as they were.

Re-enabled: cmake_full_test.yml and cmake_heavy_test.yml (PLUGINVAL_DISABLED is now empty), and sanitizer.yml (macOS ASan/TSan and Linux ASan are now empty; Linux TSan keeps only "Background thread state", which I didn't investigate). cmake_release.yml doesn't run pluginval. Nothing stays disabled for any product. The workflow edits only touch those disabled lists.

Testing

  • New products/*/tests/state_tests.cpp (EQ: added to its existing state_tests.cpp). Per product:
    • a round trip, with the version on the root;
    • a state without a version (De-Esser, Leveler, Dialogue: literal 0.1.0-layout XML, plus this build's state with the attribute removed; EQ and Compressor: the same, and the ZL fixtures in state_tests.cpp/compat_tests.cpp still pass unchanged);
    • a newer version with an unknown parameter, property and child (known parameters load, the rest is dropped, and it saves as version 1, byte-identical to the source);
    • the pluginval restoration check;
    • factory and user presets carrying the version and loading.
    • The De-Esser's tests also check the migration hook with two made-up steps.
  • Before the fixes, the restoration check failed on EQ, Compressor, Limiter (switches) and Leveler (fader). After them all [state] tests pass.
  • Linux gate as in CI (clang-21, Release, VST3, AVX2, 8 bands): full ctest passes. The one exception is Dialogue's "models load under a locale with a decimal comma", which is SKIPPED because the box has no such locale and ctest counts a skip as a failure. Dialogue was built here with a local PLATFORMS edit that isn't committed.
  • pluginval v1.0.4-temp, strictness 10, repeat 2, nothing disabled, on all six VST3s, with three seeds: all 18 runs pass (seeds 2611021100, 2611021234, 0x5eed1234). At baseline (main) one seed also passed for all six (see (b)).
  • scripts/check_upstream.sh passes for eq, compressor and limiter.
  • CI on this PR: Linux, Windows and macOS all pass with nothing disabled. "Plugin state restoration" ran 10 times on Linux (5 products, Dialogue isn't built there), 12 on Windows and 12 on macOS AU (6 products × repeat 2; auval succeeded for all 6).
  • Not run: the sanitizer and heavy-test workflows (manual dispatch), whose lists this PR also changes. I also didn't run the macOS AU validation with the test enabled on main, so I can't say whether AU failed before the switch fix.

Review

  • codex exec review --base origin/main: no findings, both before and after rebasing onto the merged Move code copied between products into libs/testing and libs/ui #22 (shared helpers). The rebase only conflicted where Move code copied between products into libs/testing and libs/ui #22 removed storePeak from the De-Esser and Dialogue processors, next to the new migration table.
  • Worth a look:
    • settleSwitches calls setValueNotifyingHost during setStateInformation. It only fires for a switch the host left off-grid, and the semantic value doesn't change. The VST3 wrapper ignores it during a state load (inSetState).
    • Dropping unknown data from newer states is deliberate: it keeps newer data from being saved under an older version label.
    • The Leveler fader's saved value changes from 0.0 to -5.36e-7 at its default. Sessions saved before load the same.
  • Coordination: the workflow edits only touch the pluginval disabled lists (the CI-schedule PR edits sanitizer.yml and cmake_heavy_test.yml too).

🤖 Generated with Claude Code

pumfleet and others added 2 commits November 2, 2026 12:35
- libs/ui session/state_schema: an integer schema_version on the root of each
  saved state (sessions and presets), a {from, apply} migration table run from
  the saved version up, and the policy for a state from a newer build: load
  each parameter this build has by ID (and what the product reads by name, the
  Leveler's session part), drop the rest, and save as this build's version.
  No version is version 0, version 1's layout, so older states and ZL's own
  load unchanged. Every product is at 1 with no migrations.
- Each processor writes and reads it; generated factory presets carry it.
- Restoring a state now puts each switch (AudioParameterBool) exactly on 0 or
  1. A switch keeps the raw value a host wrote (0.48 is off) and replaceState()
  skips a parameter whose plain value already matches, so the host went on
  reading 0.48 after an "off" state was restored (EQ, Compressor, Limiter).
  Done in our wrappers; the vendored engines are untouched.
- Leveler: the rider fader is saved as the value its normalised position gives,
  as the parameter tree stores its parameters. Its default, 0, restored as
  -5.4e-7 (a step's rounding), so the state didn't save back identically.
- Tests per product: a round trip, a state without a version (0.1.0 layout),
  one from a newer version, factory and user presets, and pluginval's state
  restoration check (each parameter moved, the state set back, the same bytes
  read back), which failed on EQ, Compressor, Limiter and Leveler before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It was disabled from the first commit, copied from ZL's workflows. Every
product passes it now (strictness 10, ZL-Audio pluginval v1.0.4-temp). The
Linux Thread Sanitizer job still skips Background thread state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pumfleet
pumfleet force-pushed the state-schema-version branch from 8f9199b to bdfdec2 Compare November 2, 2026 18:35
@pumfleet
pumfleet merged commit fa81d9d into main Nov 2, 2026
4 checks passed
@pumfleet
pumfleet deleted the state-schema-version branch November 2, 2026 19:00
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