feat(testing): add deterministic mechanics capture
Add named launcher, flipper, and claw scenarios that advance at exact 120 Hz steps with stable seeds. Emit per-step JSON state/event traces and optionally capture the logical 640x460 framebuffer at any requested time or step. Cover parser behavior, deterministic replay, launcher timing, flipper edges, and complete capture/release/idle cycles for all claw terminals. Test Plan: - cargo fmt --check - cargo test - cargo clippy --all-targets --all-features -- -D warnings - cargo run -- --simulate claw-6 --at 0.15 --screenshot /tmp/tdkpin-sim-validation/claw-6-after-flush.png --trace /tmp/tdkpin-sim-validation/claw-6.json - Inspect both generated 640x460 PNGs and JSON traces - git diff --cached --check
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
+10
-1
@@ -193,7 +193,7 @@ impl Default for Ball {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Game {
|
||||
pub players: Vec<Player>,
|
||||
pub current_player: usize,
|
||||
@@ -275,6 +275,15 @@ impl Game {
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn begin_claw_scenario(&mut self, terminal_frame: u8) -> Vec<Event> {
|
||||
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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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<u8> {
|
||||
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<Self, Self::Err> {
|
||||
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<PathBuf>,
|
||||
pub trace: Option<PathBuf>,
|
||||
pub seed: u32,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Option<Self>, 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<u64, String> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[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<Snapshot>,
|
||||
}
|
||||
|
||||
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<Event>) {
|
||||
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<String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user