use crate::math::{Fix128, Vec3Fix};
use crate::solver::RigidBody;
use crate::world_participant::{
ObservationSink, Participant, ParticipantFault, ParticipantKind, Port, PortId, StateError,
SubstepCtx,
};
#[cfg(not(feature = "std"))]
use alloc::vec;
#[cfg(not(feature = "std"))]
use alloc::vec::Vec;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct WheelConfig {
pub local_position: Vec3Fix,
pub suspension_rest: Fix128,
pub spring_stiffness: Fix128,
pub damping: Fix128,
pub radius: Fix128,
pub max_steer_angle: Fix128,
pub driven: bool,
pub has_brake: bool,
pub progressive_rate: Fix128,
pub bump_damping: Fix128,
pub rebound_damping: Fix128,
pub bump_stop_stiffness: Fix128,
pub bump_stop_threshold: Fix128,
}
impl Default for WheelConfig {
fn default() -> Self {
Self {
local_position: Vec3Fix::ZERO,
suspension_rest: Fix128::from_ratio(3, 10),
spring_stiffness: Fix128::from_int(50000),
damping: Fix128::from_int(4500),
radius: Fix128::from_ratio(3, 10),
max_steer_angle: Fix128::from_ratio(5, 10), driven: false,
has_brake: true,
progressive_rate: Fix128::ZERO,
bump_damping: Fix128::ZERO,
rebound_damping: Fix128::ZERO,
bump_stop_stiffness: Fix128::ZERO,
bump_stop_threshold: Fix128::from_ratio(9, 10), }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct EngineConfig {
pub max_torque: Fix128,
pub max_rpm: Fix128,
pub idle_rpm: Fix128,
pub engine_brake: Fix128,
pub num_gears: usize,
}
impl Default for EngineConfig {
fn default() -> Self {
Self {
max_torque: Fix128::from_int(300),
max_rpm: Fix128::from_int(7000),
idle_rpm: Fix128::from_int(800),
engine_brake: Fix128::from_ratio(5, 10),
num_gears: 5,
}
}
}
#[derive(Clone, Debug)]
pub struct VehicleConfig {
pub wheels: Vec<WheelConfig>,
pub engine: EngineConfig,
pub gear_ratios: Vec<Fix128>,
pub brake_force: Fix128,
pub aero_drag: Fix128,
pub downforce: Fix128,
pub ground_height: Fix128,
pub anti_roll_stiffness: Fix128,
}
impl Default for VehicleConfig {
fn default() -> Self {
Self {
wheels: vec![
WheelConfig {
local_position: Vec3Fix::from_f32(-0.8, -0.2, 1.2),
driven: false,
max_steer_angle: Fix128::from_ratio(5, 10),
..Default::default()
},
WheelConfig {
local_position: Vec3Fix::from_f32(0.8, -0.2, 1.2),
driven: false,
max_steer_angle: Fix128::from_ratio(5, 10),
..Default::default()
},
WheelConfig {
local_position: Vec3Fix::from_f32(-0.8, -0.2, -1.2),
driven: true,
..Default::default()
},
WheelConfig {
local_position: Vec3Fix::from_f32(0.8, -0.2, -1.2),
driven: true,
..Default::default()
},
],
engine: EngineConfig::default(),
gear_ratios: vec![
Fix128::from_ratio(35, 10), Fix128::from_ratio(22, 10), Fix128::from_ratio(15, 10), Fix128::from_ratio(11, 10), Fix128::from_ratio(8, 10), ],
brake_force: Fix128::from_int(10000),
aero_drag: Fix128::from_ratio(4, 10),
downforce: Fix128::from_ratio(1, 10),
ground_height: Fix128::ZERO,
anti_roll_stiffness: Fix128::from_int(5000),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct WheelState {
pub compression: Fix128,
pub suspension_force: Fix128,
pub grounded: bool,
pub contact_point: Vec3Fix,
pub ground_normal: Vec3Fix,
pub spin_angle: Fix128,
pub spin_speed: Fix128,
pub lateral_slip: Fix128,
pub longitudinal_slip: Fix128,
}
impl Default for WheelState {
fn default() -> Self {
Self {
compression: Fix128::ZERO,
suspension_force: Fix128::ZERO,
grounded: false,
contact_point: Vec3Fix::ZERO,
ground_normal: Vec3Fix::UNIT_Y,
spin_angle: Fix128::ZERO,
spin_speed: Fix128::ZERO,
lateral_slip: Fix128::ZERO,
longitudinal_slip: Fix128::ZERO,
}
}
}
pub struct Vehicle {
pub config: VehicleConfig,
pub wheel_states: Vec<WheelState>,
pub current_gear: usize,
pub engine_rpm: Fix128,
pub throttle: Fix128,
pub brake: Fix128,
pub steering: Fix128,
pub speed_kmh: Fix128,
}
impl Vehicle {
#[must_use]
pub fn new(config: VehicleConfig) -> Self {
let n = config.wheels.len();
Self {
wheel_states: vec![WheelState::default(); n],
current_gear: 0,
engine_rpm: config.engine.idle_rpm,
throttle: Fix128::ZERO,
brake: Fix128::ZERO,
steering: Fix128::ZERO,
speed_kmh: Fix128::ZERO,
config,
}
}
#[must_use]
pub fn new_default() -> Self {
Self::new(VehicleConfig::default())
}
pub fn update(&mut self, chassis: &mut RigidBody, dt: Fix128) {
if chassis.is_static() {
return;
}
let force = self.chassis_force(chassis, dt);
chassis.apply_impulse(force * dt);
}
#[allow(clippy::too_many_lines)]
#[must_use]
pub fn chassis_force(&mut self, chassis: &RigidBody, dt: Fix128) -> Vec3Fix {
if chassis.is_static() {
return Vec3Fix::ZERO;
}
let forward = chassis.rotation.rotate_vec(Vec3Fix::UNIT_Z);
let right = chassis.rotation.rotate_vec(Vec3Fix::UNIT_X);
let up = chassis.rotation.rotate_vec(Vec3Fix::UNIT_Y);
self.speed_kmh = chassis.velocity.dot(forward) * Fix128::from_ratio(36, 10);
let gear_ratio = if self.current_gear < self.config.gear_ratios.len() {
self.config.gear_ratios[self.current_gear]
} else {
Fix128::ONE
};
let engine_torque = self.config.engine.max_torque * self.throttle;
let wheel_torque = engine_torque * gear_ratio;
let num_driven = self.config.wheels.iter().filter(|w| w.driven).count() as i64;
let torque_per_wheel = if num_driven > 0 {
wheel_torque / Fix128::from_int(num_driven)
} else {
Fix128::ZERO
};
let mut total_force = Vec3Fix::ZERO;
for i in 0..self.config.wheels.len() {
let wheel_cfg = self.config.wheels[i];
let wheel_world =
chassis.position + chassis.rotation.rotate_vec(wheel_cfg.local_position);
let ray_start = wheel_world;
let max_dist = wheel_cfg.suspension_rest + wheel_cfg.radius;
let ground_y = self.config.ground_height;
let dist_to_ground = ray_start.y - ground_y;
if dist_to_ground < max_dist {
self.wheel_states[i].grounded = true;
self.wheel_states[i].ground_normal = Vec3Fix::UNIT_Y;
self.wheel_states[i].contact_point =
Vec3Fix::new(wheel_world.x, ground_y, wheel_world.z);
let compression =
Fix128::ONE - (dist_to_ground - wheel_cfg.radius) / wheel_cfg.suspension_rest;
let compression = if compression < Fix128::ZERO {
Fix128::ZERO
} else if compression > Fix128::ONE {
Fix128::ONE
} else {
compression
};
let effective_stiffness = wheel_cfg.spring_stiffness
* (Fix128::ONE + wheel_cfg.progressive_rate * compression);
let mut spring_force = effective_stiffness * compression;
if !wheel_cfg.bump_stop_stiffness.is_zero()
&& compression > wheel_cfg.bump_stop_threshold
{
let overshoot = compression - wheel_cfg.bump_stop_threshold;
spring_force = spring_force + wheel_cfg.bump_stop_stiffness * overshoot;
}
let vel_y = chassis.velocity.dot(up);
let damp_coeff = if vel_y < Fix128::ZERO {
if wheel_cfg.bump_damping.is_zero() {
wheel_cfg.damping
} else {
wheel_cfg.bump_damping
}
} else {
if wheel_cfg.rebound_damping.is_zero() {
wheel_cfg.damping
} else {
wheel_cfg.rebound_damping
}
};
let damp_force = damp_coeff * vel_y;
let susp_force = spring_force - damp_force;
self.wheel_states[i].compression = compression;
self.wheel_states[i].suspension_force = susp_force;
total_force = total_force + up * susp_force;
if wheel_cfg.driven {
let drive_force = forward * (torque_per_wheel / wheel_cfg.radius);
total_force = total_force + drive_force;
}
if wheel_cfg.has_brake && self.brake > Fix128::ZERO {
let speed = chassis.velocity.dot(forward);
if speed.abs() > Fix128::from_ratio(1, 10) {
let brake_dir = if speed > Fix128::ZERO {
-forward
} else {
forward
};
let brake_mag = self.config.brake_force * self.brake;
total_force = total_force + brake_dir * brake_mag;
}
}
let lateral_vel = chassis.velocity.dot(right);
let lateral_friction = -right * (lateral_vel * Fix128::from_int(5000));
total_force = total_force + lateral_friction;
if wheel_cfg.max_steer_angle > Fix128::ZERO {
let steer_angle = self.steering * wheel_cfg.max_steer_angle;
let (sin_a, cos_a) = steer_angle.sin_cos();
let steer_force = (right * sin_a + forward * cos_a) - forward;
let speed_factor = chassis.velocity.dot(forward).abs();
total_force =
total_force + steer_force * (speed_factor * Fix128::from_int(1000));
}
} else {
self.wheel_states[i].grounded = false;
self.wheel_states[i].compression = Fix128::ZERO;
}
if self.wheel_states[i].grounded {
let ground_speed = chassis.velocity.dot(forward);
self.wheel_states[i].spin_speed = ground_speed / wheel_cfg.radius;
}
self.wheel_states[i].spin_angle =
self.wheel_states[i].spin_angle + self.wheel_states[i].spin_speed * dt;
}
if !self.config.anti_roll_stiffness.is_zero() && self.config.wheels.len() >= 2 {
let num_pairs = self.config.wheels.len() / 2;
for pair in 0..num_pairs {
let left = pair * 2;
let right = pair * 2 + 1;
let diff =
self.wheel_states[left].compression - self.wheel_states[right].compression;
let anti_roll_force = self.config.anti_roll_stiffness * diff;
if self.wheel_states[left].grounded {
total_force = total_force - up * anti_roll_force;
}
if self.wheel_states[right].grounded {
total_force = total_force + up * anti_roll_force;
}
}
}
let speed_sq = chassis.velocity.length_squared();
if !speed_sq.is_zero() {
let speed = speed_sq.sqrt();
let drag_dir = chassis.velocity / speed;
total_force = total_force - drag_dir * (self.config.aero_drag * speed_sq);
total_force = total_force - up * (self.config.downforce * speed_sq);
}
let wheel_rpm = self.speed_kmh.abs() * Fix128::from_ratio(100, 36) * gear_ratio;
self.engine_rpm = wheel_rpm.abs();
if self.engine_rpm < self.config.engine.idle_rpm {
self.engine_rpm = self.config.engine.idle_rpm;
}
total_force
}
pub fn shift_up(&mut self) {
if self.current_gear + 1 < self.config.gear_ratios.len() {
self.current_gear += 1;
}
}
pub fn shift_down(&mut self) {
if self.current_gear > 0 {
self.current_gear -= 1;
}
}
#[must_use]
pub fn grounded_wheels(&self) -> usize {
self.wheel_states.iter().filter(|w| w.grounded).count()
}
}
impl core::fmt::Debug for Vehicle {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("Vehicle")
.field("config", &self.config)
.field(
"wheel_states",
&format_args!("[{} items]", self.wheel_states.len()),
)
.field("current_gear", &self.current_gear)
.field("engine_rpm", &self.engine_rpm)
.field("throttle", &self.throttle)
.field("brake", &self.brake)
.field("steering", &self.steering)
.field("speed_kmh", &self.speed_kmh)
.finish()
}
}
pub const VEHICLE_PARTICIPANT_KIND: ParticipantKind =
ParticipantKind::new(u32::from_be_bytes(*b"VHCL"));
pub const VEHICLE_INPUTS: usize = 3;
pub const VEHICLE_INPUT_THROTTLE: usize = 0;
pub const VEHICLE_INPUT_BRAKE: usize = 1;
pub const VEHICLE_INPUT_STEERING: usize = 2;
pub const VEHICLE_OBS_SPEED_KMH: u32 = 0;
pub const VEHICLE_OBS_ENGINE_RPM: u32 = 1;
pub const VEHICLE_OBS_GEAR: u32 = 2;
pub const VEHICLE_OBS_THROTTLE: u32 = 3;
pub const VEHICLE_OBS_BRAKE: u32 = 4;
pub const VEHICLE_OBS_STEERING: u32 = 5;
pub const VEHICLE_OBS_GROUNDED: u32 = 6;
const VEHICLE_STATE_VERSION: u32 = 1;
const VEHICLE_STATE_HEAD: usize = 4 + 8 + 8 + 5 * 16;
const VEHICLE_STATE_WHEEL: usize = 12 * 16 + 1;
pub struct VehicleParticipant {
vehicle: Vehicle,
chassis: usize,
inputs: Option<PortId>,
ports: Vec<Port>,
digest: u64,
}
impl VehicleParticipant {
#[must_use]
pub fn new(vehicle: Vehicle, chassis: usize) -> Self {
let digest = vehicle_digest(&vehicle.config, chassis, None);
Self {
vehicle,
chassis,
inputs: None,
ports: Vec::new(),
digest,
}
}
#[must_use]
pub fn with_inputs(mut self, field: PortId) -> Self {
self.inputs = Some(field);
self.ports = vec![Port::reads_committed(field)];
self.digest = vehicle_digest(&self.vehicle.config, self.chassis, Some(field));
self
}
#[must_use]
pub fn vehicle(&self) -> &Vehicle {
&self.vehicle
}
fn inputs(&self, ctx: &SubstepCtx<'_>) -> Result<Option<[Fix128; 3]>, ParticipantFault> {
let Some(id) = self.inputs else {
return Ok(None);
};
let values = ctx.field(id).map_err(|_| ParticipantFault::InvalidState)?;
if values.len() != VEHICLE_INPUTS {
return Err(ParticipantFault::InvalidState);
}
let (zero, one) = (Fix128::ZERO, Fix128::ONE);
let throttle = values[VEHICLE_INPUT_THROTTLE];
let brake = values[VEHICLE_INPUT_BRAKE];
let steering = values[VEHICLE_INPUT_STEERING];
if !(zero..=one).contains(&throttle)
|| !(zero..=one).contains(&brake)
|| !(-one..=one).contains(&steering)
{
return Err(ParticipantFault::OutOfRange);
}
Ok(Some([throttle, brake, steering]))
}
}
fn vehicle_digest(config: &VehicleConfig, chassis: usize, inputs: Option<PortId>) -> u64 {
let mut h = Fnv::new();
h.word(config.wheels.len() as u64);
for w in &config.wheels {
h.vec(w.local_position);
for f in [
w.suspension_rest,
w.spring_stiffness,
w.damping,
w.radius,
w.max_steer_angle,
w.progressive_rate,
w.bump_damping,
w.rebound_damping,
w.bump_stop_stiffness,
w.bump_stop_threshold,
] {
h.fix(f);
}
h.word(u64::from(w.driven) | (u64::from(w.has_brake) << 1));
}
let e = &config.engine;
for f in [e.max_torque, e.max_rpm, e.idle_rpm, e.engine_brake] {
h.fix(f);
}
h.word(e.num_gears as u64);
h.word(config.gear_ratios.len() as u64);
for &g in &config.gear_ratios {
h.fix(g);
}
for f in [
config.brake_force,
config.aero_drag,
config.downforce,
config.ground_height,
config.anti_roll_stiffness,
] {
h.fix(f);
}
h.word(chassis as u64);
h.word(inputs.map_or(u64::MAX, |p| u64::from(p.get())));
h.0
}
struct Fnv(u64);
impl Fnv {
const fn new() -> Self {
Self(0xcbf2_9ce4_8422_2325)
}
fn bytes(&mut self, bytes: &[u8]) {
for &b in bytes {
self.0 ^= u64::from(b);
self.0 = self.0.wrapping_mul(0x0000_0100_0000_01b3);
}
}
fn word(&mut self, w: u64) {
self.bytes(&w.to_le_bytes());
}
fn fix(&mut self, f: Fix128) {
self.bytes(&f.hi.to_le_bytes());
self.bytes(&f.lo.to_le_bytes());
}
fn vec(&mut self, v: Vec3Fix) {
self.fix(v.x);
self.fix(v.y);
self.fix(v.z);
}
}
fn put_fix(out: &mut Vec<u8>, f: Fix128) {
out.extend_from_slice(&f.hi.to_le_bytes());
out.extend_from_slice(&f.lo.to_le_bytes());
}
fn get_fix(bytes: &[u8], at: &mut usize) -> Fix128 {
let mut hi = [0u8; 8];
let mut lo = [0u8; 8];
hi.copy_from_slice(&bytes[*at..*at + 8]);
lo.copy_from_slice(&bytes[*at + 8..*at + 16]);
*at += 16;
Fix128 {
hi: i64::from_le_bytes(hi),
lo: u64::from_le_bytes(lo),
}
}
fn get_u64(bytes: &[u8], at: &mut usize) -> u64 {
let mut b = [0u8; 8];
b.copy_from_slice(&bytes[*at..*at + 8]);
*at += 8;
u64::from_le_bytes(b)
}
impl Participant for VehicleParticipant {
fn kind(&self) -> ParticipantKind {
VEHICLE_PARTICIPANT_KIND
}
fn ports(&self) -> &[Port] {
&self.ports
}
fn substep(&mut self, ctx: &mut SubstepCtx<'_>, h: Fix128) -> Result<(), ParticipantFault> {
let inputs = self.inputs(ctx)?;
let force = {
let Some(body) = ctx.bodies().get(self.chassis) else {
return Err(ParticipantFault::InvalidState);
};
if body.is_static() {
return Ok(());
}
if let Some([throttle, brake, steering]) = inputs {
self.vehicle.throttle = throttle;
self.vehicle.brake = brake;
self.vehicle.steering = steering;
}
self.vehicle.chassis_force(body, h)
};
ctx.add_force(self.chassis, force)
.map_err(|_| ParticipantFault::InvalidState)
}
fn observe(&self, out: &mut ObservationSink) {
let v = &self.vehicle;
let grounded = v.wheel_states.iter().filter(|w| w.grounded).count();
out.push(VEHICLE_OBS_SPEED_KMH, v.speed_kmh);
out.push(VEHICLE_OBS_ENGINE_RPM, v.engine_rpm);
out.push(VEHICLE_OBS_GEAR, Fix128::from_int(v.current_gear as i64));
out.push(VEHICLE_OBS_THROTTLE, v.throttle);
out.push(VEHICLE_OBS_BRAKE, v.brake);
out.push(VEHICLE_OBS_STEERING, v.steering);
out.push(VEHICLE_OBS_GROUNDED, Fix128::from_int(grounded as i64));
}
fn write_state(&self, out: &mut Vec<u8>) {
let v = &self.vehicle;
out.extend_from_slice(&VEHICLE_STATE_VERSION.to_le_bytes());
out.extend_from_slice(&self.digest.to_le_bytes());
out.extend_from_slice(&(v.current_gear as u64).to_le_bytes());
for f in [v.engine_rpm, v.throttle, v.brake, v.steering, v.speed_kmh] {
put_fix(out, f);
}
for w in &v.wheel_states {
for f in [
w.compression,
w.suspension_force,
w.spin_angle,
w.spin_speed,
w.lateral_slip,
w.longitudinal_slip,
w.contact_point.x,
w.contact_point.y,
w.contact_point.z,
w.ground_normal.x,
w.ground_normal.y,
w.ground_normal.z,
] {
put_fix(out, f);
}
out.extend_from_slice(&[u8::from(w.grounded)]);
}
}
fn check_state(&self, bytes: &[u8]) -> Result<(), StateError> {
let expected = VEHICLE_STATE_HEAD + VEHICLE_STATE_WHEEL * self.vehicle.wheel_states.len();
if bytes.len() != expected {
return Err(StateError::Length {
expected,
found: bytes.len(),
});
}
let mut version = [0u8; 4];
version.copy_from_slice(&bytes[0..4]);
let mut at = 4;
let digest = get_u64(bytes, &mut at);
let gear = get_u64(bytes, &mut at);
if u32::from_le_bytes(version) != VEHICLE_STATE_VERSION
|| digest != self.digest
|| usize::try_from(gear).is_err()
{
return Err(StateError::InvalidValue);
}
let mut at = 4 + 8 + 8 + 5 * 16;
for _ in 0..self.vehicle.wheel_states.len() {
at += 12 * 16;
if bytes[at] > 1 {
return Err(StateError::InvalidValue);
}
at += 1;
}
Ok(())
}
fn read_state(&mut self, bytes: &[u8]) {
let v = &mut self.vehicle;
let mut at = 4 + 8;
v.current_gear = usize::try_from(get_u64(bytes, &mut at)).unwrap_or_default();
v.engine_rpm = get_fix(bytes, &mut at);
v.throttle = get_fix(bytes, &mut at);
v.brake = get_fix(bytes, &mut at);
v.steering = get_fix(bytes, &mut at);
v.speed_kmh = get_fix(bytes, &mut at);
for w in &mut v.wheel_states {
w.compression = get_fix(bytes, &mut at);
w.suspension_force = get_fix(bytes, &mut at);
w.spin_angle = get_fix(bytes, &mut at);
w.spin_speed = get_fix(bytes, &mut at);
w.lateral_slip = get_fix(bytes, &mut at);
w.longitudinal_slip = get_fix(bytes, &mut at);
let cx = get_fix(bytes, &mut at);
let cy = get_fix(bytes, &mut at);
let cz = get_fix(bytes, &mut at);
w.contact_point = Vec3Fix::new(cx, cy, cz);
let nx = get_fix(bytes, &mut at);
let ny = get_fix(bytes, &mut at);
let nz = get_fix(bytes, &mut at);
w.ground_normal = Vec3Fix::new(nx, ny, nz);
w.grounded = bytes[at] == 1;
at += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_vehicle_creation() {
let vehicle = Vehicle::new_default();
assert_eq!(vehicle.config.wheels.len(), 4);
assert_eq!(vehicle.wheel_states.len(), 4);
}
#[test]
fn test_vehicle_suspension() {
let mut vehicle = Vehicle::new_default();
let mut chassis = RigidBody::new(Vec3Fix::from_f32(0.0, 0.5, 0.0), Fix128::from_int(1500));
let dt = Fix128::from_ratio(1, 60);
vehicle.update(&mut chassis, dt);
assert!(vehicle.grounded_wheels() > 0, "Wheels should touch ground");
}
#[test]
fn test_vehicle_acceleration() {
let mut vehicle = Vehicle::new_default();
let mut chassis = RigidBody::new(Vec3Fix::from_f32(0.0, 0.5, 0.0), Fix128::from_int(1500));
vehicle.throttle = Fix128::ONE;
let dt = Fix128::from_ratio(1, 60);
for _ in 0..60 {
vehicle.update(&mut chassis, dt);
}
let speed = vehicle.speed_kmh.to_f32();
assert!(
speed.abs() > 0.01,
"Vehicle should accelerate, speed={speed}"
);
}
#[test]
fn test_gear_shifting() {
let mut vehicle = Vehicle::new_default();
assert_eq!(vehicle.current_gear, 0);
vehicle.shift_up();
assert_eq!(vehicle.current_gear, 1);
vehicle.shift_up();
vehicle.shift_up();
vehicle.shift_up();
vehicle.shift_up(); assert_eq!(vehicle.current_gear, 4);
vehicle.shift_down();
assert_eq!(vehicle.current_gear, 3);
}
#[test]
fn test_progressive_spring() {
let mut config = VehicleConfig::default();
for w in &mut config.wheels {
w.progressive_rate = Fix128::ONE; }
let mut vehicle = Vehicle::new(config);
let mut chassis = RigidBody::new(Vec3Fix::from_f32(0.0, 0.5, 0.0), Fix128::from_int(1500));
let dt = Fix128::from_ratio(1, 60);
vehicle.update(&mut chassis, dt);
let grounded: Vec<_> = vehicle.wheel_states.iter().filter(|w| w.grounded).collect();
assert!(!grounded.is_empty(), "Should have grounded wheels");
for ws in &grounded {
assert!(
ws.suspension_force > Fix128::ZERO,
"Suspension force should be positive"
);
}
}
#[test]
fn test_anti_roll_bar() {
let config = VehicleConfig::default();
assert!(
!config.anti_roll_stiffness.is_zero(),
"Default anti-roll stiffness should be non-zero"
);
let mut vehicle = Vehicle::new(config);
let mut chassis = RigidBody::new(Vec3Fix::from_f32(0.0, 0.5, 0.0), Fix128::from_int(1500));
let dt = Fix128::from_ratio(1, 60);
vehicle.update(&mut chassis, dt);
assert!(vehicle.grounded_wheels() > 0);
}
#[test]
fn test_asymmetric_damping_defaults() {
let w = WheelConfig::default();
assert!(w.bump_damping.is_zero());
assert!(w.rebound_damping.is_zero());
}
#[test]
fn test_asymmetric_damping_simulation() {
let mut config = VehicleConfig::default();
for w in &mut config.wheels {
w.bump_damping = Fix128::from_int(6000); w.rebound_damping = Fix128::from_int(3000); }
let mut vehicle = Vehicle::new(config);
let mut chassis = RigidBody::new(Vec3Fix::from_f32(0.0, 0.5, 0.0), Fix128::from_int(1500));
let dt = Fix128::from_ratio(1, 60);
for _ in 0..30 {
vehicle.update(&mut chassis, dt);
}
assert!(vehicle.grounded_wheels() > 0, "Should have grounded wheels");
for ws in &vehicle.wheel_states {
if ws.grounded {
assert!(
ws.suspension_force > Fix128::ZERO,
"Suspension force should be positive with asymmetric damping"
);
}
}
}
#[test]
fn test_bump_stop_defaults() {
let w = WheelConfig::default();
assert!(w.bump_stop_stiffness.is_zero()); let expected = Fix128::from_ratio(9, 10);
assert_eq!(w.bump_stop_threshold, expected); }
#[test]
fn test_bump_stop_activation() {
let mut config = VehicleConfig::default();
for w in &mut config.wheels {
w.bump_stop_stiffness = Fix128::from_int(100_000); w.bump_stop_threshold = Fix128::from_ratio(5, 10); }
let mut vehicle_with_stop = Vehicle::new(config.clone());
let mut vehicle_without_stop = Vehicle::new(VehicleConfig::default());
let mut chassis_with =
RigidBody::new(Vec3Fix::from_f32(0.0, 0.35, 0.0), Fix128::from_int(1500));
let mut chassis_without =
RigidBody::new(Vec3Fix::from_f32(0.0, 0.35, 0.0), Fix128::from_int(1500));
let dt = Fix128::from_ratio(1, 60);
vehicle_with_stop.update(&mut chassis_with, dt);
vehicle_without_stop.update(&mut chassis_without, dt);
let force_with: Fix128 = vehicle_with_stop
.wheel_states
.iter()
.filter(|w| w.grounded)
.map(|w| w.suspension_force)
.fold(Fix128::ZERO, |a, b| a + b);
let force_without: Fix128 = vehicle_without_stop
.wheel_states
.iter()
.filter(|w| w.grounded)
.map(|w| w.suspension_force)
.fold(Fix128::ZERO, |a, b| a + b);
assert!(
force_with >= force_without,
"Bump stop should increase suspension force: with={}, without={}",
force_with.to_f32(),
force_without.to_f32()
);
}
#[test]
fn test_bump_stop_no_effect_low_compression() {
let mut config = VehicleConfig::default();
for w in &mut config.wheels {
w.bump_stop_stiffness = Fix128::from_int(100_000);
w.bump_stop_threshold = Fix128::from_ratio(95, 100); }
let mut vehicle_with = Vehicle::new(config);
let mut vehicle_without = Vehicle::new(VehicleConfig::default());
let mut chassis_with =
RigidBody::new(Vec3Fix::from_f32(0.0, 0.55, 0.0), Fix128::from_int(1500));
let mut chassis_without =
RigidBody::new(Vec3Fix::from_f32(0.0, 0.55, 0.0), Fix128::from_int(1500));
let dt = Fix128::from_ratio(1, 60);
vehicle_with.update(&mut chassis_with, dt);
vehicle_without.update(&mut chassis_without, dt);
for (ws_with, ws_without) in vehicle_with
.wheel_states
.iter()
.zip(vehicle_without.wheel_states.iter())
{
if ws_with.grounded && ws_without.grounded {
let diff = (ws_with.suspension_force - ws_without.suspension_force)
.to_f32()
.abs();
assert!(
diff < 0.01,
"Low compression: bump stop should have no effect, diff={diff}"
);
}
}
}
}
#[cfg(test)]
mod participant_tests {
use super::*;
#[cfg(not(feature = "std"))]
use alloc::vec::Vec;
fn resident() -> VehicleParticipant {
VehicleParticipant::new(Vehicle::new(VehicleConfig::default()), 0)
}
#[test]
fn kind_and_digest() {
let a = resident();
assert_eq!(a.kind().get(), 0x5648_434c);
let mut config = VehicleConfig::default();
config.aero_drag = config.aero_drag + Fix128::ONE;
assert_ne!(
a.digest,
VehicleParticipant::new(Vehicle::new(config), 0).digest
);
assert_ne!(
a.digest,
VehicleParticipant::new(Vehicle::new(VehicleConfig::default()), 1).digest
);
let with = resident().with_inputs(PortId::new(7));
assert_ne!(a.digest, with.digest);
assert_eq!(with.ports(), &[Port::reads_committed(PortId::new(7))]);
assert!(a.ports().is_empty());
}
#[test]
fn payload_round_trip_and_refusals() {
let mut a = resident();
a.vehicle.current_gear = 2;
a.vehicle.throttle = Fix128::from_ratio(1, 2);
a.vehicle.wheel_states[1].grounded = true;
a.vehicle.wheel_states[3].spin_angle = Fix128::from_int(7);
let mut bytes = Vec::new();
a.write_state(&mut bytes);
let expected = VEHICLE_STATE_HEAD + 4 * VEHICLE_STATE_WHEEL;
assert_eq!(bytes.len(), expected);
let mut b = resident();
assert_eq!(b.check_state(&bytes), Ok(()));
b.read_state(&bytes);
let mut again = Vec::new();
b.write_state(&mut again);
assert_eq!(again, bytes);
assert_eq!(b.vehicle.wheel_states, a.vehicle.wheel_states);
assert_eq!(
b.check_state(&bytes[1..]),
Err(StateError::Length {
expected,
found: expected - 1
})
);
let mut bad = bytes.clone();
bad[0] = 9;
assert_eq!(b.check_state(&bad), Err(StateError::InvalidValue));
let mut bad = bytes.clone();
bad[4] ^= 1;
assert_eq!(b.check_state(&bad), Err(StateError::InvalidValue));
let mut bad = bytes.clone();
bad[VEHICLE_STATE_HEAD + 12 * 16] = 2;
assert_eq!(b.check_state(&bad), Err(StateError::InvalidValue));
}
use crate::world_participant::{
FieldBoard, FieldLayout, FieldMode, FieldStage, ForceAccumulator,
};
const INPUTS: PortId = PortId::new(5);
fn chassis() -> RigidBody {
let w = VehicleConfig::default().wheels[0];
let y = w.suspension_rest + w.radius - w.local_position.y - Fix128::from_ratio(1, 10);
RigidBody::new_dynamic(
Vec3Fix::new(Fix128::ZERO, y, Fix128::ZERO),
Fix128::from_int(1200),
)
}
fn board(values: &[Fix128]) -> FieldBoard {
let mut b = FieldBoard::new();
b.declare(
INPUTS,
FieldLayout::Channels {
count: values.len(),
},
FieldMode::Replace,
)
.expect("declare");
b.set(INPUTS, values).expect("set");
b
}
fn run(
p: &mut VehicleParticipant,
bodies: &[RigidBody],
inputs: Option<&FieldBoard>,
) -> (Result<(), ParticipantFault>, Vec3Fix) {
let h = Fix128::from_ratio(1, 240);
let mut acc = ForceAccumulator::new(bodies.len());
let mut stage = FieldStage::new();
let ports = p.ports.clone();
let result = {
let ctx = SubstepCtx::new(bodies, &mut acc, 0, 4, h).expect("ctx");
let mut ctx = match inputs {
Some(b) => ctx.with_fields(b, &mut stage, &ports),
None => ctx,
};
p.substep(&mut ctx, h)
};
let force = acc.force(0).unwrap_or(Vec3Fix::ZERO);
(result, force)
}
#[test]
fn substep_stages_the_chassis_force() {
let bodies = [chassis()];
let mut p = resident();
let mut twin = Vehicle::new(VehicleConfig::default());
let want = twin.chassis_force(&bodies[0], Fix128::from_ratio(1, 240));
let (r, f) = run(&mut p, &bodies, None);
assert_eq!(r, Ok(()));
assert_eq!(f, want);
assert_ne!(f, Vec3Fix::ZERO);
assert_eq!(p.vehicle.wheel_states, twin.wheel_states);
}
#[test]
fn inputs_are_checked_and_applied() {
let bodies = [chassis()];
let half = Fix128::from_ratio(1, 2);
let mut p = resident().with_inputs(INPUTS);
let ok = board(&[half, Fix128::ZERO, -half]);
let (r, f) = run(&mut p, &bodies, Some(&ok));
assert_eq!(r, Ok(()));
assert_ne!(f, Vec3Fix::ZERO);
assert_eq!((p.vehicle.throttle, p.vehicle.steering), (half, -half));
for (values, fault) in [
(
vec![Fix128::from_int(2), Fix128::ZERO, Fix128::ZERO],
ParticipantFault::OutOfRange,
),
(
vec![Fix128::ZERO, -half, Fix128::ZERO],
ParticipantFault::OutOfRange,
),
(
vec![Fix128::ZERO, Fix128::ZERO, Fix128::from_int(-2)],
ParticipantFault::OutOfRange,
),
(
vec![Fix128::ZERO, Fix128::ZERO],
ParticipantFault::InvalidState,
),
] {
let mut q = resident().with_inputs(INPUTS);
let mut before = Vec::new();
q.write_state(&mut before);
let (r, f) = run(&mut q, &bodies, Some(&board(&values)));
assert_eq!(r, Err(fault), "{values:?}");
assert_eq!(f, Vec3Fix::ZERO);
let mut after = Vec::new();
q.write_state(&mut after);
assert_eq!(after, before);
}
let mut q = resident().with_inputs(INPUTS);
assert_eq!(
run(&mut q, &bodies, None).0,
Err(ParticipantFault::InvalidState)
);
}
#[test]
fn chassis_index_and_static_chassis() {
let mut p = VehicleParticipant::new(Vehicle::new(VehicleConfig::default()), 3);
assert_eq!(
run(&mut p, &[chassis()], None).0,
Err(ParticipantFault::InvalidState)
);
let static_body = RigidBody::new_static(chassis().position);
let mut p = resident();
let (r, f) = run(&mut p, &[static_body], None);
assert_eq!((r, f), (Ok(()), Vec3Fix::ZERO));
assert_eq!(
p.vehicle.wheel_states,
Vehicle::new(VehicleConfig::default()).wheel_states
);
let half = Fix128::from_ratio(1, 2);
let mut p = resident().with_inputs(INPUTS);
let mut before = Vec::new();
p.write_state(&mut before);
let mut seen_before = ObservationSink::new();
p.observe(&mut seen_before);
let (r, f) = run(&mut p, &[static_body], Some(&board(&[half, half, half])));
assert_eq!((r, f), (Ok(()), Vec3Fix::ZERO));
let mut after = Vec::new();
p.write_state(&mut after);
assert_eq!(after, before);
let mut seen_after = ObservationSink::new();
p.observe(&mut seen_after);
assert_eq!(seen_after.values(), seen_before.values());
}
#[test]
fn observation_channels() {
let mut p = resident();
p.vehicle.current_gear = 1;
p.vehicle.throttle = Fix128::ONE;
p.vehicle.wheel_states[0].grounded = true;
p.vehicle.wheel_states[2].grounded = true;
let mut sink = ObservationSink::new();
p.observe(&mut sink);
let v = sink.values();
assert_eq!(v.len(), 7);
assert_eq!(
v[VEHICLE_OBS_GEAR as usize],
(VEHICLE_OBS_GEAR, Fix128::ONE)
);
assert_eq!(
v[VEHICLE_OBS_THROTTLE as usize],
(VEHICLE_OBS_THROTTLE, Fix128::ONE)
);
assert_eq!(
v[VEHICLE_OBS_GROUNDED as usize],
(VEHICLE_OBS_GROUNDED, Fix128::from_int(2))
);
assert_eq!(v[VEHICLE_OBS_ENGINE_RPM as usize].1, p.vehicle.engine_rpm);
assert_eq!(v[VEHICLE_OBS_SPEED_KMH as usize].1, Fix128::ZERO);
assert_eq!(v[VEHICLE_OBS_BRAKE as usize].1, Fix128::ZERO);
assert_eq!(v[VEHICLE_OBS_STEERING as usize].1, Fix128::ZERO);
}
#[test]
fn gear_out_of_usize_is_refused() {
let a = resident();
let mut bytes = Vec::new();
a.write_state(&mut bytes);
bytes[12..20].copy_from_slice(&u64::MAX.to_le_bytes());
if usize::try_from(u64::MAX).is_err() {
assert_eq!(a.check_state(&bytes), Err(StateError::InvalidValue));
} else {
assert_eq!(a.check_state(&bytes), Ok(()));
}
}
}