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 are already
44 /// decoded; the plugin only decides whether to accept them.
45 MismatchedEnvelope {
46 plugin_id_hash: u64,
47 params: &'a [(u32, f64)],
48 extra: Option<&'a [u8]>,
49 },
50}
51
52/// Truce-shaped state produced by a successful
53/// [`crate::plugin::PluginRuntime::migrate_state`]. Rides the normal
54/// restore pipeline as a synthetic [`DeserializedState`]; the next
55/// save writes a regular envelope, so migration is a one-shot door,
56/// not a permanent dual-format reader.
57pub struct MigratedState {
58 pub params: Vec<(u32, f64)>,
59 pub extra: Option<Vec<u8>>,
60}
61
62impl From<MigratedState> for DeserializedState {
63 fn from(migrated: MigratedState) -> Self {
64 Self {
65 params: migrated.params,
66 extra: migrated.extra,
67 }
68 }
69}
70
71/// Reason a [`crate::PluginRuntime::load_state`] /
72/// `truce_plugin::PluginLogic::load_state` implementation failed to
73/// interpret the host-supplied extra-state blob. Format wrappers
74/// receive this on the audio-thread apply path and log it; hosts
75/// that surface a non-success code to the DAW (e.g. CLAP
76/// `state_load` returning `false`) read the variant via that path.
77///
78/// `Malformed` is the typical case: the blob's framing or content
79/// doesn't match what `save_state` would emit (version skew between
80/// older session files and newer plugin builds is the canonical
81/// example). `Other` carries a free-form message for plugin-specific
82/// failures that don't fit the malformed-bytes shape.
83#[derive(Debug)]
84#[non_exhaustive]
85pub enum StateLoadError {
86 /// State blob is too short, mis-framed, or otherwise unparseable.
87 Malformed(&'static str),
88 /// Plugin-specific failure with a free-form message.
89 Other(String),
90}
91
92impl std::fmt::Display for StateLoadError {
93 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 match self {
95 Self::Malformed(s) => write!(f, "malformed state: {s}"),
96 Self::Other(s) => f.write_str(s),
97 }
98 }
99}
100
101impl std::error::Error for StateLoadError {}
102
103/// Apply a deserialized state to a plugin: write parameter values,
104/// snap smoothers, then hand the optional extra blob to
105/// [`crate::plugin::PluginRuntime::load_state`].
106///
107/// Format wrappers call this from the audio thread after popping a
108/// pending load off their per-instance handoff queue. The reason it
109/// must run on the audio thread (and not on the host's main thread,
110/// where state-load callbacks are typically invoked): `load_state`
111/// takes `&mut P`, which would alias the audio thread's `&mut P`
112/// inside `process()` and produce a data race. The audio thread is
113/// the single thread that already owns `&mut P` between blocks, so
114/// running the load there sidesteps the race entirely.
115///
116/// `restore_values` and `snap_smoothers` go through the param
117/// struct's interior atomics, so they don't strictly need to run on
118/// the audio thread - but applying the whole state in one place keeps
119/// the param values and the user's extra-state blob coherent for any
120/// observer reading after this returns.
121pub fn apply_state<P: crate::export::PluginExport>(plugin: &mut P, state: &DeserializedState) {
122 use truce_params::Params;
123 plugin.params().restore_values(&state.params);
124 plugin.params().snap_smoothers();
125 if let Some(extra) = &state.extra
126 && let Err(e) = plugin.load_state(extra)
127 {
128 // Debug-only breadcrumb: this can run on the audio thread (the
129 // deferred load pops off the handoff queue at the top of
130 // `process()`), and `eprintln!` locks stderr and allocates - so
131 // it must never fire in a release / shipped build. By the time
132 // this runs the host already got a success return from the
133 // wrapper's setChunk, so there's no user-facing report left,
134 // only a dev diagnostic. Envelope-level failures the host can
135 // act on are logged synchronously in `parse_or_migrate` before
136 // the queue handoff.
137 #[cfg(debug_assertions)]
138 eprintln!("truce: load_state failed: {e}");
139 #[cfg(not(debug_assertions))]
140 let _ = e;
141 }
142}
143
144/// Parse a host-supplied state blob and, when it isn't this plugin's
145/// envelope, offer it to the plugin's
146/// [`crate::plugin::PluginRuntime::migrate_state`] hook. One routing
147/// point for every format wrapper's state callback:
148///
149/// - a matching envelope loads as always;
150/// - foreign bytes ([`StateParse::NotAnEnvelope`]) and renamed-plugin
151/// envelopes ([`StateParse::WrongPlugin`]) go to `migrate_state`;
152/// - a future envelope version and a corrupt envelope fail the load
153/// (never handed to the plugin), each with its own log line.
154///
155/// `None` means the load failed and the wrapper must report failure
156/// to the host in its own idiom. Runs on the host thread - that's
157/// where `migrate_state` is allowed to do allocator-heavy parsing.
158pub fn parse_or_migrate<P: PluginExport>(
159 data: &[u8],
160 expected_plugin_id: u64,
161 format: PluginFormat,
162 source_key: Option<&str>,
163) -> Option<DeserializedState> {
164 match truce_utils::state::parse_state(data, expected_plugin_id) {
165 StateParse::Ok(state) => Some(state),
166 StateParse::NotAnEnvelope => P::migrate_state(&ForeignState::Raw {
167 format,
168 source_key,
169 bytes: data,
170 })
171 .map(Into::into),
172 StateParse::WrongPlugin { found, state } => {
173 P::migrate_state(&ForeignState::MismatchedEnvelope {
174 plugin_id_hash: found,
175 params: &state.params,
176 extra: state.extra.as_deref(),
177 })
178 .map(Into::into)
179 }
180 StateParse::UnknownVersion(version) => {
181 // Same logging rationale as `apply_state`: one-shot event,
182 // no `log` dep in the audio-runtime crate.
183 eprintln!(
184 "truce: state blob carries envelope version {version}; this build \
185 reads version 1 - load failed"
186 );
187 None
188 }
189 StateParse::Corrupt => {
190 eprintln!("truce: state blob is a corrupt truce envelope - load failed");
191 None
192 }
193 }
194}
195
196/// Apply just the parameter values from a deserialized state - the
197/// host-thread-safe subset of [`apply_state`]. Format wrappers call
198/// this from their state-load callback (host main thread) before
199/// pushing the full state onto the audio-thread handoff queue, so
200/// host-thread reads of `getParameter`/equivalents see the restored
201/// values immediately. Validators (auval, pluginval, the VST2 binary
202/// smoke) read parameters synchronously after `setChunk`/equivalents
203/// without first running a render block, and would otherwise see the
204/// pre-restore values until the audio thread caught up.
205///
206/// The extra blob still has to round-trip through the audio thread
207/// because [`crate::plugin::PluginRuntime::load_state`] takes `&mut P`, which
208/// would alias `process()`'s `&mut P` if called from the host thread.
209/// `restore_values` and `snap_smoothers` go through atomic interior
210/// mutability and are safe to call concurrently with `process()`.
211pub fn apply_params<P: truce_params::Params>(params: &P, state: &DeserializedState) {
212 params.restore_values(&state.params);
213 params.snap_smoothers();
214}
215
216// ---------------------------------------------------------------------------
217// `snapshot_plugin` / `restore_plugin` - high-level helpers wrapping
218// `serialize_state` + `deserialize_state` with the params-collect /
219// restore + custom-state plumbing every host needs to do anyway.
220// ---------------------------------------------------------------------------
221
222use crate::export::PluginExport;
223use truce_params::Params;
224
225/// Errors `restore_plugin` can return.
226///
227/// `Invalid` covers envelope-level failures (missing / wrong magic,
228/// version mismatch, plugin-ID mismatch, truncated body); `LoadState`
229/// covers a successfully-parsed envelope whose extra-state blob the
230/// plugin's [`crate::PluginRuntime::load_state`] rejected. The caller
231/// typically prints a diagnostic and proceeds with default params.
232#[derive(Debug)]
233pub enum RestoreError {
234 /// The bytes don't parse as a state envelope for this plugin.
235 Invalid,
236 /// Envelope parsed but the plugin couldn't interpret its extra
237 /// bytes.
238 LoadState(StateLoadError),
239}
240
241impl std::fmt::Display for RestoreError {
242 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243 match self {
244 Self::Invalid => f.write_str("state envelope is invalid"),
245 Self::LoadState(e) => write!(f, "plugin load_state failed: {e}"),
246 }
247 }
248}
249
250impl std::error::Error for RestoreError {}
251
252/// Serialize a plugin instance into the canonical state envelope -
253/// parameter values + optional `Plugin::save_state()` payload, with
254/// the magic / version / plugin-ID header `serialize_state` writes.
255///
256/// Same shape every format wrapper produces, so a `.state` file
257/// written by one host loads in any other (subject to the
258/// plugin-ID match `deserialize_state` enforces).
259pub fn snapshot_plugin<P: PluginExport>(plugin: &P) -> Vec<u8> {
260 let (ids, values) = plugin.params().collect_values();
261 let extra = plugin.save_state();
262 serialize_state(hash_plugin_id(P::info().clap_id), &ids, &values, &extra)
263}
264
265/// Inverse of [`snapshot_plugin`]. Validates the envelope's magic,
266/// version, and plugin-ID hash; on success restores parameter
267/// values via `Params::restore_values` and forwards the optional
268/// extra payload to `Plugin::load_state`.
269///
270/// # Errors
271///
272/// Returns [`RestoreError::Invalid`] if the magic / version /
273/// plugin-ID hash check fails or the envelope is truncated. A
274/// successful return guarantees the params and (optional) extra
275/// payload were forwarded to the plugin.
276pub fn restore_plugin<P: PluginExport>(plugin: &mut P, bytes: &[u8]) -> Result<(), RestoreError> {
277 let id = hash_plugin_id(P::info().clap_id);
278 let s = deserialize_state(bytes, id).ok_or(RestoreError::Invalid)?;
279 plugin.params().restore_values(&s.params);
280 if let Some(extra) = s.extra {
281 plugin.load_state(&extra).map_err(RestoreError::LoadState)?;
282 }
283 Ok(())
284}
285
286/// Resolve the state-envelope hash every format wrapper stamps into
287/// the saved blob. Today this is just `hash_plugin_id(info.clap_id)`,
288/// which means the same plugin built as CLAP / VST3 / AU / AAX / VST2
289/// / LV2 produces a single state space - saving in one host and
290/// loading in another will round-trip parameter values (provided the
291/// `Plugin::save_state` / `load_state` extra payload is also
292/// format-agnostic).
293///
294/// **Trade-off:** because the input is the CLAP ID, renaming
295/// `info.clap_id` invalidates **every** saved session across **every**
296/// format. Callers that want format-pinned state (e.g. an AU build
297/// that shouldn't share state with the same plugin's CLAP build)
298/// should add a per-format ID field to [`crate::PluginInfo`] and
299/// route through it instead.
300#[must_use]
301pub fn shared_plugin_state_hash(info: &crate::PluginInfo) -> u64 {
302 hash_plugin_id(info.clap_id)
303}