From 5b8351ca1f4fcb26dbe9f75ca2279653f36fe189 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sun, 23 Aug 2026 20:45:54 +0200 Subject: [PATCH] fix(ui): reproduce long-idle item index wrap The attract item animation computes `18 - phase` as a signed value without a lower bound. At phases 19 through 21, the subsequent 16-bit handle index wraps backward from the DAT801 table into DAT703, DAT702, and DAT701, copying each bitmap's upper 68x61 region into the item panel. Rust stopped drawing after phase 18 and hid this observable long-idle behavior. Render those three adjacent status crops and return to the inactive panel for later phases. Raise the deterministic simulation ceiling to 100,000 frames so the first wrapped phase at 10.94 minutes can be captured and inspected. Test Plan: - `cargo test --workspace --all-targets --all-features` -- 125 passed - `cargo clippy --workspace --all-targets --all-features -- -D warnings` -- passed - `rumdl check --flavor commonmark RECONSTRUCTION.md CHANGELOG.md` -- passed - `cargo run -- --simulate attract --step 78797 --screenshot /tmp/tdkpin-attract-wrap-19.png` -- passed; DAT703 crop visually inspected at 640x460 - `git diff --cached --check` -- passed --- tdkpin-rs/CHANGELOG.md | 3 +++ tdkpin-rs/RECONSTRUCTION.md | 2 +- tdkpin-rs/src/app.rs | 23 +++++++++++++++++++++++ tdkpin-rs/src/simulation.rs | 4 ++-- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/tdkpin-rs/CHANGELOG.md b/tdkpin-rs/CHANGELOG.md index ba2b94a..6224a40 100644 --- a/tdkpin-rs/CHANGELOG.md +++ b/tdkpin-rs/CHANGELOG.md @@ -55,6 +55,9 @@ and this project adheres to 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. +- Preserve the long-idle signed item-index wrap: phases 19-21 read the adjacent + DAT703/DAT702/DAT701 handles and copy their upper 68x61 regions before later + phases return to the inactive item panel. - 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 diff --git a/tdkpin-rs/RECONSTRUCTION.md b/tdkpin-rs/RECONSTRUCTION.md index 3a09adf..a8d6f4e 100644 --- a/tdkpin-rs/RECONSTRUCTION.md +++ b/tdkpin-rs/RECONSTRUCTION.md @@ -29,7 +29,7 @@ the same evidence rather than against this Rust 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 active ball's 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; media markers use the binary's signed-high/unsigned-low comparison, and each add operation can advance at most one threshold. Type-4 WAVE 2004, bumper WAVE 2006, and bank-completion WAVE 2017 dispatch before their associated score mutations, leaving a crossed marker's WAVE 2007 as the audible asynchronous sample. Rendering separately follows `1008:0996`'s eight-place 130x40 fields/right edge X=606 and `1008:0b9e`'s strict signed status thresholds; configured-player rows and 5x5 remaining-ball markers retain their exact dimensions and current/noncurrent counts. | | 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 DIALOG 32514 flow with the exact caption, prompt, 21-character limit, OK/Cancel semantics, and allowance for an empty accepted name. The `HighScore` child uses exact DAT993 at 360x300, heading `(180,27)`, name/rank X=30, right-aligned score X=320, and ten 20-pixel rows before play resumes. Persisted updates use portable JSON rather than rewriting the Win16 file. | | Configuration | Recovered behavior; portable follow-up storage | Before portable JSON exists, Rust reads the original case-insensitive `[Settings]` keys `Language`, `Speed`, and singular `Sound` from the executable-adjacent INI, falling back to the embedded distributed TDKPIN.INI. Values retain the Win16 defaults/clamps; the shipped file therefore selects German, speed 3, and sound-on because its plural `Sounds` line does not override singular `Sound`. F12 toggles only runtime playback without UI or persistence. Later explicit F10 changes use 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, 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. Add-player, nudge, and F12 actions dispatch on Key-Up through the recovered release handler. Both key handlers honor the four-child auxiliary-window gate, so F1/F12 cannot control the main game through Help, HighScore, or name-entry UI. The low-byte scan aliases are preserved: both Ctrl keys map left, both Enter keys map right, and keypad plus or the physical main-keyboard `0x1b` plus/right-bracket position adds players. 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, its signed long-idle wrap through the adjacent DAT703/DAT702/DAT701 handles, and the circular BITMAP500 `WELCOME TO THE MACHINE` marquee composited over exact DAT997 regions. Add-player, nudge, and F12 actions dispatch on Key-Up through the recovered release handler. Both key handlers honor the four-child auxiliary-window gate, so F1/F12 cannot control the main game through Help, HighScore, or name-entry UI. The low-byte scan aliases are preserved: both Ctrl keys map left, both Enter keys map right, and keypad plus or the physical main-keyboard `0x1b` plus/right-bracket position adds players. 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 diff --git a/tdkpin-rs/src/app.rs b/tdkpin-rs/src/app.rs index 436ee74..a99fc01 100644 --- a/tdkpin-rs/src/app.rs +++ b/tdkpin-rs/src/app.rs @@ -159,6 +159,15 @@ impl AttractAnimation { (item != 0).then(|| usize::try_from(item - 1).unwrap_or(0)) } + fn wrapped_status_item_index(self) -> Option { + match self.tick / 1_152 { + 19 => Some(2), + 20 => Some(1), + 21 => Some(0), + _ => None, + } + } + fn marquee_source_x(self) -> Option { (self.score_counter != 0).then(|| { let phase = self.score_counter % 49; @@ -611,6 +620,16 @@ impl App { 300.0, WHITE, ); + } else if let Some(status) = self.attract.wrapped_status_item_index() { + draw_texture_region( + &self.assets.media[status], + 123.0, + 300.0, + 68.0, + 61.0, + 0.0, + 0.0, + ); } self.draw_intro_marquee(); } @@ -1405,6 +1424,10 @@ mod tests { 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); + assert_eq!(at(21_888).wrapped_status_item_index(), Some(2)); + assert_eq!(at(23_040).wrapped_status_item_index(), Some(1)); + assert_eq!(at(24_192).wrapped_status_item_index(), Some(0)); + assert_eq!(at(25_344).wrapped_status_item_index(), None); } #[test] diff --git a/tdkpin-rs/src/simulation.rs b/tdkpin-rs/src/simulation.rs index 0def3db..23498ef 100644 --- a/tdkpin-rs/src/simulation.rs +++ b/tdkpin-rs/src/simulation.rs @@ -9,7 +9,7 @@ use std::{ 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; +const MAX_SIMULATION_STEPS: u64 = 100_000; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Scenario { @@ -287,7 +287,7 @@ impl Simulation { } fn record(&mut self, events: Vec) { - let step = u32::try_from(self.step).expect("simulation step is limited to 72000"); + let step = u32::try_from(self.step).expect("simulation step is limited to 100000"); let claw_bank = match self.game.claw.bank { ClawSpriteBank::Closing => "closing", ClawSpriteBank::Opening => "opening",