fix(game): restore effect-seven multiball
Arm effect 7 after the wheel-reset target, select it on the next effect line, and spawn a second visible live ball when record 149 is consumed. Advance the second ball through recovered line, circle, ball-ball, magnetic, and scoring collisions independently. During multiball, draining one ball leaves the other active without consuming a remaining player ball. Include secondary state in deterministic traces and finite-state validation. Test Plan: - `cargo test --all-targets` -- 52 passed - `cargo clippy --all-targets -- -D warnings` -- passed - production build plus Windows GNU and macOS checks -- passed - effect-seven spawn, movement, and single-drain lifecycle test -- passed - `git diff --cached --check` -- passed
This commit is contained in:
@@ -188,6 +188,12 @@ per-player effect word, and activated type-4 record 149. `1000:c4e1` maps effect
|
||||
the accumulated bonus to score. Entering record 149 also awards its static 500,
|
||||
clears the selection, and disables the record until line 84 is hit again.
|
||||
|
||||
After record 148 sets `DAT_1028_399a`, the next flagged line-84 selection uses
|
||||
effect 7 instead of random 1-6. The effect-7 branch in `1000:c4e1` schedules the
|
||||
timer's second-ball path and raises the active-ball count to two. The Rust port
|
||||
now keeps and renders a second live ball, applies recovered physical collisions
|
||||
and scores to it, and removes only the drained ball while multiball is active.
|
||||
|
||||
The score-addition path at `1000:bc36` indexes the initialized 32-bit threshold
|
||||
table at DGROUP `0x825` using media state 1-4. The exact thresholds are 140,000,
|
||||
650,000, 1,300,000, and 4,000,000. Crossing each threshold increments the media
|
||||
|
||||
@@ -25,7 +25,7 @@ implementation.
|
||||
| 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. The upward edge transfer matches three live probes and the downstroke matches its live position-preserving `(3519,6302)` transfer; both are mirrored on the right. Object 174 is omitted because the original overwrites it with the live ball every frame. |
|
||||
| Ball launcher | Recovered | The initial 32-bit fixed-point coordinates decode to `(325, 413)` in the right shooter lane. The port reproduces the initial `-375` millipixel Down event, 650 ms repeat delay, 40 ms repeats, release impulse, and randomized clamp below the original `-3800` maximum. This replaces the former guessed 330-430 px/s shot. |
|
||||
| Physics arithmetic | Partly recovered | Production movement uses the original 10 ms millipixel substep, `+15` vertical acceleration, `3800` speed bound, point-path type-2 intersection, one-sided line response, swept type-1 circle response, and swept non-physical sensor contacts. It evaluates all records and applies the earliest contact along the substep. Live probes cover ordinary rails, ordinary circles, a kicked bumper, lock holes, and both flipper directions. Persistent physical-contact bookkeeping remains pending. |
|
||||
| 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, six-way effect selector/consumer, permanent double scoring, and four exact media/extra-ball thresholds follow original help/code paths, globals, and object flags. 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. Remaining timing uncertainty is presentation batching at non-default detail settings, not gameplay routing. |
|
||||
| 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. 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. Remaining timing uncertainty is presentation batching at non-default detail settings, not gameplay routing. |
|
||||
| Numeric scoring | Partly inferred | Visible 2000-6000 target values and recovered registration values are preserved. Some bumper, bank-completion, robot, wheel, lock, and media thresholds are best-evidence reconstructions because the decompiler did not recover meaningful names or a clean rule table. |
|
||||
| High scores | Compatible import | The original 276-byte table is decoded as ten `IWIK`-XOR-obfuscated little-endian scores plus ten 22-byte names, sorted, then migrated to portable JSON. |
|
||||
| Configuration | Behaviorally compatible | Sound, language, and five detail levels are retained. Storage moves from a local Win16 INI file to the platform user-data directory. |
|
||||
|
||||
@@ -493,6 +493,14 @@ impl App {
|
||||
WHITE,
|
||||
);
|
||||
}
|
||||
if let Some(ball) = game.secondary_ball {
|
||||
draw_texture(
|
||||
&self.assets.ball,
|
||||
ball.position.x - 8.0,
|
||||
ball.position.y - 8.0,
|
||||
WHITE,
|
||||
);
|
||||
}
|
||||
if let Some(source) = game.claw.sprite_source() {
|
||||
draw_texture_ex(
|
||||
&self.assets.robot,
|
||||
|
||||
+161
-5
@@ -176,6 +176,13 @@ enum PlayerEntry {
|
||||
Closed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
enum MultiballState {
|
||||
#[default]
|
||||
Unavailable,
|
||||
Ready,
|
||||
}
|
||||
|
||||
fn initial_object_activity() -> [bool; 176] {
|
||||
let mut active = [true; 176];
|
||||
active[0] = false;
|
||||
@@ -200,6 +207,7 @@ pub struct Game {
|
||||
pub players: Vec<Player>,
|
||||
pub current_player: usize,
|
||||
pub ball: Ball,
|
||||
pub secondary_ball: Option<Ball>,
|
||||
pub bonus: u32,
|
||||
pub wheel_holes: [bool; 5],
|
||||
pub top_targets: [bool; 3],
|
||||
@@ -227,6 +235,7 @@ pub struct Game {
|
||||
object_active: [bool; 176],
|
||||
target_effect: u8,
|
||||
claw_frame_seconds: f32,
|
||||
multiball_state: MultiballState,
|
||||
}
|
||||
|
||||
impl Game {
|
||||
@@ -239,6 +248,7 @@ impl Game {
|
||||
players: vec![Player::default(); player_count.clamp(1, 4)],
|
||||
current_player: 0,
|
||||
ball: Ball::default(),
|
||||
secondary_ball: None,
|
||||
bonus: 0,
|
||||
wheel_holes: [false; 5],
|
||||
top_targets: [false; 3],
|
||||
@@ -266,6 +276,7 @@ impl Game {
|
||||
object_active: initial_object_activity(),
|
||||
target_effect: 0,
|
||||
claw_frame_seconds: CLAW_FRAME_SECONDS,
|
||||
multiball_state: MultiballState::Unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,6 +390,7 @@ impl Game {
|
||||
self.accumulator = (self.accumulator + frame_time.min(0.05)).min(0.1);
|
||||
while self.accumulator >= STEP_SECONDS {
|
||||
self.fixed_update(STEP_SECONDS, &mut events);
|
||||
self.advance_secondary_ball(&mut events);
|
||||
self.accumulator -= STEP_SECONDS;
|
||||
}
|
||||
events
|
||||
@@ -522,6 +534,95 @@ impl Game {
|
||||
}
|
||||
}
|
||||
|
||||
fn advance_secondary_ball(&mut self, events: &mut Vec<Event>) {
|
||||
let Some(mut ball) = self.secondary_ball.take() else {
|
||||
return;
|
||||
};
|
||||
let old_position = MilliVec::from_position(ball.position);
|
||||
let mut velocity = MilliVec::from_velocity_per_second(ball.velocity);
|
||||
velocity.y += GRAVITY_MILLI_PER_STEP;
|
||||
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
|
||||
self.apply_magnetic_fields(old_position, &mut velocity);
|
||||
let mut best_collision: Option<(u8, bool, CollisionResponse)> = None;
|
||||
|
||||
for object_id in 1..=175 {
|
||||
if !self.object_active[usize::from(object_id)] {
|
||||
continue;
|
||||
}
|
||||
if let Some(wall) = WALLS.iter().find(|wall| wall.id == object_id) {
|
||||
let segment = self.live_wall_segment(wall.id, wall.segment);
|
||||
if let Some(response) = line_collision_response(
|
||||
old_position,
|
||||
velocity,
|
||||
segment.start,
|
||||
segment.end,
|
||||
f64::from(wall.normal_rebound),
|
||||
f64::from(wall.tangent_coupling),
|
||||
) && best_collision
|
||||
.is_none_or(|(_, _, closest)| response.progress < closest.progress)
|
||||
{
|
||||
best_collision = Some((wall.id, true, response));
|
||||
}
|
||||
}
|
||||
let circle = PASSIVE_CIRCLES
|
||||
.iter()
|
||||
.chain(BUMPERS.iter())
|
||||
.find(|circle| circle.id == object_id);
|
||||
if let Some(circle) = circle
|
||||
&& let Some(response) = circle_collision_response(
|
||||
old_position,
|
||||
velocity,
|
||||
self.live_circle_center(circle.id, circle.center),
|
||||
circle.contact_radius,
|
||||
f64::from(circle.normal_rebound),
|
||||
f64::from(circle.tangent_coupling),
|
||||
f64::from(circle.normal_kick),
|
||||
)
|
||||
&& best_collision.is_none_or(|(_, _, closest)| response.progress < closest.progress)
|
||||
{
|
||||
best_collision = Some((circle.id, false, response));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(response) = circle_collision_response(
|
||||
old_position,
|
||||
velocity,
|
||||
self.ball.position,
|
||||
17.0,
|
||||
0.9,
|
||||
0.0,
|
||||
0.0,
|
||||
) && best_collision.is_none_or(|(_, _, closest)| response.progress < closest.progress)
|
||||
{
|
||||
best_collision = Some((174, false, response));
|
||||
}
|
||||
let mut hit = None;
|
||||
if let Some((object_id, is_wall, response)) = best_collision {
|
||||
velocity = response.velocity;
|
||||
hit = Some((object_id, is_wall));
|
||||
}
|
||||
ball.position = old_position.add(velocity).to_position();
|
||||
ball.velocity = velocity.to_velocity_per_second();
|
||||
if hit == Some((2, true)) || ball.position.y > 470.0 {
|
||||
events.push(Event::Drain);
|
||||
return;
|
||||
}
|
||||
if let Some((object_id, true)) = hit {
|
||||
self.apply_wall_rule(object_id, events);
|
||||
} else if let Some((object_id, false)) = hit
|
||||
&& let Some(index) = BUMPERS.iter().position(|bumper| bumper.id == object_id)
|
||||
&& !self.tilted
|
||||
&& self.bumper_cooldown <= 0.0
|
||||
{
|
||||
self.add_score(self.player().bumper_value);
|
||||
self.bonus = self.bonus.saturating_add(100);
|
||||
self.bumper_cooldown = 0.08;
|
||||
self.bumper_flash[index] = 0.16;
|
||||
events.push(Event::Bumper);
|
||||
}
|
||||
self.secondary_ball = Some(ball);
|
||||
}
|
||||
|
||||
fn apply_wall_rule(&mut self, object_id: u8, events: &mut Vec<Event>) {
|
||||
let wall = WALLS
|
||||
.iter()
|
||||
@@ -578,11 +679,16 @@ impl Game {
|
||||
self.wheel_animation = 0.65;
|
||||
}
|
||||
if wall.flags & 0x0004 != 0 {
|
||||
let previous = self.target_effect;
|
||||
loop {
|
||||
self.target_effect = u8::try_from(self.next_random_value() % 6 + 1).unwrap_or(1);
|
||||
if self.target_effect != previous {
|
||||
break;
|
||||
if self.multiball_state == MultiballState::Ready {
|
||||
self.target_effect = 7;
|
||||
} else {
|
||||
let previous = self.target_effect;
|
||||
loop {
|
||||
self.target_effect =
|
||||
u8::try_from(self.next_random_value() % 6 + 1).unwrap_or(1);
|
||||
if self.target_effect != previous {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.object_active[usize::from(EFFECT_SENSOR.id)] = true;
|
||||
@@ -698,6 +804,7 @@ impl Game {
|
||||
if reset_entered && !self.tilted {
|
||||
self.wheel_holes.fill(false);
|
||||
self.wheel_animation = 0.65;
|
||||
self.multiball_state = MultiballState::Ready;
|
||||
self.reset_ball_to_launcher();
|
||||
events.push(Event::Wheel);
|
||||
return true;
|
||||
@@ -739,6 +846,14 @@ impl Game {
|
||||
self.add_score(self.bonus);
|
||||
self.bonus = 0;
|
||||
}
|
||||
7 => {
|
||||
self.secondary_ball = Some(Ball {
|
||||
position: LAUNCHER_POSITION,
|
||||
velocity: vec2(0.0, -300.0),
|
||||
in_launcher: false,
|
||||
});
|
||||
self.multiball_state = MultiballState::Unavailable;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.target_effect = 0;
|
||||
@@ -957,6 +1072,11 @@ impl Game {
|
||||
}
|
||||
|
||||
fn drain(&mut self, events: &mut Vec<Event>) {
|
||||
if let Some(remaining_ball) = self.secondary_ball.take() {
|
||||
self.ball = remaining_ball;
|
||||
events.push(Event::Drain);
|
||||
return;
|
||||
}
|
||||
let player = &mut self.players[self.current_player];
|
||||
player.score = player.score.saturating_add(self.bonus);
|
||||
if player.extra_balls > 0 {
|
||||
@@ -976,6 +1096,8 @@ impl Game {
|
||||
self.trigger_contacts.fill(false);
|
||||
self.object_active = initial_object_activity();
|
||||
self.target_effect = 0;
|
||||
self.multiball_state = MultiballState::Unavailable;
|
||||
self.secondary_ball = None;
|
||||
self.nudge_shake = 0.0;
|
||||
self.launcher_charge = 0.0;
|
||||
self.launcher_was_down = false;
|
||||
@@ -1284,6 +1406,40 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_reset_arms_effect_seven_multiball() {
|
||||
let mut game = Game::new_with_seed(1, 7);
|
||||
let mut events = Vec::new();
|
||||
game.multiball_state = MultiballState::Ready;
|
||||
game.apply_wall_rule(84, &mut events);
|
||||
assert_eq!(game.target_effect, 7);
|
||||
|
||||
game.ball.in_launcher = false;
|
||||
game.ball.position = EFFECT_SENSOR.center;
|
||||
game.check_sensor_objects(
|
||||
MilliVec::from_position(EFFECT_SENSOR.center),
|
||||
MilliVec::default(),
|
||||
&mut events,
|
||||
);
|
||||
let spawned = game
|
||||
.secondary_ball
|
||||
.expect("effect seven should spawn a second ball");
|
||||
assert_eq!(spawned.position, LAUNCHER_POSITION);
|
||||
assert_eq!(spawned.velocity, vec2(0.0, -300.0));
|
||||
|
||||
game.advance_secondary_ball(&mut events);
|
||||
let moving_ball = game
|
||||
.secondary_ball
|
||||
.expect("second ball should remain active");
|
||||
assert!(moving_ball.position.y < spawned.position.y);
|
||||
|
||||
let balls_before = game.player().balls;
|
||||
game.drain(&mut events);
|
||||
assert!(game.secondary_ball.is_none());
|
||||
assert_eq!(game.player().balls, balls_before);
|
||||
assert_eq!(game.ball.position, moving_ball.position);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn center_drain_advances_to_a_fresh_ball() {
|
||||
let mut game = Game::new(1);
|
||||
|
||||
@@ -152,6 +152,7 @@ pub struct Snapshot {
|
||||
pub step: u64,
|
||||
pub seconds: f64,
|
||||
pub ball: BallSnapshot,
|
||||
pub secondary_ball: Option<BallSnapshot>,
|
||||
pub claw: ClawSnapshot,
|
||||
pub flippers: FlipperSnapshot,
|
||||
pub launcher_charge: f32,
|
||||
@@ -254,6 +255,13 @@ impl Simulation {
|
||||
velocity_y: self.game.ball.velocity.y,
|
||||
in_launcher: self.game.ball.in_launcher,
|
||||
},
|
||||
secondary_ball: self.game.secondary_ball.map(|ball| BallSnapshot {
|
||||
x: ball.position.x,
|
||||
y: ball.position.y,
|
||||
velocity_x: ball.velocity.x,
|
||||
velocity_y: ball.velocity.y,
|
||||
in_launcher: ball.in_launcher,
|
||||
}),
|
||||
claw: ClawSnapshot {
|
||||
active: self.game.claw.active,
|
||||
frame: self.game.claw.frame,
|
||||
@@ -389,6 +397,12 @@ mod tests {
|
||||
&& snapshot.ball.y.is_finite()
|
||||
&& snapshot.ball.velocity_x.is_finite()
|
||||
&& snapshot.ball.velocity_y.is_finite()
|
||||
&& snapshot.secondary_ball.as_ref().is_none_or(|ball| {
|
||||
ball.x.is_finite()
|
||||
&& ball.y.is_finite()
|
||||
&& ball.velocity_x.is_finite()
|
||||
&& ball.velocity_y.is_finite()
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user