Skip to main content

truce_loader/
static_shell.rs

1//! `StaticShell` - embeds the plugin directly into the binary.
2//!
3//! No dlopen, no file watcher, no Mutex. Same types as `HotShell`
4//! but zero runtime overhead. Use via `export_static!`.
5
6use std::sync::Arc;
7
8use truce_core::buffer::AudioBuffer;
9use truce_core::bus::BusLayout;
10use truce_core::config::AudioConfig;
11use truce_core::events::{EventBody, EventList};
12use truce_core::info::PluginInfo;
13use truce_core::meters::MeterStore;
14use truce_core::plugin::PluginRuntime;
15use truce_core::process::{ProcessContext, ProcessStatus};
16use truce_core::snapshot::{SnapshotPublisher, SnapshotSlot};
17use truce_core::state::{ForeignState, MigratedState, StateLoadError};
18use truce_core::tasks::{AnyTaskSpawner, InitContext, warm_pool};
19use truce_params::Params;
20use truce_params::sample::Sample;
21use truce_plugin::PluginLogicCore;
22
23// ---------------------------------------------------------------------------
24// StaticShell
25// ---------------------------------------------------------------------------
26
27/// A static plugin shell that embeds the user's `PluginLogic` impl
28/// directly into the format-wrapper binary.
29///
30/// Same bridging as `HotShell` but without `NativeLoader`, `Mutex`,
31/// file watching, or any dynamic loading overhead. Use via `export_static!`.
32pub struct StaticShell<P: Params, L: PluginLogicCore<S, Params = P>, S: Sample = f32> {
33    pub params: Arc<P>,
34    /// The user's mutable DSP state, owned by the shell (not the
35    /// descriptor `L`). Built once via `L::init(&params)`.
36    state: L::DspState,
37    meters: Arc<MeterStore>,
38    /// Lock-free publish slot for `snapshot_into`-based state save.
39    snapshots: Arc<SnapshotSlot>,
40    /// Stays `true` until the logic reports (via `snapshot_into`), on a
41    /// block before it ever publishes, that it has no custom snapshot -
42    /// after which per-block publishing is skipped so non-opt-in plugins
43    /// pay nothing. A plugin that has published once stays subscribed.
44    try_snapshot: bool,
45    /// Last `snapshot_version` the shell published. A block whose version
46    /// matches this skips re-serialization entirely (see
47    /// `publish_snapshot_with`). `None` until the first landed publish.
48    last_snapshot_version: Option<u64>,
49    sample_rate: f64,
50    /// Background-task spawner bundle (one lane per declared task type),
51    /// when the plugin wired `tasks:` on `plugin!`. Type-erased; stamped
52    /// into each block's `ProcessContext` so `ctx.tasks::<T>()` works.
53    /// `None` for a plugin with no background tasks.
54    tasks: Option<AnyTaskSpawner>,
55    _sample: std::marker::PhantomData<fn() -> S>,
56}
57
58// SAFETY: `StaticShell` owns `Arc<P>` (params, `Sync` by the
59// `Params` trait contract), `L::DspState` (`Send + 'static` per the
60// `PluginLogicCore` bound), an atomic-slot `MeterStore`, and a
61// `PhantomData<fn() -> S>`. No raw pointers, no `!Send` fields, no
62// interior mutability that escapes the shell's own `&mut` borrows. The
63// host contract that format wrappers invoke methods on a single thread
64// at a time per instance is what keeps the embedded state safe to
65// access without an inner mutex - same model `HotShell` uses through
66// `parking_lot::Mutex`.
67unsafe impl<P: Params, L: PluginLogicCore<S, Params = P>, S: Sample> Send for StaticShell<P, L, S> {}
68
69impl<P: Params + Default + 'static, L: PluginLogicCore<S, Params = P> + 'static, S: Sample>
70    StaticShell<P, L, S>
71{
72    /// Build the shell from shared params, constructing the initial DSP
73    /// state via `L::init(&params, &cx)`. The descriptor `L` is a
74    /// type-only marker; the shell owns the state it produces. `tasks` is
75    /// the plugin's background-task spawner (`Some` only when the plugin
76    /// wired `tasks:` on `plugin!`); it reaches `init` through the
77    /// `InitContext` and each block through the `ProcessContext`.
78    pub fn from_parts(params: Arc<P>, tasks: Option<AnyTaskSpawner>) -> Self {
79        // A wired spawner means the plugin may schedule background work,
80        // possibly first from `process()` (a filter that only rebuilds on a
81        // knob move, with nothing in `init` to warm the pool). Start the
82        // pool here, on the instantiation (main) thread, so the first
83        // audio-thread schedule never cold-starts worker threads inside the
84        // callback - keeping the spawner's "safe from the audio thread"
85        // guarantee true regardless of where the plugin first schedules.
86        if tasks.is_some() {
87            warm_pool();
88        }
89        // Build the snapshot slot before `init` so the plugin can capture
90        // a publisher for the off-thread (large-state) lane. Pre-warm the
91        // inline buffer to the plugin's hint so a first small publish
92        // doesn't allocate on the audio thread.
93        let snapshots = SnapshotSlot::with_capacity(L::snapshot_prealloc_hint());
94        let init_ctx =
95            InitContext::new(tasks.clone()).with_snapshot(SnapshotPublisher::new(&snapshots));
96        let state = L::init(&params, &init_ctx);
97        Self {
98            params,
99            state,
100            meters: MeterStore::new(),
101            snapshots,
102            try_snapshot: true,
103            last_snapshot_version: None,
104            sample_rate: 44100.0,
105            tasks,
106            _sample: std::marker::PhantomData,
107        }
108    }
109
110    /// Shared meter storage handle - the GUI-thread-safe channel
111    /// for meter reads (see `PluginExport::meter_store`).
112    pub fn meter_store(&self) -> Arc<MeterStore> {
113        Arc::clone(&self.meters)
114    }
115
116    /// Shared snapshot slot for lock-free state save (see
117    /// `PluginExport::snapshot_slot`).
118    pub fn snapshot_slot(&self) -> Arc<SnapshotSlot> {
119        Arc::clone(&self.snapshots)
120    }
121
122    /// The plugin's background-task spawner (see
123    /// `PluginExport::task_spawner`). `None` unless the plugin wired
124    /// `tasks:` on `plugin!`.
125    pub fn task_spawner(&self) -> Option<AnyTaskSpawner> {
126        self.tasks.clone()
127    }
128
129    /// Access the plugin's DSP state (for testing).
130    pub fn state_ref(&self) -> &L::DspState {
131        &self.state
132    }
133
134    /// Mutable access to the plugin's DSP state (for testing).
135    pub fn state_ref_mut(&mut self) -> &mut L::DspState {
136        &mut self.state
137    }
138}
139
140impl<P: Params + Default + 'static, L: PluginLogicCore<S, Params = P> + 'static, S: Sample>
141    PluginRuntime for StaticShell<P, L, S>
142{
143    type Sample = S;
144
145    fn info() -> PluginInfo
146    where
147        Self: Sized,
148    {
149        unreachable!("StaticShell::info() should not be called statically")
150    }
151
152    fn bus_layouts() -> Vec<BusLayout>
153    where
154        Self: Sized,
155    {
156        unreachable!("StaticShell::bus_layouts() should not be called statically")
157    }
158
159    fn init(&mut self) {}
160
161    fn reset(&mut self, config: &AudioConfig) {
162        self.sample_rate = config.sample_rate;
163        // Params plumbing is the shell's job, not the plugin's: settle
164        // smoother coefficients and state before the user's `reset` so
165        // its body reads post-snap values.
166        self.params.set_sample_rate(config.sample_rate);
167        self.params.snap_smoothers();
168        L::reset(&mut self.state, &self.params, config);
169    }
170
171    fn process(
172        &mut self,
173        buffer: &mut AudioBuffer<S>,
174        events: &EventList,
175        context: &mut ProcessContext,
176    ) -> ProcessStatus {
177        // Apply parameter change events to the shell's params.
178        // ParamChange values from format wrappers are PLAIN (already
179        // denormalized). `set_normalized` here would double-denormalize.
180        for e in events.iter() {
181            if let EventBody::ParamChange { id, value } = &e.body {
182                self.params.set_plain(*id, *value);
183            }
184        }
185
186        // No sync needed - plugin reads from the same Arc<Params>.
187
188        // Build a ProcessContext with param/meter callbacks for the logic.
189        let params = &self.params;
190        let meters = &self.meters;
191        let param_fn = |id: u32| -> f64 { params.get_plain(id).unwrap_or(0.0) };
192        let meter_fn = |id: u32, v: f32| meters.write(id, v);
193        let ctx = ProcessContext::new(
194            context.transport,
195            context.sample_rate,
196            buffer.num_samples(),
197            &mut *context.output_events,
198        )
199        .with_process_mode(context.process_mode)
200        .with_params(&param_fn)
201        .with_meters(&meter_fn);
202        // Stamp the background-task spawner so `ctx.tasks::<T>()` works.
203        let mut ctx = match &self.tasks {
204            Some(t) => ctx.with_tasks(t),
205            None => ctx,
206        };
207
208        let status = L::process(&mut self.state, &self.params, buffer, events, &mut ctx);
209        publish_snapshot::<S, L>(
210            &self.state,
211            &self.snapshots,
212            &mut self.try_snapshot,
213            &mut self.last_snapshot_version,
214        );
215        status
216    }
217
218    fn save_state(&self) -> Vec<u8> {
219        L::save_state(&self.state)
220    }
221
222    fn snapshot_into(&self, buf: &mut Vec<u8>) -> bool {
223        L::snapshot_into(&self.state, buf)
224    }
225
226    fn republish_snapshot(&mut self) {
227        publish_snapshot::<S, L>(
228            &self.state,
229            &self.snapshots,
230            &mut self.try_snapshot,
231            &mut self.last_snapshot_version,
232        );
233    }
234
235    fn load_state(&mut self, data: &[u8]) -> Result<(), StateLoadError> {
236        let result = L::load_state(&mut self.state, data);
237        // Plugin-side cache invalidation runs in the same `&mut`
238        // borrow window so the next `process()` block sees the
239        // refreshed caches - fire it whether or not load_state
240        // succeeded so partial state still triggers a refresh.
241        L::state_changed(&mut self.state, &self.params);
242        // Invalidate the snapshot-version gate: the load replaced state
243        // without necessarily bumping `snapshot_version` (a counter that
244        // round-trips through the blob, or an author who forgets), so the
245        // next publish - the `republish_snapshot` the wrapper calls right
246        // after this - must re-serialize rather than skip on a stale
247        // version and leave pre-load bytes in the slot.
248        self.last_snapshot_version = None;
249        result
250    }
251
252    fn migrate_state(foreign: &ForeignState) -> Option<MigratedState>
253    where
254        Self: Sized,
255    {
256        <L as PluginLogicCore<S>>::migrate_state(foreign)
257    }
258
259    fn latency(&self) -> u32 {
260        L::latency(&self.state)
261    }
262    fn tail(&self) -> u32 {
263        L::tail(&self.state)
264    }
265
266    fn get_meter(&self, meter_id: u32) -> f32 {
267        self.meters.read(meter_id)
268    }
269}
270
271/// Publish the plugin's `snapshot_into` bytes into `slot` on the audio
272/// thread. Shared by both shells.
273///
274/// Opting into snapshots is a static capability: `try_snapshot` latches
275/// off only when the logic reports "no snapshot" *before it has ever
276/// published one* (the default `snapshot_into` returning false), so a
277/// non-opt-in plugin stops paying after one block. Once a plugin has
278/// published, it stays subscribed for its lifetime - a plugin that
279/// returns true then later false is violating the contract, and we keep
280/// calling it rather than silently latching off and serving stale bytes.
281/// Never blocks: `SnapshotSlot::publish` skips on reader contention, in
282/// which case the closure doesn't run and the latch is left alone.
283pub(crate) fn publish_snapshot<S, L>(
284    state: &L::DspState,
285    slot: &SnapshotSlot,
286    try_snapshot: &mut bool,
287    last_version: &mut Option<u64>,
288) where
289    S: Sample,
290    L: PluginLogicCore<S>,
291{
292    let version = L::snapshot_version(state);
293    publish_snapshot_with(slot, try_snapshot, last_version, version, |buf| {
294        L::snapshot_into(state, buf)
295    });
296}
297
298/// Latch logic behind [`publish_snapshot`], parameterized over the raw
299/// `snapshot_into` closure so it can be unit-tested without a full
300/// `PluginLogicCore` mock. `pub(crate)` so `HotShell` can drive it with
301/// a closure over the reloadable dylib's `truce_snapshot_into` symbol.
302///
303/// `version` is the plugin's [`PluginLogicCore::snapshot_version`] this
304/// block. When it's `Some(v)` and equals `*last_version`, the state is
305/// unchanged since the last landed publish, so the whole publish is
306/// skipped - no lock, no copy, O(1). `None` re-serializes every block
307/// (the historical behavior). `last_version` advances only on a landed
308/// write, so a block skipped on reader contention retries next time.
309pub(crate) fn publish_snapshot_with(
310    slot: &SnapshotSlot,
311    try_snapshot: &mut bool,
312    last_version: &mut Option<u64>,
313    version: Option<u64>,
314    snapshot_into: impl FnOnce(&mut Vec<u8>) -> bool,
315) {
316    if !*try_snapshot {
317        return;
318    }
319    // Version gate: a versioned plugin whose token is unchanged since the
320    // last landed publish keeps the previous snapshot - the common path.
321    if let Some(v) = version
322        && *last_version == Some(v)
323    {
324        return;
325    }
326    let ran_unsupported = std::cell::Cell::new(false);
327    let landed = slot.publish(|buf| {
328        let wrote = snapshot_into(buf);
329        ran_unsupported.set(!wrote);
330        wrote
331    });
332    // Record the version only on a landed real write, so a block skipped
333    // on reader contention retries next time instead of latching a
334    // version whose bytes never reached the slot.
335    if landed && !ran_unsupported.get() {
336        *last_version = version;
337    }
338    // First-block opt-out only: a plugin that has already published is
339    // committed for its lifetime, so a later false never latches us off.
340    if ran_unsupported.get() && !slot.is_supported() {
341        *try_snapshot = false;
342    }
343}
344
345// ---------------------------------------------------------------------------
346// export_static! macro
347// ---------------------------------------------------------------------------
348
349/// Compile-time static embedding of a `PluginLogic` impl into the binary.
350///
351/// Produces a `__HotShellWrapper` struct that implements `Plugin + PluginExport`,
352/// so format export macros (`export_clap!`, `export_vst3!`, etc.) work unchanged.
353/// No dlopen, no file watcher, zero runtime overhead. Bus layouts come from
354/// `<$logic as PluginLogic>::bus_layouts()` - override the trait method to
355/// pick something other than the stereo default.
356///
357/// ```ignore
358/// export_static! {
359///     params: GainParams,
360///     info: plugin_info!(...),
361///     logic: Gain,
362/// }
363///
364/// #[cfg(feature = "clap")]
365/// truce_clap::export_clap!(__HotShellWrapper);
366/// ```
367#[macro_export]
368macro_rules! export_static {
369    (
370        params: $params:ty,
371        info: $info:expr,
372        logic: $logic:ty,
373        $(tasks: [$($task:ty),+],)?
374    ) => {
375        pub struct __HotShellWrapper {
376            // `Sample` here resolves to the type alias the user
377            // imported from a prelude (`prelude` / `prelude32` →
378            // `f32`; `prelude64` → `f64`; `prelude64m` → `f32`). The
379            // `PluginLogic<Sample>` bound on the user's impl must
380            // match this, so the prelude is what picks the audio
381            // buffer precision end-to-end.
382            inner: $crate::static_shell::StaticShell<$params, $logic, Sample>,
383        }
384
385        impl $crate::__macro_deps::truce_core::plugin::PluginRuntime for __HotShellWrapper {
386            type Sample = Sample;
387
388            fn supports_in_place() -> bool
389            where
390                Self: Sized,
391            {
392                // `PluginLogicCore<Sample>` is the wrapper-facing
393                // trait; the user impl'd one of the leaf traits
394                // (`PluginLogic` / `PluginLogic64`), and the blanket
395                // bridge defined alongside those traits in
396                // `truce-plugin` makes them also satisfy
397                // `PluginLogicCore<Sample>` automatically. Sample
398                // resolves through the prelude alias in scope at the
399                // macro call site.
400                <$logic as $crate::__macro_deps::truce_plugin::PluginLogicCore<Sample>>::supports_in_place()
401            }
402
403            fn info() -> $crate::__macro_deps::truce_core::info::PluginInfo
404            where
405                Self: Sized,
406            {
407                $info
408            }
409
410            fn bus_layouts() -> Vec<$crate::__macro_deps::truce_core::bus::BusLayout>
411            where
412                Self: Sized,
413            {
414                <$logic as $crate::__macro_deps::truce_plugin::PluginLogicCore<Sample>>::bus_layouts()
415            }
416
417            fn init(&mut self) {
418                self.inner.init();
419            }
420
421            fn reset(&mut self, config: &$crate::__macro_deps::truce_core::config::AudioConfig) {
422                self.inner.reset(config);
423            }
424
425            fn process(
426                &mut self,
427                buffer: &mut $crate::__macro_deps::truce_core::buffer::AudioBuffer<Sample>,
428                events: &$crate::__macro_deps::truce_core::events::EventList,
429                context: &mut $crate::__macro_deps::truce_core::process::ProcessContext,
430            ) -> $crate::__macro_deps::truce_core::process::ProcessStatus {
431                self.inner.process(buffer, events, context)
432            }
433
434            fn save_state(&self) -> Vec<u8> {
435                self.inner.save_state()
436            }
437
438            fn snapshot_into(&self, buf: &mut Vec<u8>) -> bool {
439                self.inner.snapshot_into(buf)
440            }
441
442            fn load_state(
443                &mut self,
444                data: &[u8],
445            ) -> Result<(), $crate::__macro_deps::truce_core::state::StateLoadError> {
446                self.inner.load_state(data)
447            }
448
449            fn republish_snapshot(&mut self) {
450                self.inner.republish_snapshot();
451            }
452
453            fn migrate_state(
454                foreign: &$crate::__macro_deps::truce_core::state::ForeignState,
455            ) -> Option<$crate::__macro_deps::truce_core::state::MigratedState>
456            where
457                Self: Sized,
458            {
459                <$logic as $crate::__macro_deps::truce_plugin::PluginLogicCore<Sample>>::migrate_state(foreign)
460            }
461
462            fn latency(&self) -> u32 {
463                self.inner.latency()
464            }
465            fn tail(&self) -> u32 {
466                self.inner.tail()
467            }
468            fn get_meter(&self, meter_id: u32) -> f32 {
469                self.inner.get_meter(meter_id)
470            }
471        }
472
473        impl $crate::__macro_deps::truce_core::export::PluginExport for __HotShellWrapper {
474            type Params = $params;
475
476            fn create() -> Self {
477                let params = std::sync::Arc::new(<$params>::new());
478                // Each `tasks: [..]` type gets its own lane (queue + mode).
479                // The bundle collapses to `None` when no types were listed,
480                // so a plugin with no tasks runs with no pool.
481                #[allow(unused_mut)]
482                let mut __task_bundle =
483                    $crate::__macro_deps::truce_core::tasks::TaskSpawnerBundle::new();
484                $(
485                    $({
486                        let __task_run = {
487                            let params = std::sync::Arc::clone(&params);
488                            move |task| {
489                                <$task as $crate::__macro_deps::truce_plugin::BackgroundTask>::run(
490                                    task, &params,
491                                )
492                            }
493                        };
494                        // `SERIALIZED` picks one-slot vs concurrent draining
495                        // for this lane; the const folds the branch at
496                        // compile time.
497                        let __spawner = if <$task as $crate::__macro_deps::truce_plugin::BackgroundTask>::SERIALIZED {
498                            $crate::__macro_deps::truce_core::tasks::TaskSpawner::<$task>::new_serialized(__task_run)
499                        } else {
500                            $crate::__macro_deps::truce_core::tasks::TaskSpawner::<$task>::new(__task_run)
501                        };
502                        __task_bundle.push(__spawner);
503                    })+
504                )?
505                let tasks = __task_bundle.into_any();
506                // The descriptor `$logic` is stateless; `from_parts`
507                // builds the DSP state via `<$logic>::init(&params, &cx)`.
508                Self {
509                    inner: $crate::static_shell::StaticShell::from_parts(params, tasks),
510                }
511            }
512
513            fn params(&self) -> &$params {
514                &self.inner.params
515            }
516
517            fn params_arc(&self) -> std::sync::Arc<$params> {
518                std::sync::Arc::clone(&self.inner.params)
519            }
520
521            fn meter_store(
522                &self,
523            ) -> std::sync::Arc<$crate::__macro_deps::truce_core::meters::MeterStore> {
524                self.inner.meter_store()
525            }
526
527            fn snapshot_slot(
528                &self,
529            ) -> std::sync::Arc<$crate::__macro_deps::truce_core::snapshot::SnapshotSlot> {
530                self.inner.snapshot_slot()
531            }
532
533            fn task_spawner(
534                &self,
535            ) -> ::core::option::Option<
536                $crate::__macro_deps::truce_core::tasks::AnyTaskSpawner,
537            > {
538                self.inner.task_spawner()
539            }
540
541            fn editor_builder(
542                &self,
543            ) -> $crate::__macro_deps::truce_core::editor::EditorBuilder<$params> {
544                // Builds from the lock-free param store, never the
545                // embedded logic - the audio thread's `&mut logic` is
546                // irrelevant here, so opening the editor takes no lock.
547                Box::new(|params| {
548                    Some(
549                        <$logic as $crate::__macro_deps::truce_plugin::PluginEditor<Sample>>::editor(
550                            params,
551                        ),
552                    )
553                })
554            }
555        }
556    };
557}
558
559#[cfg(test)]
560mod tests {
561    use super::publish_snapshot_with;
562    use std::cell::Cell;
563    use truce_core::snapshot::SnapshotSlot;
564
565    #[test]
566    fn non_opt_in_latches_off_on_first_block() {
567        let slot = SnapshotSlot::new();
568        let mut try_snapshot = true;
569        let mut last = None;
570
571        // Default `snapshot_into` (returns false) before any publish:
572        // latch off so we stop paying every block.
573        publish_snapshot_with(&slot, &mut try_snapshot, &mut last, None, |_| false);
574        assert!(!try_snapshot, "first false must latch off");
575        assert!(!slot.is_supported());
576
577        // Subsequent blocks short-circuit and never call the closure.
578        let mut called = false;
579        publish_snapshot_with(&slot, &mut try_snapshot, &mut last, None, |_| {
580            called = true;
581            false
582        });
583        assert!(!called, "latched-off slot must not call snapshot_into");
584    }
585
586    #[test]
587    fn opt_in_then_contract_violation_stays_subscribed() {
588        let slot = SnapshotSlot::new();
589        let mut try_snapshot = true;
590        let mut last = None;
591
592        // Block 1: plugin publishes - it has opted in for its lifetime.
593        publish_snapshot_with(&slot, &mut try_snapshot, &mut last, None, |buf| {
594            buf.clear();
595            buf.extend_from_slice(&[1, 2, 3]);
596            true
597        });
598        assert!(try_snapshot);
599        assert!(slot.is_supported());
600        assert_eq!(slot.read(), Some(vec![1, 2, 3]));
601
602        // Block 2: a contract-violating false must NOT latch us off - we
603        // keep calling the plugin rather than silently going dark.
604        publish_snapshot_with(&slot, &mut try_snapshot, &mut last, None, |_| false);
605        assert!(try_snapshot, "a post-opt-in false must not latch off");
606
607        // Block 3: still subscribed, so a fresh publish still lands.
608        publish_snapshot_with(&slot, &mut try_snapshot, &mut last, None, |buf| {
609            buf.clear();
610            buf.extend_from_slice(&[4]);
611            true
612        });
613        assert_eq!(slot.read(), Some(vec![4]));
614    }
615
616    #[test]
617    fn unchanged_version_skips_the_copy() {
618        let slot = SnapshotSlot::new();
619        let mut try_snapshot = true;
620        let mut last = None;
621        let calls = Cell::new(0);
622        let publish = |ver, try_s: &mut bool, last: &mut Option<u64>| {
623            publish_snapshot_with(&slot, try_s, last, Some(ver), |buf| {
624                calls.set(calls.get() + 1);
625                buf.extend_from_slice(&[u8::try_from(ver).unwrap_or(0)]);
626                true
627            });
628        };
629
630        // Version 7 lands and is recorded.
631        publish(7, &mut try_snapshot, &mut last);
632        assert_eq!(calls.get(), 1);
633        assert_eq!(last, Some(7));
634        assert_eq!(slot.read(), Some(vec![7]));
635
636        // Same version twice more: the writer never runs again.
637        publish(7, &mut try_snapshot, &mut last);
638        publish(7, &mut try_snapshot, &mut last);
639        assert_eq!(calls.get(), 1, "unchanged version must skip the copy");
640
641        // A new version re-serializes.
642        publish(9, &mut try_snapshot, &mut last);
643        assert_eq!(calls.get(), 2);
644        assert_eq!(slot.read(), Some(vec![9]));
645    }
646
647    #[test]
648    fn unversioned_none_publishes_every_block() {
649        // The default (no `snapshot_version`) must keep re-serializing
650        // every block - the historical behavior, unchanged.
651        let slot = SnapshotSlot::new();
652        let mut try_snapshot = true;
653        let mut last = None;
654        let calls = Cell::new(0);
655        for _ in 0..3 {
656            publish_snapshot_with(&slot, &mut try_snapshot, &mut last, None, |buf| {
657                calls.set(calls.get() + 1);
658                buf.push(1);
659                true
660            });
661        }
662        assert_eq!(calls.get(), 3, "None version re-serializes every block");
663    }
664
665    #[test]
666    fn load_reset_forces_republish_at_unchanged_version() {
667        // A load clears `last_snapshot_version` in both shells' `load_state`,
668        // so the `republish_snapshot` the wrapper fires right after a load
669        // re-serializes even though the plugin's version token didn't change
670        // (a counter that round-trips through the blob, or an author who
671        // forgot to bump). Without the reset the gate stays closed and the
672        // slot keeps pre-load bytes - the host's next save reverts the load.
673        let slot = SnapshotSlot::new();
674        let mut try_snapshot = true;
675        let mut last = None;
676        let calls = Cell::new(0);
677        let publish = |ver, try_s: &mut bool, last: &mut Option<u64>| {
678            publish_snapshot_with(&slot, try_s, last, Some(ver), |buf| {
679                calls.set(calls.get() + 1);
680                buf.push(u8::try_from(ver).unwrap_or(0));
681                true
682            });
683        };
684
685        publish(5, &mut try_snapshot, &mut last);
686        publish(5, &mut try_snapshot, &mut last);
687        assert_eq!(calls.get(), 1, "unchanged version skips");
688
689        // `load_state` clears the gate.
690        last = None;
691        publish(5, &mut try_snapshot, &mut last);
692        assert_eq!(
693            calls.get(),
694            2,
695            "a republish after a load must re-serialize even at the same version"
696        );
697    }
698}