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>
189 lines
7.2 KiB
Rust
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);
|
|
}
|