16 Commits
master ... sn

Author SHA1 Message Date
3c31de9f9f fix mouse 2026-02-06 10:09:56 -08:00
513602e551 better time 2026-02-06 10:01:14 -08:00
b147c96240 fix negative sens 2026-02-06 09:37:51 -08:00
8f6b7bb8da eccentric fov implementation 2026-02-06 09:25:37 -08:00
2304f19239 calculate sens, fix time 2026-02-06 09:08:57 -08:00
a2155f0864 fly to start position better 2026-02-06 08:47:27 -08:00
181f39ff37 fly to starting position 2026-02-06 08:36:22 -08:00
5f99a47cf2 ignore 2026-02-06 07:36:32 -08:00
e44fd7cbb9 get map start offset 2026-02-06 07:33:19 -08:00
60874fb769 map 1st arg 2026-02-06 07:23:21 -08:00
a2f1572281 generate recording 2026-02-06 07:12:04 -08:00
baf6f7e99b remove work comment 2026-02-06 07:00:25 -08:00
934be7a28a work 2026-02-06 06:58:24 -08:00
cdd2f5cd89 wip 2026-02-05 08:41:43 -08:00
2f76e5e63d common 2026-02-05 07:53:32 -08:00
5fa13de6cf strafesnet!!!! 2026-02-05 07:41:00 -08:00
4 changed files with 336 additions and 84 deletions

2
.gitignore vendored
View File

@@ -1 +1,3 @@
/bots
/replays
/target

111
Cargo.lock generated
View File

@@ -8,6 +8,12 @@ version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "binrw"
version = "0.15.0"
@@ -38,12 +44,21 @@ version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "bnum"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "119771309b95163ec7aaf79810da82f7cd0599c19722d48b9c03894dca833966"
[[package]]
name = "bot-cruncher"
version = "0.1.0"
dependencies = [
"futures",
"strafesnet_common",
"strafesnet_physics",
"strafesnet_roblox_bot_file",
"strafesnet_snf",
"tokio",
"tokio-stream",
]
@@ -60,6 +75,18 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "fixed_wide"
version = "0.2.2"
source = "sparse+https://git.itzana.me/api/packages/strafesnet/cargo/"
checksum = "1397e01522f708e80dcf6c17db69139abb57c43212226b60b50fc09d91c607b5"
dependencies = [
"arrayvec",
"bnum",
"paste",
"ratio_ops",
]
[[package]]
name = "futures"
version = "0.3.31"
@@ -149,6 +176,23 @@ dependencies = [
"slab",
]
[[package]]
name = "glam"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74a4d85559e2637d3d839438b5b3d75c31e655276f9544d72475c36b92fabbed"
[[package]]
name = "id"
version = "0.1.0"
source = "sparse+https://git.itzana.me/api/packages/strafesnet/cargo/"
checksum = "2337e7a6c273082b672e377e159d7a168fb51438461b7c4033c79a515dd7a25a"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "itertools"
version = "0.14.0"
@@ -158,6 +202,17 @@ dependencies = [
"either",
]
[[package]]
name = "linear_ops"
version = "0.1.1"
source = "sparse+https://git.itzana.me/api/packages/strafesnet/cargo/"
checksum = "d6ea2e52a83eab4afe56536e6d27f8e815bd994111ccdc3e2c0aafce77014286"
dependencies = [
"fixed_wide",
"paste",
"ratio_ops",
]
[[package]]
name = "memchr"
version = "2.7.6"
@@ -170,6 +225,12 @@ version = "4.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52"
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pin-project-lite"
version = "0.2.16"
@@ -200,6 +261,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "ratio_ops"
version = "0.1.1"
source = "sparse+https://git.itzana.me/api/packages/strafesnet/cargo/"
checksum = "93f2dc5bfc9d878028a699e77c6f88ac59d23404218af9fcfbfc190610f49c80"
[[package]]
name = "slab"
version = "0.4.11"
@@ -207,16 +274,54 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
[[package]]
name = "strafesnet_roblox_bot_file"
version = "0.9.3"
name = "strafesnet_common"
version = "0.8.0"
source = "sparse+https://git.itzana.me/api/packages/strafesnet/cargo/"
checksum = "423d931e4f4f97406a86519a22172d1fc0d5b9d8c3b2d4553ae89b641bbd555c"
checksum = "171f2b754a8c59b335578824d5465d9637fb41ec4906c6a8c1fd39206891b09c"
dependencies = [
"arrayvec",
"bitflags",
"fixed_wide",
"glam",
"id",
"linear_ops",
"ratio_ops",
]
[[package]]
name = "strafesnet_physics"
version = "0.0.1"
source = "sparse+https://git.itzana.me/api/packages/strafesnet/cargo/"
checksum = "8bf75c5ba62556c83da56c70cc1e0f31b842fdd993646dc533ac7a844a05b926"
dependencies = [
"arrayvec",
"glam",
"id",
"strafesnet_common",
]
[[package]]
name = "strafesnet_roblox_bot_file"
version = "0.8.1"
source = "sparse+https://git.itzana.me/api/packages/strafesnet/cargo/"
checksum = "33d0fa524476d8b6cf23269b0c9cff6334b70585546b807cb8ec193858defecd"
dependencies = [
"binrw",
"bitflags",
"itertools",
]
[[package]]
name = "strafesnet_snf"
version = "0.3.2"
source = "sparse+https://git.itzana.me/api/packages/strafesnet/cargo/"
checksum = "aab96a189e3f5c4e5eca1feae704c0d6ceaa0de37a41e29ef3a89816e354292f"
dependencies = [
"binrw",
"id",
"strafesnet_common",
]
[[package]]
name = "syn"
version = "2.0.111"

View File

@@ -5,6 +5,9 @@ edition = "2024"
[dependencies]
futures = "0.3.31"
strafesnet_roblox_bot_file = { version = "0.9.3", registry = "strafesnet" }
strafesnet_common = { version = "0.8.0", registry = "strafesnet" }
strafesnet_physics = { version = "0.0.1", registry = "strafesnet" }
strafesnet_roblox_bot_file = { version = "0.8.1", registry = "strafesnet" }
strafesnet_snf = { version = "0.3.2", registry = "strafesnet" }
tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread", "fs"] }
tokio-stream = { version = "0.1.17", features = ["fs"] }

View File

@@ -1,6 +1,8 @@
use strafesnet_roblox_bot_file::v0;
use std::path::PathBuf;
use std::path::{Path,PathBuf};
use strafesnet_common::integer::{Planar64,Planar64Vec3};
use strafesnet_physics::physics::{PhysicsData,Time};
use strafesnet_roblox_bot_file::v0;
use futures::{StreamExt,TryStreamExt};
#[expect(dead_code)]
@@ -8,9 +10,11 @@ use futures::{StreamExt,TryStreamExt};
enum Error{
InvalidArgs,
Io(std::io::Error),
DecodeMap(strafesnet_snf::Error),
DecodeMap2(strafesnet_snf::map::Error),
BotFile{
path:PathBuf,
err:RoundTripError,
err:SimError,
},
Join(tokio::task::JoinError),
}
@@ -24,105 +28,243 @@ impl From<tokio::task::JoinError> for Error{
Self::Join(value)
}
}
#[expect(dead_code)]
#[derive(Debug)]
enum RoundTripError{
Decode(v0::Error),
Encode(strafesnet_roblox_bot_file::BinrwError),
RoundTripDecode(v0::Error),
NotEqual,
const fn fly_time(distance:Planar64)->Time{
Time::raw(distance.to_raw()*Time::ONE_SECOND.get()/(80*Planar64::ONE.to_raw()))
}
// #[expect(dead_code)]
#[derive(Debug)]
enum SimError{
Decode(v0::Error),
}
fn run_roblox_bot_in_strafe_client_physics(physics_data:&PhysicsData,block:&v0::Block,file_stem:std::ffi::OsString,offset:Planar64Vec3){
use strafesnet_common::instruction::TimedInstruction;
use strafesnet_common::mouse::MouseState;
use strafesnet_common::physics::{Instruction,MouseInstruction,SetControlInstruction,ModeInstruction,MiscInstruction};
use strafesnet_physics::physics::{PhysicsState,PhysicsContext};
fn vector3(v:&v0::Vector3)->Planar64Vec3{
strafesnet_common::integer::vec3::try_from_f32_array([v.x,v.y,v.z]).unwrap()
}
let mut input_it=block.input_events.iter();
let Some(input)=input_it.next()else{
return;
};
let Some(output)=block.output_events.first()else{
return;
};
let Some(setting)=block.setting_events.iter().find(|event|matches!(event.event.setting_type,v0::SettingType::Sensitivity))else{
return;
};
let mut recording=Vec::new();
let mut physics=PhysicsState::default();
let mut time=Time::ZERO;
macro_rules! run{
($time:expr,$ins:expr)=>{
let ins=TimedInstruction{
time:$time,
instruction:$ins,
};
recording.push(ins.clone());
PhysicsContext::run_input_instruction(&mut physics,physics_data,ins);
};
}
// === State Initialization ===
// Reset
run!(time,Instruction::Mode(ModeInstruction::Reset));
// generate an instruction to set the sensitivity (sens is also based on fov in rbhop, but pure in strafe client)
let fov=1.0;//tan(90)
let zoom=1.0;
let pixels_to_radians=setting.event.value*fov*zoom/128.0;
// convert radians to Angle32
let sensitivity=(pixels_to_radians*strafesnet_common::integer::Angle32::FRAC_PI_2.get() as f64/core::f64::consts::FRAC_PI_2) as i64;
let sensitivity_ratio=strafesnet_common::integer::Ratio64::new(sensitivity,1).unwrap();
run!(time,Instruction::Misc(MiscInstruction::SetSensitivity(strafesnet_common::integer::Ratio64Vec2{
x:sensitivity_ratio,
y:sensitivity_ratio,
})));
run!(time,Instruction::Mode(ModeInstruction::Restart(strafesnet_common::gameplay_modes::ModeId::MAIN)));
// Fly!
run!(time,Instruction::Misc(MiscInstruction::PracticeFly));
// NoClip!
run!(time,Instruction::SetControl(SetControlInstruction::SetSprint(true)));
// generate instructions to fly to the starting position
let fly_offset=offset+vector3(&output.event.position)-physics.body().position;
if fly_offset.x.is_negative(){
run!(time,Instruction::SetControl(SetControlInstruction::SetMoveLeft(true)));
time+=fly_time(-fly_offset.x);
run!(time,Instruction::SetControl(SetControlInstruction::SetMoveLeft(false)));
}else{
run!(time,Instruction::SetControl(SetControlInstruction::SetMoveRight(true)));
time+=fly_time(fly_offset.x);
run!(time,Instruction::SetControl(SetControlInstruction::SetMoveRight(false)));
}
if fly_offset.y.is_negative(){
run!(time,Instruction::SetControl(SetControlInstruction::SetMoveDown(true)));
time+=fly_time(-fly_offset.y);
run!(time,Instruction::SetControl(SetControlInstruction::SetMoveDown(false)));
}else{
run!(time,Instruction::SetControl(SetControlInstruction::SetMoveUp(true)));
time+=fly_time(fly_offset.y);
run!(time,Instruction::SetControl(SetControlInstruction::SetMoveUp(false)));
}
if fly_offset.z.is_negative(){
run!(time,Instruction::SetControl(SetControlInstruction::SetMoveForward(true)));
time+=fly_time(-fly_offset.z);
run!(time,Instruction::SetControl(SetControlInstruction::SetMoveForward(false)));
}else{
run!(time,Instruction::SetControl(SetControlInstruction::SetMoveBack(true)));
time+=fly_time(fly_offset.z);
run!(time,Instruction::SetControl(SetControlInstruction::SetMoveBack(false)));
}
// Fly off
run!(time,Instruction::Misc(MiscInstruction::PracticeFly));
// NoClip off
run!(time,Instruction::SetControl(SetControlInstruction::SetSprint(false)));
// TODO: generate instructions to set up the velocity
let t0=Time::from_nanos((output.time*Time::ONE_SECOND.get() as f64) as i64);
// generate an instruction that will rotate the camera to match output.angles
let mut last_mouse=&input.event.mouse_pos;
// generate an instruction for each pressed key
let mut last_controls=v0::GameControls::empty();
// construct an iterator to detect key changes and mouse movement
// run key changes first, then mouse movement
let it=input_it.map(|event|{
let time=Time::from_nanos((event.time*Time::ONE_SECOND.get() as f64) as i64)-t0+time;
// detect controls changes
let controls={
let new_controls=event.event.game_controls;
let mask=new_controls^last_controls;
last_controls=new_controls;
(mask,new_controls)
};
// detect mouse changes
let mouse=(event.event.mouse_pos.x!=last_mouse.x||event.event.mouse_pos.y!=last_mouse.y).then(||{
last_mouse=&event.event.mouse_pos;
Instruction::Mouse(MouseInstruction::SetNextMouse(MouseState{
pos:[event.event.mouse_pos.x as i32,event.event.mouse_pos.y as i32].into(),
time,
}))
});
(time,controls,mouse)
});
// detect deviation from space radius and time range.
// if the current position is not near any timeline position within +-1s, abort
for (time,(mask,controls),mouse) in it{
macro_rules! run_control{
($control:ident,$ins:ident)=>{
if mask.contains(v0::GameControls::$control){
let state=controls.contains(v0::GameControls::$control);
let instruction=Instruction::SetControl(SetControlInstruction::$ins(state));
run!(time,instruction);
}
};
}
run_control!(MoveForward,SetMoveForward);
run_control!(MoveLeft,SetMoveLeft);
run_control!(MoveBack,SetMoveBack);
run_control!(MoveRight,SetMoveRight);
run_control!(MoveUp,SetMoveUp);
run_control!(MoveDown,SetMoveDown);
// run_control!(LookUp);
// run_control!(LookLeft);
// run_control!(LookDown);
// run_control!(LookRight);
run_control!(Jump,SetJump);
// run_control!(Crouch);
run_control!(Sprint,SetSprint);
run_control!(Zoom,SetZoom);
// run_control!(Use);
// run_control!(Action1);
// run_control!(Action2);
if let Some(instruction)=mouse{
run!(time,instruction);
}
}
// idle for 1s just in case we need to glide into the finish zone
// if the bot completed the map, write a .snfb file
match physics.get_finish_time(){
Some(time)=>println!("finish time:{}",time),
None=>println!("simulation did not end in finished state"),
}
let mut replays_path=std::env::current_dir().unwrap();
let mut file_name=PathBuf::from(file_stem);
file_name.set_extension("snfb");
std::fs::create_dir_all(replays_path.as_path()).unwrap();
replays_path.push(file_name);
let file=std::fs::File::create(replays_path).unwrap();
strafesnet_snf::bot::write_bot(
std::io::BufWriter::new(file),
strafesnet_physics::VERSION.get(),
recording,
).unwrap();
println!("Finished writing bot file!");
}
#[tokio::main]
async fn main()->Result<(),Error>{
const PREFETCH_QUEUE:usize=64;
let Some(dir)=std::env::args().skip(1).next()else{
let mut args=std::env::args().skip(1);
let (Some(map),Some(dir))=(args.next(),args.next())else{
return Err(Error::InvalidArgs);
};
const ONE_SECOND:u64=1<<32;
#[derive(Default)]
struct FoldState{
count:usize,
jumps:u64,
duration:u64,
settings:u64,
outliers:Vec<PathBuf>,
}
impl std::fmt::Display for FoldState{
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
write!(f,"count={} jumps={} duration={:.3} settings={}",self.count,self.jumps,self.duration as f64/(ONE_SECOND as f64),self.settings)
}
}
impl FoldState{
fn accumulate(&mut self,path:&Path,block:&v0::Block){
if let (Some(first),Some(last))=(block.output_events.first(),block.output_events.last()){
let last=last.time*ONE_SECOND as f64;
let first=first.time*ONE_SECOND as f64;
let duration=last as u64-first as u64;
if 30000*ONE_SECOND<duration{
self.outliers.push(path.to_owned());
return;
}
self.duration+=duration;
}
self.count+=1;
self.jumps+=block.sound_events.iter()
.filter(|event|event.event.sound_type==v0::SoundType::JumpGround)
.count() as u64;
// find settings events after run has started
if let Some(run_start)=block.run_events.iter().find(|event|matches!(event.event,v0::RunEvent::Start(_))){
self.settings+=block.setting_events.iter()
.filter(|event|run_start.time<event.time)
.count() as u64;
}
}
}
println!("loading map file..");
let data=tokio::fs::read(map).await?;
let map=strafesnet_snf::read_map(std::io::Cursor::new(data))
.map_err(Error::DecodeMap)?
.into_complete_map()
.map_err(Error::DecodeMap2)?;
// grab mapstart position
let start_zone_model_id=map.modes.clone().denormalize().get_mode(strafesnet_common::gameplay_modes::ModeId::MAIN).unwrap().get_start();
let offset=map.models[start_zone_model_id.get() as usize].transform.translation;
// create recording
println!("generating models..");
let physics_data:&_=Box::leak(Box::new(PhysicsData::new(&map)));
let available_parallelism=std::thread::available_parallelism()?.get();
let read_dir=tokio::fs::read_dir(dir).await?;
let tally=tokio_stream::wrappers::ReadDirStream::new(read_dir).map(|dir_entry|async move{
tokio_stream::wrappers::ReadDirStream::new(read_dir).map(|dir_entry|async move{
let entry=dir_entry?;
let path=entry.path();
let file=tokio::fs::read(path.as_path()).await?;
Ok((path,file))
})
.buffer_unordered(PREFETCH_QUEUE)
.map(|result:Result<_,Error>|async move{
let (path,file)=result?;
.try_for_each_concurrent(available_parallelism,|(path,file)|async move{
let file_stem=path.file_stem().unwrap().to_owned();
let result=tokio::task::spawn_blocking(move||{
let block=v0::read_all_to_block(std::io::Cursor::new(file.as_slice())).map_err(RoundTripError::Decode)?;
let mut data=Vec::with_capacity(file.len()+1024);
v0::serialize(&block,&mut std::io::Cursor::new(&mut data)).map_err(RoundTripError::Encode)?;
let block_rt=v0::read_all_to_block(std::io::Cursor::new(data)).map_err(RoundTripError::RoundTripDecode)?;
if !(
block_rt.input_events.len()==block.input_events.len()&&
block_rt.output_events.len()==block.output_events.len()&&
block_rt.sound_events.len()==block.sound_events.len()&&
block_rt.world_events.len()==block.world_events.len()&&
block_rt.gravity_events.len()==block.gravity_events.len()&&
block_rt.run_events.len()==block.run_events.len()&&
block_rt.camera_events.len()==block.camera_events.len()&&
block_rt.setting_events.len()==block.setting_events.len()
){
return Err(RoundTripError::NotEqual);
}
Ok(block)
let block=v0::read_all_to_block(std::io::Cursor::new(file.as_slice())).map_err(SimError::Decode)?;
run_roblox_bot_in_strafe_client_physics(physics_data,&block,file_stem,offset);
Ok(())
}).await?;
match result{
Err(err)=>Err(Error::BotFile{path,err}),
Ok(block)=>Ok((path,block)),
Ok(())=>Ok(()),
}
})
.buffer_unordered(available_parallelism)
.try_fold(FoldState::default(),async|mut state,(path,block)|{
state.accumulate(&path,&block);
println!("{:?} {}",path.file_name(),state);
Ok(state)
}).await?;
for path in &tally.outliers{
println!("outlier: {:?}",path.file_name());
}
println!("{}",tally);
Ok(())
}