From 158936577a3b57533e18896f5a2549c24909bebd Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sat, 10 Oct 2026 11:46:28 +0200 Subject: [PATCH] fix(divergence): replace a full wheel immediately during multiball Bug: filling the fifth wheel hole with several balls in play awarded the transfer, multiplier, and extra ball, but did not run the wheel replacement. The original deferred the 281-frame panel until a later launcher reset, which with crazy-mode balls could be a long time, while the full wheel kept every hole closed. Requirement: a full wheel must be replaced with the same animation and scoring as in single-ball play. Balls still in play keep moving, with the wheel's outer border as a solid boundary and no hole captures until the replacement is done. Changes: - complete_lock_hole starts the panel for a multiball fill too (if one is not already running). The single-ball path is unchanged and still restarts it unconditionally, as in the original. Scoring was already identical and is untouched. - timer_tick only freezes physics during the panel while every ball waits in the launcher. That is always the case for the single-ball replacement (the locking ball was reset), so it is frozen exactly as before; during multiball the balls in play keep integrating. - While the panel runs, check_lock_holes captures nothing and retain_wheel_rim adds a circle around the wheel hub (record 134's center, 48 px ball-center radius, enclosing the five hole posts) with the hub's material. It only repels balls moving towards it, so a ball already inside rolls out instead of being caged. - Stress runs then showed balls trapped for good at the bottom of a hole pocket, cradled between the hub and a post, after another ball filled that hole or the panel closed it. The pocket bottom lies outside the hole's capture zone, so nothing ever moved them. A filled or closed hole now kicks such a ball (more than 4 px inside its rim) out of its center, radially away from the hub at 3 px/step, a path that clears both neighbouring posts. Balls arriving from outside are reflected by the rim before getting that deep. - A lock or special-hole capture that removes the last remaining ball of a multiball callback now resets it to the launcher (or finishes it when tilted), exactly like the single-ball capture, instead of ending the player's ball. Test Plan: - cargo test (164 tests), including full_wheel_during_multiball_replaces_it_while_balls_play_on (rim bounce, no captures, balls move, wheel cleared after the panel), filled_or_closed_lock_hole_kicks_out_a_ball_resting_in_its_pocket (the exact trapped position seen in stress runs), and the updated fifth_multiball_lock_starts_the_panel_and_caps_survivor_reaward. - A temporary release-mode stress run (not committed): 40 seeded two-player games with crazy mode, autoplay flippers, and nudges all finished; 62 wheel replacements happened during multiball; no invariant violations. - Manual: in crazy mode fill the fifth wheel hole; the replacement animation plays while the other balls keep moving and bounce off the wheel instead of dropping into its holes. --- tdkpin-rs/CHANGELOG.md | 7 ++ tdkpin-rs/PARITY_AUDIT.md | 9 ++ tdkpin-rs/RECONSTRUCTION.md | 4 + tdkpin-rs/src/game.rs | 212 ++++++++++++++++++++++++++++++++++-- 4 files changed, 222 insertions(+), 10 deletions(-) diff --git a/tdkpin-rs/CHANGELOG.md b/tdkpin-rs/CHANGELOG.md index a37c2f6..ea24b4e 100644 --- a/tdkpin-rs/CHANGELOG.md +++ b/tdkpin-rs/CHANGELOG.md @@ -36,6 +36,13 @@ and this project adheres to hole until it rolls out. - The claw never drops a ball into the shooter lane while more than one ball is in play, including balls that arrive after the capture began. +- Filling the fifth wheel hole during multiball replaces the wheel at once with + the normal animation and scoring instead of waiting for multiball to end. + Balls in play keep moving, the holes stay closed, and the wheel's outer rim + acts as a solid boundary until the replacement finishes. Locking the last + remaining ball returns it to the launcher as in single-ball play. +- A closed or filled lock hole kicks out a ball that came to rest in its pocket, + so it can no longer stay trapped next to the wheel hub. ## [1.2.1] - 2026-09-01 diff --git a/tdkpin-rs/PARITY_AUDIT.md b/tdkpin-rs/PARITY_AUDIT.md index 441350f..2d0d45b 100644 --- a/tdkpin-rs/PARITY_AUDIT.md +++ b/tdkpin-rs/PARITY_AUDIT.md @@ -91,6 +91,15 @@ differences follow from treating every ball alike: - The claw re-targets a shooter-lane release (terminal frame 18) to a table release whenever more than one ball is in play during its ride, not only when the capture began in multiball. +- A full wheel during multiball starts the 281-frame replacement immediately + (the original deferred it to the next launcher reset). The table only freezes + while every ball waits in the launcher; otherwise balls keep moving, the lock + holes do not capture, and the wheel's outer rim (record 134's center, 48 px + ball contact radius) repels balls from outside. +- A ball resting inside a filled or closed lock hole's pocket is kicked out + from the hole center, radially away from the hub. A capture that removes + the last ball of a multiball callback resets it to the launcher like a + single-ball capture instead of ending the ball. - High-score entries carry a `crazy` flag that both local storage and the shared service persist; older entries default to normal scores. diff --git a/tdkpin-rs/RECONSTRUCTION.md b/tdkpin-rs/RECONSTRUCTION.md index 662be9f..5188fbe 100644 --- a/tdkpin-rs/RECONSTRUCTION.md +++ b/tdkpin-rs/RECONSTRUCTION.md @@ -99,6 +99,10 @@ decoded, build-ready subset; it does not replace that evidence archive. others remain and ends the player's ball only for the last one; `remove_ball` keeps the claw and contact-record owners pointing at the same physical ball; effect seven's ball joins after all slot passes; + - wheel replacement: the 281-frame panel freezes the table only while every + ball waits in the launcher; during multiball the lock holes close, the + wheel rim (`retain_wheel_rim`) repels balls, and closed holes kick out a + ball resting in their pocket (`kick_out_of_lock_hole`); - crazy mode (a modern addition): `add_crazy_balls` drops ten ordinary balls and marks the current player, whose high score is labelled; - `geometry.rs`: segment/circle collision primitives; diff --git a/tdkpin-rs/src/game.rs b/tdkpin-rs/src/game.rs index 261e37e..ed92c54 100644 --- a/tdkpin-rs/src/game.rs +++ b/tdkpin-rs/src/game.rs @@ -49,6 +49,16 @@ const CLAW_TERMINAL_FRAMES: [u8; 4] = [1, 6, 7, 18]; const CLAW_TABLE_TERMINAL_FRAMES: [u8; 3] = [1, 6, 7]; /// Claw terminal frame that drops the released ball into the shooter lane. const CLAW_SHOOTER_LANE_FRAME: u8 = 18; +/// Wheel hub record 134; its center is also the center of the lock wheel. +const WHEEL_HUB_ID: u8 = 134; +/// Ball-center contact radius of the wheel's outer rim (about 40 px of +/// artwork plus the 8 px ball radius), so it encloses the five hole posts. +const WHEEL_RIM_CONTACT_RADIUS: f32 = 48.0; +/// How far (in millipixels) a ball must sit inside a closed lock hole's rim +/// before the hole kicks it out. Balls arriving from outside are reflected by +/// the rim before they get this deep. +const CLOSED_HOLE_KICKOUT_DEPTH_MILLI: i32 = 4_000; +const CLOSED_HOLE_KICKOUT_SPEED_MILLI: f32 = 3_000.0; const ORIGINAL_BALL_SPEED_PER_SECOND: f32 = 380.0; #[allow(clippy::cast_possible_wrap)] @@ -707,7 +717,9 @@ impl Game { if self.balls.len() < 2 { self.score_mode = ScoreMode::Normal; } - if !panel_active { + // The single-ball wheel replacement runs with its ball parked in the + // launcher and freezes the table; balls still in play keep moving. + if !panel_active || self.balls.iter().any(|ball| !ball.in_launcher) { self.advance_ball_slots(substeps, events); } @@ -1071,9 +1083,14 @@ impl Game { BallAction::Remove => { // A capture during multiball takes the ball off the table // without a drain. If the other balls already left during - // this callback, it was the last ball after all. + // this callback it was the last ball, which returns to the + // launcher exactly like a single-ball capture. if self.balls.len() == 1 { - return self.lose_ball(index, events); + if self.tilted { + return self.lose_ball(index, events); + } + self.reset_ball_to_launcher(index); + return BallStep::Stop; } self.remove_ball(index); return BallStep::Removed; @@ -1100,6 +1117,36 @@ impl Game { self.step_ball(pass, events) != BallStep::Continue } + /// While the wheel is being replaced its holes are closed and the + /// wheel's outer rim repels balls arriving from outside. + fn retain_wheel_rim( + old_position: MilliVec, + predicted: MilliVec, + velocity: MilliVec, + best: &mut Option<(u8, bool, StaticCollisionCandidate)>, + ) { + let Some(hub) = PASSIVE_CIRCLES + .iter() + .find(|circle| circle.id == WHEEL_HUB_ID) + else { + return; + }; + if let Some(candidate) = circle_collision_candidate_at( + old_position, + predicted, + velocity, + hub.center, + WHEEL_RIM_CONTACT_RADIUS, + CollisionMaterial::circle( + f64::from(hub.normal_rebound), + f64::from(hub.tangent_coupling), + 0.0, + ), + ) { + retain_first_collision(best, (WHEEL_HUB_ID, false, candidate)); + } + } + fn retain_static_range( &self, old_position: MilliVec, @@ -1182,6 +1229,9 @@ impl Game { *velocity = MilliVec::from_velocity_per_second(ball.velocity); } + if self.panel_frame.is_some() { + Self::retain_wheel_rim(old_position, predicted, *velocity, &mut best); + } 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(); @@ -1680,6 +1730,13 @@ impl Game { let contact = self.record_contacts[contact_index]; let contact_allowed = contact == 0 || (contact != 99 && ball_count == 1) || contact == ball_number; + if (129..=133).contains(&record_id) + && contact == 99 + && surface_distance < -CLOSED_HOLE_KICKOUT_DEPTH_MILLI + { + kick_out_of_lock_hole(ball, center_view); + return CaptureStep::Outside; + } if surface_distance < -11_000 && contact_allowed @@ -1790,6 +1847,24 @@ impl Game { best_capture: &mut Option<(u8, StaticCollisionCandidate)>, events: &mut Vec, ) -> Option { + if self.panel_frame.is_some() { + // The wheel is being replaced: no hole captures, and a ball that + // was resting in a hole's pocket is kicked back onto the table. + for sensor in LOCK_HOLES { + let center = MilliVec::from_position(sensor.center); + let current = MilliVec::from_position(ball.position); + let offset = MilliVec { + x: center.x.wrapping_sub(current.x), + y: center.y.wrapping_sub(current.y).wrapping_sub(2_000), + }; + let radius = MilliVec::from_position(vec2(sensor.radius, 0.0)).x; + if milli_distance(offset) - radius < -CLOSED_HOLE_KICKOUT_DEPTH_MILLI { + kick_out_of_lock_hole(ball, sensor.center); + break; + } + } + return None; + } for (index, sensor) in LOCK_HOLES.into_iter().enumerate() { match self.capture_record_step( ball, @@ -1839,7 +1914,10 @@ impl Game { player.secondary_score = 0; player.score_multiplier = player.score_multiplier.wrapping_add(1); player.extra_balls = player.extra_balls.wrapping_add(1); - if ball_count == 1 { + // Deliberate original-game divergence: the original deferred the + // wheel replacement until multiball ended. It now starts at once, + // and balls still in play run against the closed wheel rim. + if ball_count == 1 || self.panel_frame.is_none() { self.panel_frame = Some(0); } self.score_mode = ScoreMode::Normal; @@ -2544,6 +2622,28 @@ fn claw_release(frame: u8) -> (Vec2, Vec2) { (position, direction * ORIGINAL_BALL_SPEED_PER_SECOND) } +/// Deliberate original-game divergence: with several balls, one can come to +/// rest at the bottom of a lock hole's pocket, between the wheel hub and a +/// post, when another ball fills that hole or the wheel is being replaced. +/// The closed hole sends it out from its center, radially away from the hub, +/// which clears both neighbouring posts. +#[allow(clippy::cast_possible_truncation)] +fn kick_out_of_lock_hole(ball: &mut Ball, hole_center: Vec2) { + let hub = PASSIVE_CIRCLES + .iter() + .find(|circle| circle.id == WHEEL_HUB_ID) + .map_or(vec2(124.0, 76.0), |circle| circle.center); + let direction = (hole_center - hub).normalize_or_zero(); + ball.position = hole_center; + ball.velocity = MilliVec { + x: (direction.x * CLOSED_HOLE_KICKOUT_SPEED_MILLI).round() as i32, + y: (direction.y * CLOSED_HOLE_KICKOUT_SPEED_MILLI).round() as i32, + } + .to_velocity_per_second(); + ball.spin = Real48::ZERO; + ball.capture_age = 0; +} + 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); @@ -2825,6 +2925,102 @@ mod tests { assert!(game.balls[0].position.x < 300.0); } + #[test] + fn full_wheel_during_multiball_replaces_it_while_balls_play_on() { + let mut game = Game::new_with_seed(1, 7); + game.wheel_holes = [true, true, true, true, false]; + game.record_contacts[129..=132].fill(99); + game.players[0].secondary_score = 150_000; + let mut events = Vec::new(); + assert_eq!( + game.complete_lock_hole(4, 2, &mut events), + BallAction::Remove + ); + assert_eq!(game.panel_frame, Some(0)); + assert_eq!(game.player().secondary_score, 0); + assert_eq!(game.player().score_multiplier, 2); + + // A ball heading for the wheel bounces off its outer rim. + let hub = vec2(124.0, 76.0); + game.balls[0] = Ball { + position: hub + vec2(52.0, 0.0), + velocity: MilliVec { x: -3_000, y: 0 }.to_velocity_per_second(), + in_launcher: false, + ..Ball::default() + }; + let mut hit_rim = false; + for _ in 0..4 { + game.fixed_update(&mut events); + hit_rim |= game.last_collision_id == Some(WHEEL_HUB_ID); + } + assert!(hit_rim); + assert!(game.balls[0].velocity.x > 0.0); + + // The holes do not capture while the wheel is replaced. + game.balls[0] = Ball { + position: LOCK_HOLES[0].center, + in_launcher: false, + ..Ball::default() + }; + for _ in 0..20 { + game.fixed_update(&mut events); + } + assert!( + game.record_contacts[129..=133] + .iter() + .all(|contact| *contact != 2) + ); + assert!(!game.balls[0].in_launcher); + + // Balls keep moving during the animation, and it completes. + let mut panel = Game::new_with_seed(1, 7); + panel.panel_frame = Some(0); + panel.wheel_holes.fill(true); + panel.balls[0] = Ball { + position: vec2(160.0, 300.0), + in_launcher: false, + ..Ball::default() + }; + let start = panel.balls[0].position; + panel.timer_tick(0.03, 3, &mut events); + assert_ne!(panel.balls[0].position, start); + for _ in 0..300 { + panel.timer_tick(0.03, 3, &mut events); + } + assert_eq!(panel.panel_frame, None); + assert_eq!(panel.wheel_holes, [false; 5]); + } + + #[test] + fn filled_or_closed_lock_hole_kicks_out_a_ball_resting_in_its_pocket() { + // Found by multiball stress runs: the bottom of hole 131's pocket, + // cradled between the wheel hub and post 137. + let pocket = vec2(140.85, 65.2); + let hub = vec2(124.0, 76.0); + for closed_by_panel in [false, true] { + let mut game = Game::new_with_seed(1, 7); + if closed_by_panel { + game.panel_frame = Some(0); + } else { + game.record_contacts[131] = 99; + game.wheel_holes[2] = true; + } + game.balls[0] = Ball { + position: pocket, + in_launcher: false, + ..Ball::default() + }; + for _ in 0..40 { + game.update(0.03, 3, Controls::default()); + } + assert!( + game.balls[0].position.distance(hub) > WHEEL_RIM_CONTACT_RADIUS, + "ball still in the wheel at {:?}", + game.balls[0].position + ); + } + } + #[test] fn player_count_is_bounded() { assert_eq!(Game::new(0).players.len(), 1); @@ -5583,7 +5779,7 @@ mod tests { } #[test] - fn fifth_multiball_lock_defers_panel_and_caps_survivor_reaward() { + fn fifth_multiball_lock_starts_the_panel_and_caps_survivor_reaward() { let mut game = Game::new(1); game.wheel_holes = [true, true, true, true, false]; game.record_contacts[129..=133].fill(2); @@ -5598,13 +5794,9 @@ mod tests { assert_eq!(game.player().score, 620_000); assert_eq!(game.player().secondary_score, 0); assert_eq!(game.player().score_multiplier, 2); - assert_eq!(game.panel_frame, None); + assert_eq!(game.panel_frame, Some(0)); assert_eq!(game.score_mode, ScoreMode::Normal); - let mut reset_by_special_hole = game.clone(); - reset_by_special_hole.reset_ball_to_launcher(0); - assert_eq!(reset_by_special_hole.panel_frame, Some(0)); - game.score_mode = ScoreMode::Normal; assert_eq!( game.complete_lock_hole(4, 1, &mut events),