diff --git a/tdkpin-rs/CHANGELOG.md b/tdkpin-rs/CHANGELOG.md index 4fd8335..5103902 100644 --- a/tdkpin-rs/CHANGELOG.md +++ b/tdkpin-rs/CHANGELOG.md @@ -10,6 +10,10 @@ and this project adheres to ### Fixed +- Restore the original idle/attract timer animation: target and word-quad + chases, magnetic flashes, five-record strip, four-point chase, DAT801-809 + item progression, and the circular `WELCOME TO THE MACHINE` BITMAP500 + marquee. Move gameplay item frames to their exact `(123,300)` destination. - Match central score/event side effects: every stored collision candidate clears its ball slot's capture age, Tilt suppresses all score and rule-record mutations, and active multiball doubles points before permanent Double can diff --git a/tdkpin-rs/README.md b/tdkpin-rs/README.md index ba766dc..b54116e 100644 --- a/tdkpin-rs/README.md +++ b/tdkpin-rs/README.md @@ -44,8 +44,9 @@ cargo run -- --simulate claw-6 --at 0.15 \ ``` Use `--step N` instead of `--at SECONDS` to reproduce one exact update. The -available scenarios are `autoplay`, `launcher`, `flippers`, `panel`, `targets`, -`claw-1`, `claw-6`, `claw-7`, and `claw-18`; `--seed N` fixes random choices. +available scenarios are `attract`, `autoplay`, `launcher`, `flippers`, `panel`, +`targets`, `claw-1`, `claw-6`, `claw-7`, and `claw-18`; `--seed N` fixes +random choices. `autoplay` charges each ball and operates the flippers from live ball position for long end-to-end validation runs. diff --git a/tdkpin-rs/RECONSTRUCTION.md b/tdkpin-rs/RECONSTRUCTION.md index 8df8e7e..e03aeb5 100644 --- a/tdkpin-rs/RECONSTRUCTION.md +++ b/tdkpin-rs/RECONSTRUCTION.md @@ -29,7 +29,7 @@ implementation. | 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. The central helper suppresses all mutation during Tilt, applies a 2x active-multiball factor, and then independently applies the permanent 2x factor, allowing the original 4x stack. 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. | -| Windows UI shell | Original gameplay surface with native host shell | Win16 window ownership and GDI calls are replaced by a fixed native window, but the visible game, idle table, help resources, automatic per-player high-score flow, original keys, and 640x460 logical pixels are retained. F2/F3 remain unassigned as in normal original operation; optional portable settings and table viewers use F10/F9 and do not replace gameplay input. | +| Windows UI shell | Original gameplay surface with native host shell | Win16 window ownership and GDI calls are replaced by a fixed native window, but the visible game, help resources, automatic per-player high-score flow, original keys, and 640x460 logical pixels are retained. Idle mode advances on the selected detail callback and reproduces `idle_transition_tick`: target/word-quad/record-strip chases, magnetic flashes, four-point chase, DAT801-809 item progression, and the circular BITMAP500 `WELCOME TO THE MACHINE` marquee composited over exact DAT997 regions. F2/F3 remain unassigned as in normal original operation; optional portable settings and table viewers use F10/F9 and do not replace gameplay input. | ## Extracted asset inventory diff --git a/tdkpin-rs/src/app.rs b/tdkpin-rs/src/app.rs index cce5761..126cba0 100644 --- a/tdkpin-rs/src/app.rs +++ b/tdkpin-rs/src/app.rs @@ -10,6 +10,7 @@ use std::path::Path; const WIDTH: f32 = 640.0; const HEIGHT: f32 = 460.0; +const DETAIL_TIMER_SECONDS: [f32; 5] = [0.050, 0.040, 0.030, 0.020, 0.010]; const TARGET_POSITIONS: [[(f32, f32); 5]; 6] = [ [(9.0, 41.0), (23.0, 11.0), (56.0, 14.0), (63.0, 47.0), (35.0, 63.0)], [(11.0, 45.0), (18.0, 13.0), (52.0, 11.0), (63.0, 42.0), (39.0, 64.0)], @@ -30,6 +31,79 @@ enum Screen { NameEntry, } +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct AttractAnimation { + tick: u32, + score_counter: u32, + accumulator: f32, +} + +impl AttractAnimation { + fn update(&mut self, frame_time: f32, detail: u8) { + let interval = DETAIL_TIMER_SECONDS[usize::from(detail.clamp(1, 5) - 1)]; + self.accumulator = (self.accumulator + frame_time.min(0.05)).min(0.1); + while self.accumulator >= interval { + self.tick = self.tick.wrapping_add(1); + if self.tick.is_multiple_of(6) { + self.score_counter = self.score_counter.wrapping_add(1); + } + self.accumulator -= interval; + } + } + + fn target_active(self, index: usize) -> bool { + let start = if index == 0 { + 2 + } else { + u32::try_from(index).unwrap_or(0) * 64 + }; + let end = u32::try_from(index + 8).unwrap_or(8) * 64; + self.tick >= start && self.tick < end + } + + fn active_word_quad(self) -> Option { + (self.tick >= 4).then(|| { + let phase = (self.tick / 128).min(8); + usize::try_from(if phase <= 4 { phase } else { 8 - phase }).unwrap_or(0) + }) + } + + fn magnetic_records_active(self) -> bool { + self.tick >= 32 && self.tick % 32 < 16 + } + + fn record_strip_active(self, index: usize) -> bool { + let start = if index == 0 { + 4 + } else { + u32::try_from(index).unwrap_or(0) * 160 + }; + let end = u32::try_from(index + 5).unwrap_or(5) * 160; + self.tick >= start && self.tick < end + } + + fn chase_index(self) -> Option { + (self.tick >= 4).then(|| usize::try_from((self.tick / 64).min(3)).unwrap_or(0)) + } + + fn item_index(self) -> Option { + let phase = self.tick / 1_152; + let item = if phase < 10 { + phase + } else { + 18_u32.saturating_sub(phase) + }; + (item != 0).then(|| usize::try_from(item - 1).unwrap_or(0)) + } + + fn marquee_source_x(self) -> Option { + (self.score_counter != 0).then(|| { + let phase = self.score_counter % 49; + f32::from(u16::try_from(160 + ((phase + 14) % 49) * 16).unwrap_or(160)) + }) + } +} + pub struct App { assets: Assets, persistence: Persistence, @@ -45,6 +119,7 @@ pub struct App { message_until: f64, name: String, pending_score: u32, + attract: AttractAnimation, } impl App { @@ -69,6 +144,7 @@ impl App { message_until: 0.0, name: String::new(), pending_score: 0, + attract: AttractAnimation::default(), } } @@ -97,6 +173,15 @@ impl App { self.screen = Screen::Playing; } + pub fn set_simulation_attract(&mut self, steps: u64) { + self.game = None; + self.screen = Screen::Attract; + self.attract = AttractAnimation::default(); + for _ in 0..steps { + self.attract.update(1.0 / 120.0, 3); + } + } + pub fn render_simulation(&self) { self.draw_logical(); } @@ -159,6 +244,8 @@ impl App { } fn update_attract(&mut self) { + self.attract + .update(get_frame_time(), self.saved.settings.speed); if is_key_pressed(KeyCode::KpAdd) || is_key_pressed(KeyCode::Equal) { self.game = Some(Game::new(1)); self.screen = Screen::Playing; @@ -303,6 +390,9 @@ impl App { || is_key_pressed(KeyCode::F3) { self.screen = self.return_screen; + if self.screen == Screen::Attract { + self.attract = AttractAnimation::default(); + } } } @@ -363,6 +453,135 @@ impl App { fn draw_attract(&self) { draw_texture(&self.assets.inactive_table, 0.0, 0.0, WHITE); + + for (index, (left, top, right, bottom)) in [ + (17, 174, 31, 188), + (11, 155, 25, 169), + (10, 136, 24, 150), + (10, 117, 24, 131), + (10, 98, 24, 112), + (10, 79, 24, 93), + (10, 60, 24, 79), + (10, 41, 24, 55), + ] + .into_iter() + .enumerate() + { + if self.attract.target_active(index) { + self.draw_active_table_region(left, top, right - left + 1, bottom - top + 1); + } + } + + if let Some(active_quad) = self.attract.active_word_quad() { + let (left, top, right, bottom) = [ + (103, 304, 121, 323), + (119, 280, 137, 299), + (148, 272, 166, 291), + (177, 280, 195, 299), + (193, 303, 211, 322), + ][active_quad]; + self.draw_active_table_region(left, top, right - left + 1, bottom - top + 1); + } + + if self.attract.magnetic_records_active() { + for (x, y, width, height) in [ + (151, 389, 13, 49), + (11, 344, 13, 49), + (291, 346, 13, 49), + ] { + self.draw_active_table_region(x, y, width, height); + } + } + + for (index, (left, top, right, bottom)) in [ + (282, 231, 306, 246), + (282, 217, 306, 231), + (282, 202, 306, 216), + (282, 187, 306, 201), + (282, 174, 306, 186), + ] + .into_iter() + .enumerate() + { + if self.attract.record_strip_active(index) { + self.draw_active_table_region(left, top, right - left - 10, bottom - top + 1); + } + } + + if let Some(phase) = self.attract.chase_index() { + let y = [209, 214, 219, 224][phase]; + let x = [150, 164, 178, 192][phase]; + self.draw_active_table_region(x, y, 9, 9); + } + + if let Some(item) = self.attract.item_index() { + draw_texture( + &self.assets.diamond[item], + 123.0, + 300.0, + WHITE, + ); + } + self.draw_intro_marquee(); + } + + fn draw_intro_marquee(&self) { + let Some(source_x) = self.attract.marquee_source_x() else { + return; + }; + draw_texture_region( + &self.assets.active_table, + 372.0, + 233.0, + 100.0, + 30.0, + 473.0, + 277.0, + ); + draw_texture_region( + &self.assets.active_table, + 472.0, + 233.0, + 137.0, + 30.0, + 472.0, + 233.0, + ); + + let available = 944.0 - source_x; + let first_width = available.min(237.0); + self.draw_marquee_slice(372.0, source_x, first_width); + let wrapped_width = 237.0 - first_width; + if wrapped_width > 0.0 { + self.draw_marquee_slice(372.0 + first_width, 160.0, wrapped_width); + } + } + + fn draw_marquee_slice(&self, destination_x: f32, source_x: f32, width: f32) { + draw_texture_ex( + &self.assets.digits, + destination_x, + 233.0, + BLACK, + DrawTextureParams { + dest_size: Some(vec2(width, 28.0)), + source: Some(Rect::new(source_x, 0.0, width, 28.0)), + ..Default::default() + }, + ); + } + + #[allow(clippy::cast_precision_loss)] + fn draw_active_table_region(&self, x: i32, y: i32, width: i32, height: i32) { + draw_texture_region( + &self.assets.active_table, + x as f32, + y as f32, + width as f32, + height as f32, + x as f32, + y as f32, + ); } #[allow(clippy::cast_precision_loss, clippy::too_many_lines)] @@ -414,7 +633,7 @@ impl App { } if game.player().diamond_segments > 0 { let index = usize::from(game.player().diamond_segments.min(9) - 1); - draw_texture(&self.assets.diamond[index], 123.0, 298.0, WHITE); + draw_texture(&self.assets.diamond[index], 123.0, 300.0, WHITE); } for (object_id, region) in [ (6, Rect::new(151.0, 389.0, 13.0, 49.0)), @@ -970,3 +1189,71 @@ fn draw_centered_at(text: &str, center_x: f32, baseline: f32, font_size: u16, co color, ); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn attract_timer_uses_the_selected_detail_callback() { + for (detail, interval) in [ + (1, 0.050), + (2, 0.040), + (3, 0.030), + (4, 0.020), + (5, 0.010), + ] { + let mut animation = AttractAnimation::default(); + animation.update(interval - 0.001, detail); + assert_eq!(animation.tick, 0); + animation.update(0.001_1, detail); + assert_eq!(animation.tick, 1); + } + } + + #[test] + fn attract_phases_follow_the_reconstructed_tick_quotients() { + let at = |tick| AttractAnimation { + tick, + ..AttractAnimation::default() + }; + + assert!(!at(1).target_active(0)); + assert!(at(2).target_active(0)); + assert!(at(64).target_active(1)); + assert!(!at(512).target_active(0)); + assert_eq!(at(3).active_word_quad(), None); + assert_eq!(at(4).active_word_quad(), Some(0)); + assert_eq!(at(512).active_word_quad(), Some(4)); + assert_eq!(at(640).active_word_quad(), Some(3)); + assert!(!at(31).magnetic_records_active()); + assert!(at(32).magnetic_records_active()); + assert!(!at(48).magnetic_records_active()); + assert!(at(4).record_strip_active(0)); + assert!(at(160).record_strip_active(1)); + assert!(!at(800).record_strip_active(0)); + assert_eq!(at(4).chase_index(), Some(0)); + assert_eq!(at(192).chase_index(), Some(3)); + assert_eq!(at(1_151).item_index(), None); + assert_eq!(at(1_152).item_index(), Some(0)); + assert_eq!(at(10_368).item_index(), Some(8)); + assert_eq!(at(11_520).item_index(), Some(7)); + assert_eq!(at(20_736).item_index(), None); + } + + #[test] + fn attract_marquee_counter_advances_every_six_callbacks() { + let mut animation = AttractAnimation::default(); + for _ in 0..5 { + animation.update(0.030, 3); + } + assert_eq!(animation.score_counter, 0); + animation.update(0.030, 3); + assert_eq!(animation.score_counter, 1); + assert_eq!(animation.marquee_source_x(), Some(400.0)); + animation.score_counter = 35; + assert_eq!(animation.marquee_source_x(), Some(160.0)); + animation.score_counter = 49; + assert_eq!(animation.marquee_source_x(), Some(384.0)); + } +} diff --git a/tdkpin-rs/src/main.rs b/tdkpin-rs/src/main.rs index 2bd783c..5502a92 100644 --- a/tdkpin-rs/src/main.rs +++ b/tdkpin-rs/src/main.rs @@ -12,7 +12,7 @@ mod table; use app::App; use macroquad::prelude::*; -use simulation::{Request, Simulation, usage}; +use simulation::{Request, Scenario, Simulation, usage}; const WINDOW_WIDTH: i32 = 640; const WINDOW_HEIGHT: i32 = 460; @@ -60,7 +60,11 @@ async fn run_simulation(request: Request) -> Result<(), String> { } if let Some(path) = &request.screenshot { let mut app = App::load().await; - app.set_simulation_game(simulation.game().clone()); + if request.scenario == Scenario::Attract { + app.set_simulation_attract(request.target_step); + } else { + app.set_simulation_game(simulation.game().clone()); + } app.render_simulation(); next_frame().await; app.export_simulation_png(path)?; diff --git a/tdkpin-rs/src/simulation.rs b/tdkpin-rs/src/simulation.rs index afe105d..131fbaf 100644 --- a/tdkpin-rs/src/simulation.rs +++ b/tdkpin-rs/src/simulation.rs @@ -13,6 +13,7 @@ const MAX_SIMULATION_STEPS: u64 = 72_000; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Scenario { + Attract, Autoplay, Launcher, Flippers, @@ -27,6 +28,7 @@ pub enum Scenario { impl Scenario { pub const fn name(self) -> &'static str { match self { + Self::Attract => "attract", Self::Autoplay => "autoplay", Self::Launcher => "launcher", Self::Flippers => "flippers", @@ -45,7 +47,12 @@ impl Scenario { Self::Claw6 => Some(6), Self::Claw7 => Some(7), Self::Claw18 => Some(18), - Self::Autoplay | Self::Launcher | Self::Flippers | Self::Panel | Self::Targets => None, + Self::Attract + | Self::Autoplay + | Self::Launcher + | Self::Flippers + | Self::Panel + | Self::Targets => None, } } } @@ -55,6 +62,7 @@ impl FromStr for Scenario { fn from_str(value: &str) -> Result { match value { + "attract" => Ok(Self::Attract), "autoplay" => Ok(Self::Autoplay), "launcher" => Ok(Self::Launcher), "flippers" => Ok(Self::Flippers), @@ -65,7 +73,7 @@ impl FromStr for Scenario { "claw-7" => Ok(Self::Claw7), "claw-18" => Ok(Self::Claw18), _ => Err(format!( - "unknown scenario {value:?}; expected autoplay, launcher, flippers, panel, targets, claw-1, claw-6, claw-7, or claw-18" + "unknown scenario {value:?}; expected attract, autoplay, launcher, flippers, panel, targets, claw-1, claw-6, claw-7, or claw-18" )), } } @@ -149,7 +157,7 @@ fn seconds_to_step(value: &str) -> Result { } pub const fn usage() -> &'static str { - "Usage:\n tdkpin-rs\n tdkpin-rs --simulate SCENARIO [--at SECONDS | --step N] [--screenshot FILE.png] [--trace FILE.json] [--seed N]\n\nScenarios: autoplay, launcher, flippers, panel, targets, claw-1, claw-6, claw-7, claw-18" + "Usage:\n tdkpin-rs\n tdkpin-rs --simulate SCENARIO [--at SECONDS | --step N] [--screenshot FILE.png] [--trace FILE.json] [--seed N]\n\nScenarios: attract, autoplay, launcher, flippers, panel, targets, claw-1, claw-6, claw-7, claw-18" } #[derive(Debug, Serialize, PartialEq)] @@ -230,7 +238,11 @@ impl Simulation { } else { controls_for(self.scenario, self.step) }; - let events = self.game.update(SIMULATION_DT, 3, controls); + let events = if self.scenario == Scenario::Attract { + Vec::new() + } else { + self.game.update(SIMULATION_DT, 3, controls) + }; self.step += 1; self.record(events); } @@ -320,6 +332,7 @@ impl Simulation { fn controls_for(scenario: Scenario, step: u64) -> Controls { match scenario { + Scenario::Attract => Controls::default(), Scenario::Autoplay => unreachable!("autoplay controls need mutable simulation state"), Scenario::Launcher => Controls { launch_down: step < u64::from(SIMULATION_HZ),