diff --git a/original/reconstructed/tdkpin_flipper_collision.c b/original/reconstructed/tdkpin_flipper_collision.c index 3ff1253..fb7929d 100644 --- a/original/reconstructed/tdkpin_flipper_collision.c +++ b/original/reconstructed/tdkpin_flipper_collision.c @@ -158,10 +158,10 @@ static FlipperCollisionGeometry load_flipper_geometry( geometry.unused_positive_point_x = record_i32( 81, offsetof(TdkpinCollisionRecord, point1_x_milli)); geometry.positive_edge_x = subtract_wrap_i32( - record_i32(81, offsetof(TdkpinCollisionRecord, point1_x_milli)), + record_i32(81, offsetof(TdkpinCollisionRecord, point2_x_milli)), 20000); geometry.positive_edge_y = record_i32( - 81, offsetof(TdkpinCollisionRecord, point1_y_milli)); + 81, offsetof(TdkpinCollisionRecord, point2_y_milli)); geometry.response_record = delta == 1 ? 81 : 83; } else { geometry = (FlipperCollisionGeometry){ @@ -202,8 +202,8 @@ static int32_t cross_product_for_edge( int32_t edge_from_pivot_x = divide_by_thousand( subtract_wrap_i32(edge_x, pivot_x)); return subtract_wrap_i32( - borland_multiply_i32(edge_from_ball_y, edge_from_pivot_x), - borland_multiply_i32(edge_from_ball_x, edge_from_pivot_y)); + borland_multiply_i32(edge_from_ball_x, edge_from_pivot_y), + borland_multiply_i32(edge_from_ball_y, edge_from_pivot_x)); } static int32_t radial_distance_milli( diff --git a/original/reconstructed/tests/test_flipper_collision.c b/original/reconstructed/tests/test_flipper_collision.c index 55f4929..a7ed6bf 100644 --- a/original/reconstructed/tests/test_flipper_collision.c +++ b/original/reconstructed/tests/test_flipper_collision.c @@ -185,11 +185,25 @@ static void test_right_positive_contact(void) win16_far_add_offset(g_receiver, 0x0baa)) == -737); assert((int32_t)win16_read_u32( win16_far_add_offset(g_receiver, 0x0bae)) == 2263); - assert(g_move_x == -8549 && g_move_y == 26250); + assert(g_move_x == -5578 && g_move_y == 17127); assert(g_sound_count == 1 && g_sounds[0] == 21); assert(g_move_calls == 1); } +static void test_live_binary_cross_product_order(void) +{ + prepare_fixture(); + win16_write_u32(g_receiver, 0x56, 3800); + set_ball(100000, 370000, 1000, 2000); + tdkpin_move_flipper_collision_geometry(0x0300, -1, 1, 0); + assert((int32_t)win16_read_u32( + win16_far_add_offset(g_receiver, 0x0baa)) == -189); + assert((int32_t)win16_read_u32( + win16_far_add_offset(g_receiver, 0x0bae)) == 960); + assert(g_move_x == 0 && g_move_y == 0); + assert(g_move_calls == 1); +} + static void test_left_positive_contact(void) { prepare_fixture(); @@ -230,8 +244,12 @@ static void test_vertical_edge_fallbacks(void) prepare_fixture(); write_i32( 81, - offsetof(TdkpinCollisionRecord, point1_x_milli), + offsetof(TdkpinCollisionRecord, point2_x_milli), 240000); + write_i32( + 81, + offsetof(TdkpinCollisionRecord, point2_y_milli), + 411000); set_ball(197000, 455000, -1000, 2000); tdkpin_move_flipper_collision_geometry(0x0300, 1, 2, 0); assert(g_move_calls == 1 && g_move_y == 1000); @@ -270,6 +288,7 @@ int main(void) { test_left_negative_contact(); test_right_positive_contact(); + test_live_binary_cross_product_order(); test_left_positive_contact(); test_right_negative_contact(); test_vertical_edge_fallbacks(); diff --git a/tdkpin-rs/src/flipper_physics.rs b/tdkpin-rs/src/flipper_physics.rs index 4c7ce3e..bcdd3c7 100644 --- a/tdkpin-rs/src/flipper_physics.rs +++ b/tdkpin-rs/src/flipper_physics.rs @@ -54,9 +54,11 @@ impl Geometry { x: 152_000, y: 413_000, }, + // The binary reads record 81 point 1 but uses point 2 for this + // edge. On release that point is already in its raised state. positive_edge: MilliVec { - x: if delta == 1 { 177_000 } else { 196_000 }, - y: if delta == 1 { 405_000 } else { 411_000 }, + x: if delta == 1 { 151_000 } else { 157_000 }, + y: if delta == 1 { 378_000 } else { 427_000 }, }, }, } @@ -71,8 +73,8 @@ impl Geometry { y: 427_000, }, FlipperSide::Right => MilliVec { - x: 196_000, - y: 411_000, + x: 157_000, + y: 427_000, }, }; geometry @@ -84,9 +86,11 @@ fn cross_for_edge(edge: MilliVec, pivot: MilliVec, ball: MilliVec) -> i32 { let edge_from_pivot_y = edge.y.wrapping_sub(pivot.y) / 1_000; let edge_from_ball_y = edge.y.wrapping_sub(ball.y) / 1_000; let edge_from_pivot_x = edge.x.wrapping_sub(pivot.x) / 1_000; - edge_from_ball_y - .wrapping_mul(edge_from_pivot_x) - .wrapping_sub(edge_from_ball_x.wrapping_mul(edge_from_pivot_y)) + // 1000:8208 computes C*D first, then computes A*B and subtracts C*D. + // Reversing these operands mirrors the complete moving-hit wedge. + edge_from_ball_x + .wrapping_mul(edge_from_pivot_y) + .wrapping_sub(edge_from_ball_y.wrapping_mul(edge_from_pivot_x)) } fn collision_distance(ball: MilliVec, pivot: MilliVec) -> i32 { @@ -279,6 +283,61 @@ fn response_with_geometry( mod tests { use super::*; + fn hash_u32(mut hash: u64, value: u32) -> u64 { + for byte in value.to_le_bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(1_099_511_628_211); + } + hash + } + + const fn i32_bits(value: i32) -> u32 { + u32::from_ne_bytes(value.to_ne_bytes()) + } + + fn dense_transition_digest(side: FlipperSide, delta: i32) -> (u32, u64) { + let velocities = [ + MilliVec { x: 0, y: 2_000 }, + MilliVec { x: 1_000, y: 2_000 }, + MilliVec { x: -1_000, y: 2_000 }, + MilliVec { x: 2_700, y: -2_700 }, + ]; + let mut hash = 14_695_981_039_346_656_037_u64; + let mut hits = 0_u32; + for x in (40_000..=280_000).step_by(1_000) { + for y in (320_000..=470_000).step_by(1_000) { + for (velocity_index, input_velocity) in velocities.into_iter().enumerate() { + let response = moving_flipper_response( + MilliVec { x, y }, + input_velocity, + delta, + side, + Real48::from_bytes([0x80, 0, 0, 0, 0, 0]), + 3_800, + ); + let (hit, velocity, movement) = response.map_or( + (0_u32, input_velocity, MilliVec::default()), + |response| (1, response.velocity, response.movement), + ); + hits += hit; + for value in [ + i32_bits(x), + i32_bits(y), + u32::try_from(velocity_index).expect("four velocities fit u32"), + hit, + i32_bits(velocity.x), + i32_bits(velocity.y), + i32_bits(movement.x), + i32_bits(movement.y), + ] { + hash = hash_u32(hash, value); + } + } + } + } + (hits, hash) + } + #[test] fn four_direction_vectors_match_the_reconstructed_c_harness() { let cases = [ @@ -298,7 +357,7 @@ mod tests { FlipperSide::Right, Real48::from_bytes([0x80, 0, 0, 0, 0, 0x40]), MilliVec { x: -737, y: 2_263 }, - MilliVec { x: -8_549, y: 26_250 }, + MilliVec { x: -5_578, y: 17_127 }, ), ( MilliVec { x: 104_000, y: 421_000 }, @@ -334,4 +393,54 @@ mod tests { assert_eq!(result.movement, expected_movement); } } + + #[test] + fn boundary_vectors_match_the_live_original_binary() { + for (ball, velocity, delta, side, expected_velocity, expected_movement) in [ + ( + MilliVec { x: 100_000, y: 370_000 }, + MilliVec { x: 1_000, y: 2_000 }, + -1, + FlipperSide::Left, + MilliVec { x: -189, y: 960 }, + MilliVec::default(), + ), + ( + MilliVec { x: 209_000, y: 419_000 }, + MilliVec { x: 1_000, y: 2_000 }, + 1, + FlipperSide::Right, + MilliVec { x: 2_133, y: 3_133 }, + MilliVec { x: 5_743, y: 8_435 }, + ), + ] { + let response = moving_flipper_response( + ball, + velocity, + delta, + side, + Real48::from_bytes([0x80, 0, 0, 0, 0, 0]), + 3_800, + ) + .expect("the live original hits this flipper boundary"); + assert_eq!(response.velocity, expected_velocity); + assert_eq!(response.movement, expected_movement); + } + } + + #[test] + fn dense_transition_matrix_matches_the_reconstructed_c_oracle() { + for (side, delta, expected_hits, expected_hash) in [ + (FlipperSide::Left, -1, 9_704, 0x280f_c478_572e_ccc5), + (FlipperSide::Left, 1, 10_256, 0x65f5_a7da_3013_c0f5), + (FlipperSide::Right, -1, 9_704, 0x875b_7dd6_1574_7fac), + (FlipperSide::Right, 1, 9_512, 0x4713_d619_16d8_0fe1), + ] { + assert_eq!( + dense_transition_digest(side, delta), + (expected_hits, expected_hash), + "moving-flipper matrix mismatch for {side:?} delta {delta}" + ); + } + } } diff --git a/tdkpin-rs/src/game.rs b/tdkpin-rs/src/game.rs index e3d5c77..fba7bdd 100644 --- a/tdkpin-rs/src/game.rs +++ b/tdkpin-rs/src/game.rs @@ -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, + ) -> 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, - ) { + ) -> 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, events: &mut Vec, - ) { + ) -> 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] diff --git a/tdkpin-rs/src/original_physics.rs b/tdkpin-rs/src/original_physics.rs index e1fd487..e787743 100644 --- a/tdkpin-rs/src/original_physics.rs +++ b/tdkpin-rs/src/original_physics.rs @@ -168,9 +168,19 @@ impl MilliVec { if speed <= maximum { return; } - let scale = Real48::from_i32(maximum).divide(Real48::from_i32(speed)); - self.x = Real48::from_i32(self.x).multiply(scale).round_i32(); - self.y = Real48::from_i32(self.y).multiply(scale).round_i32(); + // The original computes the excess fraction and subtracts the + // rounded component from each axis. Scaling directly by + // `maximum / speed` is mathematically equivalent, but can differ by + // one millipixel because each Real48 operation is rounded + // independently. + let excess = Real48::from_i32(speed.wrapping_sub(maximum)) + .divide(Real48::from_i32(speed)); + self.x = self + .x + .wrapping_sub(Real48::from_i32(self.x).multiply(excess).round_i32()); + self.y = self + .y + .wrapping_sub(Real48::from_i32(self.y).multiply(excess).round_i32()); } } @@ -676,6 +686,39 @@ mod tests { } } + #[test] + fn speed_clamp_matches_reconstructed_formula() { + let maximum = 3_800; + let mut mismatches = Vec::new(); + for x in (-10_000..=10_000).step_by(37) { + for y in (-10_000..=10_000).step_by(41) { + let input = MilliVec { x, y }; + let speed = milli_distance(input); + if speed <= maximum { + continue; + } + let excess = Real48::from_i32(speed - maximum) + .divide(Real48::from_i32(speed)); + let expected = MilliVec { + x: x.wrapping_sub(Real48::from_i32(x).multiply(excess).round_i32()), + y: y.wrapping_sub(Real48::from_i32(y).multiply(excess).round_i32()), + }; + let mut actual = input; + actual.clamp_speed(maximum); + if actual != expected { + mismatches.push((input, speed, actual, expected)); + if mismatches.len() == 5 { + break; + } + } + } + if mismatches.len() == 5 { + break; + } + } + assert!(mismatches.is_empty(), "speed-clamp mismatches: {mismatches:?}"); + } + #[test] fn outer_shooter_wall_matches_the_live_tangent_response() { let old = MilliVec {