fix(game): use the original Borland random stream
Replace the Xorshift state with the reconstructed Borland Win16 generator, including its wrapping linear update and high-word Random(n) mapping. Route launcher clamp variation, effect selection, and claw terminal choices through one shared stream so seeded event order follows the executable. Use the same stream for the recovered magnetic-field response. Fields now scale horizontal speed by 0.9 and choose the upward impulse from -3800 * (1 - Random * 0.3) instead of assigning a fixed -3000 velocity. Adjust deterministic scenario expectations where the original stream permits a second natural claw capture or does not force one during a particular autoplay seed. Test Plan: - `cargo test --all-targets` -- passed, 56 tests - `cargo clippy --all-targets -- -D warnings` -- passed - `rumdl check CHANGELOG.md RECONSTRUCTION.md` -- passed - `git diff --cached --check` -- passed
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
//! Borland Win16 random-number stream used by the original executable.
|
||||
|
||||
const MULTIPLIER: u32 = 0x0808_8405;
|
||||
const TWO_TO_32: f64 = 4_294_967_296.0;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct BorlandRandom {
|
||||
seed: u32,
|
||||
}
|
||||
|
||||
impl BorlandRandom {
|
||||
pub const fn new(seed: u32) -> Self {
|
||||
Self { seed }
|
||||
}
|
||||
|
||||
pub fn next_u32(&mut self) -> u32 {
|
||||
self.seed = self.seed.wrapping_mul(MULTIPLIER).wrapping_add(1);
|
||||
self.seed
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
pub fn below(&mut self, upper_bound: u16) -> u16 {
|
||||
let product = u64::from(self.next_u32()) * u64::from(upper_bound);
|
||||
(product >> 32) as u16
|
||||
}
|
||||
|
||||
/// Exact host representation of the x87 `Random` result in `[0, 1)`.
|
||||
pub fn unit_interval(&mut self) -> f64 {
|
||||
f64::from(self.next_u32()) / TWO_TO_32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub const fn seed(self) -> u32 {
|
||||
self.seed
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stream_matches_the_reconstructed_borland_runtime() {
|
||||
let mut random = BorlandRandom::new(0x1234_5678);
|
||||
let first = 0x1234_5678_u32.wrapping_mul(MULTIPLIER).wrapping_add(1);
|
||||
assert_eq!(random.next_u32(), first);
|
||||
|
||||
assert_eq!(random.below(3_800), 1_810);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_bounds_still_advance_the_seed() {
|
||||
let mut random = BorlandRandom::new(7);
|
||||
assert_eq!(random.below(0), 0);
|
||||
assert_eq!(random.seed(), 7_u32.wrapping_mul(MULTIPLIER).wrapping_add(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_interval_is_the_exact_unsigned_seed_fraction() {
|
||||
let mut random = BorlandRandom::new(0xfedc_ba98);
|
||||
let expected_seed = 0xfedc_ba98_u32
|
||||
.wrapping_mul(MULTIPLIER)
|
||||
.wrapping_add(1);
|
||||
assert_eq!(
|
||||
random.unit_interval().to_bits(),
|
||||
(f64::from(expected_seed) / TWO_TO_32).to_bits()
|
||||
);
|
||||
}
|
||||
}
|
||||
+13
-23
@@ -1,4 +1,5 @@
|
||||
use crate::{
|
||||
borland_random::BorlandRandom,
|
||||
geometry::{Segment, closest_point},
|
||||
original_physics::{
|
||||
CollisionResponse, GRAVITY_MILLI_PER_STEP, MAXIMUM_SPEED_MILLI_PER_STEP, MilliVec,
|
||||
@@ -235,7 +236,7 @@ pub struct Game {
|
||||
launcher_next_repeat: f32,
|
||||
launcher_velocity_milli: i32,
|
||||
player_entry: PlayerEntry,
|
||||
claw_rng_state: u32,
|
||||
random: BorlandRandom,
|
||||
pending_flipper_edges: [i8; 2],
|
||||
trigger_contacts: [bool; 176],
|
||||
object_active: [bool; 176],
|
||||
@@ -276,7 +277,7 @@ impl Game {
|
||||
launcher_next_repeat: LAUNCHER_REPEAT_DELAY_SECONDS,
|
||||
launcher_velocity_milli: 0,
|
||||
player_entry: PlayerEntry::Open,
|
||||
claw_rng_state: seed.max(1),
|
||||
random: BorlandRandom::new(seed),
|
||||
pending_flipper_edges: [0; 2],
|
||||
trigger_contacts: [false; 176],
|
||||
object_active: initial_object_activity(),
|
||||
@@ -315,11 +316,8 @@ impl Game {
|
||||
self.launcher_velocity_milli -= LAUNCHER_IMPULSE_MILLI;
|
||||
let mut launch_velocity = self.launcher_velocity_milli;
|
||||
if launch_velocity < -MAXIMUM_SPEED_MILLI_PER_STEP {
|
||||
let variation = (self.next_random_value()
|
||||
% u32::try_from(MAXIMUM_SPEED_MILLI_PER_STEP).unwrap_or(3_800))
|
||||
/ 40;
|
||||
launch_velocity =
|
||||
-MAXIMUM_SPEED_MILLI_PER_STEP + i32::try_from(variation).unwrap_or_default();
|
||||
let variation = self.random.below(3_800) / 40;
|
||||
launch_velocity = -MAXIMUM_SPEED_MILLI_PER_STEP + i32::from(variation);
|
||||
}
|
||||
self.ball.in_launcher = false;
|
||||
self.ball.velocity = MilliVec {
|
||||
@@ -714,8 +712,7 @@ impl Game {
|
||||
} else {
|
||||
let previous = self.target_effect;
|
||||
loop {
|
||||
self.target_effect =
|
||||
u8::try_from(self.next_random_value() % 6 + 1).unwrap_or(1);
|
||||
self.target_effect = u8::try_from(self.random.below(6) + 1).unwrap_or(1);
|
||||
if self.target_effect != previous {
|
||||
break;
|
||||
}
|
||||
@@ -927,6 +924,7 @@ impl Game {
|
||||
self.launcher_velocity_milli = 0;
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
fn apply_magnetic_fields(&mut self, old_position: MilliVec, velocity: &mut MilliVec) {
|
||||
for (object_id, min_x, min_y, max_x, max_y) in [
|
||||
(6, 143_000, 421_000, 169_000, 452_000),
|
||||
@@ -948,7 +946,9 @@ impl Game {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
velocity.y = velocity.y.min(-3_000);
|
||||
velocity.x = (f64::from(velocity.x) * 0.9).round() as i32;
|
||||
let vertical_factor = 1.0 - self.random.unit_interval() * 0.3;
|
||||
velocity.y = -(f64::from(MAXIMUM_SPEED_MILLI_PER_STEP) * vertical_factor).round() as i32;
|
||||
if old_position.add(*velocity).y < min_y {
|
||||
self.object_active[object_id] = false;
|
||||
}
|
||||
@@ -965,17 +965,7 @@ impl Game {
|
||||
}
|
||||
|
||||
fn next_claw_terminal_frame(&mut self) -> u8 {
|
||||
let value = self.next_random_value();
|
||||
CLAW_TERMINAL_FRAMES[value as usize % CLAW_TERMINAL_FRAMES.len()]
|
||||
}
|
||||
|
||||
fn next_random_value(&mut self) -> u32 {
|
||||
let mut value = self.claw_rng_state;
|
||||
value ^= value << 13;
|
||||
value ^= value >> 17;
|
||||
value ^= value << 5;
|
||||
self.claw_rng_state = value;
|
||||
value
|
||||
CLAW_TERMINAL_FRAMES[usize::from(self.random.below(4))]
|
||||
}
|
||||
|
||||
fn apply_flipper_kicks(&mut self) {
|
||||
@@ -1870,7 +1860,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn top_sensor_uses_the_original_score_and_contact_latch() {
|
||||
let mut game = Game::new(1);
|
||||
let mut game = Game::new_with_seed(1, 7);
|
||||
game.ball.in_launcher = false;
|
||||
game.ball.position = vec2(205.0, 55.0);
|
||||
let mut events = Vec::new();
|
||||
@@ -1894,7 +1884,7 @@ mod tests {
|
||||
},
|
||||
&mut field_velocity,
|
||||
);
|
||||
assert_eq!(field_velocity, MilliVec { x: 0, y: -3_000 });
|
||||
assert_eq!(field_velocity, MilliVec { x: 0, y: -3_550 });
|
||||
game.apply_magnetic_fields(
|
||||
MilliVec {
|
||||
x: 15_000,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod app;
|
||||
mod assets;
|
||||
mod borland_random;
|
||||
mod game;
|
||||
mod geometry;
|
||||
mod original_physics;
|
||||
|
||||
@@ -389,8 +389,6 @@ mod tests {
|
||||
assert!(event_count("Bumper") >= 1);
|
||||
assert!(event_count("Target") >= 1);
|
||||
assert!(event_count("Lock") >= 1);
|
||||
assert!(event_count("ClawCapture") >= 1);
|
||||
assert_eq!(event_count("ClawCapture"), event_count("ClawRelease"));
|
||||
assert!(event_count("Drain") >= 1);
|
||||
assert!(simulation.trace.iter().all(|snapshot| {
|
||||
snapshot.ball.x.is_finite()
|
||||
@@ -459,7 +457,6 @@ mod tests {
|
||||
"{} must release every captured ball",
|
||||
scenario.name()
|
||||
);
|
||||
assert!(!simulation.game.claw.active);
|
||||
assert!(!simulation.game.claw.ball_suspended);
|
||||
assert!(simulation.game.ball.position.is_finite());
|
||||
assert!(simulation.game.ball.velocity.is_finite());
|
||||
|
||||
Reference in New Issue
Block a user