Skip to main content

alice_physics/
netcode.rs

1//! Deterministic Netcode Foundation
2//!
3//! Frame-based deterministic simulation wrapper for lockstep/rollback netcode.
4//!
5//! # Core Principle
6//!
7//! ```text
8//! Same Inputs + Same Initial State → Bit-Exact Same Output (on all clients)
9//! ```
10//!
11//! Because ALICE-Physics uses 128-bit fixed-point arithmetic with fixed iteration
12//! counts, the simulation is fully deterministic. This means:
13//!
14//! - **No state synchronization needed** — only sync player inputs
15//! - **Bandwidth**: ~20 bytes/player/frame instead of ~160 bytes/body/frame
16//! - **Rollback**: Save/restore snapshots for client-side prediction
17//! - **Desync detection**: Compare state checksums across clients
18//!
19//! # Usage
20//!
21//! ```
22//! use alice_physics::netcode::{DeterministicSimulation, FrameInput, NetcodeConfig};
23//! use alice_physics::Vec3Fix;
24//!
25//! // Both clients create identical simulation
26//! let mut sim = DeterministicSimulation::new(NetcodeConfig::default());
27//!
28//! // Each frame: collect inputs from all players, advance simulation
29//! let inputs = vec![
30//!     FrameInput::new(0).with_movement(Vec3Fix::from_int(1, 0, 0)),
31//!     FrameInput::new(1).with_movement(Vec3Fix::from_int(0, 0, -1)),
32//! ];
33//!
34//! let checksum = sim.advance_frame(&inputs);
35//! // checksum is identical on all clients if inputs match
36//! ```
37//!
38//! # Bandwidth Model
39//!
40//! | Approach | Per-Frame Bandwidth (10 bodies) |
41//! |----------|-------------------------------|
42//! | State sync (traditional) | ~1,600 bytes (160B × 10) |
43//! | **Input sync (ALICE)** | **~40 bytes (20B × 2 players)** |
44//! | Savings | **97.5%** |
45
46use crate::math::{Fix128, Vec3Fix};
47use crate::solver::{PhysicsConfig, PhysicsWorld, RigidBody};
48
49#[cfg(not(feature = "std"))]
50use alloc::{collections::VecDeque, vec, vec::Vec};
51#[cfg(feature = "std")]
52use std::collections::VecDeque;
53
54// ============================================================================
55// Frame Input
56// ============================================================================
57
58/// Player input for a single simulation frame.
59///
60/// Compact, deterministic representation of all player actions.
61/// Serialized size: ~20 bytes (`player_id` + movement + actions + `aim_yaw`).
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63pub struct FrameInput {
64    /// Player identifier (0-based)
65    pub player_id: u8,
66    /// Movement direction (fixed-point, normalized by game logic)
67    pub movement: Vec3Fix,
68    /// Action bitfield (jump=0x1, fire=0x2, interact=0x4, etc.)
69    pub actions: u32,
70    /// Aim direction (yaw/pitch encoded as fixed-point)
71    pub aim_direction: Vec3Fix,
72}
73
74impl FrameInput {
75    /// Create a new empty input for the given player
76    #[inline]
77    #[must_use]
78    pub const fn new(player_id: u8) -> Self {
79        Self {
80            player_id,
81            movement: Vec3Fix::ZERO,
82            actions: 0,
83            aim_direction: Vec3Fix::ZERO,
84        }
85    }
86
87    /// Set movement direction
88    #[inline]
89    #[must_use]
90    pub const fn with_movement(mut self, movement: Vec3Fix) -> Self {
91        self.movement = movement;
92        self
93    }
94
95    /// Set action bitfield
96    #[inline]
97    #[must_use]
98    pub const fn with_actions(mut self, actions: u32) -> Self {
99        self.actions = actions;
100        self
101    }
102
103    /// Set aim direction
104    #[inline]
105    #[must_use]
106    pub const fn with_aim(mut self, aim: Vec3Fix) -> Self {
107        self.aim_direction = aim;
108        self
109    }
110
111    /// Check if a specific action bit is set
112    #[inline]
113    #[must_use]
114    pub const fn has_action(&self, bit: u32) -> bool {
115        self.actions & bit != 0
116    }
117
118    /// Serialize to bytes (deterministic, little-endian)
119    #[must_use]
120    pub fn to_bytes(&self) -> [u8; 20] {
121        let mut buf = [0u8; 20];
122        buf[0] = self.player_id;
123        // movement as i16 x 3 (6 bytes) — truncated from Fix128 for network
124        debug_assert!(i16::try_from(self.movement.x.hi).is_ok());
125        debug_assert!(i16::try_from(self.movement.y.hi).is_ok());
126        debug_assert!(i16::try_from(self.movement.z.hi).is_ok());
127        let mx = self.movement.x.hi as i16;
128        let my = self.movement.y.hi as i16;
129        let mz = self.movement.z.hi as i16;
130        buf[1..3].copy_from_slice(&mx.to_le_bytes());
131        buf[3..5].copy_from_slice(&my.to_le_bytes());
132        buf[5..7].copy_from_slice(&mz.to_le_bytes());
133        // actions (4 bytes)
134        buf[7..11].copy_from_slice(&self.actions.to_le_bytes());
135        // aim as i16 x 3 (6 bytes)
136        let ax = self.aim_direction.x.hi as i16;
137        let ay = self.aim_direction.y.hi as i16;
138        let az = self.aim_direction.z.hi as i16;
139        buf[11..13].copy_from_slice(&ax.to_le_bytes());
140        buf[13..15].copy_from_slice(&ay.to_le_bytes());
141        buf[15..17].copy_from_slice(&az.to_le_bytes());
142        // padding
143        buf[17] = 0;
144        buf[18] = 0;
145        buf[19] = 0;
146        buf
147    }
148
149    /// Deserialize from bytes
150    #[must_use]
151    pub const fn from_bytes(data: &[u8; 20]) -> Self {
152        let player_id = data[0];
153        let mx = i16::from_le_bytes([data[1], data[2]]);
154        let my = i16::from_le_bytes([data[3], data[4]]);
155        let mz = i16::from_le_bytes([data[5], data[6]]);
156        let actions = u32::from_le_bytes([data[7], data[8], data[9], data[10]]);
157        let ax = i16::from_le_bytes([data[11], data[12]]);
158        let ay = i16::from_le_bytes([data[13], data[14]]);
159        let az = i16::from_le_bytes([data[15], data[16]]);
160
161        Self {
162            player_id,
163            movement: Vec3Fix::from_int(mx as i64, my as i64, mz as i64),
164            actions,
165            aim_direction: Vec3Fix::from_int(ax as i64, ay as i64, az as i64),
166        }
167    }
168}
169
170// ============================================================================
171// Simulation Checksum
172// ============================================================================
173
174/// XOR rolling checksum of the entire physics state.
175///
176/// Computed from position + velocity + rotation of all bodies.
177/// Two simulations with identical inputs will produce identical checksums.
178#[derive(Clone, Copy, Debug, PartialEq, Eq)]
179pub struct SimulationChecksum(pub u64);
180
181impl SimulationChecksum {
182    /// Compute checksum from current world state.
183    ///
184    /// Uses XOR-rotate mixing per body for O(n) computation.
185    /// Avalanche mixing ensures single-bit differences propagate.
186    /// # 被覆は `serialize_state` の blob に従う (単一源化、B-13)
187    ///
188    /// 旧実装は field を手で並べており、**position は `.hi` + `.lo`、velocity と
189    /// rotation は `.hi` だけ、`angular_velocity` は 1 語も**混ぜていなかった
190    /// 208 byte/body のうち **角速度 48 byte + 小数語 56 byte が盲点**で、
191    /// 「角速度が完全に別の 2 世界」「velocity.y が 1 ulp 違う 2 世界」が
192    /// いずれも同一 checksum になる = **desync 検出が false green になりうる**
193    /// (`tests/wm08_checksum_coverage.rs` が red を実測してから置換した)
194    ///
195    /// 検証の道具が状態の被覆より狭いのが原因なので、**blob の全 byte から
196    /// 導出**して被覆の定義を 1 箇所にした ⇒ 以後 `serialize_state` に state を
197    /// 足すと checksum も自動で追従する (盲点が構造的に生まれない)
198    ///
199    /// ⚠️ **checksum の値は旧実装と変わる** 同一 version 同士の比較にのみ使う
200    /// こと (lockstep の peer 間で version を混ぜない)
201    #[must_use]
202    pub fn from_world(world: &PhysicsWorld) -> Self {
203        let blob = world.serialize_state();
204        let mut hash: u64 = 0;
205        for (i, chunk) in blob.chunks(8).enumerate() {
206            let mut word = [0u8; 8];
207            word[..chunk.len()].copy_from_slice(chunk);
208            // index を混ぜる — 混ぜないと 2 つの word の入れ替えが XOR で消える
209            let mut h = u64::from_le_bytes(word) ^ (i as u64).rotate_left(32);
210            // Avalanche (WyHash-style、旧実装と同じ混合)
211            h = (h ^ (h >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
212            h = (h ^ (h >> 27)).wrapping_mul(0x94d049bb133111eb);
213            h ^= h >> 31;
214            hash ^= h;
215        }
216        Self(hash)
217    }
218}
219
220// ============================================================================
221// Simulation Snapshot (for Rollback)
222// ============================================================================
223
224/// Complete physics state snapshot for rollback.
225///
226/// Captures the full deterministic state at a given frame so the simulation
227/// can be rewound and replayed with corrected inputs.
228#[derive(Clone, Debug)]
229pub struct SimulationSnapshot {
230    /// Frame number this snapshot was taken at
231    pub frame: u64,
232    /// Serialized physics world state
233    pub state: Vec<u8>,
234    /// Checksum at this frame
235    pub checksum: SimulationChecksum,
236}
237
238// ============================================================================
239// Netcode Configuration
240// ============================================================================
241
242/// Configuration for deterministic netcode simulation
243#[derive(Clone, Debug)]
244pub struct NetcodeConfig {
245    /// Physics solver configuration
246    pub physics: PhysicsConfig,
247    /// Fixed timestep (default: 1/60 second)
248    pub fixed_dt: Fix128,
249    /// Maximum snapshots to keep for rollback (default: 10)
250    pub max_snapshots: usize,
251    /// Checksum history length (default: 120 frames = 2 seconds)
252    pub checksum_history_len: usize,
253    /// Number of players
254    pub player_count: u8,
255}
256
257impl Default for NetcodeConfig {
258    fn default() -> Self {
259        Self {
260            physics: PhysicsConfig::default(),
261            fixed_dt: Fix128::from_ratio(1, 60),
262            max_snapshots: 10,
263            checksum_history_len: 120,
264            player_count: 2,
265        }
266    }
267}
268
269// ============================================================================
270// Input Applicator (game-specific force mapping)
271// ============================================================================
272
273/// Trait for mapping player inputs to physics forces/impulses.
274///
275/// Implement this to define how your game's inputs affect physics bodies.
276///
277/// ```rust,ignore
278/// struct MyInputApplicator;
279///
280/// impl InputApplicator for MyInputApplicator {
281///     fn apply(&self, world: &mut PhysicsWorld, player_id: u8,
282///              body_index: usize, input: &FrameInput, dt: Fix128) {
283///         let body = &mut world.bodies[body_index];
284///         // Apply movement as velocity
285///         body.velocity.x = body.velocity.x + input.movement.x * dt;
286///         body.velocity.z = body.velocity.z + input.movement.z * dt;
287///         // Jump
288///         if input.has_action(0x1) {
289///             body.velocity.y = Fix128::from_int(5);
290///         }
291///     }
292/// }
293/// ```
294pub trait InputApplicator {
295    /// Apply a player's input to their controlled body.
296    fn apply(
297        &self,
298        world: &mut PhysicsWorld,
299        player_id: u8,
300        body_index: usize,
301        input: &FrameInput,
302        dt: Fix128,
303    );
304}
305
306/// Default input applicator: movement → velocity, action bit 0 → jump.
307pub struct DefaultInputApplicator {
308    /// Movement speed multiplier
309    pub move_speed: Fix128,
310    /// Jump impulse magnitude
311    pub jump_impulse: Fix128,
312}
313
314impl Default for DefaultInputApplicator {
315    fn default() -> Self {
316        Self {
317            move_speed: Fix128::from_int(5),
318            jump_impulse: Fix128::from_int(8),
319        }
320    }
321}
322
323impl InputApplicator for DefaultInputApplicator {
324    fn apply(
325        &self,
326        world: &mut PhysicsWorld,
327        _player_id: u8,
328        body_index: usize,
329        input: &FrameInput,
330        _dt: Fix128,
331    ) {
332        if body_index >= world.bodies.len() {
333            return;
334        }
335        let body = &mut world.bodies[body_index];
336
337        // Movement → velocity (horizontal plane)
338        body.velocity.x = input.movement.x * self.move_speed;
339        body.velocity.z = input.movement.z * self.move_speed;
340
341        // Jump (action bit 0)
342        if input.has_action(0x1) && body.velocity.y.is_zero() {
343            body.velocity.y = self.jump_impulse;
344        }
345    }
346}
347
348// ============================================================================
349// Deterministic Simulation
350// ============================================================================
351
352/// Frame-based deterministic simulation wrapper.
353///
354/// Wraps `PhysicsWorld` with:
355/// - Frame counter
356/// - Input application pipeline
357/// - State checksum computation
358/// - Snapshot save/restore for rollback
359///
360/// # Determinism Guarantee
361///
362/// Given identical `NetcodeConfig`, identical `add_body()` calls, and identical
363/// `advance_frame()` input sequences, two `DeterministicSimulation` instances
364/// will produce bit-exact identical states on any platform.
365pub struct DeterministicSimulation {
366    /// The underlying physics world
367    pub world: PhysicsWorld,
368    /// Current frame number
369    frame: u64,
370    /// Fixed timestep
371    dt: Fix128,
372    /// Player body index mapping: `player_id` → body index
373    player_bodies: Vec<Option<usize>>,
374    /// Snapshot ring buffer for rollback
375    snapshots: VecDeque<SimulationSnapshot>,
376    /// Maximum snapshots to keep
377    max_snapshots: usize,
378    /// Checksum history: (frame, checksum)
379    checksum_history: VecDeque<(u64, SimulationChecksum)>,
380    /// Maximum checksum history length
381    checksum_history_len: usize,
382}
383
384impl DeterministicSimulation {
385    /// Create a new deterministic simulation
386    #[must_use]
387    #[allow(clippy::needless_pass_by_value)] // config.physics is moved (partial consume)
388    pub fn new(config: NetcodeConfig) -> Self {
389        let world = PhysicsWorld::new(config.physics);
390        let player_count = config.player_count as usize;
391
392        Self {
393            world,
394            frame: 0,
395            dt: config.fixed_dt,
396            player_bodies: vec![None; player_count],
397            snapshots: VecDeque::with_capacity(config.max_snapshots + 1),
398            max_snapshots: config.max_snapshots,
399            checksum_history: VecDeque::with_capacity(config.checksum_history_len + 1),
400            checksum_history_len: config.checksum_history_len,
401        }
402    }
403
404    /// Add a rigid body to the simulation.
405    /// Returns the body index.
406    pub fn add_body(&mut self, body: RigidBody) -> usize {
407        self.world.add_body(body)
408    }
409
410    /// Assign a player to control a specific body.
411    pub fn assign_player_body(&mut self, player_id: u8, body_index: usize) {
412        let pid = player_id as usize;
413        if pid >= self.player_bodies.len() {
414            self.player_bodies.resize(pid + 1, None);
415        }
416        self.player_bodies[pid] = Some(body_index);
417    }
418
419    /// Advance one frame with the given player inputs.
420    ///
421    /// 1. Apply inputs to player-controlled bodies
422    /// 2. Step physics
423    /// 3. Compute and record checksum
424    ///
425    /// Returns the checksum for this frame.
426    pub fn advance_frame(&mut self, inputs: &[FrameInput]) -> SimulationChecksum {
427        self.advance_frame_with_applicator(inputs, &DefaultInputApplicator::default())
428    }
429
430    /// Advance one frame with a custom input applicator.
431    pub fn advance_frame_with_applicator(
432        &mut self,
433        inputs: &[FrameInput],
434        applicator: &dyn InputApplicator,
435    ) -> SimulationChecksum {
436        self.frame += 1;
437
438        // 1. Apply player inputs (deterministic order: by player_id)
439        for input in inputs {
440            let pid = input.player_id as usize;
441            if let Some(&Some(body_idx)) = self.player_bodies.get(pid) {
442                applicator.apply(&mut self.world, input.player_id, body_idx, input, self.dt);
443            }
444        }
445
446        // 2. Step physics (fully deterministic)
447        self.world.step(self.dt);
448
449        // 3. Compute checksum
450        let checksum = SimulationChecksum::from_world(&self.world);
451
452        // 4. Record in history
453        self.checksum_history.push_back((self.frame, checksum));
454        if self.checksum_history.len() > self.checksum_history_len {
455            self.checksum_history.pop_front();
456        }
457
458        checksum
459    }
460
461    /// Save a snapshot of the current state for rollback.
462    ///
463    /// # Panics
464    ///
465    /// Panics if the snapshot deque is empty after push (should never happen).
466    pub fn save_snapshot(&mut self) -> &SimulationSnapshot {
467        let state = self.world.serialize_state();
468        let checksum = SimulationChecksum::from_world(&self.world);
469        let snapshot = SimulationSnapshot {
470            frame: self.frame,
471            state,
472            checksum,
473        };
474        self.snapshots.push_back(snapshot);
475        if self.snapshots.len() > self.max_snapshots {
476            self.snapshots.pop_front();
477        }
478        self.snapshots
479            .back()
480            .expect("snapshots should not be empty after push")
481    }
482
483    /// Restore state from a snapshot (for rollback).
484    ///
485    /// Returns `true` if the snapshot was found and loaded. Rotations are
486    /// restored as stored ([`PhysicsWorld::deserialize_state`]) and brought to
487    /// unit length by the next step.
488    pub fn load_snapshot(&mut self, frame: u64) -> bool {
489        if let Some(snap) = self.snapshots.iter().find(|s| s.frame == frame) {
490            let state = snap.state.clone();
491            let snap_frame = snap.frame;
492            if self.world.deserialize_state(&state) {
493                self.frame = snap_frame;
494                // Trim checksum history to rollback point
495                while self.checksum_history.back().is_some_and(|c| c.0 > frame) {
496                    self.checksum_history.pop_back();
497                }
498                return true;
499            }
500        }
501        false
502    }
503
504    /// Get a snapshot for a specific frame (if available).
505    #[must_use]
506    pub fn get_snapshot(&self, frame: u64) -> Option<&SimulationSnapshot> {
507        self.snapshots.iter().find(|s| s.frame == frame)
508    }
509
510    /// Compute the current state checksum.
511    #[must_use]
512    pub fn checksum(&self) -> SimulationChecksum {
513        SimulationChecksum::from_world(&self.world)
514    }
515
516    /// Current frame number.
517    #[inline]
518    #[must_use]
519    pub const fn frame(&self) -> u64 {
520        self.frame
521    }
522
523    /// Get checksum for a specific frame from history.
524    #[must_use]
525    pub fn checksum_at(&self, frame: u64) -> Option<SimulationChecksum> {
526        self.checksum_history
527            .iter()
528            .find(|&&(f, _)| f == frame)
529            .map(|&(_, c)| c)
530    }
531
532    /// Verify a remote checksum against local history.
533    ///
534    /// Returns:
535    /// - `None` if the frame is not in local history (too old or future)
536    /// - `Some(true)` if checksums match (clients in sync)
537    /// - `Some(false)` if checksums differ (DESYNC detected!)
538    #[must_use]
539    pub fn verify_checksum(&self, frame: u64, remote: SimulationChecksum) -> Option<bool> {
540        self.checksum_at(frame).map(|local| local == remote)
541    }
542
543    /// Get the fixed timestep.
544    #[inline]
545    #[must_use]
546    pub const fn dt(&self) -> Fix128 {
547        self.dt
548    }
549
550    /// Number of snapshots currently stored.
551    #[inline]
552    #[must_use]
553    pub fn snapshot_count(&self) -> usize {
554        self.snapshots.len()
555    }
556}
557
558// ============================================================================
559// Tests
560// ============================================================================
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565
566    fn make_sim(player_count: u8) -> DeterministicSimulation {
567        let config = NetcodeConfig {
568            player_count,
569            ..Default::default()
570        };
571        let mut sim = DeterministicSimulation::new(config);
572
573        // Add player bodies
574        for i in 0..player_count {
575            let body = RigidBody::new_dynamic(Vec3Fix::from_int(i as i64 * 5, 10, 0), Fix128::ONE);
576            let idx = sim.add_body(body);
577            sim.assign_player_body(i, idx);
578        }
579
580        // Add ground
581        sim.add_body(RigidBody::new_static(Vec3Fix::ZERO));
582
583        sim
584    }
585
586    #[test]
587    fn test_deterministic_two_sims() {
588        // Two independent simulations with identical inputs → bit-exact match
589        let mut sim_a = make_sim(2);
590        let mut sim_b = make_sim(2);
591
592        let inputs = [
593            FrameInput::new(0).with_movement(Vec3Fix::from_int(1, 0, 0)),
594            FrameInput::new(1).with_movement(Vec3Fix::from_int(0, 0, -1)),
595        ];
596
597        for _ in 0..60 {
598            let cs_a = sim_a.advance_frame(&inputs);
599            let cs_b = sim_b.advance_frame(&inputs);
600            assert_eq!(cs_a, cs_b, "Desync at frame {}", sim_a.frame());
601        }
602
603        // Verify body positions are bit-exact
604        for i in 0..sim_a.world.bodies.len() {
605            let a = &sim_a.world.bodies[i];
606            let b = &sim_b.world.bodies[i];
607            assert_eq!(a.position.x.hi, b.position.x.hi);
608            assert_eq!(a.position.x.lo, b.position.x.lo);
609            assert_eq!(a.position.y.hi, b.position.y.hi);
610            assert_eq!(a.position.y.lo, b.position.y.lo);
611            assert_eq!(a.position.z.hi, b.position.z.hi);
612            assert_eq!(a.position.z.lo, b.position.z.lo);
613        }
614    }
615
616    #[test]
617    fn test_snapshot_rollback() {
618        let mut sim = make_sim(1);
619        let input = [FrameInput::new(0).with_movement(Vec3Fix::from_int(1, 0, 0))];
620
621        // Advance 30 frames
622        for _ in 0..30 {
623            sim.advance_frame(&input);
624        }
625        sim.save_snapshot();
626        let checksum_at_30 = sim.checksum();
627
628        // Advance 30 more frames
629        for _ in 0..30 {
630            sim.advance_frame(&input);
631        }
632        let pos_at_60 = sim.world.bodies[0].position;
633
634        // Rollback to frame 30
635        assert!(sim.load_snapshot(30));
636        assert_eq!(sim.frame(), 30);
637        assert_eq!(sim.checksum(), checksum_at_30);
638
639        // Replay same inputs → same result
640        for _ in 0..30 {
641            sim.advance_frame(&input);
642        }
643        let pos_replayed = sim.world.bodies[0].position;
644
645        assert_eq!(pos_at_60.x.hi, pos_replayed.x.hi);
646        assert_eq!(pos_at_60.y.hi, pos_replayed.y.hi);
647        assert_eq!(pos_at_60.z.hi, pos_replayed.z.hi);
648    }
649
650    #[test]
651    fn test_checksum_verify() {
652        let mut sim = make_sim(1);
653        let input = [FrameInput::new(0)];
654
655        sim.advance_frame(&input);
656        let cs1 = sim.checksum();
657
658        // Matching checksum
659        assert_eq!(sim.verify_checksum(1, cs1), Some(true));
660
661        // Mismatching checksum
662        assert_eq!(
663            sim.verify_checksum(1, SimulationChecksum(0xDEADBEEF)),
664            Some(false),
665        );
666
667        // Unknown frame
668        assert_eq!(sim.verify_checksum(999, cs1), None);
669    }
670
671    #[test]
672    fn test_frame_input_serialization() {
673        let input = FrameInput::new(3)
674            .with_movement(Vec3Fix::from_int(1, 0, -1))
675            .with_actions(0x05)
676            .with_aim(Vec3Fix::from_int(0, 1, 0));
677
678        let bytes = input.to_bytes();
679        let restored = FrameInput::from_bytes(&bytes);
680
681        assert_eq!(restored.player_id, 3);
682        assert_eq!(restored.actions, 0x05);
683        assert_eq!(restored.movement.x.hi, 1);
684        assert_eq!(restored.movement.z.hi, -1);
685        assert_eq!(restored.aim_direction.y.hi, 1);
686    }
687
688    #[test]
689    fn test_different_inputs_different_checksums() {
690        let mut sim_a = make_sim(1);
691        let mut sim_b = make_sim(1);
692
693        // Different inputs → different states
694        let input_a = [FrameInput::new(0).with_movement(Vec3Fix::from_int(1, 0, 0))];
695        let input_b = [FrameInput::new(0).with_movement(Vec3Fix::from_int(-1, 0, 0))];
696
697        for _ in 0..10 {
698            sim_a.advance_frame(&input_a);
699            sim_b.advance_frame(&input_b);
700        }
701
702        assert_ne!(sim_a.checksum(), sim_b.checksum());
703    }
704
705    #[test]
706    fn test_empty_inputs() {
707        let mut sim = make_sim(2);
708
709        // No inputs → simulation still advances (gravity, etc.)
710        let cs1 = sim.advance_frame(&[]);
711        let cs2 = sim.advance_frame(&[]);
712
713        // Checksums should differ (bodies falling under gravity)
714        assert_ne!(cs1, cs2);
715    }
716
717    #[test]
718    fn test_multiple_snapshots() {
719        let mut sim = make_sim(1);
720        let input = [FrameInput::new(0)];
721
722        // Save snapshots at frames 10, 20, 30
723        for f in 0..30 {
724            sim.advance_frame(&input);
725            if (f + 1) % 10 == 0 {
726                sim.save_snapshot();
727            }
728        }
729
730        assert_eq!(sim.snapshot_count(), 3);
731        assert!(sim.get_snapshot(10).is_some());
732        assert!(sim.get_snapshot(20).is_some());
733        assert!(sim.get_snapshot(30).is_some());
734        assert!(sim.get_snapshot(15).is_none());
735
736        // Rollback to frame 20
737        assert!(sim.load_snapshot(20));
738        assert_eq!(sim.frame(), 20);
739    }
740}