fix(physics): preserve first-candidate scan semantics

Carry predicted position separately through exact record-ID chunks, apply magnetic rewrites only at records 6/153/154, and honor type-three/dynamic broadphase. Match raw 1000:c79c by resolving fixed candidate slot one rather than a later nearer record, and restore related capture-age, tilt, and stacked multiball scoring side effects.

Test Plan:
- bash original/tools/test_reconstructed_c.sh
- python3 original/tools/audit_reconstruction.py --require-complete
- cargo test --all-targets
- cargo clippy --all-targets --all-features -- -D warnings
- rumdl check original/C_RECONSTRUCTION_FINAL_AUDIT.md original/MECHANICS_PROGRESS.md tdkpin-rs/CHANGELOG.md tdkpin-rs/RECONSTRUCTION.md tdkpin-rs/README.md
- git diff --check
This commit is contained in:
2026-08-23 18:50:20 +02:00
parent f8dcceda5e
commit 2936c69c5c
3 changed files with 90 additions and 3 deletions
+85 -2
View File
@@ -207,6 +207,13 @@ enum MultiballState {
Ready,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum ScoreMode {
#[default]
Normal,
Multiball,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CaptureStep {
Outside,
@@ -314,6 +321,7 @@ pub struct Game {
target_effect: u8,
claw_frame_seconds: f32,
multiball_state: MultiballState,
score_mode: ScoreMode,
}
impl Game {
@@ -355,6 +363,7 @@ impl Game {
target_effect: 0,
claw_frame_seconds: CLAW_FRAME_SECONDS,
multiball_state: MultiballState::Unavailable,
score_mode: ScoreMode::Normal,
}
}
@@ -481,6 +490,9 @@ impl Game {
let panel_active = self.update_panel_completion(events);
if !self.claw.ball_suspended && !panel_active {
let had_secondary_ball = self.secondary_ball.is_some();
if had_secondary_ball {
self.score_mode = ScoreMode::Multiball;
}
for _ in 0..substeps {
if self.fixed_update(STEP_SECONDS, events)
|| self.finished
@@ -489,6 +501,9 @@ impl Game {
break;
}
}
if had_secondary_ball && self.secondary_ball.is_none() {
self.score_mode = ScoreMode::Normal;
}
if had_secondary_ball && !self.finished && !self.claw.ball_suspended {
if self.secondary_ball.is_some() {
for _ in 0..substeps {
@@ -728,6 +743,9 @@ impl Game {
}
}
let collided = best_collision.is_some();
if collided {
ball.capture_age = 0;
}
let mut auxiliary_fired = false;
let (hit_wall, hit_circle) = if let Some((object_id, is_wall, response)) = best_collision {
velocity = response.velocity;
@@ -1132,6 +1150,9 @@ impl Game {
}
let mut hit = None;
let collided = best_collision.is_some();
if collided {
ball.capture_age = 0;
}
let mut auxiliary_fired = false;
if let Some((object_id, is_wall, response)) = best_collision {
velocity = response.velocity;
@@ -1149,6 +1170,7 @@ impl Game {
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 {
self.score_mode = ScoreMode::Normal;
events.push(Event::Drain);
return true;
}
@@ -1180,11 +1202,14 @@ impl Game {
}
fn apply_wall_rule(&mut self, object_id: u8, events: &mut Vec<Event>) {
if self.tilted {
return;
}
let wall = WALLS
.iter()
.find(|wall| wall.id == object_id)
.expect("a wall collision id must reference a recovered wall");
if !self.tilted && wall.score != 0 {
if wall.score != 0 {
self.add_score(wall.score, events);
events.push(if object_id == 121 {
Event::Wheel
@@ -1643,6 +1668,7 @@ impl Game {
capture_age: 0,
});
self.multiball_state = MultiballState::Unavailable;
self.score_mode = ScoreMode::Multiball;
}
_ => {}
}
@@ -1875,11 +1901,16 @@ impl Game {
fn add_score(&mut self, points: u32, events: &mut Vec<Event>) {
const THRESHOLDS: [u32; 4] = [140_000, 650_000, 1_300_000, 4_000_000];
let multiplier = if self.player().double_score {
if self.tilted {
return;
}
let multiball_multiplier = if self.score_mode == ScoreMode::Multiball {
2
} else {
1
};
let permanent_multiplier = if self.player().double_score { 2 } else { 1 };
let multiplier = multiball_multiplier * permanent_multiplier;
let player = &mut self.players[self.current_player];
player.score = player.score.wrapping_add(points.wrapping_mul(multiplier));
let level = usize::from(player.media_level);
@@ -1895,6 +1926,7 @@ impl Game {
fn drain(&mut self, events: &mut Vec<Event>) {
if let Some(remaining_ball) = self.secondary_ball.take() {
self.ball = remaining_ball;
self.score_mode = ScoreMode::Normal;
events.push(Event::Drain);
return;
}
@@ -1921,6 +1953,7 @@ impl Game {
self.flippers = Flippers::default();
self.pending_flipper_edges.fill(0);
self.secondary_ball = None;
self.score_mode = ScoreMode::Normal;
self.nudge_shake = 0.0;
self.launcher_charge = 0.0;
self.launcher_was_down = false;
@@ -2357,6 +2390,39 @@ mod tests {
assert_eq!(game.player().secondary_score, 100_000);
}
#[test]
fn score_helper_stacks_multiball_and_permanent_double_and_honors_tilt() {
let mut game = Game::new(1);
let mut events = Vec::new();
game.score_mode = ScoreMode::Multiball;
game.add_score(100, &mut events);
assert_eq!(game.player().score, 200);
game.players[0].double_score = true;
game.add_score(100, &mut events);
assert_eq!(game.player().score, 600);
game.tilted = true;
game.add_score(100, &mut events);
assert_eq!(game.player().score, 600);
}
#[test]
fn tilted_collision_dispatch_leaves_rule_records_unchanged() {
let mut game = Game::new(1);
game.tilted = true;
let mut events = Vec::new();
game.apply_wall_rule(90, &mut events);
game.apply_wall_rule(84, &mut events);
assert!(game.object_active[90]);
assert_eq!(game.target_effect, 0);
assert!(!game.effect_target_active());
assert_eq!(game.player().score, 0);
assert!(events.is_empty());
}
#[test]
fn recovered_target_banks_deactivate_and_rearm_their_line_groups() {
let mut game = Game::new(1);
@@ -2687,6 +2753,23 @@ mod tests {
assert!(game.ball.velocity.y > 0.0);
}
#[test]
fn retained_collision_candidate_clears_the_slot_capture_age() {
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);
game.ball.capture_age = 42;
for _ in 0..5 {
if game.fixed_update(STEP_SECONDS, &mut Vec::new()) {
break;
}
}
assert_eq!(game.ball.capture_age, 0);
}
#[test]
fn collision_scan_selects_the_layer_from_the_previous_y_position() {
let mut game = Game::new(1);