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