fix(physics): restore binary record scan order

Interleave static ranges, type-three captures, type-four triggers, magnetic records, and dynamic ball records in exact ID order while tracking predicted position separately from mutable motion. Preserve type-three/dynamic broadphase and the raw stack quirk where fixed candidate slot one wins even when a later candidate is nearer; seal that behavior in both C and Rust harnesses.

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:46:44 +02:00
parent 89880e9b45
commit f8dcceda5e
8 changed files with 458 additions and 105 deletions
+381 -92
View File
@@ -5,8 +5,8 @@ use crate::{
original_physics::{
CollisionMaterial, CollisionResponse, GRAVITY_MILLI_PER_STEP,
MAXIMUM_SPEED_MILLI_PER_STEP, MilliVec, STEP_SECONDS, StaticCollisionCandidate,
ball_collision_response, capture_collision_candidate, circle_collision_candidate,
line_collision_candidate, milli_distance, path_intersects_circle,
ball_collision_response, capture_collision_candidate, circle_collision_candidate_at,
line_collision_candidate_at, milli_distance, path_intersects_circle,
},
real48::Real48,
table::{
@@ -225,7 +225,15 @@ enum BallAction {
#[derive(Clone, Copy, Debug)]
struct SensorScanResult {
action: BallAction,
capture_candidate: Option<(u8, StaticCollisionCandidate)>,
}
fn retain_first_collision(
best: &mut Option<(u8, bool, StaticCollisionCandidate)>,
candidate: (u8, bool, StaticCollisionCandidate),
) {
if best.is_none() {
*best = Some(candidate);
}
}
#[derive(Clone, Copy, Debug)]
@@ -679,41 +687,32 @@ impl Game {
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 movement_velocity = velocity;
let mut best_static = self.find_static_collision_candidate(old_position, velocity);
ball.velocity = velocity.to_velocity_per_second();
let scan = self.check_sensor_objects_for_ball(
let (best_static, scan, predicted) = self.scan_ordered_records_for_ball(
&mut ball,
1,
ball_count,
old_position,
movement_velocity,
&mut velocity,
events,
);
if let Some((id, candidate)) = scan.capture_candidate
&& best_static.is_none_or(|(_, _, closest)| {
candidate.surface_distance <= closest.surface_distance
})
{
best_static = Some((id, false, candidate));
}
velocity = MilliVec::from_velocity_per_second(ball.velocity);
let mut best_collision = best_static
.map(|(id, is_wall, candidate)| (id, is_wall, candidate.resolve(velocity, ball.spin)));
let mut transferred_secondary_velocity = None;
if let Some(secondary) = initial_secondary {
let other_position = MilliVec::from_position(secondary.position);
let response = ball_collision_response(
old_position,
velocity,
ball.spin,
MilliVec::from_position(secondary.position),
other_position,
MilliVec::from_velocity_per_second(secondary.velocity),
);
if let Some(response) = response
&& best_collision.is_none_or(|(_, _, closest)| {
response.surface_distance <= closest.surface_distance
})
if predicted.x >= other_position.x.wrapping_sub(21_000)
&& predicted.x <= other_position.x.wrapping_add(21_000)
&& predicted.y >= other_position.y.wrapping_sub(21_000)
&& predicted.y <= other_position.y.wrapping_add(21_000)
&& let Some(response) = response
&& best_collision.is_none()
{
transferred_secondary_velocity = Some(response.other_velocity);
best_collision = Some((
@@ -815,15 +814,166 @@ impl Game {
collided
}
fn find_static_collision_candidate(
fn retain_static_range(
&self,
old_position: MilliVec,
predicted: MilliVec,
velocity: MilliVec,
record_ids: std::ops::RangeInclusive<u8>,
best: &mut Option<(u8, bool, StaticCollisionCandidate)>,
) {
if let Some(candidate) = self.find_static_collision_candidate_in_range(
old_position,
predicted,
velocity,
record_ids,
) {
retain_first_collision(best, candidate);
}
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn scan_ordered_records_for_ball(
&mut self,
ball: &mut Ball,
ball_number: u16,
ball_count: u16,
old_position: MilliVec,
velocity: &mut MilliVec,
events: &mut Vec<Event>,
) -> (
Option<(u8, bool, StaticCollisionCandidate)>,
SensorScanResult,
MilliVec,
) {
let mut predicted = old_position.add(*velocity);
let mut best = None;
let mut capture_candidate = None;
let mut action = BallAction::Keep;
self.retain_static_range(old_position, predicted, *velocity, 1..=5, &mut best);
if self.apply_magnetic_record(6, old_position, velocity) {
predicted = old_position.add(*velocity);
}
self.retain_static_range(old_position, predicted, *velocity, 7..=88, &mut best);
if old_position.y < 250_000 && !self.claw.active {
ball.velocity = velocity.to_velocity_per_second();
match self.capture_record_step(
ball,
ball_number,
ball_count,
89,
CLAW_TRIGGER_CENTER,
CLAW_TRIGGER_RADIUS,
old_position,
predicted,
&mut capture_candidate,
events,
) {
CaptureStep::Complete if ball_count == 1 => {
let terminal_frame = self.next_claw_terminal_frame();
self.begin_claw_capture_after_hold(ball, terminal_frame, events);
action = BallAction::Suspend;
}
CaptureStep::Complete => action = BallAction::Remove,
CaptureStep::Outside | CaptureStep::Holding => {}
}
*velocity = MilliVec::from_velocity_per_second(ball.velocity);
}
self.retain_static_range(old_position, predicted, *velocity, 90..=128, &mut best);
if old_position.y < 250_000 {
ball.velocity = velocity.to_velocity_per_second();
if let Some(completed_action) = self.check_lock_holes(
ball,
ball_number,
ball_count,
old_position,
predicted,
&mut capture_candidate,
events,
) {
action = completed_action;
}
*velocity = MilliVec::from_velocity_per_second(ball.velocity);
}
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();
let predicted_velocity = MilliVec {
x: predicted.x.wrapping_sub(old_position.x),
y: predicted.y.wrapping_sub(old_position.y),
};
self.check_target_sensors(
ball,
old_position,
predicted_velocity,
140..=147,
events,
);
if let Some(completed_action) = self.check_wheel_reset(
ball,
ball_number,
ball_count,
old_position,
predicted,
&mut capture_candidate,
events,
) {
action = completed_action;
}
self.check_effect_sensor(
ball,
ball_number,
old_position,
predicted_velocity,
events,
);
self.check_target_sensors(
ball,
old_position,
predicted_velocity,
150..=152,
events,
);
*velocity = MilliVec::from_velocity_per_second(ball.velocity);
}
if self.apply_magnetic_record(153, old_position, velocity) {
predicted = old_position.add(*velocity);
}
if self.apply_magnetic_record(154, old_position, velocity) {
predicted = old_position.add(*velocity);
}
self.retain_static_range(old_position, predicted, *velocity, 155..=173, &mut best);
if let Some((id, candidate)) = capture_candidate
&& best.is_none_or(|(static_id, _, _)| id < static_id)
{
best = Some((id, false, candidate));
}
ball.velocity = velocity.to_velocity_per_second();
(
best,
SensorScanResult {
action,
},
predicted,
)
}
fn find_static_collision_candidate_in_range(
&self,
old_position: MilliVec,
predicted: MilliVec,
velocity: MilliVec,
record_ids: std::ops::RangeInclusive<u8>,
) -> Option<(u8, bool, StaticCollisionCandidate)> {
let mut best = None;
let layer = if old_position.y < 250_000 { 0x01 } else { 0x02 };
let predicted = old_position.add(velocity);
for object_id in 1..=175 {
for object_id in record_ids {
if !self.object_active[usize::from(object_id)]
|| self.claw.active && (12..=20).contains(&object_id)
{
@@ -856,16 +1006,15 @@ impl Game {
f64::from(wall.response_kick),
)
};
if let Some(candidate) = line_collision_candidate(
if let Some(candidate) = line_collision_candidate_at(
old_position,
predicted,
velocity,
segment.start,
segment.end,
material,
) && best.is_none_or(|(_, _, closest): (u8, bool, StaticCollisionCandidate)| {
candidate.surface_distance <= closest.surface_distance
}) {
best = Some((wall.id, true, candidate));
) {
retain_first_collision(&mut best, (wall.id, true, candidate));
}
}
let circle = PASSIVE_CIRCLES
@@ -885,8 +1034,9 @@ impl Game {
&& predicted.y >= center.y.wrapping_sub(margin)
&& predicted.y <= center.y.wrapping_add(margin)
}
&& let Some(candidate) = circle_collision_candidate(
&& let Some(candidate) = circle_collision_candidate_at(
old_position,
predicted,
velocity,
self.live_circle_center(circle.id, circle.center),
circle.contact_radius,
@@ -900,16 +1050,28 @@ impl Game {
},
),
)
&& best.is_none_or(|(_, _, closest)| {
candidate.surface_distance <= closest.surface_distance
})
&& best.is_none()
{
best = Some((circle.id, false, candidate));
retain_first_collision(&mut best, (circle.id, false, candidate));
}
}
best
}
#[cfg(test)]
fn find_static_collision_candidate(
&self,
old_position: MilliVec,
velocity: MilliVec,
) -> Option<(u8, bool, StaticCollisionCandidate)> {
self.find_static_collision_candidate_in_range(
old_position,
old_position.add(velocity),
velocity,
1..=175,
)
}
fn advance_secondary_ball(&mut self, events: &mut Vec<Event>) -> bool {
self.advance_secondary_ball_slot(events, true)
}
@@ -929,41 +1091,32 @@ impl Game {
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 movement_velocity = velocity;
let mut best_static = self.find_static_collision_candidate(old_position, velocity);
ball.velocity = velocity.to_velocity_per_second();
let scan = self.check_sensor_objects_for_ball(
let (best_static, scan, predicted) = self.scan_ordered_records_for_ball(
&mut ball,
2,
2,
old_position,
movement_velocity,
&mut velocity,
events,
);
if let Some((id, candidate)) = scan.capture_candidate
&& best_static.is_none_or(|(_, _, closest)| {
candidate.surface_distance <= closest.surface_distance
})
{
best_static = Some((id, false, candidate));
}
velocity = MilliVec::from_velocity_per_second(ball.velocity);
let mut best_collision = best_static
.map(|(id, is_wall, candidate)| (id, is_wall, candidate.resolve(velocity, ball.spin)));
let mut transferred_primary_velocity = None;
let other_position = MilliVec::from_position(primary_position);
if primary_slot_active
&& predicted.x >= other_position.x.wrapping_sub(21_000)
&& predicted.x <= other_position.x.wrapping_add(21_000)
&& predicted.y >= other_position.y.wrapping_sub(21_000)
&& predicted.y <= other_position.y.wrapping_add(21_000)
&& let Some(response) = ball_collision_response(
old_position,
velocity,
ball.spin,
MilliVec::from_position(primary_position),
other_position,
MilliVec::from_velocity_per_second(primary_velocity),
)
&& best_collision.is_none_or(|(_, _, closest)| {
response.surface_distance <= closest.surface_distance
})
&& best_collision.is_none()
{
transferred_primary_velocity = Some(response.other_velocity);
best_collision = Some((
@@ -1153,6 +1306,7 @@ impl Game {
scan.action
}
#[cfg(test)]
fn check_sensor_objects_for_ball(
&mut self,
ball: &mut Ball,
@@ -1164,10 +1318,10 @@ impl Game {
) -> SensorScanResult {
let mut capture_candidate = None;
let mut action = BallAction::Keep;
let predicted_position = old_position.add(movement_velocity);
if old_position.y >= 250_000 {
return SensorScanResult {
action: BallAction::Keep,
capture_candidate,
};
}
if !self.claw.active {
@@ -1179,6 +1333,7 @@ impl Game {
CLAW_TRIGGER_CENTER,
CLAW_TRIGGER_RADIUS,
old_position,
predicted_position,
&mut capture_candidate,
events,
) {
@@ -1201,6 +1356,7 @@ impl Game {
ball_number,
ball_count,
old_position,
predicted_position,
&mut capture_candidate,
events,
)
@@ -1220,6 +1376,7 @@ impl Game {
ball_number,
ball_count,
old_position,
predicted_position,
&mut capture_candidate,
events,
)
@@ -1242,7 +1399,6 @@ impl Game {
);
SensorScanResult {
action,
capture_candidate,
}
}
@@ -1256,6 +1412,7 @@ impl Game {
center: Vec2,
radius: f32,
previous_position: MilliVec,
predicted_position: MilliVec,
best_capture: &mut Option<(u8, StaticCollisionCandidate)>,
events: &mut Vec<Event>,
) -> CaptureStep {
@@ -1264,6 +1421,14 @@ impl Game {
let center = MilliVec::from_position(center_view);
let radius_view = radius;
let radius = (radius_view * 1_000.0).round() as i32;
let broadphase_margin = MilliVec::from_position(vec2(radius_view + 5.0, 0.0)).x;
if predicted_position.x < center.x.wrapping_sub(broadphase_margin)
|| predicted_position.x > center.x.wrapping_add(broadphase_margin)
|| predicted_position.y < center.y.wrapping_sub(broadphase_margin)
|| predicted_position.y > center.y.wrapping_add(broadphase_margin)
{
return CaptureStep::Outside;
}
let dx = center.x.wrapping_sub(current_position.x);
let dy = center
.y
@@ -1340,21 +1505,21 @@ impl Game {
radius_view,
CollisionMaterial::line(0.6, 0.0),
)
&& best_capture.is_none_or(|(_, closest)| {
candidate.surface_distance <= closest.surface_distance
})
&& best_capture.is_none()
{
*best_capture = Some((record_id, candidate));
}
CaptureStep::Outside
}
#[allow(clippy::too_many_arguments)]
fn check_lock_holes(
&mut self,
ball: &mut Ball,
ball_number: u16,
ball_count: u16,
old_position: MilliVec,
predicted_position: MilliVec,
best_capture: &mut Option<(u8, StaticCollisionCandidate)>,
events: &mut Vec<Event>,
) -> Option<BallAction> {
@@ -1367,6 +1532,7 @@ impl Game {
sensor.center,
sensor.radius,
old_position,
predicted_position,
best_capture,
events,
) {
@@ -1397,12 +1563,14 @@ impl Game {
None
}
#[allow(clippy::too_many_arguments)]
fn check_wheel_reset(
&mut self,
ball: &mut Ball,
ball_number: u16,
ball_count: u16,
old_position: MilliVec,
predicted_position: MilliVec,
best_capture: &mut Option<(u8, StaticCollisionCandidate)>,
events: &mut Vec<Event>,
) -> Option<BallAction> {
@@ -1414,6 +1582,7 @@ impl Game {
WHEEL_RESET_SENSOR.center,
WHEEL_RESET_SENSOR.radius,
old_position,
predicted_position,
best_capture,
events,
) {
@@ -1546,44 +1715,57 @@ impl Game {
self.launcher_velocity_milli = 0;
}
#[cfg(test)]
fn apply_magnetic_fields(&mut self, old_position: MilliVec, velocity: &mut MilliVec) {
for (object_id, min_x, min_y, max_x, max_y) in [
(6, 143_000, 421_000, 169_000, 452_000),
(153, 1_000, 315_000, 32_000, 389_000),
(154, 278_000, 315_000, 312_000, 387_000),
] {
if !self.object_active[object_id] {
continue;
}
if old_position.x < min_x
|| old_position.x > max_x
|| old_position.y < min_y
|| old_position.y > max_y
{
continue;
}
let damping = Real48::from_bytes([0x80, 0x66, 0x66, 0x66, 0x66, 0x66]);
velocity.x = Real48::from_i32(velocity.x).multiply(damping).round_i32();
let vertical_factor = Real48::from_i32(1).subtract(
self.random
.real48()
.multiply(Real48::from_bytes([0x7f, 0x9a, 0x99, 0x99, 0x99, 0x19])),
);
velocity.y = Real48::from_i32(MAXIMUM_SPEED_MILLI_PER_STEP)
.multiply(vertical_factor)
.round_i32()
.wrapping_neg();
let predicted = old_position.add(*velocity);
if predicted.x < min_x
|| predicted.x > max_x
|| predicted.y < min_y
|| predicted.y > max_y
{
self.object_active[object_id] = false;
}
for object_id in [6, 153, 154] {
self.apply_magnetic_record(object_id, old_position, velocity);
}
}
fn apply_magnetic_record(
&mut self,
object_id: usize,
old_position: MilliVec,
velocity: &mut MilliVec,
) -> bool {
if self.tilted || !self.object_active[object_id] {
return false;
}
let (min_x, min_y, max_x, max_y) = match object_id {
6 => (143_000, 421_000, 169_000, 452_000),
153 => (1_000, 315_000, 32_000, 389_000),
154 => (278_000, 315_000, 312_000, 387_000),
_ => unreachable!("only the three rectangular type-four records are magnetic"),
};
if old_position.x < min_x
|| old_position.x > max_x
|| old_position.y < min_y
|| old_position.y > max_y
{
return false;
}
let damping = Real48::from_bytes([0x80, 0x66, 0x66, 0x66, 0x66, 0x66]);
velocity.x = Real48::from_i32(velocity.x).multiply(damping).round_i32();
let vertical_factor = Real48::from_i32(1).subtract(
self.random
.real48()
.multiply(Real48::from_bytes([0x7f, 0x9a, 0x99, 0x99, 0x99, 0x19])),
);
velocity.y = Real48::from_i32(MAXIMUM_SPEED_MILLI_PER_STEP)
.multiply(vertical_factor)
.round_i32()
.wrapping_neg();
let predicted = old_position.add(*velocity);
if predicted.x < min_x
|| predicted.x > max_x
|| predicted.y < min_y
|| predicted.y > max_y
{
self.object_active[object_id] = false;
}
true
}
pub fn magnetic_field_active(&self, object_id: usize) -> bool {
debug_assert!([6, 153, 154].contains(&object_id));
self.object_active[object_id]
@@ -1882,6 +2064,7 @@ mod tests {
) -> CaptureStep {
let mut ball = game.ball;
let mut capture_candidate = None;
let predicted_position = previous_position.add(MilliVec::from_velocity_per_second(ball.velocity));
let result = game.capture_record_step(
&mut ball,
1,
@@ -1890,6 +2073,7 @@ mod tests {
center,
radius,
previous_position,
predicted_position,
&mut capture_candidate,
events,
);
@@ -2544,6 +2728,41 @@ mod tests {
);
}
#[test]
fn first_detected_record_wins_even_when_a_later_candidate_is_nearer() {
let old = MilliVec::default();
let velocity = MilliVec { x: 10_000, y: 0 };
let predicted = old.add(velocity);
let material = CollisionMaterial::line(0.6, 0.1);
let first = line_collision_candidate_at(
old,
predicted,
velocity,
vec2(8.0, 1.0),
vec2(8.0, -1.0),
material,
)
.expect("the first record must detect the farther rail");
let later = line_collision_candidate_at(
old,
predicted,
velocity,
vec2(2.0, 1.0),
vec2(2.0, -1.0),
material,
)
.expect("the later record must detect the nearer rail");
assert!(later.surface_distance < first.surface_distance);
let mut retained = None;
retain_first_collision(&mut retained, (10, true, first));
retain_first_collision(&mut retained, (20, true, later));
let retained = retained.expect("one candidate must remain");
assert_eq!(retained.0, 10);
assert_eq!(retained.2.surface_distance, 8_000);
}
#[test]
fn bumper_base_score_and_kick_require_the_recovered_speed_threshold() {
let mut weak = Game::new(1);
@@ -3112,6 +3331,60 @@ mod tests {
assert_eq!(exiting_sideways.x, -1_800);
assert!(!game.object_active[153]);
game.object_active[153] = true;
game.tilted = true;
let tilted_seed = game.random.seed();
let mut tilted_velocity = MilliVec { x: 200, y: 300 };
game.apply_magnetic_fields(
MilliVec {
x: 15_000,
y: 350_000,
},
&mut tilted_velocity,
);
assert_eq!(tilted_velocity, MilliVec { x: 200, y: 300 });
assert_eq!(game.random.seed(), tilted_seed);
assert!(game.object_active[153]);
}
#[test]
fn record_six_mutates_motion_after_earlier_candidate_detection() {
let mut game = Game::new_with_seed(1, 7);
game.object_active.fill(false);
game.object_active[5] = true;
game.object_active[6] = true;
game.ball.in_launcher = false;
game.ball.position = vec2(149.0, 430.0);
game.ball.velocity = MilliVec { x: -2_000, y: 0 }.to_velocity_per_second();
let old_position = MilliVec::from_position(game.ball.position);
let mut expected_game = game.clone();
let mut expected_motion = MilliVec { x: -2_000, y: 15 };
let candidate = expected_game
.find_static_collision_candidate_in_range(
old_position,
old_position.add(expected_motion),
expected_motion,
1..=5,
)
.expect("record five must be detected before record six");
assert_eq!(candidate.0, 5);
assert!(expected_game.apply_magnetic_record(6, old_position, &mut expected_motion));
let expected_response = candidate
.2
.resolve(expected_motion, expected_game.ball.spin);
let mut expected_velocity = expected_response.velocity;
expected_velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
assert!(game.fixed_update(STEP_SECONDS, &mut Vec::new()));
assert_eq!(game.last_collision_id, Some(5));
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
expected_velocity
);
assert_eq!(game.random.seed(), expected_game.random.seed());
}
#[test]
@@ -3269,6 +3542,22 @@ mod tests {
);
}
#[test]
fn type_three_broadphase_skip_preserves_contact_state() {
let sensor = LOCK_HOLES[0];
let mut game = Game::new(1);
game.object_active.fill(false);
game.object_active[usize::from(sensor.id)] = true;
game.record_contacts[usize::from(sensor.id)] = 1;
game.ball.in_launcher = false;
game.ball.position = sensor.center + vec2(23.0, 0.0);
game.ball.velocity = MilliVec { x: 3_800, y: 0 }.to_velocity_per_second();
game.fixed_update(STEP_SECONDS, &mut Vec::new());
assert_eq!(game.record_contacts[usize::from(sensor.id)], 1);
}
#[test]
fn production_type_four_randomizes_motion_before_position_publication() {
let sensor = TARGET_SENSORS