Skip to main content

elevator_core/
snapshot.rs

1//! World snapshot for save/load functionality.
2//!
3//! Provides [`WorldSnapshot`](crate::snapshot::WorldSnapshot) which captures the full simulation state
4//! (all entities, components, groups, metrics, tick counter) in a
5//! serializable form. Games choose the serialization format via serde.
6//!
7//! Extension component *data* is included in the snapshot. After restoring,
8//! call [`Simulation::load_extensions_with`](crate::sim::Simulation::load_extensions_with)
9//! to register types and materialize the data.
10
11use crate::components::{
12    AccessControl, CarCall, DestinationQueue, Elevator, HallCall, Line, LineKind, Patience,
13    Position, Preferences, Rider, Route, Stop, Velocity,
14};
15use crate::entity::EntityId;
16use crate::ids::GroupId;
17use crate::metrics::Metrics;
18use crate::stop::StopId;
19use crate::tagged_metrics::MetricTags;
20use serde::{Deserialize, Serialize};
21use std::collections::{BTreeMap, HashMap, HashSet};
22
23/// Serializable snapshot of a single entity's components.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct EntitySnapshot {
26    /// The original `EntityId` (used for remapping cross-references on restore).
27    pub original_id: EntityId,
28    /// Position component (if present).
29    pub position: Option<Position>,
30    /// Velocity component (if present).
31    pub velocity: Option<Velocity>,
32    /// Elevator component (if present).
33    pub elevator: Option<Elevator>,
34    /// Stop component (if present).
35    pub stop: Option<Stop>,
36    /// Rider component (if present).
37    pub rider: Option<Rider>,
38    /// Route component (if present).
39    pub route: Option<Route>,
40    /// Line component (if present).
41    #[serde(default)]
42    pub line: Option<Line>,
43    /// Patience component (if present).
44    pub patience: Option<Patience>,
45    /// Preferences component (if present).
46    pub preferences: Option<Preferences>,
47    /// Access control component (if present).
48    #[serde(default)]
49    pub access_control: Option<AccessControl>,
50    /// Whether this entity is disabled.
51    pub disabled: bool,
52    /// Energy profile (if present, requires `energy` feature).
53    #[cfg(feature = "energy")]
54    #[serde(default)]
55    pub energy_profile: Option<crate::energy::EnergyProfile>,
56    /// Energy metrics (if present, requires `energy` feature).
57    #[cfg(feature = "energy")]
58    #[serde(default)]
59    pub energy_metrics: Option<crate::energy::EnergyMetrics>,
60    /// Service mode (if present).
61    #[serde(default)]
62    pub service_mode: Option<crate::components::ServiceMode>,
63    /// Destination queue (per-elevator; absent in legacy snapshots).
64    #[serde(default)]
65    pub destination_queue: Option<DestinationQueue>,
66    /// Car calls pressed inside this elevator (per-car; absent in legacy snapshots).
67    #[serde(default)]
68    pub car_calls: Vec<CarCall>,
69}
70
71/// Serializable snapshot of the entire simulation state.
72///
73/// Capture via [`Simulation::snapshot()`](crate::sim::Simulation::snapshot)
74/// and restore via [`WorldSnapshot::restore()`]. The game chooses the serde format
75/// (RON, JSON, bincode, etc.).
76///
77/// **Determinism:** the map fields below all use `BTreeMap` instead of
78/// `HashMap` so postcard/RON/JSON serialize entries in a deterministic
79/// (key-sorted) order. With `HashMap`, two snapshots of the same sim
80/// taken in different processes produced different bytes, defeating
81/// content-addressed caching and bit-equality replay (#254).
82///
83/// **Extension components are included** (via `extensions`); games must
84/// register types via `register_ext` before `restore()` to materialize them.
85/// **Custom resources** inserted via `world.insert_resource` are NOT
86/// snapshotted — only the built-in `MetricTags` resource is captured
87/// in `metric_tags`. Games using custom resources must save and restore
88/// them out-of-band (#296).
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct WorldSnapshot {
91    /// Schema version of this snapshot. Bumped on incompatible changes
92    /// to the snapshot layout. Loading a snapshot whose version differs
93    /// from the current crate's expected version returns
94    /// [`SimError::SnapshotVersion`](crate::error::SimError::SnapshotVersion).
95    /// Legacy snapshots default to `0` (#295).
96    #[serde(default)]
97    pub version: u32,
98    /// Current simulation tick.
99    pub tick: u64,
100    /// Time delta per tick.
101    pub dt: f64,
102    /// All entities indexed by position in this vec.
103    /// `EntityId`s are regenerated on restore.
104    pub entities: Vec<EntitySnapshot>,
105    /// Elevator groups (references into entities by index).
106    pub groups: Vec<GroupSnapshot>,
107    /// Stop ID → entity index mapping. `BTreeMap` for deterministic
108    /// snapshot bytes across processes (#254).
109    pub stop_lookup: BTreeMap<StopId, usize>,
110    /// Global metrics at snapshot time.
111    pub metrics: Metrics,
112    /// Per-tag metric accumulators and entity-tag associations.
113    pub metric_tags: MetricTags,
114    /// Serialized extension component data: name → (`EntityId` → RON string).
115    /// Both maps are `BTreeMap` for deterministic snapshot bytes (#254).
116    pub extensions: BTreeMap<String, BTreeMap<EntityId, String>>,
117    /// Ticks per second (for `TimeAdapter` reconstruction).
118    pub ticks_per_second: f64,
119    /// All pending hall calls across every stop. Absent in legacy snapshots.
120    #[serde(default)]
121    pub hall_calls: Vec<HallCall>,
122    /// Rolling per-stop arrival log. Empty in legacy snapshots; on
123    /// restore the log's `(tick, stop)` entries have their stop IDs
124    /// remapped through `id_remap` so they line up with the newly
125    /// allocated entity IDs.
126    #[serde(default)]
127    pub arrival_log: crate::arrival_log::ArrivalLog,
128    /// Retention window for the arrival log (ticks). Captured so a
129    /// host-configured value (e.g. via
130    /// `Simulation::set_arrival_log_retention_ticks`) survives
131    /// snapshot round-trip; legacy snapshots default to
132    /// [`DEFAULT_ARRIVAL_WINDOW_TICKS`](crate::arrival_log::DEFAULT_ARRIVAL_WINDOW_TICKS).
133    #[serde(default)]
134    pub arrival_log_retention: crate::arrival_log::ArrivalLogRetention,
135    /// Mirror of `arrival_log` keyed on rider *destination* — what
136    /// powers the `DownPeak` classifier branch. Same remap semantics
137    /// as `arrival_log` on restore. Empty in legacy snapshots; the
138    /// detector silently under-classifies `DownPeak` until the post-
139    /// restore log refills.
140    #[serde(default)]
141    pub destination_log: crate::arrival_log::DestinationLog,
142    /// Traffic-mode classifier state. Carries the current mode,
143    /// thresholds, and last-update tick across snapshot round-trip
144    /// so a restored sim doesn't momentarily reset to `Idle` when
145    /// the metrics phase hasn't run yet.
146    #[serde(default)]
147    pub traffic_detector: crate::traffic_detector::TrafficDetector,
148    /// Per-car reposition cooldown eligibility. Entries map to the
149    /// tick when each car next becomes eligible for reposition. Empty
150    /// in legacy snapshots; on restore the map is remapped through
151    /// `id_remap` to match newly-allocated entity IDs.
152    #[serde(default)]
153    pub reposition_cooldowns: crate::dispatch::reposition::RepositionCooldowns,
154    /// Per-group serialized dispatcher configuration produced by
155    /// [`crate::dispatch::DispatchStrategy::snapshot_config`] and
156    /// replayed via
157    /// [`crate::dispatch::DispatchStrategy::restore_config`] on
158    /// restore. Round-trips the tunable weights configured via
159    /// `with_*` builder methods (e.g. `EtdDispatch::with_delay_weight`)
160    /// that [`BuiltinStrategy::instantiate`](crate::dispatch::BuiltinStrategy::instantiate)
161    /// can't reconstruct because it always calls `::new()`. Absent /
162    /// empty in legacy snapshots — they restore to default weights,
163    /// matching pre-fix behaviour.
164    #[serde(default)]
165    pub dispatch_config: BTreeMap<GroupId, String>,
166}
167
168/// Per-line snapshot info within a group.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct LineSnapshotInfo {
171    /// Index into the `entities` vec for the line entity.
172    pub entity_index: usize,
173    /// Indices into the `entities` vec for elevators on this line.
174    pub elevator_indices: Vec<usize>,
175    /// Indices into the `entities` vec for stops served by this line.
176    pub stop_indices: Vec<usize>,
177}
178
179/// Serializable representation of an elevator group.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct GroupSnapshot {
182    /// Group identifier.
183    pub id: GroupId,
184    /// Group name.
185    pub name: String,
186    /// Indices into the `entities` vec for elevators in this group.
187    pub elevator_indices: Vec<usize>,
188    /// Indices into the `entities` vec for stops in this group.
189    pub stop_indices: Vec<usize>,
190    /// The dispatch strategy used by this group.
191    pub strategy: crate::dispatch::BuiltinStrategy,
192    /// Per-line snapshot data. Empty in legacy snapshots.
193    #[serde(default)]
194    pub lines: Vec<LineSnapshotInfo>,
195    /// Optional repositioning strategy for idle elevators.
196    #[serde(default)]
197    pub reposition: Option<crate::dispatch::BuiltinReposition>,
198    /// Hall call mode for this group. Legacy snapshots default to `Classic`.
199    #[serde(default)]
200    pub hall_call_mode: crate::dispatch::HallCallMode,
201    /// Controller ack latency in ticks. Legacy snapshots default to `0`.
202    #[serde(default)]
203    pub ack_latency_ticks: u32,
204}
205
206/// Pending extension data from a snapshot, awaiting type registration.
207///
208/// Stored as a world resource after `restore()`. Call
209/// `sim.load_extensions()` after registering extension types to
210/// deserialize the data.
211pub(crate) struct PendingExtensions(pub(crate) BTreeMap<String, BTreeMap<EntityId, String>>);
212
213/// Factory function type for instantiating custom dispatch strategies by name.
214///
215/// Wired into [`RestoreOptions::custom_strategy_factory`] and used
216/// internally by [`WorldSnapshot::restore`] /
217/// [`Simulation::restore_bytes`](crate::sim::Simulation::restore_bytes)
218/// to look up dispatch strategies that aren't built-ins. Callers that
219/// need to hold or pass the factory by value can name this alias
220/// directly instead of repeating the trait-object spelling.
221pub type CustomStrategyFactory<'a> =
222    Option<&'a dyn Fn(&str) -> Option<Box<dyn crate::dispatch::DispatchStrategy>>>;
223
224/// Options for [`WorldSnapshot::restore`] and
225/// [`Simulation::restore_bytes`](crate::sim::Simulation::restore_bytes).
226///
227/// Construct via [`Default::default`] when the snapshot only uses
228/// built-in strategies, or via [`with_factory`](Self::with_factory)
229/// when groups in the snapshot reference [`Custom`] dispatch strategies
230/// that need to be re-instantiated by name. The struct is
231/// `#[non_exhaustive]` so future restore knobs can land without an API
232/// break.
233///
234/// [`Custom`]: crate::dispatch::BuiltinStrategy::Custom
235///
236/// ```no_run
237/// # use elevator_core::snapshot::{RestoreOptions, WorldSnapshot};
238/// # fn doit(snap: WorldSnapshot) -> Result<(), elevator_core::error::SimError> {
239/// // Built-ins only:
240/// let _sim = snap.restore(RestoreOptions::default())?;
241/// # Ok(()) }
242/// ```
243#[derive(Clone, Copy, Default)]
244#[non_exhaustive]
245pub struct RestoreOptions<'a> {
246    /// Factory mapping [`Custom`](crate::dispatch::BuiltinStrategy::Custom)
247    /// strategy names to fresh trait-object instances. `None` when the
248    /// snapshot only uses built-in strategies — restore returns
249    /// [`SimError::UnresolvedCustomStrategy`](crate::error::SimError::UnresolvedCustomStrategy)
250    /// if a snapshot group needs a custom strategy and the factory isn't
251    /// supplied (or returns `None`).
252    pub custom_strategy_factory: CustomStrategyFactory<'a>,
253}
254
255impl<'a> RestoreOptions<'a> {
256    /// Build a [`RestoreOptions`] with a custom-strategy factory wired
257    /// up. Use [`Default::default`] when no custom strategies are in
258    /// play.
259    #[must_use]
260    pub fn with_factory(
261        factory: &'a dyn Fn(&str) -> Option<Box<dyn crate::dispatch::DispatchStrategy>>,
262    ) -> Self {
263        Self {
264            custom_strategy_factory: Some(factory),
265        }
266    }
267}
268
269impl WorldSnapshot {
270    /// Schema version this build of `elevator-core` writes and accepts.
271    ///
272    /// Compare against [`WorldSnapshot::version`] to decide whether a
273    /// snapshot needs forward migration before [`WorldSnapshot::restore`]
274    /// will accept it.
275    pub const CURRENT_SCHEMA_VERSION: u32 = SNAPSHOT_SCHEMA_VERSION;
276
277    /// Forward-migrate a snapshot to [`CURRENT_SCHEMA_VERSION`](Self::CURRENT_SCHEMA_VERSION).
278    ///
279    /// `restore` rejects mismatched schema versions hard so silent
280    /// `#[serde(default)]` field-filling can't mask a mismatch. Callers
281    /// that want to load older snapshots route them through this method
282    /// first:
283    ///
284    /// ```no_run
285    /// # use elevator_core::snapshot::{RestoreOptions, WorldSnapshot};
286    /// # fn load(snap: WorldSnapshot) -> Result<(), elevator_core::error::SimError> {
287    /// let sim = snap.migrate()?.restore(RestoreOptions::default())?;
288    /// # let _ = sim;
289    /// # Ok(()) }
290    /// ```
291    ///
292    /// Future schema bumps wire their step-up migrations into the match
293    /// arm below; each step consumes a snapshot at version `n` and
294    /// produces one at `n + 1`. The outer `while` drives the chain to
295    /// [`CURRENT_SCHEMA_VERSION`](Self::CURRENT_SCHEMA_VERSION) with
296    /// O(1) stack depth regardless of how far behind the input is.
297    ///
298    /// # Errors
299    ///
300    /// Returns [`SimError::SnapshotVersion`](crate::error::SimError::SnapshotVersion)
301    /// for snapshots from a version this build doesn't know how to
302    /// migrate (either `version > CURRENT_SCHEMA_VERSION`, or
303    /// `version < CURRENT_SCHEMA_VERSION` with no migration path
304    /// registered).
305    pub fn migrate(self) -> Result<Self, crate::error::SimError> {
306        // Snapshots from a future build can't be down-migrated — the
307        // newer build defines what the new shape *is*.
308        if self.version > Self::CURRENT_SCHEMA_VERSION {
309            return Err(crate::error::SimError::SnapshotVersion {
310                saved: format!("schema {}", self.version),
311                current: format!("schema {}", Self::CURRENT_SCHEMA_VERSION),
312            });
313        }
314        if self.version == Self::CURRENT_SCHEMA_VERSION {
315            return Ok(self);
316        }
317        // Schema is at v1 today, so no step-up arms exist; any
318        // version below current with no migration path errors out.
319        // The intended shape once schema bumps land is an iterative
320        // step_up loop with O(1) stack depth regardless of how many
321        // schema versions an archived snapshot has to traverse:
322        //
323        //     let mut snap = self;
324        //     while snap.version < Self::CURRENT_SCHEMA_VERSION {
325        //         snap = step_up_one(snap)?;
326        //     }
327        //     Ok(snap)
328        //
329        // where `step_up_one` matches on `snap.version` and emits one
330        // arm per `n -> n + 1` upgrade.
331        Err(crate::error::SimError::SnapshotVersion {
332            saved: format!("schema {}", self.version),
333            current: format!("schema {}", Self::CURRENT_SCHEMA_VERSION),
334        })
335    }
336
337    /// Restore a simulation from this snapshot.
338    ///
339    /// Built-in strategies (Scan, Look, `NearestCar`, ETD, RSR,
340    /// Destination) are auto-restored. For [`Custom`] strategies,
341    /// configure [`RestoreOptions`] with a factory; pass
342    /// [`RestoreOptions::default()`] when only built-in strategies are
343    /// in play. The struct is `#[non_exhaustive]` so future restore
344    /// knobs land without an API break.
345    ///
346    /// [`Custom`]: crate::dispatch::BuiltinStrategy::Custom
347    ///
348    /// # Errors
349    /// Returns [`SimError::UnresolvedCustomStrategy`](crate::error::SimError::UnresolvedCustomStrategy)
350    /// if a snapshot group uses a `Custom` strategy and the factory
351    /// returns `None` (or no factory was supplied).
352    ///
353    /// To restore extension components, call
354    /// [`Simulation::load_extensions_with`](crate::sim::Simulation::load_extensions_with)
355    /// on the returned simulation.
356    pub fn restore(
357        self,
358        options: RestoreOptions<'_>,
359    ) -> Result<crate::sim::Simulation, crate::error::SimError> {
360        use crate::world::{SortedStops, World};
361
362        let RestoreOptions {
363            custom_strategy_factory,
364            ..
365        } = options;
366
367        // Reject snapshots from incompatible schema versions. Without this
368        // guard, `#[serde(default)]` on newly-added fields would silently
369        // fill them in and mask the mismatch. The bytes envelope path
370        // separately checks the crate semver string.
371        if self.version != SNAPSHOT_SCHEMA_VERSION {
372            return Err(crate::error::SimError::SnapshotVersion {
373                saved: format!("schema {}", self.version),
374                current: format!("schema {SNAPSHOT_SCHEMA_VERSION}"),
375            });
376        }
377
378        let mut world = World::new();
379
380        // Phase 1: spawn all entities and build old→new EntityId mapping.
381        let (index_to_id, id_remap) = Self::spawn_entities(&mut world, &self.entities);
382
383        // Phase 2: attach components with remapped EntityIds.
384        Self::attach_components(&mut world, &self.entities, &index_to_id, &id_remap);
385
386        // Phase 2b: re-register hall calls (cross-reference stops/cars/riders).
387        self.attach_hall_calls(&mut world, &id_remap);
388
389        // Rebuild sorted stops index.
390        let mut sorted: Vec<(f64, EntityId)> = world
391            .iter_stops()
392            .map(|(eid, stop)| (stop.position, eid))
393            .collect();
394        sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
395        world.insert_resource(SortedStops(sorted));
396
397        // Rebuild groups, stop lookup, dispatchers, and extensions (borrows self).
398        let (mut groups, stop_lookup, dispatchers, strategy_ids) =
399            self.rebuild_groups_and_dispatchers(&index_to_id, custom_strategy_factory)?;
400
401        Self::fix_legacy_line_entities(&mut groups, &mut world);
402
403        // Resource installation moves several fields out of self via partial
404        // moves; remaining fields (dispatch_config, groups, entities, hall_calls,
405        // metrics, etc.) stay accessible for the construction steps below.
406        Self::install_runtime_resources(
407            &mut world,
408            &id_remap,
409            self.tick,
410            &self.extensions,
411            self.metric_tags,
412            self.arrival_log,
413            self.arrival_log_retention,
414            self.destination_log,
415            self.traffic_detector,
416            self.reposition_cooldowns,
417        );
418
419        let mut sim = crate::sim::Simulation::from_parts(
420            world,
421            self.tick,
422            self.dt,
423            groups,
424            stop_lookup,
425            dispatchers,
426            strategy_ids,
427            self.metrics,
428            self.ticks_per_second,
429        );
430
431        Self::replay_dispatcher_tuning(&mut sim, &self.dispatch_config);
432        Self::restore_reposition_strategies(&mut sim, &self.groups);
433
434        Self::emit_dangling_warnings(
435            &self.entities,
436            &self.hall_calls,
437            &id_remap,
438            self.tick,
439            &mut sim,
440        );
441
442        Ok(sim)
443    }
444
445    /// Replace synthetic legacy `LineInfo` entries (entity = `EntityId::default()`)
446    /// with real line entities spawned in the world. Pre-line snapshots stored
447    /// only group-level elevator/stop indices; the legacy single-line `LineInfo`
448    /// is materialised here so dispatch and rendering see a real entity.
449    fn fix_legacy_line_entities(
450        groups: &mut [crate::dispatch::ElevatorGroup],
451        world: &mut crate::world::World,
452    ) {
453        for group in groups.iter_mut() {
454            let group_id = group.id();
455            for line_info in group.lines_mut().iter_mut() {
456                if line_info.entity() != EntityId::default() {
457                    continue;
458                }
459                let (min_pos, max_pos) = line_info
460                    .serves()
461                    .iter()
462                    .filter_map(|&sid| world.stop(sid).map(|s| s.position))
463                    .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| {
464                        (lo.min(p), hi.max(p))
465                    });
466                let line_eid = world.spawn();
467                world.set_line(
468                    line_eid,
469                    Line {
470                        name: format!("Legacy-{group_id}"),
471                        group: group_id,
472                        orientation: crate::components::Orientation::Vertical,
473                        position: None,
474                        kind: LineKind::Linear {
475                            min: if min_pos.is_finite() { min_pos } else { 0.0 },
476                            max: if max_pos.is_finite() { max_pos } else { 0.0 },
477                        },
478                        max_cars: None,
479                    },
480                );
481                for &elev_eid in line_info.elevators() {
482                    if let Some(car) = world.elevator_mut(elev_eid) {
483                        car.line = line_eid;
484                    }
485                }
486                line_info.set_entity(line_eid);
487            }
488        }
489    }
490
491    /// Insert all post-entity world resources, remapping `EntityId`s where
492    /// they cross-reference. Without these `PredictiveParking`,
493    /// `DispatchManifest::arrivals_at`, the down-peak classifier branch,
494    /// host-configured retention, and reposition cooldowns silently no-op
495    /// or fall back to defaults post-restore.
496    #[allow(clippy::too_many_arguments)]
497    fn install_runtime_resources(
498        world: &mut crate::world::World,
499        id_remap: &HashMap<EntityId, EntityId>,
500        tick: u64,
501        extensions: &BTreeMap<String, BTreeMap<EntityId, String>>,
502        metric_tags: MetricTags,
503        arrival_log: crate::arrival_log::ArrivalLog,
504        arrival_log_retention: crate::arrival_log::ArrivalLogRetention,
505        destination_log: crate::arrival_log::DestinationLog,
506        traffic_detector: crate::traffic_detector::TrafficDetector,
507        reposition_cooldowns: crate::dispatch::reposition::RepositionCooldowns,
508    ) {
509        let remapped_exts = Self::remap_extensions(extensions, id_remap);
510        world.insert_resource(PendingExtensions(remapped_exts));
511
512        let mut tags = metric_tags;
513        tags.remap_entity_ids(id_remap);
514        world.insert_resource(tags);
515
516        let mut log = arrival_log;
517        log.remap_entity_ids(id_remap);
518        world.insert_resource(log);
519        world.insert_resource(crate::arrival_log::CurrentTick(tick));
520        world.insert_resource(arrival_log_retention);
521
522        let mut dest_log = destination_log;
523        dest_log.remap_entity_ids(id_remap);
524        world.insert_resource(dest_log);
525
526        // Detector is re-inserted last-writer-wins so the *classified* state
527        // carries forward; refresh_traffic_detector updates on the next
528        // metrics phase with fresh counts.
529        world.insert_resource(traffic_detector);
530
531        let mut cooldowns = reposition_cooldowns;
532        cooldowns.remap_entity_ids(id_remap);
533        world.insert_resource(cooldowns);
534    }
535
536    /// Replay per-group dispatcher tuning captured in the snapshot. Built-ins
537    /// with `with_*` builders override `snapshot_config`/`restore_config` to
538    /// round-trip their weights; strategies that don't override silently skip
539    /// (default `restore_config` is `Ok(())`), preserving pre-fix behaviour. A
540    /// deserialization failure surfaces as an event rather than a hard error
541    /// — the restored sim runs with defaults, same as a legacy snapshot.
542    fn replay_dispatcher_tuning(
543        sim: &mut crate::sim::Simulation,
544        dispatch_config: &std::collections::BTreeMap<GroupId, String>,
545    ) {
546        for (gid, serialized) in dispatch_config {
547            if let Some(dispatcher) = sim.dispatchers_mut().get_mut(gid)
548                && let Err(err) = dispatcher.restore_config(serialized)
549            {
550                sim.push_event(crate::events::Event::DispatchConfigNotRestored {
551                    group: *gid,
552                    reason: err,
553                });
554            }
555        }
556    }
557
558    /// Restore reposition strategies from group snapshots, emitting
559    /// [`Event::RepositionStrategyNotRestored`](crate::events::Event::RepositionStrategyNotRestored)
560    /// when an id can't be re-instantiated.
561    fn restore_reposition_strategies(sim: &mut crate::sim::Simulation, groups: &[GroupSnapshot]) {
562        for gs in groups {
563            let Some(ref repo_id) = gs.reposition else {
564                continue;
565            };
566            if let Some(strategy) = repo_id.instantiate() {
567                sim.set_reposition(gs.id, strategy, repo_id.clone());
568            } else {
569                sim.push_event(crate::events::Event::RepositionStrategyNotRestored {
570                    group: gs.id,
571                });
572            }
573        }
574    }
575
576    /// Spawn entities in the world and build the old→new `EntityId` mapping.
577    fn spawn_entities(
578        world: &mut crate::world::World,
579        entities: &[EntitySnapshot],
580    ) -> (Vec<EntityId>, HashMap<EntityId, EntityId>) {
581        let mut index_to_id: Vec<EntityId> = Vec::with_capacity(entities.len());
582        let mut id_remap: HashMap<EntityId, EntityId> = HashMap::new();
583        for snap in entities {
584            let new_id = world.spawn();
585            index_to_id.push(new_id);
586            id_remap.insert(snap.original_id, new_id);
587        }
588        (index_to_id, id_remap)
589    }
590
591    /// Attach components to spawned entities, remapping cross-references.
592    fn attach_components(
593        world: &mut crate::world::World,
594        entities: &[EntitySnapshot],
595        index_to_id: &[EntityId],
596        id_remap: &HashMap<EntityId, EntityId>,
597    ) {
598        let remap = |old: EntityId| -> EntityId { id_remap.get(&old).copied().unwrap_or(old) };
599        let remap_opt = |old: Option<EntityId>| -> Option<EntityId> { old.map(&remap) };
600
601        for (i, snap) in entities.iter().enumerate() {
602            let eid = index_to_id[i];
603
604            if let Some(pos) = snap.position {
605                world.set_position(eid, pos);
606            }
607            if let Some(vel) = snap.velocity {
608                world.set_velocity(eid, vel);
609            }
610            if let Some(ref elev) = snap.elevator {
611                let mut e = elev.clone();
612                e.riders = e.riders.iter().map(|&r| remap(r)).collect();
613                e.target_stop = remap_opt(e.target_stop);
614                e.line = remap(e.line);
615                e.restricted_stops = e.restricted_stops.iter().map(|&s| remap(s)).collect();
616                e.phase = match e.phase {
617                    crate::components::ElevatorPhase::MovingToStop(s) => {
618                        crate::components::ElevatorPhase::MovingToStop(remap(s))
619                    }
620                    crate::components::ElevatorPhase::Repositioning(s) => {
621                        crate::components::ElevatorPhase::Repositioning(remap(s))
622                    }
623                    other => other,
624                };
625                world.set_elevator(eid, e);
626            }
627            if let Some(ref stop) = snap.stop {
628                world.set_stop(eid, stop.clone());
629            }
630            if let Some(ref rider) = snap.rider {
631                use crate::components::RiderPhase;
632                let mut r = rider.clone();
633                r.current_stop = remap_opt(r.current_stop);
634                r.phase = match r.phase {
635                    RiderPhase::Boarding(e) => RiderPhase::Boarding(remap(e)),
636                    RiderPhase::Riding(e) => RiderPhase::Riding(remap(e)),
637                    RiderPhase::Exiting(e) => RiderPhase::Exiting(remap(e)),
638                    other => other,
639                };
640                world.set_rider(eid, r);
641            }
642            if let Some(ref route) = snap.route {
643                let mut rt = route.clone();
644                for leg in &mut rt.legs {
645                    leg.from = remap(leg.from);
646                    leg.to = remap(leg.to);
647                    if let crate::components::TransportMode::Line(ref mut l) = leg.via {
648                        *l = remap(*l);
649                    }
650                }
651                world.set_route(eid, rt);
652            }
653            if let Some(ref line) = snap.line {
654                world.set_line(eid, line.clone());
655            }
656            if let Some(patience) = snap.patience {
657                world.set_patience(eid, patience);
658            }
659            if let Some(prefs) = snap.preferences {
660                world.set_preferences(eid, prefs);
661            }
662            if let Some(ref ac) = snap.access_control {
663                let remapped =
664                    AccessControl::new(ac.allowed_stops().iter().map(|&s| remap(s)).collect());
665                world.set_access_control(eid, remapped);
666            }
667            if snap.disabled {
668                world.disable(eid);
669            }
670            #[cfg(feature = "energy")]
671            if let Some(ref profile) = snap.energy_profile {
672                world.set_energy_profile(eid, profile.clone());
673            }
674            #[cfg(feature = "energy")]
675            if let Some(ref em) = snap.energy_metrics {
676                world.set_energy_metrics(eid, em.clone());
677            }
678            if let Some(mode) = snap.service_mode {
679                world.set_service_mode(eid, mode);
680            }
681            if let Some(ref dq) = snap.destination_queue {
682                use crate::components::DestinationQueue as DQ;
683                let mut new_dq = DQ::new();
684                for &e in dq.queue() {
685                    new_dq.push_back(remap(e));
686                }
687                world.set_destination_queue(eid, new_dq);
688            }
689            Self::attach_car_calls(world, eid, &snap.car_calls, id_remap);
690        }
691    }
692
693    /// Re-register per-car floor button presses after entities are spawned.
694    fn attach_car_calls(
695        world: &mut crate::world::World,
696        car: EntityId,
697        car_calls: &[CarCall],
698        id_remap: &HashMap<EntityId, EntityId>,
699    ) {
700        if car_calls.is_empty() {
701            return;
702        }
703        let remap = |old: EntityId| -> EntityId { id_remap.get(&old).copied().unwrap_or(old) };
704        let Some(slot) = world.car_calls_mut(car) else {
705            return;
706        };
707        for cc in car_calls {
708            let mut c = cc.clone();
709            c.car = car;
710            c.floor = remap(c.floor);
711            c.pending_riders = c.pending_riders.iter().map(|&r| remap(r)).collect();
712            slot.push(c);
713        }
714    }
715
716    /// Re-register hall calls in the world after entities are spawned.
717    ///
718    /// `HallCall` cross-references stops, cars, riders, and optional
719    /// destinations — all `EntityId`s must be remapped through `id_remap`.
720    /// Pre-15.23 snapshots stored a single `assigned_car` field, silently
721    /// dropped by `#[serde(default)]` on `assigned_cars_by_line`; the
722    /// next dispatch pass repopulates the empty map, so no explicit
723    /// migration is attempted here.
724    fn attach_hall_calls(
725        &self,
726        world: &mut crate::world::World,
727        id_remap: &HashMap<EntityId, EntityId>,
728    ) {
729        let remap = |old: EntityId| -> EntityId { id_remap.get(&old).copied().unwrap_or(old) };
730        let remap_opt = |old: Option<EntityId>| -> Option<EntityId> { old.map(&remap) };
731        for hc in &self.hall_calls {
732            let mut c = hc.clone();
733            c.stop = remap(c.stop);
734            c.destination = remap_opt(c.destination);
735            c.assigned_cars_by_line = c
736                .assigned_cars_by_line
737                .iter()
738                .map(|(&line, &car)| (remap(line), remap(car)))
739                .collect();
740            c.pending_riders = c.pending_riders.iter().map(|&r| remap(r)).collect();
741            world.set_hall_call(c);
742        }
743    }
744
745    /// Rebuild groups, stop lookup, and dispatchers from snapshot data.
746    #[allow(clippy::type_complexity)]
747    fn rebuild_groups_and_dispatchers(
748        &self,
749        index_to_id: &[EntityId],
750        custom_strategy_factory: CustomStrategyFactory<'_>,
751    ) -> Result<
752        (
753            Vec<crate::dispatch::ElevatorGroup>,
754            HashMap<StopId, EntityId>,
755            std::collections::BTreeMap<GroupId, Box<dyn crate::dispatch::DispatchStrategy>>,
756            std::collections::BTreeMap<GroupId, crate::dispatch::BuiltinStrategy>,
757        ),
758        crate::error::SimError,
759    > {
760        use crate::dispatch::ElevatorGroup;
761
762        let groups: Vec<ElevatorGroup> = self
763            .groups
764            .iter()
765            .map(|gs| {
766                let elevator_entities: Vec<EntityId> = gs
767                    .elevator_indices
768                    .iter()
769                    .filter_map(|&i| index_to_id.get(i).copied())
770                    .collect();
771                let stop_entities: Vec<EntityId> = gs
772                    .stop_indices
773                    .iter()
774                    .filter_map(|&i| index_to_id.get(i).copied())
775                    .collect();
776
777                let lines = if gs.lines.is_empty() {
778                    // Legacy snapshots have no per-line data; create a single
779                    // synthetic LineInfo containing all elevators and stops.
780                    vec![crate::dispatch::LineInfo::new(
781                        EntityId::default(),
782                        elevator_entities,
783                        stop_entities,
784                    )]
785                } else {
786                    gs.lines
787                        .iter()
788                        .filter_map(|lsi| {
789                            let entity = index_to_id.get(lsi.entity_index).copied()?;
790                            Some(crate::dispatch::LineInfo::new(
791                                entity,
792                                lsi.elevator_indices
793                                    .iter()
794                                    .filter_map(|&i| index_to_id.get(i).copied())
795                                    .collect(),
796                                lsi.stop_indices
797                                    .iter()
798                                    .filter_map(|&i| index_to_id.get(i).copied())
799                                    .collect(),
800                            ))
801                        })
802                        .collect()
803                };
804
805                ElevatorGroup::new(gs.id, gs.name.clone(), lines)
806                    .with_hall_call_mode(gs.hall_call_mode)
807                    .with_ack_latency_ticks(gs.ack_latency_ticks)
808            })
809            .collect();
810
811        let stop_lookup: HashMap<StopId, EntityId> = self
812            .stop_lookup
813            .iter()
814            .filter_map(|(sid, &idx)| index_to_id.get(idx).map(|&eid| (*sid, eid)))
815            .collect();
816
817        let mut dispatchers = std::collections::BTreeMap::new();
818        let mut strategy_ids = std::collections::BTreeMap::new();
819        for (gs, group) in self.groups.iter().zip(groups.iter()) {
820            let strategy: Box<dyn crate::dispatch::DispatchStrategy> =
821                if let Some(builtin) = gs.strategy.instantiate() {
822                    builtin
823                } else if let crate::dispatch::BuiltinStrategy::Custom(ref name) = gs.strategy {
824                    custom_strategy_factory
825                        .and_then(|f| f(name))
826                        .ok_or_else(|| crate::error::SimError::UnresolvedCustomStrategy {
827                            name: name.clone(),
828                            group: group.id(),
829                        })?
830                } else {
831                    Box::new(crate::dispatch::scan::ScanDispatch::new())
832                };
833            dispatchers.insert(group.id(), strategy);
834            strategy_ids.insert(group.id(), gs.strategy.clone());
835        }
836
837        Ok((groups, stop_lookup, dispatchers, strategy_ids))
838    }
839
840    /// Remap `EntityId`s in extension data using the old→new mapping.
841    fn remap_extensions(
842        extensions: &BTreeMap<String, BTreeMap<EntityId, String>>,
843        id_remap: &HashMap<EntityId, EntityId>,
844    ) -> BTreeMap<String, BTreeMap<EntityId, String>> {
845        extensions
846            .iter()
847            .map(|(name, entries)| {
848                let remapped: BTreeMap<EntityId, String> = entries
849                    .iter()
850                    .map(|(old_id, data)| {
851                        let new_id = id_remap.get(old_id).copied().unwrap_or(*old_id);
852                        (new_id, data.clone())
853                    })
854                    .collect();
855                (name.clone(), remapped)
856            })
857            .collect()
858    }
859
860    /// Emit `SnapshotDanglingReference` events for entity IDs not in `id_remap`.
861    fn emit_dangling_warnings(
862        entities: &[EntitySnapshot],
863        hall_calls: &[HallCall],
864        id_remap: &HashMap<EntityId, EntityId>,
865        tick: u64,
866        sim: &mut crate::sim::Simulation,
867    ) {
868        let mut seen = HashSet::new();
869        let mut check = |old: EntityId| {
870            if !id_remap.contains_key(&old) && seen.insert(old) {
871                sim.push_event(crate::events::Event::SnapshotDanglingReference {
872                    stale_id: old,
873                    tick,
874                });
875            }
876        };
877        for snap in entities {
878            Self::collect_referenced_ids(snap, &mut check);
879        }
880        for hc in hall_calls {
881            check(hc.stop);
882            for (&line, &car) in &hc.assigned_cars_by_line {
883                check(line);
884                check(car);
885            }
886            if let Some(dest) = hc.destination {
887                check(dest);
888            }
889            for &rider in &hc.pending_riders {
890                check(rider);
891            }
892        }
893    }
894
895    /// Visit all cross-referenced `EntityId`s inside an entity snapshot.
896    fn collect_referenced_ids(snap: &EntitySnapshot, mut visit: impl FnMut(EntityId)) {
897        if let Some(ref elev) = snap.elevator {
898            for &r in &elev.riders {
899                visit(r);
900            }
901            if let Some(t) = elev.target_stop {
902                visit(t);
903            }
904            visit(elev.line);
905            match elev.phase {
906                crate::components::ElevatorPhase::MovingToStop(s)
907                | crate::components::ElevatorPhase::Repositioning(s) => visit(s),
908                _ => {}
909            }
910            for &s in &elev.restricted_stops {
911                visit(s);
912            }
913        }
914        if let Some(ref rider) = snap.rider {
915            if let Some(s) = rider.current_stop {
916                visit(s);
917            }
918            match rider.phase {
919                crate::components::RiderPhase::Boarding(e)
920                | crate::components::RiderPhase::Riding(e)
921                | crate::components::RiderPhase::Exiting(e) => visit(e),
922                _ => {}
923            }
924        }
925        if let Some(ref route) = snap.route {
926            for leg in &route.legs {
927                visit(leg.from);
928                visit(leg.to);
929                if let crate::components::TransportMode::Line(l) = leg.via {
930                    visit(l);
931                }
932            }
933        }
934        if let Some(ref ac) = snap.access_control {
935            for &s in ac.allowed_stops() {
936                visit(s);
937            }
938        }
939        if let Some(ref dq) = snap.destination_queue {
940            for &e in dq.queue() {
941                visit(e);
942            }
943        }
944        for cc in &snap.car_calls {
945            visit(cc.floor);
946            for &r in &cc.pending_riders {
947                visit(r);
948            }
949        }
950    }
951}
952
953/// Magic bytes identifying a postcard snapshot blob.
954const SNAPSHOT_MAGIC: [u8; 8] = *b"ELEVSNAP";
955
956/// Schema version for [`WorldSnapshot`]. Bump on incompatible layout
957/// changes so RON/JSON restore can reject older snapshots loudly
958/// instead of silently filling new fields with `#[serde(default)]`.
959///
960/// See `docs/src/snapshot-versioning.md` for the full bump-trigger
961/// policy, the asymmetry between this `u32` and the crate-version
962/// string in the bytes envelope, and the migration path.
963const SNAPSHOT_SCHEMA_VERSION: u32 = 1;
964
965/// Byte-level snapshot envelope: magic + crate version + payload.
966///
967/// Serialized via postcard. The magic and version fields are checked on
968/// restore to reject blobs from other tools or from a different
969/// `elevator-core` version.
970#[derive(Debug, Serialize, Deserialize)]
971struct SnapshotEnvelope {
972    /// Magic bytes; must equal [`SNAPSHOT_MAGIC`] or the blob is rejected.
973    magic: [u8; 8],
974    /// `elevator-core` crate version that produced the blob.
975    version: String,
976    /// The captured simulation state.
977    payload: WorldSnapshot,
978}
979
980impl crate::sim::Simulation {
981    /// Create a serializable snapshot of the current simulation state.
982    ///
983    /// The snapshot captures all entities, components, groups, metrics,
984    /// the tick counter, and extension component data (game must
985    /// re-register types via `register_ext` before `restore`).
986    /// Custom resources inserted via `world.insert_resource` are NOT
987    /// captured — games using them must save/restore separately (#296).
988    ///
989    /// **Mid-tick safety:** `snapshot()` returns a snapshot regardless
990    /// of whether you are mid-tick (between phase calls in the substep
991    /// API). For substep callers that care about event-bus state, use
992    /// [`try_snapshot`](Self::try_snapshot) which returns
993    /// [`SimError::MidTickSnapshot`](crate::error::SimError::MidTickSnapshot)
994    /// when invoked between `run_*` and `advance_tick`. (#297)
995    #[must_use]
996    #[allow(clippy::too_many_lines)]
997    pub fn snapshot(&self) -> WorldSnapshot {
998        self.snapshot_inner()
999    }
1000
1001    /// Like [`snapshot`](Self::snapshot) but returns
1002    /// [`SimError::MidTickSnapshot`](crate::error::SimError::MidTickSnapshot)
1003    /// when called between phases of an in-progress tick. (#297)
1004    ///
1005    /// # Errors
1006    ///
1007    /// Returns [`SimError::MidTickSnapshot`](crate::error::SimError::MidTickSnapshot)
1008    /// when invoked between a `run_*` phase call and the matching
1009    /// `advance_tick`.
1010    pub fn try_snapshot(&self) -> Result<WorldSnapshot, crate::error::SimError> {
1011        if self.tick_in_progress() {
1012            return Err(crate::error::SimError::MidTickSnapshot);
1013        }
1014        Ok(self.snapshot())
1015    }
1016
1017    /// Internal snapshot builder shared by [`snapshot`](Self::snapshot)
1018    /// and [`try_snapshot`](Self::try_snapshot). Holds the line-count
1019    /// allow so the public methods remain visible in nursery lints.
1020    #[allow(clippy::too_many_lines)]
1021    fn snapshot_inner(&self) -> WorldSnapshot {
1022        let world = self.world();
1023
1024        // Build entity index: map EntityId → position in vec.
1025        let all_ids: Vec<EntityId> = world.alive.keys().collect();
1026        let id_to_index: HashMap<EntityId, usize> = all_ids
1027            .iter()
1028            .copied()
1029            .enumerate()
1030            .map(|(i, e)| (e, i))
1031            .collect();
1032
1033        // Snapshot each entity.
1034        let entities: Vec<EntitySnapshot> = all_ids
1035            .iter()
1036            .map(|&eid| EntitySnapshot {
1037                original_id: eid,
1038                position: world.position(eid).copied(),
1039                velocity: world.velocity(eid).copied(),
1040                elevator: world.elevator(eid).cloned(),
1041                stop: world.stop(eid).cloned(),
1042                rider: world.rider(eid).cloned(),
1043                route: world.route(eid).cloned(),
1044                line: world.line(eid).cloned(),
1045                patience: world.patience(eid).copied(),
1046                preferences: world.preferences(eid).copied(),
1047                access_control: world.access_control(eid).cloned(),
1048                disabled: world.is_disabled(eid),
1049                #[cfg(feature = "energy")]
1050                energy_profile: world.energy_profile(eid).cloned(),
1051                #[cfg(feature = "energy")]
1052                energy_metrics: world.energy_metrics(eid).cloned(),
1053                service_mode: world.service_mode(eid).copied(),
1054                destination_queue: world.destination_queue(eid).cloned(),
1055                car_calls: world.car_calls(eid).to_vec(),
1056            })
1057            .collect();
1058
1059        // Snapshot groups (convert EntityIds to indices).
1060        let groups: Vec<GroupSnapshot> = self
1061            .groups()
1062            .iter()
1063            .map(|g| {
1064                let lines: Vec<LineSnapshotInfo> = g
1065                    .lines()
1066                    .iter()
1067                    .filter_map(|li| {
1068                        let entity_index = id_to_index.get(&li.entity()).copied()?;
1069                        Some(LineSnapshotInfo {
1070                            entity_index,
1071                            elevator_indices: li
1072                                .elevators()
1073                                .iter()
1074                                .filter_map(|eid| id_to_index.get(eid).copied())
1075                                .collect(),
1076                            stop_indices: li
1077                                .serves()
1078                                .iter()
1079                                .filter_map(|eid| id_to_index.get(eid).copied())
1080                                .collect(),
1081                        })
1082                    })
1083                    .collect();
1084                GroupSnapshot {
1085                    id: g.id(),
1086                    name: g.name().to_owned(),
1087                    elevator_indices: g
1088                        .elevator_entities()
1089                        .iter()
1090                        .filter_map(|eid| id_to_index.get(eid).copied())
1091                        .collect(),
1092                    stop_indices: g
1093                        .stop_entities()
1094                        .iter()
1095                        .filter_map(|eid| id_to_index.get(eid).copied())
1096                        .collect(),
1097                    strategy: self
1098                        .strategy_id(g.id())
1099                        .cloned()
1100                        .unwrap_or(crate::dispatch::BuiltinStrategy::Scan),
1101                    lines,
1102                    reposition: self.reposition_id(g.id()).cloned(),
1103                    hall_call_mode: g.hall_call_mode(),
1104                    ack_latency_ticks: g.ack_latency_ticks(),
1105                }
1106            })
1107            .collect();
1108
1109        // Snapshot stop lookup (convert EntityIds to indices).
1110        let stop_lookup: BTreeMap<StopId, usize> = self
1111            .stop_lookup_iter()
1112            .filter_map(|(sid, eid)| id_to_index.get(eid).map(|&idx| (*sid, idx)))
1113            .collect();
1114
1115        WorldSnapshot {
1116            version: SNAPSHOT_SCHEMA_VERSION,
1117            tick: self.current_tick(),
1118            dt: self.dt(),
1119            entities,
1120            groups,
1121            stop_lookup,
1122            metrics: self.metrics().clone(),
1123            metric_tags: self
1124                .world()
1125                .resource::<MetricTags>()
1126                .cloned()
1127                .unwrap_or_default(),
1128            extensions: self.world().serialize_extensions(),
1129            ticks_per_second: 1.0 / self.dt(),
1130            hall_calls: world.iter_hall_calls().cloned().collect(),
1131            arrival_log: world
1132                .resource::<crate::arrival_log::ArrivalLog>()
1133                .cloned()
1134                .unwrap_or_default(),
1135            arrival_log_retention: world
1136                .resource::<crate::arrival_log::ArrivalLogRetention>()
1137                .copied()
1138                .unwrap_or_default(),
1139            destination_log: world
1140                .resource::<crate::arrival_log::DestinationLog>()
1141                .cloned()
1142                .unwrap_or_default(),
1143            traffic_detector: world
1144                .resource::<crate::traffic_detector::TrafficDetector>()
1145                .cloned()
1146                .unwrap_or_default(),
1147            reposition_cooldowns: world
1148                .resource::<crate::dispatch::reposition::RepositionCooldowns>()
1149                .cloned()
1150                .unwrap_or_default(),
1151            // Per-group dispatcher configuration. Only strategies that
1152            // override `snapshot_config` emit non-None here; the rest
1153            // default to empty and restore to built-in defaults,
1154            // preserving pre-fix behaviour for stateless strategies.
1155            dispatch_config: self
1156                .dispatchers()
1157                .iter()
1158                .filter_map(|(gid, d)| d.snapshot_config().map(|s| (*gid, s)))
1159                .collect(),
1160        }
1161    }
1162
1163    /// Serialize the current state to a self-describing byte blob.
1164    ///
1165    /// The blob is postcard-encoded and carries a magic prefix plus the
1166    /// `elevator-core` crate version. Use [`Self::restore_bytes`]
1167    /// on the receiving end. Determinism is bit-exact across builds of
1168    /// the same crate version; cross-version restores return
1169    /// [`SimError::SnapshotVersion`](crate::error::SimError::SnapshotVersion).
1170    ///
1171    /// Extension component *data* is serialized (identical to
1172    /// [`Self::snapshot`]); after restore, use
1173    /// [`Simulation::load_extensions_with`](crate::sim::Simulation::load_extensions_with)
1174    /// to register and load them.
1175    /// Custom dispatch strategies and arbitrary `World` resources are
1176    /// not included.
1177    ///
1178    /// # Errors
1179    /// - [`SimError::SnapshotFormat`](crate::error::SimError::SnapshotFormat)
1180    ///   if postcard encoding fails. Unreachable for well-formed
1181    ///   `WorldSnapshot` values, so callers that don't care can `unwrap`.
1182    /// - [`SimError::MidTickSnapshot`](crate::error::SimError::MidTickSnapshot)
1183    ///   if invoked between phases of an in-progress tick (substep API
1184    ///   path) — the in-flight `EventBus` would otherwise be lost. (#297)
1185    pub fn snapshot_bytes(&self) -> Result<Vec<u8>, crate::error::SimError> {
1186        let envelope = SnapshotEnvelope {
1187            magic: SNAPSHOT_MAGIC,
1188            version: env!("CARGO_PKG_VERSION").to_owned(),
1189            payload: self.try_snapshot()?,
1190        };
1191        postcard::to_allocvec(&envelope)
1192            .map_err(|e| crate::error::SimError::SnapshotFormat(e.to_string()))
1193    }
1194
1195    /// Cheap u64 checksum of the simulation's serializable state.
1196    /// FNV-1a over the postcard encoding of [`Self::snapshot`]'s
1197    /// `WorldSnapshot` payload — the envelope (magic + crate version
1198    /// string) is *not* hashed, so the value depends only on the
1199    /// logical sim state, not on the `elevator-core` version that
1200    /// produced it. The numeric value is FNV-1a-specific and not
1201    /// equivalent to other hash functions of the same bytes; consumers
1202    /// computing an independent hash for comparison must use this
1203    /// method (or run FNV-1a themselves with the same constants).
1204    ///
1205    /// Snapshot/restore is byte-symmetric: a fresh sim and a restored
1206    /// sim with the same logical state hash equal. (Earlier code had
1207    /// a first-restore asymmetry from the `AssignedCar` extension
1208    /// type registering on restore but not `new`; that was fixed.)
1209    ///
1210    /// Designed for divergence detection between runtimes that should
1211    /// be in lockstep (browser vs server, multi-client multiplayer)
1212    /// and for golden checksums that need to survive a
1213    /// release-please version bump. Two sims that have produced bit-
1214    /// identical inputs in bit-identical order must hash to the same
1215    /// value, regardless of `CARGO_PKG_VERSION`.
1216    ///
1217    /// # Errors
1218    /// - [`SimError::MidTickSnapshot`](crate::error::SimError::MidTickSnapshot)
1219    ///   when invoked between phases of an in-progress tick (substep
1220    ///   API path).
1221    /// - [`SimError::SnapshotFormat`](crate::error::SimError::SnapshotFormat)
1222    ///   if postcard encoding of the payload fails. Unreachable for
1223    ///   well-formed sims; callers that don't care can `unwrap`.
1224    pub fn snapshot_checksum(&self) -> Result<u64, crate::error::SimError> {
1225        // FNV-1a (64-bit). Small, allocation-free over the byte slice,
1226        // well-distributed for arbitrary input. Not cryptographic;
1227        // collision tolerance is fine for divergence detection.
1228        // Constants are the standard 64-bit FNV-1a parameters from
1229        // RFC draft-eastlake-fnv (offset basis and prime), not arbitrary.
1230        const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
1231        const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
1232        let snapshot = self.try_snapshot()?;
1233        let bytes = postcard::to_allocvec(&snapshot)
1234            .map_err(|e| crate::error::SimError::SnapshotFormat(e.to_string()))?;
1235        let mut h: u64 = FNV_OFFSET;
1236        for byte in &bytes {
1237            h ^= u64::from(*byte);
1238            h = h.wrapping_mul(FNV_PRIME);
1239        }
1240        Ok(h)
1241    }
1242
1243    /// Restore a simulation from bytes produced by [`Self::snapshot_bytes`].
1244    ///
1245    /// Built-in dispatch strategies are auto-restored. For groups using
1246    /// [`BuiltinStrategy::Custom`](crate::dispatch::BuiltinStrategy::Custom),
1247    /// configure [`RestoreOptions`] with a factory; pass
1248    /// [`RestoreOptions::default()`] when the snapshot only carries
1249    /// built-in strategies.
1250    ///
1251    /// # Errors
1252    /// - [`SimError::SnapshotFormat`](crate::error::SimError::SnapshotFormat)
1253    ///   if the bytes are not a valid envelope or the magic prefix does
1254    ///   not match.
1255    /// - [`SimError::SnapshotVersion`](crate::error::SimError::SnapshotVersion)
1256    ///   if the blob was produced by a different crate version.
1257    /// - [`SimError::UnresolvedCustomStrategy`](crate::error::SimError::UnresolvedCustomStrategy)
1258    ///   if a group uses a custom strategy that the factory cannot resolve.
1259    pub fn restore_bytes(
1260        bytes: &[u8],
1261        options: RestoreOptions<'_>,
1262    ) -> Result<Self, crate::error::SimError> {
1263        let (envelope, tail): (SnapshotEnvelope, &[u8]) = postcard::take_from_bytes(bytes)
1264            .map_err(|e| crate::error::SimError::SnapshotFormat(e.to_string()))?;
1265        if !tail.is_empty() {
1266            return Err(crate::error::SimError::SnapshotFormat(format!(
1267                "trailing bytes: {} unread of {}",
1268                tail.len(),
1269                bytes.len()
1270            )));
1271        }
1272        if envelope.magic != SNAPSHOT_MAGIC {
1273            return Err(crate::error::SimError::SnapshotFormat(
1274                "magic bytes do not match".to_string(),
1275            ));
1276        }
1277        let current = env!("CARGO_PKG_VERSION");
1278        if envelope.version != current {
1279            return Err(crate::error::SimError::SnapshotVersion {
1280                saved: envelope.version,
1281                current: current.to_owned(),
1282            });
1283        }
1284        envelope.payload.restore(options)
1285    }
1286}