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:
@@ -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
|
||||
|
||||
+3
-2
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+288
-1
@@ -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<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 {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
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)?;
|
||||
|
||||
@@ -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<Self, Self::Err> {
|
||||
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<u64, String> {
|
||||
}
|
||||
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user