From f8292dc87e92ea337db8c3a65a98386528384fac Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sun, 23 Aug 2026 17:55:59 +0200 Subject: [PATCH] fix(render): restore timed record overlays Replace the shared bumper cooldown and synthetic circle outlines with the original per-record countdown bytes. Bumpers 51-53 use five active callbacks, side targets 140-147 use twenty, and top targets 150-152 use ten; countdowns pause while the ball is idle and decrement once per active detail callback. Render each active record by copying its exact inclusive binary render bounds from overlay A at the same destination, then naturally fall back to overlay B when the countdown reaches zero. This removes invented vector effects and keeps visual lifetime tied to gameplay timer state. Test Plan: - `cargo test --all-targets` -- passed, 75 tests - `cargo clippy --all-targets -- -D warnings` -- passed - `rumdl check CHANGELOG.md RECONSTRUCTION.md` -- passed - `git diff --cached --check` -- passed --- tdkpin-rs/CHANGELOG.md | 3 +++ tdkpin-rs/RECONSTRUCTION.md | 2 +- tdkpin-rs/src/app.rs | 33 ++++++++++++++++++++---- tdkpin-rs/src/game.rs | 50 ++++++++++++++++++++++++++----------- 4 files changed, 67 insertions(+), 21 deletions(-) diff --git a/tdkpin-rs/CHANGELOG.md b/tdkpin-rs/CHANGELOG.md index d44cd37..d17adbf 100644 --- a/tdkpin-rs/CHANGELOG.md +++ b/tdkpin-rs/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to ### Fixed +- Replace invented bumper outline/cooldown effects with the original per-record + 5/20/10 callback countdowns and exact overlay-A render rectangles for bumpers + 51-53 and targets 140-147/150-152. - Replace the free-running wheel animation with the original six-callback 91x90 target rotation, WAVE 2011 start, exact DAT600 frames/target positions, and state-6 rotation of the five per-player contact/item values. diff --git a/tdkpin-rs/RECONSTRUCTION.md b/tdkpin-rs/RECONSTRUCTION.md index 011c0a8..f473dcd 100644 --- a/tdkpin-rs/RECONSTRUCTION.md +++ b/tdkpin-rs/RECONSTRUCTION.md @@ -25,7 +25,7 @@ implementation. | 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. | | 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. Dynamic records 174/175 transfer normal impulse to the other ball before applying `normal_velocity-1000` to the moving 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 separate 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. 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. | +| 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. | | Configuration | Behaviorally compatible | Sound, language, and five detail levels are retained. Storage moves from a local Win16 INI file to the platform user-data directory. | diff --git a/tdkpin-rs/src/app.rs b/tdkpin-rs/src/app.rs index 40f6e37..cce5761 100644 --- a/tdkpin-rs/src/app.rs +++ b/tdkpin-rs/src/app.rs @@ -4,7 +4,6 @@ use crate::{ persistence::{ HighScore, Language, Persistence, SavedData, insert_high_score, qualifies_high_score, }, - table::BUMPERS, }; use macroquad::prelude::*; use std::path::Path; @@ -383,10 +382,34 @@ impl App { { self.draw_target_rotation(game, state); } - for (index, bumper) in BUMPERS.into_iter().enumerate() { - if game.bumper_flash[index] > 0.0 { - draw_circle_lines(bumper.center.x, bumper.center.y, 17.0, 3.0, WHITE); - draw_circle_lines(bumper.center.x, bumper.center.y, 13.0, 2.0, YELLOW); + for (record_id, left, top, right, bottom) in [ + (51, 150, 133, 180, 163), + (52, 191, 95, 221, 125), + (53, 204, 150, 234, 180), + (140, 17, 174, 31, 188), + (141, 11, 155, 25, 169), + (142, 10, 136, 24, 150), + (143, 10, 117, 24, 131), + (144, 10, 98, 24, 112), + (145, 10, 79, 24, 93), + (146, 10, 60, 24, 79), + (147, 10, 41, 24, 55), + (150, 199, 40, 211, 50), + (151, 228, 41, 240, 51), + (152, 257, 42, 269, 52), + ] { + if game.record_countdown(record_id) != 0 { + let width = (right - left + 1) as f32; + let height = (bottom - top + 1) as f32; + draw_texture_region( + &self.assets.active_table, + left as f32, + top as f32, + width, + height, + left as f32, + top as f32, + ); } } if game.player().diamond_segments > 0 { diff --git a/tdkpin-rs/src/game.rs b/tdkpin-rs/src/game.rs index 735b3ac..c2ac7e4 100644 --- a/tdkpin-rs/src/game.rs +++ b/tdkpin-rs/src/game.rs @@ -267,7 +267,6 @@ pub struct Game { pub flippers: Flippers, flipper_inputs: Flippers, pub claw: Claw, - pub bumper_flash: [f32; 3], pub target_rotation_state: Option, pub panel_frame: Option, pub nudge_shake: f32, @@ -275,7 +274,7 @@ pub struct Game { pub finished: bool, pub last_collision_id: Option, accumulator: f32, - bumper_cooldown: f32, + record_countdowns: [u8; 176], tilt_counter: u16, tilt_counter_accumulator: f32, launcher_was_down: bool, @@ -311,7 +310,6 @@ impl Game { flippers: Flippers::default(), flipper_inputs: Flippers::default(), claw: Claw::default(), - bumper_flash: [0.0; 3], target_rotation_state: None, panel_frame: None, nudge_shake: 0.0, @@ -319,7 +317,7 @@ impl Game { finished: false, last_collision_id: None, accumulator: 0.0, - bumper_cooldown: 0.0, + record_countdowns: [0; 176], tilt_counter: 0, tilt_counter_accumulator: 0.0, launcher_was_down: false, @@ -456,6 +454,9 @@ impl Game { fn timer_tick(&mut self, elapsed: f32, substeps: u8, events: &mut Vec) { self.update_claw(elapsed, events); + if !self.ball.in_launcher { + self.update_record_countdowns(); + } self.update_target_rotation(events); let panel_active = self.update_panel_completion(events); if !self.claw.ball_suspended && !panel_active { @@ -494,6 +495,18 @@ impl Game { self.apply_flipper_kicks(); } + fn update_record_countdowns(&mut self) { + for countdown in &mut self.record_countdowns[1..] { + if *countdown != 0 { + *countdown -= 1; + } + } + } + + pub fn record_countdown(&self, record_id: usize) -> u8 { + self.record_countdowns[record_id] + } + fn update_target_rotation(&mut self, events: &mut Vec) { let Some(state) = self.target_rotation_state else { return; @@ -614,11 +627,7 @@ impl Game { #[allow(clippy::too_many_lines)] fn fixed_update(&mut self, dt: f32, events: &mut Vec) { - self.bumper_cooldown = (self.bumper_cooldown - dt).max(0.0); self.nudge_shake = (self.nudge_shake - dt).max(0.0); - for flash in &mut self.bumper_flash { - *flash = (*flash - dt).max(0.0); - } if self.ball.in_launcher { self.ball.position = LAUNCHER_POSITION; self.ball.velocity = Vec2::ZERO; @@ -701,11 +710,9 @@ impl Game { if let Some(index) = hit_circle .and_then(|circle_id| BUMPERS.iter().position(|bumper| bumper.id == circle_id)) && !self.tilted - && self.bumper_cooldown <= 0.0 { self.add_score(self.player().bumper_value, events); - self.bumper_cooldown = 0.08; - self.bumper_flash[index] = 0.16; + self.record_countdowns[usize::from(BUMPERS[index].id)] = 5; events.push(Event::Bumper); events.push(Event::Sound(2006)); } @@ -836,11 +843,9 @@ impl Game { } else if let Some((object_id, false)) = hit && let Some(index) = BUMPERS.iter().position(|bumper| bumper.id == object_id) && !self.tilted - && self.bumper_cooldown <= 0.0 { self.add_score(self.player().bumper_value, events); - self.bumper_cooldown = 0.08; - self.bumper_flash[index] = 0.16; + self.record_countdowns[usize::from(BUMPERS[index].id)] = 5; events.push(Event::Bumper); events.push(Event::Sound(2006)); } @@ -1196,6 +1201,7 @@ impl Game { self.add_score(sensor.score, events); events.push(Event::Target); events.push(Event::Sound(2004)); + self.record_countdowns[usize::from(sensor.id)] = if sensor.id <= 147 { 20 } else { 10 }; if (150..=152).contains(&sensor.id) { self.top_targets[usize::from(sensor.id - 150)] = true; let field_id = [153, 6, 154][usize::from(sensor.id - 150)]; @@ -1407,7 +1413,6 @@ impl Game { self.tilted = false; self.tilt_counter = 0; self.tilt_counter_accumulator = 0.0; - self.bumper_flash.fill(0.0); self.target_rotation_state = None; self.panel_frame = None; self.claw = Claw::default(); @@ -2519,6 +2524,21 @@ mod tests { assert_eq!(game.target_rotation_state, None); } + #[test] + fn record_countdowns_advance_once_per_active_timer_callback() { + let mut game = Game::new(1); + game.record_countdowns[51] = 5; + + game.timer_tick(0.030, 0, &mut Vec::new()); + assert_eq!(game.record_countdown(51), 5, "launcher idle pauses countdowns"); + + game.ball.in_launcher = false; + for expected in (0..5).rev() { + game.timer_tick(0.030, 0, &mut Vec::new()); + assert_eq!(game.record_countdown(51), expected); + } + } + #[test] fn claw_release_table_decodes_the_original_thousandth_pixel_coordinates() { for (frame, expected_position, expected_velocity) in [