Skip to main content

sgl_core/
rng.rs

1//! Frozen `SplitMix64` stream.
2
3/// The frozen `SplitMix64` pseudo-random generator.
4#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5pub struct SplitMix64 {
6    stream_seed: u64,
7    cursor: u64,
8}
9
10impl SplitMix64 {
11    /// Starts a stream from its immutable seed.
12    #[must_use]
13    pub const fn new(seed: u64) -> Self {
14        Self {
15            stream_seed: seed,
16            cursor: seed,
17        }
18    }
19
20    /// Returns the immutable stream seed, unaffected by draws.
21    #[must_use]
22    pub const fn stream_seed(self) -> u64 {
23        self.stream_seed
24    }
25
26    /// Returns the mutable generator cursor.
27    #[must_use]
28    pub const fn cursor(self) -> u64 {
29        self.cursor
30    }
31
32    /// Produces the next frozen `SplitMix64` word.
33    pub fn next_u64(&mut self) -> u64 {
34        self.cursor = self.cursor.wrapping_add(0x9e37_79b9_7f4a_7c15);
35        let mut value = self.cursor;
36        value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
37        value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
38        value ^ (value >> 31)
39    }
40
41    /// Samples the inclusive range using frozen modulo reduction.
42    ///
43    /// # Panics
44    ///
45    /// Panics when `min > max`.
46    #[must_use]
47    pub fn range_inclusive(&mut self, min: u32, max: u32) -> u32 {
48        assert!(min <= max, "range_inclusive requires min <= max");
49        let width = u64::from(max - min) + 1;
50        min + u32::try_from(self.next_u64() % width)
51            .expect("modulo result fits within the requested u32 range")
52    }
53}
54
55/// Derives a deterministic stream seed from a game-owned base seed and three
56/// caller-supplied domain components.
57///
58/// Each component is absorbed in turn and mixed by one `SplitMix64` step, a
59/// bijection, so changing any single component always changes the seed and
60/// no two components can cancel. Inputs that differ in several components
61/// share a seed only by 64-bit chance.
62#[must_use]
63pub fn derive_stream_seed(base: u64, domain: u64, a: u32, b: u64) -> u64 {
64    let mix = |state: u64| SplitMix64::new(state).next_u64();
65    [domain, u64::from(a), b]
66        .into_iter()
67        .fold(mix(base), |state, component| mix(state ^ component))
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use wasm_bindgen_test::wasm_bindgen_test;
74
75    /// #249: a fresh generator exposes its seed as both the stream seed and
76    /// the cursor; a draw moves the cursor by the `SplitMix64` increment.
77    #[wasm_bindgen_test(unsupported = test)]
78    fn a_fresh_generator_exposes_its_seed_and_cursor() {
79        let mut rng = SplitMix64::new(42);
80        assert_eq!(rng.stream_seed(), 42);
81        assert_eq!(rng.cursor(), 42);
82        rng.next_u64();
83        assert_eq!(rng.cursor(), 42u64.wrapping_add(0x9e37_79b9_7f4a_7c15));
84    }
85
86    /// #312: components whose bits once overlapped after rotation (and
87    /// cancelled under XOR) give different seeds: chunk `(65536, 0)` versus
88    /// `(0, 1)`, and a domain bit versus a `b` bit.
89    #[wasm_bindgen_test(unsupported = test)]
90    fn overlapping_components_do_not_cancel() {
91        for (left, right) in [
92            ((9, 3, 1 << 16, 0), (9, 3, 0, 1)),
93            ((9, 1, 5, 0), (9, 0, 5, 1 << 34)),
94        ] {
95            assert_ne!(
96                derive_stream_seed(left.0, left.1, left.2, left.3),
97                derive_stream_seed(right.0, right.1, right.2, right.3),
98                "{left:?} vs {right:?}"
99            );
100        }
101    }
102
103    #[wasm_bindgen_test(unsupported = test)]
104    fn splitmix64_sequence_is_frozen() {
105        let mut rng = SplitMix64::new(0);
106        assert_eq!(rng.next_u64(), 0xe220_a839_7b1d_cdaf);
107        assert_eq!(rng.next_u64(), 0x6e78_9e6a_a1b9_65f4);
108        assert_eq!(rng.next_u64(), 0x06c4_5d18_8009_454f);
109    }
110
111    #[wasm_bindgen_test(unsupported = test)]
112    fn inclusive_range_sampling_is_stable() {
113        let mut rng = SplitMix64::new(7);
114        assert_eq!(rng.range_inclusive(3, 7), 5);
115        assert_eq!(rng.range_inclusive(3, 7), 7);
116        assert_eq!(rng.range_inclusive(3, 7), 4);
117    }
118
119    #[wasm_bindgen_test(unsupported = test)]
120    fn derived_seed_is_frozen() {
121        assert_eq!(derive_stream_seed(17, 29, 41, 53), 0xfa0d_d366_259b_7519);
122    }
123}