From 4c1198098966f4a9c2ad1901bbaee968d2703705 Mon Sep 17 00:00:00 2001
From: Quaternions <krakow20@gmail.com>
Date: Sat, 18 Jan 2025 01:13:45 -0800
Subject: [PATCH] headless replay test

---
 lib/common/src/run.rs        |   6 ++
 strafe-client/src/physics.rs | 204 ++++++++++++++++++++++++++++++++++-
 strafe-client/src/session.rs |   2 +-
 3 files changed, 210 insertions(+), 2 deletions(-)

diff --git a/lib/common/src/run.rs b/lib/common/src/run.rs
index 2b21060f..622ba4e5 100644
--- a/lib/common/src/run.rs
+++ b/lib/common/src/run.rs
@@ -110,4 +110,10 @@ impl Run{
 			self.flagged=Some(flag_reason);
 		}
 	}
+	pub fn get_finish_time(&self)->Option<Time>{
+		match &self.state{
+			RunState::Finished{timer}=>Some(timer.time()),
+			_=>None,
+		}
+	}
 }
diff --git a/strafe-client/src/physics.rs b/strafe-client/src/physics.rs
index b969c570..f2f87089 100644
--- a/strafe-client/src/physics.rs
+++ b/strafe-client/src/physics.rs
@@ -900,6 +900,9 @@ impl PhysicsState{
 	pub const fn mode(&self)->gameplay_modes::ModeId{
 		self.mode_state.get_mode_id()
 	}
+	pub fn get_finish_time(&self)->Option<run::Time>{
+		self.run.get_finish_time()
+	}
 	pub fn clear(&mut self){
 		self.touching.clear();
 	}
@@ -1899,8 +1902,9 @@ fn atomic_input_instruction(state:&mut PhysicsState,data:&PhysicsData,ins:TimedI
 
 #[cfg(test)]
 mod test{
-	use strafesnet_common::integer::{vec3::{self,int as int3},mat3};
+	use crate::file;
 	use crate::body::VirtualBody;
+	use strafesnet_common::integer::{vec3::{self,int as int3},mat3};
 	use super::*;
 	fn test_collision_axis_aligned(relative_body:Body,expected_collision_time:Option<Time>){
 		let h0=HitboxMesh::new(PhysicsMesh::unit_cube(),integer::Planar64Affine3::new(mat3::from_diagonal(int3(5,1,5)>>1),vec3::ZERO));
@@ -2113,4 +2117,202 @@ mod test{
 			Time::ZERO
 		),None);
 	}
+	#[test]
+	fn run_replay(){
+		println!("loading map file..");
+		let map=file::load("../tools/bhop_maps/5692113331.snfm");
+		println!("loading bot file..");
+		let bot=file::load("../tools/replays/534s+997497968ns.snfb");
+		if let (Ok(file::LoadFormat::Map(map)),Ok(file::LoadFormat::Bot(bot)))=(map,bot){
+			// create recording
+			let mut physics_data=PhysicsData::default();
+			println!("generating models..");
+			physics_data.generate_models(&map);
+			println!("simulating...");
+			let mut physics=PhysicsState::default();
+			for ins in bot.instructions{
+				PhysicsContext::run_input_instruction(&mut physics,&physics_data,ins);
+			}
+			match physics.get_finish_time(){
+				Some(time)=>println!("finish time:{}",time),
+				None=>println!("simulation did not end in finished state"),
+			}
+		}else{
+			panic!("missing files");
+		}
+	}
+	enum DeterminismResult{
+		Deterministic,
+		NonDeterministic,
+	}
+	#[allow(unused)]
+	#[derive(Debug)]
+	enum ReplayError{
+		Load(file::LoadError),
+		IO(std::io::Error),
+	}
+	impl From<file::LoadError> for ReplayError{
+		fn from(value:file::LoadError)->Self{
+			Self::Load(value)
+		}
+	}
+	impl From<std::io::Error> for ReplayError{
+		fn from(value:std::io::Error)->Self{
+			Self::IO(value)
+		}
+	}
+	fn segment_determinism(bot:strafesnet_snf::bot::Segment,physics_data:&PhysicsData)->DeterminismResult{
+		// create default physics state
+		let mut physics_deterministic=PhysicsState::default();
+		// create a second physics state
+		let mut physics_filtered=PhysicsState::default();
+
+		// invent a new bot id and insert the replay
+		println!("simulating...");
+
+		let mut non_idle_count=0;
+
+		for (i,ins) in bot.instructions.into_iter().enumerate(){
+			let state_deterministic=physics_deterministic.clone();
+			let state_filtered=physics_filtered.clone();
+			PhysicsContext::run_input_instruction(&mut physics_deterministic,&physics_data,ins.clone());
+			match ins{
+				strafesnet_common::instruction::TimedInstruction{instruction:strafesnet_common::physics::Instruction::Idle,..}=>(),
+				other=>{
+					non_idle_count+=1;
+					// run
+					PhysicsContext::run_input_instruction(&mut physics_filtered,&physics_data,other.clone());
+					// check if position matches
+					let b0=physics_deterministic.camera_body();
+					let b1=physics_filtered.camera_body();
+					if b0.position!=b1.position{
+						println!("desync at instruction #{}",i);
+						println!("non idle instructions completed={non_idle_count}");
+						println!("instruction #{i}={:?}",other);
+						println!("deterministic state0:\n{state_deterministic:?}");
+						println!("filtered state0:\n{state_filtered:?}");
+						println!("deterministic state1:\n{:?}",physics_deterministic);
+						println!("filtered state1:\n{:?}",physics_filtered);
+						return DeterminismResult::NonDeterministic;
+					}
+				},
+			}
+		}
+		match physics_deterministic.get_finish_time(){
+			Some(time)=>println!("[with idle] finish time:{}",time),
+			None=>println!("[with idle] simulation did not end in finished state"),
+		}
+		match physics_filtered.get_finish_time(){
+			Some(time)=>println!("[filtered] finish time:{}",time),
+			None=>println!("[filtered] simulation did not end in finished state"),
+		}
+		DeterminismResult::Deterministic
+	}
+	type ThreadResult=Result<Option<DeterminismResult>,file::LoadError>;
+	fn do_thread<'a>(s:&'a std::thread::Scope<'a,'_>,file_path:std::path::PathBuf,send:std::sync::mpsc::Sender<ThreadResult>,physics_data:&'a PhysicsData){
+		s.spawn(move ||{
+			let result=match file::load(file_path.as_path()){
+				Ok(file::LoadFormat::Bot(bot))=>{
+					println!("Running {:?}",file_path.file_stem());
+					Ok(Some(segment_determinism(bot,physics_data)))
+				},
+				Ok(_)=>{
+					println!("Provided bot file is not a bot file!");
+					Ok(None)
+				}
+				Err(e)=>{
+					println!("Load error");
+					Err(e)
+				},
+			};
+			// send when thread is complete
+			send.send(result).unwrap();
+		});
+	}
+	fn get_file_path(dir_entry:std::fs::DirEntry)->Result<Option<std::path::PathBuf>,std::io::Error>{
+		Ok(dir_entry.file_type()?.is_file().then_some(
+			dir_entry.path()
+		))
+	}
+	#[test]
+	fn test_determinism()->Result<(),ReplayError>{
+		let thread_limit=std::thread::available_parallelism()?.get();
+		println!("loading map file..");
+		let file::LoadFormat::Map(map)=file::load("../tools/bhop_maps/5692113331.snfm")? else{
+			panic!("Provided map file is not a map file!");
+		};
+		let mut physics_data=PhysicsData::default();
+		println!("generating models..");
+		physics_data.generate_models(&map);
+		let (send,recv)=std::sync::mpsc::channel();
+
+		let mut read_dir=std::fs::read_dir("../tools/replays")?;
+
+		// promise that &physics_data will outlive the spawned threads
+		let thread_results=std::thread::scope(|s|{
+			let mut thread_results=Vec::new();
+
+			// spawn threads
+			println!("spawning up to {thread_limit} threads...");
+			let mut active_thread_count=0;
+			for _ in 0..thread_limit{
+				if let Some(dir_entry_result)=read_dir.next(){
+					if let Some(file_path)=get_file_path(dir_entry_result?)?{
+						active_thread_count+=1;
+						do_thread(s,file_path,send.clone(),&physics_data);
+					}
+				}else{
+					break;
+				}
+			}
+
+			// spawn another thread every time a message is received from the channel
+			println!("riding parallelism wave...");
+			while let Some(dir_entry_result)=read_dir.next(){
+				if let Some(file_path)=get_file_path(dir_entry_result?)?{
+					// wait for a thread to complete
+					thread_results.push(recv.recv().unwrap());
+					do_thread(s,file_path,send.clone(),&physics_data);
+				}
+			}
+
+			// wait for remaining threads to complete
+			println!("waiting for all threads to complete...");
+			for _ in 0..active_thread_count{
+				thread_results.push(recv.recv().unwrap());
+			}
+
+			println!("done.");
+			Ok::<_,ReplayError>(thread_results)
+		})?;
+
+		// tally results
+		#[derive(Default)]
+		struct Totals{
+			deterministic:u32,
+			nondeterministic:u32,
+			invalid:u32,
+			error:u32,
+		}
+		let Totals{deterministic,nondeterministic,invalid,error}=thread_results.into_iter().fold(Totals::default(),|mut totals,result|{
+			match result{
+				Ok(Some(DeterminismResult::Deterministic))=>totals.deterministic+=1,
+				Ok(Some(DeterminismResult::NonDeterministic))=>totals.nondeterministic+=1,
+				Ok(None)=>totals.invalid+=1,
+				Err(_)=>totals.error+=1,
+			}
+			totals
+		});
+
+		println!("deterministic={deterministic}");
+		println!("nondeterministic={nondeterministic}");
+		println!("invalid={invalid}");
+		println!("error={error}");
+
+		assert!(nondeterministic==0);
+		assert!(invalid==0);
+		assert!(error==0);
+
+		Ok(())
+	}
 }
diff --git a/strafe-client/src/session.rs b/strafe-client/src/session.rs
index 1fa0d89d..e9d10a58 100644
--- a/strafe-client/src/session.rs
+++ b/strafe-client/src/session.rs
@@ -90,7 +90,7 @@ pub struct Recording{
 	instructions:Vec<TimedInstruction<PhysicsInputInstruction,PhysicsTimeInner>>,
 }
 impl Recording{
-	fn new(
+	pub fn new(
 		instructions:Vec<TimedInstruction<PhysicsInputInstruction,PhysicsTimeInner>>,
 	)->Self{
 		Self{instructions}