fix(physics): preserve record layer and kick fields

Carry the recovered +0x49 layer mask through every static line/circle and select the original upper/lower scan layer from the previous Y coordinate. Restore negative auxiliary thresholds and kick coefficients for bumpers and records 55, 74, and 107, including weak-hit scoring, rail sound, and tilt suppression.

Test Plan:
- cargo test --all-targets
- cargo clippy --all-targets --all-features -- -D warnings
- rumdl check CHANGELOG.md RECONSTRUCTION.md README.md
- git diff --check
This commit is contained in:
2026-08-23 18:13:35 +02:00
parent 692e0087f9
commit 3d5a105c10
5 changed files with 247 additions and 22 deletions
+5
View File
@@ -10,6 +10,11 @@ and this project adheres to
### Fixed
- Restore auxiliary response thresholds/kicks for bumpers 51-53 and line
records 55, 74, and 107, including weak-hit bumper scoring, WAVE 2019 on
kicked unscored rails, and the original tilt suppression.
- Preserve every collision record's `+0x49` layer mask and select upper layer
1 or lower layer 2 from the ball's previous 250,000-millipixel Y boundary.
- Match the original detail callback's slot-major integration order, stop a
slot's remaining 10 ms substeps after its first collision/action, delay a
newly requested second ball until the next callback, and retain ball-two
+2 -2
View File
@@ -22,9 +22,9 @@ implementation.
| Artwork | Exact | All 34 custom DIB images, three standard bitmaps, icon, and palette derivatives are preserved in `assets/original/`. The game uses the original 640x460 table, loading, help, ball, wheel, robot, plunger, media, and diamond frames. |
| Audio | Exact samples and recovered dispatch | All 16 mono PCM WAV resources are embedded unchanged. Playback is emitted at the reconstructed call sites, including multi-sound bank completions and nudge-then-tilt ordering. WAV 2022 is loaded by the original generic resource loop but has no playback call and is therefore never played by Rust. |
| 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. Moving-flipper contact ports `1000:7ed9` rather than fitting live samples: delta-specific pivots/edges, integer cross gates, radial/penetration calculations, response-record gain, and position/velocity publication are tested against all four C harness directions and the raised release geometry. Object 174 is overwritten with the live first ball and Rust handles its ball-to-ball role 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. Every static record retains its `+0x49` layer mask; the scanner selects layer 1 below the old Y value 250,000 and layer 2 at or above it, while mask 3 records remain shared. 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. Moving-flipper contact ports `1000:7ed9` rather than fitting live samples: delta-specific pivots/edges, integer cross gates, radial/penetration calculations, response-record gain, and position/velocity publication are tested against all four C harness directions and the raised release geometry. Object 174 is overwritten with the live first ball and Rust handles its ball-to-ball role directly. |
| Ball launcher and nudge input | Recovered | The initial 32-bit fixed-point coordinates decode to `(325, 413)` in the right shooter lane. Each Down keydown subtracts `15*50 = 750` millipixels, release subtracts another `15*100 = 1500`, and the result follows the recovered randomized `-3800` lower and `-2280` weak upper clamp branches. The ten decoration frames use the same strict 750-millipixel thresholds. Left Shift and keypad 3 apply their directional `(50-Random(20))*15` impulses; Space uses the recovered Real48 horizontal factor and `(60-Random(20))*15` vertical impulse. Each nudge adds 25 to the wrapping 16-bit tilt counter, compares it with `30+Random(10)`, and the detail timer decrements a nonzero counter once per callback. |
| Physics arithmetic | Recovered Real48 core | Production movement uses the original 10 ms millipixel substep, `+15` vertical acceleration, Real48 `3800/speed` clamp, type-2 distance/cross gates, type-1 midpoint normal, surface-distance candidate ordering, persistent Real48 spin, and the common impulse response. Detail callbacks freeze their starting ball count, run all 5/4/3/2/1 substeps for slot 1 before slot 2, stop a slot after its first collision/reset/removal, and apply effect-seven spawn requests after the current batch. Dynamic records 174/175 transfer normal impulse to the other ball before applying `normal_velocity-1000` to the moving ball; effect seven spawns record/slot 2 from record 148 coordinates `(17000,23000)` with exact `(0,3040)` velocity. Both slots traverse the same type-3/type-4 dispatcher. Capture age is saved per slot, contact words retain ball ownership, a completed multiball capture removes exactly that slot and promotes the survivor when needed, and effect seven's argument-2 guard prevents a third ball. The original Borland seed update, high-word `Random(n)`, and normalized Real48 random register result drive launcher variation, effects, claw terminals, magnetic fields, and trigger response. Type-4 triggers retain a shared transient entry flag; type-3 captures retain per-player 16-bit contact words, deep-inside pull/hold progression to age 300, first-contact sound, and `99`/`2` completion sentinels. Zero-spin C harnesses and retained-spin Wine probes are tested separately rather than conflated. |
| Physics arithmetic | Recovered Real48 core | Production movement uses the original 10 ms millipixel substep, `+15` vertical acceleration, Real48 `3800/speed` clamp, type-2 distance/cross gates, type-1 midpoint normal, surface-distance candidate ordering, persistent Real48 spin, and the common impulse response. Negative auxiliary thresholds and kick coefficients are retained for bumpers 51-53 and lines 55/74/107, including weak-hit score behavior, unscored-rail WAVE 2019, and tilt suppression. Detail callbacks freeze their starting ball count, run all 5/4/3/2/1 substeps for slot 1 before slot 2, stop a slot after its first collision/reset/removal, and apply effect-seven spawn requests after the current batch. Dynamic records 174/175 transfer normal impulse to the other ball before applying `normal_velocity-1000` to the moving ball; effect seven spawns record/slot 2 from record 148 coordinates `(17000,23000)` with exact `(0,3040)` velocity. Both slots traverse the same type-3/type-4 dispatcher. Capture age is saved per slot, contact words retain ball ownership, a completed multiball capture removes exactly that slot and promotes the survivor when needed, and effect seven's argument-2 guard prevents a third ball. The original Borland seed update, high-word `Random(n)`, and normalized Real48 random register result drive launcher variation, effects, claw terminals, magnetic fields, and trigger response. Type-4 triggers retain a shared transient entry flag; type-3 captures retain per-player 16-bit contact words, deep-inside pull/hold progression to age 300, first-contact sound, and `99`/`2` completion sentinels. Zero-spin C harnesses and retained-spin Wine probes are tested separately rather than conflated. |
| Rules | Recovered gameplay paths | Player count, controls, the five three-line bumper-value groups, four three-line TDK-diamond groups, five doubling-value lock holes, wheel-reset target, seven-way effect selector/consumer including multiball, permanent double scoring, and four exact media/extra-ball thresholds follow original help/code paths, globals, and object flags. The ninth diamond pays the original 24,464 completion value; the following completed bank enables double scoring, and later completions add 100,000 to the per-player secondary score. Bumpers 51-53 and targets 140-147/150-152 use the original 5/20/10 active-callback countdowns and exact overlay-A render rectangles. Record 121 runs the six-callback DAT600 target rotation with exact 91x90 frames/target points and rotates the five contact/item values at state 6. Completing all five lock holes suspends physics for the full 281-callback panel animation, clears contact/item state at the recovered boundaries, and uses the exact WAVE 2013/2012/stop sequence. Turn changes mirror the original save/load of all 175 collision record states: wheel/top targets, active/contact slots, selected effect, and multiball readiness remain attached to their player. Claw contact and all initially active type-4 targets use recovered records. The top three targets score 500 each and independently enable the left, center, or right magnetic field record; each field pulls the ball upward until it exits and then deactivates. The claw state machine and release table have live differential coverage for all four random terminals. |
| Numeric scoring | Recovered gameplay values | Static scores come from the initialized 175-object ledger. Dynamic bumper progression, target-bank completion, diamond awards, 10k-160k lock bonuses, 310k transfer, six effect values, multiball mode, and all four media thresholds are transcribed from `1000:b476`, `1000:c4e1`, `1000:bc36`, and live state probes. Lock and effect awards share the original per-player secondary score and display multiplier; the fifth hole transfers and clears it, increments the multiplier, and grants the recovered ball award. Score mutation uses the original 32-bit wrapping behavior, and each add operation can advance at most one media threshold. |
| High scores | Recovered visible flow; portable storage | The original 276-byte table is decoded as ten `IWIK`-XOR-obfuscated little-endian scores plus ten 22-byte names. Each player is checked immediately when their own last ball is lost; qualifying scores use the original signed-high/unsigned-low comparison and a `TDK Pinball Player`-prefilled name screen before the table is shown and play resumes. Persisted updates use portable JSON rather than rewriting the Win16 file. |
+124 -7
View File
@@ -695,14 +695,17 @@ impl Game {
surface_distance: response.surface_distance,
velocity: response.moving_velocity,
spin: response.moving_spin,
auxiliary_fired: false,
},
));
}
}
let collided = best_collision.is_some();
let mut auxiliary_fired = false;
let (hit_wall, hit_circle) = if let Some((object_id, is_wall, response)) = best_collision {
velocity = response.velocity;
self.ball.spin = response.spin;
auxiliary_fired = response.auxiliary_fired;
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
position = old_position.add(velocity);
self.last_collision_id = Some(object_id);
@@ -737,13 +740,22 @@ impl Game {
return true;
}
self.apply_wall_rule(wall_id, events);
if auxiliary_fired && matches!(wall_id, 55 | 74 | 107) {
events.push(Event::Sound(2019));
}
}
if let Some(index) = hit_circle
.and_then(|circle_id| BUMPERS.iter().position(|bumper| bumper.id == circle_id))
&& !self.tilted
{
self.add_score(self.player().bumper_value, events);
let points = self
.player()
.bumper_value
.saturating_sub(if auxiliary_fired { 0 } else { 1_000 });
if points != 0 {
self.add_score(points, events);
}
self.record_countdowns[usize::from(BUMPERS[index].id)] = 5;
events.push(Event::Bumper);
events.push(Event::Sound(2006));
@@ -769,6 +781,7 @@ impl Game {
spin: Real48,
) -> Option<(u8, bool, CollisionResponse)> {
let mut best = None;
let layer = if old_position.y < 250_000 { 0x01 } else { 0x02 };
for object_id in 1..=175 {
if !self.object_active[usize::from(object_id)]
|| self.claw.active && (12..=20).contains(&object_id)
@@ -776,16 +789,29 @@ impl Game {
continue;
}
if let Some(wall) = WALLS.iter().find(|wall| wall.id == object_id) {
if wall.layer_mask & layer == 0 {
continue;
}
let segment = self.live_wall_segment(wall.id, wall.segment);
let material = if self.tilted {
CollisionMaterial::line(
f64::from(wall.normal_rebound),
f64::from(wall.tangent_coupling),
)
} else {
CollisionMaterial::line_with_kick(
f64::from(wall.normal_rebound),
f64::from(wall.tangent_coupling),
f64::from(wall.response_auxiliary),
f64::from(wall.response_kick),
)
};
if let Some(response) = line_collision_response(
old_position,
velocity,
segment.start,
segment.end,
CollisionMaterial::line(
f64::from(wall.normal_rebound),
f64::from(wall.tangent_coupling),
),
material,
spin,
) && best.is_none_or(|(_, _, closest): (u8, bool, CollisionResponse)| {
response.surface_distance <= closest.surface_distance
@@ -798,6 +824,7 @@ impl Game {
.chain(BUMPERS.iter())
.find(|circle| circle.id == object_id);
if let Some(circle) = circle
&& circle.layer_mask & layer != 0
&& let Some(response) = circle_collision_response(
old_position,
velocity,
@@ -806,7 +833,11 @@ impl Game {
CollisionMaterial::circle(
f64::from(circle.normal_rebound),
f64::from(circle.tangent_coupling),
f64::from(circle.normal_kick),
if self.tilted {
0.0
} else {
f64::from(circle.normal_kick)
},
),
spin,
)
@@ -861,14 +892,17 @@ impl Game {
surface_distance: response.surface_distance,
velocity: response.moving_velocity,
spin: response.moving_spin,
auxiliary_fired: false,
},
));
}
let mut hit = None;
let collided = best_collision.is_some();
let mut auxiliary_fired = false;
if let Some((object_id, is_wall, response)) = best_collision {
velocity = response.velocity;
ball.spin = response.spin;
auxiliary_fired = response.auxiliary_fired;
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
hit = Some((object_id, is_wall));
if object_id == 174
@@ -885,11 +919,20 @@ impl Game {
}
if let Some((object_id, true)) = hit {
self.apply_wall_rule(object_id, events);
if auxiliary_fired && matches!(object_id, 55 | 74 | 107) {
events.push(Event::Sound(2019));
}
} else if let Some((object_id, false)) = hit
&& let Some(index) = BUMPERS.iter().position(|bumper| bumper.id == object_id)
&& !self.tilted
{
self.add_score(self.player().bumper_value, events);
let points = self
.player()
.bumper_value
.saturating_sub(if auxiliary_fired { 0 } else { 1_000 });
if points != 0 {
self.add_score(points, events);
}
self.record_countdowns[usize::from(BUMPERS[index].id)] = 5;
events.push(Event::Bumper);
events.push(Event::Sound(2006));
@@ -1044,6 +1087,9 @@ impl Game {
movement_velocity: MilliVec,
events: &mut Vec<Event>,
) -> BallAction {
if old_position.y >= 250_000 {
return BallAction::Keep;
}
if !self.claw.active {
match self.capture_record_step(
ball,
@@ -2329,6 +2375,77 @@ mod tests {
assert!(game.ball.velocity.y > 0.0);
}
#[test]
fn collision_scan_selects_the_layer_from_the_previous_y_position() {
let mut game = Game::new(1);
game.object_active.fill(false);
game.object_active[48] = true;
let velocity = MilliVec { x: 0, y: -15_000 };
assert_eq!(
game.find_static_collision(
MilliVec {
x: 25_000,
y: 249_000,
},
velocity,
Real48::ZERO,
)
.map(|collision| collision.0),
Some(48)
);
assert!(
game.find_static_collision(
MilliVec {
x: 25_000,
y: 250_000,
},
velocity,
Real48::ZERO,
)
.is_none()
);
}
#[test]
fn bumper_base_score_and_kick_require_the_recovered_speed_threshold() {
let mut weak = Game::new(1);
weak.object_active.fill(false);
weak.object_active[51] = true;
weak.players[0].bumper_value = 2_000;
weak.ball.in_launcher = false;
weak.ball.position = vec2(165.0, 171.1);
weak.ball.velocity = vec2(0.0, -20.0);
let mut weak_events = Vec::new();
assert!(weak.fixed_update(STEP_SECONDS, &mut weak_events));
let mut fast = Game::new(1);
fast.object_active.fill(false);
fast.object_active[51] = true;
fast.players[0].bumper_value = 2_000;
fast.ball.in_launcher = false;
fast.ball.position = vec2(165.0, 172.015);
fast.ball.velocity = vec2(0.0, -198.5);
let mut tilted = fast.clone();
tilted.tilted = true;
let mut fast_events = Vec::new();
assert!(fast.fixed_update(STEP_SECONDS, &mut fast_events));
let mut tilted_events = Vec::new();
assert!(tilted.fixed_update(STEP_SECONDS, &mut tilted_events));
assert_eq!(weak.player().score, 1_000);
assert_eq!(fast.player().score, 2_000);
assert!(weak_events.contains(&Event::Sound(2006)));
assert!(fast_events.contains(&Event::Sound(2006)));
assert_eq!(weak.record_countdown(51), 5);
assert_eq!(fast.record_countdown(51), 5);
assert!(fast.ball.velocity.y > weak.ball.velocity.y);
assert_eq!(tilted.player().score, 0);
assert_eq!(tilted.record_countdown(51), 0);
assert!(!tilted_events.contains(&Event::Sound(2006)));
assert!(fast.ball.velocity.y > tilted.ball.velocity.y);
}
#[test]
fn tilt_latches_and_disables_flippers_and_scoring() {
let mut game = Game::new_with_seed(1, 7);
+69 -13
View File
@@ -21,12 +21,14 @@ pub struct CollisionResponse {
pub surface_distance: i32,
pub velocity: MilliVec,
pub spin: Real48,
pub auxiliary_fired: bool,
}
#[derive(Clone, Copy, Debug)]
pub struct CollisionMaterial {
pub normal_rebound: f64,
pub tangent_coupling: f64,
pub response_auxiliary: f64,
pub normal_kick: f64,
}
@@ -43,10 +45,25 @@ impl CollisionMaterial {
Self {
normal_rebound,
tangent_coupling,
response_auxiliary: 0.0,
normal_kick: 0.0,
}
}
pub const fn line_with_kick(
normal_rebound: f64,
tangent_coupling: f64,
response_auxiliary: f64,
normal_kick: f64,
) -> Self {
Self {
normal_rebound,
tangent_coupling,
response_auxiliary,
normal_kick,
}
}
pub const fn circle(
normal_rebound: f64,
tangent_coupling: f64,
@@ -55,6 +72,7 @@ impl CollisionMaterial {
Self {
normal_rebound,
tangent_coupling,
response_auxiliary: if normal_kick == 0.0 { 0.0 } else { -0.1 },
normal_kick,
}
}
@@ -63,11 +81,7 @@ impl CollisionMaterial {
ResponseCoefficients {
normal: coefficient(self.normal_rebound),
tangent: coefficient(self.tangent_coupling),
auxiliary: if self.normal_kick == 0.0 {
ZERO
} else {
Real48::from_bytes([0x7d, 0xcd, 0xcc, 0xcc, 0xcc, 0xcc])
},
auxiliary: coefficient(self.response_auxiliary),
kick: coefficient(self.normal_kick),
}
}
@@ -157,7 +171,8 @@ const fn subtract(left: MilliVec, right: MilliVec) -> MilliVec {
}
fn coefficient(value: f64) -> Real48 {
match (value * 100.0).round() as i32 {
let scaled = (value * 100.0).round() as i32;
let coefficient = match scaled.wrapping_abs() {
0 => ZERO,
5 => Real48::from_bytes([0x7c, 0xcd, 0xcc, 0xcc, 0xcc, 0x4c]),
10 => Real48::from_bytes([0x7d, 0xcd, 0xcc, 0xcc, 0xcc, 0x4c]),
@@ -169,6 +184,11 @@ fn coefficient(value: f64) -> Real48 {
80 => Real48::from_bytes([0x80, 0xcd, 0xcc, 0xcc, 0xcc, 0x4c]),
90 => Real48::from_bytes([0x80, 0x66, 0x66, 0x66, 0x66, 0x66]),
other => panic!("unsupported binary Real48 coefficient {other}"),
};
if scaled < 0 {
coefficient.negate()
} else {
coefficient
}
}
@@ -217,7 +237,7 @@ fn apply_response(
normal_y: Real48,
collision_velocity: i32,
coefficients: ResponseCoefficients,
) -> (MilliVec, Real48) {
) -> (MilliVec, Real48, bool) {
let length = milli_distance(MilliVec {
x: normal_x.round_i32(),
y: normal_y.round_i32(),
@@ -240,12 +260,12 @@ fn apply_response(
let mut impulse = Real48::from_i32(collision_velocity)
.multiply(ONE.add(coefficients.normal))
.round_i32();
if coefficients.auxiliary.compare(ZERO).is_lt()
let auxiliary_fired = coefficients.auxiliary.compare(ZERO).is_lt()
&& Real48::from_i32(MAXIMUM_SPEED_MILLI_PER_STEP)
.multiply(coefficients.auxiliary)
.round_i32()
> collision_velocity
{
> collision_velocity;
if auxiliary_fired {
impulse = impulse.wrapping_sub(
Real48::from_i32(MAXIMUM_SPEED_MILLI_PER_STEP)
.multiply(coefficients.kick)
@@ -268,6 +288,7 @@ fn apply_response(
y: velocity.y.wrapping_add(delta_y),
},
spin,
auxiliary_fired,
)
}
@@ -316,7 +337,7 @@ pub fn line_collision_response(
{
return None;
}
let (velocity, spin) = apply_response(
let (velocity, spin, auxiliary_fired) = apply_response(
velocity,
spin,
normal_x,
@@ -328,6 +349,7 @@ pub fn line_collision_response(
surface_distance: distance,
velocity,
spin,
auxiliary_fired,
})
}
@@ -389,7 +411,7 @@ pub fn circle_collision_response(
return None;
}
surface_distance = surface_distance.wrapping_add(3_000);
let (velocity, spin) = apply_response(
let (velocity, spin, auxiliary_fired) = apply_response(
velocity,
spin,
normal_x,
@@ -401,6 +423,7 @@ pub fn circle_collision_response(
surface_distance,
velocity,
spin,
auxiliary_fired,
})
}
@@ -447,7 +470,7 @@ pub fn ball_collision_response(
x: other_velocity.x.wrapping_add(transfer_x),
y: other_velocity.y.wrapping_sub(transfer_y),
};
let (moving_velocity, moving_spin) = apply_response(
let (moving_velocity, moving_spin, _) = apply_response(
velocity,
spin,
normal_x,
@@ -539,6 +562,39 @@ mod tests {
);
}
#[test]
fn auxiliary_kick_uses_the_recovered_negative_speed_threshold() {
let material = CollisionMaterial::line_with_kick(0.5, 0.1, -0.4, 0.4);
let fast = line_collision_response(
MilliVec {
x: 326_000,
y: 200_000,
},
MilliVec { x: 3_000, y: 0 },
vec2(328.0, 422.0),
vec2(328.0, 58.0),
material,
Real48::ZERO,
)
.expect("the fast path must hit the vertical rail");
let slow = line_collision_response(
MilliVec {
x: 327_500,
y: 200_000,
},
MilliVec { x: 1_000, y: 0 },
vec2(328.0, 422.0),
vec2(328.0, 58.0),
material,
Real48::ZERO,
)
.expect("the slow path must hit the same vertical rail");
assert!(fast.auxiliary_fired);
assert!(!slow.auxiliary_fired);
assert_ne!(fast.velocity.x, slow.velocity.x);
}
#[test]
fn inner_shooter_wall_matches_the_zero_spin_c_response() {
let old = MilliVec {
+47
View File
@@ -18,9 +18,12 @@ pub struct TableSegment {
pub segment: Segment,
pub normal_rebound: f32,
pub tangent_coupling: f32,
pub response_auxiliary: f32,
pub response_kick: f32,
pub score: u32,
pub flags: u16,
pub contact_group: u8,
pub layer_mask: u8,
}
impl TableSegment {
@@ -69,14 +72,22 @@ impl TableSegment {
118..=120 => 118,
_ => 0,
};
let (response_auxiliary, response_kick) = match id {
55 | 74 => (-0.40, 0.40),
107 => (-0.40, 0.30),
_ => (0.0, 0.0),
};
Self {
id,
segment: Segment::new(start, end, normal_rebound),
normal_rebound,
tangent_coupling,
response_auxiliary,
response_kick,
score,
flags,
contact_group,
layer_mask: layer_mask_for_record(id),
}
}
}
@@ -89,6 +100,7 @@ pub struct StaticCircle {
pub normal_rebound: f32,
pub tangent_coupling: f32,
pub normal_kick: f32,
pub layer_mask: u8,
}
#[derive(Clone, Copy, Debug)]
@@ -139,10 +151,19 @@ impl StaticCircle {
normal_rebound,
tangent_coupling,
normal_kick,
layer_mask: layer_mask_for_record(id),
}
}
}
const fn layer_mask_for_record(id: u8) -> u8 {
match id {
11 | 24 | 49 | 84..=120 | 167..=175 => 0x03,
1..=10 | 25 | 50 | 54..=83 | 153 | 154 => 0x02,
_ => 0x01,
}
}
/// Active type-2 objects after later registrations overwrite objects 169 and
/// 170. Object 2 is the drain sensor and object 25 is the shooter stop; callers
/// apply their special behavior after detecting contact.
@@ -347,6 +368,32 @@ mod tests {
assert_eq!(BUMPERS.len(), 3);
}
#[test]
fn recovered_layer_masks_cover_upper_lower_and_shared_records() {
assert_eq!(layer_mask_for_record(48), 0x01);
assert_eq!(layer_mask_for_record(50), 0x02);
assert_eq!(layer_mask_for_record(49), 0x03);
assert_eq!(layer_mask_for_record(89), 0x03);
assert_eq!(layer_mask_for_record(140), 0x01);
assert_eq!(layer_mask_for_record(153), 0x02);
assert_eq!(layer_mask_for_record(174), 0x03);
}
#[test]
fn recovered_auxiliary_kick_records_keep_both_coefficients() {
for (id, expected_kick) in [(55, 0.40_f32), (74, 0.40), (107, 0.30)] {
let wall = WALLS
.iter()
.find(|wall| wall.id == id)
.expect("the kicked line record must be present");
assert_eq!(wall.response_auxiliary.to_bits(), (-0.40_f32).to_bits());
assert_eq!(wall.response_kick.to_bits(), expected_kick.to_bits());
}
for bumper in BUMPERS {
assert_eq!(bumper.normal_kick.to_bits(), 0.40_f32.to_bits());
}
}
#[test]
fn recovered_landmarks_match_the_visible_table() {
let left_flipper_post = PASSIVE_CIRCLES