truce_core/state.rs
1//! Plugin-state save / restore helpers layered on the canonical wire
2//! format in [`truce_utils::state`]. The wire functions are
3//! re-exported here so format wrappers and plugin code keep a single
4//! import path; the envelope itself lives in `truce-utils` so
5//! `cargo-truce` can emit byte-identical blobs (factory preset files)
6//! without inheriting `truce-core`'s runtime dependency chain.
7
8pub use truce_utils::state::{
9 DeserializedState, deserialize_state, hash_plugin_id, serialize_state, vst3_cid,
10};
11
12/// Reason a [`crate::PluginRuntime::load_state`] /
13/// `truce_plugin::PluginLogic::load_state` implementation failed to
14/// interpret the host-supplied extra-state blob. Format wrappers
15/// receive this on the audio-thread apply path and log it; hosts
16/// that surface a non-success code to the DAW (e.g. CLAP
17/// `state_load` returning `false`) read the variant via that path.
18///
19/// `Malformed` is the typical case: the blob's framing or content
20/// doesn't match what `save_state` would emit (version skew between
21/// older session files and newer plugin builds is the canonical
22/// example). `Other` carries a free-form message for plugin-specific
23/// failures that don't fit the malformed-bytes shape.
24#[derive(Debug)]
25#[non_exhaustive]
26pub enum StateLoadError {
27 /// State blob is too short, mis-framed, or otherwise unparseable.
28 Malformed(&'static str),
29 /// Plugin-specific failure with a free-form message.
30 Other(String),
31}
32
33impl std::fmt::Display for StateLoadError {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 match self {
36 Self::Malformed(s) => write!(f, "malformed state: {s}"),
37 Self::Other(s) => f.write_str(s),
38 }
39 }
40}
41
42impl std::error::Error for StateLoadError {}
43
44/// Apply a deserialized state to a plugin: write parameter values,
45/// snap smoothers, then hand the optional extra blob to
46/// [`crate::plugin::PluginRuntime::load_state`].
47///
48/// Format wrappers call this from the audio thread after popping a
49/// pending load off their per-instance handoff queue. The reason it
50/// must run on the audio thread (and not on the host's main thread,
51/// where state-load callbacks are typically invoked): `load_state`
52/// takes `&mut P`, which would alias the audio thread's `&mut P`
53/// inside `process()` and produce a data race. The audio thread is
54/// the single thread that already owns `&mut P` between blocks, so
55/// running the load there sidesteps the race entirely.
56///
57/// `restore_values` and `snap_smoothers` go through the param
58/// struct's interior atomics, so they don't strictly need to run on
59/// the audio thread - but applying the whole state in one place keeps
60/// the param values and the user's extra-state blob coherent for any
61/// observer reading after this returns.
62pub fn apply_state<P: crate::export::PluginExport>(plugin: &mut P, state: &DeserializedState) {
63 use truce_params::Params;
64 plugin.params().restore_values(&state.params);
65 plugin.params().snap_smoothers();
66 if let Some(extra) = &state.extra
67 && let Err(e) = plugin.load_state(extra)
68 {
69 // Audio-thread error path: host already received a "yes I
70 // accepted the state" return from the format wrapper's setChunk
71 // by the time we run, so the only thing left is logging.
72 // `eprintln!` is deliberate - `truce-core` is the audio-runtime
73 // crate, no `log` dep, and a state-load failure is a one-shot
74 // event not a per-block hot path. Format wrappers that surface
75 // this to the host (e.g. CLAP's `state_load` returning `false`)
76 // do so synchronously *before* the queue handoff.
77 eprintln!("truce: load_state failed: {e}");
78 }
79}
80
81/// Apply just the parameter values from a deserialized state - the
82/// host-thread-safe subset of [`apply_state`]. Format wrappers call
83/// this from their state-load callback (host main thread) before
84/// pushing the full state onto the audio-thread handoff queue, so
85/// host-thread reads of `getParameter`/equivalents see the restored
86/// values immediately. Validators (auval, pluginval, the VST2 binary
87/// smoke) read parameters synchronously after `setChunk`/equivalents
88/// without first running a render block, and would otherwise see the
89/// pre-restore values until the audio thread caught up.
90///
91/// The extra blob still has to round-trip through the audio thread
92/// because [`crate::plugin::PluginRuntime::load_state`] takes `&mut P`, which
93/// would alias `process()`'s `&mut P` if called from the host thread.
94/// `restore_values` and `snap_smoothers` go through atomic interior
95/// mutability and are safe to call concurrently with `process()`.
96pub fn apply_params<P: truce_params::Params>(params: &P, state: &DeserializedState) {
97 params.restore_values(&state.params);
98 params.snap_smoothers();
99}
100
101// ---------------------------------------------------------------------------
102// `snapshot_plugin` / `restore_plugin` - high-level helpers wrapping
103// `serialize_state` + `deserialize_state` with the params-collect /
104// restore + custom-state plumbing every host needs to do anyway.
105// ---------------------------------------------------------------------------
106
107use crate::export::PluginExport;
108use truce_params::Params;
109
110/// Errors `restore_plugin` can return.
111///
112/// `Invalid` covers envelope-level failures (missing / wrong magic,
113/// version mismatch, plugin-ID mismatch, truncated body); `LoadState`
114/// covers a successfully-parsed envelope whose extra-state blob the
115/// plugin's [`crate::PluginRuntime::load_state`] rejected. The caller
116/// typically prints a diagnostic and proceeds with default params.
117#[derive(Debug)]
118pub enum RestoreError {
119 /// The bytes don't parse as a state envelope for this plugin.
120 Invalid,
121 /// Envelope parsed but the plugin couldn't interpret its extra
122 /// bytes.
123 LoadState(StateLoadError),
124}
125
126impl std::fmt::Display for RestoreError {
127 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 match self {
129 Self::Invalid => f.write_str("state envelope is invalid"),
130 Self::LoadState(e) => write!(f, "plugin load_state failed: {e}"),
131 }
132 }
133}
134
135impl std::error::Error for RestoreError {}
136
137/// Serialize a plugin instance into the canonical state envelope -
138/// parameter values + optional `Plugin::save_state()` payload, with
139/// the magic / version / plugin-ID header `serialize_state` writes.
140///
141/// Same shape every format wrapper produces, so a `.state` file
142/// written by one host loads in any other (subject to the
143/// plugin-ID match `deserialize_state` enforces).
144pub fn snapshot_plugin<P: PluginExport>(plugin: &P) -> Vec<u8> {
145 let (ids, values) = plugin.params().collect_values();
146 let extra = plugin.save_state();
147 serialize_state(hash_plugin_id(P::info().clap_id), &ids, &values, &extra)
148}
149
150/// Inverse of [`snapshot_plugin`]. Validates the envelope's magic,
151/// version, and plugin-ID hash; on success restores parameter
152/// values via `Params::restore_values` and forwards the optional
153/// extra payload to `Plugin::load_state`.
154///
155/// # Errors
156///
157/// Returns [`RestoreError::Invalid`] if the magic / version /
158/// plugin-ID hash check fails or the envelope is truncated. A
159/// successful return guarantees the params and (optional) extra
160/// payload were forwarded to the plugin.
161pub fn restore_plugin<P: PluginExport>(plugin: &mut P, bytes: &[u8]) -> Result<(), RestoreError> {
162 let id = hash_plugin_id(P::info().clap_id);
163 let s = deserialize_state(bytes, id).ok_or(RestoreError::Invalid)?;
164 plugin.params().restore_values(&s.params);
165 if let Some(extra) = s.extra {
166 plugin.load_state(&extra).map_err(RestoreError::LoadState)?;
167 }
168 Ok(())
169}
170
171/// Resolve the state-envelope hash every format wrapper stamps into
172/// the saved blob. Today this is just `hash_plugin_id(info.clap_id)`,
173/// which means the same plugin built as CLAP / VST3 / AU / AAX / VST2
174/// / LV2 produces a single state space - saving in one host and
175/// loading in another will round-trip parameter values (provided the
176/// `Plugin::save_state` / `load_state` extra payload is also
177/// format-agnostic).
178///
179/// **Trade-off:** because the input is the CLAP ID, renaming
180/// `info.clap_id` invalidates **every** saved session across **every**
181/// format. Callers that want format-pinned state (e.g. an AU build
182/// that shouldn't share state with the same plugin's CLAP build)
183/// should add a per-format ID field to [`crate::PluginInfo`] and
184/// route through it instead.
185#[must_use]
186pub fn shared_plugin_state_hash(info: &crate::PluginInfo) -> u64 {
187 hash_plugin_id(info.clap_id)
188}