Skip to main content

pylon_replication/
lib.rs

1//! Entity replication for Pylon realtime shards.
2//!
3//! A simulation keeps its replicated state in a [`Replicated`] store:
4//! entities with stable u64 ids, a 3D position, and components (bytes in
5//! the game's own encoding, keyed by a u8 id). Every change bumps the
6//! store's change counter, so the server can tell what changed since it
7//! last wrote to a given subscriber without diffing snapshots.
8//!
9//! The server sends each subscriber replication frames ([`frame`]): spawn
10//! (full state) for entities that came into view, update (only changed
11//! axes and components) for entities in view, and despawn for entities
12//! that left view. A client applies them to a [`ReplicaTable`].
13//!
14//! A WebAssembly shard module records its store's changes
15//! ([`Replicated::record_changes`]) and hands them to the host each tick;
16//! the host applies them to its copy ([`Replicated::apply_changes`]).
17//!
18//! This crate has no dependencies, so guests build it for
19//! wasm32-unknown-unknown.
20
21use std::collections::BTreeMap;
22
23pub mod datagram;
24pub mod frame;
25pub mod varint;
26pub mod view;
27
28pub use frame::{DecodeError, FrameSummary, ReplicaEntity, ReplicaTable};
29pub use view::ViewHistory;
30
31/// A stable entity id, chosen by the simulation.
32pub type EntityId = u64;
33
34/// A component id: the game decides what each one holds.
35pub type ComponentId = u8;
36
37/// One replicated entity.
38#[derive(Debug, Clone, PartialEq)]
39pub struct Entity {
40    pub pos: [f32; 3],
41    /// Change counter value of the last position change.
42    pub pos_seq: u64,
43    /// Components. A removed component stays as `bytes: None` so readers
44    /// that last saw it can be told it is gone.
45    pub components: BTreeMap<ComponentId, Component>,
46    /// Change counter value of the last change of any kind.
47    pub seq: u64,
48    /// Change counter value of this entity's spawn. An id despawned and
49    /// spawned again gets a new value, so readers can tell a new entity
50    /// from the old one.
51    pub spawn_seq: u64,
52}
53
54impl Entity {
55    /// True when anything changed after change counter value `since`.
56    pub fn changed_since(&self, since: u64) -> bool {
57        self.seq > since
58    }
59
60    /// A component's bytes, if present.
61    pub fn component(&self, id: ComponentId) -> Option<&[u8]> {
62        self.components.get(&id).and_then(|c| c.bytes.as_deref())
63    }
64}
65
66#[derive(Debug, Clone, PartialEq)]
67pub struct Component {
68    pub bytes: Option<Vec<u8>>,
69    pub seq: u64,
70}
71
72// Heap an entity and its component entries take beyond the component
73// bytes, for limits. Measured on 64-bit targets (see the heap test in
74// tests/cost.rs): an entry in the store's map, the first leaf of an
75// entity's component map, one entry in that map, and the allocation of a
76// component's bytes.
77/// An entity's entry in the store's map.
78const ENTITY_COST: usize = 160;
79/// The first node of an entity's component map, allocated with its first
80/// component.
81const COMPONENT_MAP_COST: usize = 400;
82/// A component entry (present or removed), with its bytes' allocation.
83const COMPONENT_COST: usize = 64;
84
85/// The replicated state of a simulation.
86#[derive(Debug)]
87pub struct Replicated {
88    entities: BTreeMap<EntityId, Entity>,
89    seq: u64,
90    log: Option<Vec<u8>>,
91    /// Approximate memory held: a fixed cost per entity and per component
92    /// entry (removed ones too) plus component bytes.
93    cost: usize,
94    /// Unique per store made in this process, so a WebAssembly module can
95    /// tell when the game swapped in a new store.
96    store_id: u64,
97}
98
99impl Default for Replicated {
100    fn default() -> Self {
101        static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
102        Self {
103            entities: BTreeMap::new(),
104            seq: 0,
105            log: None,
106            cost: 0,
107            store_id: NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
108        }
109    }
110}
111
112/// A clone is a different store: it gets its own id and no change log, so
113/// a WebAssembly module that swaps one in (a round reset from a template, a
114/// rollback) sends the host a full dump of it.
115impl Clone for Replicated {
116    fn clone(&self) -> Self {
117        Self {
118            entities: self.entities.clone(),
119            seq: self.seq,
120            cost: self.cost,
121            ..Self::default()
122        }
123    }
124}
125
126/// Stores are equal when they hold the same entities; the id and the log
127/// are bookkeeping.
128impl PartialEq for Replicated {
129    fn eq(&self, other: &Self) -> bool {
130        self.entities == other.entities && self.seq == other.seq
131    }
132}
133
134mod op {
135    pub const SPAWN: u8 = 1;
136    pub const DESPAWN: u8 = 2;
137    pub const POS: u8 = 3;
138    pub const SET: u8 = 4;
139    pub const REMOVE: u8 = 5;
140}
141
142/// Why a change log did not apply.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct ChangeError(pub String);
145
146impl std::fmt::Display for ChangeError {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        f.write_str(&self.0)
149    }
150}
151
152impl std::error::Error for ChangeError {}
153
154impl Replicated {
155    pub fn new() -> Self {
156        Self::default()
157    }
158
159    /// The change counter. Every change raises it by one.
160    pub fn seq(&self) -> u64 {
161        self.seq
162    }
163
164    pub fn len(&self) -> usize {
165        self.entities.len()
166    }
167
168    pub fn is_empty(&self) -> bool {
169        self.entities.is_empty()
170    }
171
172    /// Approximate memory the store holds, for limits: a fixed cost per
173    /// entity and per component entry (removed ones too) plus the bytes.
174    pub fn cost(&self) -> usize {
175        self.cost
176    }
177
178    /// This store's process-unique id.
179    pub fn store_id(&self) -> u64 {
180        self.store_id
181    }
182
183    /// Remove every entity but keep the change counter running, so an id
184    /// spawned again afterwards gets a spawn counter no reader has seen and
185    /// reads as a new entity.
186    pub fn clear(&mut self) {
187        self.entities.clear();
188        self.cost = 0;
189        self.bump();
190    }
191
192    pub fn get(&self, id: EntityId) -> Option<&Entity> {
193        self.entities.get(&id)
194    }
195
196    pub fn contains(&self, id: EntityId) -> bool {
197        self.entities.contains_key(&id)
198    }
199
200    /// Entities in id order.
201    pub fn iter(&self) -> impl Iterator<Item = (EntityId, &Entity)> {
202        self.entities.iter().map(|(id, e)| (*id, e))
203    }
204
205    fn bump(&mut self) -> u64 {
206        self.seq += 1;
207        self.seq
208    }
209
210    /// Add an entity. Returns false (and changes nothing) when the id is
211    /// taken.
212    pub fn spawn(&mut self, id: EntityId, pos: [f32; 3]) -> bool {
213        if self.entities.contains_key(&id) {
214            return false;
215        }
216        self.cost += ENTITY_COST;
217        let seq = self.bump();
218        self.entities.insert(
219            id,
220            Entity {
221                pos,
222                pos_seq: seq,
223                components: BTreeMap::new(),
224                seq,
225                spawn_seq: seq,
226            },
227        );
228        if let Some(log) = &mut self.log {
229            log.push(op::SPAWN);
230            varint::write_u64(log, id);
231            write_pos(log, pos);
232        }
233        true
234    }
235
236    /// Remove an entity. Returns false when there was none.
237    pub fn despawn(&mut self, id: EntityId) -> bool {
238        let Some(e) = self.entities.remove(&id) else {
239            return false;
240        };
241        self.cost -= entity_cost(&e);
242        self.bump();
243        if let Some(log) = &mut self.log {
244            log.push(op::DESPAWN);
245            varint::write_u64(log, id);
246        }
247        true
248    }
249
250    /// Move an entity. A position equal to the current one is not a change.
251    pub fn set_pos(&mut self, id: EntityId, pos: [f32; 3]) -> bool {
252        let Some(current) = self.entities.get(&id).map(|e| e.pos) else {
253            return false;
254        };
255        if current.map(f32::to_bits) == pos.map(f32::to_bits) {
256            return true;
257        }
258        let seq = self.bump();
259        let e = self.entities.get_mut(&id).expect("checked above");
260        e.pos = pos;
261        e.pos_seq = seq;
262        e.seq = seq;
263        if let Some(log) = &mut self.log {
264            log.push(op::POS);
265            varint::write_u64(log, id);
266            write_pos(log, pos);
267        }
268        true
269    }
270
271    /// Set a component. Bytes equal to the current ones are not a change.
272    pub fn set_component(&mut self, id: EntityId, component: ComponentId, bytes: &[u8]) -> bool {
273        let Some(e) = self.entities.get(&id) else {
274            return false;
275        };
276        if e.component(component) == Some(bytes) {
277            return true;
278        }
279        let seq = self.bump();
280        let e = self.entities.get_mut(&id).expect("checked above");
281        if e.components.is_empty() {
282            self.cost += COMPONENT_MAP_COST;
283        }
284        let old = e.components.insert(
285            component,
286            Component {
287                bytes: Some(bytes.to_vec()),
288                seq,
289            },
290        );
291        e.seq = seq;
292        self.cost += bytes.len();
293        match old {
294            Some(c) => self.cost -= c.bytes.map_or(0, |b| b.len()),
295            None => self.cost += COMPONENT_COST,
296        }
297        if let Some(log) = &mut self.log {
298            log.push(op::SET);
299            varint::write_u64(log, id);
300            log.push(component);
301            varint::write_u64(log, bytes.len() as u64);
302            log.extend_from_slice(bytes);
303        }
304        true
305    }
306
307    /// Remove a component. Returns false when the entity or component is
308    /// missing.
309    pub fn remove_component(&mut self, id: EntityId, component: ComponentId) -> bool {
310        let present = self
311            .entities
312            .get(&id)
313            .is_some_and(|e| e.component(component).is_some());
314        if !present {
315            return false;
316        }
317        let seq = self.bump();
318        let e = self.entities.get_mut(&id).expect("checked above");
319        let old = e
320            .components
321            .insert(component, Component { bytes: None, seq });
322        e.seq = seq;
323        // The entry stays as a tombstone: only its bytes are freed.
324        self.cost -= old.and_then(|c| c.bytes).map_or(0, |b| b.len());
325        if let Some(log) = &mut self.log {
326            log.push(op::REMOVE);
327            varint::write_u64(log, id);
328            log.push(component);
329        }
330        true
331    }
332
333    /// Start or stop keeping a log of changes for [`Replicated::take_changes`].
334    /// A WebAssembly shard module turns this on.
335    pub fn record_changes(&mut self, on: bool) {
336        self.log = on.then(Vec::new);
337    }
338
339    /// The changes since the last call, as bytes for
340    /// [`Replicated::apply_changes`]. Empty when recording is off.
341    pub fn take_changes(&mut self) -> Vec<u8> {
342        match &mut self.log {
343            Some(log) => std::mem::take(log),
344            None => Vec::new(),
345        }
346    }
347
348    /// Every entity and component as a change log that rebuilds this
349    /// store from empty (spawns, then component sets).
350    pub fn full_changes(&self) -> Vec<u8> {
351        let mut out = Vec::new();
352        for (id, e) in &self.entities {
353            out.push(op::SPAWN);
354            varint::write_u64(&mut out, *id);
355            write_pos(&mut out, e.pos);
356            for (cid, c) in &e.components {
357                if let Some(bytes) = &c.bytes {
358                    out.push(op::SET);
359                    varint::write_u64(&mut out, *id);
360                    out.push(*cid);
361                    varint::write_u64(&mut out, bytes.len() as u64);
362                    out.extend_from_slice(bytes);
363                }
364            }
365        }
366        out
367    }
368
369    /// Apply changes another store recorded. Stops at the first malformed
370    /// or inconsistent change; the changes before it stay applied.
371    pub fn apply_changes(&mut self, bytes: &[u8]) -> Result<(), ChangeError> {
372        self.apply_changes_limited(bytes, usize::MAX, usize::MAX)
373    }
374
375    /// `apply_changes` for a log from an untrusted source: stops with an
376    /// error at the first change that would take the store past
377    /// `max_entities` or `max_cost` (see [`Replicated::cost`]). That change
378    /// and the rest do not apply.
379    pub fn apply_changes_limited(
380        &mut self,
381        mut bytes: &[u8],
382        max_entities: usize,
383        max_cost: usize,
384    ) -> Result<(), ChangeError> {
385        let err = |m: &str| ChangeError(m.to_string());
386        let over = |entities: usize, cost: usize| {
387            ChangeError(format!(
388                "the change takes the store past its limit ({entities} entities, {cost} bytes; at most {max_entities} and {max_cost})"
389            ))
390        };
391        while let Some((&tag, rest)) = bytes.split_first() {
392            bytes = rest;
393            let id = varint::read_u64(&mut bytes).ok_or_else(|| err("truncated entity id"))?;
394            match tag {
395                op::SPAWN => {
396                    let pos = read_pos(&mut bytes).ok_or_else(|| err("truncated position"))?;
397                    let (entities, cost) = (self.entities.len() + 1, self.cost + ENTITY_COST);
398                    if !self.entities.contains_key(&id)
399                        && (entities > max_entities || cost > max_cost)
400                    {
401                        return Err(over(entities, cost));
402                    }
403                    if !self.spawn(id, pos) {
404                        return Err(ChangeError(format!("spawn of existing entity {id}")));
405                    }
406                }
407                op::DESPAWN => {
408                    self.despawn(id);
409                }
410                op::POS => {
411                    let pos = read_pos(&mut bytes).ok_or_else(|| err("truncated position"))?;
412                    if !self.set_pos(id, pos) {
413                        return Err(ChangeError(format!("move of missing entity {id}")));
414                    }
415                }
416                op::SET => {
417                    let (&cid, rest) = bytes
418                        .split_first()
419                        .ok_or_else(|| err("truncated component"))?;
420                    bytes = rest;
421                    let len =
422                        varint::read_u64(&mut bytes).ok_or_else(|| err("truncated length"))?;
423                    let len = usize::try_from(len).map_err(|_| err("component too large"))?;
424                    if bytes.len() < len {
425                        return Err(err("component runs past the end"));
426                    }
427                    let (value, rest) = bytes.split_at(len);
428                    bytes = rest;
429                    let Some(e) = self.entities.get(&id) else {
430                        return Err(ChangeError(format!("component on missing entity {id}")));
431                    };
432                    let added = match e.components.get(&cid) {
433                        Some(c) => len.saturating_sub(c.bytes.as_ref().map_or(0, Vec::len)),
434                        None if e.components.is_empty() => {
435                            COMPONENT_MAP_COST + COMPONENT_COST + len
436                        }
437                        None => COMPONENT_COST + len,
438                    };
439                    let cost = self.cost.saturating_add(added);
440                    if cost > max_cost {
441                        return Err(over(self.entities.len(), cost));
442                    }
443                    self.set_component(id, cid, value);
444                }
445                op::REMOVE => {
446                    let (&cid, rest) = bytes
447                        .split_first()
448                        .ok_or_else(|| err("truncated component"))?;
449                    bytes = rest;
450                    self.remove_component(id, cid);
451                }
452                other => return Err(ChangeError(format!("unknown change tag {other}"))),
453            }
454        }
455        Ok(())
456    }
457}
458
459/// What an entity adds to [`Replicated::cost`].
460fn entity_cost(e: &Entity) -> usize {
461    let map = if e.components.is_empty() {
462        0
463    } else {
464        COMPONENT_MAP_COST
465    };
466    ENTITY_COST
467        + map
468        + e.components
469            .values()
470            .map(|c| COMPONENT_COST + c.bytes.as_ref().map_or(0, Vec::len))
471            .sum::<usize>()
472}
473
474fn write_pos(out: &mut Vec<u8>, pos: [f32; 3]) {
475    for v in pos {
476        out.extend_from_slice(&v.to_le_bytes());
477    }
478}
479
480fn read_pos(bytes: &mut &[u8]) -> Option<[f32; 3]> {
481    if bytes.len() < 12 {
482        return None;
483    }
484    let (head, rest) = bytes.split_at(12);
485    *bytes = rest;
486    let f = |i: usize| f32::from_le_bytes(head[i..i + 4].try_into().unwrap());
487    Some([f(0), f(4), f(8)])
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    #[test]
495    fn changes_bump_the_counter_and_no_ops_do_not() {
496        let mut r = Replicated::new();
497        assert!(r.spawn(1, [0.0, 0.0, 0.0]));
498        assert!(!r.spawn(1, [5.0, 0.0, 0.0]));
499        let s = r.seq();
500        r.set_pos(1, [0.0, 0.0, 0.0]);
501        r.set_component(1, 3, b"x");
502        let after = r.seq();
503        assert_eq!(after, s + 1);
504        r.set_component(1, 3, b"x");
505        assert_eq!(r.seq(), after);
506        assert!(r.get(1).unwrap().changed_since(s));
507        assert!(!r.get(1).unwrap().changed_since(after));
508        assert!(r.remove_component(1, 3));
509        assert_eq!(r.get(1).unwrap().component(3), None);
510        assert!(!r.remove_component(1, 3));
511    }
512
513    #[test]
514    fn a_recorded_log_rebuilds_the_same_entities() {
515        let mut guest = Replicated::new();
516        guest.record_changes(true);
517        guest.spawn(7, [1.0, 2.0, 3.0]);
518        guest.spawn(9, [0.0, 0.0, 0.0]);
519        guest.set_pos(7, [1.5, 2.0, 3.0]);
520        guest.set_component(7, 1, b"hp=10");
521        guest.set_component(9, 2, &[]);
522        guest.remove_component(9, 2);
523        guest.despawn(9);
524        let log = guest.take_changes();
525        assert!(guest.take_changes().is_empty());
526
527        let mut host = Replicated::new();
528        host.apply_changes(&log).unwrap();
529        let summary = |r: &Replicated| {
530            r.iter()
531                .map(|(id, e)| (id, e.pos, e.component(1).map(<[u8]>::to_vec)))
532                .collect::<Vec<_>>()
533        };
534        assert_eq!(summary(&host), summary(&guest));
535    }
536
537    #[test]
538    fn a_full_dump_rebuilds_the_store() {
539        let mut a = Replicated::new();
540        a.spawn(1, [1.0, 2.0, 3.0]);
541        a.spawn(4, [0.0; 3]);
542        a.set_component(1, 2, b"x");
543        a.set_component(4, 2, b"y");
544        a.remove_component(4, 2);
545        let mut b = Replicated::new();
546        b.apply_changes(&a.full_changes()).unwrap();
547        let view = |r: &Replicated| {
548            r.iter()
549                .map(|(id, e)| (id, e.pos, e.component(2).map(<[u8]>::to_vec)))
550                .collect::<Vec<_>>()
551        };
552        assert_eq!(view(&a), view(&b));
553    }
554
555    #[test]
556    fn cost_counts_entities_entries_tombstones_and_bytes() {
557        let mut r = Replicated::new();
558        r.spawn(1, [0.0; 3]);
559        assert_eq!(r.cost(), ENTITY_COST);
560        let base = ENTITY_COST + COMPONENT_MAP_COST;
561        r.set_component(1, 1, &[0; 10]);
562        r.set_component(1, 2, &[0; 5]);
563        assert_eq!(r.cost(), base + 2 * COMPONENT_COST + 15);
564        r.set_component(1, 1, &[0; 3]);
565        assert_eq!(r.cost(), base + 2 * COMPONENT_COST + 8);
566        // A removed component is a tombstone: its entry still costs.
567        r.remove_component(1, 2);
568        assert_eq!(r.cost(), base + 2 * COMPONENT_COST + 3);
569        // Empty components cost too.
570        r.set_component(1, 9, &[]);
571        assert_eq!(r.cost(), base + 3 * COMPONENT_COST + 3);
572        r.despawn(1);
573        assert_eq!(r.cost(), 0);
574    }
575
576    #[test]
577    fn a_limited_apply_stops_at_the_change_that_passes_the_limit() {
578        let mut source = Replicated::new();
579        source.record_changes(true);
580        for id in 0..1000 {
581            source.spawn(id, [0.0; 3]);
582        }
583        let log = source.take_changes();
584        let mut host = Replicated::new();
585        assert!(host.apply_changes_limited(&log, 10, usize::MAX).is_err());
586        // It stopped at the limit, not after the whole log.
587        assert_eq!(host.len(), 10);
588        let mut host = Replicated::new();
589        assert!(host
590            .apply_changes_limited(&log, usize::MAX, 20 * ENTITY_COST)
591            .is_err());
592        assert_eq!(host.len(), 20);
593        assert!(host.cost() <= 20 * ENTITY_COST);
594    }
595
596    #[test]
597    fn a_limited_apply_refuses_one_large_component_before_it_applies() {
598        let mut source = Replicated::new();
599        source.record_changes(true);
600        source.spawn(1, [0.0; 3]);
601        source.set_component(1, 1, &[7; 100]);
602        source.set_component(1, 1, &vec![7; 1 << 20]);
603        let log = source.take_changes();
604        let limit = ENTITY_COST + COMPONENT_MAP_COST + COMPONENT_COST + 1000;
605        let mut host = Replicated::new();
606        assert!(host.apply_changes_limited(&log, usize::MAX, limit).is_err());
607        // The megabyte never landed; the store holds the 100 bytes.
608        assert_eq!(
609            host.get(1).unwrap().component(1).map(<[u8]>::len),
610            Some(100)
611        );
612        assert!(host.cost() <= limit);
613    }
614
615    #[test]
616    fn a_clone_is_a_new_store_without_a_log() {
617        let mut a = Replicated::new();
618        a.record_changes(true);
619        a.spawn(1, [0.0; 3]);
620        let b = a.clone();
621        assert_ne!(a.store_id(), b.store_id());
622        let mut b = b;
623        assert!(b.take_changes().is_empty());
624        assert_eq!(a, b);
625        assert_eq!(a.cost(), b.cost());
626    }
627
628    #[test]
629    fn clear_keeps_the_counter_so_a_respawned_id_is_new() {
630        let mut r = Replicated::new();
631        r.spawn(7, [0.0; 3]);
632        let first = r.get(7).unwrap().spawn_seq;
633        r.clear();
634        r.spawn(7, [0.0; 3]);
635        assert!(r.get(7).unwrap().spawn_seq > first);
636        assert_ne!(Replicated::new().store_id(), Replicated::new().store_id());
637    }
638
639    #[test]
640    fn bad_logs_are_refused_without_panicking() {
641        let mut r = Replicated::new();
642        assert!(r.apply_changes(&[op::SPAWN]).is_err());
643        assert!(r.apply_changes(&[op::SPAWN, 1, 0, 0]).is_err());
644        assert!(r
645            .apply_changes(&[op::POS, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
646            .is_err());
647        assert!(r.apply_changes(&[99, 1]).is_err());
648        let mut long = vec![op::SPAWN, 1];
649        long.extend_from_slice(&[0; 12]);
650        long.extend_from_slice(&[op::SET, 1, 4, 0xff, 0xff, 0xff, 0xff, 0x0f]);
651        assert!(r.apply_changes(&long).is_err());
652    }
653}