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