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
This commit is contained in:
2026-08-23 20:45:54 +02:00
parent db0ec0bdc4
commit 5b8351ca1f
4 changed files with 29 additions and 3 deletions
+3
View File
@@ -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
+1 -1
View File
@@ -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
+23
View File
@@ -159,6 +159,15 @@ impl AttractAnimation {
(item != 0).then(|| usize::try_from(item - 1).unwrap_or(0))
}
fn wrapped_status_item_index(self) -> Option<usize> {
match self.tick / 1_152 {
19 => Some(2),
20 => Some(1),
21 => Some(0),
_ => None,
}
}
fn marquee_source_x(self) -> Option<f32> {
(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]
+2 -2
View File
@@ -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<Event>) {
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",