fix(game): honor original mechanism timer choices

Map detail levels 1-5 to the recovered 50, 40, 30, 20, and 10 ms callback
periods for claw animation while retaining the invariant 10 ms physics step.
This removes the hard-coded default cadence from non-default configurations.

Keep repaint batching documented separately because Rust exposes intermediate
physics states instead of reproducing Win16 GDI callback batching.

Test Plan:
- `cargo test --all-targets` -- 51 passed
- `cargo clippy --all-targets -- -D warnings` -- passed
- `cargo build --profile production` -- passed
- all five claw cadence boundary tests -- passed
- `git diff --cached --check` -- passed
This commit is contained in:
2026-08-22 21:55:21 +02:00
parent 77c93ec36b
commit 25e32b2c06
3 changed files with 34 additions and 15 deletions
+8 -10
View File
@@ -50,18 +50,16 @@ renamed.
## Explicit pending evidence ## Explicit pending evidence
- Differentially verify the recovered 10/20/30/40/50 ms timer choices against - Transcribe the full raw fixed-point flipper impulse calculation. Production
live original captures; the Rust port intentionally uses the default 30 ms behavior currently uses live-fitted up/down transfer curves with exact record
claw cadence independently of render rate. geometry and probe tests.
- Transcribe the full fixed-point flipper impulse calculation, then compare it - Reproduce original callback batching for non-default detail settings. Physics
with the Rust floating-point collision response. substeps and claw cadence are recovered, but Rust presents intermediate 10 ms
- Force each chosen claw terminal in the live original and compare its complete physics states instead of batching 1-5 substeps into one Win16 repaint.
frame/trajectory sequence with the deterministic Rust trace. A controlled
collision probe has covered terminal 18; terminals 1, 6, and 7 remain.
Those items are intentionally not counted as semantic parity. The readable Those items are intentionally not counted as semantic parity. The readable
C file preserves their raw constants and labels the unresolved conversion so a C files preserve their raw constants and label unresolved batching/fitted
later implementation cannot silently turn an estimate into claimed evidence. arithmetic so later work cannot silently turn an estimate into claimed evidence.
## Runtime comparison on 2026-08-22 ## Runtime comparison on 2026-08-22
+3 -2
View File
@@ -64,8 +64,9 @@ validation runs.
| Sound | F12 | - | | Sound | F12 | - |
The five original speed choices remain available in settings. Physics now uses The five original speed choices remain available in settings. Physics now uses
the original invariant 100 Hz millipixel substep; exact setting-specific timer the original invariant 100 Hz millipixel substep, and claw animation follows the
batching for presentation and mechanism animation remains under reconstruction. recovered 50/40/30/20/10 ms timer choices. Setting-specific repaint batching
remains a presentation-only reconstruction boundary.
The help screen is the original artwork in English, German, French, Italian, The help screen is the original artwork in English, German, French, Italian,
or Spanish. As instructed on that screen, double-clicking its upper-left exit or Spanish. As instructed on that screen, double-clicking its upper-left exit
+23 -3
View File
@@ -31,6 +31,7 @@ const CLAW_TRIGGER_RADIUS: f32 = 19.0;
// frame. Keeping that cadence independent of render rate makes captures // frame. Keeping that cadence independent of render rate makes captures
// deterministic on modern machines. // deterministic on modern machines.
const CLAW_FRAME_SECONDS: f32 = 0.030; const CLAW_FRAME_SECONDS: f32 = 0.030;
const DETAIL_TIMER_SECONDS: [f32; 5] = [0.050, 0.040, 0.030, 0.020, 0.010];
const CLAW_TERMINAL_FRAMES: [u8; 4] = [1, 6, 7, 18]; const CLAW_TERMINAL_FRAMES: [u8; 4] = [1, 6, 7, 18];
const ORIGINAL_BALL_SPEED_PER_SECOND: f32 = 380.0; const ORIGINAL_BALL_SPEED_PER_SECOND: f32 = 380.0;
@@ -225,6 +226,7 @@ pub struct Game {
trigger_contacts: [bool; 176], trigger_contacts: [bool; 176],
object_active: [bool; 176], object_active: [bool; 176],
target_effect: u8, target_effect: u8,
claw_frame_seconds: f32,
} }
impl Game { impl Game {
@@ -263,6 +265,7 @@ impl Game {
trigger_contacts: [false; 176], trigger_contacts: [false; 176],
object_active: initial_object_activity(), object_active: initial_object_activity(),
target_effect: 0, target_effect: 0,
claw_frame_seconds: CLAW_FRAME_SECONDS,
} }
} }
@@ -315,11 +318,12 @@ impl Game {
self.player_entry = PlayerEntry::Closed; self.player_entry = PlayerEntry::Closed;
} }
pub fn update(&mut self, frame_time: f32, _detail: u8, controls: Controls) -> Vec<Event> { pub fn update(&mut self, frame_time: f32, detail: u8, controls: Controls) -> Vec<Event> {
if self.finished { if self.finished {
return Vec::new(); return Vec::new();
} }
self.last_collision_id = None; self.last_collision_id = None;
self.claw_frame_seconds = DETAIL_TIMER_SECONDS[usize::from(detail.clamp(1, 5) - 1)];
let mut events = Vec::new(); let mut events = Vec::new();
let old_flippers = self.flippers; let old_flippers = self.flippers;
self.flippers.left_raised = controls.left_flipper && !self.tilted; self.flippers.left_raised = controls.left_flipper && !self.tilted;
@@ -909,8 +913,8 @@ impl Game {
return; return;
} }
self.claw.frame_accumulator += dt; self.claw.frame_accumulator += dt;
while self.claw.active && self.claw.frame_accumulator >= CLAW_FRAME_SECONDS { while self.claw.active && self.claw.frame_accumulator >= self.claw_frame_seconds {
self.claw.frame_accumulator -= CLAW_FRAME_SECONDS; self.claw.frame_accumulator -= self.claw_frame_seconds;
self.advance_claw(events); self.advance_claw(events);
} }
} }
@@ -1581,6 +1585,22 @@ mod tests {
assert!(!game.claw.active); assert!(!game.claw.active);
} }
#[test]
fn claw_frame_cadence_follows_the_five_original_timer_choices() {
for (detail, frame_seconds) in DETAIL_TIMER_SECONDS.into_iter().enumerate() {
let mut game = Game::new_with_seed(1, 7);
game.begin_claw_scenario(6);
let detail = u8::try_from(detail + 1).expect("five detail levels fit u8");
game.update(0.0, detail, Controls::default());
let mut events = Vec::new();
game.update_claw(frame_seconds - 0.001, &mut events);
assert_eq!(game.claw.frame, 10);
game.update_claw(0.001_1, &mut events);
assert_eq!(game.claw.frame, 9);
}
}
#[test] #[test]
fn claw_uses_the_original_circular_trigger_not_a_broad_rectangle() { fn claw_uses_the_original_circular_trigger_not_a_broad_rectangle() {
let mut game = Game::new(1); let mut game = Game::new(1);