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 republish_snapshot(&mut self) {
223        publish_snapshot::<S, L>(
224            &self.state,
225            &self.snapshots,
226            &mut self.try_snapshot,
227            &mut self.last_snapshot_version,
228        );
229    }
230
231    fn load_state(&mut self, data: &[u8]) -> Result<(), StateLoadError> {
232        let result = L::load_state(&mut self.state, data);
233        // Plugin-side cache invalidation runs in the same `&mut`
234        // borrow window so the next `process()` block sees the
235        // refreshed caches - fire it whether or not load_state
236        // succeeded so partial state still triggers a refresh.
237        L::state_changed(&mut self.state, &self.params);
238        // Invalidate the snapshot-version gate: the load replaced state
239        // without necessarily bumping `snapshot_version` (a counter that
240        // round-trips through the blob, or an author who forgets), so the
241        // next publish - the `republish_snapshot` the wrapper calls right
242        // after this - must re-serialize rather than skip on a stale
243        // version and leave pre-load bytes in the slot.
244        self.last_snapshot_version = None;
245        result
246    }
247
248    fn migrate_state(foreign: &ForeignState) -> Option<MigratedState>
249    where
250        Self: Sized,
251    {
252        <L as PluginLogicCore<S>>::migrate_state(foreign)
253    }
254
255    fn latency(&self) -> u32 {
256        L::latency(&self.state)
257    }
258    fn tail(&self) -> u32 {
259        L::tail(&self.state)
260    }
261
262    fn get_meter(&self, meter_id: u32) -> f32 {
263        self.meters.read(meter_id)
264    }
265}
266
267/// Publish the plugin's `snapshot_into` bytes into `slot` on the audio
268/// thread. Shared by both shells.
269///
270/// Opting into snapshots is a static capability: `try_snapshot` latches
271/// off only when the logic reports "no snapshot" *before it has ever
272/// published one* (the default `snapshot_into` returning false), so a
273/// non-opt-in plugin stops paying after one block. Once a plugin has
274/// published, it stays subscribed for its lifetime - a plugin that
275/// returns true then later false is violating the contract, and we keep
276/// calling it rather than silently latching off and serving stale bytes.
277/// Never blocks: `SnapshotSlot::publish` skips on reader contention, in
278/// which case the closure doesn't run and the latch is left alone.
279pub(crate) fn publish_snapshot<S, L>(
280    state: &L::DspState,
281    slot: &SnapshotSlot,
282    try_snapshot: &mut bool,
283    last_version: &mut Option<u64>,
284) where
285    S: Sample,
286    L: PluginLogicCore<S>,
287{
288    let version = L::snapshot_version(state);
289    publish_snapshot_with(slot, try_snapshot, last_version, version, |buf| {
290        L::snapshot_into(state, buf)
291    });
292}
293
294/// Latch logic behind [`publish_snapshot`], parameterized over the raw
295/// `snapshot_into` closure so it can be unit-tested without a full
296/// `PluginLogicCore` mock. `pub(crate)` so `HotShell` can drive it with
297/// a closure over the reloadable dylib's `truce_snapshot_into` symbol.
298///
299/// `version` is the plugin's [`PluginLogicCore::snapshot_version`] this
300/// block. When it's `Some(v)` and equals `*last_version`, the state is
301/// unchanged since the last landed publish, so the whole publish is
302/// skipped - no lock, no copy, O(1). `None` re-serializes every block
303/// (the historical behavior). `last_version` advances only on a landed
304/// write, so a block skipped on reader contention retries next time.
305pub(crate) fn publish_snapshot_with(
306    slot: &SnapshotSlot,
307    try_snapshot: &mut bool,
308    last_version: &mut Option<u64>,
309    version: Option<u64>,
310    snapshot_into: impl FnOnce(&mut Vec<u8>) -> bool,
311) {
312    if !*try_snapshot {
313        return;
314    }
315    // Version gate: a versioned plugin whose token is unchanged since the
316    // last landed publish keeps the previous snapshot - the common path.
317    if let Some(v) = version
318        && *last_version == Some(v)
319    {
320        return;
321    }
322    let ran_unsupported = std::cell::Cell::new(false);
323    let landed = slot.publish(|buf| {
324        let wrote = snapshot_into(buf);
325        ran_unsupported.set(!wrote);
326        wrote
327    });
328    // Record the version only on a landed real write, so a block skipped
329    // on reader contention retries next time instead of latching a
330    // version whose bytes never reached the slot.
331    if landed && !ran_unsupported.get() {
332        *last_version = version;
333    }
334    // First-block opt-out only: a plugin that has already published is
335    // committed for its lifetime, so a later false never latches us off.
336    if ran_unsupported.get() && !slot.is_supported() {
337        *try_snapshot = false;
338    }
339}
340
341// ---------------------------------------------------------------------------
342// export_static! macro
343// ---------------------------------------------------------------------------
344
345/// Compile-time static embedding of a `PluginLogic` impl into the binary.
346///
347/// Produces a `__HotShellWrapper` struct that implements `Plugin + PluginExport`,
348/// so format export macros (`export_clap!`, `export_vst3!`, etc.) work unchanged.
349/// No dlopen, no file watcher, zero runtime overhead. Bus layouts come from
350/// `<$logic as PluginLogic>::bus_layouts()` - override the trait method to
351/// pick something other than the stereo default.
352///
353/// ```ignore
354/// export_static! {
355///     params: GainParams,
356///     info: plugin_info!(...),
357///     logic: Gain,
358/// }
359///
360/// #[cfg(feature = "clap")]
361/// truce_clap::export_clap!(__HotShellWrapper);
362/// ```
363#[macro_export]
364macro_rules! export_static {
365    (
366        params: $params:ty,
367        info: $info:expr,
368        logic: $logic:ty,
369        $(tasks: [$($task:ty),+],)?
370    ) => {
371        pub struct __HotShellWrapper {
372            // `Sample` here resolves to the type alias the user
373            // imported from a prelude (`prelude` / `prelude32` →
374            // `f32`; `prelude64` → `f64`; `prelude64m` → `f32`). The
375            // `PluginLogic<Sample>` bound on the user's impl must
376            // match this, so the prelude is what picks the audio
377            // buffer precision end-to-end.
378            inner: $crate::static_shell::StaticShell<$params, $logic, Sample>,
379        }
380
381        impl $crate::__macro_deps::truce_core::plugin::PluginRuntime for __HotShellWrapper {
382            type Sample = Sample;
383
384            fn supports_in_place() -> bool
385            where
386                Self: Sized,
387            {
388                // `PluginLogicCore<Sample>` is the wrapper-facing
389                // trait; the user impl'd one of the leaf traits
390                // (`PluginLogic` / `PluginLogic64`), and the blanket
391                // bridge defined alongside those traits in
392                // `truce-plugin` makes them also satisfy
393                // `PluginLogicCore<Sample>` automatically. Sample
394                // resolves through the prelude alias in scope at the
395                // macro call site.
396                <$logic as $crate::__macro_deps::truce_plugin::PluginLogicCore<Sample>>::supports_in_place()
397            }
398
399            fn info() -> $crate::__macro_deps::truce_core::info::PluginInfo
400            where
401                Self: Sized,
402            {
403                $info
404            }
405
406            fn bus_layouts() -> Vec<$crate::__macro_deps::truce_core::bus::BusLayout>
407            where
408                Self: Sized,
409            {
410                <$logic as $crate::__macro_deps::truce_plugin::PluginLogicCore<Sample>>::bus_layouts()
411            }
412
413            fn init(&mut self) {
414                self.inner.init();
415            }
416
417            fn reset(&mut self, config: &$crate::__macro_deps::truce_core::config::AudioConfig) {
418                self.inner.reset(config);
419            }
420
421            fn process(
422                &mut self,
423                buffer: &mut $crate::__macro_deps::truce_core::buffer::AudioBuffer<Sample>,
424                events: &$crate::__macro_deps::truce_core::events::EventList,
425                context: &mut $crate::__macro_deps::truce_core::process::ProcessContext,
426            ) -> $crate::__macro_deps::truce_core::process::ProcessStatus {
427                self.inner.process(buffer, events, context)
428            }
429
430            fn save_state(&self) -> Vec<u8> {
431                self.inner.save_state()
432            }
433
434            fn load_state(
435                &mut self,
436                data: &[u8],
437            ) -> Result<(), $crate::__macro_deps::truce_core::state::StateLoadError> {
438                self.inner.load_state(data)
439            }
440
441            fn migrate_state(
442                foreign: &$crate::__macro_deps::truce_core::state::ForeignState,
443            ) -> Option<$crate::__macro_deps::truce_core::state::MigratedState>
444            where
445                Self: Sized,
446            {
447                <$logic as $crate::__macro_deps::truce_plugin::PluginLogicCore<Sample>>::migrate_state(foreign)
448            }
449
450            fn latency(&self) -> u32 {
451                self.inner.latency()
452            }
453            fn tail(&self) -> u32 {
454                self.inner.tail()
455            }
456            fn get_meter(&self, meter_id: u32) -> f32 {
457                self.inner.get_meter(meter_id)
458            }
459        }
460
461        impl $crate::__macro_deps::truce_core::export::PluginExport for __HotShellWrapper {
462            type Params = $params;
463
464            fn create() -> Self {
465                let params = std::sync::Arc::new(<$params>::new());
466                // Each `tasks: [..]` type gets its own lane (queue + mode).
467                // The bundle collapses to `None` when no types were listed,
468                // so a plugin with no tasks runs with no pool.
469                #[allow(unused_mut)]
470                let mut __task_bundle =
471                    $crate::__macro_deps::truce_core::tasks::TaskSpawnerBundle::new();
472                $(
473                    $({
474                        let __task_run = {
475                            let params = std::sync::Arc::clone(&params);
476                            move |task| {
477                                <$task as $crate::__macro_deps::truce_plugin::BackgroundTask>::run(
478                                    task, &params,
479                                )
480                            }
481                        };
482                        // `SERIALIZED` picks one-slot vs concurrent draining
483                        // for this lane; the const folds the branch at
484                        // compile time.
485                        let __spawner = if <$task as $crate::__macro_deps::truce_plugin::BackgroundTask>::SERIALIZED {
486                            $crate::__macro_deps::truce_core::tasks::TaskSpawner::<$task>::new_serialized(__task_run)
487                        } else {
488                            $crate::__macro_deps::truce_core::tasks::TaskSpawner::<$task>::new(__task_run)
489                        };
490                        __task_bundle.push(__spawner);
491                    })+
492                )?
493                let tasks = __task_bundle.into_any();
494                // The descriptor `$logic` is stateless; `from_parts`
495                // builds the DSP state via `<$logic>::init(&params, &cx)`.
496                Self {
497                    inner: $crate::static_shell::StaticShell::from_parts(params, tasks),
498                }
499            }
500
501            fn params(&self) -> &$params {
502                &self.inner.params
503            }
504
505            fn params_arc(&self) -> std::sync::Arc<$params> {
506                std::sync::Arc::clone(&self.inner.params)
507            }
508
509            fn meter_store(
510                &self,
511            ) -> std::sync::Arc<$crate::__macro_deps::truce_core::meters::MeterStore> {
512                self.inner.meter_store()
513            }
514
515            fn snapshot_slot(
516                &self,
517            ) -> std::sync::Arc<$crate::__macro_deps::truce_core::snapshot::SnapshotSlot> {
518                self.inner.snapshot_slot()
519            }
520
521            fn task_spawner(
522                &self,
523            ) -> ::core::option::Option<
524                $crate::__macro_deps::truce_core::tasks::AnyTaskSpawner,
525            > {
526                self.inner.task_spawner()
527            }
528
529            fn editor_builder(
530                &self,
531            ) -> $crate::__macro_deps::truce_core::editor::EditorBuilder<$params> {
532                // Builds from the lock-free param store, never the
533                // embedded logic - the audio thread's `&mut logic` is
534                // irrelevant here, so opening the editor takes no lock.
535                Box::new(|params| {
536                    Some(
537                        <$logic as $crate::__macro_deps::truce_plugin::PluginEditor<Sample>>::editor(
538                            params,
539                        ),
540                    )
541                })
542            }
543        }
544    };
545}
546
547#[cfg(test)]
548mod tests {
549    use super::publish_snapshot_with;
550    use std::cell::Cell;
551    use truce_core::snapshot::SnapshotSlot;
552
553    #[test]
554    fn non_opt_in_latches_off_on_first_block() {
555        let slot = SnapshotSlot::new();
556        let mut try_snapshot = true;
557        let mut last = None;
558
559        // Default `snapshot_into` (returns false) before any publish:
560        // latch off so we stop paying every block.
561        publish_snapshot_with(&slot, &mut try_snapshot, &mut last, None, |_| false);
562        assert!(!try_snapshot, "first false must latch off");
563        assert!(!slot.is_supported());
564
565        // Subsequent blocks short-circuit and never call the closure.
566        let mut called = false;
567        publish_snapshot_with(&slot, &mut try_snapshot, &mut last, None, |_| {
568            called = true;
569            false
570        });
571        assert!(!called, "latched-off slot must not call snapshot_into");
572    }
573
574    #[test]
575    fn opt_in_then_contract_violation_stays_subscribed() {
576        let slot = SnapshotSlot::new();
577        let mut try_snapshot = true;
578        let mut last = None;
579
580        // Block 1: plugin publishes - it has opted in for its lifetime.
581        publish_snapshot_with(&slot, &mut try_snapshot, &mut last, None, |buf| {
582            buf.clear();
583            buf.extend_from_slice(&[1, 2, 3]);
584            true
585        });
586        assert!(try_snapshot);
587        assert!(slot.is_supported());
588        assert_eq!(slot.read(), Some(vec![1, 2, 3]));
589
590        // Block 2: a contract-violating false must NOT latch us off - we
591        // keep calling the plugin rather than silently going dark.
592        publish_snapshot_with(&slot, &mut try_snapshot, &mut last, None, |_| false);
593        assert!(try_snapshot, "a post-opt-in false must not latch off");
594
595        // Block 3: still subscribed, so a fresh publish still lands.
596        publish_snapshot_with(&slot, &mut try_snapshot, &mut last, None, |buf| {
597            buf.clear();
598            buf.extend_from_slice(&[4]);
599            true
600        });
601        assert_eq!(slot.read(), Some(vec![4]));
602    }
603
604    #[test]
605    fn unchanged_version_skips_the_copy() {
606        let slot = SnapshotSlot::new();
607        let mut try_snapshot = true;
608        let mut last = None;
609        let calls = Cell::new(0);
610        let publish = |ver, try_s: &mut bool, last: &mut Option<u64>| {
611            publish_snapshot_with(&slot, try_s, last, Some(ver), |buf| {
612                calls.set(calls.get() + 1);
613                buf.extend_from_slice(&[u8::try_from(ver).unwrap_or(0)]);
614                true
615            });
616        };
617
618        // Version 7 lands and is recorded.
619        publish(7, &mut try_snapshot, &mut last);
620        assert_eq!(calls.get(), 1);
621        assert_eq!(last, Some(7));
622        assert_eq!(slot.read(), Some(vec![7]));
623
624        // Same version twice more: the writer never runs again.
625        publish(7, &mut try_snapshot, &mut last);
626        publish(7, &mut try_snapshot, &mut last);
627        assert_eq!(calls.get(), 1, "unchanged version must skip the copy");
628
629        // A new version re-serializes.
630        publish(9, &mut try_snapshot, &mut last);
631        assert_eq!(calls.get(), 2);
632        assert_eq!(slot.read(), Some(vec![9]));
633    }
634
635    #[test]
636    fn unversioned_none_publishes_every_block() {
637        // The default (no `snapshot_version`) must keep re-serializing
638        // every block - the historical behavior, unchanged.
639        let slot = SnapshotSlot::new();
640        let mut try_snapshot = true;
641        let mut last = None;
642        let calls = Cell::new(0);
643        for _ in 0..3 {
644            publish_snapshot_with(&slot, &mut try_snapshot, &mut last, None, |buf| {
645                calls.set(calls.get() + 1);
646                buf.push(1);
647                true
648            });
649        }
650        assert_eq!(calls.get(), 3, "None version re-serializes every block");
651    }
652
653    #[test]
654    fn load_reset_forces_republish_at_unchanged_version() {
655        // A load clears `last_snapshot_version` in both shells' `load_state`,
656        // so the `republish_snapshot` the wrapper fires right after a load
657        // re-serializes even though the plugin's version token didn't change
658        // (a counter that round-trips through the blob, or an author who
659        // forgot to bump). Without the reset the gate stays closed and the
660        // slot keeps pre-load bytes - the host's next save reverts the load.
661        let slot = SnapshotSlot::new();
662        let mut try_snapshot = true;
663        let mut last = None;
664        let calls = Cell::new(0);
665        let publish = |ver, try_s: &mut bool, last: &mut Option<u64>| {
666            publish_snapshot_with(&slot, try_s, last, Some(ver), |buf| {
667                calls.set(calls.get() + 1);
668                buf.push(u8::try_from(ver).unwrap_or(0));
669                true
670            });
671        };
672
673        publish(5, &mut try_snapshot, &mut last);
674        publish(5, &mut try_snapshot, &mut last);
675        assert_eq!(calls.get(), 1, "unchanged version skips");
676
677        // `load_state` clears the gate.
678        last = None;
679        publish(5, &mut try_snapshot, &mut last);
680        assert_eq!(
681            calls.get(),
682            2,
683            "a republish after a load must re-serialize even at the same version"
684        );
685    }
686}