Files
game_engine/crates/voltex_net/src/snapshot.rs
tolelom 0ef750de69 feat(net): add reliability layer, state sync, and client interpolation
- ReliableChannel: sequence numbers, ACK, retransmission, RTT estimation
- OrderedChannel: in-order delivery with out-of-order buffering
- Snapshot serialization with delta compression (per-field bitmask)
- InterpolationBuffer: linear interpolation between server snapshots
- New packet types: Reliable, Ack, Snapshot, SnapshotDelta

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:03:52 +09:00

379 lines
12 KiB
Rust

/// State of a single entity at a point in time.
#[derive(Debug, Clone, PartialEq)]
pub struct EntityState {
pub id: u32,
pub position: [f32; 3],
pub rotation: [f32; 3],
pub velocity: [f32; 3],
}
/// A snapshot of the world at a given tick.
#[derive(Debug, Clone, PartialEq)]
pub struct Snapshot {
pub tick: u32,
pub entities: Vec<EntityState>,
}
/// Binary size of one entity: id(4) + pos(12) + rot(12) + vel(12) = 40 bytes
const ENTITY_SIZE: usize = 4 + 12 + 12 + 12;
fn write_f32_le(buf: &mut Vec<u8>, v: f32) {
buf.extend_from_slice(&v.to_le_bytes());
}
fn read_f32_le(data: &[u8], offset: usize) -> f32 {
f32::from_le_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]])
}
fn write_f32x3(buf: &mut Vec<u8>, v: &[f32; 3]) {
write_f32_le(buf, v[0]);
write_f32_le(buf, v[1]);
write_f32_le(buf, v[2]);
}
fn read_f32x3(data: &[u8], offset: usize) -> [f32; 3] {
[
read_f32_le(data, offset),
read_f32_le(data, offset + 4),
read_f32_le(data, offset + 8),
]
}
fn serialize_entity(buf: &mut Vec<u8>, e: &EntityState) {
buf.extend_from_slice(&e.id.to_le_bytes());
write_f32x3(buf, &e.position);
write_f32x3(buf, &e.rotation);
write_f32x3(buf, &e.velocity);
}
fn deserialize_entity(data: &[u8], offset: usize) -> EntityState {
let id = u32::from_le_bytes([
data[offset], data[offset + 1], data[offset + 2], data[offset + 3],
]);
let position = read_f32x3(data, offset + 4);
let rotation = read_f32x3(data, offset + 16);
let velocity = read_f32x3(data, offset + 28);
EntityState { id, position, rotation, velocity }
}
/// Serialize a snapshot into compact binary format.
/// Layout: tick(4 LE) + entity_count(4 LE) + entities...
pub fn serialize_snapshot(snapshot: &Snapshot) -> Vec<u8> {
let count = snapshot.entities.len() as u32;
let mut buf = Vec::with_capacity(8 + ENTITY_SIZE * snapshot.entities.len());
buf.extend_from_slice(&snapshot.tick.to_le_bytes());
buf.extend_from_slice(&count.to_le_bytes());
for e in &snapshot.entities {
serialize_entity(&mut buf, e);
}
buf
}
/// Deserialize a snapshot from binary data.
pub fn deserialize_snapshot(data: &[u8]) -> Result<Snapshot, String> {
if data.len() < 8 {
return Err("Snapshot data too short for header".to_string());
}
let tick = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
let count = u32::from_le_bytes([data[4], data[5], data[6], data[7]]) as usize;
let expected = 8 + count * ENTITY_SIZE;
if data.len() < expected {
return Err(format!(
"Snapshot data too short: expected {} bytes, got {}",
expected,
data.len()
));
}
let mut entities = Vec::with_capacity(count);
for i in 0..count {
entities.push(deserialize_entity(data, 8 + i * ENTITY_SIZE));
}
Ok(Snapshot { tick, entities })
}
/// Compute a delta between two snapshots.
/// Format: new_tick(4) + count(4) + [id(4) + flags(1) + changed_fields...]
/// Flags bitmask: 0x01 = position, 0x02 = rotation, 0x04 = velocity, 0x80 = new entity (full)
pub fn diff_snapshots(old: &Snapshot, new: &Snapshot) -> Vec<u8> {
use std::collections::HashMap;
let old_map: HashMap<u32, &EntityState> = old.entities.iter().map(|e| (e.id, e)).collect();
let mut entries: Vec<u8> = Vec::new();
let mut count: u32 = 0;
for new_ent in &new.entities {
if let Some(old_ent) = old_map.get(&new_ent.id) {
let mut flags: u8 = 0;
let mut fields = Vec::new();
if new_ent.position != old_ent.position {
flags |= 0x01;
write_f32x3(&mut fields, &new_ent.position);
}
if new_ent.rotation != old_ent.rotation {
flags |= 0x02;
write_f32x3(&mut fields, &new_ent.rotation);
}
if new_ent.velocity != old_ent.velocity {
flags |= 0x04;
write_f32x3(&mut fields, &new_ent.velocity);
}
if flags != 0 {
entries.extend_from_slice(&new_ent.id.to_le_bytes());
entries.push(flags);
entries.extend_from_slice(&fields);
count += 1;
}
} else {
// New entity — send full state
entries.extend_from_slice(&new_ent.id.to_le_bytes());
entries.push(0x80); // "new entity" flag
write_f32x3(&mut entries, &new_ent.position);
write_f32x3(&mut entries, &new_ent.rotation);
write_f32x3(&mut entries, &new_ent.velocity);
count += 1;
}
}
let mut buf = Vec::with_capacity(8 + entries.len());
buf.extend_from_slice(&new.tick.to_le_bytes());
buf.extend_from_slice(&count.to_le_bytes());
buf.extend_from_slice(&entries);
buf
}
/// Apply a delta to a base snapshot to produce an updated snapshot.
pub fn apply_diff(base: &Snapshot, diff: &[u8]) -> Result<Snapshot, String> {
if diff.len() < 8 {
return Err("Diff data too short for header".to_string());
}
let tick = u32::from_le_bytes([diff[0], diff[1], diff[2], diff[3]]);
let count = u32::from_le_bytes([diff[4], diff[5], diff[6], diff[7]]) as usize;
// Start from a clone of the base
let mut entities: Vec<EntityState> = base.entities.clone();
let mut offset = 8;
for _ in 0..count {
if offset + 5 > diff.len() {
return Err("Diff truncated at entry header".to_string());
}
let id = u32::from_le_bytes([diff[offset], diff[offset + 1], diff[offset + 2], diff[offset + 3]]);
let flags = diff[offset + 4];
offset += 5;
if flags & 0x80 != 0 {
// New entity — full state
if offset + 36 > diff.len() {
return Err("Diff truncated at new entity data".to_string());
}
let position = read_f32x3(diff, offset);
let rotation = read_f32x3(diff, offset + 12);
let velocity = read_f32x3(diff, offset + 24);
offset += 36;
// Add or replace
if let Some(ent) = entities.iter_mut().find(|e| e.id == id) {
ent.position = position;
ent.rotation = rotation;
ent.velocity = velocity;
} else {
entities.push(EntityState { id, position, rotation, velocity });
}
} else {
// Delta update — find existing entity
let ent = entities.iter_mut().find(|e| e.id == id)
.ok_or_else(|| format!("Diff references unknown entity {}", id))?;
if flags & 0x01 != 0 {
if offset + 12 > diff.len() {
return Err("Diff truncated at position".to_string());
}
ent.position = read_f32x3(diff, offset);
offset += 12;
}
if flags & 0x02 != 0 {
if offset + 12 > diff.len() {
return Err("Diff truncated at rotation".to_string());
}
ent.rotation = read_f32x3(diff, offset);
offset += 12;
}
if flags & 0x04 != 0 {
if offset + 12 > diff.len() {
return Err("Diff truncated at velocity".to_string());
}
ent.velocity = read_f32x3(diff, offset);
offset += 12;
}
}
}
Ok(Snapshot { tick, entities })
}
#[cfg(test)]
mod tests {
use super::*;
fn make_entity(id: u32, px: f32, py: f32, pz: f32) -> EntityState {
EntityState {
id,
position: [px, py, pz],
rotation: [0.0, 0.0, 0.0],
velocity: [0.0, 0.0, 0.0],
}
}
#[test]
fn test_snapshot_roundtrip() {
let snap = Snapshot {
tick: 42,
entities: vec![
make_entity(1, 1.0, 2.0, 3.0),
make_entity(2, 4.0, 5.0, 6.0),
],
};
let bytes = serialize_snapshot(&snap);
let decoded = deserialize_snapshot(&bytes).expect("deserialize failed");
assert_eq!(snap, decoded);
}
#[test]
fn test_snapshot_empty() {
let snap = Snapshot { tick: 0, entities: vec![] };
let bytes = serialize_snapshot(&snap);
assert_eq!(bytes.len(), 8); // just header
let decoded = deserialize_snapshot(&bytes).unwrap();
assert_eq!(snap, decoded);
}
#[test]
fn test_diff_no_changes() {
let snap = Snapshot {
tick: 10,
entities: vec![make_entity(1, 1.0, 2.0, 3.0)],
};
let snap2 = Snapshot {
tick: 11,
entities: vec![make_entity(1, 1.0, 2.0, 3.0)],
};
let diff = diff_snapshots(&snap, &snap2);
// Header only: tick(4) + count(4) = 8, count = 0
assert_eq!(diff.len(), 8);
let count = u32::from_le_bytes([diff[4], diff[5], diff[6], diff[7]]);
assert_eq!(count, 0);
}
#[test]
fn test_diff_position_changed() {
let old = Snapshot {
tick: 10,
entities: vec![make_entity(1, 0.0, 0.0, 0.0)],
};
let new = Snapshot {
tick: 11,
entities: vec![EntityState {
id: 1,
position: [1.0, 2.0, 3.0],
rotation: [0.0, 0.0, 0.0],
velocity: [0.0, 0.0, 0.0],
}],
};
let diff = diff_snapshots(&old, &new);
let result = apply_diff(&old, &diff).expect("apply_diff failed");
assert_eq!(result.tick, 11);
assert_eq!(result.entities.len(), 1);
assert_eq!(result.entities[0].position, [1.0, 2.0, 3.0]);
assert_eq!(result.entities[0].rotation, [0.0, 0.0, 0.0]); // unchanged
}
#[test]
fn test_diff_new_entity() {
let old = Snapshot {
tick: 10,
entities: vec![make_entity(1, 0.0, 0.0, 0.0)],
};
let new = Snapshot {
tick: 11,
entities: vec![
make_entity(1, 0.0, 0.0, 0.0),
make_entity(2, 5.0, 6.0, 7.0),
],
};
let diff = diff_snapshots(&old, &new);
let result = apply_diff(&old, &diff).expect("apply_diff failed");
assert_eq!(result.entities.len(), 2);
assert_eq!(result.entities[1].id, 2);
assert_eq!(result.entities[1].position, [5.0, 6.0, 7.0]);
}
#[test]
fn test_diff_multiple_fields_changed() {
let old = Snapshot {
tick: 10,
entities: vec![EntityState {
id: 1,
position: [0.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0],
velocity: [0.0, 0.0, 0.0],
}],
};
let new = Snapshot {
tick: 11,
entities: vec![EntityState {
id: 1,
position: [1.0, 1.0, 1.0],
rotation: [2.0, 2.0, 2.0],
velocity: [3.0, 3.0, 3.0],
}],
};
let diff = diff_snapshots(&old, &new);
let result = apply_diff(&old, &diff).unwrap();
assert_eq!(result.entities[0].position, [1.0, 1.0, 1.0]);
assert_eq!(result.entities[0].rotation, [2.0, 2.0, 2.0]);
assert_eq!(result.entities[0].velocity, [3.0, 3.0, 3.0]);
}
#[test]
fn test_diff_is_compact() {
// Only position changes — diff should be smaller than full snapshot
let old = Snapshot {
tick: 10,
entities: vec![make_entity(1, 0.0, 0.0, 0.0)],
};
let new = Snapshot {
tick: 11,
entities: vec![EntityState {
id: 1,
position: [1.0, 2.0, 3.0],
rotation: [0.0, 0.0, 0.0],
velocity: [0.0, 0.0, 0.0],
}],
};
let full_bytes = serialize_snapshot(&new);
let diff_bytes = diff_snapshots(&old, &new);
assert!(
diff_bytes.len() < full_bytes.len(),
"Diff ({} bytes) should be smaller than full snapshot ({} bytes)",
diff_bytes.len(),
full_bytes.len()
);
}
}