fix(physics): resolve the earliest substep contact

Return exact path progress with each fixed-point line and circle candidate.
Evaluate the complete recovered physical object set and apply the nearest
contact, matching the selection logic in the original collision dispatcher
instead of stopping at the lowest matching object id.

This prevents dense assemblies from choosing a later wall or circle merely
because its record appears earlier in the table.

Test Plan:
- `cargo test --all-targets` -- 45 passed
- `cargo clippy --all-targets -- -D warnings` -- passed
- `cargo build --profile production` -- passed
- collision-progress ordering test -- passed
- `git diff --cached --check` -- passed
This commit is contained in:
2026-08-22 21:15:45 +02:00
parent bf5b855f44
commit 20e751de80
4 changed files with 174 additions and 89 deletions
+5
View File
@@ -150,3 +150,8 @@ the associated magnetic gate; there is no recovered 1,500-per-target plus
5,000 completion rule. Records 140-147 carry their exact 1,000 through 3,000
scores and use the same enter-once contact bit, which clears after the ball
leaves their radius.
The collision scan at `1000:c79c` does not resolve the first matching object id.
It retains the smallest path-progress value while traversing all 175 records,
then applies that response from the previous position. The Rust fixed-point
solver now follows that ordering for recovered type-1 and type-2 objects.
+1 -1
View File
@@ -24,7 +24,7 @@ implementation.
| Help and languages | Exact | Original resource images 1001-1005 are displayed directly. |
| Playfield collision layout | Recovered | All 109 active type-2 line objects and 40 static active type-1 circles are transcribed from the original 175-object registration table. The registration routine converts its sideways inputs with `screen = (y, x - 20)` and accumulates explicitly relative objects. Type-2 records retain every recovered Real48 normal/tangent response pair and registered one-sided orientation. Type-1 records retain their swept-circle radius, radial rebound, tangent coupling, and bumper kick. Each flipper uses its exact two line records plus moving tip circle in both positions. The upward edge transfer matches three live left-flipper probes and is mirrored on the right; the complete return-edge arithmetic remains pending. Object 174 is omitted because the original overwrites it with the live ball every frame. |
| Ball launcher | Recovered | The initial 32-bit fixed-point coordinates decode to `(325, 413)` in the right shooter lane. The port reproduces the initial `-375` millipixel Down event, 650 ms repeat delay, 40 ms repeats, release impulse, and randomized clamp below the original `-3800` maximum. This replaces the former guessed 330-430 px/s shot. |
| Physics arithmetic | Partly recovered | Production movement uses the original 10 ms millipixel substep, `+15` vertical acceleration, `3800` speed bound, point-path type-2 intersection, one-sided line response, swept type-1 circle response, and swept non-physical sensor contacts. Live probes cover ordinary rails, ordinary circles, a kicked bumper, and upward flipper transfer. Closest-contact selection, persistent physical-contact bookkeeping, type-3 lock holes, and return-edge flipper transfer remain pending. |
| Physics arithmetic | Partly recovered | Production movement uses the original 10 ms millipixel substep, `+15` vertical acceleration, `3800` speed bound, point-path type-2 intersection, one-sided line response, swept type-1 circle response, and swept non-physical sensor contacts. It evaluates all records and applies the earliest contact along the substep. Live probes cover ordinary rails, ordinary circles, a kicked bumper, and upward flipper transfer. Persistent physical-contact bookkeeping, type-3 lock holes, and return-edge flipper transfer remain pending. |
| Rules | Partly recovered | Player count, controls, increasing bumper value, nine-part TDK diamond, permanent double scoring, KByte media progression, and media extra balls follow original help/code paths. Claw contact and all initially active type-4 targets now use recovered records. The top three targets score 500 each and independently enable a magnetic gate for ten original timer callbacks, replacing the inferred three-target bonus. Wheel holes, ball lock, several target-bank completions, and magnetic save routing still require full rule restoration. The claw state machine and release table are readable and terminal 18 has live differential evidence; terminals 1, 6, and 7 still need equivalent live coverage. |
| Numeric scoring | Partly inferred | Visible 2000-6000 target values and recovered registration values are preserved. Some bumper, bank-completion, robot, wheel, lock, and media thresholds are best-evidence reconstructions because the decompiler did not recover meaningful names or a clean rule table. |
| High scores | Compatible import | The original 276-byte table is decoded as ten `IWIK`-XOR-obfuscated little-endian scores plus ten 22-byte names, sorted, then migrated to portable JSON. |
+28 -24
View File
@@ -1,8 +1,8 @@
use crate::{
geometry::{Segment, closest_point},
original_physics::{
GRAVITY_MILLI_PER_STEP, MAXIMUM_SPEED_MILLI_PER_STEP, MilliVec, STEP_SECONDS,
collide_with_circle, collide_with_line, path_intersects_circle,
CollisionResponse, GRAVITY_MILLI_PER_STEP, MAXIMUM_SPEED_MILLI_PER_STEP, MilliVec,
STEP_SECONDS, circle_collision_response, line_collision_response, path_intersects_circle,
},
table::{BUMPERS, PASSIVE_CIRCLES, TARGET_SENSORS, WALLS},
};
@@ -405,53 +405,57 @@ impl Game {
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
let movement_velocity = velocity;
let mut position = old_position.add(velocity);
let mut hit_wall = None;
let mut hit_circle = None;
let mut best_collision: Option<(u8, bool, CollisionResponse)> = None;
for object_id in 1..=175 {
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);
let mut response = velocity;
if collide_with_line(
if let Some(response) = line_collision_response(
old_position,
&mut response,
velocity,
segment.start,
segment.end,
f64::from(wall.normal_rebound),
f64::from(wall.tangent_coupling),
) {
velocity = response;
position = old_position.add(velocity);
hit_wall = Some(wall.id);
self.last_collision_id = Some(wall.id);
break;
) && 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 mut response = velocity;
if collide_with_circle(
if let Some(circle) = circle
&& let Some(response) = circle_collision_response(
old_position,
&mut response,
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),
) {
velocity = response;
position = old_position.add(velocity);
hit_circle = Some(circle.id);
self.last_collision_id = Some(circle.id);
break;
}
)
&& best_collision.is_none_or(|(_, _, closest)| response.progress < closest.progress)
{
best_collision = Some((circle.id, false, response));
}
}
let (hit_wall, hit_circle) = if let Some((object_id, is_wall, response)) = best_collision {
velocity = response.velocity;
position = old_position.add(velocity);
self.last_collision_id = Some(object_id);
if is_wall {
(Some(object_id), None)
} else {
(None, Some(object_id))
}
} else {
(None, None)
};
self.ball.position = position.to_position();
self.ball.velocity = velocity.to_velocity_per_second();
+140 -64
View File
@@ -14,6 +14,12 @@ pub struct MilliVec {
pub y: i32,
}
#[derive(Clone, Copy, Debug)]
pub struct CollisionResponse {
pub progress: f64,
pub velocity: MilliVec,
}
impl MilliVec {
pub fn from_position(position: Vec2) -> Self {
Self {
@@ -76,21 +82,21 @@ const fn subtract(left: MilliVec, right: MilliVec) -> MilliVec {
/// Reports whether the ball-center path crosses the registered line segment.
/// A contact at the old position is excluded, matching the shooter-stop path:
/// the waiting ball starts on object 25 and must be able to launch away from it.
fn paths_intersect(
fn path_intersection_progress(
old_position: MilliVec,
velocity: MilliVec,
line_start: MilliVec,
line_end: MilliVec,
) -> bool {
) -> Option<f64> {
let line = subtract(line_end, line_start);
let from_ball = subtract(line_start, old_position);
let denominator = cross(velocity, line);
if denominator == 0 {
return false;
return None;
}
let path_numerator = cross(from_ball, line);
let line_numerator = cross(from_ball, velocity);
if denominator > 0 {
let intersects = if denominator > 0 {
path_numerator > 0
&& path_numerator <= denominator
&& line_numerator >= 0
@@ -100,29 +106,28 @@ fn paths_intersect(
&& path_numerator >= denominator
&& line_numerator <= 0
&& line_numerator >= denominator
}
};
intersects.then(|| path_numerator as f64 / denominator as f64)
}
/// Apply the original type-2 response in the registered segment's basis.
pub fn collide_with_line(
/// Calculate the original type-2 response in the registered segment's basis.
pub fn line_collision_response(
old_position: MilliVec,
velocity: &mut MilliVec,
velocity: MilliVec,
line_start: Vec2,
line_end: Vec2,
normal_rebound: f64,
tangent_coupling: f64,
) -> bool {
) -> Option<CollisionResponse> {
let start = MilliVec::from_position(line_start);
let end = MilliVec::from_position(line_end);
if !paths_intersect(old_position, *velocity, start, end) {
return false;
}
let progress = path_intersection_progress(old_position, velocity, start, end)?;
let line_x = f64::from(end.x - start.x);
let line_y = f64::from(end.y - start.y);
let length = line_x.hypot(line_y);
if length == 0.0 {
return false;
return None;
}
let tangent_x = line_x / length;
let tangent_y = line_y / length;
@@ -132,18 +137,110 @@ pub fn collide_with_line(
let incoming_y = f64::from(velocity.y);
let normal_speed = incoming_x * normal_x + incoming_y * normal_y;
if normal_speed <= 0.0 {
return false;
return None;
}
let tangent_speed = incoming_x * tangent_x + incoming_y * tangent_y;
let outgoing_normal = -normal_rebound * normal_speed;
let outgoing_tangent = tangent_speed + tangent_coupling * normal_speed;
velocity.x = (normal_x * outgoing_normal + tangent_x * outgoing_tangent).round() as i32;
velocity.y = (normal_y * outgoing_normal + tangent_y * outgoing_tangent).round() as i32;
Some(CollisionResponse {
progress,
velocity: MilliVec {
x: (normal_x * outgoing_normal + tangent_x * outgoing_tangent).round() as i32,
y: (normal_y * outgoing_normal + tangent_y * outgoing_tangent).round() as i32,
},
})
}
#[cfg(test)]
pub fn collide_with_line(
old_position: MilliVec,
velocity: &mut MilliVec,
line_start: Vec2,
line_end: Vec2,
normal_rebound: f64,
tangent_coupling: f64,
) -> bool {
let Some(response) = line_collision_response(
old_position,
*velocity,
line_start,
line_end,
normal_rebound,
tangent_coupling,
) else {
return false;
};
*velocity = response.velocity;
true
}
/// Apply the original type-1 circle response to a path entering the circle.
/// Calculate the original type-1 circle response for a path entering it.
pub fn circle_collision_response(
old_position: MilliVec,
velocity: MilliVec,
center: Vec2,
radius: f32,
normal_rebound: f64,
tangent_coupling: f64,
normal_kick: f64,
) -> Option<CollisionResponse> {
let center = MilliVec::from_position(center);
let radius_milli = (radius * 1_000.0).round() as i32;
let from_center = subtract(old_position, center);
let radius_squared = i64::from(radius_milli).pow(2);
let old_distance_squared = i64::from(from_center.x).pow(2) + i64::from(from_center.y).pow(2);
if old_distance_squared <= radius_squared {
return None;
}
let vx = f64::from(velocity.x);
let vy = f64::from(velocity.y);
let offset_x = f64::from(from_center.x);
let offset_y = f64::from(from_center.y);
let quadratic_a = vx * vx + vy * vy;
if quadratic_a == 0.0 {
return None;
}
let quadratic_b = 2.0 * (offset_x * vx + offset_y * vy);
let quadratic_c = old_distance_squared as f64 - f64::from(radius_milli).powi(2);
let discriminant = quadratic_b * quadratic_b - 4.0 * quadratic_a * quadratic_c;
if discriminant < 0.0 {
return None;
}
let progress = (-quadratic_b - discriminant.sqrt()) / (2.0 * quadratic_a);
if !(0.0 < progress && progress <= 1.0) {
return None;
}
let hit_x = offset_x + vx * progress;
let hit_y = offset_y + vy * progress;
let hit_length = hit_x.hypot(hit_y);
if hit_length == 0.0 {
return None;
}
let normal_x = hit_x / hit_length;
let normal_y = hit_y / hit_length;
let tangent_x = normal_y;
let tangent_y = -normal_x;
let normal_speed = vx * normal_x + vy * normal_y;
if normal_speed >= 0.0 {
return None;
}
let tangent_speed = vx * tangent_x + vy * tangent_y;
let outgoing_normal =
-normal_rebound * normal_speed + normal_kick * f64::from(MAXIMUM_SPEED_MILLI_PER_STEP);
let outgoing_tangent = tangent_speed - tangent_coupling * normal_speed;
Some(CollisionResponse {
progress,
velocity: MilliVec {
x: (normal_x * outgoing_normal + tangent_x * outgoing_tangent).round() as i32,
y: (normal_y * outgoing_normal + tangent_y * outgoing_tangent).round() as i32,
},
})
}
#[cfg(test)]
pub fn collide_with_circle(
old_position: MilliVec,
velocity: &mut MilliVec,
@@ -153,54 +250,18 @@ pub fn collide_with_circle(
tangent_coupling: f64,
normal_kick: f64,
) -> bool {
let center = MilliVec::from_position(center);
let radius_milli = (radius * 1_000.0).round() as i32;
let from_center = subtract(old_position, center);
let radius_squared = i64::from(radius_milli).pow(2);
let old_distance_squared = i64::from(from_center.x).pow(2) + i64::from(from_center.y).pow(2);
if old_distance_squared <= radius_squared {
let Some(response) = circle_collision_response(
old_position,
*velocity,
center,
radius,
normal_rebound,
tangent_coupling,
normal_kick,
) else {
return false;
}
let vx = f64::from(velocity.x);
let vy = f64::from(velocity.y);
let offset_x = f64::from(from_center.x);
let offset_y = f64::from(from_center.y);
let quadratic_a = vx * vx + vy * vy;
if quadratic_a == 0.0 {
return false;
}
let quadratic_b = 2.0 * (offset_x * vx + offset_y * vy);
let quadratic_c = old_distance_squared as f64 - f64::from(radius_milli).powi(2);
let discriminant = quadratic_b * quadratic_b - 4.0 * quadratic_a * quadratic_c;
if discriminant < 0.0 {
return false;
}
let progress = (-quadratic_b - discriminant.sqrt()) / (2.0 * quadratic_a);
if !(0.0 < progress && progress <= 1.0) {
return false;
}
let hit_x = offset_x + vx * progress;
let hit_y = offset_y + vy * progress;
let hit_length = hit_x.hypot(hit_y);
if hit_length == 0.0 {
return false;
}
let normal_x = hit_x / hit_length;
let normal_y = hit_y / hit_length;
let tangent_x = normal_y;
let tangent_y = -normal_x;
let normal_speed = vx * normal_x + vy * normal_y;
if normal_speed >= 0.0 {
return false;
}
let tangent_speed = vx * tangent_x + vy * tangent_y;
let outgoing_normal =
-normal_rebound * normal_speed + normal_kick * f64::from(MAXIMUM_SPEED_MILLI_PER_STEP);
let outgoing_tangent = tangent_speed - tangent_coupling * normal_speed;
velocity.x = (normal_x * outgoing_normal + tangent_x * outgoing_tangent).round() as i32;
velocity.y = (normal_y * outgoing_normal + tangent_y * outgoing_tangent).round() as i32;
};
*velocity = response.velocity;
true
}
@@ -335,6 +396,21 @@ mod tests {
));
}
#[test]
fn collision_progress_orders_contacts_along_the_substep() {
let old = MilliVec::default();
let velocity = MilliVec { x: 10_000, y: 0 };
let near =
line_collision_response(old, velocity, vec2(2.0, 1.0), vec2(2.0, -1.0), 0.6, 0.1)
.expect("near rail should be crossed");
let far = line_collision_response(old, velocity, vec2(8.0, 1.0), vec2(8.0, -1.0), 0.6, 0.1)
.expect("far rail should be crossed");
assert!((near.progress - 0.2).abs() < f64::EPSILON);
assert!((far.progress - 0.8).abs() < f64::EPSILON);
assert!(near.progress < far.progress);
}
#[test]
fn ordinary_circle_matches_the_live_object_155_probe() {
let old = MilliVec {