makepad/libs/game/sim/tests/rigid_dynamics.rs
Admin f78563e848 sim: absurd entity dimensions can no longer crash the solver
A generated game handed box3d an infinite extent and took the whole process
down — found by the eval harness actually generating games, not by reading
code.

NaN was already absorbed (Rust's `max` returns the non-NaN operand), but
INFINITY survives it and poisons every plane normal to NaN. box3d's face query
then never beats its -f32::MAX starting separation, leaves max_face_index at
the -1 sentinel, and convex_manifold.rs casts that sentinel through u8 into
255 and uses it to index a 6-element array.

The port is FAITHFUL to upstream C here — convex_manifold.c does the same
(uint8_t)maxFaceIndex cast; C reads garbage where Rust panics — so box3d is
not the place to diverge. The boundary is: never hand the solver a value it
cannot reason about. sane_extent() clamps non-finite and out-of-range
dimensions (and density) to a workable range.

Regression test covers +inf, -inf, NaN, 0 and negative extents: none may panic
and all must leave a finite pose after 30 ticks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 09:52:17 +02:00

332 lines
11 KiB
Rust

//! M1a rigid-dynamics gates: determinism (double-run + recorded golden),
//! bit-exact Clone via the box3d snapshot, heightfield rest position (also
//! validates the terrain→box3d transform orientation), reconcile lifecycle,
//! and a perf smoke (run with --release --nocapture for the real number).
use makepad_game_sim::*;
use makepad_math::*;
fn fnv(h: u64, v: u32) -> u64 {
(h ^ v as u64).wrapping_mul(0x100000001b3)
}
fn hash_world(w: &GameWorld) -> u64 {
let mut h = 0xcbf29ce484222325;
for e in &w.entities {
for f in [
e.pos.x, e.pos.y, e.pos.z, e.vel.x, e.vel.y, e.vel.z, e.orient.x, e.orient.y,
e.orient.z, e.orient.w,
] {
h = fnv(h, f.to_bits());
}
}
h
}
fn ent(id: u64, kind: BodyKind, pos: Vec3f, half: Vec3f) -> Entity {
Entity {
id,
kind,
pos,
half,
collide: true,
gravity_scale: 1.0,
density: 1.0,
friction: 0.6,
restitution: 0.0,
scale: vec3f(1.0, 1.0, 1.0),
scale_target: vec3f(1.0, 1.0, 1.0),
speed_mult: 1.0,
..Default::default()
}
}
/// Ground slab + offset crate stack + two spheres, one kinematic platform.
fn rigid_scene() -> GameWorld {
let mut w = GameWorld::new();
w.reset_content();
let mut id = 0;
id += 1;
w.push_entity(ent(id, BodyKind::Static, vec3f(0.0, -0.5, 0.0), vec3f(30.0, 0.5, 30.0)));
for i in 0..6 {
id += 1;
let jitter = (i as f32) * 0.03;
let mut e = ent(
id,
BodyKind::Rigid,
vec3f(jitter, 0.5 + i as f32 * 1.02, jitter * 0.7),
vec3f(0.5, 0.5, 0.5),
);
e.restitution = 0.05;
w.push_entity(e);
}
for i in 0..2 {
id += 1;
let mut e = ent(
id,
BodyKind::Rigid,
vec3f(3.0 + i as f32 * 1.5, 4.0, -2.0),
vec3f(0.6, 0.6, 0.6),
);
e.shape = Shape::Sphere;
e.vel = vec3f(-1.0, 0.0, 0.5);
e.restitution = 0.4;
w.push_entity(e);
}
id += 1;
let mut plat = ent(id, BodyKind::Kinematic, vec3f(-4.0, 1.0, 4.0), vec3f(2.0, 0.25, 2.0));
plat.vel = vec3f(0.5, 0.0, 0.0);
w.push_entity(plat);
w.next_id = id;
w
}
fn run(w: &mut GameWorld, ticks: usize) {
for _ in 0..ticks {
step_world(w);
}
}
#[test]
fn rigid_determinism_double_run_matches_golden() {
let mut a = rigid_scene();
let mut b = rigid_scene();
run(&mut a, 600);
run(&mut b, 600);
let (ha, hb) = (hash_world(&a), hash_world(&b));
assert_eq!(ha, hb, "two identical runs diverged");
// Recorded on aarch64 (2026-08-02). Equality on every other target IS the
// cross-arch determinism claim for the whole sim+box3d stack. If a solver
// or scene change is intentional, re-record via --nocapture println.
const GOLDEN: u64 = 0xa8a2baf71e4a564f;
println!("rigid golden hash: {ha:#018x}");
assert_eq!(ha, GOLDEN, "rigid determinism golden changed");
// Stack must have settled, not exploded or sunk: every crate above the
// slab top, below spawn ceiling.
for e in a.entities.iter().filter(|e| e.kind == BodyKind::Rigid) {
assert!(e.pos.y > -0.1 && e.pos.y < 8.0, "rigid escaped: {:?}", e.pos);
}
}
#[test]
fn clone_is_bit_identical_going_forward() {
let mut w = rigid_scene();
run(&mut w, 300);
let mut c = w.clone();
assert_eq!(hash_world(&w), hash_world(&c), "clone changed state");
run(&mut w, 120);
run(&mut c, 120);
assert_eq!(
hash_world(&w),
hash_world(&c),
"clone diverged from original within 120 ticks"
);
}
#[test]
fn rigid_rests_on_heightfield_terrain() {
let mut w = GameWorld::new();
w.reset_content();
// Asymmetric ramp along +x: catches x/z transposition in the mirror.
let cells = 17usize;
let cell_size = 2.0f32;
let origin = -(cells as f32 - 1.0) * cell_size * 0.5;
let mut heights = vec![0.0f32; cells * cells];
for z in 0..cells {
for x in 0..cells {
heights[z * cells + x] = x as f32 * 0.5;
}
}
w.terrain = Some(Terrain {
cells,
cell_size,
origin,
heights,
colors: Vec::new(),
revision: 1,
});
let expect_low = w.terrain.as_ref().unwrap().height_at(-10.0, 0.0).unwrap();
let expect_high = w.terrain.as_ref().unwrap().height_at(10.0, 0.0).unwrap();
assert!(expect_high - expect_low > 3.0, "ramp fixture is flat?");
w.next_id = 2;
let mut low = ent(1, BodyKind::Rigid, vec3f(-10.0, expect_low + 6.0, 0.0), vec3f(0.4, 0.4, 0.4));
low.friction = 2.0; // park on the slope instead of sliding off
let mut high = ent(2, BodyKind::Rigid, vec3f(10.0, expect_high + 6.0, 0.0), vec3f(0.4, 0.4, 0.4));
high.friction = 2.0;
w.push_entity(low);
w.push_entity(high);
run(&mut w, 480);
let e_low = w.entity(1).unwrap();
let e_high = w.entity(2).unwrap();
// Resting near the local surface (generous: they may settle slightly
// downhill of the drop point on the slope).
let terrain = w.terrain.as_ref().unwrap();
for e in [e_low, e_high] {
let ground = terrain
.height_at(e.pos.x, e.pos.z)
.expect("rigid slid off the terrain");
assert!(
(e.pos.y - ground).abs() < 1.5,
"rigid not resting on terrain: pos {:?} ground {}",
e.pos,
ground
);
}
// And the ramp ordering held: the high-side crate rests above the low one.
assert!(e_high.pos.y > e_low.pos.y + 2.0, "x/z transposed in heightfield mirror?");
}
#[test]
fn reconcile_lifecycle_creates_and_destroys_bodies() {
let mut w = rigid_scene();
run(&mut w, 5);
let full = w.dynamics.body_count();
assert!(full >= 9, "expected mirrored bodies, got {full}");
// remove all rigids (game.remove path = retain)
w.entities.retain(|e| e.kind != BodyKind::Rigid);
run(&mut w, 1);
let after = w.dynamics.body_count();
assert_eq!(after, full - 8, "rigid bodies not destroyed on retain");
// reset drops everything
w.reset_content();
run(&mut w, 1);
assert_eq!(w.dynamics.body_count(), 0);
}
#[test]
fn set_pos_and_set_vel_semantics_reach_box3d() {
let mut w = rigid_scene();
run(&mut w, 200);
// Host-side set_pos semantics: teleport + zero velocity.
{
let e = w.entity_mut(2).unwrap();
e.pos = vec3f(8.0, 6.0, 8.0);
e.orient = Quat::default();
e.vel = vec3f(0.0, 0.0, 0.0);
}
run(&mut w, 1);
let e = w.entity(2).unwrap();
assert!(
(e.pos - vec3f(8.0, 6.0, 8.0)).length() < 1.0,
"teleport ignored by box3d: {:?}",
e.pos
);
// It should now be falling from the new spot.
run(&mut w, 30);
let e = w.entity(2).unwrap();
assert!(e.pos.y < 6.0, "teleported rigid not simulated: {:?}", e.pos);
// Let it land and settle before kicking (an impulse against a -15 m/s
// fall correctly nets downward — learned from the first version).
run(&mut w, 150);
// Impulse helper: a solid upward kick must actually lift it.
let before = w.entity(2).unwrap().pos.y;
assert!(w.dynamics.rigid_impulse(2, vec3f(0.0, 12.0, 0.0)));
run(&mut w, 12);
assert!(
w.entity(2).unwrap().pos.y > before + 0.5,
"impulse had no effect"
);
}
#[test]
fn perf_smoke_100_movers_50_rigids_terrain() {
let mut w = GameWorld::new();
w.reset_content();
let cells = 65usize;
let cell_size = 2.0f32;
let origin = -(cells as f32 - 1.0) * cell_size * 0.5;
let mut heights = vec![0.0f32; cells * cells];
for z in 0..cells {
for x in 0..cells {
heights[z * cells + x] =
((x as f32 * 0.37).sin() + (z as f32 * 0.29).cos()) * 1.5;
}
}
w.terrain = Some(Terrain {
cells,
cell_size,
origin,
heights,
colors: Vec::new(),
revision: 1,
});
let mut id = 0;
for i in 0..20 {
id += 1;
w.push_entity(ent(
id,
BodyKind::Static,
vec3f((i % 5) as f32 * 8.0 - 16.0, 1.0, (i / 5) as f32 * 8.0 - 16.0),
vec3f(1.0, 1.0, 1.0),
));
}
for i in 0..100 {
id += 1;
let mut e = ent(
id,
BodyKind::Mover,
vec3f((i % 10) as f32 * 3.0 - 15.0, 5.0, (i / 10) as f32 * 3.0 - 15.0),
vec3f(0.4, 0.8, 0.4),
);
e.vel = vec3f(if i % 2 == 0 { 1.0 } else { -1.0 }, 0.0, 0.7);
w.push_entity(e);
}
for i in 0..50 {
id += 1;
w.push_entity(ent(
id,
BodyKind::Rigid,
vec3f(
(i % 7) as f32 * 2.0 - 6.0,
3.0 + (i / 7) as f32 * 1.5,
(i % 5) as f32 * 2.0 + 10.0,
),
vec3f(0.5, 0.5, 0.5),
));
}
w.next_id = id;
let t0 = std::time::Instant::now();
run(&mut w, 600);
let total = t0.elapsed().as_secs_f64() * 1000.0;
println!(
"perf: 600 ticks (100 movers + 50 rigids + 65x65 terrain) = {:.2} ms total, {:.3} ms/tick",
total,
total / 600.0
);
// Debug builds are ~10x slower; the real target (<2ms/tick) is asserted
// loosely here and read off a --release run for the report.
let budget = if cfg!(debug_assertions) { 20.0 } else { 2.0 };
assert!(
total / 600.0 < budget,
"tick budget blown: {:.3} ms/tick (budget {budget})",
total / 600.0
);
}
/// A generated game handed the solver an infinite extent and took the whole
/// process down. NaN is absorbed by `max`, but INFINITY survives it and
/// poisons every plane normal to NaN — box3d's face query then never beats its
/// -f32::MAX starting separation, leaves max_face_index at the -1 sentinel,
/// and that sentinel is cast through u8 into 255 and used as an array index.
/// The port is faithful to upstream C there (C reads garbage where Rust
/// panics), so the boundary is the right place to refuse the value.
#[test]
fn absurd_entity_dimensions_cannot_crash_the_solver() {
for bad in [f32::INFINITY, f32::NEG_INFINITY, f32::NAN, 0.0, -5.0] {
let mut w = GameWorld::new();
w.reset_content();
w.push_entity(ent(1, BodyKind::Static, vec3f(0.0, -0.5, 0.0), vec3f(20.0, 0.5, 20.0)));
let mut e = ent(2, BodyKind::Rigid, vec3f(0.0, 3.0, 0.0), vec3f(bad, bad, bad));
e.density = bad;
w.push_entity(e);
// Must not panic, and must still be stepping a coherent world.
for _ in 0..30 {
step_world(&mut w);
}
let p = w.entity(2).unwrap().pos;
assert!(
p.x.is_finite() && p.y.is_finite() && p.z.is_finite(),
"half {bad} left a non-finite pose {p:?}"
);
}
}