Files
tdkpin/tdkpin-rs/src/borland_random.rs
T
ddidderr db0ec0bdc4 fix(random): continue Borland seed across games
The original RandSeed is program-global and is initialized once at startup.
Rust stored the exact Borland generator inside Game but discarded its final
state at game over and seeded every following game from Macroquad again,
breaking stream continuity across high-score/attract flow.

Expose the current seed, retain it when the App consumes a finished game, and
construct the next game from that value. Attract, high-score, and portable UI
continue to consume no gameplay draws.

Test Plan:
- `cargo test --workspace --all-targets --all-features` -- 125 passed
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` -- passed
- `rumdl check --flavor commonmark RECONSTRUCTION.md CHANGELOG.md` -- passed
- `git diff --cached --check` -- passed
2026-08-23 20:43:10 +02:00

104 lines
2.8 KiB
Rust

//! Borland Win16 random-number stream used by the original executable.
use crate::real48::Real48;
const MULTIPLIER: u32 = 0x0808_8405;
#[cfg(test)]
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
}
#[allow(clippy::cast_possible_truncation)]
pub fn real48(&mut self) -> Real48 {
let mut random = self.next_u32();
if random == 0 {
return Real48::ZERO;
}
let mut exponent = 0x80_u8;
while random & 0x8000_0000 == 0 {
random <<= 1;
exponent = exponent.wrapping_sub(1);
}
random &= 0x7fff_ffff;
Real48::from_bytes([
exponent,
0,
random as u8,
(random >> 8) as u8,
(random >> 16) as u8,
(random >> 24) as u8,
])
}
/// Exact host representation of the x87 `Random` result in `[0, 1)`.
#[cfg(test)]
pub fn unit_interval(&mut self) -> f64 {
f64::from(self.next_u32()) / TWO_TO_32
}
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()
);
}
#[test]
fn real48_register_result_matches_the_reconstructed_normalization() {
let mut random = BorlandRandom::new(7);
assert_eq!(
random.real48(),
Real48::from_bytes([0x7e, 0, 0x90, 0x70, 0xee, 0x60])
);
}
}