Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
5b91fcf
Add delta-compression using an operation log
Jun 2, 2026
80defdd
rename delta_op -> diff
Jun 5, 2026
1f303c9
Write: only include first index, and don't copy when writing
Jun 5, 2026
208dcf3
Move serialization away from server.py
Jun 5, 2026
7022fdb
1) make diff-handling part of serialize/deserialize only instead of w…
Jun 8, 2026
7bcc817
fix ci
Jun 9, 2026
fcfba17
revert changes and add extra tests
Jun 10, 2026
851abf8
clean up PR
Jun 10, 2026
9115038
fix test
Jun 11, 2026
b8e3856
Increment the patch-cursor only when we send
Jun 11, 2026
99c185b
simplify tests
Jun 11, 2026
0653034
rename to PatchHistory and PatchBuffer
Jun 13, 2026
9429674
address comment: remove PatchHistory as required component
Jun 13, 2026
a8a2140
address comments
Jun 13, 2026
d144ce9
Do not replicate if PatchHistory is not present
Jun 13, 2026
a2a300b
Move `replicate_diff_filtered` to the trait
Shatur Jun 14, 2026
52d890d
Remove `PhantomData`
Shatur Jun 14, 2026
ca04e5c
Inline `apply_patch_to_entity`
Shatur Jun 14, 2026
cc55d8a
Avoid extra lookup in `EntityWorldMut`
Shatur Jun 14, 2026
b7ce2a0
Update docs for `replicate_diff`
Shatur Jun 14, 2026
081f52c
Avoid allocation in `write`
Shatur Jun 14, 2026
be8e465
Use iterator inside `BatchSlice`
Shatur Jun 14, 2026
c24dbdc
Remove `PatchBatch` slice
Shatur Jun 14, 2026
766629f
Remove extra qualifiers
Shatur Jun 14, 2026
fb47c87
Remove extra type hint
Shatur Jun 14, 2026
4691394
Reduce generics
Shatur Jun 14, 2026
b480e94
Encapsulate `DiffFns` fields
Shatur Jun 14, 2026
3c694a2
Rename `history_component_id` into `history_id`
Shatur Jun 14, 2026
7a55566
Move history ID into component rule
Shatur Jun 14, 2026
a5ce077
Don't remove the constructor
Shatur Jun 14, 2026
d6a0fa5
Make `PatchHistory<C>` required for `C`
Shatur Jun 14, 2026
f59a53a
Remove storage lookup
Shatur Jun 14, 2026
b0673fa
Rename `first_patch_index` into `first_index`
Shatur Jun 14, 2026
83de49e
Return `None` if patches cannot be used
Shatur Jun 14, 2026
5fd6e82
Inline `first_index`
Shatur Jun 14, 2026
897d0b0
Simplify math
Shatur Jun 14, 2026
d751b3a
Use a dedicated type for the index
Shatur Jun 15, 2026
7c02fe0
Flatten patches storage
Shatur Jun 15, 2026
6e7d6d4
Use cached serialization
Shatur Jun 15, 2026
138a347
Merge remote-tracking branch 'origin/master' into cb/delta-compression
Shatur Jun 21, 2026
7283367
Refactor and migrate to the storage API
Shatur Jun 21, 2026
89b743b
Restore the old naming
Shatur Jun 22, 2026
a1de1b2
Rename `apply_patch` -> `apply_diff`
Shatur Jun 23, 2026
0616ac7
Rename `Diffable::Patch` into `Diffable::Diff`
Shatur Jun 23, 2026
87a096f
Rename "patch" into "diff"
Shatur Jun 23, 2026
d3766a8
Improve `diffs_after` test
Shatur Jun 23, 2026
a29813b
Use `insert`
Shatur Jun 23, 2026
c805814
Rename `WireDiff` into `ComponentDelta`
Shatur Jun 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Diff-based replication for components via `replicate_diff`.
- `ReplicationStorage` resource for storing arbitrary serialization/deserialization state.
- `SerializeCtx::entity` and `WriteCtx::entity` with the current entity.
- `AllExcept` filter scope and `VisibilityScope::AllExcept` as a counterpart to `Components`. When a `VisibilityFilter` denies visibility, every component except the listed ones is hidden. Useful for replicating a stripped-down entity (e.g. only its transform and light) to clients outside its full visibility range.
Expand Down
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ required-features = ["client", "server"]
name = "despawn"
required-features = ["client", "server"]

[[test]]
name = "diff"
required-features = ["client", "server"]

[[test]]
name = "fns"
required-features = ["client"]
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,7 @@ pub mod prelude {
protocol::{ProtocolHash, ProtocolHasher, ProtocolMismatch},
replication::{
Replicated,
diff::{Diffable, EntityCommandsDiffExt, EntityDiffExt, diff_index::DiffIndex},
receive_markers::AppMarkerExt,
registry::rule_fns::RuleFns,
rules::{AppRuleExt, component::ReplicationMode},
Expand Down
34 changes: 22 additions & 12 deletions src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,7 @@ fn collect_removals(
}
let fns_id_range = serialized.write_cached_fns_id(&mut fns_id_range, fns_id)?;
message.add_removal(fns_id_range);
entity_ticks.components.remove(component_index);
entity_ticks.remove_component(component_index);
}
}
}
Expand Down Expand Up @@ -582,7 +582,7 @@ fn collect_removals(
}
let fns_id_range = serialized.write_fns_id(rule.fns_id)?;
message.add_removal(fns_id_range);
entity_ticks.components.remove(component_index);
entity_ticks.remove_component(component_index);

Ok(())
};
Expand Down Expand Up @@ -675,7 +675,9 @@ fn collect_changes(
let mut ctx = SerializeCtx {
entity: entity.id(),
component_id,
last_changed: ticks.changed,
server_tick: **server_tick,
diff_cursor: None,
type_registry: &type_registry,
storage: &mut replication_storage,
};
Expand Down Expand Up @@ -713,11 +715,23 @@ fn collect_changes(
.write_cached_entity(&mut entity_range, entity.id())?;
mutations.add_entity(entity.id(), graph_index, entity_range);
}
let component_range = serialized.write_cached_component(
&mut ctx,
&mut component_range,
&mut component,
)?;

let diff_cursor = entity_ticks.diff_cursor(component_index);
let component_range = if diff_cursor.is_none() {
// Cache only full component snapshots.
serialized.write_cached_component(
&mut ctx,
&mut component_range,
&mut component,
)?
} else {
ctx.diff_cursor = diff_cursor;
let range = serialized.write_component(&mut ctx, &mut component)?;
if let Some(cursor) = ctx.diff_cursor.take() {
mutations.add_diff_cursor(component_index, cursor);
}
range
};
mutations.add_component(component_range);
}
} else {
Expand Down Expand Up @@ -802,11 +816,7 @@ fn update_ticks(
entity_ticks.components |= &components;
}
Entry::Vacant(entry) => {
entry.insert(EntityTicks {
server_tick,
system_tick,
components,
});
entry.insert(EntityTicks::new(server_tick, system_tick, components));
}
}
}
Expand Down
37 changes: 30 additions & 7 deletions src/server/replication_messages/mutations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ use crate::{
shared::{
backend::channels::ServerChannel,
replication::{
client_ticks::{ClientTicks, MutateInfo},
client_ticks::{ClientTicks, DiffCursors, MutateInfo, MutatedEntityInfo},
mutate_index::MutateIndex,
registry::component_mask::ComponentMask,
registry::{ComponentIndex, component_mask::ComponentMask},
},
},
};
Expand Down Expand Up @@ -70,6 +70,7 @@ impl Mutations {
data: Default::default(),
},
components: Default::default(),
diff_cursors: Default::default(),
};

match graph_index {
Expand Down Expand Up @@ -97,6 +98,19 @@ impl Mutations {
mutations.ranges.add_data(component);
}

/// Adds the diff cursor serialized for component.
pub(crate) fn add_diff_cursor(&mut self, component: ComponentIndex, cursor: DiffIndex) {
let mutations = self
.entity_location
.and_then(|location| match location {
EntityLocation::Related { index } => self.related[index].last_mut(),
EntityLocation::Standalone => self.standalone.last_mut(),
})
.expect("entity should be written before adding diff cursors");

mutations.diff_cursors.push((component, cursor));
}

/// Removes last added entity from [`Self::add_entity`] and returns it.
pub(super) fn pop(&mut self) -> Option<EntityMutations> {
self.entity_location
Expand Down Expand Up @@ -187,11 +201,13 @@ impl Mutations {
body_size = 0;
}

mutate_info.entities.extend(
chunk
.iter_mut()
.map(|mutations| (mutations.entity, mem::take(&mut mutations.components))),
);
mutate_info
.entities
.extend(chunk.iter_mut().map(|mutations| MutatedEntityInfo {
entity: mutations.entity,
components: mem::take(&mut mutations.components),
diff_cursors: mem::take(&mut mutations.diff_cursors),
}));
chunks_range.end += 1;
body_size += mutations_size;
}
Expand Down Expand Up @@ -278,6 +294,13 @@ pub(crate) struct EntityMutations {
///
/// Like [`Self::entity`], used for later component acknowledgement.
pub(super) components: ComponentMask,

/// Diff cursors represented by the serialized component ranges.
///
/// When the client ACKs this mutation message, these cursors become the last
/// acknowledged diff indices for their components. Future mutations can then
/// include only diffs after these indices.
pub(super) diff_cursors: DiffCursors,
}

#[derive(Clone, Copy)]
Expand Down
2 changes: 1 addition & 1 deletion src/server/replication_messages/serialized_data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ impl SerializedData {
Ok(range)
}

fn write_component(
pub(crate) fn write_component(
&mut self,
ctx: &mut SerializeCtx,
component: &mut ErasedComponent,
Expand Down
1 change: 1 addition & 0 deletions src/shared/replication.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod client_ticks;
pub mod deferred_entity;
pub mod diff;
pub(crate) mod mutate_index;
pub mod receive_markers;
pub mod registry;
Expand Down
95 changes: 90 additions & 5 deletions src/shared/replication/client_ticks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,19 @@ use bevy::{
prelude::*,
};
use log::{debug, trace};
use smallvec::SmallVec;

use super::mutate_index::MutateIndex;
use crate::{prelude::*, shared::replication::registry::component_mask::ComponentMask};
use crate::{
prelude::*,
shared::replication::registry::{ComponentIndex, component_mask::ComponentMask},
};

/// Alias for cursors associated with components.
///
/// We use a [`SmallVec`] because entities usually don't have more than a few
/// components with diff replication enabled.
pub(crate) type DiffCursors = SmallVec<[(ComponentIndex, DiffIndex); 3]>;

/// Tracks replication ticks for a client.
#[derive(Component, Default)]
Expand Down Expand Up @@ -59,8 +69,8 @@ impl ClientTicks {
return;
};

for (entity, components) in &mutate_info.entities {
let Some(entity_ticks) = self.entities.get_mut(entity) else {
for info in mutate_info.entities {
let Some(entity_ticks) = self.entities.get_mut(&info.entity) else {
// We ignore missing entities, since they were probably despawned.
continue;
};
Expand All @@ -70,7 +80,13 @@ impl ClientTicks {
if entity_ticks.server_tick < mutate_info.server_tick {
entity_ticks.server_tick = mutate_info.server_tick;
entity_ticks.system_tick = mutate_info.system_tick;
entity_ticks.components |= components;
entity_ticks.components |= &info.components;

for (component, cursor) in info.diff_cursors {
if entity_ticks.components.contains(component) {
entity_ticks.set_diff_cursor(component, cursor);
}
}
}
}
trace!(
Expand Down Expand Up @@ -102,12 +118,81 @@ pub(crate) struct EntityTicks {

/// The list of components that were replicated on this tick.
pub(crate) components: ComponentMask,

/// Last acknowledged diff cursor for components.
///
/// This is separate from [`Self::server_tick`]: the server tick controls change
/// detection for the component as a whole, while the cursor controls the
/// acknowledged base used to serialize only diffs that the client has not yet
/// acknowledged.
///
/// Absence means the client has never acknowledged the base value, or
/// diff replication is not enabled for it.
///
/// Cursors are pruned when the component is removed.
diff_cursors: DiffCursors,
}

impl EntityTicks {
pub(crate) fn new(
server_tick: RepliconTick,
system_tick: Tick,
components: ComponentMask,
) -> Self {
Self {
server_tick,
system_tick,
components,
diff_cursors: Default::default(),
}
}

pub(crate) fn diff_cursor(&self, component: ComponentIndex) -> Option<DiffIndex> {
self.diff_cursors
.iter()
.find_map(|&(index, cursor)| (index == component).then_some(cursor))
}

/// Sets the acknowledged diff cursor for a component.
///
/// If ACKs arrive out of order, older ACKs must be filtered out by the caller.
fn set_diff_cursor(&mut self, component: ComponentIndex, cursor: DiffIndex) {
if let Some((_, existing)) = self
.diff_cursors
.iter_mut()
.find(|(index, _)| *index == component)
{
*existing = cursor;
} else {
self.diff_cursors.push((component, cursor));
}
}

pub(crate) fn remove_component(&mut self, component: ComponentIndex) {
self.components.remove(component);
// Component removal resets the entity's state for this component, so
// its diff cursor becomes stale too.
if let Some(index) = self
.diff_cursors
.iter()
.position(|(index, _)| *index == component)
{
self.diff_cursors.remove(index);
}
}
}

/// Information about a mutation message.
pub(crate) struct MutateInfo {
pub(crate) server_tick: RepliconTick,
pub(crate) system_tick: Tick,
pub(crate) timestamp: Duration,
pub(crate) entities: Vec<(Entity, ComponentMask)>,
pub(crate) entities: Vec<MutatedEntityInfo>,
}

/// Entity data acknowledged by a mutation message.
pub(crate) struct MutatedEntityInfo {
pub(crate) entity: Entity,
pub(crate) components: ComponentMask,
pub(crate) diff_cursors: DiffCursors,
}
Loading