fix(physics): restore slot-major timer batches

Freeze the active ball count at each detail callback, integrate every substep for slot one before slot two, and stop a slot after its first collision or lifecycle action. Effect-seven spawns are now deferred until the next callback, while a surviving slot two retains its original ball number for the remainder of a callback after slot one exits.

Test Plan:
- cargo test --all-targets
- cargo clippy --all-targets --all-features -- -D warnings
- rumdl check CHANGELOG.md RECONSTRUCTION.md README.md
- git diff --check
This commit is contained in:
2026-08-23 18:08:21 +02:00
parent 445af4a030
commit 692e0087f9
3 changed files with 155 additions and 32 deletions
+4
View File
@@ -10,6 +10,10 @@ and this project adheres to
### Fixed
- Match the original detail callback's slot-major integration order, stop a
slot's remaining 10 ms substeps after its first collision/action, delay a
newly requested second ball until the next callback, and retain ball-two
identity while a captured/drained ball one is collapsed.
- Run type-3 captures and type-4 triggers for both live ball slots, including
per-slot capture age/contact ownership, exact captured-slot removal, survivor
promotion, and the effect-seven ball-number guard against a third ball.
+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. Moving-flipper contact ports `1000:7ed9` rather than fitting live samples: delta-specific pivots/edges, integer cross gates, radial/penetration calculations, response-record gain, and position/velocity publication are tested against all four C harness directions and the raised release geometry. Object 174 is overwritten with the live first ball and Rust handles its ball-to-ball role directly. |
| Ball launcher and nudge input | Recovered | The initial 32-bit fixed-point coordinates decode to `(325, 413)` in the right shooter lane. Each Down keydown subtracts `15*50 = 750` millipixels, release subtracts another `15*100 = 1500`, and the result follows the recovered randomized `-3800` lower and `-2280` weak upper clamp branches. The ten decoration frames use the same strict 750-millipixel thresholds. Left Shift and keypad 3 apply their directional `(50-Random(20))*15` impulses; Space uses the recovered Real48 horizontal factor and `(60-Random(20))*15` vertical impulse. Each nudge adds 25 to the wrapping 16-bit tilt counter, compares it with `30+Random(10)`, and the detail timer decrements a nonzero counter once per callback. |
| Physics arithmetic | Recovered Real48 core | Production movement uses the original 10 ms millipixel substep, `+15` vertical acceleration, Real48 `3800/speed` clamp, type-2 distance/cross gates, type-1 midpoint normal, surface-distance candidate ordering, persistent Real48 spin, and the common impulse response. Dynamic records 174/175 transfer normal impulse to the other ball before applying `normal_velocity-1000` to the moving ball; effect seven spawns record/slot 2 from record 148 coordinates `(17000,23000)` with exact `(0,3040)` velocity. Both slots traverse the same type-3/type-4 dispatcher. Capture age is saved per slot, contact words retain ball ownership, a completed multiball capture removes exactly that slot and promotes the survivor when needed, and effect seven's argument-2 guard prevents a third ball. The original Borland seed update, high-word `Random(n)`, and normalized Real48 random register result drive launcher variation, effects, claw terminals, magnetic fields, and trigger response. Type-4 triggers retain a shared transient entry flag; type-3 captures retain per-player 16-bit contact words, deep-inside pull/hold progression to age 300, first-contact sound, and `99`/`2` completion sentinels. Zero-spin C harnesses and retained-spin Wine probes are tested separately rather than conflated. |
| Physics arithmetic | Recovered Real48 core | Production movement uses the original 10 ms millipixel substep, `+15` vertical acceleration, Real48 `3800/speed` clamp, type-2 distance/cross gates, type-1 midpoint normal, surface-distance candidate ordering, persistent Real48 spin, and the common impulse response. Detail callbacks freeze their starting ball count, run all 5/4/3/2/1 substeps for slot 1 before slot 2, stop a slot after its first collision/reset/removal, and apply effect-seven spawn requests after the current batch. Dynamic records 174/175 transfer normal impulse to the other ball before applying `normal_velocity-1000` to the moving ball; effect seven spawns record/slot 2 from record 148 coordinates `(17000,23000)` with exact `(0,3040)` velocity. Both slots traverse the same type-3/type-4 dispatcher. Capture age is saved per slot, contact words retain ball ownership, a completed multiball capture removes exactly that slot and promotes the survivor when needed, and effect seven's argument-2 guard prevents a third ball. The original Borland seed update, high-word `Random(n)`, and normalized Real48 random register result drive launcher variation, effects, claw terminals, magnetic fields, and trigger response. Type-4 triggers retain a shared transient entry flag; type-3 captures retain per-player 16-bit contact words, deep-inside pull/hold progression to age 300, first-contact sound, and `99`/`2` completion sentinels. Zero-spin C harnesses and retained-spin Wine probes are tested separately rather than conflated. |
| Rules | Recovered gameplay paths | Player count, controls, the five three-line bumper-value groups, four three-line TDK-diamond groups, five doubling-value lock holes, wheel-reset target, seven-way effect selector/consumer including multiball, permanent double scoring, and four exact media/extra-ball thresholds follow original help/code paths, globals, and object flags. The ninth diamond pays the original 24,464 completion value; the following completed bank enables double scoring, and later completions add 100,000 to the per-player secondary score. Bumpers 51-53 and targets 140-147/150-152 use the original 5/20/10 active-callback countdowns and exact overlay-A render rectangles. Record 121 runs the six-callback DAT600 target rotation with exact 91x90 frames/target points and rotates the five contact/item values at state 6. Completing all five lock holes suspends physics for the full 281-callback panel animation, clears contact/item state at the recovered boundaries, and uses the exact WAVE 2013/2012/stop sequence. Turn changes mirror the original save/load of all 175 collision record states: wheel/top targets, active/contact slots, selected effect, and multiball readiness remain attached to their player. Claw contact and all initially active type-4 targets use recovered records. The top three targets score 500 each and independently enable the left, center, or right magnetic field record; each field pulls the ball upward until it exits and then deactivates. The claw state machine and release table have live differential coverage for all four random terminals. |
| Numeric scoring | Recovered gameplay values | Static scores come from the initialized 175-object ledger. Dynamic bumper progression, target-bank completion, diamond awards, 10k-160k lock bonuses, 310k transfer, six effect values, multiball mode, and all four media thresholds are transcribed from `1000:b476`, `1000:c4e1`, `1000:bc36`, and live state probes. Lock and effect awards share the original per-player secondary score and display multiplier; the fifth hole transfers and clears it, increments the multiplier, and grants the recovered ball award. Score mutation uses the original 32-bit wrapping behavior, and each add operation can advance at most one media threshold. |
| High scores | Recovered visible flow; portable storage | The original 276-byte table is decoded as ten `IWIK`-XOR-obfuscated little-endian scores plus ten 22-byte names. Each player is checked immediately when their own last ball is lost; qualifying scores use the original signed-high/unsigned-low comparison and a `TDK Pinball Player`-prefilled name screen before the table is shown and play resumes. Persisted updates use portable JSON rather than rewriting the Win16 file. |
+150 -31
View File
@@ -216,6 +216,7 @@ enum CaptureStep {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BallAction {
Keep,
Suspend,
Reset,
Remove,
}
@@ -467,13 +468,36 @@ impl Game {
self.update_target_rotation(events);
let panel_active = self.update_panel_completion(events);
if !self.claw.ball_suspended && !panel_active {
let had_secondary_ball = self.secondary_ball.is_some();
for _ in 0..substeps {
self.fixed_update(STEP_SECONDS, events);
self.advance_secondary_ball(events);
if self.finished || self.claw.ball_suspended {
if self.fixed_update(STEP_SECONDS, events)
|| self.finished
|| self.claw.ball_suspended
{
break;
}
}
if had_secondary_ball && !self.finished && !self.claw.ball_suspended {
if self.secondary_ball.is_some() {
for _ in 0..substeps {
if self.advance_secondary_ball(events) {
break;
}
}
} else {
self.secondary_ball = Some(self.ball);
for _ in 0..substeps {
if self.advance_secondary_ball_slot(events, false) {
break;
}
}
if let Some(survivor) = self.secondary_ball.take() {
self.ball = survivor;
} else {
self.drain(events);
}
}
}
}
self.pending_flipper_edges[0] = match (
@@ -633,12 +657,12 @@ impl Game {
}
#[allow(clippy::too_many_lines)]
fn fixed_update(&mut self, dt: f32, events: &mut Vec<Event>) {
fn fixed_update(&mut self, dt: f32, events: &mut Vec<Event>) -> bool {
self.nudge_shake = (self.nudge_shake - dt).max(0.0);
if self.ball.in_launcher {
self.ball.position = LAUNCHER_POSITION;
self.ball.velocity = Vec2::ZERO;
return;
return true;
}
let old_position = MilliVec::from_position(self.ball.position);
@@ -675,6 +699,7 @@ impl Game {
));
}
}
let collided = best_collision.is_some();
let (hit_wall, hit_circle) = if let Some((object_id, is_wall, response)) = best_collision {
velocity = response.velocity;
self.ball.spin = response.spin;
@@ -701,7 +726,7 @@ impl Game {
if let Some(wall_id) = hit_wall {
if wall_id == 2 {
self.drain(events);
return;
return true;
}
if wall_id == 25
&& i64::from(velocity.x).pow(2) + i64::from(velocity.y).pow(2) < 1_000_i64.pow(2)
@@ -709,7 +734,7 @@ impl Game {
self.ball = Ball::default();
self.launcher_charge = 0.0;
self.launcher_was_down = false;
return;
return true;
}
self.apply_wall_rule(wall_id, events);
}
@@ -724,16 +749,17 @@ impl Game {
events.push(Event::Sound(2006));
}
self.check_sensor_objects(old_position, movement_velocity, events);
if self.claw.ball_suspended {
return;
if self.check_sensor_objects(old_position, movement_velocity, events) != BallAction::Keep {
return true;
}
if self.ball.position.y > 470.0 {
// Only malformed/out-of-table states reach this guard; the real
// drain is collision object 2 at y=455.
self.drain(events);
return true;
}
collided
}
fn find_static_collision(
@@ -794,9 +820,17 @@ impl Game {
best
}
fn advance_secondary_ball(&mut self, events: &mut Vec<Event>) {
fn advance_secondary_ball(&mut self, events: &mut Vec<Event>) -> bool {
self.advance_secondary_ball_slot(events, true)
}
fn advance_secondary_ball_slot(
&mut self,
events: &mut Vec<Event>,
primary_slot_active: bool,
) -> bool {
let Some(mut ball) = self.secondary_ball.take() else {
return;
return true;
};
let old_position = MilliVec::from_position(ball.position);
let mut velocity = MilliVec::from_velocity_per_second(ball.velocity);
@@ -807,15 +841,17 @@ impl Game {
let mut best_collision = self.find_static_collision(old_position, velocity, ball.spin);
let mut transferred_primary_velocity = None;
if let Some(response) = ball_collision_response(
old_position,
velocity,
ball.spin,
MilliVec::from_position(self.ball.position),
MilliVec::from_velocity_per_second(self.ball.velocity),
) && best_collision.is_none_or(|(_, _, closest)| {
response.surface_distance <= closest.surface_distance
})
if primary_slot_active
&& let Some(response) = ball_collision_response(
old_position,
velocity,
ball.spin,
MilliVec::from_position(self.ball.position),
MilliVec::from_velocity_per_second(self.ball.velocity),
)
&& best_collision.is_none_or(|(_, _, closest)| {
response.surface_distance <= closest.surface_distance
})
{
transferred_primary_velocity = Some(response.other_velocity);
best_collision = Some((
@@ -829,6 +865,7 @@ impl Game {
));
}
let mut hit = None;
let collided = best_collision.is_some();
if let Some((object_id, is_wall, response)) = best_collision {
velocity = response.velocity;
ball.spin = response.spin;
@@ -844,7 +881,7 @@ impl Game {
ball.velocity = velocity.to_velocity_per_second();
if hit == Some((2, true)) || ball.position.y > 470.0 {
events.push(Event::Drain);
return;
return true;
}
if let Some((object_id, true)) = hit {
self.apply_wall_rule(object_id, events);
@@ -857,17 +894,19 @@ impl Game {
events.push(Event::Bumper);
events.push(Event::Sound(2006));
}
if self.check_sensor_objects_for_ball(
let action = self.check_sensor_objects_for_ball(
&mut ball,
2,
2,
old_position,
movement_velocity,
events,
) == BallAction::Keep
{
self.secondary_ball = Some(ball);
);
match action {
BallAction::Keep | BallAction::Suspend => self.secondary_ball = Some(ball),
BallAction::Reset | BallAction::Remove => return true,
}
collided || action == BallAction::Suspend
}
fn apply_wall_rule(&mut self, object_id: u8, events: &mut Vec<Event>) {
@@ -972,18 +1011,19 @@ impl Game {
old_position: MilliVec,
movement_velocity: MilliVec,
events: &mut Vec<Event>,
) {
) -> BallAction {
let ball_count = if self.secondary_ball.is_some() { 2 } else { 1 };
let mut ball = self.ball;
match self.check_sensor_objects_for_ball(
let action = self.check_sensor_objects_for_ball(
&mut ball,
1,
ball_count,
old_position,
movement_velocity,
events,
) {
BallAction::Keep => self.ball = ball,
);
match action {
BallAction::Keep | BallAction::Suspend => self.ball = ball,
BallAction::Reset => self.reset_ball_to_launcher(),
BallAction::Remove => {
self.ball = self
@@ -992,6 +1032,7 @@ impl Game {
.expect("a multiball capture must leave the other slot active");
}
}
action
}
fn check_sensor_objects_for_ball(
@@ -1019,7 +1060,7 @@ impl Game {
if ball_count == 1 {
let terminal_frame = self.next_claw_terminal_frame();
self.begin_claw_capture_after_hold(ball, terminal_frame, events);
return BallAction::Keep;
return BallAction::Suspend;
}
return BallAction::Remove;
}
@@ -1741,6 +1782,84 @@ mod tests {
}
}
#[test]
fn detail_batch_stops_after_the_first_collision_like_integrate_one_ball() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = vec2(270.0, 30.0);
game.ball.velocity = vec2(0.0, -380.0);
let mut expected = game.clone();
let mut expected_events = Vec::new();
let mut stopped = false;
for _ in 0..5 {
if expected.fixed_update(STEP_SECONDS, &mut expected_events) {
stopped = true;
break;
}
}
assert!(stopped, "the recovered top rail must end this detail batch");
let mut events = Vec::new();
game.timer_tick(0.050, 5, &mut events);
assert_eq!(game.ball.position, expected.ball.position);
assert_eq!(game.ball.velocity, expected.ball.velocity);
assert_eq!(events, expected_events);
}
#[test]
fn effect_seven_spawn_waits_until_the_next_timer_callback() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = EFFECT_SENSOR.center;
game.ball.velocity = Vec2::ZERO;
game.object_active.fill(false);
game.object_active[usize::from(EFFECT_SENSOR.id)] = true;
game.multiball_state = MultiballState::Ready;
game.target_effect = 7;
game.timer_tick(0.050, 5, &mut Vec::new());
let spawned = game
.secondary_ball
.expect("the effect-seven request must create slot two after simulation");
assert_eq!(spawned.position, vec2(17.0, 23.0));
assert_eq!(
MilliVec::from_velocity_per_second(spawned.velocity),
MilliVec { x: 0, y: 3_040 }
);
}
#[test]
fn survivor_keeps_ball_two_identity_for_the_rest_of_the_callback() {
let lock = LOCK_HOLES[0];
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = lock.center;
game.ball.velocity = Vec2::ZERO;
game.ball.capture_age = 300;
game.record_contacts[usize::from(lock.id)] = 1;
game.secondary_ball = Some(Ball {
position: EFFECT_SENSOR.center,
velocity: Vec2::ZERO,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
game.object_active[usize::from(EFFECT_SENSOR.id)] = true;
game.multiball_state = MultiballState::Ready;
game.target_effect = 7;
game.timer_tick(0.050, 5, &mut Vec::new());
assert!(game.secondary_ball.is_none());
assert_eq!(game.target_effect, 0);
assert!(!game.effect_target_active());
assert_eq!(game.multiball_state, MultiballState::Ready);
assert!(game.wheel_holes[0]);
assert!(game.ball.position.y > EFFECT_SENSOR.center.y);
}
#[test]
fn launcher_charges_while_held_and_fires_on_release() {
let mut game = Game::new(1);