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(¶ms)`.
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(¶ms, &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(¶ms, &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(¶m_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(¶ms);
476 move |task| {
477 <$task as $crate::__macro_deps::truce_plugin::BackgroundTask>::run(
478 task, ¶ms,
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(¶ms, &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}