fix(game): restore wheel locks and reset target

Replace the inferred eight-sector wheel and central lock rectangle with the six
recovered type-3 records. Fill the five lock holes at their exact centers,
double each successive bonus from 10,000 through 160,000, transfer the complete
310,000 on the fifth, and return a launcher ball without consuming one.

Restore object 148 as the wheel-reset target and clear all filled-hole state on
contact. Keep these sensors on the same swept path and contact-latch machinery
as the claw and type-4 targets.

Test Plan:
- `cargo test --all-targets` -- 47 passed
- `cargo clippy --all-targets -- -D warnings` -- passed
- `cargo build --profile production` -- passed
- live five-hole bonus/score sequence matched exactly
- live object-148 reset cleared all five contact states
- `git diff --cached --check` -- passed
This commit is contained in:
2026-08-22 21:26:20 +02:00
parent 1ca8597080
commit 5e63772967
4 changed files with 136 additions and 63 deletions
+6
View File
@@ -162,3 +162,9 @@ raises the bumper value and rearms them. Flags `0x2092` on records 109, 112,
115, and 118 deactivate four three-line groups; clearing all four advances one 115, and 118 deactivate four three-line groups; clearing all four advances one
of the nine TDK diamond segments, awards the recovered segment value, and of the nine TDK diamond segments, awards the recovered segment value, and
rearms records 109-120. rearms records 109-120.
Controlled type-3 probes filled records 129-133 in order. Their bonus sequence
was 10,000, 30,000, 70,000, and 150,000; the fifth hole transferred the full
310,000 to score and cleared bonus. Each hole returned the ball to `(325,413)`
without consuming it and retained contact state 99. Entering record 148 then
cleared all five hole states, confirming its wheel-reset role.
+2 -2
View File
@@ -24,8 +24,8 @@ implementation.
| Help and languages | Exact | Original resource images 1001-1005 are displayed directly. | | 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. The upward edge transfer matches three live left-flipper probes and is mirrored on the right; the complete return-edge arithmetic remains pending. Object 174 is omitted because the original overwrites it with the live ball every frame. | | 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 left-flipper probes and is mirrored on the right; the complete return-edge arithmetic remains pending. 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. | | 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, and upward flipper transfer. Persistent physical-contact bookkeeping, type-3 lock holes, and return-edge flipper transfer remain pending. | | 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 upward flipper transfer. Persistent physical-contact bookkeeping and return-edge flipper transfer remain pending. |
| Rules | Partly recovered | Player count, controls, the five three-line bumper-value groups, the four three-line TDK-diamond groups, permanent double scoring, KByte media progression, and media extra balls follow original help/code paths 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 a magnetic gate for ten original timer callbacks, replacing the inferred three-target bonus. Wheel holes, ball lock, remaining special targets, and magnetic save routing still require full rule restoration. The claw state machine and release table are readable and terminal 18 has live differential evidence; terminals 1, 6, and 7 still need equivalent live coverage. | | Rules | Partly recovered | Player count, controls, the five three-line bumper-value groups, four three-line TDK-diamond groups, five doubling-value lock holes, wheel-reset target, permanent double scoring, KByte media progression, and media extra balls follow original help/code paths 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 a magnetic gate for ten original timer callbacks, replacing the inferred three-target bonus. Remaining special targets and magnetic save routing still require full rule restoration. The claw state machine and release table are readable and terminal 18 has live differential evidence; terminals 1, 6, and 7 still need equivalent live coverage. |
| 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. | | 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. | | 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. | | 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. |
+104 -61
View File
@@ -4,7 +4,7 @@ use crate::{
CollisionResponse, GRAVITY_MILLI_PER_STEP, MAXIMUM_SPEED_MILLI_PER_STEP, MilliVec, CollisionResponse, GRAVITY_MILLI_PER_STEP, MAXIMUM_SPEED_MILLI_PER_STEP, MilliVec,
STEP_SECONDS, circle_collision_response, line_collision_response, path_intersects_circle, STEP_SECONDS, circle_collision_response, line_collision_response, path_intersects_circle,
}, },
table::{BUMPERS, PASSIVE_CIRCLES, TARGET_SENSORS, WALLS}, table::{BUMPERS, LOCK_HOLES, PASSIVE_CIRCLES, TARGET_SENSORS, WALLS, WHEEL_RESET_SENSOR},
}; };
use macroquad::prelude::{Rect, Vec2, vec2}; use macroquad::prelude::{Rect, Vec2, vec2};
@@ -197,10 +197,8 @@ pub struct Game {
pub current_player: usize, pub current_player: usize,
pub ball: Ball, pub ball: Ball,
pub bonus: u32, pub bonus: u32,
pub wheel_holes: [bool; 8], pub wheel_holes: [bool; 5],
pub side_targets: [bool; 5],
pub top_targets: [bool; 3], pub top_targets: [bool; 3],
pub lock_lights: u8,
pub magnets: f32, pub magnets: f32,
pub tilted: bool, pub tilted: bool,
pub flippers: Flippers, pub flippers: Flippers,
@@ -212,7 +210,6 @@ pub struct Game {
pub finished: bool, pub finished: bool,
pub last_collision_id: Option<u8>, pub last_collision_id: Option<u8>,
accumulator: f32, accumulator: f32,
target_cooldown: f32,
bumper_cooldown: f32, bumper_cooldown: f32,
nudge_cooldown: f32, nudge_cooldown: f32,
nudge_meter: f32, nudge_meter: f32,
@@ -238,10 +235,8 @@ impl Game {
current_player: 0, current_player: 0,
ball: Ball::default(), ball: Ball::default(),
bonus: 0, bonus: 0,
wheel_holes: [false; 8], wheel_holes: [false; 5],
side_targets: [false; 5],
top_targets: [false; 3], top_targets: [false; 3],
lock_lights: 0,
magnets: 0.0, magnets: 0.0,
tilted: false, tilted: false,
flippers: Flippers::default(), flippers: Flippers::default(),
@@ -253,7 +248,6 @@ impl Game {
finished: false, finished: false,
last_collision_id: None, last_collision_id: None,
accumulator: 0.0, accumulator: 0.0,
target_cooldown: 0.0,
bumper_cooldown: 0.0, bumper_cooldown: 0.0,
nudge_cooldown: 0.0, nudge_cooldown: 0.0,
nudge_meter: 0.0, nudge_meter: 0.0,
@@ -385,7 +379,6 @@ impl Game {
#[allow(clippy::too_many_lines)] #[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>) {
self.target_cooldown = (self.target_cooldown - dt).max(0.0);
self.bumper_cooldown = (self.bumper_cooldown - dt).max(0.0); self.bumper_cooldown = (self.bumper_cooldown - dt).max(0.0);
self.nudge_cooldown = (self.nudge_cooldown - dt).max(0.0); self.nudge_cooldown = (self.nudge_cooldown - dt).max(0.0);
self.magnets = (self.magnets - dt).max(0.0); self.magnets = (self.magnets - dt).max(0.0);
@@ -515,7 +508,6 @@ impl Game {
} }
if !self.tilted { if !self.tilted {
self.check_targets(events);
self.check_media(events); self.check_media(events);
} }
if self.claw.ball_suspended { if self.claw.ball_suspended {
@@ -531,51 +523,6 @@ impl Game {
} }
} }
fn check_targets(&mut self, events: &mut Vec<Event>) {
if self.target_cooldown > 0.0 {
return;
}
let position = self.ball.position;
let wheel_center = vec2(114.0, 79.0);
let from_wheel = position - wheel_center;
if (25.0..=43.0).contains(&from_wheel.length()) {
let angle =
(from_wheel.y.atan2(from_wheel.x) + std::f32::consts::TAU) % std::f32::consts::TAU;
let sector_boundaries = [
std::f32::consts::TAU / 16.0,
std::f32::consts::TAU * 3.0 / 16.0,
std::f32::consts::TAU * 5.0 / 16.0,
std::f32::consts::TAU * 7.0 / 16.0,
std::f32::consts::TAU * 9.0 / 16.0,
std::f32::consts::TAU * 11.0 / 16.0,
std::f32::consts::TAU * 13.0 / 16.0,
std::f32::consts::TAU * 15.0 / 16.0,
];
let hole = sector_boundaries.partition_point(|boundary| angle >= *boundary) % 8;
if !self.wheel_holes[hole] {
self.wheel_holes[hole] = true;
self.add_score(2_500);
self.target_cooldown = 0.15;
self.wheel_animation = 0.65;
events.push(Event::Wheel);
}
if self.wheel_holes.iter().all(|hole| *hole) {
self.wheel_holes.fill(false);
self.add_score(25_000);
}
}
if (151.0..=220.0).contains(&position.x) && (187.0..=205.0).contains(&position.y) {
self.lock_lights = (self.lock_lights + 1).min(4);
self.add_score(u32::from(self.lock_lights) * 5_000);
self.ball.position = vec2(185.0, 181.0);
self.ball.velocity = vec2(-40.0 + f32::from(self.lock_lights) * 18.0, -120.0);
self.target_cooldown = 0.4;
events.push(Event::Lock);
}
}
fn apply_wall_rule(&mut self, object_id: u8, events: &mut Vec<Event>) { fn apply_wall_rule(&mut self, object_id: u8, events: &mut Vec<Event>) {
let wall = WALLS let wall = WALLS
.iter() .iter()
@@ -611,7 +558,6 @@ impl Game {
0 0
}; };
self.add_score(completion_bonus); self.add_score(completion_bonus);
self.side_targets.fill(false);
for active in &mut self.object_active[90..=104] { for active in &mut self.object_active[90..=104] {
*active = true; *active = true;
} }
@@ -668,6 +614,61 @@ impl Game {
} }
let current_position = MilliVec::from_position(self.ball.position); let current_position = MilliVec::from_position(self.ball.position);
for (index, sensor) in LOCK_HOLES.into_iter().enumerate() {
let touched = path_intersects_circle(
old_position,
movement_velocity,
sensor.center,
sensor.radius,
);
let contact_index = usize::from(sensor.id);
let entered =
touched && !self.trigger_contacts[contact_index] && !self.wheel_holes[index];
self.trigger_contacts[contact_index] = path_intersects_circle(
current_position,
MilliVec::default(),
sensor.center,
sensor.radius,
);
if !entered || self.tilted {
continue;
}
let filled_before = self.wheel_holes.iter().filter(|filled| **filled).count();
self.wheel_holes[index] = true;
self.bonus = self
.bonus
.saturating_add(10_000_u32 << u32::try_from(filled_before).unwrap_or_default());
if self.wheel_holes.iter().all(|filled| *filled) {
self.add_score(self.bonus);
self.bonus = 0;
}
self.reset_ball_to_launcher();
events.push(Event::Lock);
return;
}
let reset_touched = path_intersects_circle(
old_position,
movement_velocity,
WHEEL_RESET_SENSOR.center,
WHEEL_RESET_SENSOR.radius,
);
let reset_index = usize::from(WHEEL_RESET_SENSOR.id);
let reset_entered = reset_touched && !self.trigger_contacts[reset_index];
self.trigger_contacts[reset_index] = path_intersects_circle(
current_position,
MilliVec::default(),
WHEEL_RESET_SENSOR.center,
WHEEL_RESET_SENSOR.radius,
);
if reset_entered && !self.tilted {
self.wheel_holes.fill(false);
self.wheel_animation = 0.65;
self.reset_ball_to_launcher();
events.push(Event::Wheel);
return;
}
for sensor in TARGET_SENSORS { for sensor in TARGET_SENSORS {
let touched = path_intersects_circle( let touched = path_intersects_circle(
old_position, old_position,
@@ -695,6 +696,15 @@ impl Game {
} }
} }
fn reset_ball_to_launcher(&mut self) {
self.ball = Ball::default();
self.launcher_charge = 0.0;
self.launcher_was_down = false;
self.launcher_hold_seconds = 0.0;
self.launcher_next_repeat = LAUNCHER_REPEAT_DELAY_SECONDS;
self.launcher_velocity_milli = 0;
}
fn next_claw_terminal_frame(&mut self) -> u8 { fn next_claw_terminal_frame(&mut self) -> u8 {
let value = self.next_random_value(); let value = self.next_random_value();
CLAW_TERMINAL_FRAMES[value as usize % CLAW_TERMINAL_FRAMES.len()] CLAW_TERMINAL_FRAMES[value as usize % CLAW_TERMINAL_FRAMES.len()]
@@ -825,10 +835,8 @@ impl Game {
} }
events.push(Event::Drain); events.push(Event::Drain);
self.bonus = 0; self.bonus = 0;
self.side_targets.fill(false);
self.top_targets.fill(false); self.top_targets.fill(false);
self.wheel_holes.fill(false); self.wheel_holes.fill(false);
self.lock_lights = 0;
self.magnets = 0.0; self.magnets = 0.0;
self.tilted = false; self.tilted = false;
self.nudge_meter = 0.0; self.nudge_meter = 0.0;
@@ -1023,7 +1031,7 @@ mod tests {
#[test] #[test]
fn charged_ball_clears_the_shooter_lane() { fn charged_ball_clears_the_shooter_lane() {
for detail in 1..=5 { for detail in 1..=5 {
let mut game = Game::new(1); let mut game = Game::new_with_seed(1, u32::from(detail));
launch_ball(&mut game, 60); launch_ball(&mut game, 60);
let mut entered_table = false; let mut entered_table = false;
let mut returned_to_launcher = false; let mut returned_to_launcher = false;
@@ -1035,7 +1043,7 @@ mod tests {
if game.ball.position.x < 280.0 && game.ball.position.y > 30.0 { if game.ball.position.x < 280.0 && game.ball.position.y > 30.0 {
entered_table = true; entered_table = true;
} }
returned_to_launcher |= game.ball.in_launcher; returned_to_launcher |= game.ball.in_launcher && !entered_table;
} }
assert!( assert!(
@@ -1449,6 +1457,41 @@ mod tests {
assert_eq!(game.player().score, 1_000); assert_eq!(game.player().score, 1_000);
} }
#[test]
fn wheel_holes_double_bonus_then_transfer_all_310k() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
let mut events = Vec::new();
let expected_bonus = [10_000, 30_000, 70_000, 150_000, 0];
for (index, sensor) in LOCK_HOLES.into_iter().enumerate() {
game.ball.position = sensor.center;
game.check_sensor_objects(
MilliVec::from_position(sensor.center),
MilliVec::default(),
&mut events,
);
assert_eq!(game.bonus, expected_bonus[index]);
assert!(game.ball.in_launcher);
}
assert_eq!(game.player().score, 310_000);
assert!(game.wheel_holes.iter().all(|filled| *filled));
assert_eq!(
events.iter().filter(|event| **event == Event::Lock).count(),
5
);
game.ball.in_launcher = false;
game.ball.position = WHEEL_RESET_SENSOR.center;
game.check_sensor_objects(
MilliVec::from_position(WHEEL_RESET_SENSOR.center),
MilliVec::default(),
&mut events,
);
assert!(game.wheel_holes.iter().all(|filled| !*filled));
assert_eq!(events.last(), Some(&Event::Wheel));
}
#[test] #[test]
fn claw_release_table_decodes_the_original_thousandth_pixel_coordinates() { fn claw_release_table_decodes_the_original_thousandth_pixel_coordinates() {
for (frame, expected) in [ for (frame, expected) in [
+24
View File
@@ -99,6 +99,19 @@ pub struct TargetSensor {
pub score: u32, pub score: u32,
} }
#[derive(Clone, Copy, Debug)]
pub struct MechanismSensor {
pub id: u8,
pub center: Vec2,
pub radius: f32,
}
impl MechanismSensor {
const fn new(id: u8, center: Vec2, radius: f32) -> Self {
Self { id, center, radius }
}
}
impl TargetSensor { impl TargetSensor {
const fn new(id: u8, center: Vec2, radius: f32, score: u32) -> Self { const fn new(id: u8, center: Vec2, radius: f32, score: u32) -> Self {
Self { Self {
@@ -309,6 +322,17 @@ pub const TARGET_SENSORS: [TargetSensor; 11] = [
TargetSensor::new(152, Vec2::new(264.0, 46.0), 11.0, 500), TargetSensor::new(152, Vec2::new(264.0, 46.0), 11.0, 500),
]; ];
pub const LOCK_HOLES: [MechanismSensor; 5] = [
MechanismSensor::new(129, Vec2::new(96.0, 76.0), 19.0),
MechanismSensor::new(130, Vec2::new(115.0, 49.0), 19.0),
MechanismSensor::new(131, Vec2::new(147.0, 60.0), 19.0),
MechanismSensor::new(132, Vec2::new(147.0, 92.0), 19.0),
MechanismSensor::new(133, Vec2::new(115.0, 103.0), 19.0),
];
pub const WHEEL_RESET_SENSOR: MechanismSensor =
MechanismSensor::new(148, Vec2::new(17.0, 23.0), 19.0);
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;