Skip to main content

alice_physics/
fluid_netcode.rs

1//! Deterministic Fluid Netcode
2//!
3//! Extends the netcode system for deterministic fluid simulation.
4//! Uses Fix128 fluid state serialization for rollback and lockstep sync.
5//!
6//! # Design
7//!
8//! Fluid state is large (N particles * position+velocity), so this module
9//! provides:
10//! - Delta compression (only send changed particles)
11//! - Checksum validation (detect desync early)
12//! - Snapshot/restore for rollback
13//!
14//! Author: Moroya Sakamoto
15
16use crate::math::{Fix128, Vec3Fix};
17
18#[cfg(not(feature = "std"))]
19use alloc::vec::Vec;
20
21// ============================================================================
22// Fluid State Snapshot
23// ============================================================================
24
25/// Serialized fluid state for netcode
26#[derive(Clone, Debug)]
27pub struct FluidSnapshot {
28    /// Number of particles
29    pub particle_count: u32,
30    /// Serialized positions (N * 3 * 16 bytes)
31    pub positions: Vec<u8>,
32    /// Serialized velocities (N * 3 * 16 bytes)
33    pub velocities: Vec<u8>,
34    /// State checksum for desync detection: FNV-1a 64 over the positions'
35    /// byte length (u64 little-endian) and bytes, then the velocities' length
36    /// and bytes
37    pub checksum: u64,
38    /// Frame number
39    pub frame: u64,
40}
41
42impl FluidSnapshot {
43    /// Create snapshot from fluid particle data
44    ///
45    /// `positions` and `velocities` must describe the same particle set and
46    /// so have the same length: `particle_count` is one count for both
47    /// arrays, and [`FluidSnapshot::restore`] deserializes that many of
48    /// each. Accepting a mismatch meant taking `positions.len()` as the
49    /// count for both, so a longer velocity array had its surplus silently
50    /// dropped on restore — data loss with no error, while
51    /// [`FluidSnapshot::verify`] still reported a match because it compares
52    /// checksums over the bytes rather than reading `particle_count`.
53    ///
54    /// # Panics
55    ///
56    /// Panics if the two slices have mismatched lengths.
57    #[must_use]
58    pub fn capture(positions: &[Vec3Fix], velocities: &[Vec3Fix], frame: u64) -> Self {
59        assert_eq!(positions.len(), velocities.len(), "length mismatch");
60        let n = positions.len();
61        let pos_data = serialize_vec3_array(positions);
62        let vel_data = serialize_vec3_array(velocities);
63
64        let checksum = compute_checksum(&pos_data, &vel_data);
65
66        Self {
67            particle_count: n as u32,
68            positions: pos_data,
69            velocities: vel_data,
70            checksum,
71            frame,
72        }
73    }
74
75    /// Restore fluid state from snapshot
76    #[must_use]
77    pub fn restore(&self) -> Option<(Vec<Vec3Fix>, Vec<Vec3Fix>)> {
78        let n = self.particle_count as usize;
79        let positions = deserialize_vec3_array(&self.positions, n)?;
80        let velocities = deserialize_vec3_array(&self.velocities, n)?;
81        Some((positions, velocities))
82    }
83
84    /// Verify checksum matches current data
85    #[must_use]
86    pub fn verify(&self, positions: &[Vec3Fix], velocities: &[Vec3Fix]) -> bool {
87        let pos_data = serialize_vec3_array(positions);
88        let vel_data = serialize_vec3_array(velocities);
89        let checksum = compute_checksum(&pos_data, &vel_data);
90        checksum == self.checksum
91    }
92
93    /// Total serialized size in bytes
94    #[must_use]
95    pub fn size_bytes(&self) -> usize {
96        4 + self.positions.len() + self.velocities.len() + 8 + 8
97    }
98}
99
100// ============================================================================
101// Delta Compression
102// ============================================================================
103
104/// Delta-compressed fluid state update
105///
106/// Only contains particles that have changed significantly
107/// since the last snapshot.
108#[derive(Clone, Debug)]
109pub struct FluidDelta {
110    /// Frame number
111    pub frame: u64,
112    /// Base frame this delta is relative to
113    pub base_frame: u64,
114    /// Changed particle indices
115    pub changed_indices: Vec<u32>,
116    /// New positions for changed particles
117    pub positions: Vec<Vec3Fix>,
118    /// New velocities for changed particles
119    pub velocities: Vec<Vec3Fix>,
120    /// Full state checksum (for validation)
121    pub checksum: u64,
122}
123
124impl FluidDelta {
125    /// Compute delta between two states
126    #[must_use]
127    pub fn compute(
128        old_positions: &[Vec3Fix],
129        old_velocities: &[Vec3Fix],
130        new_positions: &[Vec3Fix],
131        new_velocities: &[Vec3Fix],
132        threshold: Fix128,
133        base_frame: u64,
134        new_frame: u64,
135    ) -> Self {
136        let mut changed_indices = Vec::new();
137        let mut positions = Vec::new();
138        let mut velocities = Vec::new();
139
140        // Per-component comparison against the threshold, never squared:
141        // a component change below 2⁻³² squares to exactly 0 in Fix128, so
142        // `length_squared() > τ²` dropped 1-ulp changes even at τ = 0 and the
143        // receiver silently diverged from the sender (1.2.0). With τ = 0 any
144        // bit difference counts; with τ > 0 the per-particle error after
145        // `apply` is bounded by τ per component.
146        let exceeds =
147            |d: Vec3Fix| d.x.abs() > threshold || d.y.abs() > threshold || d.z.abs() > threshold;
148
149        // The common prefix of all four slices, not just the two position
150        // ones: indexing `new_velocities`/`old_velocities` with a bound
151        // taken from the position arrays alone panics (index out of bounds)
152        // the moment a velocity slice is the shorter one.
153        let n = new_positions
154            .len()
155            .min(old_positions.len())
156            .min(new_velocities.len())
157            .min(old_velocities.len());
158        for i in 0..n {
159            let pos_diff = new_positions[i] - old_positions[i];
160            let vel_diff = new_velocities[i] - old_velocities[i];
161
162            if exceeds(pos_diff) || exceeds(vel_diff) {
163                changed_indices.push(i as u32);
164                positions.push(new_positions[i]);
165                velocities.push(new_velocities[i]);
166            }
167        }
168
169        let pos_data = serialize_vec3_array(new_positions);
170        let vel_data = serialize_vec3_array(new_velocities);
171        let checksum = compute_checksum(&pos_data, &vel_data);
172
173        Self {
174            frame: new_frame,
175            base_frame,
176            changed_indices,
177            positions,
178            velocities,
179            checksum,
180        }
181    }
182
183    /// Apply delta to base state
184    ///
185    /// Both base slices are bounds-checked before either one is written: a
186    /// `base_velocities` shorter than `base_positions` used to panic on the
187    /// velocity write after `base_positions[i]` had already been
188    /// overwritten, leaving the pair in a half-updated state for whichever
189    /// particle triggered it.
190    pub fn apply(&self, base_positions: &mut [Vec3Fix], base_velocities: &mut [Vec3Fix]) {
191        for (idx, &particle_idx) in self.changed_indices.iter().enumerate() {
192            let i = particle_idx as usize;
193            if i < base_positions.len() && i < base_velocities.len() {
194                base_positions[i] = self.positions[idx];
195                base_velocities[i] = self.velocities[idx];
196            }
197        }
198    }
199
200    /// Number of changed particles
201    #[must_use]
202    pub fn changed_count(&self) -> usize {
203        self.changed_indices.len()
204    }
205
206    /// Compression ratio (1.0 = no compression, 0.0 = perfect)
207    #[must_use]
208    pub fn compression_ratio(&self, total_particles: usize) -> f32 {
209        if total_particles == 0 {
210            return 1.0;
211        }
212        self.changed_indices.len() as f32 / total_particles as f32
213    }
214}
215
216// ============================================================================
217// Serialization Helpers
218// ============================================================================
219
220fn serialize_vec3_array(vecs: &[Vec3Fix]) -> Vec<u8> {
221    let mut data = Vec::with_capacity(vecs.len() * 48); // 3 components * 16 bytes each
222
223    for v in vecs {
224        for component in &[v.x, v.y, v.z] {
225            data.extend_from_slice(&component.hi.to_le_bytes());
226            data.extend_from_slice(&component.lo.to_le_bytes());
227        }
228    }
229
230    data
231}
232
233fn deserialize_vec3_array(data: &[u8], count: usize) -> Option<Vec<Vec3Fix>> {
234    let expected = count * 48;
235    if data.len() < expected {
236        return None;
237    }
238
239    let mut vecs = Vec::with_capacity(count);
240    let mut offset = 0;
241
242    for _ in 0..count {
243        let mut components = [Fix128::ZERO; 3];
244        for c in &mut components {
245            if offset + 16 > data.len() {
246                return None;
247            }
248            let hi = i64::from_le_bytes([
249                data[offset],
250                data[offset + 1],
251                data[offset + 2],
252                data[offset + 3],
253                data[offset + 4],
254                data[offset + 5],
255                data[offset + 6],
256                data[offset + 7],
257            ]);
258            offset += 8;
259            let lo = u64::from_le_bytes([
260                data[offset],
261                data[offset + 1],
262                data[offset + 2],
263                data[offset + 3],
264                data[offset + 4],
265                data[offset + 5],
266                data[offset + 6],
267                data[offset + 7],
268            ]);
269            offset += 8;
270            *c = Fix128 { hi, lo };
271        }
272        vecs.push(Vec3Fix::new(components[0], components[1], components[2]));
273    }
274
275    Some(vecs)
276}
277
278/// FNV-1a 64-bit hash for checksum, over each array's byte length (u64,
279/// little-endian) followed by its bytes: positions, then velocities. The
280/// lengths make the split part of the hash, so the same bytes divided
281/// differently between the two arrays do not collide.
282fn compute_checksum(pos_data: &[u8], vel_data: &[u8]) -> u64 {
283    let pos_len = (pos_data.len() as u64).to_le_bytes();
284    let vel_len = (vel_data.len() as u64).to_le_bytes();
285    let mut hash: u64 = 0xcbf29ce484222325;
286    for &byte in pos_len
287        .iter()
288        .chain(pos_data)
289        .chain(vel_len.iter())
290        .chain(vel_data)
291    {
292        hash ^= byte as u64;
293        hash = hash.wrapping_mul(0x100000001b3);
294    }
295    hash
296}
297
298// ============================================================================
299// Tests
300// ============================================================================
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_snapshot_roundtrip() {
308        let positions = vec![Vec3Fix::from_int(1, 2, 3), Vec3Fix::from_int(4, 5, 6)];
309        let velocities = vec![
310            Vec3Fix::from_f32(0.1, 0.2, 0.3),
311            Vec3Fix::from_f32(0.4, 0.5, 0.6),
312        ];
313
314        let snapshot = FluidSnapshot::capture(&positions, &velocities, 42);
315        let (restored_pos, restored_vel) = snapshot.restore().unwrap();
316
317        assert_eq!(restored_pos.len(), 2);
318        assert_eq!(restored_vel.len(), 2);
319        assert_eq!(restored_pos[0].x.hi, 1);
320        assert_eq!(restored_pos[1].y.hi, 5);
321    }
322
323    #[test]
324    fn test_snapshot_verify() {
325        let positions = vec![Vec3Fix::from_int(1, 2, 3)];
326        let velocities = vec![Vec3Fix::ZERO];
327
328        let snapshot = FluidSnapshot::capture(&positions, &velocities, 0);
329        assert!(snapshot.verify(&positions, &velocities));
330
331        // Modified data should fail verification
332        let modified = vec![Vec3Fix::from_int(99, 99, 99)];
333        assert!(!snapshot.verify(&modified, &velocities));
334    }
335
336    #[test]
337    fn test_delta_compression() {
338        let old_pos = vec![
339            Vec3Fix::from_int(0, 0, 0),
340            Vec3Fix::from_int(1, 0, 0),
341            Vec3Fix::from_int(2, 0, 0),
342        ];
343        let old_vel = vec![Vec3Fix::ZERO; 3];
344
345        // Only move particle 1
346        let mut new_pos = old_pos.clone();
347        new_pos[1] = Vec3Fix::from_int(1, 5, 0);
348        let new_vel = old_vel.clone();
349
350        let delta = FluidDelta::compute(
351            &old_pos,
352            &old_vel,
353            &new_pos,
354            &new_vel,
355            Fix128::from_ratio(1, 10),
356            0,
357            1,
358        );
359
360        assert_eq!(delta.changed_count(), 1, "Only one particle changed");
361        assert!(
362            delta.compression_ratio(3) < 0.5,
363            "Should be well compressed"
364        );
365    }
366
367    #[test]
368    fn test_delta_apply() {
369        let old_pos = vec![Vec3Fix::ZERO; 3];
370        let old_vel = vec![Vec3Fix::ZERO; 3];
371
372        let new_pos = vec![Vec3Fix::ZERO, Vec3Fix::from_int(5, 5, 5), Vec3Fix::ZERO];
373        let new_vel = old_vel.clone();
374
375        let delta = FluidDelta::compute(
376            &old_pos,
377            &old_vel,
378            &new_pos,
379            &new_vel,
380            Fix128::from_ratio(1, 10),
381            0,
382            1,
383        );
384
385        let mut base_pos = old_pos;
386        let mut base_vel = old_vel;
387        delta.apply(&mut base_pos, &mut base_vel);
388
389        assert_eq!(base_pos[1].x.hi, 5, "Delta should update changed particle");
390        assert!(base_pos[0].x.is_zero(), "Unchanged particle should remain");
391    }
392}