Skip to main content

truce_core/
state.rs

1/// Magic bytes for state identification.
2const STATE_MAGIC: &[u8; 4] = b"OAST";
3const STATE_VERSION: u32 = 1;
4
5/// Reason a [`crate::Plugin::load_state`] /
6/// `truce_plugin::PluginLogic::load_state` implementation failed to
7/// interpret the host-supplied extra-state blob. Format wrappers
8/// receive this on the audio-thread apply path and log it; hosts
9/// that surface a non-success code to the DAW (e.g. CLAP
10/// `state_load` returning `false`) read the variant via that path.
11///
12/// `Malformed` is the typical case: the blob's framing or content
13/// doesn't match what `save_state` would emit (version skew between
14/// older session files and newer plugin builds is the canonical
15/// example). `Other` carries a free-form message for plugin-specific
16/// failures that don't fit the malformed-bytes shape.
17#[derive(Debug)]
18#[non_exhaustive]
19pub enum StateLoadError {
20    /// State blob is too short, mis-framed, or otherwise unparseable.
21    Malformed(&'static str),
22    /// Plugin-specific failure with a free-form message.
23    Other(String),
24}
25
26impl std::fmt::Display for StateLoadError {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            Self::Malformed(s) => write!(f, "malformed state: {s}"),
30            Self::Other(s) => f.write_str(s),
31        }
32    }
33}
34
35impl std::error::Error for StateLoadError {}
36
37/// Serialize plugin state: parameter values + extra state. Empty
38/// `extra` slice serializes as the same `0u64` length-prefix that an
39/// absent extra block would, so callers don't need an `Option`
40/// wrapper to express "no extra state".
41#[must_use]
42pub fn serialize_state(
43    plugin_id_hash: u64,
44    param_ids: &[u32],
45    param_values: &[f64],
46    extra: &[u8],
47) -> Vec<u8> {
48    let mut data = Vec::new();
49
50    // Header
51    data.extend_from_slice(STATE_MAGIC);
52    data.extend_from_slice(&STATE_VERSION.to_le_bytes());
53    data.extend_from_slice(&plugin_id_hash.to_le_bytes());
54
55    // Parameter block
56    let count = crate::cast::len_u32(param_ids.len());
57    data.extend_from_slice(&count.to_le_bytes());
58    for (id, value) in param_ids.iter().zip(param_values.iter()) {
59        data.extend_from_slice(&id.to_le_bytes());
60        data.extend_from_slice(&value.to_le_bytes());
61    }
62
63    // Extra state block: length-prefixed, may be zero-length.
64    let len = extra.len() as u64;
65    data.extend_from_slice(&len.to_le_bytes());
66    data.extend_from_slice(extra);
67
68    data
69}
70
71/// Deserialized state.
72pub struct DeserializedState {
73    pub params: Vec<(u32, f64)>,
74    pub extra: Option<Vec<u8>>,
75}
76
77/// Apply a deserialized state to a plugin: write parameter values,
78/// snap smoothers, then hand the optional extra blob to
79/// [`crate::plugin::Plugin::load_state`].
80///
81/// Format wrappers call this from the audio thread after popping a
82/// pending load off their per-instance handoff queue. The reason it
83/// must run on the audio thread (and not on the host's main thread,
84/// where state-load callbacks are typically invoked): `load_state`
85/// takes `&mut P`, which would alias the audio thread's `&mut P`
86/// inside `process()` and produce a data race. The audio thread is
87/// the single thread that already owns `&mut P` between blocks, so
88/// running the load there sidesteps the race entirely.
89///
90/// `restore_values` and `snap_smoothers` go through the param
91/// struct's interior atomics, so they don't strictly need to run on
92/// the audio thread - but applying the whole state in one place keeps
93/// the param values and the user's extra-state blob coherent for any
94/// observer reading after this returns.
95pub fn apply_state<P: crate::export::PluginExport>(plugin: &mut P, state: &DeserializedState) {
96    use truce_params::Params;
97    plugin.params().restore_values(&state.params);
98    plugin.params().snap_smoothers();
99    if let Some(extra) = &state.extra
100        && let Err(e) = plugin.load_state(extra)
101    {
102        // Audio-thread error path: host already received a "yes I
103        // accepted the state" return from the format wrapper's setChunk
104        // by the time we run, so the only thing left is logging.
105        // `eprintln!` is deliberate - `truce-core` is the audio-runtime
106        // crate, no `log` dep, and a state-load failure is a one-shot
107        // event not a per-block hot path. Format wrappers that surface
108        // this to the host (e.g. CLAP's `state_load` returning `false`)
109        // do so synchronously *before* the queue handoff.
110        eprintln!("truce: load_state failed: {e}");
111    }
112}
113
114/// Apply just the parameter values from a deserialized state - the
115/// host-thread-safe subset of [`apply_state`]. Format wrappers call
116/// this from their state-load callback (host main thread) before
117/// pushing the full state onto the audio-thread handoff queue, so
118/// host-thread reads of `getParameter`/equivalents see the restored
119/// values immediately. Validators (auval, pluginval, the VST2 binary
120/// smoke) read parameters synchronously after `setChunk`/equivalents
121/// without first running a render block, and would otherwise see the
122/// pre-restore values until the audio thread caught up.
123///
124/// The extra blob still has to round-trip through the audio thread
125/// because [`crate::plugin::Plugin::load_state`] takes `&mut P`, which
126/// would alias `process()`'s `&mut P` if called from the host thread.
127/// `restore_values` and `snap_smoothers` go through atomic interior
128/// mutability and are safe to call concurrently with `process()`.
129pub fn apply_params<P: truce_params::Params>(params: &P, state: &DeserializedState) {
130    params.restore_values(&state.params);
131    params.snap_smoothers();
132}
133
134/// Deserialize plugin state.
135#[must_use]
136pub fn deserialize_state(data: &[u8], expected_plugin_id: u64) -> Option<DeserializedState> {
137    if data.len() < 16 {
138        return None;
139    }
140
141    // Check magic
142    if &data[0..4] != STATE_MAGIC {
143        return None;
144    }
145
146    let version = u32::from_le_bytes(data[4..8].try_into().ok()?);
147    if version != STATE_VERSION {
148        return None;
149    }
150
151    let plugin_id = u64::from_le_bytes(data[8..16].try_into().ok()?);
152    if plugin_id != expected_plugin_id {
153        return None;
154    }
155
156    let mut offset = 16;
157
158    // Parameter block
159    if offset + 4 > data.len() {
160        return None;
161    }
162    let count = u32::from_le_bytes(data[offset..offset + 4].try_into().ok()?) as usize;
163    offset += 4;
164
165    // Cap the pre-allocation by what the remaining buffer could
166    // possibly hold. Each entry is 12 bytes (`u32 id` + `f64 value`),
167    // so a hostile or corrupted blob with `count = u32::MAX` (≈64 GB
168    // request) is clamped to at most the remaining byte budget. The
169    // per-iteration bounds check below still rejects entries that
170    // overrun the buffer; this just keeps the up-front allocation
171    // honest.
172    let max_count = data.len().saturating_sub(offset) / 12;
173    let mut params = Vec::with_capacity(count.min(max_count));
174    for _ in 0..count {
175        if offset + 12 > data.len() {
176            return None;
177        }
178        let id = u32::from_le_bytes(data[offset..offset + 4].try_into().ok()?);
179        offset += 4;
180        let value = f64::from_le_bytes(data[offset..offset + 8].try_into().ok()?);
181        offset += 8;
182        params.push((id, value));
183    }
184
185    // Extra state block
186    if offset + 8 > data.len() {
187        return None;
188    }
189    // The wire format encodes `extra_len` as `u64`; on 32-bit
190    // targets the cast may truncate, but the next branch validates
191    // `offset.checked_add(extra_len)` against the buffer length.
192    #[allow(clippy::cast_possible_truncation)]
193    let extra_len = u64::from_le_bytes(data[offset..offset + 8].try_into().ok()?) as usize;
194    offset += 8;
195
196    let extra = if extra_len > 0 {
197        // `offset + extra_len` can wrap to a small value when
198        // `extra_len` is huge (host-supplied), making the comparison
199        // pass even though the slice would overrun. Use `checked_add`
200        // and reject overflow as malformed.
201        match offset.checked_add(extra_len) {
202            Some(end) if end <= data.len() => Some(data[offset..end].to_vec()),
203            _ => return None,
204        }
205    } else {
206        None
207    };
208
209    Some(DeserializedState { params, extra })
210}
211
212// ---------------------------------------------------------------------------
213// `snapshot_plugin` / `restore_plugin` - high-level helpers wrapping
214// `serialize_state` + `deserialize_state` with the params-collect /
215// restore + custom-state plumbing every host needs to do anyway.
216// ---------------------------------------------------------------------------
217
218use crate::export::PluginExport;
219use truce_params::Params;
220
221/// Errors `restore_plugin` can return.
222///
223/// `Invalid` covers envelope-level failures (missing / wrong magic,
224/// version mismatch, plugin-ID mismatch, truncated body); `LoadState`
225/// covers a successfully-parsed envelope whose extra-state blob the
226/// plugin's [`crate::Plugin::load_state`] rejected. The caller
227/// typically prints a diagnostic and proceeds with default params.
228#[derive(Debug)]
229pub enum RestoreError {
230    /// The bytes don't parse as a state envelope for this plugin.
231    Invalid,
232    /// Envelope parsed but the plugin couldn't interpret its extra
233    /// bytes.
234    LoadState(StateLoadError),
235}
236
237impl std::fmt::Display for RestoreError {
238    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239        match self {
240            Self::Invalid => f.write_str("state envelope is invalid"),
241            Self::LoadState(e) => write!(f, "plugin load_state failed: {e}"),
242        }
243    }
244}
245
246impl std::error::Error for RestoreError {}
247
248/// Serialize a plugin instance into the canonical state envelope -
249/// parameter values + optional `Plugin::save_state()` payload, with
250/// the magic / version / plugin-ID header `serialize_state` writes.
251///
252/// Same shape every format wrapper produces, so a `.state` file
253/// written by one host loads in any other (subject to the
254/// plugin-ID match `deserialize_state` enforces).
255pub fn snapshot_plugin<P: PluginExport>(plugin: &P) -> Vec<u8> {
256    let (ids, values) = plugin.params().collect_values();
257    let extra = plugin.save_state();
258    serialize_state(hash_plugin_id(P::info().clap_id), &ids, &values, &extra)
259}
260
261/// Inverse of [`snapshot_plugin`]. Validates the envelope's magic,
262/// version, and plugin-ID hash; on success restores parameter
263/// values via `Params::restore_values` and forwards the optional
264/// extra payload to `Plugin::load_state`.
265///
266/// # Errors
267///
268/// Returns [`RestoreError::Invalid`] if the magic / version /
269/// plugin-ID hash check fails or the envelope is truncated. A
270/// successful return guarantees the params and (optional) extra
271/// payload were forwarded to the plugin.
272pub fn restore_plugin<P: PluginExport>(plugin: &mut P, bytes: &[u8]) -> Result<(), RestoreError> {
273    let id = hash_plugin_id(P::info().clap_id);
274    let s = deserialize_state(bytes, id).ok_or(RestoreError::Invalid)?;
275    plugin.params().restore_values(&s.params);
276    if let Some(extra) = s.extra {
277        plugin.load_state(&extra).map_err(RestoreError::LoadState)?;
278    }
279    Ok(())
280}
281
282/// Resolve the state-envelope hash every format wrapper stamps into
283/// the saved blob. Today this is just `hash_plugin_id(info.clap_id)`,
284/// which means the same plugin built as CLAP / VST3 / AU / AAX / VST2
285/// / LV2 produces a single state space - saving in one host and
286/// loading in another will round-trip parameter values (provided the
287/// `Plugin::save_state` / `load_state` extra payload is also
288/// format-agnostic).
289///
290/// **Trade-off:** because the input is the CLAP ID, renaming
291/// `info.clap_id` invalidates **every** saved session across **every**
292/// format. Callers that want format-pinned state (e.g. an AU build
293/// that shouldn't share state with the same plugin's CLAP build)
294/// should add a per-format ID field to [`crate::PluginInfo`] and
295/// route through it instead.
296#[must_use]
297pub fn shared_plugin_state_hash(info: &crate::PluginInfo) -> u64 {
298    hash_plugin_id(info.clap_id)
299}
300
301/// Compute a simple hash of the plugin ID string for state identification.
302///
303/// Uses FNV-1a-64. **Do not change this without bumping the envelope's
304/// `STATE_VERSION` and writing a migration:** the returned hash is
305/// stored verbatim in every `.pluginstate` blob the host has saved,
306/// and a different algorithm here would invalidate every shipped
307/// session. If a stronger hash is ever needed, it must be selected via
308/// the version byte in the envelope, not by replacing this function in
309/// place.
310#[must_use]
311pub fn hash_plugin_id(id: &str) -> u64 {
312    let mut hash: u64 = 0xcbf2_9ce4_8422_2325; // FNV-1a offset basis
313    for byte in id.bytes() {
314        hash ^= u64::from(byte);
315        hash = hash.wrapping_mul(0x0100_0000_01b3); // FNV prime
316    }
317    hash
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn round_trip_state() {
326        let plugin_id = hash_plugin_id("com.test.plugin");
327        let ids = [0u32, 1, 2];
328        let values = [0.5f64, 1.0, -12.0];
329        let extra = b"hello extra state";
330
331        let data = serialize_state(plugin_id, &ids, &values, extra);
332        let state = deserialize_state(&data, plugin_id).unwrap();
333
334        assert_eq!(state.params.len(), 3);
335        assert_eq!(state.params[0], (0, 0.5));
336        assert_eq!(state.params[1], (1, 1.0));
337        assert_eq!(state.params[2], (2, -12.0));
338        assert_eq!(state.extra.unwrap(), b"hello extra state");
339    }
340
341    #[test]
342    fn wrong_plugin_id_fails() {
343        let plugin_id = hash_plugin_id("com.test.plugin");
344        let data = serialize_state(plugin_id, &[], &[], &[]);
345        assert!(deserialize_state(&data, 12345).is_none());
346    }
347}