fix(physics): match live tangent response transitions

Correct the common projection to spin*0.4 + normal_velocity*response_tangent and seal line/circle behavior against current Wine one-substep traces. Preserve record-two's in-response special-respawn continuation, producing the live (16698,21191)/(-302,-1809) state after publishing (17000,23000)/(0,3040), and update the readable C evidence.

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 tdkpin-rs/CHANGELOG.md tdkpin-rs/RECONSTRUCTION.md tdkpin-rs/README.md
- live Wine record 2 trace: /tmp/original-special-respawn.csv
- live Wine object 155 trace: /tmp/original-circle155-after.csv
- git diff --check
This commit is contained in:
2026-08-23 19:42:23 +02:00
parent 06d1010431
commit 3dce159965
8 changed files with 107 additions and 26 deletions
+57 -4
View File
@@ -803,7 +803,15 @@ impl Game {
if let Some(wall_id) = hit_wall {
if wall_id == 2 {
let special_respawn = self.special_respawn_pending();
let response_spin = self.ball.spin;
self.drain(events);
if special_respawn {
let origin = MilliVec::from_position(self.ball.position);
self.ball.position = origin.add(velocity).to_position();
self.ball.velocity = velocity.to_velocity_per_second();
self.ball.spin = response_spin;
}
return true;
}
if wall_id == 25
@@ -2006,9 +2014,7 @@ impl Game {
events.push(Event::Drain);
return;
}
if self.score_mode == ScoreMode::Normal
&& matches!(self.multiball_state, MultiballState::Ready | MultiballState::Active)
{
if self.special_respawn_pending() {
self.ball = Ball {
position: vec2(17.0, 23.0),
velocity: MilliVec { x: 0, y: 3_040 }.to_velocity_per_second(),
@@ -2064,6 +2070,12 @@ impl Game {
self.finished = true;
}
fn special_respawn_pending(&self) -> bool {
self.secondary_ball.is_none()
&& self.score_mode == ScoreMode::Normal
&& matches!(self.multiball_state, MultiballState::Ready | MultiballState::Active)
}
fn save_current_rule_state(&mut self) {
self.players[self.current_player].rules = RuleState {
wheel_holes: self.wheel_holes,
@@ -2640,7 +2652,20 @@ mod tests {
game.fixed_update(&mut events);
assert!(!game.ball.in_launcher);
assert!(game.ball.position.y > 23.0);
assert_eq!(
MilliVec::from_position(game.ball.position),
MilliVec {
x: 17_000,
y: 26_055,
}
);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec {
x: 0,
y: 3_055,
}
);
assert_eq!(game.wheel_reset_gate, WheelResetGate::Suppressed);
game.ball.position = vec2(17.0, 31.0);
@@ -2649,6 +2674,34 @@ mod tests {
assert_eq!(game.wheel_reset_gate, WheelResetGate::Enabled);
}
#[test]
fn record_two_response_continues_after_special_respawn_like_live_original() {
let mut game = Game::new(1);
game.multiball_state = MultiballState::Active;
game.ball.in_launcher = false;
game.ball.position = vec2(157.0, 453.0);
game.ball.velocity = MilliVec { x: 0, y: 3_000 }.to_velocity_per_second();
assert!(game.fixed_update(&mut Vec::new()));
assert_eq!(
MilliVec::from_position(game.ball.position),
MilliVec {
x: 16_698,
y: 21_191,
}
);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec {
x: -302,
y: -1_809,
}
);
assert_eq!(game.multiball_state, MultiballState::Unavailable);
assert_eq!(game.wheel_reset_gate, WheelResetGate::Suppressed);
}
#[test]
fn armed_effect_seven_also_special_respawns_before_multiball_starts() {
let mut game = Game::new(1);