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:
2026-08-22 18:50:25 +02:00
parent 75c2464e72
commit 16c055eec6
6 changed files with 481 additions and 1 deletions
+16
View File
@@ -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 |
+4
View File
@@ -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.
+21
View File
@@ -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
View File
@@ -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;
+37
View File
@@ -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(())
}
+393
View File
@@ -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());
}
}
}