diff --git a/tdkpin-rs/CHANGELOG.md b/tdkpin-rs/CHANGELOG.md index e4d1fd4..0a53d29 100644 --- a/tdkpin-rs/CHANGELOG.md +++ b/tdkpin-rs/CHANGELOG.md @@ -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 diff --git a/tdkpin-rs/RECONSTRUCTION.md b/tdkpin-rs/RECONSTRUCTION.md index 0e39f5b..b408dc7 100644 --- a/tdkpin-rs/RECONSTRUCTION.md +++ b/tdkpin-rs/RECONSTRUCTION.md @@ -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. | diff --git a/tdkpin-rs/src/game.rs b/tdkpin-rs/src/game.rs index 23d6813..48bb14a 100644 --- a/tdkpin-rs/src/game.rs +++ b/tdkpin-rs/src/game.rs @@ -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, ) -> 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); diff --git a/tdkpin-rs/src/original_physics.rs b/tdkpin-rs/src/original_physics.rs index e783af6..a0b7bcd 100644 --- a/tdkpin-rs/src/original_physics.rs +++ b/tdkpin-rs/src/original_physics.rs @@ -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 { diff --git a/tdkpin-rs/src/table.rs b/tdkpin-rs/src/table.rs index 1214fbb..b81b0f3 100644 --- a/tdkpin-rs/src/table.rs +++ b/tdkpin-rs/src/table.rs @@ -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