alice-physics 3.0.1

Deterministic physics engine: 128-bit fixed-point rigid bodies with bit-exact, replayable results, typed observation and rollback for search and planning, plus FEM, CFD and 3D-print analysis modules
Documentation
//! Deterministic Fluid Netcode
//!
//! Extends the netcode system for deterministic fluid simulation.
//! Uses Fix128 fluid state serialization for rollback and lockstep sync.
//!
//! # Design
//!
//! Fluid state is large (N particles * position+velocity), so this module
//! provides:
//! - Delta compression (only send changed particles)
//! - Checksum validation (detect desync early)
//! - Snapshot/restore for rollback
//!
//! Author: Moroya Sakamoto

use crate::math::{Fix128, Vec3Fix};

#[cfg(not(feature = "std"))]
use alloc::vec::Vec;

// ============================================================================
// Fluid State Snapshot
// ============================================================================

/// Serialized fluid state for netcode
#[derive(Clone, Debug)]
pub struct FluidSnapshot {
    /// Number of particles
    pub particle_count: u32,
    /// Serialized positions (N * 3 * 16 bytes)
    pub positions: Vec<u8>,
    /// Serialized velocities (N * 3 * 16 bytes)
    pub velocities: Vec<u8>,
    /// State checksum for desync detection: FNV-1a 64 over the positions'
    /// byte length (u64 little-endian) and bytes, then the velocities' length
    /// and bytes
    pub checksum: u64,
    /// Frame number
    pub frame: u64,
}

impl FluidSnapshot {
    /// Create snapshot from fluid particle data
    ///
    /// `positions` and `velocities` must describe the same particle set and
    /// so have the same length: `particle_count` is one count for both
    /// arrays, and [`FluidSnapshot::restore`] deserializes that many of
    /// each. Accepting a mismatch meant taking `positions.len()` as the
    /// count for both, so a longer velocity array had its surplus silently
    /// dropped on restore — data loss with no error, while
    /// [`FluidSnapshot::verify`] still reported a match because it compares
    /// checksums over the bytes rather than reading `particle_count`.
    ///
    /// # Panics
    ///
    /// Panics if the two slices have mismatched lengths.
    #[must_use]
    pub fn capture(positions: &[Vec3Fix], velocities: &[Vec3Fix], frame: u64) -> Self {
        assert_eq!(positions.len(), velocities.len(), "length mismatch");
        let n = positions.len();
        let pos_data = serialize_vec3_array(positions);
        let vel_data = serialize_vec3_array(velocities);

        let checksum = compute_checksum(&pos_data, &vel_data);

        Self {
            particle_count: n as u32,
            positions: pos_data,
            velocities: vel_data,
            checksum,
            frame,
        }
    }

    /// Restore fluid state from snapshot
    #[must_use]
    pub fn restore(&self) -> Option<(Vec<Vec3Fix>, Vec<Vec3Fix>)> {
        let n = self.particle_count as usize;
        let positions = deserialize_vec3_array(&self.positions, n)?;
        let velocities = deserialize_vec3_array(&self.velocities, n)?;
        Some((positions, velocities))
    }

    /// Verify checksum matches current data
    #[must_use]
    pub fn verify(&self, positions: &[Vec3Fix], velocities: &[Vec3Fix]) -> bool {
        let pos_data = serialize_vec3_array(positions);
        let vel_data = serialize_vec3_array(velocities);
        let checksum = compute_checksum(&pos_data, &vel_data);
        checksum == self.checksum
    }

    /// Total serialized size in bytes
    #[must_use]
    pub fn size_bytes(&self) -> usize {
        4 + self.positions.len() + self.velocities.len() + 8 + 8
    }
}

// ============================================================================
// Delta Compression
// ============================================================================

/// Delta-compressed fluid state update
///
/// Only contains particles that have changed significantly
/// since the last snapshot.
#[derive(Clone, Debug)]
pub struct FluidDelta {
    /// Frame number
    pub frame: u64,
    /// Base frame this delta is relative to
    pub base_frame: u64,
    /// Changed particle indices
    pub changed_indices: Vec<u32>,
    /// New positions for changed particles
    pub positions: Vec<Vec3Fix>,
    /// New velocities for changed particles
    pub velocities: Vec<Vec3Fix>,
    /// Full state checksum (for validation)
    pub checksum: u64,
}

impl FluidDelta {
    /// Compute delta between two states
    #[must_use]
    pub fn compute(
        old_positions: &[Vec3Fix],
        old_velocities: &[Vec3Fix],
        new_positions: &[Vec3Fix],
        new_velocities: &[Vec3Fix],
        threshold: Fix128,
        base_frame: u64,
        new_frame: u64,
    ) -> Self {
        let mut changed_indices = Vec::new();
        let mut positions = Vec::new();
        let mut velocities = Vec::new();

        // Per-component comparison against the threshold, never squared:
        // a component change below 2⁻³² squares to exactly 0 in Fix128, so
        // `length_squared() > τ²` dropped 1-ulp changes even at τ = 0 and the
        // receiver silently diverged from the sender (1.2.0). With τ = 0 any
        // bit difference counts; with τ > 0 the per-particle error after
        // `apply` is bounded by τ per component.
        let exceeds =
            |d: Vec3Fix| d.x.abs() > threshold || d.y.abs() > threshold || d.z.abs() > threshold;

        // The common prefix of all four slices, not just the two position
        // ones: indexing `new_velocities`/`old_velocities` with a bound
        // taken from the position arrays alone panics (index out of bounds)
        // the moment a velocity slice is the shorter one.
        let n = new_positions
            .len()
            .min(old_positions.len())
            .min(new_velocities.len())
            .min(old_velocities.len());
        for i in 0..n {
            let pos_diff = new_positions[i] - old_positions[i];
            let vel_diff = new_velocities[i] - old_velocities[i];

            if exceeds(pos_diff) || exceeds(vel_diff) {
                changed_indices.push(i as u32);
                positions.push(new_positions[i]);
                velocities.push(new_velocities[i]);
            }
        }

        let pos_data = serialize_vec3_array(new_positions);
        let vel_data = serialize_vec3_array(new_velocities);
        let checksum = compute_checksum(&pos_data, &vel_data);

        Self {
            frame: new_frame,
            base_frame,
            changed_indices,
            positions,
            velocities,
            checksum,
        }
    }

    /// Apply delta to base state
    ///
    /// Both base slices are bounds-checked before either one is written: a
    /// `base_velocities` shorter than `base_positions` used to panic on the
    /// velocity write after `base_positions[i]` had already been
    /// overwritten, leaving the pair in a half-updated state for whichever
    /// particle triggered it.
    pub fn apply(&self, base_positions: &mut [Vec3Fix], base_velocities: &mut [Vec3Fix]) {
        for (idx, &particle_idx) in self.changed_indices.iter().enumerate() {
            let i = particle_idx as usize;
            if i < base_positions.len() && i < base_velocities.len() {
                base_positions[i] = self.positions[idx];
                base_velocities[i] = self.velocities[idx];
            }
        }
    }

    /// Number of changed particles
    #[must_use]
    pub fn changed_count(&self) -> usize {
        self.changed_indices.len()
    }

    /// Compression ratio (1.0 = no compression, 0.0 = perfect)
    #[must_use]
    pub fn compression_ratio(&self, total_particles: usize) -> f32 {
        if total_particles == 0 {
            return 1.0;
        }
        self.changed_indices.len() as f32 / total_particles as f32
    }
}

// ============================================================================
// Serialization Helpers
// ============================================================================

fn serialize_vec3_array(vecs: &[Vec3Fix]) -> Vec<u8> {
    let mut data = Vec::with_capacity(vecs.len() * 48); // 3 components * 16 bytes each

    for v in vecs {
        for component in &[v.x, v.y, v.z] {
            data.extend_from_slice(&component.hi.to_le_bytes());
            data.extend_from_slice(&component.lo.to_le_bytes());
        }
    }

    data
}

fn deserialize_vec3_array(data: &[u8], count: usize) -> Option<Vec<Vec3Fix>> {
    let expected = count * 48;
    if data.len() < expected {
        return None;
    }

    let mut vecs = Vec::with_capacity(count);
    let mut offset = 0;

    for _ in 0..count {
        let mut components = [Fix128::ZERO; 3];
        for c in &mut components {
            if offset + 16 > data.len() {
                return None;
            }
            let hi = i64::from_le_bytes([
                data[offset],
                data[offset + 1],
                data[offset + 2],
                data[offset + 3],
                data[offset + 4],
                data[offset + 5],
                data[offset + 6],
                data[offset + 7],
            ]);
            offset += 8;
            let lo = u64::from_le_bytes([
                data[offset],
                data[offset + 1],
                data[offset + 2],
                data[offset + 3],
                data[offset + 4],
                data[offset + 5],
                data[offset + 6],
                data[offset + 7],
            ]);
            offset += 8;
            *c = Fix128 { hi, lo };
        }
        vecs.push(Vec3Fix::new(components[0], components[1], components[2]));
    }

    Some(vecs)
}

/// FNV-1a 64-bit hash for checksum, over each array's byte length (u64,
/// little-endian) followed by its bytes: positions, then velocities. The
/// lengths make the split part of the hash, so the same bytes divided
/// differently between the two arrays do not collide.
fn compute_checksum(pos_data: &[u8], vel_data: &[u8]) -> u64 {
    let pos_len = (pos_data.len() as u64).to_le_bytes();
    let vel_len = (vel_data.len() as u64).to_le_bytes();
    let mut hash: u64 = 0xcbf29ce484222325;
    for &byte in pos_len
        .iter()
        .chain(pos_data)
        .chain(vel_len.iter())
        .chain(vel_data)
    {
        hash ^= byte as u64;
        hash = hash.wrapping_mul(0x100000001b3);
    }
    hash
}

// ============================================================================
// Tests
// ============================================================================

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_snapshot_roundtrip() {
        let positions = vec![Vec3Fix::from_int(1, 2, 3), Vec3Fix::from_int(4, 5, 6)];
        let velocities = vec![
            Vec3Fix::from_f32(0.1, 0.2, 0.3),
            Vec3Fix::from_f32(0.4, 0.5, 0.6),
        ];

        let snapshot = FluidSnapshot::capture(&positions, &velocities, 42);
        let (restored_pos, restored_vel) = snapshot.restore().unwrap();

        assert_eq!(restored_pos.len(), 2);
        assert_eq!(restored_vel.len(), 2);
        assert_eq!(restored_pos[0].x.hi, 1);
        assert_eq!(restored_pos[1].y.hi, 5);
    }

    #[test]
    fn test_snapshot_verify() {
        let positions = vec![Vec3Fix::from_int(1, 2, 3)];
        let velocities = vec![Vec3Fix::ZERO];

        let snapshot = FluidSnapshot::capture(&positions, &velocities, 0);
        assert!(snapshot.verify(&positions, &velocities));

        // Modified data should fail verification
        let modified = vec![Vec3Fix::from_int(99, 99, 99)];
        assert!(!snapshot.verify(&modified, &velocities));
    }

    #[test]
    fn test_delta_compression() {
        let old_pos = vec![
            Vec3Fix::from_int(0, 0, 0),
            Vec3Fix::from_int(1, 0, 0),
            Vec3Fix::from_int(2, 0, 0),
        ];
        let old_vel = vec![Vec3Fix::ZERO; 3];

        // Only move particle 1
        let mut new_pos = old_pos.clone();
        new_pos[1] = Vec3Fix::from_int(1, 5, 0);
        let new_vel = old_vel.clone();

        let delta = FluidDelta::compute(
            &old_pos,
            &old_vel,
            &new_pos,
            &new_vel,
            Fix128::from_ratio(1, 10),
            0,
            1,
        );

        assert_eq!(delta.changed_count(), 1, "Only one particle changed");
        assert!(
            delta.compression_ratio(3) < 0.5,
            "Should be well compressed"
        );
    }

    #[test]
    fn test_delta_apply() {
        let old_pos = vec![Vec3Fix::ZERO; 3];
        let old_vel = vec![Vec3Fix::ZERO; 3];

        let new_pos = vec![Vec3Fix::ZERO, Vec3Fix::from_int(5, 5, 5), Vec3Fix::ZERO];
        let new_vel = old_vel.clone();

        let delta = FluidDelta::compute(
            &old_pos,
            &old_vel,
            &new_pos,
            &new_vel,
            Fix128::from_ratio(1, 10),
            0,
            1,
        );

        let mut base_pos = old_pos;
        let mut base_vel = old_vel;
        delta.apply(&mut base_pos, &mut base_vel);

        assert_eq!(base_pos[1].x.hi, 5, "Delta should update changed particle");
        assert!(base_pos[0].x.is_zero(), "Unchanged particle should remain");
    }
}