Drop the archived old/ tree, root AI notes, splashgame.md (Arcade lives in the private sandbox), widgets-dll leftovers, and LAN/oracle helper scripts. Move download_*.sh into tools/, and move sim/math out of libs/game/ so the public repo no longer has a game/ directory. Also quiet first-party compile noise and skip asset-ui tests that need sandbox kits or uncommitted fixtures.
494 lines
17 KiB
Rust
494 lines
17 KiB
Rust
//! Gates for the mix sim-core work (mix.md F6-F10):
|
|
//!
|
|
//! - F6 movers mirror into box3d as kinematic capsules → a ray SEES a
|
|
//! moving player, at its current position, not its spawn.
|
|
//! - F7 `world_raycast` runs on box3d (exact normals/distances, terrain,
|
|
//! decor, sensors skipped) — the 0.15 march is gone.
|
|
//! - F8 capsule↔rigid contact events → mover impulse + `knocked_down`,
|
|
//! mass-ratio scaled, deterministic to a golden hash; movers push light
|
|
//! rigids.
|
|
//! - F9 fast projectiles cannot tunnel (shapecast CCD).
|
|
//! - F10 per-rigid acceleration is readable; REAL terrain material indices
|
|
//! and per-entity materials come back from queries.
|
|
|
|
use makepad_game_sim::*;
|
|
use makepad_math::*;
|
|
|
|
fn fnv(h: u64, v: u32) -> u64 {
|
|
(h ^ v as u64).wrapping_mul(0x100000001b3)
|
|
}
|
|
|
|
/// Everything the contact pipeline can influence, knockdown included.
|
|
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 = fnv(h, e.id as u32);
|
|
h = fnv(h, e.knocked_down as u32);
|
|
h = fnv(h, e.hit_wall as u32);
|
|
}
|
|
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,
|
|
push_mass: 1.0,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn ground(w: &mut GameWorld) {
|
|
w.push_entity(ent(
|
|
1,
|
|
BodyKind::Static,
|
|
vec3f(0.0, -0.5, 0.0),
|
|
vec3f(60.0, 0.5, 60.0),
|
|
));
|
|
}
|
|
|
|
// ------------------------------------------------------------------- F6/F7
|
|
|
|
/// The F6 gate: an exact box3d ray finds a WALKING mover where it stands
|
|
/// now. Casts at head height from the side; also proves the capsule moved
|
|
/// (a cast at the spawn position finds nothing).
|
|
#[test]
|
|
fn box3d_ray_sees_a_moving_mover() {
|
|
let mut w = GameWorld::new();
|
|
w.reset_content();
|
|
ground(&mut w);
|
|
let mut m = ent(2, BodyKind::Mover, vec3f(0.0, 0.8, 0.0), vec3f(0.35, 0.8, 0.35));
|
|
m.vel = vec3f(3.0, 0.0, 0.0);
|
|
w.push_entity(m);
|
|
|
|
for _ in 0..60 {
|
|
w.entity_mut(2).unwrap().vel.x = 3.0;
|
|
step_world(&mut w);
|
|
}
|
|
let pos = w.entity(2).unwrap().pos;
|
|
assert!(pos.x > 2.5, "walker should have walked, at {pos:?}");
|
|
|
|
// Side-on ray at the walker's current x: must hit the capsule.
|
|
let hit = world_raycast(&mut w, vec3f(pos.x, pos.y, -5.0), vec3f(0.0, 0.0, 1.0), 20.0)
|
|
.expect("ray should hit the walking mover");
|
|
let (id, point, normal, dist, _mat) = hit;
|
|
assert_eq!(id, 2, "ray hit {id}, wanted the mover");
|
|
// Exact contact: the capsule surface is radius (0.35) in front of its
|
|
// center, and the normal faces the ray.
|
|
assert!(
|
|
(dist - (5.0 + pos.z - 0.35)).abs() < 1.0e-3,
|
|
"dist {dist} should be exact to the capsule surface"
|
|
);
|
|
assert!(normal.z < -0.99, "true surface normal, got {normal:?}");
|
|
assert!((point.z - (pos.z - 0.35)).abs() < 1.0e-3);
|
|
|
|
// The spawn position is empty now — the mirror FOLLOWED the mover.
|
|
assert!(
|
|
world_raycast(&mut w, vec3f(0.0, pos.y, -5.0), vec3f(0.0, 0.0, 1.0), 20.0)
|
|
.map_or(true, |(id, ..)| id != 2),
|
|
"stale capsule left at the spawn position"
|
|
);
|
|
}
|
|
|
|
/// F7 contract preservation: `collide:false` decor is visually solid and
|
|
/// still hittable (now as a query-only mirror shape); sensors stay
|
|
/// invisible; terrain reports TERRAIN_ID with a true surface normal.
|
|
#[test]
|
|
fn raycast_hits_decor_skips_sensors_reports_terrain() {
|
|
let mut w = GameWorld::new();
|
|
w.reset_content();
|
|
// Flat terrain instead of a ground slab.
|
|
let cells = 17usize;
|
|
let cell_size = 2.0;
|
|
let origin = -(cells as f32 - 1.0) * cell_size * 0.5;
|
|
w.terrain = Some(Terrain {
|
|
cells,
|
|
cell_size,
|
|
origin,
|
|
heights: vec![0.0; cells * cells],
|
|
colors: Vec::new(),
|
|
revision: 1,
|
|
});
|
|
// A sensor directly in the ray's path — must be ignored.
|
|
let mut sensor = ent(1, BodyKind::Static, vec3f(0.0, 1.0, 4.0), vec3f(1.0, 1.0, 0.5));
|
|
sensor.sensor = true;
|
|
w.push_entity(sensor);
|
|
// Decor (collide:false) behind it — must be hit.
|
|
let mut decor = ent(2, BodyKind::Static, vec3f(0.0, 1.0, 8.0), vec3f(1.0, 1.0, 0.5));
|
|
decor.collide = false;
|
|
w.push_entity(decor);
|
|
|
|
let (id, _p, normal, dist, _m) =
|
|
world_raycast(&mut w, vec3f(0.0, 1.0, 0.0), vec3f(0.0, 0.0, 1.0), 30.0)
|
|
.expect("decor should be hit");
|
|
assert_eq!(id, 2, "ray must skip the sensor and hit the decor");
|
|
assert!((dist - 7.5).abs() < 1.0e-3, "exact decor face at 7.5, got {dist}");
|
|
assert!(normal.z < -0.99);
|
|
|
|
// Straight down: terrain, sentinel id, up normal.
|
|
let (id, point, normal, _d, _m) =
|
|
world_raycast(&mut w, vec3f(3.0, 5.0, 3.0), vec3f(0.0, -1.0, 0.0), 30.0)
|
|
.expect("terrain should be hit");
|
|
assert_eq!(id, TERRAIN_ID);
|
|
assert!(point.y.abs() < 1.0e-3);
|
|
assert!(normal.y > 0.99);
|
|
}
|
|
|
|
/// A ray that starts inside the caster's own body no longer reports the
|
|
/// caster (the march did; FPS eye rays had to skip their own id). It reports
|
|
/// what is BEYOND instead.
|
|
#[test]
|
|
fn raycast_from_inside_own_body_sees_past_it() {
|
|
let mut w = GameWorld::new();
|
|
w.reset_content();
|
|
ground(&mut w);
|
|
w.push_entity(ent(2, BodyKind::Mover, vec3f(0.0, 0.8, 0.0), vec3f(0.35, 0.8, 0.35)));
|
|
w.push_entity(ent(3, BodyKind::Static, vec3f(0.0, 1.0, 6.0), vec3f(1.0, 1.0, 0.5)));
|
|
for _ in 0..5 {
|
|
step_world(&mut w);
|
|
}
|
|
let eye = w.entity(2).unwrap().pos + vec3f(0.0, 0.5, 0.0);
|
|
let (id, ..) = world_raycast(&mut w, eye, vec3f(0.0, 0.0, 1.0), 30.0)
|
|
.expect("should hit the wall beyond");
|
|
assert_eq!(id, 3, "eye ray must not hit the caster's own capsule");
|
|
}
|
|
|
|
// --------------------------------------------------------------------- F8
|
|
|
|
/// The F8 gate: a rigid "car" at speed hits a standing walker. The walker
|
|
/// takes a mass-ratio-scaled impulse with vertical pop and a knocked_down
|
|
/// timer, deterministically — double-run identical AND pinned to a golden.
|
|
#[test]
|
|
fn car_hits_walker_sends_them_flying_golden() {
|
|
fn scenario() -> (GameWorld, u64) {
|
|
let mut w = GameWorld::new();
|
|
w.reset_content();
|
|
ground(&mut w);
|
|
// A heavy sliding box plays the car (density 2, ~7.4 mass units).
|
|
let mut car = ent(2, BodyKind::Rigid, vec3f(-12.0, 0.45, 0.0), vec3f(0.9, 0.4, 1.6));
|
|
car.density = 2.0;
|
|
car.friction = 0.1;
|
|
car.vel = vec3f(18.0, 0.0, 0.0);
|
|
w.push_entity(car);
|
|
let walker = ent(3, BodyKind::Mover, vec3f(0.0, 0.8, 0.0), vec3f(0.35, 0.8, 0.35));
|
|
w.push_entity(walker);
|
|
|
|
let mut max_knock = 0u16;
|
|
for _ in 0..180 {
|
|
step_world(&mut w);
|
|
max_knock = max_knock.max(w.entity(3).map_or(0, |e| e.knocked_down));
|
|
}
|
|
(w, max_knock as u64)
|
|
}
|
|
let (a, knock_a) = scenario();
|
|
let (b, knock_b) = scenario();
|
|
assert_eq!(hash_world(&a), hash_world(&b), "car-hits-walker diverged between runs");
|
|
assert!(knock_a > 0, "walker was never knocked down");
|
|
assert_eq!(knock_a, knock_b);
|
|
|
|
let walker = a.entity(3).unwrap();
|
|
assert!(
|
|
walker.pos.x > 3.0,
|
|
"walker should be sent flying downrange, only reached {:?}",
|
|
walker.pos
|
|
);
|
|
// Recorded on aarch64 (2026-08-11). Equality elsewhere is the cross-arch
|
|
// determinism claim for the whole contact pipeline. Re-record via
|
|
// --nocapture println if the impulse constants change deliberately.
|
|
let got = hash_world(&a);
|
|
println!("car-hits-walker golden hash: {got:#018x}");
|
|
const GOLDEN: u64 = 0x6819478b276cd7fc;
|
|
assert_eq!(got, GOLDEN, "car-hits-walker golden changed (got {got:#x})");
|
|
}
|
|
|
|
/// D4's other direction: a walker walking into a LIGHT crate shoves it
|
|
/// along; the walker itself is not bounced backward (the sweep owns the
|
|
/// walker's blocking).
|
|
#[test]
|
|
fn walker_pushes_a_light_crate() {
|
|
let mut w = GameWorld::new();
|
|
w.reset_content();
|
|
ground(&mut w);
|
|
let mut crate_e = ent(2, BodyKind::Rigid, vec3f(2.0, 0.5, 0.0), vec3f(0.5, 0.5, 0.5));
|
|
crate_e.density = 0.2; // light: 1 m³ * 0.2
|
|
crate_e.friction = 0.2;
|
|
w.push_entity(crate_e);
|
|
let walker = ent(3, BodyKind::Mover, vec3f(0.0, 0.8, 0.0), vec3f(0.35, 0.8, 0.35));
|
|
w.push_entity(walker);
|
|
|
|
for _ in 0..240 {
|
|
// The walk is a velocity the controller re-asserts each tick.
|
|
w.entity_mut(3).unwrap().vel.x = 3.0;
|
|
step_world(&mut w);
|
|
}
|
|
let crate_x = w.entity(2).unwrap().pos.x;
|
|
let walker_x = w.entity(3).unwrap().pos.x;
|
|
assert!(
|
|
crate_x > 3.0,
|
|
"light crate should have been shoved along, still at x {crate_x}"
|
|
);
|
|
assert!(
|
|
walker_x > 1.0,
|
|
"walker should advance behind the crate, at x {walker_x}"
|
|
);
|
|
// And nobody got floored by a walking-speed shove.
|
|
assert_eq!(w.entity(3).unwrap().knocked_down, 0);
|
|
}
|
|
|
|
/// Pending contacts are world state: a snapshot taken between ticks replays
|
|
/// the same impulses — clone and original stay bit-identical through the
|
|
/// collision.
|
|
#[test]
|
|
fn clone_replays_the_same_collision() {
|
|
let mut w = GameWorld::new();
|
|
w.reset_content();
|
|
ground(&mut w);
|
|
let mut car = ent(2, BodyKind::Rigid, vec3f(-6.0, 0.45, 0.0), vec3f(0.9, 0.4, 1.6));
|
|
car.density = 2.0;
|
|
car.friction = 0.1;
|
|
car.vel = vec3f(18.0, 0.0, 0.0);
|
|
w.push_entity(car);
|
|
w.push_entity(ent(3, BodyKind::Mover, vec3f(0.0, 0.8, 0.0), vec3f(0.35, 0.8, 0.35)));
|
|
|
|
for _ in 0..20 {
|
|
step_world(&mut w);
|
|
}
|
|
let mut c = w.clone();
|
|
for _ in 0..120 {
|
|
step_world(&mut w);
|
|
step_world(&mut c);
|
|
}
|
|
assert_eq!(
|
|
hash_world(&w),
|
|
hash_world(&c),
|
|
"clone diverged through the collision"
|
|
);
|
|
}
|
|
|
|
// --------------------------------------------------------------------- F9
|
|
|
|
/// The F9 gate: a projectile at extreme speed must stop at a thin wall, not
|
|
/// pass through it. 300 u/s = 5 units per tick against a 0.2-thick wall the
|
|
/// axis sweeps would step straight over.
|
|
#[test]
|
|
fn high_speed_projectile_never_tunnels() {
|
|
let mut w = GameWorld::new();
|
|
w.reset_content();
|
|
ground(&mut w);
|
|
// Thin wall at x = 10.
|
|
w.push_entity(ent(2, BodyKind::Static, vec3f(10.0, 2.0, 0.0), vec3f(0.1, 2.0, 4.0)));
|
|
let mut p = ent(3, BodyKind::Mover, vec3f(0.0, 2.0, 0.0), vec3f(0.15, 0.15, 0.15));
|
|
p.vel = vec3f(300.0, 0.0, 0.0);
|
|
p.gravity_scale = 0.0;
|
|
p.hits = true;
|
|
p.life = 5.0;
|
|
w.push_entity(p);
|
|
|
|
// hit_wall is transient (reset each tick, read by collect_touches every
|
|
// tick) — capture it on the tick it fires.
|
|
let mut reported = 0u64;
|
|
for _ in 0..30 {
|
|
step_world(&mut w);
|
|
let hw = w.entity(3).map_or(0, |e| e.hit_wall);
|
|
if hw != 0 {
|
|
reported = hw;
|
|
}
|
|
}
|
|
let p = w.entity(3).expect("projectile should still exist");
|
|
assert!(
|
|
p.pos.x < 10.0,
|
|
"projectile tunneled through the wall to x {}",
|
|
p.pos.x
|
|
);
|
|
assert!(
|
|
p.pos.x > 9.0,
|
|
"projectile should be AT the wall, stopped at x {}",
|
|
p.pos.x
|
|
);
|
|
assert_eq!(reported, 2, "the wall hit must be reported for on_touch");
|
|
assert_eq!(p.vel.x, 0.0);
|
|
}
|
|
|
|
/// A fast projectile stops at a MOVER too (D4: projectile → mover = hit,
|
|
/// via the capsule mirror) and reports the victim's id.
|
|
#[test]
|
|
fn high_speed_projectile_hits_a_mover() {
|
|
let mut w = GameWorld::new();
|
|
w.reset_content();
|
|
ground(&mut w);
|
|
w.push_entity(ent(2, BodyKind::Mover, vec3f(12.0, 0.8, 0.0), vec3f(0.35, 0.8, 0.35)));
|
|
let mut p = ent(3, BodyKind::Mover, vec3f(0.0, 1.0, 0.0), vec3f(0.1, 0.1, 0.1));
|
|
p.vel = vec3f(240.0, 0.0, 0.0);
|
|
p.gravity_scale = 0.0;
|
|
p.hits = true;
|
|
p.life = 5.0;
|
|
w.push_entity(p);
|
|
|
|
// Let the victim's capsule exist (mirror reconciles during the tick),
|
|
// then fire through. hit_wall is transient — capture it when it fires.
|
|
let mut reported = 0u64;
|
|
for _ in 0..30 {
|
|
step_world(&mut w);
|
|
let hw = w.entity(3).map_or(0, |e| e.hit_wall);
|
|
if hw != 0 {
|
|
reported = hw;
|
|
}
|
|
}
|
|
let p = w.entity(3).expect("projectile should still exist");
|
|
assert!(
|
|
p.pos.x < 12.0 && p.pos.x > 10.5,
|
|
"projectile should stop at the mover capsule, at x {}",
|
|
p.pos.x
|
|
);
|
|
assert_eq!(reported, 2, "the mover hit must be reported");
|
|
}
|
|
|
|
// -------------------------------------------------------------------- F10
|
|
|
|
/// Per-rigid acceleration from the cached prev_vel: free fall reads the
|
|
/// gravity vector; resting reads ~zero.
|
|
#[test]
|
|
fn rigid_acceleration_is_readable() {
|
|
let mut w = GameWorld::new();
|
|
w.reset_content();
|
|
ground(&mut w);
|
|
w.push_entity(ent(2, BodyKind::Rigid, vec3f(0.0, 20.0, 0.0), vec3f(0.4, 0.4, 0.4)));
|
|
|
|
// Two steps so prev_vel and vel are both post-solver values.
|
|
step_world(&mut w);
|
|
step_world(&mut w);
|
|
let a = w.dynamics.rigid_accel(2).expect("accel should be readable");
|
|
assert!(
|
|
(a.y + 30.0).abs() < 1.5,
|
|
"free fall should read ~-30 (world gravity), got {a:?}"
|
|
);
|
|
|
|
// Let it land and settle: acceleration returns to ~0.
|
|
for _ in 0..240 {
|
|
step_world(&mut w);
|
|
}
|
|
let a = w.dynamics.rigid_accel(2).expect("accel still readable");
|
|
assert!(a.length() < 0.5, "resting accel should be ~0, got {a:?}");
|
|
|
|
// Movers have no rigid accel.
|
|
w.push_entity(ent(9, BodyKind::Mover, vec3f(3.0, 1.0, 0.0), vec3f(0.3, 0.5, 0.3)));
|
|
step_world(&mut w);
|
|
assert!(w.dynamics.rigid_accel(9).is_none());
|
|
}
|
|
|
|
/// REAL terrain material indices (B6 fixed): a two-surface terrain reports
|
|
/// which half the ray hit; a per-entity material comes back from props.
|
|
#[test]
|
|
fn material_indices_reach_queries() {
|
|
let mut w = GameWorld::new();
|
|
w.reset_content();
|
|
let cells = 17usize;
|
|
let cell_size = 2.0;
|
|
let origin = -(cells as f32 - 1.0) * cell_size * 0.5;
|
|
w.terrain = Some(Terrain {
|
|
cells,
|
|
cell_size,
|
|
origin,
|
|
heights: vec![0.0; cells * cells],
|
|
colors: Vec::new(),
|
|
revision: 1,
|
|
});
|
|
// Left half tarmac (0), right half gravel (1).
|
|
let side = cells - 1;
|
|
let mut indices = vec![0u8; side * side];
|
|
for z in 0..side {
|
|
for x in 0..side {
|
|
if x >= side / 2 {
|
|
indices[z * side + x] = 1;
|
|
}
|
|
}
|
|
}
|
|
w.terrain_materials = Some(TerrainMaterials {
|
|
indices,
|
|
surfaces: vec![
|
|
TerrainSurface { friction: 0.9, restitution: 0.0 },
|
|
TerrainSurface { friction: 0.4, restitution: 0.0 },
|
|
],
|
|
});
|
|
// A prop with a per-entity material id.
|
|
let mut prop = ent(1, BodyKind::Static, vec3f(0.0, 1.0, 10.0), vec3f(1.0, 1.0, 1.0));
|
|
prop.material = 7;
|
|
w.push_entity(prop);
|
|
|
|
let (id, _p, _n, _d, mat) =
|
|
world_raycast(&mut w, vec3f(-10.0, 5.0, 0.0), vec3f(0.0, -1.0, 0.0), 20.0)
|
|
.expect("left terrain hit");
|
|
assert_eq!(id, TERRAIN_ID);
|
|
assert_eq!(mat, 0, "left half should be material 0");
|
|
|
|
let (id, _p, _n, _d, mat) =
|
|
world_raycast(&mut w, vec3f(10.0, 5.0, 0.0), vec3f(0.0, -1.0, 0.0), 20.0)
|
|
.expect("right terrain hit");
|
|
assert_eq!(id, TERRAIN_ID);
|
|
assert_eq!(mat, 1, "right half should be material 1");
|
|
|
|
let (id, _p, _n, _d, mat) =
|
|
world_raycast(&mut w, vec3f(0.0, 1.0, 0.0), vec3f(0.0, 0.0, 1.0), 20.0)
|
|
.expect("prop hit");
|
|
assert_eq!(id, 1);
|
|
assert_eq!(mat, 7, "per-entity material should come back from the ray");
|
|
}
|
|
|
|
/// Different terrain materials produce different PHYSICS: the same crate
|
|
/// slides further on the slick half than on the grippy half.
|
|
#[test]
|
|
fn terrain_materials_change_friction() {
|
|
fn slide_distance(indices_value: u8) -> f32 {
|
|
let mut w = GameWorld::new();
|
|
w.reset_content();
|
|
let cells = 33usize;
|
|
let cell_size = 2.0;
|
|
let origin = -(cells as f32 - 1.0) * cell_size * 0.5;
|
|
w.terrain = Some(Terrain {
|
|
cells,
|
|
cell_size,
|
|
origin,
|
|
heights: vec![0.0; cells * cells],
|
|
colors: Vec::new(),
|
|
revision: 1,
|
|
});
|
|
let side = cells - 1;
|
|
w.terrain_materials = Some(TerrainMaterials {
|
|
indices: vec![indices_value; side * side],
|
|
surfaces: vec![
|
|
TerrainSurface { friction: 1.2, restitution: 0.0 },
|
|
TerrainSurface { friction: 0.05, restitution: 0.0 },
|
|
],
|
|
});
|
|
let mut crate_e = ent(1, BodyKind::Rigid, vec3f(-20.0, 0.5, 0.0), vec3f(0.5, 0.5, 0.5));
|
|
crate_e.friction = 0.6;
|
|
crate_e.vel = vec3f(12.0, 0.0, 0.0);
|
|
w.push_entity(crate_e);
|
|
for _ in 0..300 {
|
|
step_world(&mut w);
|
|
}
|
|
w.entity(1).unwrap().pos.x - (-20.0)
|
|
}
|
|
let grippy = slide_distance(0);
|
|
let slick = slide_distance(1);
|
|
assert!(
|
|
slick > grippy + 5.0,
|
|
"low-friction surface should slide much further (grippy {grippy:.2}, slick {slick:.2})"
|
|
);
|
|
}
|