fix(physics): port the Borland Real48 collision core

Add a bit-exact six-byte Real48 implementation for integer conversion,
rounding, comparison, add/subtract, multiply/divide, and Newton square root.
Use the normalized Borland random-register result and route speed clamps,
segment/circle detection, moving flippers, captures, triggers, and magnetic
fields through the recovered arithmetic instead of host floating formulas.

Preserve Real48 spin per ball and feed it through the original tangent/spin
response, separating zero-spin C fixtures from retained-spin Wine traces.
Replace path-progress selection with surface-distance ordering and implement
dynamic records 174/175, including impulse transfer to the other slot,
normal_velocity-1000 response, and the second post-collision speed clamp.

Test Plan:
- `cargo test --all-targets` -- passed, 69 tests
- `cargo clippy --all-targets -- -D warnings` -- passed
- `rumdl check tdkpin-rs/CHANGELOG.md tdkpin-rs/RECONSTRUCTION.md` -- passed
- `git diff --cached --check` -- passed
This commit is contained in:
2026-08-23 17:36:03 +02:00
parent 98f21b31c0
commit 1f0085902c
9 changed files with 1004 additions and 297 deletions
+145 -88
View File
@@ -3,9 +3,11 @@ use crate::{
flipper_physics::{FlipperSide, moving_flipper_response},
geometry::Segment,
original_physics::{
CollisionResponse, GRAVITY_MILLI_PER_STEP, MAXIMUM_SPEED_MILLI_PER_STEP, MilliVec,
STEP_SECONDS, circle_collision_response, line_collision_response, path_intersects_circle,
CollisionMaterial, CollisionResponse, GRAVITY_MILLI_PER_STEP,
MAXIMUM_SPEED_MILLI_PER_STEP, MilliVec, STEP_SECONDS, ball_collision_response,
circle_collision_response, line_collision_response, milli_distance, path_intersects_circle,
},
real48::Real48,
table::{
BUMPERS, EFFECT_SENSOR, LOCK_HOLES, PASSIVE_CIRCLES, TARGET_SENSORS, WALLS,
WHEEL_RESET_SENSOR,
@@ -184,6 +186,7 @@ pub struct Ball {
pub position: Vec2,
pub velocity: Vec2,
pub in_launcher: bool,
spin: Real48,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -244,6 +247,7 @@ impl Default for Ball {
position: LAUNCHER_POSITION,
velocity: Vec2::ZERO,
in_launcher: true,
spin: Real48::ZERO,
}
}
}
@@ -475,9 +479,14 @@ impl Game {
velocity.x = velocity.x.wrapping_add(signed);
}
Nudge::Center => {
let horizontal =
(f64::from(SCALAR) * (self.random.unit_interval() * 20.0 - 10.0)).round()
as i32;
let horizontal = Real48::from_i32(SCALAR)
.multiply(
self.random
.real48()
.multiply(Real48::from_i32(20))
.subtract(Real48::from_i32(10)),
)
.round_i32();
let vertical = -(60 - i32::from(self.random.below(20))) * SCALAR;
velocity.x = velocity.x.wrapping_add(horizontal);
velocity.y = velocity.y.wrapping_add(vertical);
@@ -545,52 +554,45 @@ impl Game {
self.apply_magnetic_fields(old_position, &mut velocity);
let movement_velocity = velocity;
let mut position = old_position.add(velocity);
let mut best_collision: Option<(u8, bool, CollisionResponse)> = None;
for object_id in 1..=175 {
if !self.object_active[usize::from(object_id)] {
continue;
}
if self.claw.active && (12..=20).contains(&object_id) {
continue;
}
if let Some(wall) = WALLS.iter().find(|wall| wall.id == object_id) {
let segment = self.live_wall_segment(wall.id, wall.segment);
if let Some(response) = line_collision_response(
old_position,
velocity,
segment.start,
segment.end,
f64::from(wall.normal_rebound),
f64::from(wall.tangent_coupling),
) && best_collision
.is_none_or(|(_, _, closest)| response.progress < closest.progress)
{
best_collision = Some((wall.id, true, response));
}
}
let circle = PASSIVE_CIRCLES
.iter()
.chain(BUMPERS.iter())
.find(|circle| circle.id == object_id);
if let Some(circle) = circle
&& let Some(response) = circle_collision_response(
old_position,
velocity,
self.live_circle_center(circle.id, circle.center),
circle.contact_radius,
f64::from(circle.normal_rebound),
f64::from(circle.tangent_coupling),
f64::from(circle.normal_kick),
)
&& best_collision.is_none_or(|(_, _, closest)| response.progress < closest.progress)
let mut best_collision = self.find_static_collision(old_position, velocity, self.ball.spin);
let mut transferred_secondary_velocity = None;
if let Some(secondary) = self.secondary_ball {
let response = ball_collision_response(
old_position,
velocity,
self.ball.spin,
MilliVec::from_position(secondary.position),
MilliVec::from_velocity_per_second(secondary.velocity),
);
if let Some(response) = response
&& best_collision.is_none_or(|(_, _, closest)| {
response.surface_distance <= closest.surface_distance
})
{
best_collision = Some((circle.id, false, response));
transferred_secondary_velocity = Some(response.other_velocity);
best_collision = Some((
175,
false,
CollisionResponse {
surface_distance: response.surface_distance,
velocity: response.moving_velocity,
spin: response.moving_spin,
},
));
}
}
let (hit_wall, hit_circle) = if let Some((object_id, is_wall, response)) = best_collision {
velocity = response.velocity;
self.ball.spin = response.spin;
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
position = old_position.add(velocity);
self.last_collision_id = Some(object_id);
if object_id == 175
&& let (Some(secondary), Some(transferred)) =
(&mut self.secondary_ball, transferred_secondary_velocity)
{
secondary.velocity = transferred.to_velocity_per_second();
}
if is_wall {
(Some(object_id), None)
} else {
@@ -644,19 +646,17 @@ impl Game {
}
}
fn advance_secondary_ball(&mut self, events: &mut Vec<Event>) {
let Some(mut ball) = self.secondary_ball.take() else {
return;
};
let old_position = MilliVec::from_position(ball.position);
let mut velocity = MilliVec::from_velocity_per_second(ball.velocity);
velocity.y += GRAVITY_MILLI_PER_STEP;
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
self.apply_magnetic_fields(old_position, &mut velocity);
let mut best_collision: Option<(u8, bool, CollisionResponse)> = None;
fn find_static_collision(
&self,
old_position: MilliVec,
velocity: MilliVec,
spin: Real48,
) -> Option<(u8, bool, CollisionResponse)> {
let mut best = None;
for object_id in 1..=175 {
if !self.object_active[usize::from(object_id)] {
if !self.object_active[usize::from(object_id)]
|| self.claw.active && (12..=20).contains(&object_id)
{
continue;
}
if let Some(wall) = WALLS.iter().find(|wall| wall.id == object_id) {
@@ -666,12 +666,15 @@ impl Game {
velocity,
segment.start,
segment.end,
f64::from(wall.normal_rebound),
f64::from(wall.tangent_coupling),
) && best_collision
.is_none_or(|(_, _, closest)| response.progress < closest.progress)
{
best_collision = Some((wall.id, true, response));
CollisionMaterial::line(
f64::from(wall.normal_rebound),
f64::from(wall.tangent_coupling),
),
spin,
) && best.is_none_or(|(_, _, closest): (u8, bool, CollisionResponse)| {
response.surface_distance <= closest.surface_distance
}) {
best = Some((wall.id, true, response));
}
}
let circle = PASSIVE_CIRCLES
@@ -684,32 +687,67 @@ impl Game {
velocity,
self.live_circle_center(circle.id, circle.center),
circle.contact_radius,
f64::from(circle.normal_rebound),
f64::from(circle.tangent_coupling),
f64::from(circle.normal_kick),
CollisionMaterial::circle(
f64::from(circle.normal_rebound),
f64::from(circle.tangent_coupling),
f64::from(circle.normal_kick),
),
spin,
)
&& best_collision.is_none_or(|(_, _, closest)| response.progress < closest.progress)
&& best.is_none_or(|(_, _, closest)| {
response.surface_distance <= closest.surface_distance
})
{
best_collision = Some((circle.id, false, response));
best = Some((circle.id, false, response));
}
}
best
}
if let Some(response) = circle_collision_response(
fn advance_secondary_ball(&mut self, events: &mut Vec<Event>) {
let Some(mut ball) = self.secondary_ball.take() else {
return;
};
let old_position = MilliVec::from_position(ball.position);
let mut velocity = MilliVec::from_velocity_per_second(ball.velocity);
velocity.y += GRAVITY_MILLI_PER_STEP;
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
self.apply_magnetic_fields(old_position, &mut velocity);
let mut best_collision = self.find_static_collision(old_position, velocity, ball.spin);
let mut transferred_primary_velocity = None;
if let Some(response) = ball_collision_response(
old_position,
velocity,
self.ball.position,
17.0,
0.9,
0.0,
0.0,
) && best_collision.is_none_or(|(_, _, closest)| response.progress < closest.progress)
ball.spin,
MilliVec::from_position(self.ball.position),
MilliVec::from_velocity_per_second(self.ball.velocity),
) && best_collision.is_none_or(|(_, _, closest)| {
response.surface_distance <= closest.surface_distance
})
{
best_collision = Some((174, false, response));
transferred_primary_velocity = Some(response.other_velocity);
best_collision = Some((
174,
false,
CollisionResponse {
surface_distance: response.surface_distance,
velocity: response.moving_velocity,
spin: response.moving_spin,
},
));
}
let mut hit = None;
if let Some((object_id, is_wall, response)) = best_collision {
velocity = response.velocity;
ball.spin = response.spin;
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
hit = Some((object_id, is_wall));
if object_id == 174
&& let Some(transferred) = transferred_primary_velocity
{
self.ball.velocity = transferred.to_velocity_per_second();
}
}
ball.position = old_position.add(velocity).to_position();
ball.velocity = velocity.to_velocity_per_second();
@@ -879,7 +917,7 @@ impl Game {
.y
.wrapping_sub(current_position.y)
.wrapping_sub(2_000);
let surface_distance = (f64::from(dx).hypot(f64::from(dy))).round() as i32 - radius;
let surface_distance = milli_distance(MilliVec { x: dx, y: dy }) - radius;
let contact_index = usize::from(record_id);
let contact = self.record_contacts[contact_index];
let ball_count = if self.secondary_ball.is_some() { 2 } else { 1 };
@@ -905,8 +943,9 @@ impl Game {
self.capture_age.wrapping_add(5)
};
} else {
velocity.x = (f64::from(velocity.x) * 0.9).round() as i32;
velocity.y = (f64::from(velocity.y) * 0.9).round() as i32;
let damping = Real48::from_bytes([0x80, 0x66, 0x66, 0x66, 0x66, 0x66]);
velocity.x = Real48::from_i32(velocity.x).multiply(damping).round_i32();
velocity.y = Real48::from_i32(velocity.y).multiply(damping).round_i32();
velocity.x = if center.x > current_position.x {
velocity.x.wrapping_add(150)
} else {
@@ -1036,6 +1075,7 @@ impl Game {
position: LAUNCHER_POSITION,
velocity: vec2(0.0, -300.0),
in_launcher: false,
spin: Real48::ZERO,
});
self.multiball_state = MultiballState::Unavailable;
}
@@ -1087,13 +1127,14 @@ impl Game {
}
}
#[allow(clippy::cast_possible_truncation)]
fn randomize_trigger_velocity(&mut self) {
let mut velocity = MilliVec::from_velocity_per_second(self.ball.velocity);
let x_factor = 1.03 - self.random.unit_interval() * 0.08;
let y_factor = 1.03 - self.random.unit_interval() * 0.08;
velocity.x = (f64::from(velocity.x) * x_factor).round() as i32;
velocity.y = (f64::from(velocity.y) * y_factor).round() as i32;
let offset = Real48::from_bytes([0x81, 0x71, 0x3d, 0x0a, 0xd7, 0x03]);
let span = Real48::from_bytes([0x7d, 0x71, 0x3d, 0x0a, 0xd7, 0x23]);
let x_factor = offset.subtract(self.random.real48().multiply(span));
let y_factor = offset.subtract(self.random.real48().multiply(span));
velocity.x = Real48::from_i32(velocity.x).multiply(x_factor).round_i32();
velocity.y = Real48::from_i32(velocity.y).multiply(y_factor).round_i32();
self.ball.velocity = velocity.to_velocity_per_second();
}
@@ -1106,7 +1147,6 @@ impl Game {
self.launcher_velocity_milli = 0;
}
#[allow(clippy::cast_possible_truncation)]
fn apply_magnetic_fields(&mut self, old_position: MilliVec, velocity: &mut MilliVec) {
for (object_id, min_x, min_y, max_x, max_y) in [
(6, 143_000, 421_000, 169_000, 452_000),
@@ -1128,9 +1168,17 @@ impl Game {
{
continue;
}
velocity.x = (f64::from(velocity.x) * 0.9).round() as i32;
let vertical_factor = 1.0 - self.random.unit_interval() * 0.3;
velocity.y = -(f64::from(MAXIMUM_SPEED_MILLI_PER_STEP) * vertical_factor).round() as i32;
let damping = Real48::from_bytes([0x80, 0x66, 0x66, 0x66, 0x66, 0x66]);
velocity.x = Real48::from_i32(velocity.x).multiply(damping).round_i32();
let vertical_factor = Real48::from_i32(1).subtract(
self.random
.real48()
.multiply(Real48::from_bytes([0x7f, 0x9a, 0x99, 0x99, 0x99, 0x19])),
);
velocity.y = Real48::from_i32(MAXIMUM_SPEED_MILLI_PER_STEP)
.multiply(vertical_factor)
.round_i32()
.wrapping_neg();
if old_position.add(*velocity).y < min_y {
self.object_active[object_id] = false;
}
@@ -1182,6 +1230,7 @@ impl Game {
self.claw.ball_suspended = true;
self.claw.frame_accumulator = 0.0;
self.ball.velocity = Vec2::ZERO;
self.ball.spin = Real48::ZERO;
events.push(Event::ClawCapture);
events.push(Event::Sound(2015));
}
@@ -1197,6 +1246,7 @@ impl Game {
self.claw.ball_suspended = true;
self.claw.frame_accumulator = 0.0;
self.ball.velocity = Vec2::ZERO;
self.ball.spin = Real48::ZERO;
events.push(Event::ClawCapture);
}
@@ -1367,7 +1417,14 @@ fn apply_flipper_response_to_ball(ball: &mut Ball, delta: i32, side: FlipperSide
let position = MilliVec::from_position(ball.position);
let velocity = MilliVec::from_velocity_per_second(ball.velocity);
if let Some(response) =
moving_flipper_response(position, velocity, delta, side, 0.5, MAXIMUM_SPEED_MILLI_PER_STEP)
moving_flipper_response(
position,
velocity,
delta,
side,
Real48::from_bytes([0x80, 0, 0, 0, 0, 0]),
MAXIMUM_SPEED_MILLI_PER_STEP,
)
{
ball.position = position.add(response.movement).to_position();
ball.velocity = response.velocity.to_velocity_per_second();