makepad/libs/box3d/tests/test_recording.rs
Admin 995aa23bc1 box3d: recording replay player + test_recording port
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>
2026-07-04 16:06:18 +02:00

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, &parallel_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
}