Skip to main content

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, StateParse, deserialize_state, hash_plugin_id, parse_state, serialize_state,
10    vst3_cid,
11};
12
13/// The plugin format whose wrapper found a foreign state blob.
14/// Carried in [`ForeignState::Raw`] so a `migrate_state`
15/// implementation can branch per format when the old builds
16/// serialized differently per format.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18#[non_exhaustive]
19pub enum PluginFormat {
20    Clap,
21    Vst3,
22    Vst2,
23    Au,
24    Lv2,
25    Aax,
26}
27
28/// What a format wrapper found where truce state should have been.
29/// Handed to [`crate::plugin::PluginRuntime::migrate_state`] on the
30/// host thread; the plugin decides whether it recognizes the bytes.
31pub enum ForeignState<'a> {
32    /// Bytes that aren't a truce envelope: a previous framework's
33    /// state, exactly as the old build saved it.
34    Raw {
35        format: PluginFormat,
36        /// The container key the bytes were found under, for keyed
37        /// formats (AU dict key, LV2 property URI, AAX chunk id).
38        /// `None` for stream formats (CLAP / VST3 / VST2).
39        source_key: Option<&'a str>,
40        bytes: &'a [u8],
41    },
42    /// A valid truce envelope whose `plugin_id_hash` doesn't match:
43    /// the plugin was renamed / re-identified. Params, extra, and the
44    /// `#[persist]` block are already decoded; the plugin only decides
45    /// whether to accept them. Forward `persist` into the returned
46    /// [`MigratedState`] to keep GUI layout / file paths / instance
47    /// names across the rename.
48    MismatchedEnvelope {
49        plugin_id_hash: u64,
50        params: &'a [(u32, f64)],
51        extra: Option<&'a [u8]>,
52        persist: &'a [u8],
53    },
54}
55
56/// Truce-shaped state produced by a successful
57/// [`crate::plugin::PluginRuntime::migrate_state`]. Rides the normal
58/// restore pipeline as a synthetic [`DeserializedState`]; the next
59/// save writes a regular envelope, so migration is a one-shot door,
60/// not a permanent dual-format reader.
61#[derive(Default)]
62pub struct MigratedState {
63    pub params: Vec<(u32, f64)>,
64    pub extra: Option<Vec<u8>>,
65    /// Serialized `#[persist]` block to restore. Leave empty when the
66    /// source has no truce persist data (a foreign framework's blob);
67    /// forward [`ForeignState::MismatchedEnvelope::persist`] to carry
68    /// a renamed plugin's persisted fields across the migration.
69    pub persist: Vec<u8>,
70}
71
72impl From<MigratedState> for DeserializedState {
73    fn from(migrated: MigratedState) -> Self {
74        Self {
75            params: migrated.params,
76            extra: migrated.extra,
77            persist: migrated.persist,
78        }
79    }
80}
81
82/// Reason a [`crate::PluginRuntime::load_state`] /
83/// `truce_plugin::PluginLogic::load_state` implementation failed to
84/// interpret the host-supplied extra-state blob. Format wrappers
85/// receive this on the audio-thread apply path and log it; hosts
86/// that surface a non-success code to the DAW (e.g. CLAP
87/// `state_load` returning `false`) read the variant via that path.
88///
89/// `Malformed` is the typical case: the blob's framing or content
90/// doesn't match what `save_state` would emit (version skew between
91/// older session files and newer plugin builds is the canonical
92/// example). `Other` carries a free-form message for plugin-specific
93/// failures that don't fit the malformed-bytes shape.
94#[derive(Debug)]
95#[non_exhaustive]
96pub enum StateLoadError {
97    /// State blob is too short, mis-framed, or otherwise unparseable.
98    Malformed(&'static str),
99    /// Plugin-specific failure with a free-form message.
100    Other(String),
101}
102
103impl std::fmt::Display for StateLoadError {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        match self {
106            Self::Malformed(s) => write!(f, "malformed state: {s}"),
107            Self::Other(s) => f.write_str(s),
108        }
109    }
110}
111
112impl std::error::Error for StateLoadError {}
113
114/// Apply a deserialized state to a plugin: write parameter values,
115/// snap smoothers, then hand the optional extra blob to
116/// [`crate::plugin::PluginRuntime::load_state`].
117///
118/// Format wrappers call this from the audio thread after popping a
119/// pending load off their per-instance handoff queue. The reason it
120/// must run on the audio thread (and not on the host's main thread,
121/// where state-load callbacks are typically invoked): `load_state`
122/// takes `&mut P`, which would alias the audio thread's `&mut P`
123/// inside `process()` and produce a data race. The audio thread is
124/// the single thread that already owns `&mut P` between blocks, so
125/// running the load there sidesteps the race entirely.
126///
127/// `restore_values` and `snap_smoothers` go through the param
128/// struct's interior atomics, so they don't strictly need to run on
129/// the audio thread - but applying them here keeps the param values and
130/// the user's extra-state blob coherent for any observer reading after
131/// this returns.
132///
133/// `#[persist]` fields are **not** applied here - `load_persist` takes a
134/// lock per field that a GUI reader can hold, so it would risk blocking
135/// the audio thread. [`apply_params`] applies persist on the host thread;
136/// the deferred wrappers call it before queueing, and a host-thread full
137/// load (the standalone host, an inactive-plugin load) calls it alongside
138/// this.
139pub fn apply_state<P: crate::export::PluginExport>(plugin: &mut P, state: &DeserializedState) {
140    use truce_params::Params;
141    plugin.params().restore_values(&state.params);
142    plugin.params().snap_smoothers();
143    if let Some(extra) = &state.extra
144        && let Err(e) = plugin.load_state(extra)
145    {
146        // Debug-only breadcrumb: this can run on the audio thread (the
147        // deferred load pops off the handoff queue at the top of
148        // `process()`), and `eprintln!` locks stderr and allocates - so
149        // it must never fire in a release / shipped build. By the time
150        // this runs the host already got a success return from the
151        // wrapper's setChunk, so there's no user-facing report left,
152        // only a dev diagnostic. Envelope-level failures the host can
153        // act on are logged synchronously in `parse_or_migrate` before
154        // the queue handoff.
155        #[cfg(debug_assertions)]
156        eprintln!("truce: load_state failed: {e}");
157        #[cfg(not(debug_assertions))]
158        let _ = e;
159    }
160}
161
162/// Parse a host-supplied state blob and, when it isn't this plugin's
163/// envelope, offer it to the plugin's
164/// [`crate::plugin::PluginRuntime::migrate_state`] hook. One routing
165/// point for every format wrapper's state callback:
166///
167/// - a matching envelope loads as always;
168/// - foreign bytes ([`StateParse::NotAnEnvelope`]) and renamed-plugin
169///   envelopes ([`StateParse::WrongPlugin`]) go to `migrate_state`;
170/// - a future envelope version and a corrupt envelope fail the load
171///   (never handed to the plugin), each with its own log line.
172///
173/// `None` means the load failed and the wrapper must report failure
174/// to the host in its own idiom. Runs on the host thread - that's
175/// where `migrate_state` is allowed to do allocator-heavy parsing.
176pub fn parse_or_migrate<P: PluginExport>(
177    data: &[u8],
178    expected_plugin_id: u64,
179    format: PluginFormat,
180    source_key: Option<&str>,
181) -> Option<DeserializedState> {
182    match truce_utils::state::parse_state(data, expected_plugin_id) {
183        StateParse::Ok(state) => Some(state),
184        StateParse::NotAnEnvelope => P::migrate_state(&ForeignState::Raw {
185            format,
186            source_key,
187            bytes: data,
188        })
189        .map(Into::into),
190        StateParse::WrongPlugin { found, state } => {
191            P::migrate_state(&ForeignState::MismatchedEnvelope {
192                plugin_id_hash: found,
193                params: &state.params,
194                extra: state.extra.as_deref(),
195                persist: &state.persist,
196            })
197            .map(Into::into)
198        }
199        StateParse::UnknownVersion(version) => {
200            // Same logging rationale as `apply_state`: one-shot event,
201            // no `log` dep in the audio-runtime crate.
202            eprintln!(
203                "truce: state blob carries envelope version {version}; this build \
204                 reads version 1 - load failed"
205            );
206            None
207        }
208        StateParse::Corrupt => {
209            eprintln!("truce: state blob is a corrupt truce envelope - load failed");
210            None
211        }
212    }
213}
214
215/// Apply just the parameter values from a deserialized state - the
216/// host-thread-safe subset of [`apply_state`]. Format wrappers call
217/// this from their state-load callback (host main thread) before
218/// pushing the full state onto the audio-thread handoff queue, so
219/// host-thread reads of `getParameter`/equivalents see the restored
220/// values immediately. Validators (auval, pluginval, the VST2 binary
221/// smoke) read parameters synchronously after `setChunk`/equivalents
222/// without first running a render block, and would otherwise see the
223/// pre-restore values until the audio thread caught up.
224///
225/// The extra blob still has to round-trip through the audio thread
226/// because [`crate::plugin::PluginRuntime::load_state`] takes `&mut P`, which
227/// would alias `process()`'s `&mut P` if called from the host thread.
228/// `restore_values` and `snap_smoothers` go through atomic interior
229/// mutability and are safe to call concurrently with `process()`.
230///
231/// `#[persist]` fields are applied here too, and deliberately *not* on the
232/// audio thread: `load_persist` takes a `RwLock::write` / `Mutex::lock`
233/// per persisted field - fields a GUI thread reads at frame rate - so
234/// applying it inside `process()` would let the editor block the audio
235/// thread (priority inversion). It also allocates (deserializing
236/// `String` / `Vec`). Running it on the host thread keeps both off the
237/// real-time path.
238pub fn apply_params<P: truce_params::Params>(params: &P, state: &DeserializedState) {
239    params.restore_values(&state.params);
240    params.load_persist(&state.persist);
241    params.snap_smoothers();
242}
243
244// ---------------------------------------------------------------------------
245// `snapshot_plugin` / `restore_plugin` - high-level helpers wrapping
246// `serialize_state` + `deserialize_state` with the params-collect /
247// restore + custom-state plumbing every host needs to do anyway.
248// ---------------------------------------------------------------------------
249
250use crate::export::{PluginExport, read_custom_state_offthread};
251use truce_params::Params;
252
253/// Errors `restore_plugin` can return.
254///
255/// `Invalid` covers envelope-level failures (missing / wrong magic,
256/// version mismatch, plugin-ID mismatch, truncated body); `LoadState`
257/// covers a successfully-parsed envelope whose extra-state blob the
258/// plugin's [`crate::PluginRuntime::load_state`] rejected. The caller
259/// typically prints a diagnostic and proceeds with default params.
260#[derive(Debug)]
261pub enum RestoreError {
262    /// The bytes don't parse as a state envelope for this plugin.
263    Invalid,
264    /// Envelope parsed but the plugin couldn't interpret its extra
265    /// bytes.
266    LoadState(StateLoadError),
267}
268
269impl std::fmt::Display for RestoreError {
270    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
271        match self {
272            Self::Invalid => f.write_str("state envelope is invalid"),
273            Self::LoadState(e) => write!(f, "plugin load_state failed: {e}"),
274        }
275    }
276}
277
278impl std::error::Error for RestoreError {}
279
280/// Serialize a plugin instance into the canonical state envelope -
281/// parameter values + optional custom-state payload, with the magic /
282/// version / plugin-ID header `serialize_state` writes.
283///
284/// Same shape every format wrapper produces, so a `.state` file
285/// written by one host loads in any other (subject to the
286/// plugin-ID match `deserialize_state` enforces).
287pub fn snapshot_plugin<P: PluginExport>(plugin: &P) -> Vec<u8> {
288    use truce_params::Params;
289    let (ids, values) = plugin.params().collect_values();
290    let extra = read_custom_state_offthread(plugin);
291    let persist = plugin.params().serialize_persist();
292    serialize_state(
293        hash_plugin_id(P::info().clap_id),
294        &ids,
295        &values,
296        &extra,
297        &persist,
298    )
299}
300
301/// Inverse of [`snapshot_plugin`]. Validates the envelope's magic,
302/// version, and plugin-ID hash; on success restores parameter
303/// values via `Params::restore_values` and forwards the optional
304/// extra payload to `Plugin::load_state`.
305///
306/// # Errors
307///
308/// Returns [`RestoreError::Invalid`] if the magic / version /
309/// plugin-ID hash check fails or the envelope is truncated. A
310/// successful return guarantees the params and (optional) extra
311/// payload were forwarded to the plugin.
312pub fn restore_plugin<P: PluginExport>(plugin: &mut P, bytes: &[u8]) -> Result<(), RestoreError> {
313    let id = hash_plugin_id(P::info().clap_id);
314    let s = deserialize_state(bytes, id).ok_or(RestoreError::Invalid)?;
315    plugin.params().restore_values(&s.params);
316    plugin.params().load_persist(&s.persist);
317    if let Some(extra) = s.extra {
318        plugin.load_state(&extra).map_err(RestoreError::LoadState)?;
319    }
320    Ok(())
321}
322
323/// Resolve the state-envelope hash every format wrapper stamps into
324/// the saved blob. Today this is just `hash_plugin_id(info.clap_id)`,
325/// which means the same plugin built as CLAP / VST3 / AU / AAX / VST2
326/// / LV2 produces a single state space - saving in one host and
327/// loading in another will round-trip parameter values (provided the
328/// `Plugin::save_state` / `load_state` extra payload is also
329/// format-agnostic).
330///
331/// **Trade-off:** because the input is the CLAP ID, renaming
332/// `info.clap_id` invalidates **every** saved session across **every**
333/// format. Callers that want format-pinned state (e.g. an AU build
334/// that shouldn't share state with the same plugin's CLAP build)
335/// should add a per-format ID field to [`crate::PluginInfo`] and
336/// route through it instead.
337#[must_use]
338pub fn shared_plugin_state_hash(info: &crate::PluginInfo) -> u64 {
339    hash_plugin_id(info.clap_id)
340}
341
342#[cfg(test)]
343mod tests {
344    use super::{DeserializedState, MigratedState};
345
346    // A renamed plugin that forwards its old envelope's `#[persist]`
347    // bytes must keep them: the `From` bridge used to hardcode
348    // `persist: Vec::new()`, silently reverting persisted fields to
349    // default on every migrated load.
350    #[test]
351    fn migrated_state_forwards_persist() {
352        let migrated = MigratedState {
353            params: vec![(0, 1.0)],
354            extra: Some(vec![9, 9]),
355            persist: vec![1, 2, 3],
356        };
357        let restored: DeserializedState = migrated.into();
358        assert_eq!(restored.persist, vec![1, 2, 3]);
359        assert_eq!(restored.extra.as_deref(), Some(&[9, 9][..]));
360        assert_eq!(restored.params, vec![(0, 1.0)]);
361    }
362
363    #[test]
364    fn migrated_state_default_has_empty_persist() {
365        let restored: DeserializedState = MigratedState::default().into();
366        assert!(restored.persist.is_empty());
367        assert!(restored.extra.is_none());
368        assert!(restored.params.is_empty());
369    }
370}