Full op-stream player in recording_replay.rs (b3RecPlayer port): opcode dispatch for ~150 ops, StateHash verification at every step marker, query replay with bitwise comparison, keyframe ring with budget-driven interval doubling, seek/restart/scrub, validate_replay. tests/test_recording.rs ports test_recording.c (17 tests incl. record-at-4-workers/replay-at-1-and-4 hash equality). 179/185/179 tests green across default/double-precision/disable-simd, zero warnings, determinism hash unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1384 lines
55 KiB
Rust
1384 lines
55 KiB
Rust
// Port of box3d/test/test_recording.c — recording round trips, the headless
|
|
// replay validator, and the incremental player (keyframes, seek, restart,
|
|
// per-frame query store, tags).
|
|
//
|
|
// Not ported (debug draw / debug-shape callbacks are not in the port):
|
|
// - DebugShapeCallbacks — exercises b3RecPlayer_SetDebugShapeCallbacks + draw
|
|
// - KeyframeHandleReuse — renderer-handle reuse across keyframe restores
|
|
// The keyframe restore path those tests also touch is covered by
|
|
// scrub_backward / seek_with_hull / player_accessors below.
|
|
//
|
|
// Port-specific additions at the end: a round trip over the capture test's
|
|
// scene, and a worker-count invariance check (record at 4 workers, replay at
|
|
// 1 and 4 — the sim is worker-count invariant so all must reproduce).
|
|
|
|
use std::sync::Arc;
|
|
|
|
use makepad_box3d::body::*;
|
|
use makepad_box3d::compound::create_compound;
|
|
use makepad_box3d::distance_joint::*;
|
|
use makepad_box3d::ensure;
|
|
use makepad_box3d::height_field::create_grid;
|
|
use makepad_box3d::hull::{create_hull, make_box_hull};
|
|
use makepad_box3d::id::{ShapeId, NULL_BODY_ID};
|
|
use makepad_box3d::joint::*;
|
|
use makepad_box3d::math_functions::{
|
|
make_quat_from_axis_angle, pos, vec3, Matrix3, Quat, Transform, WorldTransform, AABB,
|
|
};
|
|
use makepad_box3d::mesh::create_grid_mesh;
|
|
use makepad_box3d::motor_joint::*;
|
|
use makepad_box3d::parallel_joint::*;
|
|
use makepad_box3d::physics_world::*;
|
|
use makepad_box3d::prismatic_joint::*;
|
|
use makepad_box3d::recording::{
|
|
append_geometry, hash64_blob, hash_query_tag, hash_world_state, intern_geometry,
|
|
load_recording_from_file, save_recording_to_file, world_start_recording, world_stop_recording,
|
|
GeometryKind, GeometryRegistry, REC_HEADER_SIZE,
|
|
};
|
|
use makepad_box3d::recording_replay::{validate_replay, Player, RecQueryKind};
|
|
use makepad_box3d::revolute_joint::*;
|
|
use makepad_box3d::shape::*;
|
|
use makepad_box3d::spherical_joint::*;
|
|
use makepad_box3d::types::*;
|
|
use makepad_box3d::weld_joint::*;
|
|
use makepad_box3d::wheel_joint::*;
|
|
|
|
fn read_u32(d: &[u8], o: usize) -> u32 {
|
|
u32::from_le_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]])
|
|
}
|
|
fn read_u64(d: &[u8], o: usize) -> u64 {
|
|
let mut b = [0u8; 8];
|
|
b.copy_from_slice(&d[o..o + 8]);
|
|
u64::from_le_bytes(b)
|
|
}
|
|
|
|
// Query callbacks matching the C test's QueryReplay*Fcn set.
|
|
fn overlap_fcn() -> impl FnMut(ShapeId) -> bool {
|
|
|_id| true
|
|
}
|
|
fn cast_fcn() -> impl FnMut(makepad_box3d::id::ShapeId, makepad_box3d::math_functions::Pos, makepad_box3d::math_functions::Vec3, f32, u64, i32, i32) -> f32
|
|
{
|
|
// Return the fraction to keep the closest hit, exercising the recorded
|
|
// user-return path.
|
|
|_id, _point, _normal, fraction, _mat, _tri, _child| fraction
|
|
}
|
|
fn plane_fcn() -> impl FnMut(ShapeId, &[PlaneResult]) -> bool {
|
|
|_id, _planes| true
|
|
}
|
|
|
|
// C: SphereRoundTrip — record/step/stop, then replay and validate.
|
|
#[test]
|
|
fn sphere_round_trip() {
|
|
let mut world = create_world(&default_world_def());
|
|
world_start_recording(&mut world);
|
|
|
|
// Set a non-default gravity so the setter op appears in the stream.
|
|
world_set_gravity(&mut world, vec3(0.0, -10.0, 0.0));
|
|
|
|
// Static ground
|
|
let ground_id = create_body(&mut world, &default_body_def());
|
|
let ground_box = make_box_hull(50.0, 1.0, 50.0);
|
|
create_hull_shape(&mut world, ground_id, &default_shape_def(), &ground_box);
|
|
|
|
// Dynamic body with a sphere shape
|
|
let mut body_def = default_body_def();
|
|
body_def.body_type = BodyType::Dynamic;
|
|
body_def.position = pos(0.0, 5.0, 0.0);
|
|
let body_id = create_body(&mut world, &body_def);
|
|
let sphere = Sphere { center: vec3(0.0, 0.0, 0.0), radius: 0.5 };
|
|
let mut sphere_def = default_shape_def();
|
|
sphere_def.density = 1.0;
|
|
create_sphere_shape(&mut world, body_id, &sphere_def, &sphere);
|
|
|
|
for _ in 0..30 {
|
|
world_step(&mut world, 1.0 / 60.0, 4);
|
|
}
|
|
|
|
let rec = world_stop_recording(&mut world).expect("recording active");
|
|
destroy_world(world);
|
|
|
|
ensure!(validate_replay(rec.data(), 1));
|
|
}
|
|
|
|
// C: EmptyWorldRoundTrip — an empty world is still seed-serialized; Restart
|
|
// restores in place with a stable world id.
|
|
#[test]
|
|
fn empty_world_round_trip() {
|
|
let mut world = create_world(&default_world_def());
|
|
world_start_recording(&mut world);
|
|
world_set_gravity(&mut world, vec3(0.0, -10.0, 0.0));
|
|
|
|
for _ in 0..10 {
|
|
world_step(&mut world, 1.0 / 60.0, 4);
|
|
}
|
|
|
|
let rec = world_stop_recording(&mut world).expect("recording active");
|
|
destroy_world(world);
|
|
|
|
let data = rec.data();
|
|
|
|
// The seed snapshot is written even with no bodies.
|
|
ensure!(read_u64(data, 24) > 0);
|
|
|
|
ensure!(validate_replay(data, 1));
|
|
|
|
// Restart restores in place, so the replay world survives a rewind.
|
|
let mut player = Player::create(data, 1).expect("player");
|
|
let world_key = (player.world().world_id, player.world().generation);
|
|
while !player.is_at_end() {
|
|
player.step_frame();
|
|
}
|
|
player.restart();
|
|
ensure!((player.world().world_id, player.world().generation) == world_key);
|
|
ensure!(player.get_frame() == 0);
|
|
ensure!(!player.has_diverged());
|
|
player.destroy();
|
|
}
|
|
|
|
// C: HullDedup — three bodies sharing one hull produce one registry entry.
|
|
#[test]
|
|
fn hull_dedup() {
|
|
let pts = [
|
|
vec3(-1.0, -1.0, -1.0),
|
|
vec3(1.0, -1.0, -1.0),
|
|
vec3(1.0, 1.0, -1.0),
|
|
vec3(-1.0, 1.0, -1.0),
|
|
vec3(-1.0, -1.0, 1.0),
|
|
vec3(1.0, -1.0, 1.0),
|
|
vec3(1.0, 1.0, 1.0),
|
|
vec3(-1.0, 1.0, 1.0),
|
|
];
|
|
let hull = create_hull(&pts, 8).expect("hull");
|
|
|
|
let mut world = create_world(&default_world_def());
|
|
world_start_recording(&mut world);
|
|
|
|
let mut shape_def = default_shape_def();
|
|
shape_def.density = 1.0;
|
|
|
|
for i in 0..3 {
|
|
let mut body_def = default_body_def();
|
|
body_def.body_type = BodyType::Dynamic;
|
|
body_def.position = pos((i * 3) as f32, 5.0, 0.0);
|
|
let body_id = create_body(&mut world, &body_def);
|
|
create_hull_shape(&mut world, body_id, &shape_def, &hull);
|
|
}
|
|
|
|
for _ in 0..5 {
|
|
world_step(&mut world, 1.0 / 60.0, 4);
|
|
}
|
|
|
|
let rec = world_stop_recording(&mut world).expect("recording active");
|
|
destroy_world(world);
|
|
|
|
ensure!(validate_replay(rec.data(), 1));
|
|
|
|
// Registry deduped to 1 hull entry: registryOffset at byte 32 of the
|
|
// header, entryCount is a LE u32 at the start of the registry block.
|
|
let data = rec.data();
|
|
ensure!(data.len() >= REC_HEADER_SIZE);
|
|
let reg_off = read_u64(data, 32) as usize;
|
|
ensure!(reg_off != 0 && reg_off + 4 <= data.len());
|
|
ensure!(read_u32(data, reg_off) == 1);
|
|
}
|
|
|
|
// C: MidStreamNoContacts — recording starts after steps, snapshot-seeded, with
|
|
// free-falling bodies only.
|
|
#[test]
|
|
fn mid_stream_no_contacts() {
|
|
let mut world = create_world(&default_world_def());
|
|
world_set_gravity(&mut world, vec3(0.0, -10.0, 0.0));
|
|
|
|
let sphere = Sphere { center: vec3(0.0, 0.0, 0.0), radius: 0.5 };
|
|
let mut shape_def = default_shape_def();
|
|
shape_def.density = 1.0;
|
|
|
|
for i in 0..4 {
|
|
let mut body_def = default_body_def();
|
|
body_def.body_type = BodyType::Dynamic;
|
|
body_def.position = pos((i * 10) as f32, 50.0, 0.0);
|
|
let body_id = create_body(&mut world, &body_def);
|
|
create_sphere_shape(&mut world, body_id, &shape_def, &sphere);
|
|
}
|
|
|
|
for _ in 0..10 {
|
|
world_step(&mut world, 1.0 / 60.0, 4);
|
|
}
|
|
|
|
world_start_recording(&mut world);
|
|
for _ in 0..30 {
|
|
world_step(&mut world, 1.0 / 60.0, 4);
|
|
}
|
|
|
|
let rec = world_stop_recording(&mut world).expect("recording active");
|
|
destroy_world(world);
|
|
|
|
ensure!(validate_replay(rec.data(), 1));
|
|
}
|
|
|
|
// C: MidStreamContacts — snapshot with warm-start manifolds, islands, colors.
|
|
#[test]
|
|
fn mid_stream_contacts() {
|
|
let mut world = create_world(&default_world_def());
|
|
world_set_gravity(&mut world, vec3(0.0, -10.0, 0.0));
|
|
|
|
let ground_id = create_body(&mut world, &default_body_def());
|
|
let ground_box = make_box_hull(50.0, 1.0, 50.0);
|
|
create_hull_shape(&mut world, ground_id, &default_shape_def(), &ground_box);
|
|
|
|
let mut dynamic_shape = default_shape_def();
|
|
dynamic_shape.density = 1.0;
|
|
|
|
for i in 0..3 {
|
|
let bx = make_box_hull(0.5, 0.5, 0.5);
|
|
let mut body_def = default_body_def();
|
|
body_def.body_type = BodyType::Dynamic;
|
|
body_def.position = pos((i * 2) as f32 - 2.0, 5.0, 0.0);
|
|
let body_id = create_body(&mut world, &body_def);
|
|
create_hull_shape(&mut world, body_id, &dynamic_shape, &bx);
|
|
}
|
|
|
|
// Let the scene settle: manifolds, islands, graph colors.
|
|
for _ in 0..60 {
|
|
world_step(&mut world, 1.0 / 60.0, 4);
|
|
}
|
|
|
|
world_start_recording(&mut world);
|
|
for _ in 0..30 {
|
|
world_step(&mut world, 1.0 / 60.0, 4);
|
|
}
|
|
|
|
let rec = world_stop_recording(&mut world).expect("recording active");
|
|
destroy_world(world);
|
|
|
|
ensure!(validate_replay(rec.data(), 1));
|
|
}
|
|
|
|
// C: ScrubBackward — forward pass recording per-frame hashes, then backward
|
|
// seeks that must reproduce each recorded hash exactly.
|
|
#[test]
|
|
fn scrub_backward() {
|
|
let mut world = create_world(&default_world_def());
|
|
world_set_gravity(&mut world, vec3(0.0, -10.0, 0.0));
|
|
world_start_recording(&mut world);
|
|
|
|
let ground_id = create_body(&mut world, &default_body_def());
|
|
let ground_box = make_box_hull(20.0, 1.0, 20.0);
|
|
create_hull_shape(&mut world, ground_id, &default_shape_def(), &ground_box);
|
|
|
|
let mut box_shape = default_shape_def();
|
|
box_shape.density = 1.0;
|
|
for i in 0..4 {
|
|
let bx = make_box_hull(0.5, 0.5, 0.5);
|
|
let mut body_def = default_body_def();
|
|
body_def.body_type = BodyType::Dynamic;
|
|
body_def.position = pos(0.0, 2.0 + i as f32 * 1.5, 0.0);
|
|
let body_id = create_body(&mut world, &body_def);
|
|
create_hull_shape(&mut world, body_id, &box_shape, &bx);
|
|
}
|
|
|
|
let total_frames = 80;
|
|
for _ in 0..total_frames {
|
|
world_step(&mut world, 1.0 / 60.0, 4);
|
|
}
|
|
|
|
let rec = world_stop_recording(&mut world).expect("recording active");
|
|
destroy_world(world);
|
|
|
|
let mut player = Player::create(rec.data(), 1).expect("player");
|
|
ensure!(player.get_frame_count() == total_frames);
|
|
|
|
// Forward pass: record per-frame hashes.
|
|
let mut hashes = vec![0u64; (total_frames + 1) as usize];
|
|
while !player.is_at_end() {
|
|
player.step_frame();
|
|
let f = player.get_frame();
|
|
if f <= total_frames {
|
|
hashes[f as usize] = hash_world_state(player.world());
|
|
}
|
|
}
|
|
ensure!(player.get_frame() == total_frames);
|
|
ensure!(!player.has_diverged());
|
|
|
|
// Backward seeks must land exactly and reproduce the recorded hash.
|
|
let seek_targets = [total_frames, total_frames / 2, 5, total_frames - 1, 0, 1];
|
|
for &target in seek_targets.iter() {
|
|
player.seek_frame(target);
|
|
ensure!(player.get_frame() == target);
|
|
ensure!(!player.has_diverged());
|
|
if target > 0 {
|
|
ensure!(hash_world_state(player.world()) == hashes[target as usize]);
|
|
}
|
|
}
|
|
|
|
player.destroy();
|
|
}
|
|
|
|
// C: SeekWithHull — seek across keyframes with shared custom hulls; the
|
|
// keyframe capture path re-serializes the world against the pre-seeded
|
|
// registry, which must not grow.
|
|
#[test]
|
|
fn seek_with_hull() {
|
|
let pts = [
|
|
vec3(-1.0, -1.0, -1.0),
|
|
vec3(1.0, -1.0, -1.0),
|
|
vec3(1.0, 1.0, -1.0),
|
|
vec3(-1.0, 1.0, -1.0),
|
|
vec3(-1.0, -1.0, 1.0),
|
|
vec3(1.0, -1.0, 1.0),
|
|
vec3(1.0, 1.0, 1.0),
|
|
vec3(-1.0, 1.0, 1.0),
|
|
];
|
|
let hull = create_hull(&pts, 8).expect("hull");
|
|
|
|
let mut world = create_world(&default_world_def());
|
|
world_set_gravity(&mut world, vec3(0.0, -10.0, 0.0));
|
|
world_start_recording(&mut world);
|
|
|
|
let ground_id = create_body(&mut world, &default_body_def());
|
|
let ground_box = make_box_hull(20.0, 1.0, 20.0);
|
|
create_hull_shape(&mut world, ground_id, &default_shape_def(), &ground_box);
|
|
|
|
let mut sd = default_shape_def();
|
|
sd.density = 1.0;
|
|
for i in 0..3 {
|
|
let mut bd = default_body_def();
|
|
bd.body_type = BodyType::Dynamic;
|
|
bd.position = pos((i * 4) as f32 - 4.0, 5.0, 0.0);
|
|
let body_id = create_body(&mut world, &bd);
|
|
create_hull_shape(&mut world, body_id, &sd, &hull);
|
|
}
|
|
|
|
let total_frames = 40;
|
|
for _ in 0..total_frames {
|
|
world_step(&mut world, 1.0 / 60.0, 4);
|
|
}
|
|
|
|
let rec = world_stop_recording(&mut world).expect("recording active");
|
|
destroy_world(world);
|
|
|
|
let mut player = Player::create(rec.data(), 1).expect("player");
|
|
while !player.is_at_end() {
|
|
player.step_frame();
|
|
}
|
|
ensure!(!player.has_diverged());
|
|
|
|
let mid_frame = total_frames / 2;
|
|
player.seek_frame(mid_frame);
|
|
ensure!(player.get_frame() == mid_frame);
|
|
ensure!(!player.has_diverged());
|
|
|
|
player.seek_frame(0);
|
|
ensure!(player.get_frame() == 0);
|
|
|
|
player.destroy();
|
|
}
|
|
|
|
// C: PlayerAccessors — recording info, creation-ordinal body tracking (seeded
|
|
// from the snapshot), divergence frame, keyframe policy.
|
|
#[test]
|
|
fn player_accessors() {
|
|
let mut world = create_world(&default_world_def());
|
|
world_set_gravity(&mut world, vec3(0.0, -10.0, 0.0));
|
|
|
|
// Static ground (creation ordinal 0)
|
|
let ground_id = create_body(&mut world, &default_body_def());
|
|
let ground_box = make_box_hull(20.0, 1.0, 20.0);
|
|
create_hull_shape(&mut world, ground_id, &default_shape_def(), &ground_box);
|
|
|
|
// Four dynamic boxes (ordinals 1..4)
|
|
let dynamic_count = 4;
|
|
let mut box_shape = default_shape_def();
|
|
box_shape.density = 1.0;
|
|
for i in 0..dynamic_count {
|
|
let bx = make_box_hull(0.5, 0.5, 0.5);
|
|
let mut body_def = default_body_def();
|
|
body_def.body_type = BodyType::Dynamic;
|
|
body_def.position = pos(0.0, 2.0 + i as f32 * 1.5, 0.0);
|
|
let body_id = create_body(&mut world, &body_def);
|
|
create_hull_shape(&mut world, body_id, &box_shape, &bx);
|
|
}
|
|
|
|
let time_step = 1.0 / 60.0;
|
|
let sub_step_count = 4;
|
|
|
|
// Settle, then record with a snapshot of the populated world.
|
|
for _ in 0..10 {
|
|
world_step(&mut world, time_step, sub_step_count);
|
|
}
|
|
|
|
world_start_recording(&mut world);
|
|
let total_frames = 80;
|
|
for _ in 0..total_frames {
|
|
world_step(&mut world, time_step, sub_step_count);
|
|
}
|
|
let rec = world_stop_recording(&mut world).expect("recording active");
|
|
destroy_world(world);
|
|
|
|
let mut player = Player::create(rec.data(), 1).expect("player");
|
|
|
|
// Info reflects the recorded tuning and a non-degenerate bounds.
|
|
let info = player.get_info();
|
|
ensure!(info.frame_count == total_frames);
|
|
ensure!(info.sub_step_count == sub_step_count);
|
|
ensure!(info.time_step > 0.0);
|
|
let extent = makepad_box3d::math_functions::sub(info.bounds.upper_bound, info.bounds.lower_bound);
|
|
ensure!(extent.x > 0.0 && extent.y > 0.0 && extent.z > 0.0);
|
|
|
|
// Body ordinals: ground + 4 dynamic, seeded from the snapshot.
|
|
ensure!(player.get_body_count() == 1 + dynamic_count);
|
|
let ground = player.get_body_id(0);
|
|
ensure!(ground != NULL_BODY_ID);
|
|
ensure!(body_get_type(player.world(), ground) == BodyType::Static);
|
|
for i in 1..=dynamic_count {
|
|
let id = player.get_body_id(i);
|
|
ensure!(id != NULL_BODY_ID);
|
|
ensure!(body_get_type(player.world(), id) == BodyType::Dynamic);
|
|
}
|
|
ensure!(player.get_body_id(1 + dynamic_count) == NULL_BODY_ID);
|
|
|
|
// No divergence on a clean serial replay.
|
|
player.seek_frame(total_frames);
|
|
ensure!(!player.has_diverged());
|
|
ensure!(player.get_diverge_frame() == -1);
|
|
|
|
// Ordinals survive a backward seek that restores from a keyframe.
|
|
let before = player.get_body_id(2);
|
|
player.seek_frame(total_frames / 2);
|
|
player.seek_frame(total_frames);
|
|
let after = player.get_body_id(2);
|
|
ensure!(before == after);
|
|
|
|
// Keyframe policy: defaults present, setter takes effect, ring cleared.
|
|
ensure!(player.get_keyframe_min_interval() == 16);
|
|
player.set_keyframe_policy(256 * 1024 * 1024, 8);
|
|
ensure!(player.get_keyframe_min_interval() == 8);
|
|
ensure!(player.get_keyframe_interval() == 8);
|
|
ensure!(player.get_keyframe_budget() == 256 * 1024 * 1024);
|
|
ensure!(player.get_keyframe_bytes() == 0);
|
|
|
|
player.destroy();
|
|
}
|
|
|
|
// C: QueryReplay — all seven world queries each frame; a clean replay proves
|
|
// the queries reproduce; the per-frame store surfaces all seven.
|
|
#[test]
|
|
fn query_replay() {
|
|
let mut world = create_world(&default_world_def());
|
|
world_set_gravity(&mut world, vec3(0.0, -10.0, 0.0));
|
|
|
|
let ground_id = create_body(&mut world, &default_body_def());
|
|
let ground_box = make_box_hull(20.0, 1.0, 20.0);
|
|
create_hull_shape(&mut world, ground_id, &default_shape_def(), &ground_box);
|
|
|
|
// A few dynamic spheres for the queries to find.
|
|
for i in 0..4 {
|
|
let mut body_def = default_body_def();
|
|
body_def.body_type = BodyType::Dynamic;
|
|
body_def.position = pos(i as f32 - 1.5, 3.0, 0.0);
|
|
let body_id = create_body(&mut world, &body_def);
|
|
let sphere = Sphere { center: vec3(0.0, 0.0, 0.0), radius: 0.5 };
|
|
let mut sphere_def = default_shape_def();
|
|
sphere_def.density = 1.0;
|
|
create_sphere_shape(&mut world, body_id, &sphere_def, &sphere);
|
|
}
|
|
|
|
world_start_recording(&mut world);
|
|
|
|
let filter = default_query_filter();
|
|
let total_frames = 30;
|
|
for _ in 0..total_frames {
|
|
let origin = pos(0.0, 6.0, 0.0);
|
|
let translation = vec3(0.0, -8.0, 0.0);
|
|
let aabb = AABB { lower_bound: vec3(-5.0, -1.0, -5.0), upper_bound: vec3(5.0, 6.0, 5.0) };
|
|
|
|
let proxy_pts = [vec3(0.0, 0.0, 0.0)];
|
|
let proxy = ShapeProxy { points: &proxy_pts, radius: 0.5 };
|
|
let mover = Capsule { center1: vec3(0.0, 0.0, 0.0), center2: vec3(0.0, 1.0, 0.0), radius: 0.3 };
|
|
|
|
world_overlap_aabb(&mut world, aabb, filter, &mut overlap_fcn());
|
|
world_overlap_shape(&world, origin, &proxy, filter, &mut overlap_fcn());
|
|
world_cast_ray(&world, origin, translation, filter, &mut cast_fcn());
|
|
world_cast_ray_closest(&world, origin, translation, filter);
|
|
world_cast_shape(&world, origin, &proxy, translation, filter, &mut cast_fcn());
|
|
world_cast_mover(&world, origin, &mover, translation, filter, Some(&mut overlap_fcn()));
|
|
world_collide_mover(&world, origin, &mover, filter, &mut plane_fcn());
|
|
|
|
world_step(&mut world, 1.0 / 60.0, 4);
|
|
}
|
|
|
|
let rec = world_stop_recording(&mut world).expect("recording active");
|
|
destroy_world(world);
|
|
|
|
// Headless validation re-issues every recorded query and compares.
|
|
ensure!(validate_replay(rec.data(), 1));
|
|
|
|
// Player path: the per-frame store holds all seven at a mid frame.
|
|
let mut player = Player::create(rec.data(), 1).expect("player");
|
|
player.seek_frame(15);
|
|
ensure!(!player.has_diverged());
|
|
ensure!(player.get_frame_query_count() == 7);
|
|
|
|
let first = player.get_frame_query(0);
|
|
ensure!(first.kind == RecQueryKind::OverlapAabb);
|
|
|
|
// The ray cast finds at least the ground: non-empty recorded hit list.
|
|
let mut saw_cast_ray = false;
|
|
for qi in 0..player.get_frame_query_count() {
|
|
let info = player.get_frame_query(qi);
|
|
if info.kind == RecQueryKind::CastRay {
|
|
saw_cast_ray = true;
|
|
ensure!(info.hit_count > 0);
|
|
}
|
|
}
|
|
ensure!(saw_cast_ray);
|
|
|
|
player.destroy();
|
|
}
|
|
|
|
// C: TaggedQuery — caller (id, label) keys ride the QueryTag op and intern in
|
|
// the trailing tag table; distinct ids under one label are distinct keys; all
|
|
// survive a file round trip; untagged queries report key 0 / id 0 / no name.
|
|
#[test]
|
|
fn tagged_query() {
|
|
let mut world = create_world(&default_world_def());
|
|
|
|
let ground_id = create_body(&mut world, &default_body_def());
|
|
let ground_box = make_box_hull(20.0, 1.0, 20.0);
|
|
create_hull_shape(&mut world, ground_id, &default_shape_def(), &ground_box);
|
|
|
|
world_start_recording(&mut world);
|
|
|
|
let mut bullet53 = default_query_filter();
|
|
bullet53.id = 53;
|
|
bullet53.name = "bullet";
|
|
|
|
let mut bullet54 = default_query_filter();
|
|
bullet54.id = 54;
|
|
bullet54.name = "bullet";
|
|
|
|
let untagged = default_query_filter();
|
|
|
|
let key53 = hash_query_tag(53, "bullet");
|
|
let key54 = hash_query_tag(54, "bullet");
|
|
ensure!(key53 != 0 && key54 != 0 && key53 != key54);
|
|
|
|
let total_frames = 10;
|
|
for _ in 0..total_frames {
|
|
let origin = pos(0.0, 6.0, 0.0);
|
|
let translation = vec3(0.0, -8.0, 0.0);
|
|
let aabb = AABB { lower_bound: vec3(-5.0, -1.0, -5.0), upper_bound: vec3(5.0, 6.0, 5.0) };
|
|
|
|
world_cast_ray(&world, origin, translation, bullet53, &mut cast_fcn());
|
|
world_cast_ray(&world, origin, translation, bullet54, &mut cast_fcn());
|
|
world_overlap_aabb(&mut world, aabb, untagged, &mut overlap_fcn());
|
|
|
|
world_step(&mut world, 1.0 / 60.0, 4);
|
|
}
|
|
|
|
let rec = world_stop_recording(&mut world).expect("recording active");
|
|
destroy_world(world);
|
|
|
|
ensure!(validate_replay(rec.data(), 1));
|
|
|
|
// Round trip through a file so the interned tag table is exercised on the
|
|
// persisted bytes.
|
|
let path = std::env::temp_dir().join("box3d_tagged_query_test.b3rc");
|
|
let path = path.to_str().unwrap();
|
|
ensure!(save_recording_to_file(&rec, path));
|
|
let loaded = load_recording_from_file(path).expect("load");
|
|
|
|
let mut player = Player::create(loaded.data(), 1).expect("player");
|
|
player.seek_frame(5);
|
|
ensure!(!player.has_diverged());
|
|
ensure!(player.get_frame_query_count() == 3);
|
|
|
|
let (mut saw53, mut saw54, mut saw_untagged) = (false, false, false);
|
|
for qi in 0..player.get_frame_query_count() {
|
|
let info = player.get_frame_query(qi);
|
|
if info.key == key53 {
|
|
saw53 = true;
|
|
ensure!(info.id == 53 && info.name.as_deref() == Some("bullet"));
|
|
} else if info.key == key54 {
|
|
saw54 = true;
|
|
ensure!(info.id == 54 && info.name.as_deref() == Some("bullet"));
|
|
} else {
|
|
saw_untagged = true;
|
|
ensure!(info.key == 0 && info.id == 0 && info.name.is_none());
|
|
}
|
|
}
|
|
ensure!(saw53 && saw54 && saw_untagged);
|
|
|
|
player.destroy();
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
|
|
// C: TransformedHullRoundTrip — a transformed hull bakes transform + scale at
|
|
// create time; it must record like any other shape create or every later
|
|
// shape id drifts on replay.
|
|
#[test]
|
|
fn transformed_hull_round_trip() {
|
|
let pts = [
|
|
vec3(-1.0, -1.0, -1.0),
|
|
vec3(1.0, -1.0, -1.0),
|
|
vec3(1.0, 1.0, -1.0),
|
|
vec3(-1.0, 1.0, -1.0),
|
|
vec3(-1.0, -1.0, 1.0),
|
|
vec3(1.0, -1.0, 1.0),
|
|
vec3(1.0, 1.0, 1.0),
|
|
vec3(-1.0, 1.0, 1.0),
|
|
];
|
|
let hull = create_hull(&pts, 8).expect("hull");
|
|
|
|
let mut world = create_world(&default_world_def());
|
|
world_start_recording(&mut world);
|
|
|
|
let mut shape_def = default_shape_def();
|
|
shape_def.density = 1.0;
|
|
|
|
let xf = Transform {
|
|
p: vec3(0.25, 0.0, -0.5),
|
|
q: make_quat_from_axis_angle(vec3(0.0, 0.0, 1.0), 0.3),
|
|
};
|
|
let scl = vec3(1.5, 0.5, 2.0);
|
|
for i in 0..3 {
|
|
let mut body_def = default_body_def();
|
|
body_def.body_type = BodyType::Dynamic;
|
|
body_def.position = pos((i * 3) as f32, 5.0, 0.0);
|
|
let body_id = create_body(&mut world, &body_def);
|
|
let sid = create_transformed_hull_shape(&mut world, body_id, &shape_def, &hull, xf, scl);
|
|
ensure!(sid.index1 != 0);
|
|
}
|
|
|
|
// A plain hull after the transformed ones: a desynced id pool would make
|
|
// this shape's recorded id mismatch on replay.
|
|
{
|
|
let mut body_def = default_body_def();
|
|
body_def.body_type = BodyType::Dynamic;
|
|
body_def.position = pos(0.0, 10.0, 0.0);
|
|
let body_id = create_body(&mut world, &body_def);
|
|
let sid = create_hull_shape(&mut world, body_id, &shape_def, &hull);
|
|
ensure!(sid.index1 != 0);
|
|
}
|
|
|
|
// Step past the keyframe interval so replay captures a keyframe, which
|
|
// re-serializes the live world against the pre-seeded registry.
|
|
for _ in 0..20 {
|
|
world_step(&mut world, 1.0 / 60.0, 4);
|
|
}
|
|
|
|
let rec = world_stop_recording(&mut world).expect("recording active");
|
|
destroy_world(world);
|
|
|
|
ensure!(validate_replay(rec.data(), 1));
|
|
ensure!(validate_replay(rec.data(), 4));
|
|
}
|
|
|
|
// C: AllOps — every recorded op in one session; validate at two worker
|
|
// counts, round trip through a file, drive the incremental player.
|
|
#[test]
|
|
fn all_ops() {
|
|
let mut world_def = default_world_def();
|
|
world_def.worker_count = 1;
|
|
let mut world = create_world(&world_def);
|
|
|
|
world_start_recording(&mut world);
|
|
|
|
// Static ground with a box-hull shape
|
|
let ground_id = create_body(&mut world, &default_body_def());
|
|
let ground_box = make_box_hull(50.0, 1.0, 50.0);
|
|
let ground_shape_id = create_hull_shape(&mut world, ground_id, &default_shape_def(), &ground_box);
|
|
ensure!(ground_shape_id.index1 != 0);
|
|
|
|
// Dynamic body with a sphere shape. Name intentionally longer than
|
|
// BODY_NAME_LENGTH so replay exercises the over-length name path.
|
|
let mut body_def = default_body_def();
|
|
body_def.body_type = BodyType::Dynamic;
|
|
body_def.position = pos(0.0, 5.0, 0.0);
|
|
body_def.name = String::from("testBodyWithVeryLongNameThatExceedsTheNameLength");
|
|
let body_id = create_body(&mut world, &body_def);
|
|
|
|
let mut sphere_shape_def = default_shape_def();
|
|
sphere_shape_def.density = 1.0;
|
|
let sphere = Sphere { center: vec3(0.0, 0.0, 0.0), radius: 0.5 };
|
|
let sphere_shape_id = create_sphere_shape(&mut world, body_id, &sphere_shape_def, &sphere);
|
|
ensure!(sphere_shape_id.index1 != 0);
|
|
|
|
// Capsule shape on a second dynamic body
|
|
let mut capsule_body_def = default_body_def();
|
|
capsule_body_def.body_type = BodyType::Dynamic;
|
|
capsule_body_def.position = pos(3.0, 5.0, 0.0);
|
|
let capsule_body_id = create_body(&mut world, &capsule_body_def);
|
|
|
|
let mut capsule_shape_def = default_shape_def();
|
|
capsule_shape_def.density = 1.0;
|
|
let capsule = Capsule { center1: vec3(0.0, -0.4, 0.0), center2: vec3(0.0, 0.4, 0.0), radius: 0.25 };
|
|
let capsule_shape_id = create_capsule_shape(&mut world, capsule_body_id, &capsule_shape_def, &capsule);
|
|
ensure!(capsule_shape_id.index1 != 0);
|
|
|
|
// Custom hull shape on a third dynamic body
|
|
let hull_pts = [
|
|
vec3(-0.5, -0.5, -0.5),
|
|
vec3(0.5, -0.5, -0.5),
|
|
vec3(0.5, 0.5, -0.5),
|
|
vec3(-0.5, 0.5, -0.5),
|
|
vec3(-0.5, -0.5, 0.5),
|
|
vec3(0.5, -0.5, 0.5),
|
|
vec3(0.5, 0.5, 0.5),
|
|
vec3(-0.5, 0.5, 0.5),
|
|
];
|
|
let custom_hull = create_hull(&hull_pts, 8).expect("hull");
|
|
|
|
let mut hull_body_def = default_body_def();
|
|
hull_body_def.body_type = BodyType::Dynamic;
|
|
hull_body_def.position = pos(-3.0, 5.0, 0.0);
|
|
let hull_body_id = create_body(&mut world, &hull_body_def);
|
|
|
|
let mut hull_shape_def = default_shape_def();
|
|
hull_shape_def.density = 1.0;
|
|
let hull_shape_id = create_hull_shape(&mut world, hull_body_id, &hull_shape_def, &custom_hull);
|
|
ensure!(hull_shape_id.index1 != 0);
|
|
|
|
// Box hull shape on a fourth dynamic body
|
|
let mut box_body_def = default_body_def();
|
|
box_body_def.body_type = BodyType::Dynamic;
|
|
box_body_def.position = pos(6.0, 5.0, 0.0);
|
|
let box_body_id = create_body(&mut world, &box_body_def);
|
|
|
|
let mut box_shape_def = default_shape_def();
|
|
box_shape_def.density = 2.0;
|
|
let box_hull = make_box_hull(0.5, 0.5, 0.5);
|
|
let box_shape_id = create_hull_shape(&mut world, box_body_id, &box_shape_def, &box_hull);
|
|
ensure!(box_shape_id.index1 != 0);
|
|
|
|
// Transformed hull shape on a fifth dynamic body
|
|
let mut xform_body_def = default_body_def();
|
|
xform_body_def.body_type = BodyType::Dynamic;
|
|
xform_body_def.position = pos(12.0, 5.0, 0.0);
|
|
let xform_body_id = create_body(&mut world, &xform_body_def);
|
|
let mut xform_shape_def = default_shape_def();
|
|
xform_shape_def.density = 1.0;
|
|
let xform_xf = Transform {
|
|
p: vec3(0.1, 0.2, -0.1),
|
|
q: make_quat_from_axis_angle(vec3(0.0, 1.0, 0.0), 0.4),
|
|
};
|
|
let xform_shape_id = create_transformed_hull_shape(
|
|
&mut world,
|
|
xform_body_id,
|
|
&xform_shape_def,
|
|
&custom_hull,
|
|
xform_xf,
|
|
vec3(1.25, 0.75, 1.5),
|
|
);
|
|
ensure!(xform_shape_id.index1 != 0);
|
|
|
|
// Mesh, height field, and compound static shapes (3D-only)
|
|
let mut mesh_body_def = default_body_def();
|
|
mesh_body_def.position = pos(20.0, 0.0, 0.0);
|
|
let mesh_body_id = create_body(&mut world, &mesh_body_def);
|
|
let mesh_data = create_grid_mesh(3, 3, 2.0, 0, false);
|
|
create_mesh_shape(&mut world, mesh_body_id, &default_shape_def(), &mesh_data, vec3(1.0, 1.0, 1.0));
|
|
|
|
let mut hf_body_def = default_body_def();
|
|
hf_body_def.position = pos(-20.0, 0.0, 0.0);
|
|
let hf_body_id = create_body(&mut world, &hf_body_def);
|
|
let hf = create_grid(4, 4, vec3(2.0, 1.0, 2.0), false);
|
|
create_height_field_shape(&mut world, hf_body_id, &default_shape_def(), &hf);
|
|
|
|
let mut compound_body_def = default_body_def();
|
|
compound_body_def.position = pos(30.0, 0.0, 0.0);
|
|
let compound_body_id = create_body(&mut world, &compound_body_def);
|
|
let compound_def = CompoundDef {
|
|
spheres: vec![CompoundSphereDef {
|
|
sphere: Sphere { center: vec3(0.0, 0.0, 0.0), radius: 1.0 },
|
|
material: default_surface_material(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let compound = create_compound(&compound_def);
|
|
create_compound_shape(&mut world, compound_body_id, &default_shape_def(), &compound);
|
|
|
|
// Throwaway shape to exercise DestroyShape
|
|
let tmp_sphere = Sphere { center: vec3(0.0, 0.0, 0.0), radius: 0.1 };
|
|
let tmp_shape_id = create_sphere_shape(&mut world, capsule_body_id, &capsule_shape_def, &tmp_sphere);
|
|
destroy_shape(&mut world, tmp_shape_id, true);
|
|
|
|
// Shape mutators
|
|
shape_set_friction(&mut world, box_shape_id, 0.3);
|
|
shape_set_restitution(&mut world, capsule_shape_id, 0.5);
|
|
shape_set_density(&mut world, box_shape_id, 3.0, true);
|
|
let mut surf_mat = default_surface_material();
|
|
surf_mat.friction = 0.7;
|
|
surf_mat.restitution = 0.1;
|
|
shape_set_surface_material(&mut world, capsule_shape_id, surf_mat);
|
|
let mut shape_filter = default_filter();
|
|
shape_filter.category_bits = 0x2;
|
|
shape_set_filter(&mut world, box_shape_id, shape_filter, false);
|
|
shape_enable_sensor_events(&mut world, capsule_shape_id, true);
|
|
shape_enable_contact_events(&mut world, capsule_shape_id, true);
|
|
shape_enable_hit_events(&mut world, box_shape_id, true);
|
|
shape_enable_pre_solve_events(&mut world, box_shape_id, true);
|
|
shape_apply_wind(&mut world, capsule_shape_id, vec3(1.0, 0.0, 0.0), 0.1, 0.0, 10.0, true);
|
|
let new_sphere = Sphere { center: vec3(0.0, 0.0, 0.0), radius: 0.45 };
|
|
shape_set_sphere(&mut world, sphere_shape_id, &new_sphere);
|
|
let new_capsule = Capsule { center1: vec3(0.0, -0.3, 0.0), center2: vec3(0.0, 0.3, 0.0), radius: 0.3 };
|
|
shape_set_capsule(&mut world, capsule_shape_id, &new_capsule);
|
|
|
|
// Body mutators
|
|
body_set_transform(&mut world, body_id, pos(1.0, 6.0, 0.0), Quat::IDENTITY);
|
|
body_set_linear_velocity(&mut world, body_id, vec3(0.5, 0.0, 0.0));
|
|
body_set_angular_velocity(&mut world, body_id, vec3(0.0, 0.25, 0.0));
|
|
body_set_name(&mut world, body_id, "renamedBody");
|
|
body_set_linear_damping(&mut world, body_id, 0.1);
|
|
body_set_angular_damping(&mut world, body_id, 0.05);
|
|
body_set_gravity_scale(&mut world, body_id, 0.9);
|
|
body_set_sleep_threshold(&mut world, body_id, 0.02);
|
|
body_enable_sleep(&mut world, body_id, false);
|
|
body_set_bullet(&mut world, body_id, true);
|
|
body_enable_contact_recycling(&mut world, body_id, false);
|
|
body_enable_hit_events(&mut world, body_id, true);
|
|
body_set_motion_locks(
|
|
&mut world,
|
|
body_id,
|
|
MotionLocks {
|
|
linear_x: false,
|
|
linear_y: false,
|
|
linear_z: false,
|
|
angular_x: false,
|
|
angular_y: false,
|
|
angular_z: true,
|
|
},
|
|
);
|
|
let mass_data = MassData { mass: 2.0, center: vec3(0.0, 0.0, 0.0), inertia: Matrix3::IDENTITY };
|
|
body_set_mass_data(&mut world, body_id, mass_data);
|
|
body_apply_mass_from_shapes(&mut world, body_id);
|
|
body_set_type(&mut world, capsule_body_id, BodyType::Kinematic);
|
|
body_set_type(&mut world, capsule_body_id, BodyType::Dynamic);
|
|
body_set_awake(&mut world, body_id, true);
|
|
|
|
// Kinematic body to exercise SetTargetTransform
|
|
let mut kinematic_def = default_body_def();
|
|
kinematic_def.body_type = BodyType::Kinematic;
|
|
kinematic_def.position = pos(-6.0, 5.0, 0.0);
|
|
let kinematic_id = create_body(&mut world, &kinematic_def);
|
|
let kin_box = make_box_hull(0.4, 0.4, 0.4);
|
|
create_hull_shape(&mut world, kinematic_id, &default_shape_def(), &kin_box);
|
|
let kin_target = WorldTransform { p: pos(-5.0, 5.0, 0.0), q: Quat::IDENTITY };
|
|
body_set_target_transform(&mut world, kinematic_id, kin_target, 1.0 / 60.0, true);
|
|
|
|
// Body to exercise Disable/Enable
|
|
let mut disable_def = default_body_def();
|
|
disable_def.body_type = BodyType::Dynamic;
|
|
disable_def.position = pos(9.0, 5.0, 0.0);
|
|
let disable_id = create_body(&mut world, &disable_def);
|
|
let disable_sphere = Sphere { center: vec3(0.0, 0.0, 0.0), radius: 0.3 };
|
|
create_sphere_shape(&mut world, disable_id, &sphere_shape_def, &disable_sphere);
|
|
body_disable(&mut world, disable_id);
|
|
body_enable(&mut world, disable_id);
|
|
|
|
// Force/impulse/torque
|
|
body_apply_force(&mut world, body_id, vec3(0.0, 50.0, 0.0), pos(1.0, 6.0, 0.0), true);
|
|
body_apply_force_to_center(&mut world, body_id, vec3(5.0, 0.0, 0.0), true);
|
|
body_apply_torque(&mut world, body_id, vec3(0.0, 1.0, 0.0), true);
|
|
body_apply_linear_impulse(&mut world, body_id, vec3(0.1, 0.0, 0.0), pos(1.0, 6.0, 0.0), true);
|
|
body_apply_linear_impulse_to_center(&mut world, body_id, vec3(0.0, 0.1, 0.0), true);
|
|
body_apply_angular_impulse(&mut world, body_id, vec3(0.0, 0.05, 0.0), true);
|
|
|
|
// Joint bodies: a row of dynamic bodies connected by each joint type
|
|
let mut jb = Vec::new();
|
|
for i in 0..9 {
|
|
let mut jbd = default_body_def();
|
|
jbd.body_type = BodyType::Dynamic;
|
|
jbd.position = pos(-8.0 + i as f32 * 2.0, 10.0, 0.0);
|
|
let jid = create_body(&mut world, &jbd);
|
|
let js = Sphere { center: vec3(0.0, 0.0, 0.0), radius: 0.25 };
|
|
let mut jsd = default_shape_def();
|
|
jsd.density = 1.0;
|
|
create_sphere_shape(&mut world, jid, &jsd, &js);
|
|
jb.push(jid);
|
|
}
|
|
|
|
// Revolute joint with full setter coverage and the generic mutators
|
|
let mut rev_def = default_revolute_joint_def();
|
|
rev_def.base.body_id_a = jb[0];
|
|
rev_def.base.body_id_b = jb[1];
|
|
rev_def.base.local_frame_a.p = vec3(1.0, 0.0, 0.0);
|
|
rev_def.base.local_frame_b.p = vec3(-1.0, 0.0, 0.0);
|
|
let rev_id = create_revolute_joint(&mut world, &rev_def);
|
|
ensure!(rev_id.index1 != 0);
|
|
revolute_joint_enable_limit(&mut world, rev_id, true);
|
|
revolute_joint_set_limits(&mut world, rev_id, -1.0, 1.0);
|
|
revolute_joint_enable_motor(&mut world, rev_id, true);
|
|
revolute_joint_set_motor_speed(&mut world, rev_id, 0.5);
|
|
revolute_joint_set_max_motor_torque(&mut world, rev_id, 10.0);
|
|
revolute_joint_enable_spring(&mut world, rev_id, true);
|
|
revolute_joint_set_spring_hertz(&mut world, rev_id, 2.0);
|
|
revolute_joint_set_spring_damping_ratio(&mut world, rev_id, 0.5);
|
|
revolute_joint_set_target_angle(&mut world, rev_id, 0.25);
|
|
joint_set_local_frame_a(&mut world, rev_id, Transform { p: vec3(1.0, 0.0, 0.0), q: Quat::IDENTITY });
|
|
joint_set_local_frame_b(&mut world, rev_id, Transform { p: vec3(-1.0, 0.0, 0.0), q: Quat::IDENTITY });
|
|
joint_set_constraint_tuning(&mut world, rev_id, 60.0, 2.0);
|
|
joint_set_force_threshold(&mut world, rev_id, 100.0);
|
|
joint_set_torque_threshold(&mut world, rev_id, 50.0);
|
|
joint_set_collide_connected(&mut world, rev_id, false);
|
|
joint_wake_bodies(&mut world, rev_id);
|
|
|
|
// Distance joint
|
|
let mut dist_def = default_distance_joint_def();
|
|
dist_def.base.body_id_a = jb[1];
|
|
dist_def.base.body_id_b = jb[2];
|
|
dist_def.length = 2.0;
|
|
let dist_id = create_distance_joint(&mut world, &dist_def);
|
|
distance_joint_set_length(&mut world, dist_id, 2.2);
|
|
distance_joint_enable_spring(&mut world, dist_id, true);
|
|
distance_joint_set_spring_hertz(&mut world, dist_id, 3.0);
|
|
distance_joint_set_spring_damping_ratio(&mut world, dist_id, 0.4);
|
|
distance_joint_set_spring_force_range(&mut world, dist_id, -50.0, 50.0);
|
|
distance_joint_enable_limit(&mut world, dist_id, true);
|
|
distance_joint_set_length_range(&mut world, dist_id, 1.0, 4.0);
|
|
distance_joint_enable_motor(&mut world, dist_id, true);
|
|
distance_joint_set_motor_speed(&mut world, dist_id, 0.3);
|
|
distance_joint_set_max_motor_force(&mut world, dist_id, 5.0);
|
|
|
|
// Filter joint (plus a throwaway to exercise DestroyJoint)
|
|
let mut filter_def = default_filter_joint_def();
|
|
filter_def.base.body_id_a = jb[2];
|
|
filter_def.base.body_id_b = jb[3];
|
|
let filter_id = create_filter_joint(&mut world, &filter_def);
|
|
ensure!(filter_id.index1 != 0);
|
|
|
|
let mut tmp_joint_def = default_distance_joint_def();
|
|
tmp_joint_def.base.body_id_a = jb[0];
|
|
tmp_joint_def.base.body_id_b = jb[8];
|
|
tmp_joint_def.length = 5.0;
|
|
let tmp_joint_id = create_distance_joint(&mut world, &tmp_joint_def);
|
|
destroy_joint(&mut world, tmp_joint_id, true);
|
|
|
|
// Motor joint
|
|
let mut motor_def = default_motor_joint_def();
|
|
motor_def.base.body_id_a = jb[3];
|
|
motor_def.base.body_id_b = jb[4];
|
|
let motor_id = create_motor_joint(&mut world, &motor_def);
|
|
motor_joint_set_linear_velocity(&mut world, motor_id, vec3(0.1, 0.0, 0.0));
|
|
motor_joint_set_angular_velocity(&mut world, motor_id, vec3(0.0, 0.2, 0.0));
|
|
motor_joint_set_max_velocity_force(&mut world, motor_id, 10.0);
|
|
motor_joint_set_max_velocity_torque(&mut world, motor_id, 10.0);
|
|
motor_joint_set_linear_hertz(&mut world, motor_id, 2.0);
|
|
motor_joint_set_linear_damping_ratio(&mut world, motor_id, 0.5);
|
|
motor_joint_set_angular_hertz(&mut world, motor_id, 2.0);
|
|
motor_joint_set_angular_damping_ratio(&mut world, motor_id, 0.5);
|
|
motor_joint_set_max_spring_force(&mut world, motor_id, 20.0);
|
|
motor_joint_set_max_spring_torque(&mut world, motor_id, 20.0);
|
|
|
|
// Prismatic joint
|
|
let mut pris_def = default_prismatic_joint_def();
|
|
pris_def.base.body_id_a = jb[4];
|
|
pris_def.base.body_id_b = jb[5];
|
|
let pris_id = create_prismatic_joint(&mut world, &pris_def);
|
|
prismatic_joint_enable_spring(&mut world, pris_id, true);
|
|
prismatic_joint_set_spring_hertz(&mut world, pris_id, 2.0);
|
|
prismatic_joint_set_spring_damping_ratio(&mut world, pris_id, 0.5);
|
|
prismatic_joint_set_target_translation(&mut world, pris_id, 0.1);
|
|
prismatic_joint_enable_limit(&mut world, pris_id, true);
|
|
prismatic_joint_set_limits(&mut world, pris_id, -1.0, 1.0);
|
|
prismatic_joint_enable_motor(&mut world, pris_id, true);
|
|
prismatic_joint_set_motor_speed(&mut world, pris_id, 0.2);
|
|
prismatic_joint_set_max_motor_force(&mut world, pris_id, 8.0);
|
|
|
|
// Spherical joint (3D-only)
|
|
let mut sph_def = default_spherical_joint_def();
|
|
sph_def.base.body_id_a = jb[5];
|
|
sph_def.base.body_id_b = jb[6];
|
|
let sph_id = create_spherical_joint(&mut world, &sph_def);
|
|
spherical_joint_enable_cone_limit(&mut world, sph_id, true);
|
|
spherical_joint_set_cone_limit(&mut world, sph_id, 0.5);
|
|
spherical_joint_enable_twist_limit(&mut world, sph_id, true);
|
|
spherical_joint_set_twist_limits(&mut world, sph_id, -0.3, 0.3);
|
|
spherical_joint_enable_spring(&mut world, sph_id, true);
|
|
spherical_joint_set_spring_hertz(&mut world, sph_id, 3.0);
|
|
spherical_joint_set_spring_damping_ratio(&mut world, sph_id, 0.5);
|
|
spherical_joint_set_target_rotation(&mut world, sph_id, Quat::IDENTITY);
|
|
spherical_joint_enable_motor(&mut world, sph_id, true);
|
|
spherical_joint_set_motor_velocity(&mut world, sph_id, vec3(0.0, 0.1, 0.0));
|
|
spherical_joint_set_max_motor_torque(&mut world, sph_id, 5.0);
|
|
|
|
// Weld joint
|
|
let mut weld_def = default_weld_joint_def();
|
|
weld_def.base.body_id_a = jb[6];
|
|
weld_def.base.body_id_b = jb[7];
|
|
let weld_id = create_weld_joint(&mut world, &weld_def);
|
|
weld_joint_set_linear_hertz(&mut world, weld_id, 5.0);
|
|
weld_joint_set_linear_damping_ratio(&mut world, weld_id, 0.6);
|
|
weld_joint_set_angular_hertz(&mut world, weld_id, 5.0);
|
|
weld_joint_set_angular_damping_ratio(&mut world, weld_id, 0.6);
|
|
|
|
// Wheel joint
|
|
let mut wheel_def = default_wheel_joint_def();
|
|
wheel_def.base.body_id_a = jb[7];
|
|
wheel_def.base.body_id_b = jb[8];
|
|
let wheel_id = create_wheel_joint(&mut world, &wheel_def);
|
|
wheel_joint_enable_suspension(&mut world, wheel_id, true);
|
|
wheel_joint_set_suspension_hertz(&mut world, wheel_id, 4.0);
|
|
wheel_joint_set_suspension_damping_ratio(&mut world, wheel_id, 0.7);
|
|
wheel_joint_enable_suspension_limit(&mut world, wheel_id, true);
|
|
wheel_joint_set_suspension_limits(&mut world, wheel_id, -0.5, 0.5);
|
|
wheel_joint_enable_spin_motor(&mut world, wheel_id, true);
|
|
wheel_joint_set_spin_motor_speed(&mut world, wheel_id, 1.0);
|
|
wheel_joint_set_max_spin_torque(&mut world, wheel_id, 6.0);
|
|
wheel_joint_enable_steering(&mut world, wheel_id, true);
|
|
wheel_joint_set_steering_hertz(&mut world, wheel_id, 2.0);
|
|
wheel_joint_set_steering_damping_ratio(&mut world, wheel_id, 0.5);
|
|
wheel_joint_set_max_steering_torque(&mut world, wheel_id, 3.0);
|
|
wheel_joint_enable_steering_limit(&mut world, wheel_id, true);
|
|
wheel_joint_set_steering_limits(&mut world, wheel_id, -0.5, 0.5);
|
|
wheel_joint_set_target_steering_angle(&mut world, wheel_id, 0.1);
|
|
|
|
// Parallel joint (3D-only)
|
|
let mut parallel_def = default_parallel_joint_def();
|
|
parallel_def.base.body_id_a = ground_id;
|
|
parallel_def.base.body_id_b = body_id;
|
|
let parallel_id = create_parallel_joint(&mut world, ¶llel_def);
|
|
parallel_joint_set_spring_hertz(&mut world, parallel_id, 2.0);
|
|
parallel_joint_set_spring_damping_ratio(&mut world, parallel_id, 0.5);
|
|
parallel_joint_set_max_torque(&mut world, parallel_id, 20.0);
|
|
|
|
// World config mutators
|
|
world_set_gravity(&mut world, vec3(0.0, -9.8, 0.0));
|
|
world_enable_sleeping(&mut world, true);
|
|
world_enable_continuous(&mut world, false);
|
|
world_enable_warm_starting(&mut world, true);
|
|
world_enable_speculative(&mut world, true);
|
|
world_set_restitution_threshold(&mut world, 1.5);
|
|
world_set_hit_event_threshold(&mut world, 2.0);
|
|
world_set_contact_tuning(&mut world, 30.0, 10.0, 3.0);
|
|
world_set_contact_recycle_distance(&mut world, 0.05);
|
|
world_set_maximum_linear_speed(&mut world, 100.0);
|
|
world_rebuild_static_tree(&mut world);
|
|
|
|
let mut explosion = default_explosion_def();
|
|
explosion.position = pos(0.0, 5.0, 0.0);
|
|
explosion.radius = 3.0;
|
|
explosion.falloff = 1.0;
|
|
explosion.impulse_per_area = 2.0;
|
|
world_explode(&mut world, &explosion);
|
|
|
|
// Pre-step queries (all seven kinds)
|
|
let qfilter = default_query_filter();
|
|
let qaabb = AABB { lower_bound: vec3(-10.0, -5.0, -10.0), upper_bound: vec3(10.0, 15.0, 10.0) };
|
|
world_overlap_aabb(&mut world, qaabb, qfilter, &mut overlap_fcn());
|
|
let qorigin = pos(0.0, 15.0, 0.0);
|
|
let proxy_pts = [vec3(0.0, 0.0, 0.0)];
|
|
let proxy = ShapeProxy { points: &proxy_pts, radius: 0.5 };
|
|
world_overlap_shape(&world, qorigin, &proxy, qfilter, &mut overlap_fcn());
|
|
let q_translation = vec3(0.0, -20.0, 0.0);
|
|
world_cast_ray(&world, qorigin, q_translation, qfilter, &mut cast_fcn());
|
|
world_cast_ray_closest(&world, qorigin, q_translation, qfilter);
|
|
world_cast_shape(&world, qorigin, &proxy, q_translation, qfilter, &mut cast_fcn());
|
|
let mover = Capsule { center1: vec3(0.0, 0.0, 0.0), center2: vec3(0.0, 1.0, 0.0), radius: 0.3 };
|
|
world_cast_mover(&world, qorigin, &mover, q_translation, qfilter, Some(&mut overlap_fcn()));
|
|
world_collide_mover(&world, qorigin, &mover, qfilter, &mut plane_fcn());
|
|
|
|
let time_step = 1.0 / 60.0;
|
|
let sub_step_count = 4;
|
|
for i in 0..12 {
|
|
// Inject mutators mid-simulation
|
|
if i == 6 {
|
|
body_apply_linear_impulse_to_center(&mut world, capsule_body_id, vec3(2.0, 0.0, 0.0), true);
|
|
body_set_gravity_scale(&mut world, body_id, 1.0);
|
|
}
|
|
|
|
// Issue queries mid-loop to exercise recording across steps
|
|
if i == 3 {
|
|
world_overlap_aabb(&mut world, qaabb, qfilter, &mut overlap_fcn());
|
|
world_cast_ray(&world, qorigin, q_translation, qfilter, &mut cast_fcn());
|
|
}
|
|
|
|
world_step(&mut world, time_step, sub_step_count);
|
|
}
|
|
|
|
let rec = world_stop_recording(&mut world).expect("recording active");
|
|
destroy_world(world);
|
|
|
|
let rec_data = rec.data();
|
|
ensure!(!rec_data.is_empty());
|
|
|
|
// Replay headless at worker count 1 and 4 — cross-thread determinism.
|
|
ensure!(validate_replay(rec_data, 1));
|
|
ensure!(validate_replay(rec_data, 4));
|
|
|
|
// File round trip
|
|
let path = std::env::temp_dir().join("box3d_recording_allops_test.b3rc");
|
|
let path = path.to_str().unwrap();
|
|
ensure!(save_recording_to_file(&rec, path));
|
|
let loaded = load_recording_from_file(path).expect("load");
|
|
ensure!(validate_replay(loaded.data(), 1));
|
|
|
|
// Drive the incremental player: per-frame stepping, restart, getters.
|
|
{
|
|
let mut player = Player::create(rec_data, 1).expect("player");
|
|
|
|
let info = player.get_info();
|
|
let rec_extents =
|
|
makepad_box3d::math_functions::sub(info.bounds.upper_bound, info.bounds.lower_bound);
|
|
ensure!(rec_extents.x > 0.0 && rec_extents.y > 0.0);
|
|
|
|
let mut frames = 0;
|
|
while player.step_frame() {
|
|
frames += 1;
|
|
}
|
|
ensure!(frames == 12);
|
|
ensure!(player.get_frame() == 12);
|
|
ensure!(player.is_at_end());
|
|
ensure!(!player.has_diverged());
|
|
|
|
// The trailing DestroyWorld is an end marker; the world stays usable.
|
|
ensure!(!player.world().bodies.is_empty());
|
|
|
|
// Restart reproduces the same run without reloading the file.
|
|
player.restart();
|
|
ensure!(player.get_frame() == 0);
|
|
ensure!(!player.is_at_end());
|
|
|
|
let mut frames2 = 0;
|
|
while player.step_frame() {
|
|
frames2 += 1;
|
|
}
|
|
ensure!(frames2 == 12);
|
|
ensure!(!player.has_diverged());
|
|
|
|
player.destroy();
|
|
}
|
|
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
|
|
// C: ReservedHeaderBytes — reserved header fields must not affect replay.
|
|
#[test]
|
|
fn reserved_header_bytes() {
|
|
let mut world = create_world(&default_world_def());
|
|
world_start_recording(&mut world);
|
|
world_set_gravity(&mut world, vec3(0.0, -10.0, 0.0));
|
|
|
|
let mut bd = default_body_def();
|
|
bd.body_type = BodyType::Dynamic;
|
|
bd.position = pos(0.0, 5.0, 0.0);
|
|
let body_id = create_body(&mut world, &bd);
|
|
let s = Sphere { center: vec3(0.0, 0.0, 0.0), radius: 0.5 };
|
|
let mut sd = default_shape_def();
|
|
sd.density = 1.0;
|
|
create_sphere_shape(&mut world, body_id, &sd, &s);
|
|
|
|
for _ in 0..10 {
|
|
world_step(&mut world, 1.0 / 60.0, 4);
|
|
}
|
|
|
|
let rec = world_stop_recording(&mut world).expect("recording active");
|
|
destroy_world(world);
|
|
|
|
// Patch reserved fields at their byte offsets in the header:
|
|
// byte 11: reserved, bytes 16-19: reserved2, bytes 20-23: reserved3.
|
|
let mut patched = rec.data().to_vec();
|
|
ensure!(patched.len() >= REC_HEADER_SIZE);
|
|
patched[11] = 0xAB;
|
|
patched[16] = 0xCD;
|
|
patched[17] = 0xEF;
|
|
patched[18] = 0x12;
|
|
patched[19] = 0x34;
|
|
patched[20] = 0x56;
|
|
patched[21] = 0x78;
|
|
patched[22] = 0x9A;
|
|
patched[23] = 0xBC;
|
|
ensure!(validate_replay(&patched, 1));
|
|
}
|
|
|
|
// C: GeometryHashCollision — colliding content hashes must dedup exactly, and
|
|
// an already-seeded registry (byte-identical duplicate slots included) must
|
|
// resolve a live blob without growing.
|
|
#[test]
|
|
fn geometry_hash_collision() {
|
|
// The content hash must use its full width: a one-byte change must perturb
|
|
// the high word too.
|
|
{
|
|
let p = [0x11u8; 16];
|
|
let mut q = [0x11u8; 16];
|
|
q[7] = 0x12;
|
|
let hp = hash64_blob(&p);
|
|
let hq = hash64_blob(&q);
|
|
ensure!(hp != hq);
|
|
ensure!((hp >> 32) as u32 != (hq >> 32) as u32);
|
|
}
|
|
|
|
let n = 64usize;
|
|
let shared_hash = 0x1234_5678_9ABC_DEF0u64;
|
|
|
|
let mut reg = GeometryRegistry::default();
|
|
let blob_a = vec![0xAAu8; n];
|
|
let blob_b = vec![0xBBu8; n];
|
|
|
|
// Distinct blobs colliding on the hash become two entries.
|
|
let id_a = intern_geometry(&mut reg, GeometryKind::Hull, shared_hash, blob_a);
|
|
let id_b = intern_geometry(&mut reg, GeometryKind::Hull, shared_hash, blob_b);
|
|
ensure!(id_a != id_b);
|
|
ensure!(reg.entries.len() == 2);
|
|
|
|
// Re-interning either blob must find it through the hash chain and never
|
|
// grow the registry, including the one shadowed behind the bucket head.
|
|
ensure!(intern_geometry(&mut reg, GeometryKind::Hull, shared_hash, vec![0xAAu8; n]) == id_a);
|
|
ensure!(reg.entries.len() == 2);
|
|
ensure!(intern_geometry(&mut reg, GeometryKind::Hull, shared_hash, vec![0xBBu8; n]) == id_b);
|
|
ensure!(reg.entries.len() == 2);
|
|
|
|
// Seed-then-capture: appending byte-identical duplicate slots keeps
|
|
// id == slot index, and a later exact intern resolves without appending.
|
|
let mut seeded = GeometryRegistry::default();
|
|
ensure!(append_geometry(&mut seeded, GeometryKind::Hull, shared_hash, vec![0xAAu8; n]) == 0);
|
|
ensure!(append_geometry(&mut seeded, GeometryKind::Hull, shared_hash, vec![0xBBu8; n]) == 1);
|
|
ensure!(append_geometry(&mut seeded, GeometryKind::Hull, shared_hash, vec![0xAAu8; n]) == 2);
|
|
|
|
let resolved = intern_geometry(&mut seeded, GeometryKind::Hull, shared_hash, vec![0xAAu8; n]);
|
|
ensure!(seeded.entries.len() == 3); // no growth
|
|
ensure!(resolved == 0 || resolved == 2);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Port-specific additions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// The capture test's scene (tests/test_recording_capture.rs::build_and_run)
|
|
// must round-trip: record -> replay with state-hash verification at every step
|
|
// marker (validate_replay checks each recorded StateHash op).
|
|
#[test]
|
|
fn capture_stream_round_trip() {
|
|
let mut world = create_world(&default_world_def());
|
|
world_start_recording(&mut world);
|
|
|
|
let ground_hull = make_box_hull(10.0, 0.5, 10.0);
|
|
let ground_id = create_body(&mut world, &default_body_def());
|
|
let shape_def = default_shape_def();
|
|
create_hull_shape(&mut world, ground_id, &shape_def, &ground_hull);
|
|
|
|
let mut body_def = default_body_def();
|
|
body_def.body_type = BodyType::Dynamic;
|
|
body_def.position = pos(0.0, 4.0, 0.0);
|
|
let body_a = create_body(&mut world, &body_def);
|
|
let sphere = Sphere { center: vec3(0.0, 0.0, 0.0), radius: 0.5 };
|
|
create_sphere_shape(&mut world, body_a, &shape_def, &sphere);
|
|
|
|
body_def.position = pos(1.2, 4.0, 0.0);
|
|
let body_b = create_body(&mut world, &body_def);
|
|
create_sphere_shape(&mut world, body_b, &shape_def, &sphere);
|
|
|
|
let mut joint_def = default_revolute_joint_def();
|
|
joint_def.base.body_id_a = body_a;
|
|
joint_def.base.body_id_b = body_b;
|
|
create_revolute_joint(&mut world, &joint_def);
|
|
|
|
body_set_linear_velocity(&mut world, body_a, vec3(0.0, -1.0, 0.0));
|
|
body_set_name(&mut world, body_b, "pendulum");
|
|
|
|
for _ in 0..10 {
|
|
world_step(&mut world, 1.0 / 60.0, 4);
|
|
}
|
|
|
|
let mut filter = default_query_filter();
|
|
filter.id = 42;
|
|
filter.name = "probe";
|
|
world_cast_ray_closest(&world, pos(0.0, 5.0, 0.0), vec3(0.0, -10.0, 0.0), filter);
|
|
|
|
let aabb = AABB { lower_bound: vec3(-2.0, -2.0, -2.0), upper_bound: vec3(2.0, 6.0, 2.0) };
|
|
world_overlap_aabb(&mut world, aabb, default_query_filter(), &mut overlap_fcn());
|
|
|
|
destroy_body(&mut world, body_b);
|
|
for _ in 0..2 {
|
|
world_step(&mut world, 1.0 / 60.0, 4);
|
|
}
|
|
|
|
let final_hash = hash_world_state(&world);
|
|
let rec = world_stop_recording(&mut world).expect("recording active");
|
|
destroy_world(world);
|
|
|
|
ensure!(validate_replay(rec.data(), 1));
|
|
ensure!(validate_replay(rec.data(), 4));
|
|
|
|
// The player's end state must match the recording world's final hash.
|
|
let mut player = Player::create(rec.data(), 1).expect("player");
|
|
while player.step_frame() {}
|
|
ensure!(!player.has_diverged());
|
|
ensure!(hash_world_state(player.world()) == final_hash);
|
|
player.destroy();
|
|
}
|
|
|
|
// Record a multithreaded session (worker_count = 4) and replay it at 1 and 4
|
|
// workers. The sim is worker-count invariant, so every recorded StateHash must
|
|
// reproduce at both counts.
|
|
#[test]
|
|
fn multithread_record_round_trip() {
|
|
let mut world_def = default_world_def();
|
|
world_def.worker_count = 4;
|
|
let mut world = create_world(&world_def);
|
|
world_set_gravity(&mut world, vec3(0.0, -10.0, 0.0));
|
|
world_start_recording(&mut world);
|
|
|
|
let ground_id = create_body(&mut world, &default_body_def());
|
|
let ground_box = make_box_hull(20.0, 1.0, 20.0);
|
|
create_hull_shape(&mut world, ground_id, &default_shape_def(), &ground_box);
|
|
|
|
// Enough falling boxes to give the solver real islands to partition.
|
|
let mut box_shape = default_shape_def();
|
|
box_shape.density = 1.0;
|
|
let bx = make_box_hull(0.5, 0.5, 0.5);
|
|
for i in 0..24 {
|
|
let mut body_def = default_body_def();
|
|
body_def.body_type = BodyType::Dynamic;
|
|
body_def.position =
|
|
pos(((i % 4) as f32 - 1.5) * 1.2, 1.0 + (i / 4) as f32 * 1.1, ((i % 3) as f32 - 1.0) * 1.2);
|
|
let body_id = create_body(&mut world, &body_def);
|
|
create_hull_shape(&mut world, body_id, &box_shape, &bx);
|
|
}
|
|
|
|
for _ in 0..40 {
|
|
world_step(&mut world, 1.0 / 60.0, 4);
|
|
}
|
|
|
|
let rec = world_stop_recording(&mut world).expect("recording active");
|
|
destroy_world(world);
|
|
|
|
ensure!(validate_replay(rec.data(), 1));
|
|
ensure!(validate_replay(rec.data(), 4));
|
|
}
|
|
|
|
// Corrupt input must fail cleanly, never panic.
|
|
#[test]
|
|
fn corrupt_input_rejected() {
|
|
ensure!(!validate_replay(&[], 1));
|
|
ensure!(!validate_replay(&[0u8; 16], 1));
|
|
ensure!(!validate_replay(&[0u8; 64], 1));
|
|
|
|
// A valid recording truncated mid-stream stops without panicking.
|
|
let mut world = create_world(&default_world_def());
|
|
world_start_recording(&mut world);
|
|
let mut bd = default_body_def();
|
|
bd.body_type = BodyType::Dynamic;
|
|
bd.position = pos(0.0, 5.0, 0.0);
|
|
let body_id = create_body(&mut world, &bd);
|
|
let s = Sphere { center: vec3(0.0, 0.0, 0.0), radius: 0.5 };
|
|
create_sphere_shape(&mut world, body_id, &default_shape_def(), &s);
|
|
for _ in 0..5 {
|
|
world_step(&mut world, 1.0 / 60.0, 4);
|
|
}
|
|
let rec = world_stop_recording(&mut world).expect("recording active");
|
|
destroy_world(world);
|
|
|
|
// Chop the registry off and half the op stream: replay must not panic.
|
|
// (The truncated stream has no registry locator, so the ops run to the
|
|
// truncation point; a missing hull registry entry fails the read cleanly.)
|
|
let data = rec.data();
|
|
let snapshot_size = read_u64(data, 24) as usize;
|
|
let cut = REC_HEADER_SIZE + snapshot_size + (data.len() - REC_HEADER_SIZE - snapshot_size) / 2;
|
|
let mut truncated = data[..cut].to_vec();
|
|
// Zero the registry locator since the block is gone.
|
|
truncated[32..48].fill(0);
|
|
let _ = validate_replay(&truncated, 1);
|
|
|
|
let _ = Arc::new(0); // keep the Arc import exercised under all features
|
|
}
|