diff --git a/NOTES.md b/NOTES.md index c3e5f27..81b5827 100644 --- a/NOTES.md +++ b/NOTES.md @@ -1,48 +1,433 @@ -# TimewarpSet::RecordComponentValues - -* add_frame_to_freshly_added_despawn_markers - -* add_timewarp_buffer_components:: -* record_component_added_to_alive_ranges:: -* record_component_history_values:: -** remove_components_from_entities_with_freshly_added_despawn_markers::, - -# TimewarpSet::RollbackUnderwayComponents -``` - .run_if(resource_exists::()) - .run_if(not(resource_added::())) -``` - -* apply_snapshot_to_component_if_available::, -* reinsert_components_removed_during_rollback_at_correct_frame::, -* reremove_components_inserted_during_rollback_at_correct_frame::, -** clear_removed_components_queue:: - -# TimewarpSet::RollbackUnderwayGlobal -``` - .run_if(resource_exists::()) - .run_if(not(resource_added::())) -``` - -* check_for_rollback_completion - -# TimewarpSet::NoRollback -``` - .run_if(not(resource_exists::())) - .run_if(not(resource_added::())) -``` - -* record_component_removed_to_alive_ranges::, -* insert_components_at_prior_frames::, -* apply_snapshot_to_component_if_available::, // can req rb -* trigger_rollback_when_snapshot_added (after insert_comp_at_priors) // can req rb -** consolidate_rollback_requests -*** do_actual_despawn_after_rollback_frames_from_despawn_marker - -# TimewarpSet::RollbackInitiated -``` - .run_if(resource_added::()) -``` - -* rollback_initiated -** rollback_initiated_for_component:: \ No newline at end of file + + +# Anatomy of a Frame + +## Server Frame + +The server ticks along at the fixed rate, never rewinding or rolling back. Player inputs must arrive +in time for the frame they are associated with to be simulated. + +The server runs the game simulation, applying forces to player objects in response to inputs, +then runs the physics update sets, then broadcasts the new position of objects. + + + + + + + + + + + + + + + + + + + + + + +
PreUpdateReplicon reads from network, writes to Events<IncomingMessage>
FixedUpdate + + + + + + + + + + + + + + + + + + + + + +
My Game Sets
FirstIncrements tick
IncomingMessagesReads and handles Events<IncomingMessage>
eg, player 1's input for frame F = "Fire + Turn Left"
GameSimulationTurns player inputs into forces, actions, spawns, etc
OutgoingMessagesWrites custom game events to network (ie not replicated component state)
eg, broadcasting chat messages, or player inputs, to all other players
+ + +
FixedUpdate + + + + + + + + + +
bevy_xpbd
PhysicsAll the physics sets run here, controlled by bevy_xpbd
+ +
PostUpdateReplicon broadcasts entity/component changes (the post-physics values for this frame)
PostUpdateRendering (if server is built with "gui" feature)
+ +## Client Frame + +The client tries to be a few frames ahead of the server's simulation, such that inputs for +frame F arrive by frame F-1 on the server. + +This means inputs from the server arriving at the client are, from the client's POV, in the past. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PreUpdate +Replicon reads from network, deserializes and applies the replication data to the client. +This can include spawning new entities and updating components. +For timewarp-specific components, the new updates are written to the ServerSnapshot<Component> at +the RepliconTick. Timewarp applies the updates to the components at the correct frame during rollback, as required. +
+New custom events are written to the bevy event queue, to be consumed by my game's systems. +
FixedUpdate + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Timewarp Prefix Sets
Firstalwayssanity check systems prevent blowing your own foot off, kinda.
CheckIfRollbackCompletein rbDuring rollback, we check if we should exit rollback, having resimulated everything in the requested rollback range.
CheckIfRollbackNeedednot in rbCheck if a newly added ServerSnapshot or ABAF/ICAF means we should rollback
StartRollbacknew rb +If previous set requested a rollback, we wind back the game clock, and load in component data for +the appropriate frame for starting rollback. +
DuringRollbackin rbrecord component removals, revive dead components that should be alive this frame, etc.
UnwrapBlueprintsalwaysUnwrap ABAFs for this frame
Lastalways...
+ +
FixedUpdate + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
My Game Sets
FirstalwaysIncrement GameClock `gc.frame += 1`
IncomingMessagesnot rb + Reads and handles Events<IncomingMessage> +
Housekeepingnot rb + Monitoring lag, tuning various things, collecting metrics +
GameSimulationalways + Apply player inputs to simulation. Fetches inputs for game_clock.current_frame() from storage, + so will apply correct inputs during rollback. +
AssembleBlueprintsalways + Any new blueprint components get assembled (ie, bunch of components get added) +
OutgoingMessagesnot rb + Writes custom game events to network
Send our inputs for this frame to the server +
+ + + +
FixedUpdate + + + + + + + + + + + +
bevy_xpbd
PhysicsalwaysAll the physics sets run here, controlled by bevy_xpbd
+ +
FixedUpdate + + + + + + + + + + + + + + + + + + + + + + + + +
Timewarp Postfix Sets
Firstalways...
Componentsalways +record component history to ComponentHistory(Component), clean up despawn requests, add +timewarp components to entities with freshly added tw-registered components. +record component births. +
DuringRollbackin rb +wipe removed component queue, remove components which shouldn't exist at this frame +
Lastalways...
+ +
PostUpdateMessages "sent" in OutgoingMessages are sent now by Replicon.
PostUpdateRendering
+ + +## How rollbacks happen + +Systems that initiate rollbacks write a `RollbackRequest` to an event queue, specifying the frame they +wish to start resimulating from. These are in the `CheckIfRollbackNeeded` set. + +All rollback requests are consolidated, and a `Rollback` resource is written. The `RollbackConsolidationStrategy` from `TimewarpConfig` +determines if the oldest or newest frame from the list of requests is used. +If you only receive entire-world updates at a time, taking the newest frame requested is optimal. +This is how replicon currently works, and is the default. + +If we need to resimulate from frame `N` onwards, before we start simulating that frame, we load in +stored component values from frame `N - 1`. + +We also unwrap any blueprints (ABAF) for frame `N`. + +## On blueprint and component temporality + +The server sends replicon data containing component values in PostUpdate, after physics. + +So when the client receives a packet saying that a component value is X at frame `N`, that means +the value was X on the server, after frame `N` was simulated. + +So if the client receives this, they can resimulate from frame `N+1`, and set the component to X +before starting - representing the correct state at the end of frame `N`. + +### Spawning via blueprint + +Say the server spawns a new player during frame 100. It inserts a PlayerShip blueprint, and then +the blueprint assembly fn for players adds a Position, Collider, etc. +At the end of the frame in postupdate, replicon sends this data out to clients. + +That player entity might have been given a position of X,Y during server's frame 100, but during +physics that position might have changed to X',Y' before replication data was sent. + +On clients, when we get a player blueprint for frame 100 we'll be rolling back to 101, +and inserting the replicated Pos value@100 before we start simulating 101. +But we'll only end up adding the (non-replicated) collider during bp assembly in frame 101. +Although not optimal, this is correct โ€“ the replicated components are correct for that frame. + +Anything the player's collider interacted with during physics on frame 100 on the server may end up +requiring a correction on the client (since client couldn't have simualted that). but that's fine. + +if we embed the pos/whatever into the BP we could actually assemble on the correct frame? + +REALLY, the actual bundles on the server are what we need to replicate, like we do for BPs at the mo. +so the current blueprint comps need all the stuff that's in the bundle, then the assembly fn creates the comps. +then we can rollback and insert blueprints on the same frame the server did - even though replicon will have +also received the post-physics replicated data for them at the end of that frame. our bp assembly fn should overwrite +it on the client. + +HOWEVER then we get stale data left over in the blueprint (like the pos from when it was spawned) and +we can't really filter or control how replicon sends these bp compoents to clients. +we don't want to continually update the BPs copying in fresh data, just so new players can spawn +stuff from existing blueprints. kinda want a way to dynamically create a blueprint-bundle just for +replicating to new clients. + +that's annoying. + +that's why we spawn blueprints the next frame on the client. + +This isn't a concern for bullets that get predicted and spammed, so it's ok ๐Ÿฅบ + +
+ +| __WARNING__ unedited ravings below this line that haven't yet necessarily coalesced into useful code + +
+ +## Client INPUT DELAY means other players' inputs can arrive early + +As a client it's possible to get a player's input for a future frame before getting the component data, ie +before you simulates that frame. + +A low lag player with a 3 frame input delay will send inputs for frame 103 to the server while the server is doing frame 101, and the server will rebroadcast, so you might get them a frame before needed on a remote client. + +so perhaps we should be locally simulating the bullet spawn for remote players too somehow? + +when the server gets a fire command, before broadcasting it to others, it should immediately spawn +an entity, even if input is for a future frame. associate the spawned entity with the input command on the server, and +replicate the entity with a FutureBullet(f=100, client_id=X) component. + +it's possible the client coule receive this right before they even simulate f100. in which case they can assemble on the correct frame, set the pos +using the same local prediction logic as firing yourself (because we wouldn't have comps from the server's assembly of the bp yet), and wait on normal server updates to arrive to correct it. + +when the server does the bp assembly, it will have the prespawned entity associated with the input, and it just +assembles into that entity. + +Maybe all firing should work this way, rather than apply_inputs doing it? +apply inputs makes sense for thrusting and rotating etc, but when you need a new entity it's different. + +FireIntent(client_id, frame, bullet_blueprint) component ? could do the same on the client, since the client also prespawns +entities to send them to the server, for matching up. can add the Prediction comp to that too to clean up misfires. + +## FireIntent + +>intent to fire is locked in at the time of sampling inputs for a future frame (input delay..), and immediately sent to the server. + +local client A, simulating f100 with input delay of 3. so it's sampling inputs for 103 at f100. +presses fire. spawns an entity with Predicted component with entity id of af_100_103 +sends fire input to server, with associated entity of af_100_103. + +server receives the input in time for frame 101. +server spawns an entity sf_101_103_a with Replicated and FireIntent(bullet_bp, f=103, client_id=A). +(this entity doesnt have physics components, or even a transform yet. invisible.) +server adds mapping for client A between af_100_103 <--> sf_101_103_a, which is sent back to A next packet. +server replicates entity sf_101_103_a to all players. + +client B, about to simulate frame 103, (so in prefix clock still 102) receives the sf_101_103_a entity. +server has not yet delivered replication data for 103 to this client, so no pos@100 for the new bullet. +notices the FireIntent with a f=103, and unwraps it into a normal Bullet blueprint component. +client B simulates f 103, assembles bullet using normal prediction logic, into the fireintent entity. +will receive updates to it normally since it's already a server entity. + +client C, on a very low lag connection, is about to simulate frame 102, (so in prefix clock still 101) receives the sf_101_103_a entity. +the entity gets created on the client with the fireintent, but not unwrapped, because fireintent.frame=103, and next client frame will be 102. +... +NEXT frame, the client unwraps it at frame=103. all good, as per the B client. + +client D, on a high lag connection, receives the sf_101_103_a entity as it's about to simulate f 108. +issues RollbackRequest(104) +in prefix, while clock was wound back to 103, the fireintent bp is + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components.rs b/src/components.rs index 5031cea..05ed912 100644 --- a/src/components.rs +++ b/src/components.rs @@ -1,4 +1,4 @@ -use crate::{FrameBuffer, FrameNumber, TimewarpComponent}; +use crate::{prelude::TimewarpError, FrameBuffer, FrameNumber, TimewarpComponent}; use bevy::prelude::*; /// entities with NotRollbackable are ignored, even if they have components which @@ -47,6 +47,27 @@ impl InsertComponentAtFrame { } } +/// For assembling a blueprint in the past - testing. +#[derive(Component, Debug)] +pub struct AssembleBlueprintAtFrame { + pub component: T, + pub frame: FrameNumber, +} +impl AssembleBlueprintAtFrame { + pub fn new(frame: FrameNumber, component: T) -> Self { + Self { component, frame } + } + pub fn type_name(&self) -> &str { + std::any::type_name::() + } +} + +/// presence on an entity during rollback means there will be no older values available, +/// since the entity is being assembled from blueprint this frame. +/// so we load in component values matching the origin frame (not one frame prior, like usual) +#[derive(Component, Debug, Clone)] +pub struct OriginFrame(pub FrameNumber); + /// entities with components that were registered with error correction logging will receive /// one of these components, updated with before/after values when a simulation correction /// resulting from a rollback and resimulate causes a snap. @@ -67,14 +88,25 @@ pub struct ServerSnapshot { impl ServerSnapshot { pub fn with_capacity(len: usize) -> Self { Self { - values: FrameBuffer::with_capacity(len), + values: FrameBuffer::with_capacity(len, "SS"), } } pub fn at_frame(&self, frame: FrameNumber) -> Option<&T> { self.values.get(frame) } - pub fn insert(&mut self, frame: FrameNumber, val: T) { - self.values.insert(frame, val); + pub fn insert(&mut self, frame: FrameNumber, val: T) -> Result<(), TimewarpError> { + self.values.insert(frame, val) + } + pub fn type_name(&self) -> &str { + std::any::type_name::() + } + pub fn newest_snap_frame(&self) -> Option { + let nf = self.values.newest_frame(); + if nf == 0 { + None + } else { + Some(nf) + } } } @@ -93,15 +125,26 @@ pub struct ComponentHistory { // lazy first version - don't need a clone each frame if value hasn't changed! // just store once and reference from each unchanged frame number. impl ComponentHistory { - pub fn with_capacity(len: usize, birth_frame: FrameNumber) -> Self { + /// The entity param is just for logging. + pub fn with_capacity( + len: usize, + birth_frame: FrameNumber, + component: T, + entity: &Entity, + ) -> Self { let mut this = Self { - values: FrameBuffer::with_capacity(len), - alive_ranges: Vec::new(), + values: FrameBuffer::with_capacity(len, "CH"), + alive_ranges: vec![(birth_frame, None)], correction_logging_enabled: false, }; - this.report_birth_at_frame(birth_frame); + trace!("CH.new {entity:?} {birth_frame} = {component:?}"); + // can't error on a brand new buffer: + _ = this.values.insert(birth_frame, component); this } + pub fn type_name(&self) -> &str { + std::any::type_name::() + } /// will compute and insert `TimewarpCorrection`s when snapping pub fn enable_correction_logging(&mut self) { self.correction_logging_enabled = true; @@ -110,16 +153,24 @@ impl ComponentHistory { self.values.get(frame) } // adding entity just for debugging print outs. - pub fn insert(&mut self, frame: FrameNumber, val: T, entity: &Entity) { + pub fn insert( + &mut self, + frame: FrameNumber, + val: T, + entity: &Entity, + ) -> Result<(), TimewarpError> { trace!("CH.Insert {entity:?} {frame} = {val:?}"); - self.values.insert(frame, val); + self.values.insert(frame, val) } + /// removes values buffered for this frame, and greater frames. + #[allow(dead_code)] pub fn remove_frame_and_beyond(&mut self, frame: FrameNumber) { self.values .remove_entries_newer_than(frame.saturating_sub(1)); } pub fn alive_at_frame(&self, frame: FrameNumber) -> bool { + // self.values.get(frame).is_some() for (start, maybe_end) in &self.alive_ranges { if *start <= frame && (maybe_end.is_none() || maybe_end.unwrap() > frame) { return true; @@ -129,9 +180,13 @@ impl ComponentHistory { } pub fn report_birth_at_frame(&mut self, frame: FrameNumber) { debug!("component birth @ {frame} {:?}", std::any::type_name::()); + if self.alive_at_frame(frame) { + warn!("Can't report birth of component already alive"); + return; + } assert!( - !self.alive_at_frame(frame), - "Can't report birth of component already alive" + self.values.get(frame).is_some(), + "No stored component value when reporting birth @ {frame}" ); self.alive_ranges.push((frame, None)); } @@ -144,11 +199,13 @@ impl ComponentHistory { if !self.alive_at_frame(frame) { return; } - debug!( + + trace!( "component death @ {frame} {:?} {:?}", std::any::type_name::(), self.alive_ranges ); + assert!( self.alive_at_frame(frame), "Can't report death of component not alive" diff --git a/src/error.rs b/src/error.rs index 071c5d0..291b2a5 100644 --- a/src/error.rs +++ b/src/error.rs @@ -4,7 +4,7 @@ #[derive(Debug)] pub enum TimewarpError { Io(std::io::Error), - SequenceBufferFull, + FrameTooOld, } impl std::fmt::Display for TimewarpError { diff --git a/src/frame_buffer.rs b/src/frame_buffer.rs index 1539467..2a15f9d 100644 --- a/src/frame_buffer.rs +++ b/src/frame_buffer.rs @@ -6,13 +6,13 @@ /// use crate::*; use bevy::prelude::*; -use std::{collections::VecDeque, ops::Range}; +use std::{collections::VecDeque, fmt, ops::Range}; /// values for new frames are push_front'ed onto the vecdeque -#[derive(Debug, Resource, Clone)] +#[derive(Resource, Clone)] pub struct FrameBuffer where - T: Clone + Send + Sync + std::fmt::Debug, + T: Clone + Send + Sync + PartialEq + std::fmt::Debug, { /// Contains Option because there can be gaps /// and we want to be able to store 'None' as a normal value in here. @@ -20,32 +20,36 @@ where /// frame number of the first elem of vecdeque ie newest value. 0 = empty. front_frame: FrameNumber, capacity: usize, + pub name: String, } -// impl fmt::Debug for FrameBuffer -// where -// T: Clone + Send + Sync + std::fmt::Debug, -// { -// fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { -// write!( -// f, -// "FrameBuffer{{front_frame:{:?}, capacity:{:?} entries:[{:?},...]}}", -// self.front_frame, -// self.capacity, -// self.get(self.newest_frame()), -// ) -// } -// } +impl fmt::Debug for FrameBuffer +where + T: Clone + Send + Sync + PartialEq + std::fmt::Debug, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "FrameBuffer[{}]<{}>{{front_frame:{:?}, capacity:{:?} entries:[{:?},...]}}", + self.name, + std::any::type_name::(), + self.front_frame, + self.capacity, + self.get(self.newest_frame()), + ) + } +} impl FrameBuffer where - T: Clone + Send + Sync + std::fmt::Debug, + T: Clone + Send + Sync + PartialEq + std::fmt::Debug, { - pub fn with_capacity(len: usize) -> Self { + pub fn with_capacity(len: usize, name: &str) -> Self { Self { entries: VecDeque::with_capacity(len), capacity: len, front_frame: 0, + name: name.into(), } } @@ -75,9 +79,11 @@ where return; } if let Some(index) = self.index(frame) { - self.entries.drain(0..index); + self.entries.drain(0..index.min(self.entries.len())); + self.front_frame = frame; + } else { + error!("remove_entries_newer_than {frame} failed, no index."); } - self.front_frame = frame; } /// value at frame, or None if out of the range of values currently stored in the buffer @@ -116,44 +122,65 @@ where } } + pub fn insert_blanks(&mut self, num_blanks: usize) { + for _ in 0..num_blanks { + self.entries.push_front(None); + } + } + + // which frames have values? + pub fn frame_occupancy(&self) -> Vec { + self.entries.iter().map(|e| e.is_some()).collect::>() + } + /// insert value at given frame. /// It is permitted to insert at old frames that are still in the range, but /// not allowed to insert at a frame older than the oldest existing frame. /// - /// Is is permitted to insert at any future frame, any gaps will be make None. + /// Is is permitted to insert at any future frame, any gaps will be made None. /// so if you insert at newest_frame() + a gazillion, you gets a buffer containing your /// one new value and a bunch of Nones after it. - pub fn insert(&mut self, frame: FrameNumber, value: T) { + pub fn insert(&mut self, frame: FrameNumber, value: T) -> Result<(), TimewarpError> { // is this frame too old to be accepted? if frame < self.oldest_frame() { // probably outrageous lag or network desync or something? pretty bad. - error!( - "Frame too old! range: {:?} attempt: {frame} = {value:?}", - ( - self.front_frame, - self.front_frame - .saturating_sub(self.capacity as FrameNumber) - ) - ); - return; + // error!( + // "Frame too old! range: {:?} attempt: {frame} = {value:?}", + // ( + // self.front_frame, + // self.front_frame + // .saturating_sub(self.capacity as FrameNumber) + // ) + // ); + return Err(TimewarpError::FrameTooOld); } // are we replacing a potential existing value, ie no change in buffer range if let Some(index) = self.index(frame) { if let Some(val) = self.entries.get_mut(index) { + // TODO should we test if we are we replacing with same-val that already exists, + // and bail out here? would still need to avoid mutably derefing the SS somehow. *val = Some(value); } - return; + return Ok(()); } - // so we are inserting a frame greater than front_frame. - // any gaps between current `front_frame` and `frame` need to be created as None - for _ in (self.front_frame + 1)..frame { - // print!("{self:?} ... Inserting a None val @ {f}\n"); - self.entries.push_front(None); + if self.front_frame == 0 || frame == self.front_frame + 1 { + // no gaps. + } else { + // so we are inserting a frame greater than front_frame. + // any gaps between current `front_frame` and `frame` need to be created as None + // warn!( + // "inserting f {frame}, front_frame currently: {} {self:?}", + // self.front_frame + // ); + for _f in (self.front_frame + 1)..frame { + self.entries.push_front(None); + } } self.entries.push_front(Some(value)); self.front_frame = frame; self.entries.truncate(self.capacity); + Ok(()) } /// gets index into vecdeq for frame number, or None if out of range. @@ -185,34 +212,34 @@ mod tests { #[test] fn test_frame_buffer() { - let mut fb = FrameBuffer::::with_capacity(5); - fb.insert(1, 1); + let mut fb = FrameBuffer::::with_capacity(5, ""); + fb.insert(1, 1).unwrap(); assert_eq!(fb.get(1), Some(&1)); - fb.insert(2, 2); + fb.insert(2, 2).unwrap(); // print!("{fb:?}"); - fb.insert(3, 3); - fb.insert(4, 4); - fb.insert(5, 5); + fb.insert(3, 3).unwrap(); + fb.insert(4, 4).unwrap(); + fb.insert(5, 5).unwrap(); assert_eq!(fb.get(1), Some(&1)); assert_eq!(fb.get(3), Some(&3)); assert_eq!(fb.get(5), Some(&5)); assert_eq!(fb.get(6), None); - fb.insert(6, 6); + fb.insert(6, 6).unwrap(); assert_eq!(fb.get(6), Some(&6)); // 1 should be dropped now assert_eq!(fb.get(1), None); // now test modifying a val by inserting over assert_eq!(fb.get(3), Some(&3)); - fb.insert(3, 33); + fb.insert(3, 33).unwrap(); assert_eq!(fb.get(3), Some(&33)); // test modifying by get_mut let v2 = fb.get_mut(2).unwrap(); *v2 = 22; - fb.insert(2, 22); + fb.insert(2, 22).unwrap(); assert_eq!(fb.newest_frame(), 6); // inserting with a gap should fill with nones - fb.insert(8, 8); + fb.insert(8, 8).unwrap(); assert_eq!(fb.get(7), None); assert_eq!(fb.get(8), Some(&8)); assert_eq!(fb.newest_frame(), 8); diff --git a/src/lib.rs b/src/lib.rs index d9399a1..2f7ef99 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -198,30 +198,58 @@ pub mod prelude { pub use crate::traits::*; pub use crate::TimewarpPlugin; pub type FrameNumber = u32; - pub use crate::systems::RollbackRequest; - pub use crate::TimewarpSet; + pub use crate::TimewarpPostfixSet; + pub use crate::TimewarpPrefixSet; } use bevy::prelude::*; use prelude::*; -/// bevy_timewarp's systems run in these three sets, which get configured to run -/// after the main game logic (the set for which is provided in the plugin setup) #[derive(SystemSet, Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum TimewarpSet { - RecordComponentValues, - RollbackUnderwayComponents, - RollbackUnderwayGlobal, - RollbackInitiated, - NoRollback, +pub enum TimewarpPrefixSet { + First, + + CheckIfRollbackComplete, + + /// Runs during rollback + DuringRollback, + + /// Doesn't run in rollback + /// + /// Contains: + /// * detect_misuse_of_icaf + /// * trigger_rollback_when_snapshot_added + /// * trigger_rollback_when_icaf_added + /// then: + /// * consolidate_rollback_requests + /// then: + /// * apply_deferred (so the Rollback res might exist, Ch/SS added.) + CheckIfRollbackNeeded, + + /// Runs only if the Rollback resource was just Added<> + /// + /// * rollback_initiated + /// then: + /// * rollback_component + /// * rollback_component + /// ... + StartRollback, + + /// Runs always, unpacks non-timewarp components at this frame. + /// Current used for blueprints. + UnwrapBlueprints, + + Last, } +/// bevy_timewarp's systems run in these three sets, which get configured to run +/// after the main game logic (the set for which is provided in the plugin setup) #[derive(SystemSet, Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub(crate) enum TimewarpSetMarkers { - /// remains empty - exists as marker for interleaving apply_deferreds - RollbackStartMarker, - /// remains empty - exists as marker for interleaving apply_deferreds - RollbackEndMarker, +pub enum TimewarpPostfixSet { + First, + Components, + DuringRollback, + Last, } pub struct TimewarpPlugin { @@ -242,82 +270,158 @@ impl Plugin for TimewarpPlugin { // RollbackRequest events are drained manually in `consolidate_rollback_requests` .init_resource::>() .insert_resource(RollbackStats::default()) + // + // PREFIX + // .configure_sets( self.config.schedule(), ( - TimewarpSetMarkers::RollbackStartMarker, - // --- APPLY_DEFERRED --- - // Runs in normal frames : Yes - // Runs when rollback initiated: Yes - // Runs during ongoing rollback: Yes - TimewarpSet::RecordComponentValues, - // --- APPLY_DEFERRED --- - // Runs in normal frames : No - // Runs when rollback initiated: Yes - // Runs during ongoing rollback: Yes - TimewarpSet::RollbackUnderwayComponents.run_if(resource_exists::()), - // Runs in normal frames : No - // Runs when rollback initiated: Yes - // Runs during ongoing rollback: Yes - TimewarpSet::RollbackUnderwayGlobal.run_if(resource_exists::()), - // Runs in normal frames : Yes - // Runs when rollback initiated: No - // Runs during ongoing rollback: No - // systems in here can potentially initiate a rollback: - TimewarpSet::NoRollback.run_if(not(resource_exists::())), - // --- APPLY_DEFERRED --- - // Runs in normal frames : No - // Runs when rollback initiated: Yes - // Runs during ongoing rollback: No - TimewarpSet::RollbackInitiated.run_if(resource_added::()), - TimewarpSetMarkers::RollbackEndMarker, + TimewarpPrefixSet::First, + TimewarpPrefixSet::CheckIfRollbackComplete + .run_if(resource_exists::()), + // -- apply_deferred -- // + TimewarpPrefixSet::CheckIfRollbackNeeded + .run_if(not(resource_exists::())), + // -- apply_deferred -- // + TimewarpPrefixSet::StartRollback.run_if(resource_added::()), + // -- apply_deferred -- // + TimewarpPrefixSet::DuringRollback.run_if(resource_exists::()), + TimewarpPrefixSet::UnwrapBlueprints, + TimewarpPrefixSet::Last, + // -- apply_deferred -- // ) .chain(), ) - // BoxedSystemSet implements IntoSystemSetConfig, but not IntoSystemSetConfigs - // so for now, have to use the deprecated configure_set instead of configure_sets. - // assume this is because bevy's API is in transitional phase in this regards.. - // https://github.com/bevyengine/bevy/pull/9247 - .configure_set( + // + // APPLY DEFERREDS + // + .add_systems( self.config.schedule(), - self.config - .after_set() - .before(TimewarpSetMarkers::RollbackStartMarker), + apply_deferred + .after(TimewarpPrefixSet::CheckIfRollbackComplete) + .before(TimewarpPrefixSet::CheckIfRollbackNeeded), ) - .insert_resource(FixedTime::new_from_secs(1.0 / 60.0)) - .insert_resource(GameClock::new()) - // apply_deferred between rollback sets - // needed because rollback logic can insert/remove components and the Rollback resource. .add_systems( self.config.schedule(), - ( - apply_deferred - .after(TimewarpSetMarkers::RollbackStartMarker) - .before(TimewarpSet::RecordComponentValues), - apply_deferred - .after(TimewarpSet::RecordComponentValues) - .before(TimewarpSet::RollbackInitiated), - apply_deferred - .after(TimewarpSet::NoRollback) - .before(TimewarpSet::RollbackInitiated), - ), + apply_deferred + .after(TimewarpPrefixSet::CheckIfRollbackNeeded) + .before(TimewarpPrefixSet::StartRollback), + ) + .add_systems( + self.config.schedule(), + apply_deferred + .after(TimewarpPrefixSet::StartRollback) + .before(TimewarpPrefixSet::DuringRollback), + ) + // + // END APPLY DEFERREDS + // + // -- + // + // Debug headers + // + .add_systems( + self.config.schedule(), + (|game_clock: Res, rb: Option>| { + trace!("Prefix::First for {} {rb:?}", **game_clock + 1) + }) + .in_set(TimewarpPrefixSet::First), + ) + .add_systems( + self.config.schedule(), + (|game_clock: Res, rb: Option>| { + trace!("Prefix::Last for {} {rb:?}", **game_clock + 1) + }) + .in_set(TimewarpPrefixSet::Last), + ) + .add_systems( + self.config.schedule(), + (|game_clock: Res, rb: Option>| { + trace!("Postfix::First {game_clock:?} {rb:?}"); + }) + .in_set(TimewarpPostfixSet::First), + ) + .add_systems( + self.config.schedule(), + (|game_clock: Res, rb: Option>| { + trace!("Postfix::Last {game_clock:?} {rb:?}"); + }) + .in_set(TimewarpPostfixSet::Last), ) + // + // End debug headers + // .add_systems( self.config.schedule(), - systems::check_for_rollback_completion.in_set(TimewarpSet::RollbackUnderwayGlobal), + systems::sanity_check.in_set(TimewarpPrefixSet::First), ) .add_systems( self.config.schedule(), - systems::rollback_initiated.in_set(TimewarpSet::RollbackInitiated), + ( + systems::prefix_check_for_rollback_completion::check_for_rollback_completion, + apply_deferred, + ) + .chain() + .in_set(TimewarpPrefixSet::CheckIfRollbackComplete), ) .add_systems( self.config.schedule(), ( - systems::consolidate_rollback_requests, - systems::despawn_entities_with_elapsed_despawn_marker, + systems::prefix_check_if_rollback_needed::consolidate_rollback_requests, + apply_deferred, ) .chain() - .in_set(TimewarpSet::NoRollback), - ); + .in_set(TimewarpPrefixSet::CheckIfRollbackNeeded), + ) + .add_systems( + self.config.schedule(), + systems::prefix_start_rollback::rollback_initiated + .in_set(TimewarpPrefixSet::StartRollback), + ) + .add_systems( + self.config.schedule(), + apply_deferred.in_set(TimewarpPrefixSet::Last), + ) + // + // POSTFIX + // + .configure_sets( + self.config.schedule(), + ( + TimewarpPostfixSet::First, + TimewarpPostfixSet::Components, + TimewarpPostfixSet::DuringRollback.run_if(resource_exists::()), + TimewarpPostfixSet::Last, + ) + .chain(), + ) + .add_systems( + self.config.schedule(), + systems::postfix_last::despawn_entities_with_elapsed_despawn_marker + .in_set(TimewarpPostfixSet::Last), + ) + // flush commands at the very end, since they may be referencing entities which + // get despawned in PreUpdate next tick + .add_systems( + self.config.schedule(), + apply_deferred.after(TimewarpPostfixSet::Last), + ) + // BoxedSystemSet implements IntoSystemSetConfig, but not IntoSystemSetConfigs + // so for now, have to use the deprecated configure_set instead of configure_sets. + // assume this is because bevy's API is in transitional phase in this regards.. + // https://github.com/bevyengine/bevy/pull/9247 + // the specified first set must be after our TW prefix runs + .configure_set( + self.config.schedule(), + self.config.first_set().after(TimewarpPrefixSet::Last), + ) + // the specified last set must be before the TW postfix runs. + .configure_set( + self.config.schedule(), + self.config.last_set().before(TimewarpPostfixSet::First), + ) + // + .insert_resource(FixedTime::new_from_secs(1.0 / 60.0)) + .insert_resource(GameClock::new()); } } diff --git a/src/resources.rs b/src/resources.rs index 87bc59c..315fe3e 100644 --- a/src/resources.rs +++ b/src/resources.rs @@ -5,8 +5,21 @@ use bevy::{ }; use std::{ops::Range, time::Duration}; +/// if various systems request rollbacks to different frames within one tick, when consolidating +/// those requests into an actionable Rollback, do we choose the oldest or newest frame from the +/// list of requests? +#[derive(Debug, Copy, Clone)] +pub enum RollbackConsolidationStrategy { + Oldest, + Newest, +} + #[derive(Resource, Debug, Clone)] pub struct TimewarpConfig { + /// if you can update some entities one frame and some another, ie you don't receive + /// entire-world update, set this to Oldest, or you will miss data. + /// the default is Newest (for replicon, which is entire-world updates only atm) + pub consolidation_strategy: RollbackConsolidationStrategy, /// how many frames of old component values should we buffer? /// can't roll back any further than this. will depend on network lag and game mechanics. pub rollback_window: FrameNumber, @@ -16,8 +29,10 @@ pub struct TimewarpConfig { pub force_rollback_always: bool, /// schedule in which our `after_set` and rollback systems run, defaults to FixedUpdate pub schedule: Box, - /// set containing game logic, after which the rollback systems will run - pub after_set: BoxedSystemSet, + /// first set containing game logic + pub first_set: BoxedSystemSet, + /// last set containing game logic + pub last_set: BoxedSystemSet, } impl TimewarpConfig { @@ -25,29 +40,39 @@ impl TimewarpConfig { /// rollback_window: 30 /// forced_rollback: false /// schedule: FixedUpdate - pub fn new(after_set: impl SystemSet) -> Self { + pub fn new(first_set: impl SystemSet, last_set: impl SystemSet) -> Self { Self { - after_set: Box::new(after_set), + consolidation_strategy: RollbackConsolidationStrategy::Newest, + first_set: Box::new(first_set), + last_set: Box::new(last_set), // and defaults, override with builder fns: rollback_window: 30, force_rollback_always: false, schedule: Box::new(FixedUpdate), } } - pub fn set_schedule(mut self, schedule: impl ScheduleLabel) -> Self { + pub fn with_schedule(mut self, schedule: impl ScheduleLabel) -> Self { self.schedule = Box::new(schedule); self } - pub fn set_forced_rollback(mut self, enabled: bool) -> Self { + pub fn with_forced_rollback(mut self, enabled: bool) -> Self { self.force_rollback_always = enabled; self } - pub fn set_rollback_window(mut self, num_frames: FrameNumber) -> Self { + pub fn with_rollback_window(mut self, num_frames: FrameNumber) -> Self { self.rollback_window = num_frames; self } - pub fn after_set(&self) -> BoxedSystemSet { - self.after_set.as_ref().dyn_clone() + pub fn with_consolidation_strategy(mut self, strategy: RollbackConsolidationStrategy) -> Self { + self.consolidation_strategy = strategy; + self + } + + pub fn first_set(&self) -> BoxedSystemSet { + self.first_set.as_ref().dyn_clone() + } + pub fn last_set(&self) -> BoxedSystemSet { + self.last_set.as_ref().dyn_clone() } pub fn forced_rollback(&self) -> bool { self.force_rollback_always @@ -58,6 +83,12 @@ impl TimewarpConfig { pub fn rollback_window(&self) -> FrameNumber { self.rollback_window } + pub fn consolidation_strategy(&self) -> RollbackConsolidationStrategy { + self.consolidation_strategy + } + pub fn set_consolidation_strategy(&mut self, strategy: RollbackConsolidationStrategy) { + self.consolidation_strategy = strategy; + } pub fn is_within_rollback_window( &self, current_frame: FrameNumber, @@ -71,6 +102,8 @@ impl TimewarpConfig { #[derive(Resource, Debug, Default)] pub struct RollbackStats { pub num_rollbacks: u64, + pub range_faults: u64, + pub non_rollback_updates: u64, } /// If this resource exists, we are doing a rollback. Insert it to initate one manually. @@ -78,26 +111,50 @@ pub struct RollbackStats { /// in one of the following ways: /// /// * You insert a `InsertComponentAtFrame` for a past frame +/// * You insert a `AssembleBlueprintAtFrame` for a past frame /// * You supply ServerSnapshot data for a past frame /// #[derive(Resource, Debug, Clone)] pub struct Rollback { - /// the range of frames, start being the target we rollback to + /// the range of frames, start being the target we resimulate first pub range: Range, /// we preserve the original FixedUpdate period here and restore after rollback completes. /// (during rollback, we set the FixedUpdate period to 0.0, to effect fast-forward resimulation) pub original_period: Option, } impl Rollback { - /// The range start..end contains all values with start <= x < end. It is empty if start >= end. - pub fn new(start: FrameNumber, end: FrameNumber) -> Self { + /// `end` is the last frame to be resimulated + pub fn new( + first_frame_to_resimulate: FrameNumber, + last_frame_to_resimulate: FrameNumber, + ) -> Self { Self { - range: Range { start, end }, + range: Range { + start: first_frame_to_resimulate, + end: last_frame_to_resimulate, + }, original_period: None, } } } +/// systems that want to initiate a rollback write one of these to +/// the Events queue. +#[derive(Event, Debug)] +pub struct RollbackRequest(FrameNumber); + +impl RollbackRequest { + pub fn resimulate_this_frame_onwards(frame: FrameNumber) -> Self { + if frame == 0 { + warn!("RollbackRequest(0)!"); + } + Self(frame) + } + pub fn frame(&self) -> FrameNumber { + self.0 + } +} + /// Every time a rollback completes, before the `Rollback` resources is removed, /// we copy it into the `PreviousRollback` resources. /// diff --git a/src/systems.rs b/src/systems.rs index 5e15b15..1c4e969 100644 --- a/src/systems.rs +++ b/src/systems.rs @@ -1,582 +1,38 @@ use crate::prelude::*; use bevy::prelude::*; -use std::time::Duration; -/// systems that want to initiate a rollback write one of these to -/// the Events queue. -#[derive(Event, Debug)] -pub struct RollbackRequest(pub FrameNumber); +pub(crate) mod postfix_components; +pub(crate) mod postfix_during_rollback; +pub(crate) mod postfix_last; -/// potentially-concurrent systems request rollbacks by writing a request -/// to the Events, which we drain and use the smallest -/// frame that was requested - ie, covering all requested frames. -pub(crate) fn consolidate_rollback_requests( - mut rb_events: ResMut>, - mut commands: Commands, - game_clock: Res, -) { - let mut rb_frame: FrameNumber = 0; - // NB: a manually managed event queue, which we drain here - for ev in rb_events.drain() { - if rb_frame == 0 || ev.0 < rb_frame { - rb_frame = ev.0; - } - } - if rb_frame == 0 { - return; - } - commands.insert_resource(Rollback::new(rb_frame, game_clock.frame())); -} - -/// wipes RemovedComponents queue for component T. -/// useful during rollback, because we don't react to removals that are part of resimulating. -pub(crate) fn clear_removed_components_queue( - mut e: RemovedComponents, - game_clock: Res, -) { - if !e.is_empty() { - debug!( - "Clearing f:{:?} RemovedComponents<{}> during rollback: {:?}", - game_clock.frame(), - std::any::type_name::(), - e.len() - ); - } - e.clear(); -} - -/// add the ComponentHistory and ServerSnapshot whenever an entity gets the T component. -/// NB: you must have called `app.register_rollback::()` for this to work. -pub(crate) fn add_timewarp_buffer_components< - T: TimewarpComponent, - const CORRECTION_LOGGING: bool, ->( - q: Query< - (Entity, &T), - ( - Added, - Without, - Without>, - ), - >, - mut commands: Commands, - game_clock: Res, - timewarp_config: Res, -) { - for (e, comp) in q.iter() { - // insert component value at this frame, since the system that records it won't run - // if a rollback is happening this frame. and if it does it just overwrites - let mut comp_history = ComponentHistory::::with_capacity( - timewarp_config.rollback_window as usize, - game_clock.frame(), - ); - if CORRECTION_LOGGING { - comp_history.enable_correction_logging(); - } - comp_history.insert(game_clock.frame(), comp.clone(), &e); - - debug!( - "Adding ComponentHistory<> to {e:?} for {:?}\nInitial val @ {:?} = {:?}", - std::any::type_name::(), - game_clock.frame(), - comp.clone(), - ); - commands.entity(e).insert(( - TimewarpStatus::new(0), - comp_history, - // server snapshots are sent event n frames, so there are going to be lots of Nones in - // the sequence buffer. increase capacity accordingly. - // TODO compute based on snapshot send rate. - ServerSnapshot::::with_capacity(timewarp_config.rollback_window as usize * 60), // TODO yuk - )); - } -} - -/// record component lifetimes -/// won't be called first time comp is added, since it won't have a ComponentHistory yet. -/// only for comp removed ... then readded birth -pub(crate) fn record_component_birth( - mut q: Query<(Entity, &mut ComponentHistory), (Added, Without)>, - game_clock: Res, - rb: Option>, -) { - // during rollback, components are removed and readded. - // but we don't want to log the same as outside of rollback, we want to ignore. - // however this system still runs, so that the Added filters update their markers - // otherwise things added during rollback would all show as Added the first frame back. - if rb.is_some() { - return; - } - - for (entity, mut ct) in q.iter_mut() { - debug!( - "{entity:?} Component birth @ {:?} {:?}", - game_clock.frame(), - std::any::type_name::() - ); - // if an entity was created with InsertComponentAtPastFrame it will have its birth frame recorded - // but this system won't catch it via Added<> until the next frame, which we need to supress. - // an alternative solution is to force insert_components_at_prior_frames to run before this, - // with an apply_deferred, but that seemed worse. systems run in parallel at the mo. - if ct.alive_ranges.first() == Some(&(game_clock.frame() - 1, None)) { - // this comp is alive and born last frame: skip registering the birth. - return; - } - ct.report_birth_at_frame(game_clock.frame()); - } -} +pub(crate) mod prefix_blueprints; +pub(crate) mod prefix_check_for_rollback_completion; +pub(crate) mod prefix_check_if_rollback_needed; +pub(crate) mod prefix_during_rollback; +pub(crate) mod prefix_first; +pub(crate) mod prefix_start_rollback; -/// Write current value of component to the ComponentHistory buffer for this frame -pub(crate) fn record_component_history( - mut q: Query<( - Entity, - &T, - &mut ComponentHistory, - Option<&mut TimewarpCorrection>, - )>, +/// footgun protection - in case your clock ticking fn isn't running properly, this avoids +/// timewarp rolling back if the clock won't advance, since that would be an infinite loop. +pub(crate) fn sanity_check( game_clock: Res, - mut commands: Commands, opt_rb: Option>, + mut prev_frame: Local, ) { - for (entity, comp, mut comp_hist, opt_correction) in q.iter_mut() { - // if we're in rollback, and on the last frame, we're about to overwrite something. - // we need to preserve it an report a misprediction, if it differs from the new value. - if comp_hist.correction_logging_enabled { - if let Some(ref rb) = opt_rb { - if rb.range.end == game_clock.frame() { - if let Some(old_val) = comp_hist.at_frame(game_clock.frame()) { - if *old_val != *comp { - warn!( - "Generating Correction for {entity:?}", //old:{:?} new{:?}", - // old_val, comp - ); - if let Some(mut correction) = opt_correction { - correction.before = old_val.clone(); - correction.after = comp.clone(); - correction.frame = game_clock.frame(); - } else { - commands.entity(entity).insert(TimewarpCorrection:: { - before: old_val.clone(), - after: comp.clone(), - frame: game_clock.frame(), - }); - } - } - } else { - // trace!("End of rb range, but no existing comp to correct"); - // this is normal in the case of spawning a new entity in the past, - // like a bullet. it was never simulated for the current frame yet, so - // it's expected that there wasn't an existing comp history val to replace. - } - } - } - } - trace!("record @ {:?} {entity:?} {comp:?}", game_clock.frame()); - // the main point of this system is just to save the component value to the buffer: - comp_hist.insert(game_clock.frame(), comp.clone(), &entity); - } -} - -/// when you need to insert a component at a previous frame, you wrap it up like: -/// InsertComponentAtFrame::::new(frame, shield_component); -/// and this system handles things. -/// not triggering rollbacks here, that will happen if we add or change SS. -pub(crate) fn insert_components_at_prior_frames< - T: TimewarpComponent, - const CORRECTION_LOGGING: bool, ->( - mut q: Query< - ( - Entity, - &InsertComponentAtFrame, - // NOTE the timewarp components might not have been added if this is a first-timer entity - // which is why they have to be Option<> here, in case we need to insert them. - Option<&mut ComponentHistory>, - Option<&mut ServerSnapshot>, - Option<&mut TimewarpStatus>, - ), - Added>, - >, - mut commands: Commands, - timewarp_config: Res, -) { - for (entity, icaf, opt_ch, opt_ss, opt_tw_status) in q.iter_mut() { - // warn!("{icaf:?}"); - let mut ent_cmd = commands.entity(entity); - ent_cmd.remove::>(); - if let Some(mut tw_status) = opt_tw_status { - tw_status.set_snapped_at(icaf.frame); - } else { - ent_cmd.insert(TimewarpStatus::new(icaf.frame)); - } - // if the entity never had this component type T before, we'll need to insert - // the ComponentHistory and ServerSnapshot components. - // NOTE: don't insert historial value into ComponentHistory, only ServerSnapshot. - // let trigger_rollback_when... copy it to CH, or things break. - if let Some(mut ch) = opt_ch { - ch.report_birth_at_frame(icaf.frame); - trace!("Inserting component at past frame for existing ComponentHistory"); - } else { - let mut ch = ComponentHistory::::with_capacity( - timewarp_config.rollback_window as usize, - icaf.frame, - ); - if CORRECTION_LOGGING { - ch.enable_correction_logging(); - } - ent_cmd.insert(ch); - trace!("Inserting component at past frame by inserting new ComponentHistory"); - } - // reminder: inserting a new ServerSnapshot, or adding a value to an existing ServerSnapshot - // will cause a rollback, per the `trigger_rollback_when_snapshot_added` system - if let Some(mut ss) = opt_ss { - // Entity already has a ServerSnapshot component, add our new data - ss.insert(icaf.frame, icaf.component.clone()); - } else { - // Add a new ServerSnapshot component to the entity - let mut ss = - ServerSnapshot::::with_capacity(timewarp_config.rollback_window as usize * 60); // TODO yuk - ss.insert(icaf.frame, icaf.component.clone()); - ent_cmd.insert(ss); - } - } -} - -/// If a new snapshot was added, we may need to initiate a rollback to that frame -pub(crate) fn trigger_rollback_when_snapshot_added( - mut q: Query< - ( - Entity, - &ServerSnapshot, - &mut ComponentHistory, - &mut TimewarpStatus, - ), - Changed>, // this includes Added<> - >, - game_clock: Res, - mut rb_ev: ResMut>, - config: Res, -) { - for (entity, server_snapshot, mut comp_hist, mut tw_status) in q.iter_mut() { - let snap_frame = server_snapshot.values.newest_frame(); - - if snap_frame == 0 { - continue; - } - // if this snapshot is ahead of where we want the entity to be, it's useless to rollback - // this happens for clients with almost no lag. they receive snapshots for frame N+1 while on frame N. - // - // TODO test if we get a snapshot for the frame we just processed.. what if snap_frame == game_clock.frame() - // does the value still get applied? - if snap_frame > game_clock.frame() { - warn!( - "f={:?} {entity:?} Snap frame {snap_frame} > f", - game_clock.frame(), - ); - continue; - } - tw_status.set_snapped_at(snap_frame); - - // insert into comp history, because if we rollback exactly to snap-frame - // the `apply_snapshot_to_component` won't have run, and we need it in there. - let comp_from_snapshot = server_snapshot - .at_frame(snap_frame) - .expect("snap_frame must have a value here"); - - // check if our historical value for the snap_frame is the same as what snapshot says - // because if they match, we predicted successfully, and there's no need to rollback. - if let Some(stored_comp_val) = comp_hist.at_frame(snap_frame) { - if !config.forced_rollback() && *stored_comp_val == *comp_from_snapshot { - // a correct prediction, no need to rollback. hooray! - // info!("skipping rollback ๐ŸŽ–๏ธ {stored_comp_val:?}"); - continue; - } - } - - comp_hist.insert(snap_frame, comp_from_snapshot.clone(), &entity); - - debug!("f={:?} SNAPPING and Triggering rollback due to snapshot. {entity:?} snap_frame: {snap_frame}", game_clock.frame()); - - // trigger a rollback - // - // Although this is the only system that asks for rollbacks, we request them - // by writing to an Event<> and consolidating afterwards. - // It's possible different generic versions of this function - // will want to rollback to different frames, and we can't have them trampling - // over eachother by just inserting the Rollback resoruce directly. - rb_ev.send(RollbackRequest(snap_frame)); - } -} - -// /// if we are at a frame where a snapshot exists, apply the SS value to the component. -// pub(crate) fn apply_snapshot_to_component_if_available( -// mut q: Query<(Entity, &mut T, &mut ComponentHistory, &ServerSnapshot)>, -// game_clock: Res, -// ) { -// for (entity, mut comp, mut comp_history, comp_server) in q.iter_mut() { -// if comp_server.values.newest_frame() == 0 { -// // no data yet -// continue; -// } - -// let verbose = true; // std::any::type_name::().contains("::Position"); - -// // is there a snapshot value for our target_frame? -// if let Some(new_comp_val) = comp_server.values.get(game_clock.frame()) { -// if verbose { -// info!( -// "๐Ÿซฐ f={:?} SNAPPING for {:?}", -// game_clock.frame(), -// std::any::type_name::(), -// ); -// } -// comp_history.insert(game_clock.frame(), new_comp_val.clone(), &entity); -// *comp = new_comp_val.clone(); -// } -// } -// } - -/// when components are removed, we log the death frame -pub(crate) fn record_component_death( - mut removed: RemovedComponents, - mut q: Query<&mut ComponentHistory>, - game_clock: Res, -) { - for entity in &mut removed { - if let Ok(mut ct) = q.get_mut(entity) { - debug!( - "{entity:?} Component death @ {:?} {:?}", - game_clock.frame(), - std::any::type_name::() - ); - ct.report_death_at_frame(game_clock.frame()); - } - } -} - -/// Runs when we detect that the [`Rollback`] resource has been added. -/// we wind back the game_clock to the first frame of the rollback range, and set the fixed period -/// to zero so frames don't require elapsed time to tick. (ie, fast forward mode) -pub(crate) fn rollback_initiated( - mut game_clock: ResMut, - mut rb: ResMut, - mut fx: ResMut, - mut rb_stats: ResMut, - timewarp_config: Res, -) { - // if we're trying to roll back further than our configured rollback window, - // all sorts of things will fail spectacularly, so i'm just going to panic for now. - // i think the way to handle this is in the game, if you get an update from the past older - // than the window that you can't afford to ignore, like a reliable spawn message, then - // perhaps modify the spawn frame to the oldest allowable frame within the window, - // and rely on snapshots to sort you out. - if rb.range.end - rb.range.start > timewarp_config.rollback_window { - panic!("Attempted to rollback further than rollback_window: {rb:?}"); - } - // save original period for restoration after rollback completion - rb.original_period = Some(fx.period); - rb_stats.num_rollbacks += 1; - debug!("๐Ÿ›ผ ROLLBACK RESOURCE ADDED ({}), reseting game clock from {:?} for {:?}, setting period -> 0 for fast fwd.", rb_stats.num_rollbacks, game_clock.frame(), rb); - // make fixed-update ticks free, ie fast-forward the simulation at max speed - fx.period = Duration::ZERO; - game_clock.set(rb.range.start); -} - -/// during rollback, need to re-insert components that were removed, based on stored lifetimes. -pub(crate) fn rebirth_components_during_rollback( - q: Query<(Entity, &ComponentHistory), Without>, - game_clock: Res, - mut commands: Commands, -) { - // info!( - // "reinsert_components_removed_during_rollback_at_correct_frame {:?} {:?}", - // game_clock.frame(), - // std::any::type_name::() - // ); - for (entity, comp_history) in q.iter() { - if comp_history.alive_at_frame(game_clock.frame()) { - let comp_val = comp_history - .at_frame(game_clock.frame()) - .unwrap_or_else(|| { - panic!( - "{entity:?} no comp history @ {:?} for {:?}", - game_clock.frame(), - std::any::type_name::() - ) - }); - trace!( - "Reinserting {entity:?} -> {:?} during rollback @ {:?}\n{:?}", - std::any::type_name::(), - game_clock.frame(), - comp_val - ); - commands.entity(entity).insert(comp_val.clone()); - } else { - trace!( - "comp not alive at this frame for {entity:?} {:?}", - comp_history.alive_ranges - ); - } - } -} - -// during rollback, need to re-remove components that were inserted, based on stored lifetimes. -pub(crate) fn rekill_components_during_rollback( - mut q: Query<(Entity, &mut ComponentHistory), With>, - game_clock: Res, - mut commands: Commands, -) { - for (entity, mut comp_history) in q.iter_mut() { - if !comp_history.alive_at_frame(game_clock.frame()) { - trace!( - "Re-removing {entity:?} -> {:?} during rollback @ {:?}", - std::any::type_name::(), - game_clock.frame() - ); - comp_history.remove_frame_and_beyond(game_clock.frame()); - commands.entity(entity).remove::(); - } - } -} - -/// Runs on first frame of rollback, needs to restore the actual component values to our record of -/// them at that frame. -/// -/// Also has to handle situation where the component didn't exist at the target frame -/// or it did exist, but doesnt in the present. -/// -/// Also note because `rollback_initiated` has already run, the game clock is set to the first -/// rollback frame. So really all we are doing is syncing the actual Components with the values -/// from ComponentHistory at the "current" frame. -pub(crate) fn rollback_component( - rb: Res, - // T is None in case where component removed but ComponentHistory persists - mut q: Query<(Entity, Option<&mut T>, &ComponentHistory), Without>, - mut commands: Commands, - game_clock: Res, -) { - for (entity, opt_comp, comp_hist) in q.iter_mut() { - let verbose = false; - let rollback_frame = rb.range.start; - assert_eq!( - game_clock.frame(), - rollback_frame, - "game clock should already be set back by rollback_initiated" + if **game_clock == 0 && opt_rb.is_some() { + panic!( + "โ›”๏ธ GameClock is on 0, but timewarp wants to rollback. {game_clock:?} rb:{:?}", + opt_rb.unwrap().clone() ); - - let str = format!( - "ROLLBACK {:?} {entity:?} -> {:?} target rollback_frame={rollback_frame}", - std::any::type_name::(), - rb.range, - ); - if !comp_hist.alive_at_frame(rollback_frame) && opt_comp.is_none() { - // info!("{str}\n(dead in present and rollback target, do nothing)"); - // not alive then, not alive now, do nothing. - continue; - } - if !comp_hist.alive_at_frame(rollback_frame) && opt_comp.is_some() { - // not alive then, alive now = remove the component - if verbose { - info!("{str}\n- Not alive in past, but alive in pressent = remove component. alive ranges = {:?}", comp_hist.alive_ranges); - } - commands.entity(entity).remove::(); - continue; - } - if comp_hist.alive_at_frame(rollback_frame) { - if let Some(component) = comp_hist.at_frame(rollback_frame) { - if let Some(mut current_component) = opt_comp { - if verbose { - info!( - "{str}\n- Injecting older data by assigning, {:?} ----> {:?}", - Some(current_component.clone()), - component - ); - } - *current_component = component.clone(); - } else { - if verbose { - info!( - "{str}\n- Injecting older data by reinserting comp, {:?} ----> {:?}", - opt_comp, component - ); - } - commands.entity(entity).insert(component.clone()); - } - } else { - // we chose to rollback to this frame, we would expect there to be data here.. - error!( - "{str}\n- Need to revive/update component, but not in history @ {rollback_frame}. comp_hist range: {:?} alive_ranges: {:?}", - comp_hist.values.current_range(), - comp_hist.alive_ranges, - ); - } - } } -} - -/// If we reached the end of the Rollback range, restore the frame period and cleanup. -/// this will remove the [`Rollback`] resource. -pub(crate) fn check_for_rollback_completion( - game_clock: Res, - rb: Res, - mut commands: Commands, - mut fx: ResMut, -) { - // info!("๐Ÿ›ผ {:?}..{:?} f={:?} (in rollback)", rb.range.start, rb.range.end, game_clock.frame()); - if rb.range.contains(&game_clock.frame()) { - return; - } - // we keep track of the previous rollback mainly for integration tests - commands.insert_resource(PreviousRollback(rb.as_ref().clone())); - debug!( - "๐Ÿ›ผ๐Ÿ›ผ Rollback complete. {:?}, frames: {}", - rb, - rb.range.end - rb.range.start - ); - fx.period = rb.original_period.unwrap(); - commands.remove_resource::(); -} - -/// despawn marker means remove all useful components, pending actual despawn after -/// ROLLBACK_WINDOW frames have elapsed. -pub(crate) fn remove_components_from_despawning_entities( - mut q: Query, With)>, - mut commands: Commands, -) { - for entity in q.iter_mut() { - debug!( - "doing despawn marker component removal for {entity:?} / {:?}", - std::any::type_name::() - ); - // record_component_death looks at RemovedComponents and will catch this, and - // register the death (ie, comphist.report_death_at_frame) - commands.entity(entity).remove::().despawn_descendants(); - // (also despawn any children, which in my game means the mesh/visual bits) - } -} - -/// Once a [`DespawnMarker`] has been around for `rollback_frames`, do the actual despawn. -/// also for new DespawnMarkers that don't have a frame yet, add one. -pub(crate) fn despawn_entities_with_elapsed_despawn_marker( - mut q: Query<(Entity, &mut DespawnMarker)>, - mut commands: Commands, - game_clock: Res, - timewarp_config: Res, -) { - for (entity, mut marker) in q.iter_mut() { - if marker.0.is_none() { - marker.0 = Some(game_clock.frame()); - continue; - } - if (marker.0.expect("Despawn marker should have a frame!") - + timewarp_config.rollback_window) - == game_clock.frame() + if let Some(rb) = opt_rb { + if *prev_frame == **game_clock + && (rb.range.start == *prev_frame && rb.range.end != *prev_frame) { - debug!( - "Doing actual despawn of {entity:?} at frame {:?}", - game_clock.frame() + panic!( + "โ›”๏ธ GameClock not advancing properly, and timewarp wants to rollback. {game_clock:?} rb:{rb:?}" ); - commands.entity(entity).despawn_recursive(); } } + *prev_frame = **game_clock; } diff --git a/src/systems/postfix_components.rs b/src/systems/postfix_components.rs new file mode 100644 index 0000000..f340c23 --- /dev/null +++ b/src/systems/postfix_components.rs @@ -0,0 +1,169 @@ +use crate::prelude::*; +use bevy::prelude::*; +/* + Postfix Sets + + NOTE: Timewarp Postfix Systems run AFTER physics. +*/ + +// debugging.. +#[allow(dead_code)] +fn debug_type() -> bool { + std::any::type_name::().contains("::Position") +} + +/// despawn marker means remove all useful components, pending actual despawn after +/// ROLLBACK_WINDOW frames have elapsed. +pub(crate) fn remove_components_from_despawning_entities( + mut q: Query< + (Entity, &mut ComponentHistory, &DespawnMarker), + (Added, With), + >, + mut commands: Commands, + game_clock: Res, +) { + for (entity, mut ch, dsm) in q.iter_mut() { + trace!( + "doing despawn marker {dsm:?} component removal for {entity:?} / {:?}", + std::any::type_name::() + ); + // record_component_death looks at RemovedComponents and will catch this, and + // register the death (ie, comphist.report_death_at_frame) + commands.entity(entity).remove::(); + ch.report_death_at_frame(game_clock.frame()); + } +} + +/// Write current value of component to the ComponentHistory buffer for this frame +pub(crate) fn record_component_history( + mut q: Query<( + Entity, + &T, + &mut ComponentHistory, + Option<&mut TimewarpCorrection>, + )>, + game_clock: Res, + mut commands: Commands, + opt_rb: Option>, +) { + for (entity, comp, mut comp_hist, opt_correction) in q.iter_mut() { + // if we're in rollback, and on the last frame, we're about to overwrite something. + // we need to preserve it an report a misprediction, if it differs from the new value. + if comp_hist.correction_logging_enabled { + if let Some(ref rb) = opt_rb { + if rb.range.end == game_clock.frame() { + if let Some(old_val) = comp_hist.at_frame(game_clock.frame()) { + if *old_val != *comp { + info!( + "Generating Correction for {entity:?}", //old:{:?} new{:?}", + // old_val, comp + ); + if let Some(mut correction) = opt_correction { + correction.before = old_val.clone(); + correction.after = comp.clone(); + correction.frame = game_clock.frame(); + } else { + commands.entity(entity).insert(TimewarpCorrection:: { + before: old_val.clone(), + after: comp.clone(), + frame: game_clock.frame(), + }); + } + } + } else { + // trace!("End of rb range, but no existing comp to correct"); + // this is normal in the case of spawning a new entity in the past, + // like a bullet. it was never simulated for the current frame yet, so + // it's expected that there wasn't an existing comp history val to replace. + } + } + } + } + // if debug_type::() { + // info!("Recording Position {entity:?} @ {game_clock:?}"); + // } + // the main point of this system is just to save the component value to the buffer: + // insert() does some logging + match comp_hist.insert(game_clock.frame(), comp.clone(), &entity) { + Ok(()) => (), + Err(err) => { + warn!("{err:?} Inserted a too-old frame value in record_component_history @ {game_clock:?} {}", comp_hist.type_name()); + } + } + } +} + +/// add the ComponentHistory and ServerSnapshot whenever an entity gets the T component. +/// NB: you must have called `app.register_rollback::()` for this to work. +pub(crate) fn add_timewarp_components( + q: Query< + (Entity, &T), + ( + Added, + Without, + Without>, + ), + >, + mut commands: Commands, + game_clock: Res, + timewarp_config: Res, +) { + for (e, comp) in q.iter() { + // insert component value at this frame, since the system that records it won't run + // if a rollback is happening this frame. and if it does it just overwrites + let mut comp_history = ComponentHistory::::with_capacity( + timewarp_config.rollback_window as usize, + game_clock.frame(), + comp.clone(), + &e, + ); + if CORRECTION_LOGGING { + comp_history.enable_correction_logging(); + } + trace!( + "Adding ComponentHistory<> to {e:?} for {:?}\nInitial val @ {:?} = {:?}", + std::any::type_name::(), + game_clock.frame(), + comp.clone(), + ); + commands.entity(e).insert(( + TimewarpStatus::new(0), + comp_history, + // server snapshots are sent event n frames, so there are going to be lots of Nones in + // the sequence buffer. increase capacity accordingly. + // TODO compute based on snapshot send rate. + ServerSnapshot::::with_capacity(timewarp_config.rollback_window as usize * 60), // TODO yuk + )); + } +} + +/// record component lifetimes +/// won't be called first time comp is added, since it won't have a ComponentHistory yet. +/// only for comp removed ... then readded birth +/// TODO not sure if we need this birth tracking at all? +pub(crate) fn record_component_birth( + mut q: Query<(Entity, &mut ComponentHistory), (Added, Without)>, + game_clock: Res, + rb: Option>, +) { + // during rollback, components are removed and readded. + // but we don't want to log the same as outside of rollback, we want to ignore. + // however this system still runs, so that the Added filters update their markers + // otherwise things added during rollback would all show as Added the first frame back. + if rb.is_some() { + return; + } + + for (entity, mut ch) in q.iter_mut() { + debug!( + "{entity:?} Component birth @ {:?} {:?}", + game_clock.frame(), + std::any::type_name::() + ); + ch.report_birth_at_frame(**game_clock); + assert!( + ch.at_frame(**game_clock).is_some(), + "Reported birth, but no CH value stored" + ); + } +} diff --git a/src/systems/postfix_during_rollback.rs b/src/systems/postfix_during_rollback.rs new file mode 100644 index 0000000..2ef93dd --- /dev/null +++ b/src/systems/postfix_during_rollback.rs @@ -0,0 +1,48 @@ +use crate::prelude::*; +use bevy::prelude::*; +/* + Postfix Sets + + NOTE: Timewarp Postfix Systems run AFTER physics. +*/ + +/// wipes RemovedComponents queue for component T. +/// useful during rollback, because we don't react to removals that are part of resimulating. +pub(crate) fn clear_removed_components_queue( + mut e: RemovedComponents, + game_clock: Res, +) { + if !e.is_empty() { + debug!( + "Clearing f:{:?} RemovedComponents<{}> during rollback: {:?}", + game_clock.frame(), + std::any::type_name::(), + e.len() + ); + } + e.clear(); +} + +// during rollback, need to re-remove components that were inserted, based on stored lifetimes. +pub(crate) fn rekill_components_during_rollback( + mut q: Query<(Entity, &ComponentHistory), With>, + game_clock: Res, + mut commands: Commands, +) { + let target_frame = game_clock.frame(); + for (entity, comp_history) in q.iter_mut() { + trace!( + "rekill check? {entity:?} CH {} alive_range: {:?}", + comp_history.type_name(), + comp_history.alive_ranges + ); + if !comp_history.alive_at_frame(target_frame) { + debug!( + "Re-removing {entity:?} -> {:?} during rollback for {:?}", + std::any::type_name::(), + target_frame + ); + commands.entity(entity).remove::(); + } + } +} diff --git a/src/systems/postfix_last.rs b/src/systems/postfix_last.rs new file mode 100644 index 0000000..fb5adf9 --- /dev/null +++ b/src/systems/postfix_last.rs @@ -0,0 +1,33 @@ +use crate::prelude::*; +use bevy::prelude::*; +/* + Postfix Sets + + NOTE: Timewarp Postfix Systems run AFTER physics. +*/ + +/// Once a [`DespawnMarker`] has been around for `rollback_frames`, do the actual despawn. +/// also for new DespawnMarkers that don't have a frame yet, add one. +pub(crate) fn despawn_entities_with_elapsed_despawn_marker( + mut q: Query<(Entity, &mut DespawnMarker)>, + mut commands: Commands, + game_clock: Res, + timewarp_config: Res, +) { + for (entity, mut marker) in q.iter_mut() { + if marker.0.is_none() { + marker.0 = Some(game_clock.frame()); + continue; + } + if (marker.0.expect("Despawn marker should have a frame!") + + timewarp_config.rollback_window) + == game_clock.frame() + { + trace!( + "๐Ÿ’€ Doing actual despawn of {entity:?} at frame {:?}", + game_clock.frame() + ); + commands.entity(entity).despawn_recursive(); + } + } +} diff --git a/src/systems/prefix_blueprints.rs b/src/systems/prefix_blueprints.rs new file mode 100644 index 0000000..9c19360 --- /dev/null +++ b/src/systems/prefix_blueprints.rs @@ -0,0 +1,34 @@ +use crate::prelude::*; +use bevy::prelude::*; +/* + NOTE: Timewarp Prefix Systems run at the top of FixedUpdate: + * RIGHT BEFORE THE GameClock IS INCREMENTED. + * Before the game simulation loop + * Before Physics + +*/ + +/// Blueprint components stay wrapped up until their target frame, then we unwrap them +/// so the assembly systems can decorate them with various other components at that frame. +pub(crate) fn unwrap_blueprints_at_target_frame( + q: Query<(Entity, &AssembleBlueprintAtFrame)>, + mut commands: Commands, + game_clock: Res, + rb: Option>, +) { + for (e, abaf) in q.iter() { + // yes, blueprints assembled 1 frame late on clients. see NOTES + if abaf.frame != **game_clock { + // debug!("Not assembling, gc={game_clock:?} {abaf:?}"); + continue; + } + debug!( + "๐ŸŽ {game_clock:?} Unwrapping {abaf:?} @ {game_clock:?} rb:{rb:?} {}", + std::any::type_name::() + ); + commands + .entity(e) + .insert(abaf.component.clone()) + .remove::>(); + } +} diff --git a/src/systems/prefix_check_for_rollback_completion.rs b/src/systems/prefix_check_for_rollback_completion.rs new file mode 100644 index 0000000..a51f236 --- /dev/null +++ b/src/systems/prefix_check_for_rollback_completion.rs @@ -0,0 +1,31 @@ +use crate::prelude::*; +use bevy::prelude::*; +/* + NOTE: Timewarp Prefix Systems run at the top of FixedUpdate: + * RIGHT BEFORE THE GameClock IS INCREMENTED. + * Before the game simulation loop + * Before Physics + +*/ + +/// If we reached the end of the Rollback range, restore the frame period and cleanup. +/// this will remove the [`Rollback`] resource. +pub(crate) fn check_for_rollback_completion( + game_clock: Res, + rb: Res, + mut commands: Commands, + mut fx: ResMut, +) { + if rb.range.end != **game_clock { + return; + } + // we keep track of the previous rollback mainly for integration tests + commands.insert_resource(PreviousRollback(rb.as_ref().clone())); + info!( + "๐Ÿ›ผ๐Ÿ›ผ Rollback complete. {:?}, frames: {} gc:{game_clock:?}", + rb, + rb.range.end - rb.range.start + ); + fx.period = rb.original_period.unwrap(); + commands.remove_resource::(); +} diff --git a/src/systems/prefix_check_if_rollback_needed.rs b/src/systems/prefix_check_if_rollback_needed.rs new file mode 100644 index 0000000..b6e22df --- /dev/null +++ b/src/systems/prefix_check_if_rollback_needed.rs @@ -0,0 +1,234 @@ +/* + NOTE: Timewarp Prefix Systems run at the top of FixedUpdate: + * RIGHT BEFORE THE GameClock IS INCREMENTED. + * Before the game simulation loop + * Before Physics + +*/ +use crate::prelude::*; +use bevy::prelude::*; + +/// Don't insert ICAFs if the SS exists, use the SS. +/// can probably support this later, but keeps things simpler for now. +pub(crate) fn detect_misuse_of_icaf( + q: Query<(Entity, &ServerSnapshot, &InsertComponentAtFrame)>, +) { + for (e, _ss, icaf) in q.iter() { + panic!("ICAF and SS exist on {e:?} {icaf:?}"); + } +} + +/// If a new snapshot was added to SS, we may need to initiate a rollback +pub(crate) fn apply_snapshots_and_maybe_rollback( + mut q: Query< + ( + Entity, + &ServerSnapshot, + &mut ComponentHistory, + &mut TimewarpStatus, + ), + Changed>, // this includes Added<> + >, + game_clock: Res, + mut rb_ev: ResMut>, + config: Res, + mut commands: Commands, + mut rb_stats: ResMut, +) { + for (entity, server_snapshot, mut comp_hist, mut tw_status) in q.iter_mut() { + let snap_frame = server_snapshot.values.newest_frame(); + + if snap_frame == 0 { + continue; + } + + tw_status.set_snapped_at(snap_frame); + + // the value in the SS that we are concerned with, which may possibly trigger a rollback: + let comp_from_snapshot = server_snapshot + .at_frame(snap_frame) + .expect("snap_frame must have a value here"); + + // we're in preudpate, the game clock is about to be incremented. + // so if the snap frame = current clock, we need it inserted right now without rolling back + // in this case, we don't need to write to comp_hist either, it will happen normally at the end of the frame. + if snap_frame == **game_clock { + trace!("Inserting latecomer {entity:?} {comp_from_snapshot:?} @ {snap_frame}"); + commands.entity(entity).insert(comp_from_snapshot.clone()); + rb_stats.non_rollback_updates += 1; + continue; + } + + // check if our historical value for the snap_frame is the same as what snapshot says + // because if they match, we predicted successfully, and there's no need to rollback. + if let Some(stored_comp_val) = comp_hist.at_frame(snap_frame) { + if !config.forced_rollback() && *stored_comp_val == *comp_from_snapshot { + // a correct prediction, no need to rollback. hooray! + trace!("skipping rollback ๐ŸŽ–๏ธ {entity:?} {stored_comp_val:?}"); + continue; + } + } + + // need to update comp_hist, since that's where it's loaded from if we rollback. + match comp_hist.insert(snap_frame, comp_from_snapshot.clone(), &entity) { + Ok(()) => (), + Err(err) => { + // probably FrameTooOld. + error!( + "{err:?} {entity:?} apply_snapshots_and_maybe_rollback({}) - skipping", + comp_hist.type_name() + ); + rb_stats.range_faults += 1; + // we can't rollback to this + // this is bad. + continue; + } + } + + if !comp_hist.alive_at_frame(snap_frame) { + info!("Setting liveness for {snap_frame} {entity:?} {comp_from_snapshot:?} "); + comp_hist.report_birth_at_frame(snap_frame); + assert!(comp_hist.at_frame(snap_frame).is_some()); + } + + if snap_frame < **game_clock { + debug!( + "Triggering rollback due to snapshot. {entity:?} snap_frame: {snap_frame} {}", + comp_hist.type_name() + ); + + // data for frame 100 is the post-physics value at the server, so we need it to be + // inserted in time for the client to simulate frame 101. + rb_ev.send(RollbackRequest::resimulate_this_frame_onwards( + snap_frame + 1, + )); + } + } +} + +/// Move ICAF data to the SS. +/// +/// if an ICAF was inserted, we may need to rollback. +/// +pub(crate) fn unpack_icafs_and_maybe_rollback< + T: TimewarpComponent, + const CORRECTION_LOGGING: bool, +>( + q: Query<(Entity, &InsertComponentAtFrame), Added>>, + mut commands: Commands, + timewarp_config: Res, + game_clock: Res, + mut rb_ev: ResMut>, +) { + for (e, icaf) in q.iter() { + // insert the timewarp components + let tw_status = TimewarpStatus::new(icaf.frame); + let mut ch = ComponentHistory::::with_capacity( + timewarp_config.rollback_window as usize, + icaf.frame, + icaf.component.clone(), + &e, + ); + if CORRECTION_LOGGING { + ch.enable_correction_logging(); + } + // TODO SS = yuk, sparse. use better data structure + let mut ss = + ServerSnapshot::::with_capacity(timewarp_config.rollback_window as usize * 60); + ss.insert(icaf.frame, icaf.component.clone()).unwrap(); + // (this will be applied in the ApplyComponents set next) + commands + .entity(e) + .insert((tw_status, ch, ss)) + .remove::>(); + + // if frames match, we want it inserted this frame but not rolled back + if icaf.frame == **game_clock { + // info!("Inserting latecomer in trigger icafs: {e:?} {icaf:?}"); + commands.entity(e).insert(icaf.component.clone()); + continue; + } + + if icaf.frame < **game_clock { + // trigger a rollback using the frame we just added authoritative values for + debug!( + "{e:?} trigger_rollback_when_icaf_added {icaf:?} requesting rb to {}", + icaf.frame + 1 + ); + rb_ev.send(RollbackRequest::resimulate_this_frame_onwards( + icaf.frame + 1, + )); + } + } +} + +pub(crate) fn request_rollback_for_blueprints( + q: Query<(Entity, &AssembleBlueprintAtFrame), Added>>, + game_clock: Res, + mut rb_ev: ResMut>, +) { + for (entity, abaf) in q.iter() { + let snap_frame = abaf.frame; + // if frames == match, we want it inserted this frame but not rolled back. + // don't do this here, the blueprint unpacking fn does this even during rollback. + // all we have to do is trigger a rollback, and it'll be unpacked for us. + if snap_frame < **game_clock { + debug!( + "{game_clock:?} {entity:?} Requesting rollback for blueprint with snap_frame:{snap_frame} - {abaf:?}" + ); + rb_ev.send(RollbackRequest::resimulate_this_frame_onwards( + snap_frame + 1, + )); + } + } +} + +/// potentially-concurrent systems request rollbacks by writing a request +/// to the Events, which we drain and use the smallest +/// frame that was requested - ie, covering all requested frames. +/// +pub(crate) fn consolidate_rollback_requests( + mut rb_events: ResMut>, + mut commands: Commands, + conf: Res, + game_clock: Res, +) { + if rb_events.is_empty() { + return; + } + /* + Say the client is in PreUpdate, with clock at 100. + There are 2 replicon packets to process which we just read from the network in this order: + * Updates for frame 95 + * Updates for frame 96 + + Client processes first packet: inserts values into SS for frame 95, and request rollbacks to 95+1 + Client processes second packet: inserts values into SS for frame 96, and request rollbacks to 96+1 + + If we are sure we're getting entire world updates per packet โ€“ which we are with replicon + as of october 2023, then it's safe to rollback to the most recent frame. + + if we get partial updates per packet - ie not all entities included per tick - then we need + to rollback to the oldest requested frame, or we might miss data for entities that were + included in the first packet (@95) but not in the second (@96). + + if've not really tested the second scenario yet, because replicon uses whole-world updates atm. + */ + let mut rb_frame: FrameNumber = 0; + // NB: a manually managed event queue, which we drain here + for ev in rb_events.drain() { + match conf.consolidation_strategy() { + RollbackConsolidationStrategy::Newest => { + if rb_frame == 0 || ev.frame() > rb_frame { + rb_frame = ev.frame(); + } + } + RollbackConsolidationStrategy::Oldest => { + if rb_frame == 0 || ev.frame() < rb_frame { + rb_frame = ev.frame(); + } + } + } + } + commands.insert_resource(Rollback::new(rb_frame, game_clock.frame())); +} diff --git a/src/systems/prefix_during_rollback.rs b/src/systems/prefix_during_rollback.rs new file mode 100644 index 0000000..6b00ff6 --- /dev/null +++ b/src/systems/prefix_during_rollback.rs @@ -0,0 +1,69 @@ +use crate::prelude::*; +use bevy::prelude::*; +/* + NOTE: Timewarp Prefix Systems run at the top of FixedUpdate: + * RIGHT BEFORE THE GameClock IS INCREMENTED. + * Before the game simulation loop + * Before Physics + +*/ + +/// when components are removed, we log the death frame +pub(crate) fn record_component_death( + mut removed: RemovedComponents, + mut q: Query<&mut ComponentHistory>, + game_clock: Res, +) { + for entity in &mut removed { + if let Ok(mut ct) = q.get_mut(entity) { + debug!( + "{entity:?} Component death @ {:?} {:?}", + game_clock.frame(), + std::any::type_name::() + ); + ct.report_death_at_frame(game_clock.frame()); + } + } +} + +/// during rollback, need to re-insert components that were removed, based on stored lifetimes. +pub(crate) fn rebirth_components_during_rollback( + q: Query<(Entity, &ComponentHistory, Option<&OriginFrame>), Without>, + game_clock: Res, + mut commands: Commands, + rb: Res, +) { + for (entity, comp_history, opt_originframe) in q.iter() { + let target_frame = game_clock.frame().max(opt_originframe.map_or(0, |of| of.0)); + if comp_history.alive_at_frame(target_frame) { + // we could go fishing in SS for this, but it should be here if its alive. + // i think i'm only hitting this with rollback underflows though, during load? + // need more investigation and to figure out a test case.. + let comp_val = comp_history.at_frame(target_frame).unwrap_or_else(|| { + error!( + // gaps in CH values, can't rb to a gap? + "{entity:?} no comp history for {:?} for {:?} focc:{:?} {game_clock:?} {rb:?}", + target_frame, + std::any::type_name::(), + comp_history.values.frame_occupancy(), + ); + error!("alive_ranges: {:?}", comp_history.alive_ranges); + panic!("death"); + }); + + debug!( + "Reinserting {entity:?} -> {:?} during rollback for {:?}\n{:?}", + std::any::type_name::(), + target_frame, + comp_val + ); + commands.entity(entity).insert(comp_val.clone()); + } else { + trace!( + "comp not alive at {game_clock:?} for {entity:?} {:?} {}", + comp_history.alive_ranges, + std::any::type_name::(), + ); + } + } +} diff --git a/src/systems/prefix_first.rs b/src/systems/prefix_first.rs new file mode 100644 index 0000000..2b65474 --- /dev/null +++ b/src/systems/prefix_first.rs @@ -0,0 +1,17 @@ +/* + NOTE: Timewarp Prefix Systems run at the top of FixedUpdate: + * RIGHT BEFORE THE GameClock IS INCREMENTED. + * Before the game simulation loop + * Before Physics + +*/ +use crate::prelude::*; +use bevy::prelude::*; + +pub(crate) fn enable_error_correction_for_new_component_histories( + mut q: Query<&mut ComponentHistory, Added>>, +) { + for mut ch in q.iter_mut() { + ch.enable_correction_logging(); + } +} diff --git a/src/systems/prefix_start_rollback.rs b/src/systems/prefix_start_rollback.rs new file mode 100644 index 0000000..22e6856 --- /dev/null +++ b/src/systems/prefix_start_rollback.rs @@ -0,0 +1,194 @@ +use crate::prelude::*; +use bevy::prelude::*; +use std::time::Duration; +/* + NOTE: Timewarp Prefix Systems run at the top of FixedUpdate: + * RIGHT BEFORE THE GameClock IS INCREMENTED. + * Before the game simulation loop + * Before Physics + +*/ + +/// Runs when we detect that the [`Rollback`] resource has been added. +/// +/// The start of the rollback +/// we wind back the game_clock to the first frame of the rollback range, and set the fixed period +/// to zero so frames don't require elapsed time to tick. (ie, fast forward mode) +pub(crate) fn rollback_initiated( + mut game_clock: ResMut, + mut rb: ResMut, + mut fx: ResMut, + mut rb_stats: ResMut, + timewarp_config: Res, +) { + // during syncing large worlds, we kinda have to skip impossible rollbacks until things catch up + // TODO revise comment: + // if we're trying to roll back further than our configured rollback window, + // all sorts of things will fail spectacularly, so i'm just going to panic for now. + // i think the way to handle this is in the game, if you get an update from the past older + // than the window that you can't afford to ignore, like a reliable spawn message, then + // perhaps modify the spawn frame to the oldest allowable frame within the window, + // and rely on snapshots to sort you out. + if rb.range.end - rb.range.start >= timewarp_config.rollback_window { + panic!( + "โš ๏ธโš ๏ธโš ๏ธ Attempted to rollback further than rollback_window: {rb:?} @ {:?}", + game_clock.frame() + ); + // error!("โš ๏ธโš ๏ธโš ๏ธ Ignoring this rollback request. ๐Ÿ›ผ "); + // TODO this isn't really safe - what if there was an ICAF or ABAF and then it never + // gets unpacked because it was outside the window. + // perhaps we need to mark the RB as "desperate", rollback to the oldest frame, + // and unpack anything destined for an even older (oob) frame that go around. + // at least unpack the ABAF ones, maybe don't care about SS in a desperate rollback. + // commands.remove_resource::(); + // return; + } + // save original period for restoration after rollback completion + rb.original_period = Some(fx.period); + rb_stats.num_rollbacks += 1; + // we wind clock back 1 past first resim frame, so we can load in data for the frame prior + // so we go into our first resim frame with components in the correct state. + let reset_game_clock_to = rb.range.start.saturating_sub(1); + info!("๐Ÿ›ผ ROLLBACK RESOURCE ADDED (rb#{}), reseting game clock from {game_clock:?}-->{reset_game_clock_to} rb:{rb:?}", + rb_stats.num_rollbacks); + // make fixed-update ticks free, ie fast-forward the simulation at max speed + fx.period = Duration::ZERO; + // the start of the rb range is the frame with the newly added authoritative data. + // since increment happens after the timewarp prefix sets, we set the clock to this value - 1, + // knowing that it will immediately be incremented to the next frame we need to simulate. + // (once we've loaded in historical component values) + game_clock.set(reset_game_clock_to); +} + +// for clarity when rolling back components +#[derive(Debug)] +enum Provenance { + AliveThenAlive, + AliveThenDead, + DeadThenAlive, + DeadThenDead, +} + +/// Runs if Rollback was only just Added. +/// A rollback range starts on the frame we added new authoritative data for, so we need to +/// restore component values to what they were at that frame, so the next frame can be resimulated. +/// +/// Also has to handle situation where the component didn't exist then, or it did exist, but doesnt in the present. +pub(crate) fn rollback_component( + rb: Res, + // T is None in case where component removed but ComponentHistory persists + mut q: Query< + ( + Entity, + Option<&mut T>, + &ComponentHistory, + Option<&OriginFrame>, + ), + Without, + >, + mut commands: Commands, + game_clock: Res, +) { + for (entity, opt_comp, comp_hist, opt_originframe) in q.iter_mut() { + let rollback_frame = if let Some(OriginFrame(of)) = opt_originframe { + game_clock.frame().max(*of) + } else { + **game_clock + }; + + let prefix = if rollback_frame != **game_clock { + warn!( + "๐Ÿ˜ฌ rollback_component {entity:?} {game_clock:?} rollback_frame:{rollback_frame} {}", + comp_hist.type_name() + ); + "๐Ÿ˜ฌ" + } else { + "" + }; + let provenance = match ( + comp_hist.alive_at_frame(rollback_frame), + comp_hist.alive_at_frame(**game_clock), + ) { + (true, true) => Provenance::AliveThenAlive, + (true, false) => Provenance::AliveThenDead, + (false, true) => Provenance::DeadThenAlive, + (false, false) => Provenance::DeadThenDead, + }; + + trace!( + "โ›ณ๏ธ {prefix} {entity:?} {} CH alive_ranges: {:?}", + comp_hist.type_name(), + comp_hist.alive_ranges + ); + + match provenance { + Provenance::DeadThenDead => { + trace!( + "{prefix} {game_clock:?} rollback component {entity:?} {} {provenance:?} - NOOP {:?}", + comp_hist.type_name(), + comp_hist.alive_ranges + ); + } + Provenance::DeadThenAlive => { + trace!( + "{prefix} {game_clock:?} rollback component {entity:?} {} {provenance:?} - REMOVE", + comp_hist.type_name() + ); + commands.entity(entity).remove::(); + } + Provenance::AliveThenAlive => { + // TODO we might want a general way to check the oldest frame for this comp, + // and if we dont have the requested frame, use the oldest instead? + // assuming a request OLDER than the requested can't be serviced. + let comp_at_frame = comp_hist.at_frame(rollback_frame); + + // debugging + if comp_at_frame.is_none() { + let oldest_frame = comp_hist.values.oldest_frame(); + + error!( + "HMMMM f @ oldest_frame ({oldest_frame}) comp_val = {:?}", + comp_hist.at_frame(oldest_frame) + ); + error!("HMMMM {game_clock:?} OPT_COMP = {opt_comp:?}"); + for f in (rollback_frame - 2)..=(rollback_frame + 2) { + error!("HMMMM f={f} comp_val = {:?}", comp_hist.at_frame(f)); + } + + panic!("{prefix} {game_clock:?} {entity:?} {provenance:?} {} rollback_frame: {rollback_frame} alive_ranges:{:?} rb:{rb:?} oldest value in comp_hist: {oldest_frame} occ:{:?}\n", + comp_hist.type_name(), comp_hist.alive_ranges, comp_hist.values.frame_occupancy()); + } + // + let comp_val = comp_at_frame.unwrap().clone(); + trace!( + "{prefix} {game_clock:?} rollback component {entity:?} {} {provenance:?} - REPLACE WITH {comp_val:?}", + comp_hist.type_name() + ); + if let Some(mut comp) = opt_comp { + *comp = comp_val; + } else { + // during new spawns this happens. not a bug. + trace!( + "{prefix} {entity:?} Actually having to insert for {comp_val:?} doesn't exist yet" + ); + commands.entity(entity).insert(comp_val); + } + } + Provenance::AliveThenDead => { + let comp_at_frame = comp_hist.at_frame(rollback_frame); + // debugging + if comp_at_frame.is_none() { + panic!("{game_clock:?} {entity:?} {provenance:?} {} rollback_frame: {rollback_frame} alive_ranges:{:?} rb:{rb:?}", + comp_hist.type_name(), comp_hist.alive_ranges); + } + // + let comp_val = comp_at_frame.unwrap().clone(); + trace!( + "{prefix} {game_clock:?} rollback component {entity:?} {} {provenance:?} - INSERT {comp_val:?}", + comp_hist.type_name() + ); + commands.entity(entity).insert(comp_val); + } + } + } +} diff --git a/src/traits.rs b/src/traits.rs index 54aa955..624b374 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -1,5 +1,5 @@ use crate::systems::*; -use bevy::prelude::*; +use bevy::{ecs::world::EntityMut, prelude::*}; use super::*; @@ -28,6 +28,7 @@ pub trait TimewarpTraits { fn register_rollback_with_options( &mut self, ) -> &mut Self; + fn register_blueprint(&mut self) -> &mut Self; } impl TimewarpTraits for App { @@ -37,6 +38,31 @@ impl TimewarpTraits for App { fn register_rollback_with_correction_logging(&mut self) -> &mut Self { self.register_rollback_with_options::() } + fn register_blueprint(&mut self) -> &mut Self { + let config = self + .world + .get_resource::() + .expect("TimewarpConfig resource expected"); + let schedule = config.schedule(); + // when we rollback, unpack anything wrapped up for this frame. + // this handles the case where we are rolling back because of a wrapped blueprint, and + // we hit the exact frame to unwrap it like this: + self.add_systems( + schedule.clone(), + // this apply_deferred is a hack so Res is visible for debugging in this systeem + ( + apply_deferred, + prefix_blueprints::unwrap_blueprints_at_target_frame::, + ) + .in_set(TimewarpPrefixSet::UnwrapBlueprints), + ); + self.add_systems( + schedule.clone(), + prefix_check_if_rollback_needed::request_rollback_for_blueprints:: + .before(prefix_check_if_rollback_needed::consolidate_rollback_requests) + .in_set(TimewarpPrefixSet::CheckIfRollbackNeeded), + ) + } fn register_rollback_with_options( &mut self, ) -> &mut Self { @@ -46,63 +72,138 @@ impl TimewarpTraits for App { .expect("TimewarpConfig resource expected"); let schedule = config.schedule(); - self - // we want to record frame values even if we're about to rollback - - // we need values pre-rb to diff against post-rb versions. - // --- - // TimewarpSet::RecordComponentValues - // * Runs always - // --- - .add_systems( + /* + Prefix Systems + */ + if CORRECTION_LOGGING { + self.add_systems( schedule.clone(), - ( - add_timewarp_buffer_components::, - // Recording component births. this does the Added<> query, and bails if in rollback - // so that the Added query is refreshed. - record_component_history::, - insert_components_at_prior_frames::, - record_component_birth::, - remove_components_from_despawning_entities:: - .after(record_component_history::), - ) - .in_set(TimewarpSet::RecordComponentValues), + prefix_first::enable_error_correction_for_new_component_histories:: + .in_set(TimewarpPrefixSet::First), + ); + } + self.add_systems( + schedule.clone(), // TODO RJRJR move to _first file? + prefix_check_if_rollback_needed::detect_misuse_of_icaf:: + .in_set(TimewarpPrefixSet::First), + ); + self.add_systems( + schedule.clone(), // TODO RJRJ MOVE FILE + prefix_during_rollback::record_component_death:: + .run_if(not(resource_exists::())) + .in_set(TimewarpPrefixSet::First), // RJRJRJ X + ); + self.add_systems( + schedule.clone(), + ( + // prefix_during_rollback::record_component_death::, + prefix_during_rollback::rebirth_components_during_rollback::, ) - // --- - // TimewarpSet::RollbackUnderwayComponents - // * run_if(resource_exists(Rollback)) - // --- - .add_systems( - schedule.clone(), - ( - rekill_components_during_rollback::, - rebirth_components_during_rollback::, - clear_removed_components_queue:: - .after(rekill_components_during_rollback::), - ) - .in_set(TimewarpSet::RollbackUnderwayComponents), + .in_set(TimewarpPrefixSet::DuringRollback), + ); + // this may result in a Rollback resource being inserted. + self.add_systems( + schedule.clone(), + ( + prefix_check_if_rollback_needed::detect_misuse_of_icaf::, + prefix_check_if_rollback_needed::unpack_icafs_and_maybe_rollback::< + T, + CORRECTION_LOGGING, + >, + prefix_check_if_rollback_needed::apply_snapshots_and_maybe_rollback::, ) - // --- - // TimewarpSet::RollbackInitiated - // * run_if(resource_added(Rollback)) - // --- - .add_systems( - schedule.clone(), - rollback_component:: - .after(rollback_initiated) - .in_set(TimewarpSet::RollbackInitiated), + .before(prefix_check_if_rollback_needed::consolidate_rollback_requests) + .in_set(TimewarpPrefixSet::CheckIfRollbackNeeded), + ); + self.add_systems( + schedule.clone(), + (prefix_start_rollback::rollback_component::,) + .in_set(TimewarpPrefixSet::StartRollback) + .after(prefix_start_rollback::rollback_initiated), + ); + + /* + Postfix Systems + */ + self.add_systems( + schedule.clone(), + ( + postfix_components::remove_components_from_despawning_entities::, + postfix_components::record_component_history::, + postfix_components::add_timewarp_components::, + postfix_components::record_component_birth::, ) - // --- - // TimewarpSet::NoRollback - // * run_if(not(resource_exists(Rollback))) - // --- - .add_systems( - schedule.clone(), - ( - record_component_death::, - trigger_rollback_when_snapshot_added::, - ) - .before(consolidate_rollback_requests) - .in_set(TimewarpSet::NoRollback), + .in_set(TimewarpPostfixSet::Components), + ); + self.add_systems( + schedule.clone(), + ( + postfix_during_rollback::rekill_components_during_rollback::, + postfix_during_rollback::clear_removed_components_queue::, ) + .in_set(TimewarpPostfixSet::DuringRollback), + ) + } +} + +pub enum InsertComponentResult { + /// means the SS already existed + IntoExistingSnapshot, + /// had to add the timewarp components. SS, CH. + ComponentsAdded, +} + +/// This exists to make my replicon custom deserializing functions nicer. +/// in theory you can do this with checks for SS or InsertComponentAtFrame everywhere. +pub trait TimewarpEntityMutTraits { + /// For inserting a component into a specific frame. + /// Timewarp systems will insert into the entity at the correct point. + fn insert_component_at_frame( + &mut self, + frame: FrameNumber, + component: &T, + ) -> Result; +} + +impl TimewarpEntityMutTraits for EntityMut<'_> { + fn insert_component_at_frame( + &mut self, + frame: FrameNumber, + component: &T, + ) -> Result { + if let Some(mut ss) = self.get_mut::>() { + ss.insert(frame, component.clone())?; + Ok(InsertComponentResult::IntoExistingSnapshot) + } else { + let tw_config = self + .world() + .get_resource::() + .expect("TimewarpConfig resource missing"); + let window_size = tw_config.rollback_window() as usize; + // insert component value at this frame, since the system that records it won't run + // if a rollback is happening this frame. and if it does it just overwrites + let comp_history = ComponentHistory::::with_capacity( + // timewarp_config.rollback_window as usize, + window_size, + frame, + component.clone(), + &self.id(), + ); + + let mut ss = ServerSnapshot::::with_capacity(window_size * 60); + ss.insert(frame, component.clone()) + .expect("fresh one can't fail"); + // (tw system sets correction logging for us later, if needed) + info!( + "Adding SS/CH to {:?} for {}\nInitial val @ {:?} = {:?}", + self.id(), + std::any::type_name::(), + frame, + component.clone(), + ); + + self.insert((comp_history, ss, TimewarpStatus::new(frame))); + Ok(InsertComponentResult::ComponentsAdded) + } } } diff --git a/tests/basic_rollback.rs b/tests/basic_rollback.rs index 2a05c9c..e81eb76 100644 --- a/tests/basic_rollback.rs +++ b/tests/basic_rollback.rs @@ -131,12 +131,18 @@ fn basic_rollback() { // our app's netcode would insert the authoritative (slightly outdated) values into ServerSnapshots: let mut ss_e2 = app.world.get_mut::>(e2).unwrap(); - ss_e2.insert(2, Enemy { health: 100 }); + ss_e2.insert(2, Enemy { health: 100 }).unwrap(); // this message will be processed in the next tick - frame 5. + let gc = app.world.get_resource::().unwrap(); + assert_eq!(gc.frame(), 4); + tick(&mut app); // frame 5 + let gc = app.world.get_resource::().unwrap(); + assert_eq!(gc.frame(), 5); + // frame 5 should run normally, then rollback systems will run, effect a rollback, // and resimulate from f2 assert_eq!( @@ -202,7 +208,7 @@ fn basic_rollback() { let mut ss_e2 = app.world.get_mut::>(e2).unwrap(); // we know from the asserts above that health of e2 was 97 at frame 5. // so lets make the server confirm that: - ss_e2.insert(5, Enemy { health: 97 }); + ss_e2.insert(5, Enemy { health: 97 }).unwrap(); tick(&mut app); // frame 8, potential rollback diff --git a/tests/component_add_and_remove.rs b/tests/component_add_and_remove.rs index 77d8ec7..91aa73f 100644 --- a/tests/component_add_and_remove.rs +++ b/tests/component_add_and_remove.rs @@ -112,7 +112,8 @@ fn component_add_and_remove() { ); let prb = app.world.get_resource::().unwrap(); - assert_eq!(prb.0.range.start, 3); + // last rollback should have resimualted from 4, since we modified something at 3. + assert_eq!(prb.0.range.start, 4); // health should not have reduced since shield was added at f3 assert_eq!(app.comp_val_at::(e1, 5).unwrap().health, 7); @@ -144,9 +145,16 @@ fn component_add_and_remove() { // this tests the following two slightly different code paths: // * add component at old frame where entity never had this component before // * add component at old frame where entity used to have this comp but doesn't atm - app.world - .entity_mut(e1) - .insert(InsertComponentAtFrame::::new(8, Shield)); + + let new_shield = Shield; + + let mut ss_e1 = app.world.get_mut::>(e1).unwrap(); + ss_e1.insert(8, new_shield).unwrap(); + + // PANICs on purpose atm, don't support ICAF if SS present. + // app.world + // .entity_mut(e1) + // .insert(InsertComponentAtFrame::::new(8, new_shield)); tick(&mut app); // frame 10 @@ -159,7 +167,7 @@ fn component_add_and_remove() { ); assert_eq!(app.comp_val_at::(e1, 8).unwrap().health, 5); - assert_eq!(app.comp_val_at::(e1, 9).unwrap().health, 5); + assert_eq!(app.comp_val_at::(e1, 9).unwrap().health, 5); // x assert_eq!(app.comp_val_at::(e1, 10).unwrap().health, 5); assert_eq!(app.world.get::(e1).unwrap().health, 5); diff --git a/tests/despawning.rs b/tests/despawning.rs index 401f724..6043766 100644 --- a/tests/despawning.rs +++ b/tests/despawning.rs @@ -146,12 +146,19 @@ fn despawn_revival_during_rollback() { // timewarp systems run after game logic, and remove components, which will be done by f5. // TODO perhaps we want to move the despawn systems to a timewarp header set before game logic? // this would make the behaviour seem more sane? hmm. + + // when we get a "despawn @ 4" from the server, it means: + // + // The Despawn happened during frame 4, and was detected in PostUpdate. + // so the entity should always exist at the start of frame 4, + // but should not exist at the start of frame 5. + let despawn_frame = 4; app.world .entity_mut(e1) .insert(DespawnMarker::for_frame(despawn_frame)); - tick(&mut app); // frame 4 + tick(&mut app); // frame 4 - "sometime during frame 4, we despawned e1" assert!( app.world.get_entity(e1).is_some(), @@ -164,7 +171,7 @@ fn despawn_revival_during_rollback() { // generate a rollback that should revive the component temporarily let mut ss_e2 = app.world.get_mut::>(e1).unwrap(); - ss_e2.insert(2, Enemy { health: 100 }); + ss_e2.insert(2, Enemy { health: 100 }).unwrap(); tick(&mut app); // frame 5 -- 1 of rollback_window until despawn @@ -188,10 +195,14 @@ fn despawn_revival_during_rollback() { // even though the Enemy component doesn't exist on e1 now, we can see a rollback happened // because the buffered older values have changed in accordance with the ServerSnapshot: assert_eq!(app.comp_val_at::(e1, 2).unwrap().health, 100); - assert_eq!(app.comp_val_at::(e1, 3).unwrap().health, 99); + assert_eq!(app.comp_val_at::(e1, 3).unwrap().health, 99); // x + assert!( + app.comp_val_at::(e1, despawn_frame).is_some(), + "should exist at the start of the frame it's despawned on" + ); assert!( - app.comp_val_at::(e1, despawn_frame).is_none(), - "should have been despawned" + app.comp_val_at::(e1, despawn_frame + 1).is_none(), + "should have been despawned at despawn_frame + 1" ); assert!( diff --git a/tests/error_corrections.rs b/tests/error_corrections.rs index fbefd88..64444d0 100644 --- a/tests/error_corrections.rs +++ b/tests/error_corrections.rs @@ -79,9 +79,10 @@ fn error_correction() { // by now, these should be current values assert_eq!(app.world.get::(e1).unwrap().health, 6); + // note for later: after tick 4, E1's health is 6. assert_eq!(app.comp_val_at::(e1, 4).unwrap().health, 6); - // let's pretend during frame 5 we get a message from the server saying that on frame 2, E1 + // let's pretend between frames 4 and 5 we get a message from the server saying that on frame 2, E1 // ate a powerup, changing his health to 100. // our app's netcode would insert the authoritative (slightly outdated) values into ServerSnapshots. // then, the trigger_rollback_when_snapshot_added system would detect that @@ -89,7 +90,7 @@ fn error_correction() { // diff_at_frame flag for the current frame, so a TimewarpCorrection is generated. let mut ss_e1 = app.world.get_mut::>(e1).unwrap(); - ss_e1.insert(2, Enemy { health: 100 }); + ss_e1.insert(2, Enemy { health: 100 }).unwrap(); // this message will be processed in the next tick - frame 5. // prior to this there shouldn't be a TimewarpCorrection component, @@ -120,17 +121,23 @@ fn error_correction() { // now the meat of this test - we check that the before/after component values are correct // either side of the rollback that just happened on tick 5 // - // NB we actually can't view the pre-rollback comp value @ 5 because within the tick the - // rollback runs and replaces it before the end of the tick. + // Note: during the last tick, we started the tick having calcualted tick 4 previously. + // then rolled back applying new values, resimulated 3 & 4, then simulated 5 for the first time. + // + // so we can't give a diff between what 5 would have been, and the new 5. + // seems wasteful to simulate frame 5 twice in this situation. + // + // instead, we're given the correction for the most recently simulated frame that got replaced, + // eg, frame 4. // // we already asserted that at tick 4 E1's health was 6, so we'd expect it to be 5 at tick 5. let twc = app.world.get::>(e1).unwrap(); // component values before/after the rollback - assert_eq!(twc.before.health, 5); - assert_eq!(twc.after.health, 97); - assert_eq!(twc.frame, 5); - warn!("{twc:?}"); + assert_eq!(twc.before.health, 6); + assert_eq!(twc.after.health, 98); + assert_eq!(twc.frame, 4); + // NB rendering is happening in PostUpdate, which runs after FixedUpdate // * FixedUpdate @ 4 (normal frame) // * PostUpdate render @@ -155,9 +162,9 @@ fn error_correction() { // correction values shouldn't have changed โ€“ there was no rollback that frame let twc = app.world.get::>(e1).unwrap(); - assert_eq!(twc.before.health, 5); - assert_eq!(twc.after.health, 97); - assert_eq!(twc.frame, 5); + assert_eq!(twc.before.health, 6); + assert_eq!(twc.after.health, 98); + assert_eq!(twc.frame, 4); tick(&mut app); // frame 7 tick(&mut app); // frame 8 @@ -170,7 +177,7 @@ fn error_correction() { // supply frame 7 value at known local value, ie server confirms our simulation value let mut ss_e1 = app.world.get_mut::>(e1).unwrap(); - ss_e1.insert(7, Enemy { health: 95 }); + ss_e1.insert(7, Enemy { health: 95 }).unwrap(); tick(&mut app); // frame 10 - rollback? no. should be bypassed because prediction was right @@ -188,5 +195,5 @@ fn error_correction() { // no correction should be created since server confirmed predicted value, // thus the frame on the TimewarpCorrection should still be 5, from the earlier correction let twc = app.world.get::>(e1).unwrap(); - assert_eq!(twc.frame, 5); + assert_eq!(twc.frame, 4); } diff --git a/tests/spawning_in_the_past.rs b/tests/spawning_in_the_past.rs index 0a081e7..399583d 100644 --- a/tests/spawning_in_the_past.rs +++ b/tests/spawning_in_the_past.rs @@ -164,21 +164,16 @@ fn spawning_in_the_past() { assert_eq!(app.comp_val_at::(e3, 5).unwrap().health, 997); } -/* -error i noticed during game dev: -client is at frame 10. -messages arrive from server causing client to want to: -* insert an entity in the past, at frame 7. -* update server snapshot for something at frame 8 - -the update SS thing will do insert_resource and replace the rb to 7 with 8 -then the initial value of the past-entity comps won't exist at f8, since we -birthed it at f7 --> boom. -*/ #[test] -fn spawning_in_the_past_with_ss() { +fn spawning_in_the_past_with_ss_partial_updates() { let mut app = setup_test_app(); + // this test modifies distinct things in the past at two different frames during the same tick, so: + { + let mut tw_config = app.world.get_resource_mut::().unwrap(); + tw_config.set_consolidation_strategy(RollbackConsolidationStrategy::Oldest); + } + app.register_rollback::(); app.add_systems( @@ -226,7 +221,7 @@ fn spawning_in_the_past_with_ss() { .id(); let mut ss = app.world.get_mut::>(e1).unwrap(); - ss.insert(3, Enemy { health: 1000 }); + ss.insert(3, Enemy { health: 1000 }).unwrap(); tick(&mut app); // frame 5 - will trigger rollback diff --git a/tests/test_utils.rs b/tests/test_utils.rs index f9761ea..6e9faaf 100644 --- a/tests/test_utils.rs +++ b/tests/test_utils.rs @@ -22,14 +22,18 @@ pub struct EntName { } pub fn setup_test_app() -> App { - let tw_config = TimewarpConfig::new(TimewarpTestSets::GameLogic) - .set_rollback_window(TEST_ROLLBACK_WINDOW) - .set_schedule(FixedUpdate); + let tw_config = TimewarpConfig::new(TimewarpTestSets::GameLogic, TimewarpTestSets::GameLogic) + .with_rollback_window(TEST_ROLLBACK_WINDOW) + .with_schedule(FixedUpdate); let mut app = App::new(); - app.add_plugins(bevy::log::LogPlugin::default()); - app.add_plugins(TimewarpPlugin::new(tw_config.clone())); + app.add_plugins(bevy::log::LogPlugin { + level: bevy::log::Level::TRACE, + filter: "bevy_timewarp=trace".to_string(), + }); + app.add_plugins(TimewarpPlugin::new(tw_config)); app.add_plugins(bevy::time::TimePlugin::default()); app.insert_resource(FixedTime::new(TIMESTEP)); + warn!("โฑ๏ธInstant::now= {:?}", bevy::utils::Instant::now()); app } @@ -38,10 +42,10 @@ pub fn setup_test_app() -> App { pub fn tick(app: &mut App) { let mut fxt = app.world.resource_mut::(); let period = fxt.period; - info!(""); fxt.tick(period); app.update(); - info!(""); + let f = app.world.resource::().frame(); + info!("end of update for {f} ----------------------------------------------------------"); } // some syntactic sugar, just to make tests less of an eyesore: