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.
This commit is contained in:
ddidderr committed 2026-10-10 11:46:28 +02:00
1 parent 17a7b3c328
commit 158936577a
4 files changed
+222 -10

No files matched your search

+7
View File
@@ -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
+9
View File
@@ -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.
+4
View File
@@ -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;
+202 -10
View File
@@ -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<Event>,
) -> Option<BallAction> {
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),