fix(attract): use remainder-driven item phases

Raw 1000:061c-063b divides the idle tick by 144, copies the remainder from
CX:BX into AX:DX, and only then divides by 8. Readable C and Rust instead used
an unbounded quotient equivalent to tick/1152, making DAT801-809 advance far
too slowly and suggesting a nonexistent negative long-idle index.

Use `(tick % 144) / 8` to repeat item states 0,1..9,8..1 every 144 callbacks.
Remove the disproven long-idle status crops and restore the ten-minute simulator
ceiling. Extend the C and Rust phase tests across the forward, reverse, and wrap
boundaries.

Test Plan:
- raw instruction review at `1000:061c-063b` -- remainder transfer 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
- `rumdl check --flavor commonmark RECONSTRUCTION.md CHANGELOG.md` -- passed
- `git diff --cached --check` -- passed
This commit is contained in:
2026-08-23 20:50:05 +02:00
parent 5b8351ca1f
commit dd0299c30b
8 changed files with 45 additions and 41 deletions
+3 -5
View File
@@ -53,11 +53,9 @@ 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, 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.
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.
- 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, 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. |
| 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. |
## Extracted asset inventory
+8 -30
View File
@@ -150,24 +150,15 @@ impl AttractAnimation {
}
fn item_index(self) -> Option<usize> {
let phase = self.tick / 1_152;
let phase = (self.tick % 144) / 8;
let item = if phase < 10 {
phase
} else {
18_u32.saturating_sub(phase)
18 - phase
};
(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;
@@ -620,16 +611,6 @@ 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();
}
@@ -1419,15 +1400,12 @@ mod tests {
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);
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);
assert_eq!(at(7).item_index(), None);
assert_eq!(at(8).item_index(), Some(0));
assert_eq!(at(72).item_index(), Some(8));
assert_eq!(at(80).item_index(), Some(7));
assert_eq!(at(136).item_index(), Some(0));
assert_eq!(at(144).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 = 100_000;
const MAX_SIMULATION_STEPS: u64 = 72_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 100000");
let step = u32::try_from(self.step).expect("simulation step is limited to 72000");
let claw_bank = match self.game.claw.bank {
ClawSpriteBank::Closing => "closing",
ClawSpriteBank::Opening => "opening",