fix(flippers): match original moving-hit geometry

The clone's moving-flipper gate used the cross-product operands in the
opposite order, mirroring and narrowing the hit wedge. Right-flipper release
also used record 81's first endpoint even though the original reads its second
endpoint. Correct both geometry paths, retain the recovered swept tip bounds,
and add live-binary boundary vectors plus a dense transition regression.

Test Plan:
- `cargo test --workspace --all-targets --all-features` -- passed (134 tests)
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` -- passed
- `cargo build --profile production` -- passed
- `LSAN_OPTIONS=detect_leaks=0 ASAN_OPTIONS=detect_leaks=0 bash original/tools/test_reconstructed_c.sh` -- passed
- `python3 original/tools/audit_reconstruction.py --require-complete` -- passed
- `git diff --cached --check` -- passed
This commit is contained in:
2026-08-29 09:15:25 +02:00
parent faf66f1ac0
commit fa3f168467
5 changed files with 517 additions and 55 deletions
+329 -38
View File
@@ -277,6 +277,13 @@ fn trigger_broadphase_contains(predicted: MilliVec, center: Vec2, radius: f32) -
&& predicted.y <= center.y.wrapping_add(margin)
}
const fn movement_from_prediction(old_position: MilliVec, predicted: MilliVec) -> MilliVec {
MilliVec {
x: predicted.x.wrapping_sub(old_position.x),
y: predicted.y.wrapping_sub(old_position.y),
}
}
#[derive(Clone, Copy, Debug)]
struct RuleState {
wheel_holes: [bool; 5],
@@ -777,6 +784,7 @@ impl Game {
let mut velocity = MilliVec::from_velocity_per_second(ball.velocity);
velocity.y += GRAVITY_MILLI_PER_STEP;
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
let speed_before_collision = milli_distance(velocity);
if predicted_outside_board(old_position.add(velocity)) {
self.drain(events);
return true;
@@ -831,7 +839,6 @@ impl Game {
velocity = response.velocity;
ball.spin = response.spin;
auxiliary_fired = response.auxiliary_fired;
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
self.last_collision_id = Some(object_id);
if object_id == 175
&& let (Some(secondary), Some(transferred)) =
@@ -847,6 +854,13 @@ impl Game {
} else {
(None, None)
};
if hit_wall == Some(25) && speed_before_collision >= 1_000 {
velocity.x = 0;
}
if hit_wall == Some(25) && speed_before_collision < 1_000 {
velocity.x = 0;
velocity.y = 0;
}
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
ball.position = old_position.add(velocity).to_position();
ball.velocity = velocity.to_velocity_per_second();
@@ -865,9 +879,7 @@ impl Game {
}
return true;
}
if wall_id == 25
&& i64::from(velocity.x).pow(2) + i64::from(velocity.y).pow(2) < 1_000_i64.pow(2)
{
if wall_id == 25 && speed_before_collision < 1_000 {
self.ball = Ball::default();
self.launcher_charge = 0.0;
self.launcher_was_down = false;
@@ -994,30 +1006,20 @@ impl Game {
self.retain_static_range(old_position, predicted, *velocity, 134..=139, &mut best);
if old_position.y < 250_000 {
ball.velocity = velocity.to_velocity_per_second();
let predicted_velocity = MilliVec {
x: predicted.x.wrapping_sub(old_position.x),
y: predicted.y.wrapping_sub(old_position.y),
};
if !self.tilted
&& (TARGET_SENSORS.into_iter().any(|sensor| {
self.object_active[usize::from(sensor.id)]
&& trigger_broadphase_contains(predicted, sensor.center, sensor.radius)
}) || self.object_active[usize::from(EFFECT_SENSOR.id)]
&& trigger_broadphase_contains(
predicted,
EFFECT_SENSOR.center,
EFFECT_SENSOR.radius,
))
{
if self.type4_broadphase_contains(predicted, 140..=147) {
self.special_hole_gate = SpecialHoleGate::Enabled;
}
self.check_target_sensors(
let target_triggered = self.check_target_sensors(
ball,
old_position,
predicted_velocity,
movement_from_prediction(old_position, predicted),
140..=147,
events,
);
*velocity = MilliVec::from_velocity_per_second(ball.velocity);
if target_triggered {
predicted = old_position.add(*velocity);
}
if let Some(completed_action) = self.check_special_hole(
ball,
ball_number,
@@ -1029,21 +1031,29 @@ impl Game {
) {
action = completed_action;
}
self.check_effect_sensor(
*velocity = MilliVec::from_velocity_per_second(ball.velocity);
let effect_triggered = self.check_effect_sensor(
ball,
ball_number,
old_position,
predicted_velocity,
movement_from_prediction(old_position, predicted),
events,
);
self.check_target_sensors(
*velocity = MilliVec::from_velocity_per_second(ball.velocity);
if effect_triggered {
predicted = old_position.add(*velocity);
}
let target_triggered = self.check_target_sensors(
ball,
old_position,
predicted_velocity,
movement_from_prediction(old_position, predicted),
150..=152,
events,
);
*velocity = MilliVec::from_velocity_per_second(ball.velocity);
if target_triggered {
predicted = old_position.add(*velocity);
}
}
if self.apply_magnetic_record(153, old_position, velocity) {
@@ -1129,15 +1139,15 @@ impl Game {
if let Some(circle) = circle
&& circle.layer_mask & layer != 0
&& {
let center = MilliVec::from_position(
let (minimum, maximum) = self.live_circle_bounds(
circle.id,
self.live_circle_center(circle.id, circle.center),
circle.contact_radius,
);
let margin =
MilliVec::from_position(vec2(circle.contact_radius + 5.0, 0.0)).x;
predicted.x >= center.x.wrapping_sub(margin)
&& predicted.x <= center.x.wrapping_add(margin)
&& predicted.y >= center.y.wrapping_sub(margin)
&& predicted.y <= center.y.wrapping_add(margin)
predicted.x >= minimum.x
&& predicted.x <= maximum.x
&& predicted.y >= minimum.y
&& predicted.y <= maximum.y
}
&& let Some(candidate) = circle_collision_candidate_at(
old_position,
@@ -1163,6 +1173,19 @@ impl Game {
best
}
fn type4_broadphase_contains(
&self,
predicted: MilliVec,
record_ids: std::ops::RangeInclusive<u8>,
) -> bool {
!self.tilted
&& TARGET_SENSORS.into_iter().any(|sensor| {
record_ids.contains(&sensor.id)
&& self.object_active[usize::from(sensor.id)]
&& trigger_broadphase_contains(predicted, sensor.center, sensor.radius)
})
}
#[cfg(test)]
fn find_static_collision_candidate(
&self,
@@ -1197,6 +1220,7 @@ impl Game {
let mut velocity = MilliVec::from_velocity_per_second(ball.velocity);
velocity.y += GRAVITY_MILLI_PER_STEP;
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
let speed_before_collision = milli_distance(velocity);
if predicted_outside_board(old_position.add(velocity)) {
self.score_mode = ScoreMode::Normal;
events.push(Event::Drain);
@@ -1252,7 +1276,6 @@ impl Game {
velocity = response.velocity;
ball.spin = response.spin;
auxiliary_fired = response.auxiliary_fired;
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
hit = Some((object_id, is_wall));
if object_id == 174
&& let Some(transferred) = transferred_primary_velocity
@@ -1260,6 +1283,13 @@ impl Game {
self.ball.velocity = transferred.to_velocity_per_second();
}
}
if hit == Some((25, true)) && speed_before_collision >= 1_000 {
velocity.x = 0;
}
if hit == Some((25, true)) && speed_before_collision < 1_000 {
velocity.x = 0;
velocity.y = 0;
}
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
ball.position = old_position.add(velocity).to_position();
ball.velocity = velocity.to_velocity_per_second();
@@ -1269,6 +1299,10 @@ impl Game {
return true;
}
if let Some((object_id, true)) = hit {
if object_id == 25 && speed_before_collision < 1_000 {
self.score_mode = ScoreMode::Normal;
return true;
}
self.apply_wall_rule(object_id, events);
if auxiliary_fired && matches!(object_id, 55 | 74 | 107) {
events.push(Event::Sound(2019));
@@ -1796,9 +1830,9 @@ impl Game {
old_position: MilliVec,
movement_velocity: MilliVec,
events: &mut Vec<Event>,
) {
) -> bool {
if self.tilted {
return;
return false;
}
let effect_index = usize::from(EFFECT_SENSOR.id);
if self.object_active[effect_index] {
@@ -1827,6 +1861,7 @@ impl Game {
self.players[self.current_player].secondary_score = 0;
}
7 if ball_number != 2
&& self.secondary_ball.is_none()
&& matches!(
self.multiball_state,
MultiballState::Ready | MultiballState::Active
@@ -1847,8 +1882,10 @@ impl Game {
self.target_effect = 0;
self.object_active[effect_index] = false;
self.randomize_trigger_velocity(ball);
return true;
}
}
false
}
fn check_target_sensors(
@@ -1858,10 +1895,11 @@ impl Game {
movement_velocity: MilliVec,
record_ids: std::ops::RangeInclusive<u8>,
events: &mut Vec<Event>,
) {
) -> bool {
if self.tilted {
return;
return false;
}
let mut triggered = false;
for sensor in TARGET_SENSORS {
if !record_ids.contains(&sensor.id) {
continue;
@@ -1888,7 +1926,9 @@ impl Game {
self.object_active[field_id] = true;
}
self.randomize_trigger_velocity(ball);
triggered = true;
}
triggered
}
fn randomize_trigger_velocity(&mut self, ball: &mut Ball) {
@@ -1909,6 +1949,8 @@ impl Game {
{
self.panel_frame = Some(0);
}
self.tilted = false;
self.tilt_counter = 0;
self.ball = Ball::default();
self.launcher_charge = 0.0;
self.launcher_was_down = false;
@@ -2230,6 +2272,52 @@ impl Game {
_ => resting,
}
}
fn live_circle_bounds(
&self,
object_id: u8,
center: Vec2,
contact_radius: f32,
) -> (MilliVec, MilliVec) {
// 1000:8b0d moves the tip records through an asymmetric swept box;
// their collision broadphase is not center +/- (radius + 5).
if object_id == 67 && self.flippers.left_raised {
return (
MilliVec {
x: 118_000,
y: 362_000,
},
MilliVec {
x: 150_000,
y: 476_000,
},
);
}
if object_id == 82 && self.flippers.right_raised {
return (
MilliVec {
x: 167_000,
y: 362_000,
},
MilliVec {
x: 191_000,
y: 476_000,
},
);
}
let center = MilliVec::from_position(center);
let margin = MilliVec::from_position(vec2(contact_radius + 5.0, 0.0)).x;
(
MilliVec {
x: center.x.wrapping_sub(margin),
y: center.y.wrapping_sub(margin),
},
MilliVec {
x: center.x.wrapping_add(margin),
y: center.y.wrapping_add(margin),
},
)
}
}
fn claw_release(frame: u8) -> (Vec2, Vec2) {
@@ -2360,6 +2448,47 @@ mod tests {
assert!(game.ball.in_launcher);
}
#[test]
fn each_static_record_reaches_the_production_scanner() {
let mut missed_walls = Vec::new();
for wall in WALLS {
let direction = wall.segment.end - wall.segment.start;
let normal = vec2(-direction.y, direction.x).normalize();
let midpoint = (wall.segment.start + wall.segment.end) * 0.5;
let mut game = Game::new(1);
game.object_active.fill(false);
game.object_active[usize::from(wall.id)] = true;
game.ball.in_launcher = false;
game.ball.position = midpoint - normal;
game.ball.velocity = MilliVec::from_velocity_per_second(normal * 200.0)
.to_velocity_per_second();
game.fixed_update(&mut Vec::new());
if game.last_collision_id != Some(wall.id) {
missed_walls.push(wall.id);
}
}
let mut missed_circles = Vec::new();
for circle in PASSIVE_CIRCLES.iter().chain(BUMPERS.iter()) {
let mut game = Game::new(1);
game.object_active.fill(false);
game.object_active[usize::from(circle.id)] = true;
game.ball.in_launcher = false;
game.ball.position = circle.center + vec2(circle.contact_radius + 1.0, 0.0);
game.ball.velocity = MilliVec { x: -2_000, y: 0 }.to_velocity_per_second();
game.fixed_update(&mut Vec::new());
if game.last_collision_id != Some(circle.id) {
missed_circles.push(circle.id);
}
}
assert!(missed_walls.is_empty(), "missed production wall records: {missed_walls:?}");
assert!(
missed_circles.is_empty(),
"missed production circle records: {missed_circles:?}"
);
}
#[test]
fn detail_timer_batches_the_original_number_of_substeps() {
for (detail, interval, substeps) in [
@@ -2565,7 +2694,7 @@ mod tests {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = Vec2::new(325.0, 410.0);
game.ball.velocity = Vec2::new(0.0, 180.0);
game.ball.velocity = Vec2::new(0.0, 80.0);
for _ in 0..8 {
game.update(1.0 / 120.0, 5, Controls::default());
@@ -2579,6 +2708,36 @@ mod tests {
assert_eq!(game.ball.velocity, Vec2::ZERO);
}
#[test]
fn fast_shooter_stop_contact_bounces_without_rearming() {
let mut game = Game::new(1);
game.object_active.fill(false);
game.object_active[25] = true;
game.ball.in_launcher = false;
game.ball.position = Vec2::new(320.0, 412.0);
game.ball.velocity = MilliVec { x: 500, y: 1_000 }.to_velocity_per_second();
assert!(game.fixed_update(&mut Vec::new()));
assert!(!game.ball.in_launcher);
assert!(game.ball.velocity.y < 0.0);
assert_eq!(MilliVec::from_velocity_per_second(game.ball.velocity).x, 0);
assert_eq!(MilliVec::from_position(game.ball.position).x, 320_000);
}
#[test]
fn ball_reset_clears_tilt_state_before_the_next_launch() {
let mut game = Game::new(1);
game.tilted = true;
game.tilt_counter = 39;
game.reset_ball_to_launcher();
assert!(!game.tilted);
assert_eq!(game.tilt_counter, 0);
assert!(game.ball.in_launcher);
}
#[test]
fn ninth_diamond_and_followup_banks_match_original_awards() {
let mut game = Game::new(1);
@@ -2866,6 +3025,41 @@ mod tests {
);
}
#[test]
fn type_four_motion_response_refreshes_the_remaining_prediction() {
let sensor = TARGET_SENSORS
.into_iter()
.find(|sensor| sensor.id == 150)
.expect("record 150 must be present");
let mut game = Game::new_with_seed(1, 7);
game.object_active.fill(false);
game.object_active[usize::from(sensor.id)] = true;
let old_position = MilliVec::from_position(sensor.center - vec2(1.0, 0.0));
let mut ball = Ball {
position: old_position.to_position(),
velocity: MilliVec { x: 100, y: 0 }.to_velocity_per_second(),
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
};
let initial_velocity = MilliVec::from_velocity_per_second(ball.velocity);
let mut velocity = initial_velocity;
let mut events = Vec::new();
let (_, _, predicted) = game.scan_ordered_records_for_ball(
&mut ball,
1,
1,
old_position,
&mut velocity,
&mut events,
);
let updated_velocity = MilliVec::from_velocity_per_second(ball.velocity);
assert_ne!(updated_velocity, initial_velocity);
assert_eq!(predicted, old_position.add(updated_velocity));
}
#[test]
fn bumper_sound_precedes_a_crossed_media_marker() {
let mut game = Game::new(1);
@@ -3126,6 +3320,41 @@ mod tests {
assert!(!game.effect_target_active());
}
#[test]
fn rearmed_effect_seven_does_not_replace_an_active_secondary_ball() {
let mut game = Game::new_with_seed(1, 7);
game.multiball_state = MultiballState::Active;
game.target_effect = 7;
game.object_active[usize::from(EFFECT_SENSOR.id)] = true;
let secondary = Ball {
position: vec2(200.0, 200.0),
velocity: vec2(-12.0, 34.0),
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
};
game.secondary_ball = Some(secondary);
let mut primary = game.ball;
let mut events = Vec::new();
assert_eq!(
game.check_sensor_objects_for_ball(
&mut primary,
1,
2,
MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(),
&mut events,
)
.action,
BallAction::Keep
);
assert_eq!(game.secondary_ball.map(|ball| ball.position), Some(secondary.position));
assert_eq!(game.multiball_state, MultiballState::Active);
assert_eq!(game.target_effect, 0);
}
#[test]
fn multiball_capture_age_is_per_slot_and_removes_only_ball_two() {
let sensor = LOCK_HOLES[0];
@@ -3628,6 +3857,68 @@ mod tests {
game.live_circle_center(82, vec2(179.0, 419.0)),
vec2(181.0, 376.0)
);
assert_eq!(
game.live_circle_bounds(67, vec2(133.0, 377.0), 9.0),
(
MilliVec {
x: 118_000,
y: 362_000,
},
MilliVec {
x: 150_000,
y: 476_000,
},
)
);
assert_eq!(
game.live_circle_bounds(82, vec2(181.0, 376.0), 9.0),
(
MilliVec {
x: 167_000,
y: 362_000,
},
MilliVec {
x: 191_000,
y: 476_000,
},
)
);
}
#[test]
fn raised_flipper_tips_use_their_asymmetric_swept_bounds() {
for (object_id, center) in [(67_u8, vec2(133.0, 377.0)), (82, vec2(181.0, 376.0))]
{
let mut game = Game::new(1);
game.object_active.fill(false);
game.object_active[usize::from(object_id)] = true;
if object_id == 67 {
game.flippers.left_raised = true;
} else {
game.flippers.right_raised = true;
}
let old_position = MilliVec::from_position(vec2(center.x, center.y + 12.0));
let velocity = MilliVec { x: 0, y: -3_800 };
// The scanner carries prediction separately from mutable motion;
// keep this point outside the old radius box while the motion
// still approaches the tip.
let predicted = old_position.add(MilliVec { x: 0, y: 4_000 });
let generic_max_y = MilliVec::from_position(vec2(center.x, center.y + 14.0)).y;
assert!(predicted.y > generic_max_y);
assert_eq!(
game.find_static_collision_candidate_in_range(
old_position,
predicted,
velocity,
object_id..=object_id,
)
.map(|candidate| candidate.0),
Some(object_id),
"the original swept tip bounds must admit the path for record {object_id}"
);
}
}
#[test]