From 3dce159965210a121baa29b75d884a7c254dac61 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sun, 23 Aug 2026 19:42:23 +0200 Subject: [PATCH] fix(physics): match live tangent response transitions Correct the common projection to spin*0.4 + normal_velocity*response_tangent and seal line/circle behavior against current Wine one-substep traces. Preserve record-two's in-response special-respawn continuation, producing the live (16698,21191)/(-302,-1809) state after publishing (17000,23000)/(0,3040), and update the readable C evidence. Test Plan: - bash original/tools/test_reconstructed_c.sh - python3 original/tools/audit_reconstruction.py --require-complete - cargo test --all-targets - cargo clippy --all-targets --all-features -- -D warnings - rumdl check original/C_RECONSTRUCTION_FINAL_AUDIT.md tdkpin-rs/CHANGELOG.md tdkpin-rs/RECONSTRUCTION.md tdkpin-rs/README.md - live Wine record 2 trace: /tmp/original-special-respawn.csv - live Wine object 155 trace: /tmp/original-circle155-after.csv - git diff --check --- original/C_RECONSTRUCTION_FINAL_AUDIT.md | 5 ++ original/FUNCTION_RECONSTRUCTION.tsv | 2 +- original/reconstructed/tdkpin_physics.c | 6 +- original/reconstructed/tests/test_physics.c | 15 +++++ tdkpin-rs/CHANGELOG.md | 4 ++ tdkpin-rs/RECONSTRUCTION.md | 8 ++- tdkpin-rs/src/game.rs | 61 +++++++++++++++++++-- tdkpin-rs/src/original_physics.rs | 32 +++++------ 8 files changed, 107 insertions(+), 26 deletions(-) diff --git a/original/C_RECONSTRUCTION_FINAL_AUDIT.md b/original/C_RECONSTRUCTION_FINAL_AUDIT.md index 6f348dd..e807698 100644 --- a/original/C_RECONSTRUCTION_FINAL_AUDIT.md +++ b/original/C_RECONSTRUCTION_FINAL_AUDIT.md @@ -82,6 +82,11 @@ Raw `1000:531e` further corrects the Pascal-order BitBlt interpretation: the DAT994 progress strip is drawn at `(202,328)` with height 13 and width `(stage-1)*7`, rather than the impossible historical 328x202 read from a 237x13 bitmap. The focused loading-stage harness seals stages 1, 35, and 0. +Live one-substep rail/circle probes further correct the response projection: +the tangential delta is `normal_velocity * response_tangent`, added to retained +`spin * 0.4`; subtracting the bare coefficient incorrectly rounded it to zero. +The documented horizontal drain transition `(0,3015)->(-302,-1809)` and the +ordinary circle/bumper tangent transitions seal this correction. ## Reproducible gates diff --git a/original/FUNCTION_RECONSTRUCTION.tsv b/original/FUNCTION_RECONSTRUCTION.tsv index 16204af..2dc1e06 100644 --- a/original/FUNCTION_RECONSTRUCTION.tsv +++ b/original/FUNCTION_RECONSTRUCTION.tsv @@ -68,7 +68,7 @@ address raw_name segment body_bytes status reconstructed_name module evidence no 1000:c36b FUN_1000_c36b Code1 187 verified tdkpin_load_ball_slot reconstructed/tdkpin_gameplay_helpers.c disassembly 1000:c36b-c425; SS caller-frame receiver; reconstructed/tests/test_gameplay_helpers.c Exact unchecked uint16 slot times 4/6 addressing, slot position to global ball X/Y, velocity and auxiliary dword fields to receiver +0baa/+0bae/+0bca, six-byte Real48 copy to +0bce, overlap behavior, and six-byte cleanup are represented and tested. 1000:c426 FUN_1000_c426 Code1 187 verified tdkpin_save_ball_slot reconstructed/tdkpin_gameplay_helpers.c disassembly 1000:c426-c4e0; SS caller-frame receiver; reconstructed/tests/test_gameplay_helpers.c Exact inverse unchecked uint16 slot times 4/6 transfer of global position, receiver velocity/auxiliary dwords and six-byte Real48 field into packed DGROUP slot arrays, overlapping out-of-range slot behavior, and six-byte cleanup are represented and tested. 1000:c4e1 FUN_1000_c4e1 Code1 699 verified tdkpin_apply_player_sprite_effect reconstructed/tdkpin_player_state.c disassembly 1000:c4e1-c79b; verified multiply/score dependencies; exact player tables and record 399a access; reconstructed/tests/test_player_state.c Exact sprite-index dispatch 1..7, wrapping secondary-score additions 10000/20000/50000/100000/200000, unsigned-byte multiplier redraw, effect-6 multiplied transfer/reset/zero redraw, effect-7 nonzero record gate, signed-negative or unequal argument condition, receiver +be0 and global 0874 publications, and default no-op behavior are represented and branch-tested. -1000:c79c FUN_1000_c79c Code1 8981 verified tdkpin_simulate_ball_and_dispatch_collision reconstructed/tdkpin_physics.c disassembly 1000:c79c-eab0 and rz-ghidra stack-offset view; packed 83-byte records and exact 52-byte candidate layout; all integer/Real48/gameplay/GDI dependencies verified; reconstructed/tests/test_physics.c Exact RETF-2 caller-frame ABI, simulation-call counter, snapshotted ball count and substep word, multiball load/save, gravity and two ordered speed clamps, previous/predicted coordinates, board-loss/layer gates, 175-record broadphase, types 1 circle, 2 segment, 3 capture/pocket, and 4 stateful trigger, subtype normal reversal, signed cross/end-point tests, contact-state sentinels, claw/target-set/sprite/score special paths, trigger 0x4000 lifecycle and random bounce, packed nearest-candidate copies, event/score dispatch, Real48 normal/tangent/spin/response/kick transform including deliberately zeroed intermediate, ball-to-ball impulse transfer, record 25/2 terminal paths, ordered velocity/position publication, dynamic-record update, swept rendering, 32-bit wrap, and Borland divide/error behavior are represented. Free integration, exact 3-4-5 clamp, circle/segment responses, four trigger/capture modes, distinct response/event/score paths, out-of-bounds, and zero-substep multiball are tested. +1000:c79c FUN_1000_c79c Code1 8981 verified tdkpin_simulate_ball_and_dispatch_collision reconstructed/tdkpin_physics.c disassembly 1000:c79c-eab0 and rz-ghidra stack-offset view; packed 83-byte records and exact 52-byte candidate layout; all integer/Real48/gameplay/GDI dependencies verified; reconstructed/tests/test_physics.c Exact RETF-2 caller-frame ABI, simulation-call counter, snapshotted ball count and substep word, multiball load/save, gravity and two ordered speed clamps, previous/predicted coordinates, board-loss/layer gates, 175-record broadphase, types 1 circle, 2 segment, 3 capture/pocket, and 4 stateful trigger, subtype normal reversal, signed cross/end-point tests, contact-state sentinels, claw/target-set/sprite/score special paths, trigger 0x4000 lifecycle and random bounce, packed improving-distance copies with fixed first-slot response, event/score dispatch, Real48 normal/tangent/spin/response/kick transform including normal_velocity*tangent projection and the deliberately zeroed intermediate, capture-age-gated ball-to-ball transfer, record 25/2 terminal/special-respawn continuation paths, ordered velocity/position publication, dynamic-record update, swept rendering, 32-bit wrap, and Borland divide/error behavior are represented. Free integration, exact 3-4-5 clamp, live line/circle tangent transitions, four trigger/capture modes, two-candidate first-slot behavior, distinct response/event/score paths, out-of-bounds, and zero-substep multiball are tested. 1000:eab1 exported_ordinal_10 Code1 4820 verified tdkpin_timer_tick reconstructed/tdkpin_timer_tick.c disassembly 1000:eab1-fd84 including RETF-8 and all annotated calls; exact Real48 claw-release table; complete verified dependency closure including 1000:c79c; reconstructed/tests/test_timer_tick.c Exact two-DWORD callback ABI with unused second argument, all nine guard conditions, busy flag, 175-record refresh, two dword counters, claw movement/release/record activation, ten exact spawn states, 175 countdowns, target rotation, multiball collapse/promotion/reset/spawn, physics gate, quotient-versus-remainder intro schedules, record/item/chase/status animations, complete 30x284 score-marquee GDI lifetime, panel frames 1..281, game initialization, flipper/message epilogue, busy clear, redundant tilt read, and tilt-counter decrement are represented and branch-/sequence-tested. 1000:fd85 ne_program_entry Code1 181 verified tdkpin_ne_program_entry reconstructed/tdkpin_program_entry.c disassembly 1000:fd85-fe39; relocations 1669-1679; reconstructed/tests/test_program_entry.c Exact InitTask register handoff, eight-unit initialization order, full DX:AX GetVersion store, Windows 3.10 threshold, indirect MessageBox error path, fixed application object construction, VMT +1c Run and +08 destructor calls, dispose mode zero, and unconditional Halt(0) are represented and branch-tested. 1008:0002 FUN_1008_0002 Code2 33 verified tdkpin_play_sound_if_not_tilted reconstructed/tdkpin_sound.c disassembly 1008:0002-0022; verified 1008:1131 dependency; DGROUP game-tilted byte 07c6 and sound-enabled byte 43f2; reconstructed/tests/test_sound.c Exact Tilted==0 then Enabled!=0 short-circuit order, unchanged sound-number argument, ignored player result, two-byte Pascal cleanup, and all gate combinations are branch-tested. diff --git a/original/reconstructed/tdkpin_physics.c b/original/reconstructed/tdkpin_physics.c index b3e4d0e..a8642d3 100644 --- a/original/reconstructed/tdkpin_physics.c +++ b/original/reconstructed/tdkpin_physics.c @@ -935,7 +935,7 @@ static void apply_collision_response( (void)discarded; discarded = (BorlandReal48){{0,0,0,0,0,0}}; - projection = borland_real48_round_to_i32(borland_real48_subtract( + projection = borland_real48_round_to_i32(borland_real48_add( borland_real48_subtract( borland_real48_multiply(motion->spin, g_real48_point_four), borland_real48_divide( @@ -943,7 +943,9 @@ static void apply_collision_response( borland_i32_to_real48(absolute_wrap(projection)), discarded), borland_i32_to_real48(motion->speed))), - candidate->response_tangent)); + borland_real48_multiply( + borland_i32_to_real48(candidate->normal_velocity), + candidate->response_tangent))); motion->spin = borland_real48_add( borland_real48_multiply(motion->spin, g_real48_point_six), spin_delta); diff --git a/original/reconstructed/tests/test_physics.c b/original/reconstructed/tests/test_physics.c index e4a076b..cf4eb53 100644 --- a/original/reconstructed/tests/test_physics.c +++ b/original/reconstructed/tests/test_physics.c @@ -321,6 +321,21 @@ static void test_circle_and_segment_collisions(void) win16_far_add_offset(g_window, 0x0bae)) == 0); assert((int32_t)win16_read_u32(win16_dgroup_pointer(0x07d7)) == 100000); + prepare_fixture(); + set_ball(100000, 100000, 0, 1000); + segment = base_record(2); + segment.point1_x_milli = 50000; + segment.point1_y_milli = 101000; + segment.point2_x_milli = 150000; + segment.point2_y_milli = 101000; + segment.response_tangent = (BorlandReal48){{0x7d,0xcd,0xcc,0xcc,0xcc,0x4c}}; + write_record(1, &segment); + tdkpin_simulate_ball_and_dispatch_collision(0x0300); + assert((int32_t)win16_read_u32( + win16_far_add_offset(g_window, 0x0baa)) == -100); + assert((int32_t)win16_read_u32( + win16_far_add_offset(g_window, 0x0bae)) == 0); + prepare_fixture(); set_ball(100000, 100000, 1000, 0); circle = base_record(1); diff --git a/tdkpin-rs/CHANGELOG.md b/tdkpin-rs/CHANGELOG.md index 79db164..b190064 100644 --- a/tdkpin-rs/CHANGELOG.md +++ b/tdkpin-rs/CHANGELOG.md @@ -10,6 +10,10 @@ and this project adheres to ### Fixed +- Correct the common Real48 tangent projection to + `spin*0.4 + normal_velocity*response_tangent`; live one-substep probes now + match horizontal drain `(-302,-1809)`, ordinary circle `(94,564)`, and + bumper `(197,3096)` transitions instead of rounding a bare coefficient away. - Gate dynamic record 174/175 impulse transfer on the other slot's capture age: a held ball remains velocity-stationary and the moving response retains its full normal velocity instead of always subtracting 1,000. diff --git a/tdkpin-rs/RECONSTRUCTION.md b/tdkpin-rs/RECONSTRUCTION.md index 1883594..1c1e5b2 100644 --- a/tdkpin-rs/RECONSTRUCTION.md +++ b/tdkpin-rs/RECONSTRUCTION.md @@ -59,9 +59,11 @@ decoded, build-ready subset; it does not replace that evidence archive. launched, collision scoring was observed, and a rendered frame was captured. - Original-runtime comparison: Wine 11.15 staging ran the preserved Win16 NE executable. Live millipixel traces cover keypad `+`, repeat-driven charging, - release, the shooter route, ordinary rail responses, and a controlled - terminal-18 claw capture and release. The line solver has unit tests against - exact outer/inner shooter-wall states from those traces. + release, the shooter route, ordinary rail/circle tangent responses, and a + controlled terminal-18 claw capture and release. A current one-substep + record-2 probe proves special-respawn publication `(17000,23000)/(0,3040)` + followed by the same response's `(16698,21191)/(-302,-1809)` continuation. + Rust tests assert these exact live states. - Deterministic mechanics coverage: named launcher, flipper, and all four claw terminal scenarios are driven by a 120 Hz validation clock while production physics accumulates the recovered 100 Hz substep. Seeded runs use the original diff --git a/tdkpin-rs/src/game.rs b/tdkpin-rs/src/game.rs index baee271..6bdea72 100644 --- a/tdkpin-rs/src/game.rs +++ b/tdkpin-rs/src/game.rs @@ -803,7 +803,15 @@ impl Game { if let Some(wall_id) = hit_wall { if wall_id == 2 { + let special_respawn = self.special_respawn_pending(); + let response_spin = self.ball.spin; self.drain(events); + if special_respawn { + let origin = MilliVec::from_position(self.ball.position); + self.ball.position = origin.add(velocity).to_position(); + self.ball.velocity = velocity.to_velocity_per_second(); + self.ball.spin = response_spin; + } return true; } if wall_id == 25 @@ -2006,9 +2014,7 @@ impl Game { events.push(Event::Drain); return; } - if self.score_mode == ScoreMode::Normal - && matches!(self.multiball_state, MultiballState::Ready | MultiballState::Active) - { + if self.special_respawn_pending() { self.ball = Ball { position: vec2(17.0, 23.0), velocity: MilliVec { x: 0, y: 3_040 }.to_velocity_per_second(), @@ -2064,6 +2070,12 @@ impl Game { self.finished = true; } + fn special_respawn_pending(&self) -> bool { + self.secondary_ball.is_none() + && self.score_mode == ScoreMode::Normal + && matches!(self.multiball_state, MultiballState::Ready | MultiballState::Active) + } + fn save_current_rule_state(&mut self) { self.players[self.current_player].rules = RuleState { wheel_holes: self.wheel_holes, @@ -2640,7 +2652,20 @@ mod tests { game.fixed_update(&mut events); assert!(!game.ball.in_launcher); - assert!(game.ball.position.y > 23.0); + assert_eq!( + MilliVec::from_position(game.ball.position), + MilliVec { + x: 17_000, + y: 26_055, + } + ); + assert_eq!( + MilliVec::from_velocity_per_second(game.ball.velocity), + MilliVec { + x: 0, + y: 3_055, + } + ); assert_eq!(game.wheel_reset_gate, WheelResetGate::Suppressed); game.ball.position = vec2(17.0, 31.0); @@ -2649,6 +2674,34 @@ mod tests { assert_eq!(game.wheel_reset_gate, WheelResetGate::Enabled); } + #[test] + fn record_two_response_continues_after_special_respawn_like_live_original() { + let mut game = Game::new(1); + game.multiball_state = MultiballState::Active; + game.ball.in_launcher = false; + game.ball.position = vec2(157.0, 453.0); + game.ball.velocity = MilliVec { x: 0, y: 3_000 }.to_velocity_per_second(); + + assert!(game.fixed_update(&mut Vec::new())); + + assert_eq!( + MilliVec::from_position(game.ball.position), + MilliVec { + x: 16_698, + y: 21_191, + } + ); + assert_eq!( + MilliVec::from_velocity_per_second(game.ball.velocity), + MilliVec { + x: -302, + y: -1_809, + } + ); + assert_eq!(game.multiball_state, MultiballState::Unavailable); + assert_eq!(game.wheel_reset_gate, WheelResetGate::Suppressed); + } + #[test] fn armed_effect_seven_also_special_respawns_before_multiball_starts() { let mut game = Game::new(1); diff --git a/tdkpin-rs/src/original_physics.rs b/tdkpin-rs/src/original_physics.rs index 5c70247..fde7c1d 100644 --- a/tdkpin-rs/src/original_physics.rs +++ b/tdkpin-rs/src/original_physics.rs @@ -280,7 +280,7 @@ fn apply_response( } let projection = spin .multiply(Real48::from_bytes([0x7f, 0xcd, 0xcc, 0xcc, 0xcc, 0x4c])) - .subtract(coefficients.tangent) + .add(Real48::from_i32(collision_velocity).multiply(coefficients.tangent)) .round_i32(); let spin = spin .multiply(Real48::from_bytes([0x80, 0x9a, 0x99, 0x99, 0x99, 0x19])) @@ -662,7 +662,7 @@ mod tests { } #[test] - fn outer_shooter_wall_matches_the_zero_spin_c_response() { + fn outer_shooter_wall_matches_the_live_tangent_response() { let old = MilliVec { x: 326_000, y: 200_045, @@ -677,12 +677,12 @@ mod tests { 0.6, 0.1, )); - assert_eq!(velocity, MilliVec { x: -1_800, y: 45 }); + assert_eq!(velocity, MilliVec { x: -1_800, y: 345 }); assert_eq!( old.add(velocity), MilliVec { x: 324_200, - y: 200_090 + y: 200_390 } ); } @@ -705,7 +705,7 @@ mod tests { let response = candidate.resolve(MilliVec { x: 3_000, y: 1_000 }, Real48::ZERO); - assert_eq!(response.velocity, MilliVec { x: -1_800, y: 1_000 }); + assert_eq!(response.velocity, MilliVec { x: -1_800, y: 1_300 }); assert_eq!(response.surface_distance, 2_000); } @@ -764,7 +764,7 @@ mod tests { } #[test] - fn inner_shooter_wall_matches_the_zero_spin_c_response() { + fn inner_shooter_wall_matches_the_live_tangent_response() { let old = MilliVec { x: 320_600, y: 199_325, @@ -779,12 +779,12 @@ mod tests { 0.6, 0.1, )); - assert_eq!(velocity, MilliVec { x: 1_080, y: -210 }); + assert_eq!(velocity, MilliVec { x: 1_080, y: -390 }); assert_eq!( old.add(velocity), MilliVec { x: 321_680, - y: 199_115 + y: 198_935 } ); } @@ -855,7 +855,7 @@ mod tests { } #[test] - fn ordinary_circle_matches_the_zero_spin_c_response() { + fn ordinary_circle_matches_the_live_zero_spin_response() { let old = MilliVec { x: 183_000, y: 70_090, @@ -871,18 +871,18 @@ mod tests { 0.1, 0.0, )); - assert_eq!(velocity, MilliVec { x: 0, y: 564 }); + assert_eq!(velocity, MilliVec { x: 94, y: 564 }); assert_eq!( old.add(velocity), MilliVec { - x: 183_000, + x: 183_094, y: 70_654 } ); } #[test] - fn bumper_circle_matches_the_zero_spin_c_response() { + fn bumper_circle_matches_the_live_zero_spin_response() { let old = MilliVec { x: 165_000, y: 172_015, @@ -898,18 +898,18 @@ mod tests { 0.1, 0.4, )); - assert_eq!(velocity, MilliVec { x: 0, y: 3_096 }); + assert_eq!(velocity, MilliVec { x: 197, y: 3_096 }); assert_eq!( old.add(velocity), MilliVec { - x: 165_000, + x: 165_197, y: 175_111 } ); } #[test] - fn retained_spin_reproduces_the_live_circle_tangent() { + fn retained_spin_adds_to_the_live_circle_tangent() { let response = circle_collision_response( MilliVec { x: 183_000, @@ -922,7 +922,7 @@ mod tests { Real48::from_i32(-235), ) .expect("live probe enters object 155"); - assert_eq!(response.velocity, MilliVec { x: 94, y: 564 }); + assert_eq!(response.velocity, MilliVec { x: 188, y: 564 }); } #[test]