Skip to main content

truce_utils/
state.rs

1//! Canonical plugin-state wire format.
2//!
3//! Lives in `truce-utils` (rather than `truce-core`) so build-time
4//! tools - `cargo-truce` emits factory-preset files containing this
5//! envelope at install time - can produce byte-identical blobs
6//! without inheriting `truce-core`'s runtime dependency chain.
7//! `truce-core::state` re-exports everything here and layers the
8//! plugin-coupled helpers (`apply_state`, `snapshot_plugin`, ...)
9//! on top.
10
11/// Magic bytes for state identification.
12const STATE_MAGIC: &[u8; 4] = b"OAST";
13const STATE_VERSION: u32 = 1;
14
15/// Serialize plugin state: parameter values + extra state. Empty
16/// `extra` slice serializes as the same `0u64` length-prefix that an
17/// absent extra block would, so callers don't need an `Option`
18/// wrapper to express "no extra state".
19#[must_use]
20pub fn serialize_state(
21    plugin_id_hash: u64,
22    param_ids: &[u32],
23    param_values: &[f64],
24    extra: &[u8],
25) -> Vec<u8> {
26    let mut data = Vec::new();
27
28    // Header
29    data.extend_from_slice(STATE_MAGIC);
30    data.extend_from_slice(&STATE_VERSION.to_le_bytes());
31    data.extend_from_slice(&plugin_id_hash.to_le_bytes());
32
33    // Parameter block
34    let count = crate::cast::len_u32(param_ids.len());
35    data.extend_from_slice(&count.to_le_bytes());
36    for (id, value) in param_ids.iter().zip(param_values.iter()) {
37        data.extend_from_slice(&id.to_le_bytes());
38        data.extend_from_slice(&value.to_le_bytes());
39    }
40
41    // Extra state block: length-prefixed, may be zero-length.
42    let len = extra.len() as u64;
43    data.extend_from_slice(&len.to_le_bytes());
44    data.extend_from_slice(extra);
45
46    data
47}
48
49/// Deserialized state.
50pub struct DeserializedState {
51    pub params: Vec<(u32, f64)>,
52    pub extra: Option<Vec<u8>>,
53}
54
55/// Deserialize plugin state.
56#[must_use]
57pub fn deserialize_state(data: &[u8], expected_plugin_id: u64) -> Option<DeserializedState> {
58    if data.len() < 16 {
59        return None;
60    }
61
62    // Check magic
63    if &data[0..4] != STATE_MAGIC {
64        return None;
65    }
66
67    let version = u32::from_le_bytes(data[4..8].try_into().ok()?);
68    if version != STATE_VERSION {
69        return None;
70    }
71
72    let plugin_id = u64::from_le_bytes(data[8..16].try_into().ok()?);
73    if plugin_id != expected_plugin_id {
74        return None;
75    }
76
77    let mut offset = 16;
78
79    // Parameter block
80    if offset + 4 > data.len() {
81        return None;
82    }
83    let count = u32::from_le_bytes(data[offset..offset + 4].try_into().ok()?) as usize;
84    offset += 4;
85
86    // Cap the pre-allocation by what the remaining buffer could
87    // possibly hold. Each entry is 12 bytes (`u32 id` + `f64 value`),
88    // so a hostile or corrupted blob with `count = u32::MAX` (≈64 GB
89    // request) is clamped to at most the remaining byte budget. The
90    // per-iteration bounds check below still rejects entries that
91    // overrun the buffer; this just keeps the up-front allocation
92    // honest.
93    let max_count = data.len().saturating_sub(offset) / 12;
94    let mut params = Vec::with_capacity(count.min(max_count));
95    for _ in 0..count {
96        if offset + 12 > data.len() {
97            return None;
98        }
99        let id = u32::from_le_bytes(data[offset..offset + 4].try_into().ok()?);
100        offset += 4;
101        let value = f64::from_le_bytes(data[offset..offset + 8].try_into().ok()?);
102        offset += 8;
103        params.push((id, value));
104    }
105
106    // Extra state block
107    if offset + 8 > data.len() {
108        return None;
109    }
110    // The wire format encodes `extra_len` as `u64`; on 32-bit
111    // targets the cast may truncate, but the next branch validates
112    // `offset.checked_add(extra_len)` against the buffer length.
113    #[allow(clippy::cast_possible_truncation)]
114    let extra_len = u64::from_le_bytes(data[offset..offset + 8].try_into().ok()?) as usize;
115    offset += 8;
116
117    let extra = if extra_len > 0 {
118        // `offset + extra_len` can wrap to a small value when
119        // `extra_len` is huge (host-supplied), making the comparison
120        // pass even though the slice would overrun. Use `checked_add`
121        // and reject overflow as malformed.
122        match offset.checked_add(extra_len) {
123            Some(end) if end <= data.len() => Some(data[offset..end].to_vec()),
124            _ => return None,
125        }
126    } else {
127        None
128    };
129
130    Some(DeserializedState { params, extra })
131}
132
133/// Compute a simple hash of the plugin ID string for state identification.
134///
135/// Uses FNV-1a-64. **Do not change this without bumping the envelope's
136/// `STATE_VERSION` and writing a migration:** the returned hash is
137/// stored verbatim in every `.pluginstate` blob the host has saved,
138/// and a different algorithm here would invalidate every shipped
139/// session. If a stronger hash is ever needed, it must be selected via
140/// the version byte in the envelope, not by replacing this function in
141/// place.
142#[must_use]
143pub fn hash_plugin_id(id: &str) -> u64 {
144    let mut hash: u64 = 0xcbf2_9ce4_8422_2325; // FNV-1a offset basis
145    for byte in id.bytes() {
146        hash ^= u64::from(byte);
147        hash = hash.wrapping_mul(0x0100_0000_01b3); // FNV prime
148    }
149    hash
150}
151
152/// The 16-byte VST3 class ID for a plugin ID string.
153///
154/// FNV-1a-128, per <http://www.isthe.com/chongo/tech/comp/fnv/>.
155/// Standard constants - DAWs persist this CID as the plugin's identity
156/// in saved sessions and `.vstpreset` files, so the algorithm and
157/// constants must stay stable across releases. `truce-vst3` reports
158/// this as the component's TUID; `cargo-truce` stamps the same bytes
159/// (hex-encoded) into emitted `.vstpreset` headers.
160#[must_use]
161pub fn vst3_cid(id: &str) -> [u8; 16] {
162    const FNV_OFFSET_BASIS: u128 = 0x6C62_272E_07BB_0142_62B8_2175_6295_C58D;
163    const FNV_PRIME: u128 = 0x0000_0000_0100_0000_0000_0000_0000_013B;
164    let mut hash = FNV_OFFSET_BASIS;
165    for byte in id.bytes() {
166        hash ^= u128::from(byte);
167        hash = hash.wrapping_mul(FNV_PRIME);
168    }
169    hash.to_le_bytes()
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn round_trip_state() {
178        let plugin_id = hash_plugin_id("com.test.plugin");
179        let ids = [0u32, 1, 2];
180        let values = [0.5f64, 1.0, -12.0];
181        let extra = b"hello extra state";
182
183        let data = serialize_state(plugin_id, &ids, &values, extra);
184        let state = deserialize_state(&data, plugin_id).unwrap();
185
186        assert_eq!(state.params.len(), 3);
187        assert_eq!(state.params[0], (0, 0.5));
188        assert_eq!(state.params[1], (1, 1.0));
189        assert_eq!(state.params[2], (2, -12.0));
190        assert_eq!(state.extra.unwrap(), b"hello extra state");
191    }
192
193    #[test]
194    fn wrong_plugin_id_fails() {
195        let plugin_id = hash_plugin_id("com.test.plugin");
196        let data = serialize_state(plugin_id, &[], &[], &[]);
197        assert!(deserialize_state(&data, 12345).is_none());
198    }
199}