diff --git a/tdkpin-rs/CHANGELOG.md b/tdkpin-rs/CHANGELOG.md index 0a53d29..f14108f 100644 --- a/tdkpin-rs/CHANGELOG.md +++ b/tdkpin-rs/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to ### Fixed +- Match the multiball nudge path's saved-slot behavior: consume the active + formula's random draws, then add one shared fixed/random impulse to both slot + velocity records instead of applying different formulas to each ball. - 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. diff --git a/tdkpin-rs/RECONSTRUCTION.md b/tdkpin-rs/RECONSTRUCTION.md index b408dc7..eba1c64 100644 --- a/tdkpin-rs/RECONSTRUCTION.md +++ b/tdkpin-rs/RECONSTRUCTION.md @@ -23,7 +23,7 @@ implementation. | 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. 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. | +| 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. With two balls, the key path still consumes those active-window draws, then applies its separately recovered fixed/shared random slot impulse to both saved velocities. 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. 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. | diff --git a/tdkpin-rs/src/game.rs b/tdkpin-rs/src/game.rs index 48bb14a..56eb9e9 100644 --- a/tdkpin-rs/src/game.rs +++ b/tdkpin-rs/src/game.rs @@ -622,24 +622,34 @@ impl Game { } Nudge::None => return, } - self.ball.velocity = velocity.to_velocity_per_second(); - if let Some(secondary) = &mut self.secondary_ball { - let mut slot_velocity = MilliVec::from_velocity_per_second(secondary.velocity); - match nudge { + let slot_impulse = match nudge { Nudge::Left | Nudge::Right => { - let amount = if nudge == Nudge::Left { -600 } else { 600 }; - slot_velocity.x = slot_velocity.x.wrapping_add(amount); + MilliVec { + x: if nudge == Nudge::Left { -600 } else { 600 }, + y: 0, + } } Nudge::Center => { let horizontal = (i32::from(self.random.below(21)) - 10) * SCALAR; let vertical = -(i32::from(self.random.below(100)) + 50) * SCALAR; - slot_velocity.x = slot_velocity.x.wrapping_add(horizontal); - slot_velocity.y = slot_velocity.y.wrapping_add(vertical); + MilliVec { + x: horizontal, + y: vertical, + } } - Nudge::None => {} - } - secondary.velocity = slot_velocity.to_velocity_per_second(); + Nudge::None => MilliVec::default(), + }; + let mut primary_slot = MilliVec::from_velocity_per_second(self.ball.velocity); + primary_slot.x = primary_slot.x.wrapping_add(slot_impulse.x); + primary_slot.y = primary_slot.y.wrapping_add(slot_impulse.y); + self.ball.velocity = primary_slot.to_velocity_per_second(); + let mut secondary_slot = MilliVec::from_velocity_per_second(secondary.velocity); + secondary_slot.x = secondary_slot.x.wrapping_add(slot_impulse.x); + secondary_slot.y = secondary_slot.y.wrapping_add(slot_impulse.y); + secondary.velocity = secondary_slot.to_velocity_per_second(); + } else { + self.ball.velocity = velocity.to_velocity_per_second(); } self.nudge_shake = 0.18; @@ -2690,6 +2700,47 @@ mod tests { assert_eq!(game.tilt_counter, 24); } + #[test] + fn multiball_nudge_consumes_active_draws_then_updates_both_saved_slots() { + let mut game = Game::new_with_seed(1, 7); + game.ball.in_launcher = false; + game.ball.velocity = MilliVec { x: 100, y: 200 }.to_velocity_per_second(); + game.secondary_ball = Some(Ball { + position: vec2(200.0, 200.0), + velocity: MilliVec { x: -300, y: 400 }.to_velocity_per_second(), + in_launcher: false, + spin: Real48::ZERO, + capture_age: 0, + }); + let mut reference = BorlandRandom::new(7); + let _discarded_active_x = reference.real48(); + let _discarded_active_y = reference.below(20); + let shared_impulse = MilliVec { + x: (i32::from(reference.below(21)) - 10) * 15, + y: -(i32::from(reference.below(100)) + 50) * 15, + }; + let _tilt_threshold = reference.below(10); + + game.apply_nudge(Nudge::Center, &mut Vec::new()); + + assert_eq!( + MilliVec::from_velocity_per_second(game.ball.velocity), + MilliVec { + x: 100 + shared_impulse.x, + y: 200 + shared_impulse.y, + } + ); + let secondary = game.secondary_ball.expect("slot two must remain active"); + assert_eq!( + MilliVec::from_velocity_per_second(secondary.velocity), + MilliVec { + x: -300 + shared_impulse.x, + y: 400 + shared_impulse.y, + } + ); + assert_eq!(game.random.seed(), reference.seed()); + } + #[test] fn claw_sprite_rects_follow_the_recovered_four_row_sheet() { let source = |frame, bank| {