fix(attract): restore all remainder-driven cycles

The same raw Div32 pattern used by the item animation appears in every nested
idle phase: after division by 32, 40, or 16, the binary copies the remainder
from CX:BX before the second division. Readable C and Rust used quotients for
targets, word quads, the record strip, and the four-point chase, turning short
repeating chases into slow one-shot progressions.

Use `%32/2`, `%32/4`, `%40/4`, and `%16/4` for those cycles alongside the
already corrected `%144/8` items. Preserve the first-cycle phase-zero delay,
then repeat each incremental overlay state exactly. Add C and Rust coverage at
phase activation, reversal, deactivation, and wrap boundaries.

Test Plan:
- raw instruction review at `1000:01b7-063b` -- all remainder transfers confirmed
- `cargo test --workspace --all-targets --all-features` -- 125 passed
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` -- passed
- `bash original/tools/test_reconstructed_c.sh` -- passed
- `python3 original/tools/audit_reconstruction.py --require-complete` -- passed with zero incomplete or unclassified units
- `cargo run -- --simulate attract --step 145 --screenshot /tmp/tdkpin-attract-remainder.png` -- passed; visually inspected at 640x460
- `rumdl check --flavor commonmark RECONSTRUCTION.md CHANGELOG.md` -- passed
- `git diff --cached --check` -- passed
This commit is contained in:
2026-08-23 20:53:12 +02:00
parent dd0299c30b
commit 7633ef9990
7 changed files with 82 additions and 39 deletions
+4 -3
View File
@@ -53,9 +53,10 @@ and this project adheres to
previously impossible Pascal-order C interpretation of a 328x202 blit.
- Restore the original idle/attract timer animation: target and word-quad
chases, magnetic flashes, five-record strip, four-point chase, DAT801-809
item progression on exact `(tick % 144) / 8` phases, and the circular
`WELCOME TO THE MACHINE` BITMAP500 marquee. Move gameplay item frames to
their exact `(123,300)` destination.
item progression, and the circular `WELCOME TO THE MACHINE` BITMAP500
marquee. Each nested phase uses the binary's remainder first: `%32/2`,
`%32/4`, `%40/4`, `%16/4`, and `%144/8`. 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
+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 on `(tick % 144) / 8`, 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 `%32/2`, word-quad `%32/4`, record-strip `%40/4`, magnetic `%32`, four-point `%16/4`, and DAT801-809 `%144/8` cycles, plus 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
+29 -24
View File
@@ -115,18 +115,18 @@ impl AttractAnimation {
}
fn target_active(self, index: usize) -> bool {
let start = if index == 0 {
2
let index = u32::try_from(index).unwrap_or(0);
let first_activation = if index == 0 {
32
} else {
u32::try_from(index).unwrap_or(0) * 64
index * 2
};
let end = u32::try_from(index + 8).unwrap_or(8) * 64;
self.tick >= start && self.tick < end
self.tick >= first_activation && self.tick.wrapping_sub(index * 2) % 32 < 16
}
fn active_word_quad(self) -> Option<usize> {
(self.tick >= 4).then(|| {
let phase = (self.tick / 128).min(8);
let phase = (self.tick % 32) / 4;
usize::try_from(if phase <= 4 { phase } else { 8 - phase }).unwrap_or(0)
})
}
@@ -136,17 +136,17 @@ impl AttractAnimation {
}
fn record_strip_active(self, index: usize) -> bool {
let start = if index == 0 {
4
let index = u32::try_from(index).unwrap_or(0);
let first_activation = if index == 0 {
40
} else {
u32::try_from(index).unwrap_or(0) * 160
index * 4
};
let end = u32::try_from(index + 5).unwrap_or(5) * 160;
self.tick >= start && self.tick < end
self.tick >= first_activation && self.tick.wrapping_sub(index * 4) % 40 < 20
}
fn chase_index(self) -> Option<usize> {
(self.tick >= 4).then(|| usize::try_from((self.tick / 64).min(3)).unwrap_or(0))
(self.tick >= 4).then(|| usize::try_from((self.tick % 16) / 4).unwrap_or(0))
}
fn item_index(self) -> Option<usize> {
@@ -1384,22 +1384,27 @@ mod tests {
..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!(!at(31).target_active(0));
assert!(at(32).target_active(0));
assert!(at(2).target_active(1));
assert!(!at(18).target_active(1));
assert!(at(34).target_active(1));
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_eq!(at(4).active_word_quad(), Some(1));
assert_eq!(at(16).active_word_quad(), Some(4));
assert_eq!(at(20).active_word_quad(), Some(3));
assert_eq!(at(32).active_word_quad(), Some(0));
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!(!at(39).record_strip_active(0));
assert!(at(40).record_strip_active(0));
assert!(at(4).record_strip_active(1));
assert!(!at(24).record_strip_active(1));
assert_eq!(at(4).chase_index(), Some(1));
assert_eq!(at(8).chase_index(), Some(2));
assert_eq!(at(12).chase_index(), Some(3));
assert_eq!(at(16).chase_index(), Some(0));
assert_eq!(at(7).item_index(), None);
assert_eq!(at(8).item_index(), Some(0));
assert_eq!(at(72).item_index(), Some(8));