mod common;
use common::controls::fall_asleep;
use common::determinism::{assert_same, child_request, digest_in_child, finish_child, Digest};
use common::*;
use oxijolt::*;
const GRAVITY: Vec3 = Vec3::new(0.0, -9.81, 0.0);
const TICKS: usize = 240;
const TILE: Vec3 = Vec3::new(1.0, 0.25, 1.0);
const LEDGE_Y: Real = 2.0;
const TILES: u32 = 12;
const CARRYING: u32 = 8;
const REMOVED: [u32; 3] = [5, 3, 1];
const LAST_EDIT: usize = 160;
fn tile_offset(index: u32) -> f32 {
2.0 * f32::from(index as u16)
}
fn tile_x(index: u32) -> Real {
2.0 * Real::from(index as u16)
}
struct Scene {
world: PhysicsWorld,
tile: Shape,
ledge_editor: MutableCompound,
ledge: BodyId,
debris_editors: Vec<MutableCompound>,
debris: Vec<BodyId>,
cubes: Vec<BodyId>,
faller: BodyId,
sleeper: BodyId,
bodies: Vec<BodyId>,
}
impl Scene {
fn new(threads: u32) -> Self {
let mut world = world(GRAVITY, threads);
let floor = add_floor(&mut world);
let tile = Shape::new_box(TILE).unwrap();
let children: Vec<_> = (0..TILES)
.map(|i| child(&tile, Vec3::new(tile_offset(i), 0.0, 0.0), 100 + i))
.collect();
let ledge_editor = MutableCompound::from_children(&children).unwrap();
drop(children);
let ledge = world
.create_body(
&ledge_editor.to_shape().unwrap(),
&BodySettings::new_static().position(RVec3::new(0.0, LEDGE_Y, 0.0)),
)
.unwrap();
let cubes: Vec<_> = (0..CARRYING)
.map(|i| add_cube(&mut world, RVec3::new(tile_x(i), LEDGE_Y + 0.75, 0.0)))
.collect();
for &cube in &cubes {
fall_asleep(&mut world, cube);
}
assert!(cubes
.iter()
.all(|&cube| world.body(cube).unwrap().is_sleeping()));
let block = Shape::new_box(Vec3::new(0.3, 0.3, 0.3)).unwrap();
let ball = Shape::new_sphere(0.3).unwrap();
let debris_editors: Vec<_> = (0..2)
.map(|_| {
MutableCompound::from_children(&[
child(&block, Vec3::ZERO, 0),
child(&block, Vec3::new(0.6, 0.0, 0.0), 1),
child(&ball, Vec3::new(1.2, 0.1, 0.0), 2),
])
.unwrap()
})
.collect();
let debris: Vec<_> = debris_editors
.iter()
.zip([0.0, 5.0])
.map(|(editor, z)| {
world
.create_body(
&editor.to_shape().unwrap(),
&BodySettings::new_dynamic()
.position(RVec3::new(-10.0, 1.5, z))
.rotation(quat_about(Vec3::new(0.0, 0.6, 0.8), 0.4)),
)
.unwrap()
})
.collect();
let faller = add_cube(&mut world, RVec3::new(tile_x(TILES + 1), 14.0, 0.0));
let sleeper = world
.create_body(
&cube_shape(),
&BodySettings::new_dynamic()
.position(RVec3::new(-30.0, 0.5, -30.0))
.activation(Activation::DontActivate),
)
.unwrap();
let mut bodies = vec![floor, ledge];
bodies.extend(&cubes);
bodies.extend(&debris);
bodies.extend([faller, sleeper]);
Self {
world,
tile,
ledge_editor,
ledge,
debris_editors,
debris,
cubes,
faller,
sleeper,
bodies,
}
}
fn commit_ledge(&mut self) {
let shape = self.ledge_editor.to_shape().unwrap();
self.world
.body_mut(self.ledge)
.unwrap()
.set_shape(&shape, None, Activation::DontActivate)
.unwrap();
}
fn edit(&mut self, tick: usize) {
match tick {
40 => {
for index in REMOVED {
self.ledge_editor.remove_shape(index).unwrap();
}
self.commit_ledge();
for index in REMOVED {
let cube = self.cubes[index as usize];
assert!(!self.world.body(cube).unwrap().is_sleeping());
}
}
80 => {
let ramp = CompoundChild {
rotation: quat_about(Vec3::new(0.0, 0.0, 1.0), 0.1),
..child(&self.tile, Vec3::new(tile_offset(TILES + 1), 0.0, 0.0), 200)
};
self.ledge_editor.add_shape(&ramp).unwrap();
self.commit_ledge();
}
120 => {
let index = CARRYING - 1 - REMOVED.len() as u32;
let position = Vec3::new(tile_offset(CARRYING - 1), 0.2, 0.0);
self.ledge_editor
.modify_shape(index, position, Quat::IDENTITY, None)
.unwrap();
self.commit_ledge();
}
LAST_EDIT => {
let tall = Shape::new_box(Vec3::new(0.5, 0.5, 1.0)).unwrap();
self.ledge_editor
.modify_shape(0, Vec3::ZERO, Quat::IDENTITY, Some(&tall))
.unwrap();
self.commit_ledge();
self.debris_editors[0].remove_shape(2).unwrap();
let shape = self.debris_editors[0].to_shape().unwrap();
self.world
.body_mut(self.debris[0])
.unwrap()
.set_shape(&shape, None, Activation::Activate)
.unwrap();
}
_ => {}
}
}
fn record(&self, digest: &mut Digest) {
let tick = digest.push();
for &id in &self.bodies {
record_body(&self.world, id, &mut tick.state);
}
let count = self.ledge_editor.sub_shape_count();
tick.shape.extend_from_slice(&count.to_le_bytes());
let ray = RayCast::new(
RVec3::new(tile_x(9), LEDGE_Y + 0.1, 0.0),
Vec3::new(0.0, -0.2, 0.0),
);
let hit = self.world.cast_ray(&ray, &QueryFilter::new()).unwrap();
let probe = hit.and_then(|hit| hit.compound_child);
tick.shape.extend(format!("{probe:?}").bytes());
}
fn detour_tick(&mut self, tick: usize) {
self.world.set_gravity(Vec3::new(1.0, -4.0, 0.0)).unwrap();
if tick.is_multiple_of(5) {
let kicked = self.cubes[tick % self.cubes.len()];
self.world
.body_mut(kicked)
.unwrap()
.add_impulse(Vec3::new(0.0, 5.0, 2.0))
.unwrap();
}
if tick == 10 {
self.world
.body_mut(self.debris[1])
.unwrap()
.set_position(RVec3::new(-10.0, 3.0, 8.0), Activation::Activate)
.unwrap();
self.world.body_mut(self.sleeper).unwrap().activate();
}
step(&mut self.world, 1);
}
}
fn assert_scene_played(scene: &Scene) {
let y = |id: BodyId| scene.world.body(id).unwrap().position().y;
for index in REMOVED {
let fallen = y(scene.cubes[index as usize]);
assert!(fallen < 1.0, "cube {index} fell to the floor: {fallen}");
}
let caught = y(scene.faller);
assert!(caught > LEDGE_Y, "the ramp caught the faller: {caught}");
let lifted = y(scene.cubes[CARRYING as usize - 1]);
assert!(
lifted > LEDGE_Y + 0.85,
"the plate lifted its cube: {lifted}"
);
assert_eq!(scene.debris_editors[0].sub_shape_count(), 2);
}
fn run(threads: u32) -> (Digest, Scene) {
let mut scene = Scene::new(threads);
let mut digest = Digest::new();
for tick in 0..TICKS {
scene.edit(tick);
step(&mut scene.world, 1);
scene.record(&mut digest);
}
(digest, scene)
}
fn compounds(threads: u32) -> Digest {
run(threads).0
}
fn replayed(threads: u32) -> Digest {
let mut scene = Scene::new(threads);
let mut digest = Digest::new();
for tick in 0..LAST_EDIT {
scene.edit(tick);
step(&mut scene.world, 1);
scene.record(&mut digest);
}
scene.edit(LAST_EDIT);
let saved = scene.world.save_state();
for tick in 0..30 {
scene.detour_tick(tick);
}
assert!(!scene.world.body(scene.sleeper).unwrap().is_sleeping());
scene.world.restore_state(&saved).unwrap();
assert_eq!(scene.world.gravity(), GRAVITY);
step(&mut scene.world, 1);
scene.record(&mut digest);
for tick in LAST_EDIT + 1..TICKS {
scene.edit(tick);
step(&mut scene.world, 1);
scene.record(&mut digest);
}
assert_scene_played(&scene);
digest
}
#[test]
#[ignore = "child process of the compound edits determinism gate"]
fn mutable_compounds_determinism_child() {
let Some((scenario, threads, _)) = child_request() else {
return;
};
assert_eq!(scenario, "compounds");
finish_child(&compounds(threads));
}
#[test]
fn mutable_compounds_match_with_1_and_4_workers() {
let (one, scene) = run(1);
assert_eq!(one.ticks.len(), TICKS);
assert_scene_played(&scene);
assert_same("1 vs 4 workers in one process", &one, &compounds(4));
}
#[test]
fn a_rollback_after_the_last_edit_replays_exactly() {
for threads in [1, 4] {
assert_same(
"straight run vs rollback and replay",
&compounds(threads),
&replayed(threads),
);
}
}
#[test]
fn mutable_compounds_match_across_processes() {
let one = digest_in_child("mutable_compounds_determinism_child", "compounds", 1, "");
let four = digest_in_child("mutable_compounds_determinism_child", "compounds", 4, "");
assert_eq!(one.ticks.len(), TICKS);
assert_same("1 vs 4 workers in two processes", &one, &four);
assert_same("one process vs a child", &compounds(1), &one);
}