Skip to main content
← PSoXide SDK guides and examples · SDK f2fec1c301f1 · All Cargo features enabled; check feature gates before use.

psx_fx/
rng.rs

1//! Deterministic linear-congruential PRNG.
2//!
3//! Not cryptographically interesting -- perfect for sprinkling
4//! variability across particle velocity / enemy-shot cadence /
5//! any "looks random but must replay identically" effect. The
6//! constants match the venerable `glibc` LCG, which has
7//! good-enough statistical properties for game use and produces
8//! the same output on every PS1 / host / emulator.
9
10/// 32-bit integer LCG. Step once per `next()` / `signed()` call.
11#[derive(Copy, Clone, Debug)]
12#[repr(transparent)]
13pub struct LcgRng(u32);
14
15impl LcgRng {
16    /// Build with an explicit seed. Same seed → same sequence.
17    pub const fn new(seed: u32) -> Self {
18        Self(seed)
19    }
20
21    /// One LCG step. Multiplier + increment are `glibc`'s constants.
22    /// Returns the fresh internal state.
23    ///
24    /// # Take the high bits, not the low ones
25    ///
26    /// The low bits of any power-of-two LCG are very weak, and bit 0 of this
27    /// one is not random at all. The multiplier and the increment are both
28    /// odd, so `x' = x*m + c` gives `x'0 = x0 ^ 1`: bit 0 strictly alternates,
29    /// period two. Bit `k` has period at most `2^(k+1)`.
30    ///
31    /// So `next() & 1` ping-pongs, `next() & 127` cycles inside 128 draws, and
32    /// `next() % 25` leans on the same weak bits. Use [`LcgRng::next_mixed`]
33    /// for anything that masks or takes a remainder, or shift the high half
34    /// down yourself as [`LcgRng::signed`] does.
35    #[inline]
36    #[allow(clippy::should_implement_trait)]
37    pub fn next(&mut self) -> u32 {
38        self.0 = self.0.wrapping_mul(1_103_515_245).wrapping_add(12345);
39        self.0
40    }
41
42    /// One step with the strong high half folded down over the weak low half,
43    /// for callers that mask or take a remainder.
44    ///
45    /// VoXide worked this out and carried it as a private wrapper: its callers
46    /// lean on `& 1`, `% 4` and `% 120`, and the raw low bits cycle with tiny
47    /// periods. It belongs on the generator rather than in one game.
48    #[inline]
49    pub fn next_mixed(&mut self) -> u32 {
50        let x = self.next();
51        x ^ (x >> 16)
52    }
53
54    /// Uniform-ish value in `[0, max)`, or 0 when `max` is 0.
55    ///
56    /// Sourced from [`LcgRng::next_mixed`], so it is safe against the low-bit
57    /// weakness a bare `next() % max` walks into.
58    #[inline]
59    pub fn below(&mut self, max: u32) -> u32 {
60        if max == 0 {
61            return 0;
62        }
63        self.next_mixed() % max
64    }
65
66    /// Signed integer in `[-range, +range]`, symmetric about zero, sourced from
67    /// five bits of the LCG: thirty-two evenly spaced values from `-range` to
68    /// `+range`, rounded toward zero. Any `range` is fine, negative included
69    /// (the result is then mirrored); the arithmetic is 32-bit, so it does not
70    /// overflow for the whole `i16` range.
71    #[inline]
72    pub fn signed(&mut self, range: i16) -> i16 {
73        let r = self.next();
74        spread(((r >> 16) & 0x1F) as u8, range)
75    }
76
77    /// Current internal state -- useful if a caller wants to save /
78    /// restore the RNG across reset boundaries.
79    pub const fn state(self) -> u32 {
80        self.0
81    }
82}
83
84/// Map five random bits (`0..=31`) onto `[-range, +range]`: odd multiples of
85/// `range / 31` from `-31` to `+31`, so the two ends are exactly `-range` and
86/// `+range` and the mapping is odd (`spread(31 - raw) == -spread(raw)`).
87const fn spread(raw: u8, range: i16) -> i16 {
88    let steps = 2 * (raw as i32) - 31; // -31, -29, ..., 31
89    (steps * range as i32 / 31) as i16
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn same_seed_same_sequence() {
98        let mut a = LcgRng::new(0xC0DE_F00D);
99        let mut b = LcgRng::new(0xC0DE_F00D);
100        for _ in 0..100 {
101            assert_eq!(a.next(), b.next());
102        }
103    }
104
105    #[test]
106    fn signed_stays_in_range() {
107        let mut rng = LcgRng::new(0xBEEF_0042);
108        for _ in 0..10_000 {
109            let v = rng.signed(40);
110            assert!((-40..=40).contains(&v), "out of range: {v}");
111        }
112    }
113
114    #[test]
115    fn signed_spans_the_whole_range_and_is_symmetric() {
116        // The old mapping gave [-range, 15 * range / 16]: at range = 40 the
117        // maximum was 37, and every burst drifted toward -x / -y.
118        for range in [1i16, 5, 40, 100, 1000, 2047, 2048, 20_000, i16::MAX] {
119            let mut min = i16::MAX;
120            let mut max = i16::MIN;
121            let mut sum = 0i64;
122            for raw in 0..32u8 {
123                let v = spread(raw, range);
124                assert_eq!(v, -spread(31 - raw, range), "range {range} raw {raw}");
125                min = min.min(v);
126                max = max.max(v);
127                sum += v as i64;
128            }
129            assert_eq!((min, max), (-range, range), "range {range}");
130            assert_eq!(sum, 0, "no net drift at range {range}");
131        }
132    }
133
134    #[test]
135    fn signed_does_not_overflow_on_large_ranges() {
136        // (raw - 16) * range overflowed i16 above 2047: a panic in a host
137        // debug build, a flipped sign on the console.
138        let mut rng = LcgRng::new(9);
139        for _ in 0..1000 {
140            let v = rng.signed(i16::MAX);
141            assert!(i32::from(v).abs() <= i32::from(i16::MAX));
142            let w = rng.signed(i16::MIN + 1);
143            assert!(i32::from(w).abs() <= i32::from(i16::MAX));
144        }
145    }
146
147    #[test]
148    fn signed_zero_range_is_zero() {
149        let mut rng = LcgRng::new(1);
150        for _ in 0..100 {
151            assert_eq!(rng.signed(0), 0);
152        }
153    }
154}