diff --git a/tdkpin-rs/README.md b/tdkpin-rs/README.md index c254c98..219e4df 100644 --- a/tdkpin-rs/README.md +++ b/tdkpin-rs/README.md @@ -31,6 +31,22 @@ development packages are also required (X11, OpenGL, and ALSA). Windows needs no extra runtime installation; macOS builds with the normal Apple developer command-line tools. +## Deterministic mechanics validation + +Named scenarios can be advanced without waiting in real time. The simulator +uses exact 120 Hz steps, prints its final state as JSON, and can write both the +complete step trace and the original-size 640x460 framebuffer: + +```sh +cargo run -- --simulate claw-6 --at 0.15 \ + --trace /tmp/claw-6.json \ + --screenshot /tmp/claw-6.png +``` + +Use `--step N` instead of `--at SECONDS` to reproduce one exact update. The +available scenarios are `launcher`, `flippers`, `claw-1`, `claw-6`, `claw-7`, +and `claw-18`; `--seed N` fixes random choices for later seeded scenarios. + ## Original and modern controls | Action | Original key | Additional modern key | diff --git a/tdkpin-rs/RECONSTRUCTION.md b/tdkpin-rs/RECONSTRUCTION.md index ddc018d..12a9806 100644 --- a/tdkpin-rs/RECONSTRUCTION.md +++ b/tdkpin-rs/RECONSTRUCTION.md @@ -57,6 +57,10 @@ decoded, build-ready subset; it does not replace that evidence archive. - Runtime coverage: the Linux executable was launched through the real window backend, the attract screen was inspected, a game was started, a ball was launched, collision scoring was observed, and a rendered frame was captured. +- Deterministic mechanics coverage: named launcher, flipper, and all four claw + terminal scenarios advance at exact 120 Hz steps, record JSON state/event + traces, and can export the logical 640x460 render target at any requested + step for visual inspection. - Semantic boundary: no claim is made that every trajectory or score tick is bit-identical to the 16-bit executable. Remaining numeric inference is listed above instead of being presented as proven parity. diff --git a/tdkpin-rs/src/app.rs b/tdkpin-rs/src/app.rs index 9865595..4e6ee99 100644 --- a/tdkpin-rs/src/app.rs +++ b/tdkpin-rs/src/app.rs @@ -5,6 +5,7 @@ use crate::{ table::BUMPERS, }; use macroquad::prelude::*; +use std::path::Path; const WIDTH: f32 = 640.0; const HEIGHT: f32 = 460.0; @@ -82,6 +83,26 @@ impl App { self.present(); } + pub fn set_simulation_game(&mut self, game: Game) { + self.game = Some(game); + self.screen = Screen::Playing; + } + + pub fn render_simulation(&self) { + self.draw_logical(); + } + + pub fn export_simulation_png(&self, path: &Path) -> Result<(), String> { + let path = path + .to_str() + .ok_or_else(|| format!("screenshot path is not valid UTF-8: {}", path.display()))?; + self.render_target + .texture + .get_texture_data() + .export_png(path); + Ok(()) + } + fn handle_global_input(&mut self) { if is_key_pressed(KeyCode::F12) { self.saved.settings.sounds = !self.saved.settings.sounds; diff --git a/tdkpin-rs/src/game.rs b/tdkpin-rs/src/game.rs index 18396b5..dffa097 100644 --- a/tdkpin-rs/src/game.rs +++ b/tdkpin-rs/src/game.rs @@ -193,7 +193,7 @@ impl Default for Ball { } } -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct Game { pub players: Vec, pub current_player: usize, @@ -275,6 +275,15 @@ impl Game { true } + pub(crate) fn begin_claw_scenario(&mut self, terminal_frame: u8) -> Vec { + self.ball.in_launcher = false; + self.ball.position = CLAW_TRIGGER_CENTER; + self.ball.velocity = Vec2::ZERO; + let mut events = Vec::new(); + self.begin_claw_capture(terminal_frame, &mut events); + events + } + fn fire_launcher(&mut self) { let launch_speed = LAUNCH_SPEED_MIN + self.launcher_charge * LAUNCH_SPEED_RANGE; self.ball.in_launcher = false; diff --git a/tdkpin-rs/src/main.rs b/tdkpin-rs/src/main.rs index e7b0d21..bda8ce4 100644 --- a/tdkpin-rs/src/main.rs +++ b/tdkpin-rs/src/main.rs @@ -3,10 +3,12 @@ mod assets; mod game; mod geometry; mod persistence; +mod simulation; mod table; use app::App; use macroquad::prelude::*; +use simulation::{Request, Simulation, usage}; const WINDOW_WIDTH: i32 = 960; const WINDOW_HEIGHT: i32 = 690; @@ -24,9 +26,44 @@ fn window_conf() -> Conf { #[macroquad::main(window_conf)] async fn main() { + let request = match Request::parse(std::env::args()) { + Ok(request) => request, + Err(error) => { + eprintln!("{error}"); + return; + } + }; + if let Some(request) = request { + if let Err(error) = run_simulation(request).await { + eprintln!("{error}\n\n{}", usage()); + } + return; + } + let mut app = App::load().await; loop { app.frame(); next_frame().await; } } + +async fn run_simulation(request: Request) -> Result<(), String> { + let mut simulation = Simulation::new(request.scenario, request.seed); + simulation.advance_to(request.target_step); + + if let Some(path) = &request.trace { + simulation.write_trace(path)?; + } + if let Some(path) = &request.screenshot { + let mut app = App::load().await; + app.set_simulation_game(simulation.game().clone()); + app.render_simulation(); + next_frame().await; + app.export_simulation_png(path)?; + } + + let final_state = serde_json::to_string_pretty(simulation.final_snapshot()) + .map_err(|error| format!("could not encode final simulation state: {error}"))?; + println!("{final_state}"); + Ok(()) +} diff --git a/tdkpin-rs/src/simulation.rs b/tdkpin-rs/src/simulation.rs new file mode 100644 index 0000000..c3c5970 --- /dev/null +++ b/tdkpin-rs/src/simulation.rs @@ -0,0 +1,393 @@ +use crate::game::{ClawSpriteBank, Controls, Event, Game}; +use serde::Serialize; +use std::{ + fs, + path::{Path, PathBuf}, + str::FromStr, +}; + +pub const SIMULATION_HZ: u32 = 120; +const SIMULATION_HZ_F64: f64 = 120.0; +const SIMULATION_DT: f32 = 1.0 / 120.0; +const MAX_SIMULATION_STEPS: u64 = 72_000; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Scenario { + Launcher, + Flippers, + Claw1, + Claw6, + Claw7, + Claw18, +} + +impl Scenario { + pub const fn name(self) -> &'static str { + match self { + Self::Launcher => "launcher", + Self::Flippers => "flippers", + Self::Claw1 => "claw-1", + Self::Claw6 => "claw-6", + Self::Claw7 => "claw-7", + Self::Claw18 => "claw-18", + } + } + + const fn claw_terminal(self) -> Option { + match self { + Self::Claw1 => Some(1), + Self::Claw6 => Some(6), + Self::Claw7 => Some(7), + Self::Claw18 => Some(18), + Self::Launcher | Self::Flippers => None, + } + } +} + +impl FromStr for Scenario { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "launcher" => Ok(Self::Launcher), + "flippers" => Ok(Self::Flippers), + "claw-1" => Ok(Self::Claw1), + "claw-6" => Ok(Self::Claw6), + "claw-7" => Ok(Self::Claw7), + "claw-18" => Ok(Self::Claw18), + _ => Err(format!( + "unknown scenario {value:?}; expected launcher, flippers, claw-1, claw-6, claw-7, or claw-18" + )), + } + } +} + +#[derive(Debug, PartialEq)] +pub struct Request { + pub scenario: Scenario, + pub target_step: u64, + pub screenshot: Option, + pub trace: Option, + pub seed: u32, +} + +impl Request { + pub fn parse(args: impl IntoIterator) -> Result, String> { + let mut arguments = args.into_iter(); + let _program = arguments.next(); + let Some(first) = arguments.next() else { + return Ok(None); + }; + if first != "--simulate" { + return Err(format!("unknown argument {first:?}\n\n{}", usage())); + } + let scenario = arguments + .next() + .ok_or_else(|| format!("--simulate requires a scenario\n\n{}", usage()))? + .parse()?; + let mut request = Self { + scenario, + target_step: u64::from(SIMULATION_HZ), + screenshot: None, + trace: None, + seed: 0x5444_4b50, + }; + + while let Some(option) = arguments.next() { + let value = arguments + .next() + .ok_or_else(|| format!("{option} requires a value"))?; + match option.as_str() { + "--at" => request.target_step = seconds_to_step(&value)?, + "--step" => { + request.target_step = value + .parse() + .map_err(|_| format!("invalid step {value:?}"))?; + } + "--screenshot" => request.screenshot = Some(PathBuf::from(value)), + "--trace" => request.trace = Some(PathBuf::from(value)), + "--seed" => { + request.seed = value + .parse() + .map_err(|_| format!("invalid seed {value:?}"))?; + } + _ => return Err(format!("unknown simulation option {option:?}")), + } + } + if request.target_step > MAX_SIMULATION_STEPS { + return Err(format!( + "requested step {} exceeds the ten-minute validation limit {MAX_SIMULATION_STEPS}", + request.target_step + )); + } + Ok(Some(request)) + } +} + +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +fn seconds_to_step(value: &str) -> Result { + let seconds: f64 = value + .parse() + .map_err(|_| format!("invalid simulation time {value:?}"))?; + if !seconds.is_finite() || seconds < 0.0 { + return Err("simulation time must be a finite non-negative number".to_owned()); + } + if seconds > 600.0 { + return Err("simulation time exceeds the ten-minute validation limit".to_owned()); + } + let step = (seconds * SIMULATION_HZ_F64).round(); + Ok(step as u64) +} + +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: launcher, flippers, claw-1, claw-6, claw-7, claw-18" +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct Snapshot { + pub scenario: &'static str, + pub step: u64, + pub seconds: f64, + pub ball: BallSnapshot, + pub claw: ClawSnapshot, + pub flippers: FlipperSnapshot, + pub launcher_charge: f32, + pub events: Vec, +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct BallSnapshot { + pub x: f32, + pub y: f32, + pub velocity_x: f32, + pub velocity_y: f32, + pub in_launcher: bool, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct ClawSnapshot { + pub active: bool, + pub frame: u8, + pub bank: &'static str, + pub ball_suspended: bool, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct FlipperSnapshot { + pub left_raised: bool, + pub right_raised: bool, +} + +pub struct Simulation { + scenario: Scenario, + step: u64, + game: Game, + trace: Vec, +} + +impl Simulation { + pub fn new(scenario: Scenario, seed: u32) -> Self { + let mut game = Game::new_with_seed(1, seed); + let initial_events = scenario + .claw_terminal() + .map_or_else(Vec::new, |terminal| game.begin_claw_scenario(terminal)); + let mut simulation = Self { + scenario, + step: 0, + game, + trace: Vec::new(), + }; + simulation.record(initial_events); + simulation + } + + pub fn advance_to(&mut self, target_step: u64) { + while self.step < target_step { + let controls = controls_for(self.scenario, self.step); + let events = self.game.update(SIMULATION_DT, 3, controls); + self.step += 1; + self.record(events); + } + } + + pub fn game(&self) -> &Game { + &self.game + } + + pub fn final_snapshot(&self) -> &Snapshot { + self.trace + .last() + .expect("a simulation always records step zero") + } + + pub fn write_trace(&self, path: &Path) -> Result<(), String> { + let json = serde_json::to_vec_pretty(&self.trace) + .map_err(|error| format!("could not encode simulation trace: {error}"))?; + fs::write(path, json) + .map_err(|error| format!("could not write trace {}: {error}", path.display())) + } + + fn record(&mut self, events: Vec) { + let step = u32::try_from(self.step).expect("simulation step is limited to 72000"); + let claw_bank = match self.game.claw.bank { + ClawSpriteBank::Closing => "closing", + ClawSpriteBank::Opening => "opening", + }; + self.trace.push(Snapshot { + scenario: self.scenario.name(), + step: self.step, + seconds: f64::from(step) / SIMULATION_HZ_F64, + ball: BallSnapshot { + x: self.game.ball.position.x, + y: self.game.ball.position.y, + velocity_x: self.game.ball.velocity.x, + velocity_y: self.game.ball.velocity.y, + in_launcher: self.game.ball.in_launcher, + }, + claw: ClawSnapshot { + active: self.game.claw.active, + frame: self.game.claw.frame, + bank: claw_bank, + ball_suspended: self.game.claw.ball_suspended, + }, + flippers: FlipperSnapshot { + left_raised: self.game.flippers.left_raised, + right_raised: self.game.flippers.right_raised, + }, + launcher_charge: self.game.launcher_charge, + events: events + .into_iter() + .map(|event| format!("{event:?}")) + .collect(), + }); + } +} + +fn controls_for(scenario: Scenario, step: u64) -> Controls { + match scenario { + Scenario::Launcher => Controls { + launch_down: step < u64::from(SIMULATION_HZ), + ..Controls::default() + }, + Scenario::Flippers => { + let phase = step % u64::from(SIMULATION_HZ); + Controls { + left_flipper: (15..45).contains(&phase), + right_flipper: (60..90).contains(&phase), + ..Controls::default() + } + } + Scenario::Claw1 | Scenario::Claw6 | Scenario::Claw7 | Scenario::Claw18 => { + Controls::default() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_owned()).collect() + } + + #[test] + fn interactive_run_has_no_simulation_request() { + assert_eq!(Request::parse(args(&["tdkpin-rs"])), Ok(None)); + } + + #[test] + fn simulation_request_accepts_time_outputs_and_seed() { + let request = Request::parse(args(&[ + "tdkpin-rs", + "--simulate", + "claw-7", + "--at", + "0.15", + "--screenshot", + "frame.png", + "--trace", + "trace.json", + "--seed", + "42", + ])) + .expect("request should parse") + .expect("simulation should be selected"); + + assert_eq!(request.scenario, Scenario::Claw7); + assert_eq!(request.target_step, 18); + assert_eq!(request.screenshot, Some(PathBuf::from("frame.png"))); + assert_eq!(request.trace, Some(PathBuf::from("trace.json"))); + assert_eq!(request.seed, 42); + } + + #[test] + fn seeded_simulations_have_identical_step_traces() { + let mut first = Simulation::new(Scenario::Claw6, 7); + let mut second = Simulation::new(Scenario::Claw6, 7); + first.advance_to(90); + second.advance_to(90); + + assert_eq!(first.trace, second.trace); + } + + #[test] + fn launcher_scenario_holds_for_one_second_then_releases() { + let mut simulation = Simulation::new(Scenario::Launcher, 1); + simulation.advance_to(60); + assert!(simulation.game.ball.in_launcher); + assert!((0.49..=0.51).contains(&simulation.game.launcher_charge)); + + simulation.advance_to(121); + assert!(!simulation.game.ball.in_launcher); + assert!(simulation.trace[121].events.contains(&"Launch".to_owned())); + } + + #[test] + fn flipper_scenario_records_both_movement_edges() { + let mut simulation = Simulation::new(Scenario::Flippers, 1); + simulation.advance_to(46); + + assert_eq!(simulation.trace[16].events, ["FlipperMove"]); + assert_eq!(simulation.trace[46].events, ["FlipperMove"]); + assert!(!simulation.game.flippers.left_raised); + } + + #[test] + fn every_claw_scenario_completes_all_captures_and_returns_idle() { + for scenario in [ + Scenario::Claw1, + Scenario::Claw6, + Scenario::Claw7, + Scenario::Claw18, + ] { + let mut simulation = Simulation::new(scenario, 1); + simulation.advance_to(120); + let events: Vec<&str> = simulation + .trace + .iter() + .flat_map(|snapshot| snapshot.events.iter().map(String::as_str)) + .collect(); + + let captures = events + .iter() + .filter(|event| **event == "ClawCapture") + .count(); + let releases = events + .iter() + .filter(|event| **event == "ClawRelease") + .count(); + assert!(captures >= 1, "{} must enter the claw", scenario.name()); + assert_eq!( + captures, + releases, + "{} must release every captured ball", + scenario.name() + ); + assert!(!simulation.game.claw.active); + assert!(!simulation.game.claw.ball_suspended); + assert!(simulation.game.ball.position.is_finite()); + assert!(simulation.game.ball.velocity.is_finite()); + } + } +}