fix(physics): replace fitted flipper transfer

Remove the live-fitted upward and downward flipper polynomials. Port the
reconstructed 1000:7ed9 path with delta-specific pivots and record edges,
integer cross-product and radius gates, penetration, response-record gain,
wrapping velocity updates, and the final position delta.

Use resting records on press and the already-raised records on release, correct
the original delta direction, and apply each edge to both live ball slots. Keep
render-facing Vec2 projections, but adjust their float representation by ULPs so
every gameplay millipixel round-trips exactly instead of losing reconstructed
integer results.

Test Plan:
- `bash original/tools/test_reconstructed_c.sh` -- passed during raised-record reference capture
- `cargo test --all-targets` -- passed, 62 tests
- `cargo clippy --all-targets -- -D warnings` -- passed
- `rumdl check tdkpin-rs/CHANGELOG.md tdkpin-rs/RECONSTRUCTION.md` -- passed
- `git diff --cached --check` -- passed
This commit is contained in:
2026-08-23 17:11:48 +02:00
parent c1d8e5755f
commit d72db79af6
7 changed files with 392 additions and 92 deletions
+317
View File
@@ -0,0 +1,317 @@
//! Moving-flipper collision response reconstructed from `1000:7ed9`.
use crate::original_physics::MilliVec;
const SEARCH_RADIUS: i32 = 54_000;
const RESPONSE_RADIUS: f64 = 44_000.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FlipperSide {
Left,
Right,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FlipperResponse {
pub velocity: MilliVec,
pub movement: MilliVec,
}
#[derive(Clone, Copy)]
struct Geometry {
pivot: MilliVec,
negative_edge: MilliVec,
positive_edge: MilliVec,
}
impl Geometry {
fn for_side(side: FlipperSide, delta: i32) -> Self {
match side {
FlipperSide::Left => Self {
pivot: MilliVec {
x: 93_000,
y: 397_000_i32.wrapping_add(delta.wrapping_mul(15_000)),
},
negative_edge: MilliVec {
x: 161_000,
y: 413_000,
},
positive_edge: MilliVec {
x: if delta == 1 { 157_000 } else { 150_000 },
y: if delta == 1 { 384_000 } else { 427_000 },
},
},
FlipperSide::Right => Self {
pivot: MilliVec {
x: 220_000,
y: 397_000_i32.wrapping_add(delta.wrapping_mul(15_000)),
},
negative_edge: MilliVec {
x: 152_000,
y: 413_000,
},
positive_edge: MilliVec {
x: if delta == 1 { 177_000 } else { 196_000 },
y: if delta == 1 { 405_000 } else { 411_000 },
},
},
}
}
#[cfg(test)]
fn isolated_c_fixture(side: FlipperSide, delta: i32) -> Self {
let mut geometry = Self::for_side(side, delta);
geometry.positive_edge = match side {
FlipperSide::Left => MilliVec {
x: 150_000,
y: 427_000,
},
FlipperSide::Right => MilliVec {
x: 196_000,
y: 411_000,
},
};
geometry
}
}
fn cross_for_edge(edge: MilliVec, pivot: MilliVec, ball: MilliVec) -> i32 {
let edge_from_ball_x = edge.x.wrapping_sub(ball.x) / 1_000;
let edge_from_pivot_y = edge.y.wrapping_sub(pivot.y) / 1_000;
let edge_from_ball_y = edge.y.wrapping_sub(ball.y) / 1_000;
let edge_from_pivot_x = edge.x.wrapping_sub(pivot.x) / 1_000;
edge_from_ball_y
.wrapping_mul(edge_from_pivot_x)
.wrapping_sub(edge_from_ball_x.wrapping_mul(edge_from_pivot_y))
}
#[allow(clippy::cast_possible_truncation)]
fn rounded(value: f64) -> i32 {
value.round() as i32
}
fn collision_distance(ball: MilliVec, pivot: MilliVec) -> i32 {
rounded(f64::from(ball.x.wrapping_sub(pivot.x)).hypot(f64::from(
ball.y.wrapping_sub(pivot.y),
)))
}
fn contains(geometry: Geometry, side: FlipperSide, delta: i32, ball: MilliVec) -> Option<i32> {
let (first_edge, second_edge) = if delta == -1 {
(
MilliVec {
x: geometry.negative_edge.x,
y: geometry.negative_edge.y.wrapping_sub(43_000),
},
MilliVec {
x: geometry.negative_edge.x,
y: geometry.negative_edge.y.wrapping_add(12_000),
},
)
} else {
(
MilliVec {
x: geometry.positive_edge.x,
y: geometry.positive_edge.y.wrapping_sub(12_000),
},
MilliVec {
x: geometry.positive_edge.x,
y: geometry.positive_edge.y.wrapping_add(43_000),
},
)
};
if ball.x.wrapping_sub(geometry.pivot.x).wrapping_abs() >= SEARCH_RADIUS
|| ball.y.wrapping_sub(geometry.pivot.y).wrapping_abs() >= SEARCH_RADIUS
{
return None;
}
let distance = collision_distance(ball, geometry.pivot);
if distance > SEARCH_RADIUS {
return None;
}
let first = cross_for_edge(first_edge, geometry.pivot, ball);
let second = cross_for_edge(second_edge, geometry.pivot, ball);
let inside = match side {
FlipperSide::Left => first > -1_000 && second < 1_000 && ball.x > geometry.pivot.x,
FlipperSide::Right => first < 1_000 && second > -1_000 && ball.x < geometry.pivot.x,
};
inside.then_some(distance)
}
fn penetration(geometry: Geometry, delta: i32, ball: MilliVec) -> i32 {
let collision_y = if delta == -1 {
let edge_dx = geometry
.negative_edge
.x
.wrapping_sub(geometry.pivot.x)
.wrapping_abs();
if edge_dx > 0 {
geometry
.pivot
.y
.wrapping_sub(ball.x.wrapping_sub(geometry.pivot.x).wrapping_abs())
.wrapping_add(5_000)
} else {
geometry.negative_edge.y.wrapping_sub(43_000)
}
} else {
let edge_dx = geometry.positive_edge.x.wrapping_sub(geometry.pivot.x);
if edge_dx.wrapping_abs() > 0 {
let numerator = ball.x.wrapping_sub(geometry.pivot.x).wrapping_mul(
geometry
.positive_edge
.y
.wrapping_add(43_000)
.wrapping_sub(geometry.pivot.y),
);
geometry
.pivot
.y
.wrapping_sub(rounded(f64::from(numerator) / f64::from(edge_dx)))
} else {
geometry.positive_edge.y.wrapping_add(43_000)
}
};
let value = collision_y.wrapping_sub(ball.y);
if value < 0 {
value.wrapping_mul(delta).wrapping_neg()
} else {
0
}
}
pub fn moving_flipper_response(
ball: MilliVec,
velocity: MilliVec,
delta: i32,
side: FlipperSide,
response_normal: f64,
maximum_speed: i32,
) -> Option<FlipperResponse> {
let geometry = Geometry::for_side(side, delta);
response_with_geometry(
ball,
velocity,
delta,
side,
response_normal,
maximum_speed,
geometry,
)
}
fn response_with_geometry(
ball: MilliVec,
velocity: MilliVec,
delta: i32,
side: FlipperSide,
response_normal: f64,
maximum_speed: i32,
geometry: Geometry,
) -> Option<FlipperResponse> {
let distance = contains(geometry, side, delta, ball)?;
let penetration = penetration(geometry, delta, ball);
let mut normal_x = ball.x.wrapping_sub(geometry.pivot.x).wrapping_mul(delta);
let mut normal_y = ball
.y
.wrapping_sub(geometry.pivot.y)
.wrapping_add(4_000)
.wrapping_mul(delta);
if side == FlipperSide::Right {
normal_x = normal_x.wrapping_neg();
normal_y = normal_y.wrapping_neg();
}
let tangent_projection = rounded(
(f64::from(velocity.x) * f64::from(normal_y)
- f64::from(velocity.y) * f64::from(normal_x))
/ RESPONSE_RADIUS,
);
let gain = (f64::from(distance) / RESPONSE_RADIUS).sqrt() * 2.0 + 0.4;
let tangent_projection = rounded(f64::from(tangent_projection) * (1.0 + response_normal))
.wrapping_add(rounded(f64::from(maximum_speed) * gain));
let delta_velocity_x = rounded(
-f64::from(tangent_projection) * f64::from(normal_y) / RESPONSE_RADIUS,
);
let delta_velocity_y = rounded(
f64::from(tangent_projection) * f64::from(normal_x) / RESPONSE_RADIUS,
);
let velocity = MilliVec {
x: velocity.x.wrapping_add(delta_velocity_x),
y: velocity.y.wrapping_add(delta_velocity_y),
};
if velocity.y == 0 {
return None;
}
let movement_x = rounded(
f64::from(penetration.wrapping_mul(velocity.x)) / f64::from(velocity.y),
);
Some(FlipperResponse {
velocity,
movement: MilliVec {
x: movement_x,
y: penetration,
},
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn four_direction_vectors_match_the_reconstructed_c_harness() {
let cases = [
(
MilliVec { x: 104_000, y: 384_000 },
MilliVec { x: 1_000, y: 2_000 },
-1,
FlipperSide::Left,
0.5,
MilliVec { x: 1_266, y: 1_511 },
MilliVec { x: -6_703, y: -8_000 },
),
(
MilliVec { x: 209_000, y: 419_000 },
MilliVec { x: -1_000, y: 2_000 },
1,
FlipperSide::Right,
0.75,
MilliVec { x: -737, y: 2_263 },
MilliVec { x: -8_549, y: 26_250 },
),
(
MilliVec { x: 104_000, y: 421_000 },
MilliVec { x: 1_000, y: 2_000 },
1,
FlipperSide::Left,
0.25,
MilliVec { x: 622, y: 2_320 },
MilliVec { x: 5_414, y: 20_193 },
),
(
MilliVec { x: 209_000, y: 385_000 },
MilliVec { x: -1_000, y: 2_000 },
-1,
FlipperSide::Right,
0.0,
MilliVec { x: -1_280, y: 1_560 },
MilliVec { x: 7_385, y: -9_000 },
),
];
for (ball, velocity, delta, side, response, expected_velocity, expected_movement) in cases {
let result = response_with_geometry(
ball,
velocity,
delta,
side,
response,
1_000,
Geometry::isolated_c_fixture(side, delta),
)
.expect("C fixture must contact the moving flipper");
assert_eq!(result.velocity, expected_velocity);
assert_eq!(result.movement, expected_movement);
}
}
}