fix(ui): restore original attract animation

Advance idle mode on the selected detail callback and reproduce the reconstructed target, word-quad, magnetic, record-strip, point-chase, item, and BITMAP500 marquee phases from original assets. Add a deterministic attract screenshot scenario and correct DAT801-809 item placement to y=300.

Test Plan:
- cargo test --all-targets
- cargo clippy --all-targets --all-features -- -D warnings
- rumdl check CHANGELOG.md RECONSTRUCTION.md README.md
- cargo run --quiet -- --simulate attract --step 24 --screenshot /tmp/tdkpin-attract-24.png
- cargo run --quiet -- --simulate attract --step 192 --screenshot /tmp/tdkpin-attract-192.png
- cargo run --quiet -- --simulate attract --step 4608 --screenshot /tmp/tdkpin-attract-4608.png
- git diff --check
This commit is contained in:
2026-08-23 18:59:19 +02:00
parent 2936c69c5c
commit 8168d9dc23
6 changed files with 319 additions and 10 deletions
+4
View File
@@ -10,6 +10,10 @@ and this project adheres to
### Fixed ### 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 - Match central score/event side effects: every stored collision candidate
clears its ball slot's capture age, Tilt suppresses all score and rule-record clears its ball slot's capture age, Tilt suppresses all score and rule-record
mutations, and active multiball doubles points before permanent Double can mutations, and active multiball doubles points before permanent Double can
+3 -2
View File
@@ -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 Use `--step N` instead of `--at SECONDS` to reproduce one exact update. The
available scenarios are `autoplay`, `launcher`, `flippers`, `panel`, `targets`, available scenarios are `attract`, `autoplay`, `launcher`, `flippers`, `panel`,
`claw-1`, `claw-6`, `claw-7`, and `claw-18`; `--seed N` fixes random choices. `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 `autoplay` charges each ball and operates the flippers from live ball position
for long end-to-end validation runs. for long end-to-end validation runs.
+1 -1
View File
@@ -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. | | 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. | | 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. | | 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 ## Extracted asset inventory
+288 -1
View File
@@ -10,6 +10,7 @@ use std::path::Path;
const WIDTH: f32 = 640.0; const WIDTH: f32 = 640.0;
const HEIGHT: f32 = 460.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] = [ 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)], [(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)], [(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, 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<usize> {
(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<usize> {
(self.tick >= 4).then(|| usize::try_from((self.tick / 64).min(3)).unwrap_or(0))
}
fn item_index(self) -> Option<usize> {
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<f32> {
(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 { pub struct App {
assets: Assets, assets: Assets,
persistence: Persistence, persistence: Persistence,
@@ -45,6 +119,7 @@ pub struct App {
message_until: f64, message_until: f64,
name: String, name: String,
pending_score: u32, pending_score: u32,
attract: AttractAnimation,
} }
impl App { impl App {
@@ -69,6 +144,7 @@ impl App {
message_until: 0.0, message_until: 0.0,
name: String::new(), name: String::new(),
pending_score: 0, pending_score: 0,
attract: AttractAnimation::default(),
} }
} }
@@ -97,6 +173,15 @@ impl App {
self.screen = Screen::Playing; 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) { pub fn render_simulation(&self) {
self.draw_logical(); self.draw_logical();
} }
@@ -159,6 +244,8 @@ impl App {
} }
fn update_attract(&mut self) { 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) { if is_key_pressed(KeyCode::KpAdd) || is_key_pressed(KeyCode::Equal) {
self.game = Some(Game::new(1)); self.game = Some(Game::new(1));
self.screen = Screen::Playing; self.screen = Screen::Playing;
@@ -303,6 +390,9 @@ impl App {
|| is_key_pressed(KeyCode::F3) || is_key_pressed(KeyCode::F3)
{ {
self.screen = self.return_screen; self.screen = self.return_screen;
if self.screen == Screen::Attract {
self.attract = AttractAnimation::default();
}
} }
} }
@@ -363,6 +453,135 @@ impl App {
fn draw_attract(&self) { fn draw_attract(&self) {
draw_texture(&self.assets.inactive_table, 0.0, 0.0, WHITE); 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)] #[allow(clippy::cast_precision_loss, clippy::too_many_lines)]
@@ -414,7 +633,7 @@ impl App {
} }
if game.player().diamond_segments > 0 { if game.player().diamond_segments > 0 {
let index = usize::from(game.player().diamond_segments.min(9) - 1); 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 [ for (object_id, region) in [
(6, Rect::new(151.0, 389.0, 13.0, 49.0)), (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, 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));
}
}
+6 -2
View File
@@ -12,7 +12,7 @@ mod table;
use app::App; use app::App;
use macroquad::prelude::*; use macroquad::prelude::*;
use simulation::{Request, Simulation, usage}; use simulation::{Request, Scenario, Simulation, usage};
const WINDOW_WIDTH: i32 = 640; const WINDOW_WIDTH: i32 = 640;
const WINDOW_HEIGHT: i32 = 460; const WINDOW_HEIGHT: i32 = 460;
@@ -60,7 +60,11 @@ async fn run_simulation(request: Request) -> Result<(), String> {
} }
if let Some(path) = &request.screenshot { if let Some(path) = &request.screenshot {
let mut app = App::load().await; 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(); app.render_simulation();
next_frame().await; next_frame().await;
app.export_simulation_png(path)?; app.export_simulation_png(path)?;
+17 -4
View File
@@ -13,6 +13,7 @@ const MAX_SIMULATION_STEPS: u64 = 72_000;
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Scenario { pub enum Scenario {
Attract,
Autoplay, Autoplay,
Launcher, Launcher,
Flippers, Flippers,
@@ -27,6 +28,7 @@ pub enum Scenario {
impl Scenario { impl Scenario {
pub const fn name(self) -> &'static str { pub const fn name(self) -> &'static str {
match self { match self {
Self::Attract => "attract",
Self::Autoplay => "autoplay", Self::Autoplay => "autoplay",
Self::Launcher => "launcher", Self::Launcher => "launcher",
Self::Flippers => "flippers", Self::Flippers => "flippers",
@@ -45,7 +47,12 @@ impl Scenario {
Self::Claw6 => Some(6), Self::Claw6 => Some(6),
Self::Claw7 => Some(7), Self::Claw7 => Some(7),
Self::Claw18 => Some(18), 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<Self, Self::Err> { fn from_str(value: &str) -> Result<Self, Self::Err> {
match value { match value {
"attract" => Ok(Self::Attract),
"autoplay" => Ok(Self::Autoplay), "autoplay" => Ok(Self::Autoplay),
"launcher" => Ok(Self::Launcher), "launcher" => Ok(Self::Launcher),
"flippers" => Ok(Self::Flippers), "flippers" => Ok(Self::Flippers),
@@ -65,7 +73,7 @@ impl FromStr for Scenario {
"claw-7" => Ok(Self::Claw7), "claw-7" => Ok(Self::Claw7),
"claw-18" => Ok(Self::Claw18), "claw-18" => Ok(Self::Claw18),
_ => Err(format!( _ => 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<u64, String> {
} }
pub const fn usage() -> &'static str { 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)] #[derive(Debug, Serialize, PartialEq)]
@@ -230,7 +238,11 @@ impl Simulation {
} else { } else {
controls_for(self.scenario, self.step) 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.step += 1;
self.record(events); self.record(events);
} }
@@ -320,6 +332,7 @@ impl Simulation {
fn controls_for(scenario: Scenario, step: u64) -> Controls { fn controls_for(scenario: Scenario, step: u64) -> Controls {
match scenario { match scenario {
Scenario::Attract => Controls::default(),
Scenario::Autoplay => unreachable!("autoplay controls need mutable simulation state"), Scenario::Autoplay => unreachable!("autoplay controls need mutable simulation state"),
Scenario::Launcher => Controls { Scenario::Launcher => Controls {
launch_down: step < u64::from(SIMULATION_HZ), launch_down: step < u64::from(SIMULATION_HZ),