makepad/libs/box3d/tests/test_recording_capture.rs
Admin dfa0b07753 box3d: recording op stream (capture side)
All ~140 opcodes from recording_ops.inl with exact C values, capture hooks
in every mutator and query (~137 sites across body/shape/joint/world),
48-byte header with registry locator backpatch, snapshot seed, query tag
interning, state-hash anchors per step. Recording is observer-only
(bit-identical world state with and without a recording attached).
Replay/player side lands separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:06:18 +02:00

189 lines
7.2 KiB
Rust

// Capture-side test for the recording op stream (replay lands separately).
// Validates stream structure: header, snapshot seed, framed ops, registry.
use makepad_box3d::body::*;
use makepad_box3d::ensure;
use makepad_box3d::hull::make_box_hull;
use makepad_box3d::joint::create_revolute_joint;
use makepad_box3d::math_functions::{pos, vec3};
use makepad_box3d::physics_world::*;
use makepad_box3d::recording::{
hash_world_state, load_recording_from_file, save_recording_to_file, world_start_recording,
world_stop_recording, RecOp, REC_HEADER_SIZE, REC_MAGIC, REC_VERSION_MAJOR,
};
use makepad_box3d::shape::*;
use makepad_box3d::types::*;
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)
}
/// Walk the framed op stream between the snapshot seed and the registry block.
/// Returns (op, payload_offset, payload_len) triples.
fn walk_ops(data: &[u8]) -> Vec<(u8, usize, usize)> {
let snapshot_size = read_u64(data, 24) as usize;
let registry_offset = read_u64(data, 32) as usize;
let mut ops = Vec::new();
let mut p = REC_HEADER_SIZE + snapshot_size;
while p < registry_offset {
let op = data[p];
let size = data[p + 1] as usize | (data[p + 2] as usize) << 8 | (data[p + 3] as usize) << 16;
ops.push((op, p + 4, size));
p += 4 + size;
}
assert_eq!(p, registry_offset, "op stream must end exactly at the registry");
ops
}
fn build_and_run(world: &mut World, record: bool) -> u64 {
if record {
world_start_recording(world);
}
// Ground box (interns a hull into the registry)
let ground_hull = make_box_hull(10.0, 0.5, 10.0);
let ground_def = default_body_def();
let ground_id = create_body(world, &ground_def);
let shape_def = default_shape_def();
let _hull_shape = create_hull_shape(world, ground_id, &shape_def, &ground_hull);
// Falling bodies: a sphere and a second sphere joined by a revolute joint
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(world, &body_def);
let sphere = Sphere { center: vec3(0.0, 0.0, 0.0), radius: 0.5 };
let _sa = create_sphere_shape(world, body_a, &shape_def, &sphere);
body_def.position = pos(1.2, 4.0, 0.0);
let body_b = create_body(world, &body_def);
let _sb = create_sphere_shape(world, body_b, &shape_def, &sphere);
let mut joint_def = makepad_box3d::joint::default_revolute_joint_def();
joint_def.base.body_id_a = body_a;
joint_def.base.body_id_b = body_b;
let _joint = create_revolute_joint(world, &joint_def);
// A couple of mutators
body_set_linear_velocity(world, body_a, vec3(0.0, -1.0, 0.0));
body_set_name(world, body_b, "pendulum");
// Step some frames
for _ in 0..10 {
world_step(world, 1.0 / 60.0, 4);
}
// Tagged ray cast + untagged overlap
let mut filter = default_query_filter();
filter.id = 42;
filter.name = "probe";
let _hit = world_cast_ray_closest(world, pos(0.0, 5.0, 0.0), vec3(0.0, -10.0, 0.0), filter);
let aabb = makepad_box3d::math_functions::AABB {
lower_bound: vec3(-2.0, -2.0, -2.0),
upper_bound: vec3(2.0, 6.0, 2.0),
};
let mut count = 0;
world_overlap_aabb(world, aabb, default_query_filter(), &mut |_id| {
count += 1;
true
});
ensure!(count > 0);
// Destroy one body, then a couple more steps
destroy_body(world, body_b);
for _ in 0..2 {
world_step(world, 1.0 / 60.0, 4);
}
hash_world_state(world)
}
#[test]
fn recording_capture_stream_structure() {
let world_def = default_world_def();
let mut world = create_world(&world_def);
let _final_hash = build_and_run(&mut world, true);
let rec = world_stop_recording(&mut world).expect("recording active");
let data = rec.data();
// Header
ensure!(data.len() > REC_HEADER_SIZE);
ensure!(read_u32(data, 0) == REC_MAGIC);
ensure!(u16::from_le_bytes([data[4], data[5]]) == REC_VERSION_MAJOR);
let snapshot_size = read_u64(data, 24) as usize;
let registry_offset = read_u64(data, 32) as usize;
let registry_byte_count = read_u64(data, 40) as usize;
ensure!(snapshot_size > 0); // snapshot-seeded (empty world still serializes)
ensure!(registry_offset >= REC_HEADER_SIZE + snapshot_size);
ensure!(registry_offset + registry_byte_count == data.len());
// Ops
let ops = walk_ops(data);
let count_op = |op: RecOp| ops.iter().filter(|(o, _, _)| *o == op as u8).count();
ensure!(count_op(RecOp::Step) == 12);
// StateHash: one anchor at start + one per step
ensure!(count_op(RecOp::StateHash) == 13);
ensure!(count_op(RecOp::CreateBody) == 3);
ensure!(count_op(RecOp::CreateHullShape) == 1);
ensure!(count_op(RecOp::CreateSphereShape) == 2);
ensure!(count_op(RecOp::CreateRevoluteJoint) == 1);
ensure!(count_op(RecOp::BodySetLinearVelocity) == 1);
ensure!(count_op(RecOp::BodySetName) == 1);
ensure!(count_op(RecOp::DestroyBody) == 1);
ensure!(count_op(RecOp::QueryCastRayClosest) == 1);
ensure!(count_op(RecOp::QueryOverlapAABB) == 1);
// Tagged ray cast emits its QueryTag right before the query record
ensure!(count_op(RecOp::QueryTag) == 1);
let tag_pos = ops.iter().position(|(o, _, _)| *o == RecOp::QueryTag as u8).unwrap();
ensure!(ops[tag_pos + 1].0 == RecOp::QueryCastRayClosest as u8);
// Stop wrote bounds + the end-of-stream marker as the last two records
ensure!(ops[ops.len() - 2].0 == RecOp::RecordingBounds as u8);
ensure!(ops[ops.len() - 1].0 == RecOp::DestroyWorld as u8);
// Registry: the ground hull was interned once; the tag table has one entry
let entry_count = read_u32(data, registry_offset) as usize;
ensure!(entry_count == 1);
let mut p = registry_offset + 4;
let kind = data[p];
ensure!(kind == 0); // GeometryKind::Hull
let byte_count = read_u32(data, p + 1) as usize;
ensure!(byte_count > 0);
p += 5 + byte_count;
let tag_count = read_u32(data, p) as usize;
ensure!(tag_count == 1);
ensure!(read_u64(data, p + 4 + 8) == 42); // tag id
// Save/load round trip validates the magic path
let path = std::env::temp_dir().join("box3d_capture_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!(loaded.data() == rec.data());
let _ = std::fs::remove_file(path);
destroy_world(world);
}
#[test]
fn recording_is_zero_change() {
// The same scene stepped with and without a recording must produce
// bit-identical simulation state (hooks are observers only).
let mut world_a = create_world(&default_world_def());
let hash_a = build_and_run(&mut world_a, false);
destroy_world(world_a);
let mut world_b = create_world(&default_world_def());
let hash_b = build_and_run(&mut world_b, true);
let _rec = world_stop_recording(&mut world_b).expect("recording active");
destroy_world(world_b);
ensure!(hash_a == hash_b);
}