diff --git a/tdkpin-rs/CHANGELOG.md b/tdkpin-rs/CHANGELOG.md index 6762fc4..825133a 100644 --- a/tdkpin-rs/CHANGELOG.md +++ b/tdkpin-rs/CHANGELOG.md @@ -10,6 +10,10 @@ and this project adheres to ### Fixed +- Check every player against the high-score table when their own last ball is + lost, prefill the original `TDK Pinball Player` name, show the table before + resuming the next player, and use the original signed-high/unsigned-low score + ordering instead of selecting only the maximum score at whole-game end. - Preserve each player's complete rule-record state across turn changes instead of globally clearing wheel holes, top targets, 176 active/contact slots, selected effect, and multiball readiness on every drain. diff --git a/tdkpin-rs/RECONSTRUCTION.md b/tdkpin-rs/RECONSTRUCTION.md index ce88fc0..5c72e2d 100644 --- a/tdkpin-rs/RECONSTRUCTION.md +++ b/tdkpin-rs/RECONSTRUCTION.md @@ -27,7 +27,7 @@ implementation. | Physics arithmetic | Recovered gameplay behavior | Production movement uses the original 10 ms millipixel substep, `+15` vertical acceleration, `3800` speed bound, point-path type-2 intersection, one-sided line response, swept type-1 circle response, and swept non-physical sensor contacts. It evaluates all records and applies the earliest contact along the substep. The original Borland seed update and high-word `Random(n)` mapping drive launcher variation, effects, claw terminals, and the recovered randomized magnetic-field impulse. Live probes cover ordinary rails, ordinary circles, a kicked bumper, lock holes, magnetic fields, all claw exits, and both flipper directions. Rust represents the original per-ball contact words as path-entry/inside latches; this is a source-structure difference rather than a missing collision route. | | 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. 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. Remaining timing uncertainty is presentation batching at non-default detail settings, not gameplay routing. | | 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 | Compatible import | The original 276-byte table is decoded as ten `IWIK`-XOR-obfuscated little-endian scores plus ten 22-byte names, sorted, then migrated to portable JSON. | +| 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. | | Windows UI shell | Deliberately modernized | Win16 menus, modal dialogs, GDI blitting, and multimedia timers are replaced by a fixed native window with keyboard overlays. The visible game and original help render one-for-one at the original 640x460 pixels. | diff --git a/tdkpin-rs/src/app.rs b/tdkpin-rs/src/app.rs index d734137..0cd4da6 100644 --- a/tdkpin-rs/src/app.rs +++ b/tdkpin-rs/src/app.rs @@ -1,7 +1,9 @@ use crate::{ assets::Assets, game::{Controls, Event, Game, Nudge}, - persistence::{HighScore, Language, Persistence, SavedData, insert_high_score}, + persistence::{ + HighScore, Language, Persistence, SavedData, insert_high_score, qualifies_high_score, + }, table::BUMPERS, }; use macroquad::prelude::*; @@ -191,10 +193,23 @@ impl App { let events = self.game.as_mut().map_or_else(Vec::new, |game| { game.update(get_frame_time(), self.saved.settings.speed, controls) }); + let mut high_score_candidate = None; for event in events { + if let Event::HighScoreCandidate(score) = event { + high_score_candidate = Some(score); + } self.play_event(event); } + if let Some(score) = high_score_candidate + && qualifies_high_score(&self.saved.high_scores, score) + { + self.pending_score = score; + "TDK Pinball Player".clone_into(&mut self.name); + self.screen = Screen::NameEntry; + return; + } + if self.game.as_ref().is_some_and(|game| game.finished) { self.finish_game(); } @@ -207,26 +222,9 @@ impl App { } fn finish_game(&mut self) { - self.pending_score = self - .game - .as_ref() - .and_then(|game| game.players.iter().map(|player| player.score).max()) - .unwrap_or(0); - let qualifies = self.saved.high_scores.len() < 10 - || self - .saved - .high_scores - .last() - .is_some_and(|entry| self.pending_score > entry.score); - if qualifies { - self.name.clear(); - self.screen = Screen::NameEntry; - } else { - self.game = None; - self.return_screen = Screen::Attract; - self.screen = Screen::HighScores; - } - self.assets.play(2020, self.saved.settings.sounds); + self.game = None; + self.return_screen = Screen::Attract; + self.screen = Screen::HighScores; } fn update_help(&mut self) { @@ -315,8 +313,12 @@ impl App { }, ); self.save(); - self.game = None; - self.return_screen = Screen::Attract; + if self.game.as_ref().is_some_and(|game| game.finished) { + self.game = None; + self.return_screen = Screen::Attract; + } else { + self.return_screen = Screen::Playing; + } self.screen = Screen::HighScores; } } diff --git a/tdkpin-rs/src/game.rs b/tdkpin-rs/src/game.rs index fa015aa..1d0a61e 100644 --- a/tdkpin-rs/src/game.rs +++ b/tdkpin-rs/src/game.rs @@ -67,6 +67,7 @@ pub enum Event { Nudge, Tilt, Drain, + HighScoreCandidate(u32), Sound(u16), } @@ -87,7 +88,8 @@ impl Event { | Self::ExtraBall | Self::Nudge | Self::Tilt - | Self::Drain => None, + | Self::Drain + | Self::HighScoreCandidate(_) => None, } } } @@ -1190,9 +1192,14 @@ impl Game { } else { player.balls = player.balls.saturating_sub(1); } + let high_score_candidate = + (player.balls == 0 && player.extra_balls == 0).then_some(player.score); self.save_current_rule_state(); events.push(Event::Drain); events.push(Event::Sound(2008)); + if let Some(score) = high_score_candidate { + events.push(Event::HighScoreCandidate(score)); + } self.tilted = false; self.tilt_counter = 0; self.tilt_counter_accumulator = 0.0; @@ -1634,6 +1641,26 @@ mod tests { assert_eq!(game.multiball_state, MultiballState::Ready); } + #[test] + fn each_player_reports_a_high_score_candidate_on_their_last_ball() { + let mut game = Game::new(2); + game.players[0].balls = 1; + game.players[0].score = 123_456; + let mut events = Vec::new(); + + game.drain(&mut events); + assert!(events.contains(&Event::HighScoreCandidate(123_456))); + assert_eq!(game.current_player, 1); + assert!(!game.finished); + + events.clear(); + game.players[1].balls = 1; + game.players[1].score = 654_321; + game.drain(&mut events); + assert!(events.contains(&Event::HighScoreCandidate(654_321))); + assert!(game.finished); + } + #[test] fn fast_ball_cannot_tunnel_through_the_top_rail() { let mut game = Game::new(1); diff --git a/tdkpin-rs/src/persistence.rs b/tdkpin-rs/src/persistence.rs index caec789..23f8127 100644 --- a/tdkpin-rs/src/persistence.rs +++ b/tdkpin-rs/src/persistence.rs @@ -1,6 +1,6 @@ use directories::ProjectDirs; use serde::{Deserialize, Serialize}; -use std::{cmp::Reverse, fs, io, path::PathBuf}; +use std::{fs, io, path::PathBuf}; const ORIGINAL_HIGHSCORES: &[u8] = include_bytes!("../assets/original/HISCORES.DAT"); @@ -125,7 +125,7 @@ pub fn parse_original_high_scores(bytes: &[u8]) -> Vec { let scores = &bytes[HEADER..HEADER + COUNT * 4]; let names = &bytes[HEADER + COUNT * 4..]; - let mut result = (0..COUNT) + (0..COUNT) .map(|index| { let score_start = index * 4; let score = u32::from_le_bytes([ @@ -145,17 +145,38 @@ pub fn parse_original_high_scores(bytes: &[u8]) -> Vec { score, } }) - .collect::>(); - result.sort_by_key(|entry| Reverse(entry.score)); - result + .collect() } pub fn insert_high_score(scores: &mut Vec, entry: HighScore) { - scores.push(entry); - scores.sort_by_key(|entry| Reverse(entry.score)); + let Some(index) = scores + .iter() + .position(|existing| score_is_greater(entry.score, existing.score)) + .or_else(|| (scores.len() < 10).then_some(scores.len())) + else { + return; + }; + scores.insert(index, entry); scores.truncate(10); } +pub fn qualifies_high_score(scores: &[HighScore], score: u32) -> bool { + scores.len() < 10 + || scores + .last() + .is_some_and(|entry| score_is_greater(score, entry.score)) +} + +fn score_is_greater(left: u32, right: u32) -> bool { + let [left_0, left_1, left_2, left_3] = left.to_le_bytes(); + let [right_0, right_1, right_2, right_3] = right.to_le_bytes(); + let left_high = i16::from_le_bytes([left_2, left_3]); + let right_high = i16::from_le_bytes([right_2, right_3]); + let left_low = u16::from_le_bytes([left_0, left_1]); + let right_low = u16::from_le_bytes([right_0, right_1]); + left_high > right_high || (left_high == right_high && left_low > right_low) +} + #[cfg(test)] mod tests { use super::*; @@ -181,16 +202,30 @@ mod tests { } #[test] - fn new_score_is_inserted_and_table_stays_bounded() { + fn qualifying_score_is_inserted_and_table_stays_bounded() { let mut scores = parse_original_high_scores(ORIGINAL_HIGHSCORES); insert_high_score( &mut scores, HighScore { name: "TEST".to_owned(), - score: u32::MAX, + score: 6_537_393, }, ); assert_eq!(scores.len(), 10); assert_eq!(scores[0].name, "TEST"); } + + #[test] + fn comparison_uses_the_original_signed_high_word() { + let mut scores = parse_original_high_scores(ORIGINAL_HIGHSCORES); + insert_high_score( + &mut scores, + HighScore { + name: "WRAPPED".to_owned(), + score: u32::MAX, + }, + ); + assert!(!scores.iter().any(|entry| entry.name == "WRAPPED")); + assert!(!qualifies_high_score(&scores, u32::MAX)); + } }