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, Patience, Position,
13 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 min_position: if min_pos.is_finite() { min_pos } else { 0.0 },
475 max_position: if max_pos.is_finite() { max_pos } else { 0.0 },
476 max_cars: None,
477 },
478 );
479 for &elev_eid in line_info.elevators() {
480 if let Some(car) = world.elevator_mut(elev_eid) {
481 car.line = line_eid;
482 }
483 }
484 line_info.set_entity(line_eid);
485 }
486 }
487 }
488
489 /// Insert all post-entity world resources, remapping `EntityId`s where
490 /// they cross-reference. Without these `PredictiveParking`,
491 /// `DispatchManifest::arrivals_at`, the down-peak classifier branch,
492 /// host-configured retention, and reposition cooldowns silently no-op
493 /// or fall back to defaults post-restore.
494 #[allow(clippy::too_many_arguments)]
495 fn install_runtime_resources(
496 world: &mut crate::world::World,
497 id_remap: &HashMap<EntityId, EntityId>,
498 tick: u64,
499 extensions: &BTreeMap<String, BTreeMap<EntityId, String>>,
500 metric_tags: MetricTags,
501 arrival_log: crate::arrival_log::ArrivalLog,
502 arrival_log_retention: crate::arrival_log::ArrivalLogRetention,
503 destination_log: crate::arrival_log::DestinationLog,
504 traffic_detector: crate::traffic_detector::TrafficDetector,
505 reposition_cooldowns: crate::dispatch::reposition::RepositionCooldowns,
506 ) {
507 let remapped_exts = Self::remap_extensions(extensions, id_remap);
508 world.insert_resource(PendingExtensions(remapped_exts));
509
510 let mut tags = metric_tags;
511 tags.remap_entity_ids(id_remap);
512 world.insert_resource(tags);
513
514 let mut log = arrival_log;
515 log.remap_entity_ids(id_remap);
516 world.insert_resource(log);
517 world.insert_resource(crate::arrival_log::CurrentTick(tick));
518 world.insert_resource(arrival_log_retention);
519
520 let mut dest_log = destination_log;
521 dest_log.remap_entity_ids(id_remap);
522 world.insert_resource(dest_log);
523
524 // Detector is re-inserted last-writer-wins so the *classified* state
525 // carries forward; refresh_traffic_detector updates on the next
526 // metrics phase with fresh counts.
527 world.insert_resource(traffic_detector);
528
529 let mut cooldowns = reposition_cooldowns;
530 cooldowns.remap_entity_ids(id_remap);
531 world.insert_resource(cooldowns);
532 }
533
534 /// Replay per-group dispatcher tuning captured in the snapshot. Built-ins
535 /// with `with_*` builders override `snapshot_config`/`restore_config` to
536 /// round-trip their weights; strategies that don't override silently skip
537 /// (default `restore_config` is `Ok(())`), preserving pre-fix behaviour. A
538 /// deserialization failure surfaces as an event rather than a hard error
539 /// — the restored sim runs with defaults, same as a legacy snapshot.
540 fn replay_dispatcher_tuning(
541 sim: &mut crate::sim::Simulation,
542 dispatch_config: &std::collections::BTreeMap<GroupId, String>,
543 ) {
544 for (gid, serialized) in dispatch_config {
545 if let Some(dispatcher) = sim.dispatchers_mut().get_mut(gid)
546 && let Err(err) = dispatcher.restore_config(serialized)
547 {
548 sim.push_event(crate::events::Event::DispatchConfigNotRestored {
549 group: *gid,
550 reason: err,
551 });
552 }
553 }
554 }
555
556 /// Restore reposition strategies from group snapshots, emitting
557 /// [`Event::RepositionStrategyNotRestored`](crate::events::Event::RepositionStrategyNotRestored)
558 /// when an id can't be re-instantiated.
559 fn restore_reposition_strategies(sim: &mut crate::sim::Simulation, groups: &[GroupSnapshot]) {
560 for gs in groups {
561 let Some(ref repo_id) = gs.reposition else {
562 continue;
563 };
564 if let Some(strategy) = repo_id.instantiate() {
565 sim.set_reposition(gs.id, strategy, repo_id.clone());
566 } else {
567 sim.push_event(crate::events::Event::RepositionStrategyNotRestored {
568 group: gs.id,
569 });
570 }
571 }
572 }
573
574 /// Spawn entities in the world and build the old→new `EntityId` mapping.
575 fn spawn_entities(
576 world: &mut crate::world::World,
577 entities: &[EntitySnapshot],
578 ) -> (Vec<EntityId>, HashMap<EntityId, EntityId>) {
579 let mut index_to_id: Vec<EntityId> = Vec::with_capacity(entities.len());
580 let mut id_remap: HashMap<EntityId, EntityId> = HashMap::new();
581 for snap in entities {
582 let new_id = world.spawn();
583 index_to_id.push(new_id);
584 id_remap.insert(snap.original_id, new_id);
585 }
586 (index_to_id, id_remap)
587 }
588
589 /// Attach components to spawned entities, remapping cross-references.
590 fn attach_components(
591 world: &mut crate::world::World,
592 entities: &[EntitySnapshot],
593 index_to_id: &[EntityId],
594 id_remap: &HashMap<EntityId, EntityId>,
595 ) {
596 let remap = |old: EntityId| -> EntityId { id_remap.get(&old).copied().unwrap_or(old) };
597 let remap_opt = |old: Option<EntityId>| -> Option<EntityId> { old.map(&remap) };
598
599 for (i, snap) in entities.iter().enumerate() {
600 let eid = index_to_id[i];
601
602 if let Some(pos) = snap.position {
603 world.set_position(eid, pos);
604 }
605 if let Some(vel) = snap.velocity {
606 world.set_velocity(eid, vel);
607 }
608 if let Some(ref elev) = snap.elevator {
609 let mut e = elev.clone();
610 e.riders = e.riders.iter().map(|&r| remap(r)).collect();
611 e.target_stop = remap_opt(e.target_stop);
612 e.line = remap(e.line);
613 e.restricted_stops = e.restricted_stops.iter().map(|&s| remap(s)).collect();
614 e.phase = match e.phase {
615 crate::components::ElevatorPhase::MovingToStop(s) => {
616 crate::components::ElevatorPhase::MovingToStop(remap(s))
617 }
618 crate::components::ElevatorPhase::Repositioning(s) => {
619 crate::components::ElevatorPhase::Repositioning(remap(s))
620 }
621 other => other,
622 };
623 world.set_elevator(eid, e);
624 }
625 if let Some(ref stop) = snap.stop {
626 world.set_stop(eid, stop.clone());
627 }
628 if let Some(ref rider) = snap.rider {
629 use crate::components::RiderPhase;
630 let mut r = rider.clone();
631 r.current_stop = remap_opt(r.current_stop);
632 r.phase = match r.phase {
633 RiderPhase::Boarding(e) => RiderPhase::Boarding(remap(e)),
634 RiderPhase::Riding(e) => RiderPhase::Riding(remap(e)),
635 RiderPhase::Exiting(e) => RiderPhase::Exiting(remap(e)),
636 other => other,
637 };
638 world.set_rider(eid, r);
639 }
640 if let Some(ref route) = snap.route {
641 let mut rt = route.clone();
642 for leg in &mut rt.legs {
643 leg.from = remap(leg.from);
644 leg.to = remap(leg.to);
645 if let crate::components::TransportMode::Line(ref mut l) = leg.via {
646 *l = remap(*l);
647 }
648 }
649 world.set_route(eid, rt);
650 }
651 if let Some(ref line) = snap.line {
652 world.set_line(eid, line.clone());
653 }
654 if let Some(patience) = snap.patience {
655 world.set_patience(eid, patience);
656 }
657 if let Some(prefs) = snap.preferences {
658 world.set_preferences(eid, prefs);
659 }
660 if let Some(ref ac) = snap.access_control {
661 let remapped =
662 AccessControl::new(ac.allowed_stops().iter().map(|&s| remap(s)).collect());
663 world.set_access_control(eid, remapped);
664 }
665 if snap.disabled {
666 world.disable(eid);
667 }
668 #[cfg(feature = "energy")]
669 if let Some(ref profile) = snap.energy_profile {
670 world.set_energy_profile(eid, profile.clone());
671 }
672 #[cfg(feature = "energy")]
673 if let Some(ref em) = snap.energy_metrics {
674 world.set_energy_metrics(eid, em.clone());
675 }
676 if let Some(mode) = snap.service_mode {
677 world.set_service_mode(eid, mode);
678 }
679 if let Some(ref dq) = snap.destination_queue {
680 use crate::components::DestinationQueue as DQ;
681 let mut new_dq = DQ::new();
682 for &e in dq.queue() {
683 new_dq.push_back(remap(e));
684 }
685 world.set_destination_queue(eid, new_dq);
686 }
687 Self::attach_car_calls(world, eid, &snap.car_calls, id_remap);
688 }
689 }
690
691 /// Re-register per-car floor button presses after entities are spawned.
692 fn attach_car_calls(
693 world: &mut crate::world::World,
694 car: EntityId,
695 car_calls: &[CarCall],
696 id_remap: &HashMap<EntityId, EntityId>,
697 ) {
698 if car_calls.is_empty() {
699 return;
700 }
701 let remap = |old: EntityId| -> EntityId { id_remap.get(&old).copied().unwrap_or(old) };
702 let Some(slot) = world.car_calls_mut(car) else {
703 return;
704 };
705 for cc in car_calls {
706 let mut c = cc.clone();
707 c.car = car;
708 c.floor = remap(c.floor);
709 c.pending_riders = c.pending_riders.iter().map(|&r| remap(r)).collect();
710 slot.push(c);
711 }
712 }
713
714 /// Re-register hall calls in the world after entities are spawned.
715 ///
716 /// `HallCall` cross-references stops, cars, riders, and optional
717 /// destinations — all `EntityId`s must be remapped through `id_remap`.
718 /// Pre-15.23 snapshots stored a single `assigned_car` field, silently
719 /// dropped by `#[serde(default)]` on `assigned_cars_by_line`; the
720 /// next dispatch pass repopulates the empty map, so no explicit
721 /// migration is attempted here.
722 fn attach_hall_calls(
723 &self,
724 world: &mut crate::world::World,
725 id_remap: &HashMap<EntityId, EntityId>,
726 ) {
727 let remap = |old: EntityId| -> EntityId { id_remap.get(&old).copied().unwrap_or(old) };
728 let remap_opt = |old: Option<EntityId>| -> Option<EntityId> { old.map(&remap) };
729 for hc in &self.hall_calls {
730 let mut c = hc.clone();
731 c.stop = remap(c.stop);
732 c.destination = remap_opt(c.destination);
733 c.assigned_cars_by_line = c
734 .assigned_cars_by_line
735 .iter()
736 .map(|(&line, &car)| (remap(line), remap(car)))
737 .collect();
738 c.pending_riders = c.pending_riders.iter().map(|&r| remap(r)).collect();
739 world.set_hall_call(c);
740 }
741 }
742
743 /// Rebuild groups, stop lookup, and dispatchers from snapshot data.
744 #[allow(clippy::type_complexity)]
745 fn rebuild_groups_and_dispatchers(
746 &self,
747 index_to_id: &[EntityId],
748 custom_strategy_factory: CustomStrategyFactory<'_>,
749 ) -> Result<
750 (
751 Vec<crate::dispatch::ElevatorGroup>,
752 HashMap<StopId, EntityId>,
753 std::collections::BTreeMap<GroupId, Box<dyn crate::dispatch::DispatchStrategy>>,
754 std::collections::BTreeMap<GroupId, crate::dispatch::BuiltinStrategy>,
755 ),
756 crate::error::SimError,
757 > {
758 use crate::dispatch::ElevatorGroup;
759
760 let groups: Vec<ElevatorGroup> = self
761 .groups
762 .iter()
763 .map(|gs| {
764 let elevator_entities: Vec<EntityId> = gs
765 .elevator_indices
766 .iter()
767 .filter_map(|&i| index_to_id.get(i).copied())
768 .collect();
769 let stop_entities: Vec<EntityId> = gs
770 .stop_indices
771 .iter()
772 .filter_map(|&i| index_to_id.get(i).copied())
773 .collect();
774
775 let lines = if gs.lines.is_empty() {
776 // Legacy snapshots have no per-line data; create a single
777 // synthetic LineInfo containing all elevators and stops.
778 vec![crate::dispatch::LineInfo::new(
779 EntityId::default(),
780 elevator_entities,
781 stop_entities,
782 )]
783 } else {
784 gs.lines
785 .iter()
786 .filter_map(|lsi| {
787 let entity = index_to_id.get(lsi.entity_index).copied()?;
788 Some(crate::dispatch::LineInfo::new(
789 entity,
790 lsi.elevator_indices
791 .iter()
792 .filter_map(|&i| index_to_id.get(i).copied())
793 .collect(),
794 lsi.stop_indices
795 .iter()
796 .filter_map(|&i| index_to_id.get(i).copied())
797 .collect(),
798 ))
799 })
800 .collect()
801 };
802
803 ElevatorGroup::new(gs.id, gs.name.clone(), lines)
804 .with_hall_call_mode(gs.hall_call_mode)
805 .with_ack_latency_ticks(gs.ack_latency_ticks)
806 })
807 .collect();
808
809 let stop_lookup: HashMap<StopId, EntityId> = self
810 .stop_lookup
811 .iter()
812 .filter_map(|(sid, &idx)| index_to_id.get(idx).map(|&eid| (*sid, eid)))
813 .collect();
814
815 let mut dispatchers = std::collections::BTreeMap::new();
816 let mut strategy_ids = std::collections::BTreeMap::new();
817 for (gs, group) in self.groups.iter().zip(groups.iter()) {
818 let strategy: Box<dyn crate::dispatch::DispatchStrategy> =
819 if let Some(builtin) = gs.strategy.instantiate() {
820 builtin
821 } else if let crate::dispatch::BuiltinStrategy::Custom(ref name) = gs.strategy {
822 custom_strategy_factory
823 .and_then(|f| f(name))
824 .ok_or_else(|| crate::error::SimError::UnresolvedCustomStrategy {
825 name: name.clone(),
826 group: group.id(),
827 })?
828 } else {
829 Box::new(crate::dispatch::scan::ScanDispatch::new())
830 };
831 dispatchers.insert(group.id(), strategy);
832 strategy_ids.insert(group.id(), gs.strategy.clone());
833 }
834
835 Ok((groups, stop_lookup, dispatchers, strategy_ids))
836 }
837
838 /// Remap `EntityId`s in extension data using the old→new mapping.
839 fn remap_extensions(
840 extensions: &BTreeMap<String, BTreeMap<EntityId, String>>,
841 id_remap: &HashMap<EntityId, EntityId>,
842 ) -> BTreeMap<String, BTreeMap<EntityId, String>> {
843 extensions
844 .iter()
845 .map(|(name, entries)| {
846 let remapped: BTreeMap<EntityId, String> = entries
847 .iter()
848 .map(|(old_id, data)| {
849 let new_id = id_remap.get(old_id).copied().unwrap_or(*old_id);
850 (new_id, data.clone())
851 })
852 .collect();
853 (name.clone(), remapped)
854 })
855 .collect()
856 }
857
858 /// Emit `SnapshotDanglingReference` events for entity IDs not in `id_remap`.
859 fn emit_dangling_warnings(
860 entities: &[EntitySnapshot],
861 hall_calls: &[HallCall],
862 id_remap: &HashMap<EntityId, EntityId>,
863 tick: u64,
864 sim: &mut crate::sim::Simulation,
865 ) {
866 let mut seen = HashSet::new();
867 let mut check = |old: EntityId| {
868 if !id_remap.contains_key(&old) && seen.insert(old) {
869 sim.push_event(crate::events::Event::SnapshotDanglingReference {
870 stale_id: old,
871 tick,
872 });
873 }
874 };
875 for snap in entities {
876 Self::collect_referenced_ids(snap, &mut check);
877 }
878 for hc in hall_calls {
879 check(hc.stop);
880 for (&line, &car) in &hc.assigned_cars_by_line {
881 check(line);
882 check(car);
883 }
884 if let Some(dest) = hc.destination {
885 check(dest);
886 }
887 for &rider in &hc.pending_riders {
888 check(rider);
889 }
890 }
891 }
892
893 /// Visit all cross-referenced `EntityId`s inside an entity snapshot.
894 fn collect_referenced_ids(snap: &EntitySnapshot, mut visit: impl FnMut(EntityId)) {
895 if let Some(ref elev) = snap.elevator {
896 for &r in &elev.riders {
897 visit(r);
898 }
899 if let Some(t) = elev.target_stop {
900 visit(t);
901 }
902 visit(elev.line);
903 match elev.phase {
904 crate::components::ElevatorPhase::MovingToStop(s)
905 | crate::components::ElevatorPhase::Repositioning(s) => visit(s),
906 _ => {}
907 }
908 for &s in &elev.restricted_stops {
909 visit(s);
910 }
911 }
912 if let Some(ref rider) = snap.rider {
913 if let Some(s) = rider.current_stop {
914 visit(s);
915 }
916 match rider.phase {
917 crate::components::RiderPhase::Boarding(e)
918 | crate::components::RiderPhase::Riding(e)
919 | crate::components::RiderPhase::Exiting(e) => visit(e),
920 _ => {}
921 }
922 }
923 if let Some(ref route) = snap.route {
924 for leg in &route.legs {
925 visit(leg.from);
926 visit(leg.to);
927 if let crate::components::TransportMode::Line(l) = leg.via {
928 visit(l);
929 }
930 }
931 }
932 if let Some(ref ac) = snap.access_control {
933 for &s in ac.allowed_stops() {
934 visit(s);
935 }
936 }
937 if let Some(ref dq) = snap.destination_queue {
938 for &e in dq.queue() {
939 visit(e);
940 }
941 }
942 for cc in &snap.car_calls {
943 visit(cc.floor);
944 for &r in &cc.pending_riders {
945 visit(r);
946 }
947 }
948 }
949}
950
951/// Magic bytes identifying a postcard snapshot blob.
952const SNAPSHOT_MAGIC: [u8; 8] = *b"ELEVSNAP";
953
954/// Schema version for [`WorldSnapshot`]. Bump on incompatible layout
955/// changes so RON/JSON restore can reject older snapshots loudly
956/// instead of silently filling new fields with `#[serde(default)]`.
957///
958/// See `docs/src/snapshot-versioning.md` for the full bump-trigger
959/// policy, the asymmetry between this `u32` and the crate-version
960/// string in the bytes envelope, and the migration path.
961const SNAPSHOT_SCHEMA_VERSION: u32 = 1;
962
963/// Byte-level snapshot envelope: magic + crate version + payload.
964///
965/// Serialized via postcard. The magic and version fields are checked on
966/// restore to reject blobs from other tools or from a different
967/// `elevator-core` version.
968#[derive(Debug, Serialize, Deserialize)]
969struct SnapshotEnvelope {
970 /// Magic bytes; must equal [`SNAPSHOT_MAGIC`] or the blob is rejected.
971 magic: [u8; 8],
972 /// `elevator-core` crate version that produced the blob.
973 version: String,
974 /// The captured simulation state.
975 payload: WorldSnapshot,
976}
977
978impl crate::sim::Simulation {
979 /// Create a serializable snapshot of the current simulation state.
980 ///
981 /// The snapshot captures all entities, components, groups, metrics,
982 /// the tick counter, and extension component data (game must
983 /// re-register types via `register_ext` before `restore`).
984 /// Custom resources inserted via `world.insert_resource` are NOT
985 /// captured — games using them must save/restore separately (#296).
986 ///
987 /// **Mid-tick safety:** `snapshot()` returns a snapshot regardless
988 /// of whether you are mid-tick (between phase calls in the substep
989 /// API). For substep callers that care about event-bus state, use
990 /// [`try_snapshot`](Self::try_snapshot) which returns
991 /// [`SimError::MidTickSnapshot`](crate::error::SimError::MidTickSnapshot)
992 /// when invoked between `run_*` and `advance_tick`. (#297)
993 #[must_use]
994 #[allow(clippy::too_many_lines)]
995 pub fn snapshot(&self) -> WorldSnapshot {
996 self.snapshot_inner()
997 }
998
999 /// Like [`snapshot`](Self::snapshot) but returns
1000 /// [`SimError::MidTickSnapshot`](crate::error::SimError::MidTickSnapshot)
1001 /// when called between phases of an in-progress tick. (#297)
1002 ///
1003 /// # Errors
1004 ///
1005 /// Returns [`SimError::MidTickSnapshot`](crate::error::SimError::MidTickSnapshot)
1006 /// when invoked between a `run_*` phase call and the matching
1007 /// `advance_tick`.
1008 pub fn try_snapshot(&self) -> Result<WorldSnapshot, crate::error::SimError> {
1009 if self.tick_in_progress {
1010 return Err(crate::error::SimError::MidTickSnapshot);
1011 }
1012 Ok(self.snapshot())
1013 }
1014
1015 /// Internal snapshot builder shared by [`snapshot`](Self::snapshot)
1016 /// and [`try_snapshot`](Self::try_snapshot). Holds the line-count
1017 /// allow so the public methods remain visible in nursery lints.
1018 #[allow(clippy::too_many_lines)]
1019 fn snapshot_inner(&self) -> WorldSnapshot {
1020 let world = self.world();
1021
1022 // Build entity index: map EntityId → position in vec.
1023 let all_ids: Vec<EntityId> = world.alive.keys().collect();
1024 let id_to_index: HashMap<EntityId, usize> = all_ids
1025 .iter()
1026 .copied()
1027 .enumerate()
1028 .map(|(i, e)| (e, i))
1029 .collect();
1030
1031 // Snapshot each entity.
1032 let entities: Vec<EntitySnapshot> = all_ids
1033 .iter()
1034 .map(|&eid| EntitySnapshot {
1035 original_id: eid,
1036 position: world.position(eid).copied(),
1037 velocity: world.velocity(eid).copied(),
1038 elevator: world.elevator(eid).cloned(),
1039 stop: world.stop(eid).cloned(),
1040 rider: world.rider(eid).cloned(),
1041 route: world.route(eid).cloned(),
1042 line: world.line(eid).cloned(),
1043 patience: world.patience(eid).copied(),
1044 preferences: world.preferences(eid).copied(),
1045 access_control: world.access_control(eid).cloned(),
1046 disabled: world.is_disabled(eid),
1047 #[cfg(feature = "energy")]
1048 energy_profile: world.energy_profile(eid).cloned(),
1049 #[cfg(feature = "energy")]
1050 energy_metrics: world.energy_metrics(eid).cloned(),
1051 service_mode: world.service_mode(eid).copied(),
1052 destination_queue: world.destination_queue(eid).cloned(),
1053 car_calls: world.car_calls(eid).to_vec(),
1054 })
1055 .collect();
1056
1057 // Snapshot groups (convert EntityIds to indices).
1058 let groups: Vec<GroupSnapshot> = self
1059 .groups()
1060 .iter()
1061 .map(|g| {
1062 let lines: Vec<LineSnapshotInfo> = g
1063 .lines()
1064 .iter()
1065 .filter_map(|li| {
1066 let entity_index = id_to_index.get(&li.entity()).copied()?;
1067 Some(LineSnapshotInfo {
1068 entity_index,
1069 elevator_indices: li
1070 .elevators()
1071 .iter()
1072 .filter_map(|eid| id_to_index.get(eid).copied())
1073 .collect(),
1074 stop_indices: li
1075 .serves()
1076 .iter()
1077 .filter_map(|eid| id_to_index.get(eid).copied())
1078 .collect(),
1079 })
1080 })
1081 .collect();
1082 GroupSnapshot {
1083 id: g.id(),
1084 name: g.name().to_owned(),
1085 elevator_indices: g
1086 .elevator_entities()
1087 .iter()
1088 .filter_map(|eid| id_to_index.get(eid).copied())
1089 .collect(),
1090 stop_indices: g
1091 .stop_entities()
1092 .iter()
1093 .filter_map(|eid| id_to_index.get(eid).copied())
1094 .collect(),
1095 strategy: self
1096 .strategy_id(g.id())
1097 .cloned()
1098 .unwrap_or(crate::dispatch::BuiltinStrategy::Scan),
1099 lines,
1100 reposition: self.reposition_id(g.id()).cloned(),
1101 hall_call_mode: g.hall_call_mode(),
1102 ack_latency_ticks: g.ack_latency_ticks(),
1103 }
1104 })
1105 .collect();
1106
1107 // Snapshot stop lookup (convert EntityIds to indices).
1108 let stop_lookup: BTreeMap<StopId, usize> = self
1109 .stop_lookup_iter()
1110 .filter_map(|(sid, eid)| id_to_index.get(eid).map(|&idx| (*sid, idx)))
1111 .collect();
1112
1113 WorldSnapshot {
1114 version: SNAPSHOT_SCHEMA_VERSION,
1115 tick: self.current_tick(),
1116 dt: self.dt(),
1117 entities,
1118 groups,
1119 stop_lookup,
1120 metrics: self.metrics().clone(),
1121 metric_tags: self
1122 .world()
1123 .resource::<MetricTags>()
1124 .cloned()
1125 .unwrap_or_default(),
1126 extensions: self.world().serialize_extensions(),
1127 ticks_per_second: 1.0 / self.dt(),
1128 hall_calls: world.iter_hall_calls().cloned().collect(),
1129 arrival_log: world
1130 .resource::<crate::arrival_log::ArrivalLog>()
1131 .cloned()
1132 .unwrap_or_default(),
1133 arrival_log_retention: world
1134 .resource::<crate::arrival_log::ArrivalLogRetention>()
1135 .copied()
1136 .unwrap_or_default(),
1137 destination_log: world
1138 .resource::<crate::arrival_log::DestinationLog>()
1139 .cloned()
1140 .unwrap_or_default(),
1141 traffic_detector: world
1142 .resource::<crate::traffic_detector::TrafficDetector>()
1143 .cloned()
1144 .unwrap_or_default(),
1145 reposition_cooldowns: world
1146 .resource::<crate::dispatch::reposition::RepositionCooldowns>()
1147 .cloned()
1148 .unwrap_or_default(),
1149 // Per-group dispatcher configuration. Only strategies that
1150 // override `snapshot_config` emit non-None here; the rest
1151 // default to empty and restore to built-in defaults,
1152 // preserving pre-fix behaviour for stateless strategies.
1153 dispatch_config: self
1154 .dispatchers()
1155 .iter()
1156 .filter_map(|(gid, d)| d.snapshot_config().map(|s| (*gid, s)))
1157 .collect(),
1158 }
1159 }
1160
1161 /// Serialize the current state to a self-describing byte blob.
1162 ///
1163 /// The blob is postcard-encoded and carries a magic prefix plus the
1164 /// `elevator-core` crate version. Use [`Self::restore_bytes`]
1165 /// on the receiving end. Determinism is bit-exact across builds of
1166 /// the same crate version; cross-version restores return
1167 /// [`SimError::SnapshotVersion`](crate::error::SimError::SnapshotVersion).
1168 ///
1169 /// Extension component *data* is serialized (identical to
1170 /// [`Self::snapshot`]); after restore, use
1171 /// [`Simulation::load_extensions_with`](crate::sim::Simulation::load_extensions_with)
1172 /// to register and load them.
1173 /// Custom dispatch strategies and arbitrary `World` resources are
1174 /// not included.
1175 ///
1176 /// # Errors
1177 /// - [`SimError::SnapshotFormat`](crate::error::SimError::SnapshotFormat)
1178 /// if postcard encoding fails. Unreachable for well-formed
1179 /// `WorldSnapshot` values, so callers that don't care can `unwrap`.
1180 /// - [`SimError::MidTickSnapshot`](crate::error::SimError::MidTickSnapshot)
1181 /// if invoked between phases of an in-progress tick (substep API
1182 /// path) — the in-flight `EventBus` would otherwise be lost. (#297)
1183 pub fn snapshot_bytes(&self) -> Result<Vec<u8>, crate::error::SimError> {
1184 let envelope = SnapshotEnvelope {
1185 magic: SNAPSHOT_MAGIC,
1186 version: env!("CARGO_PKG_VERSION").to_owned(),
1187 payload: self.try_snapshot()?,
1188 };
1189 postcard::to_allocvec(&envelope)
1190 .map_err(|e| crate::error::SimError::SnapshotFormat(e.to_string()))
1191 }
1192
1193 /// Cheap u64 checksum of the simulation's serializable state.
1194 /// FNV-1a over the postcard encoding of [`Self::snapshot`]'s
1195 /// `WorldSnapshot` payload — the envelope (magic + crate version
1196 /// string) is *not* hashed, so the value depends only on the
1197 /// logical sim state, not on the `elevator-core` version that
1198 /// produced it. The numeric value is FNV-1a-specific and not
1199 /// equivalent to other hash functions of the same bytes; consumers
1200 /// computing an independent hash for comparison must use this
1201 /// method (or run FNV-1a themselves with the same constants).
1202 ///
1203 /// Snapshot/restore is byte-symmetric: a fresh sim and a restored
1204 /// sim with the same logical state hash equal. (Earlier code had
1205 /// a first-restore asymmetry from the `AssignedCar` extension
1206 /// type registering on restore but not `new`; that was fixed.)
1207 ///
1208 /// Designed for divergence detection between runtimes that should
1209 /// be in lockstep (browser vs server, multi-client multiplayer)
1210 /// and for golden checksums that need to survive a
1211 /// release-please version bump. Two sims that have produced bit-
1212 /// identical inputs in bit-identical order must hash to the same
1213 /// value, regardless of `CARGO_PKG_VERSION`.
1214 ///
1215 /// # Errors
1216 /// - [`SimError::MidTickSnapshot`](crate::error::SimError::MidTickSnapshot)
1217 /// when invoked between phases of an in-progress tick (substep
1218 /// API path).
1219 /// - [`SimError::SnapshotFormat`](crate::error::SimError::SnapshotFormat)
1220 /// if postcard encoding of the payload fails. Unreachable for
1221 /// well-formed sims; callers that don't care can `unwrap`.
1222 pub fn snapshot_checksum(&self) -> Result<u64, crate::error::SimError> {
1223 // FNV-1a (64-bit). Small, allocation-free over the byte slice,
1224 // well-distributed for arbitrary input. Not cryptographic;
1225 // collision tolerance is fine for divergence detection.
1226 // Constants are the standard 64-bit FNV-1a parameters from
1227 // RFC draft-eastlake-fnv (offset basis and prime), not arbitrary.
1228 const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
1229 const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
1230 let snapshot = self.try_snapshot()?;
1231 let bytes = postcard::to_allocvec(&snapshot)
1232 .map_err(|e| crate::error::SimError::SnapshotFormat(e.to_string()))?;
1233 let mut h: u64 = FNV_OFFSET;
1234 for byte in &bytes {
1235 h ^= u64::from(*byte);
1236 h = h.wrapping_mul(FNV_PRIME);
1237 }
1238 Ok(h)
1239 }
1240
1241 /// Restore a simulation from bytes produced by [`Self::snapshot_bytes`].
1242 ///
1243 /// Built-in dispatch strategies are auto-restored. For groups using
1244 /// [`BuiltinStrategy::Custom`](crate::dispatch::BuiltinStrategy::Custom),
1245 /// configure [`RestoreOptions`] with a factory; pass
1246 /// [`RestoreOptions::default()`] when the snapshot only carries
1247 /// built-in strategies.
1248 ///
1249 /// # Errors
1250 /// - [`SimError::SnapshotFormat`](crate::error::SimError::SnapshotFormat)
1251 /// if the bytes are not a valid envelope or the magic prefix does
1252 /// not match.
1253 /// - [`SimError::SnapshotVersion`](crate::error::SimError::SnapshotVersion)
1254 /// if the blob was produced by a different crate version.
1255 /// - [`SimError::UnresolvedCustomStrategy`](crate::error::SimError::UnresolvedCustomStrategy)
1256 /// if a group uses a custom strategy that the factory cannot resolve.
1257 pub fn restore_bytes(
1258 bytes: &[u8],
1259 options: RestoreOptions<'_>,
1260 ) -> Result<Self, crate::error::SimError> {
1261 let (envelope, tail): (SnapshotEnvelope, &[u8]) = postcard::take_from_bytes(bytes)
1262 .map_err(|e| crate::error::SimError::SnapshotFormat(e.to_string()))?;
1263 if !tail.is_empty() {
1264 return Err(crate::error::SimError::SnapshotFormat(format!(
1265 "trailing bytes: {} unread of {}",
1266 tail.len(),
1267 bytes.len()
1268 )));
1269 }
1270 if envelope.magic != SNAPSHOT_MAGIC {
1271 return Err(crate::error::SimError::SnapshotFormat(
1272 "magic bytes do not match".to_string(),
1273 ));
1274 }
1275 let current = env!("CARGO_PKG_VERSION");
1276 if envelope.version != current {
1277 return Err(crate::error::SimError::SnapshotVersion {
1278 saved: envelope.version,
1279 current: current.to_owned(),
1280 });
1281 }
1282 envelope.payload.restore(options)
1283 }
1284}