From 3d19f91eb36e6204bb436640052da796ee830e8b Mon Sep 17 00:00:00 2001 From: "DESKTOP-5KM6RA3\\david" Date: Tue, 30 Sep 2025 14:48:24 -0400 Subject: [PATCH] refactor: random generator for reproducible results regardless of parallelization --- README.md | 29 ++ .../Utility/DeterministicRandom.cs | 211 +++++++++++++ .../Utility/ThreadLocalRandom.cs | 127 +++----- .../DeterministicRandom.Tests.cs | 297 ++++++++++++++++++ 4 files changed, 578 insertions(+), 86 deletions(-) create mode 100644 src/FixedMathSharp/Utility/DeterministicRandom.cs create mode 100644 tests/FixedMathSharp.Tests/DeterministicRandom.Tests.cs diff --git a/README.md b/README.md index 4368cdc..01015de 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,34 @@ Fixed64 sinValue = FixedTrigonometry.Sin(angle); Console.WriteLine(sinValue); // Output: ~0.707 ``` +### Deterministic Random Generation + +Use `DeterministicRandom` when you need reproducible random values across runs, worlds, or features. +Streams are derived from a seed and remain deterministic regardless of threading or platform. + +```csharp +// Simple constructor-based stream: +var rng = new DeterministicRandom(42UL); + +// Deterministic integer: +int value = rng.Next(1, 10); // [1,10) + +// Deterministic Fixed64 in [0,1): +Fixed64 ratio = rng.NextFixed6401(); + +// One stream per “feature” that’s stable for the same worldSeed + key: +var rngOre = DeterministicRandom.FromWorldFeature(worldSeed: 123456789UL, featureKey: 0xORE); +var rngRivers = DeterministicRandom.FromWorldFeature(123456789UL, 0xRIV, index: 0); + +// Deterministic Fixed64 draws: +Fixed64 h = rngOre.NextFixed64(Fixed64.One); // [0, 1) +Fixed64 size = rngOre.NextFixed64(Fixed64.Zero, 5 * Fixed64.One); // [0, 5) +Fixed64 posX = rngRivers.NextFixed64(-Fixed64.One, Fixed64.One); // [-1, 1) + +// Deterministic integers: +int loot = rngOre.Next(1, 5); // [1,5) +``` + --- ## 📦 Library Structure @@ -116,6 +144,7 @@ Console.WriteLine(sinValue); // Output: ~0.707 - **`IBound` Interface:** Standard interface for bounding shapes `BoundingBox`, `BoundingArea`, and `BoundingSphere`, each offering intersection, containment, and projection logic. - **`FixedMath` Static Class:** Provides common math and trigonometric functions using fixed-point math. - **`Fixed4x4` and `Fixed3x3`:** Support matrix operations for transformations. +- **`DeterministicRandom` Struct:** Seedable, allocation-free RNG for repeatable procedural generation. ### Fixed64 Struct diff --git a/src/FixedMathSharp/Utility/DeterministicRandom.cs b/src/FixedMathSharp/Utility/DeterministicRandom.cs new file mode 100644 index 0000000..51e216b --- /dev/null +++ b/src/FixedMathSharp/Utility/DeterministicRandom.cs @@ -0,0 +1,211 @@ +using System; +using System.Runtime.CompilerServices; + +namespace FixedMathSharp.Utility +{ + /// + /// Fast, seedable, deterministic RNG suitable for lockstep sims and map gen. + /// Uses xoroshiro128++ with splitmix64 seeding. No allocations, no time/GUID. + /// + public struct DeterministicRandom + { + // xoroshiro128++ state + private ulong _s0; + private ulong _s1; + + #region Construction / Seeding + + public DeterministicRandom(ulong seed) + { + // Expand a single seed into two 64-bit state words via splitmix64. + _s0 = SplitMix64(ref seed); + _s1 = SplitMix64(ref seed); + + // xoroshiro requires non-zero state; repair pathological seed. + if (_s0 == 0UL && _s1 == 0UL) + _s1 = 0x9E3779B97F4A7C15UL; + } + + /// + /// Create a stream deterministically + /// Derived from (worldSeed, featureKey[,index]). + /// + public static DeterministicRandom FromWorldFeature(ulong worldSeed, ulong featureKey, ulong index = 0) + { + // Simple reversible mix (swap for a stronger mix if required). + ulong seed = Mix64(worldSeed, featureKey); + seed = Mix64(seed, index); + return new DeterministicRandom(seed); + } + + #endregion + + #region Core PRNG + + /// + /// xoroshiro128++ next 64 bits. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ulong NextU64() + { + ulong s0 = _s0, s1 = _s1; + ulong result = RotL(s0 + s1, 17) + s0; + + s1 ^= s0; + _s0 = RotL(s0, 49) ^ s1 ^ (s1 << 21); // a,b + _s1 = RotL(s1, 28); // c + + return result; + } + + /// + /// Next non-negative Int32 in [0, int.MaxValue]. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int Next() + { + // Take high bits for better quality; mask to 31 bits non-negative. + return (int)(NextU64() >> 33); + } + + /// + /// Unbiased int in [0, maxExclusive). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int Next(int maxExclusive) + { + return maxExclusive <= 0 + ? throw new ArgumentOutOfRangeException(nameof(maxExclusive)) + : (int)NextBounded((uint)maxExclusive); + } + + /// + /// Unbiased int in [min, maxExclusive). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int Next(int minInclusive, int maxExclusive) + { + if (minInclusive >= maxExclusive) + throw new ArgumentException("min >= max"); + uint range = (uint)(maxExclusive - minInclusive); + return minInclusive + (int)NextBounded(range); + } + + /// + /// Double in [0,1). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public double NextDouble() + { + // 53 random bits -> [0,1) + return (NextU64() >> 11) * (1.0 / (1UL << 53)); + } + + /// + /// Fill span with random bytes. + /// + public void NextBytes(Span buffer) + { + int i = 0; + while (i + 8 <= buffer.Length) + { + ulong v = NextU64(); + Unsafe.WriteUnaligned(ref buffer[i], v); + i += 8; + } + if (i < buffer.Length) + { + ulong v = NextU64(); + while (i < buffer.Length) + { + buffer[i++] = (byte)v; + v >>= 8; + } + } + } + + #endregion + + #region Fixed64 helpers + + /// + /// Random Fixed64 in [0,1). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Fixed64 NextFixed6401() + { + // Produce a raw value in [0, One.m_rawValue) + ulong rawOne = (ulong)Fixed64.One.m_rawValue; + ulong r = NextBounded(rawOne); + return Fixed64.FromRaw((long)r); + } + + /// + /// Random Fixed64 in [0, maxExclusive). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Fixed64 NextFixed64(Fixed64 maxExclusive) + { + if (maxExclusive <= Fixed64.Zero) + throw new ArgumentOutOfRangeException(nameof(maxExclusive), "max must be > 0"); + ulong rawMax = (ulong)maxExclusive.m_rawValue; + ulong r = NextBounded(rawMax); + return Fixed64.FromRaw((long)r); + } + + /// + /// Random Fixed64 in [minInclusive, maxExclusive). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Fixed64 NextFixed64(Fixed64 minInclusive, Fixed64 maxExclusive) + { + if (minInclusive >= maxExclusive) + throw new ArgumentException("min >= max"); + ulong span = (ulong)(maxExclusive.m_rawValue - minInclusive.m_rawValue); + ulong r = NextBounded(span); + return Fixed64.FromRaw((long)r + minInclusive.m_rawValue); + } + + #endregion + + #region Internals: unbiased range, splitmix64, mixing, rotations + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private ulong NextBounded(ulong bound) + { + // Rejection to avoid modulo bias. + // threshold = 2^64 % bound, but expressed as (-bound) % bound + ulong threshold = unchecked((ulong)-(long)bound) % bound; + while (true) + { + ulong r = NextU64(); + if (r >= threshold) + return r % bound; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static ulong RotL(ulong x, int k) => (x << k) | (x >> (64 - k)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static ulong SplitMix64(ref ulong state) + { + ulong z = (state += 0x9E3779B97F4A7C15UL); + z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL; + z = (z ^ (z >> 27)) * 0x94D049BB133111EBUL; + return z ^ (z >> 31); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static ulong Mix64(ulong a, ulong b) + { + // Simple reversible mix (variant of splitmix finalizer). + ulong x = a ^ (b + 0x9E3779B97F4A7C15UL); + x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9UL; + x = (x ^ (x >> 27)) * 0x94D049BB133111EBUL; + return x ^ (x >> 31); + } + + #endregion + } +} \ No newline at end of file diff --git a/src/FixedMathSharp/Utility/ThreadLocalRandom.cs b/src/FixedMathSharp/Utility/ThreadLocalRandom.cs index 23680a2..393da6c 100644 --- a/src/FixedMathSharp/Utility/ThreadLocalRandom.cs +++ b/src/FixedMathSharp/Utility/ThreadLocalRandom.cs @@ -4,113 +4,68 @@ namespace FixedMathSharp.Utility { /// - /// Provides thread-safe, deterministic random number generation for use in simulations, games, - /// and physics engines. This utility ensures randomness is consistent across multiple threads, - /// avoiding common pitfalls of shared Random instances and aiding reproducible calculations. + /// Deterministic per-thread RNG facade. /// + [Obsolete("ThreadLocalRandom is deprecated. Use DeterministicRandom or DeterministicRandom.FromWorldFeature(...) for deterministic streams.", false)] public static class ThreadLocalRandom { - /// - /// Random number generator used to generate seeds, - /// which are then used to create new random number - /// generators on a per-thread basis. - /// - private static readonly Random globalRandom = new Random(Guid.NewGuid().GetHashCode()); - private static readonly object globalLock = new object(); - - /// - /// Random number generator - /// - private static readonly ThreadLocal threadRandom = new ThreadLocal(NewRandom); + private static ulong _rootSeed = 0; + private static Func _threadIndexProvider = null!; + private static ThreadLocal _threadRng = null!; /// - /// Creates a new instance of Random. The seed is derived - /// from a global (static) instance of Random, rather - /// than time. + /// Initialize global deterministic seeding. + /// Provide a stable threadIndex (0..T-1) for each thread. /// - public static Random NewRandom() + public static void Initialize(ulong rootSeed, Func threadIndexProvider) { - lock (globalLock) - return new Random(globalRandom.Next()); + _rootSeed = rootSeed; + _threadIndexProvider = threadIndexProvider ?? throw new ArgumentNullException(nameof(threadIndexProvider)); + + _threadRng = new ThreadLocal(() => + { + int idx = _threadIndexProvider(); + // Derive a unique stream per thread deterministically from rootSeed + idx. + return DeterministicRandom.FromWorldFeature(_rootSeed, (ulong)idx); + }); } /// - /// Returns an instance of Random which can be used freely - /// within the current thread. + /// Create a new independent RNG from a specific seed (does not affect thread Instance). /// - public static Random Instance => threadRandom.Value; - - /// See - public static int Next() - { - return Instance.Next(); - } - - /// See - public static int Next(int maxValue) - { - return Instance.Next(maxValue); - } - - /// See - public static int Next(int minValue, int maxValue) - { - return Instance.Next(minValue, maxValue); - } + public static DeterministicRandom NewRandom(ulong seed) => new(seed); /// - /// Returns a random Fixed64 number that is less than `max`. + /// Per-thread RNG instance (requires Initialize to be called first). /// - /// - public static Fixed64 NextFixed64(Fixed64 max = default) + public static DeterministicRandom Instance { - if (max == Fixed64.Zero) - throw new ArgumentException("Max value must be greater than zero."); - - byte[] buf = new byte[8]; - Instance.NextBytes(buf); - - // Use bitwise operation to ensure a non-negative long. - long longRand = BitConverter.ToInt64(buf, 0) & long.MaxValue; - - return Fixed64.FromRaw(longRand % max.m_rawValue); + get + { + if (_threadRng == null) + throw new InvalidOperationException("ThreadLocalRandom.Initialize(rootSeed, threadIndexProvider) must be called first."); + return _threadRng.Value; + } } - /// - /// Returns a random Fixed64 number that is greater than or equal to `min`, and less than `max`. - /// - /// - /// - public static Fixed64 NextFixed64(Fixed64 min, Fixed64 max) - { - if (min >= max) - throw new ArgumentException("Min value must be less than max."); - - byte[] buf = new byte[8]; - Instance.NextBytes(buf); - - // Ensure non-negative random long. - long longRand = BitConverter.ToInt64(buf, 0) & long.MaxValue; + #region Convenience mirrors - return Fixed64.FromRaw(longRand % (max.m_rawValue - min.m_rawValue)) + min; - } + public static int Next() => Instance.Next(); + public static int Next(int maxExclusive) => Instance.Next(maxExclusive); + public static int Next(int minInclusive, int maxExclusive) => Instance.Next(minInclusive, maxExclusive); + public static double NextDouble() => Instance.NextDouble(); + public static double NextDouble(double min, double max) => Instance.NextDouble() * (max - min) + min; - /// See - public static double NextDouble() - { - return Instance.NextDouble(); - } - - /// See - public static double NextDouble(double min, double max) - { - return Instance.NextDouble() * (max - min) + min; - } - - /// See public static void NextBytes(byte[] buffer) { + if (buffer == null) throw new ArgumentNullException(nameof(buffer)); Instance.NextBytes(buffer); } + + public static Fixed64 NextFixed6401() => Instance.NextFixed6401(); + public static Fixed64 NextFixed64(Fixed64 maxExclusive) => Instance.NextFixed64(maxExclusive); + public static Fixed64 NextFixed64(Fixed64 minInclusive, Fixed64 maxExclusive) => Instance.NextFixed64(minInclusive, maxExclusive); + + #endregion } -} \ No newline at end of file +} diff --git a/tests/FixedMathSharp.Tests/DeterministicRandom.Tests.cs b/tests/FixedMathSharp.Tests/DeterministicRandom.Tests.cs new file mode 100644 index 0000000..95d72ab --- /dev/null +++ b/tests/FixedMathSharp.Tests/DeterministicRandom.Tests.cs @@ -0,0 +1,297 @@ +using System; +using System.Linq; +using System.Runtime.CompilerServices; +using FixedMathSharp.Utility; +using Xunit; + +namespace FixedMathSharp.Tests +{ + public class DeterministicRandomTests + { + // Helper: pull a sequence from NextU64 to compare streams + private static ulong[] U64Seq(DeterministicRandom rng, int count) + { + var arr = new ulong[count]; + for (int i = 0; i < count; i++) arr[i] = rng.NextU64(); + return arr; + } + + // Helper: pull a sequence from Next(int) to compare streams + private static int[] IntSeq(DeterministicRandom rng, int count, int min, int max) + { + var arr = new int[count]; + for (int i = 0; i < count; i++) arr[i] = rng.Next(min, max); + return arr; + } + + [Fact] + public void SameSeed_Yields_IdenticalSequences() + { + var a = new DeterministicRandom(123456789UL); + var b = new DeterministicRandom(123456789UL); + + // Interleave various calls to ensure internal state advances identically + Assert.Equal(a.NextU64(), b.NextU64()); + Assert.Equal(a.Next(), b.Next()); + Assert.Equal(a.Next(1000), b.Next(1000)); + Assert.Equal(a.Next(-50, 50), b.Next(-50, 50)); + Assert.Equal(a.NextDouble(), b.NextDouble(), 14); + + // Then compare a longer run of NextU64 to be extra sure + var seqA = U64Seq(a, 32); + var seqB = U64Seq(b, 32); + Assert.Equal(seqA, seqB); + } + + [Fact] + public void DifferentSeeds_Yield_DifferentSequences() + { + var a = new DeterministicRandom(1UL); + var b = new DeterministicRandom(2UL); + + // It's possible (but astronomically unlikely) that first value matches; check a window + var seqA = U64Seq(a, 16); + var seqB = U64Seq(b, 16); + + // Require at least one difference in the first 16 draws + Assert.NotEqual(seqA, seqB); + } + + [Fact] + public void FromWorldFeature_IsStable_AndSeparatesByFeature_AndIndex() + { + ulong world = 0xDEADBEEFCAFEBABEUL; + ulong featureOre = 0x4F5245UL; // 'ORE' + ulong featureRiver = 0x524956UL; // 'RIV' + + // Stability: repeated construction yields same sequence + var a1 = DeterministicRandom.FromWorldFeature(world, featureOre, index: 0); + var a2 = DeterministicRandom.FromWorldFeature(world, featureOre, index: 0); + Assert.Equal(U64Seq(a1, 8), U64Seq(a2, 8)); + + // Different featureKey -> different stream + var b = DeterministicRandom.FromWorldFeature(world, featureRiver, index: 0); + Assert.NotEqual(U64Seq(a1, 8), U64Seq(b, 8)); + + // Different index -> different stream + var c = DeterministicRandom.FromWorldFeature(world, featureOre, index: 1); + Assert.NotEqual(U64Seq(a1, 8), U64Seq(c, 8)); + } + + [Fact] + public void Next_NoArg_IsNonNegative_AndWithinRange() + { + var rng = new DeterministicRandom(42UL); + for (int i = 0; i < 1000; i++) + { + int v = rng.Next(); + Assert.InRange(v, 0, int.MaxValue); + } + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(7)] + [InlineData(97)] + [InlineData(int.MaxValue)] + public void Next_MaxExclusive_RespectsBounds_AndThrows_OnInvalid(int maxExclusive) + { + var rng = new DeterministicRandom(9001UL); + for (int i = 0; i < 4096; i++) + { + int v = rng.Next(maxExclusive); + Assert.InRange(v, 0, maxExclusive - 1); + } + } + + [Fact] + public void Next_MaxExclusive_Throws_WhenNonPositive() + { + var rng = new DeterministicRandom(1UL); + Assert.Throws(() => rng.Next(0)); + Assert.Throws(() => rng.Next(-5)); + } + + [Fact] + public void Next_MinMax_RespectsBounds_AndThrows_OnInvalidRange() + { + var rng = new DeterministicRandom(2024UL); + + for (int i = 0; i < 4096; i++) + { + int v = rng.Next(-10, 10); + Assert.InRange(v, -10, 9); + } + + Assert.Throws(() => rng.Next(5, 5)); + Assert.Throws(() => rng.Next(10, -10)); + Assert.Throws(() => rng.Next(10, 10)); + } + + [Fact] + public void NextDouble_IsInUnitInterval() + { + var rng = new DeterministicRandom(123UL); + for (int i = 0; i < 4096; i++) + { + double d = rng.NextDouble(); + Assert.True(d >= 0.0 && d < 1.0, "NextDouble() must be in [0,1)"); + } + } + + [Fact] + public void NextBytes_FillsEntireBuffer_AndAdvancesState() + { + var rng = new DeterministicRandom(55555UL); + + // Exercise both the fast 8-byte path and the tail path + var sizes = new[] { 1, 7, 8, 9, 10, 15, 16, 17, 31, 32, 33 }; + byte[][] results = new byte[sizes.Length][]; + + for (int i = 0; i < sizes.Length; i++) + { + var buf = new byte[sizes[i]]; + rng.NextBytes(buf); + // Ensure something was written (not all zeros) and length correct + Assert.Equal(sizes[i], buf.Length); + Assert.True(buf.Any(b => b != 0) || sizes[i] == 0); + results[i] = buf; + } + + // Make sure successive calls produce different buffers (very high probability) + for (int i = 1; i < sizes.Length; i++) + { + Assert.NotEqual(results[i - 1], results[i]); + } + } + + [Fact] + public void Fixed64_ZeroToOne_IsWithinRange_AndReproducible() + { + var rng1 = new DeterministicRandom(777UL); + var rng2 = new DeterministicRandom(777UL); + + for (int i = 0; i < 2048; i++) + { + var a = rng1.NextFixed6401(); // [0,1) + var b = rng2.NextFixed6401(); + Assert.True(a >= Fixed64.Zero && a < Fixed64.One); + Assert.Equal(a, b); // same seed, same draw order → identical + } + } + + [Fact] + public void Fixed64_MaxExclusive_IsWithinRange_AndThrowsOnInvalid() + { + var rng = new DeterministicRandom(888UL); + + // Positive max + var max = 5 * Fixed64.One; + for (int i = 0; i < 2048; i++) + { + var v = rng.NextFixed64(max); + Assert.True(v >= Fixed64.Zero && v < max); + } + + // Invalid: max <= 0 + Assert.Throws(() => rng.NextFixed64(Fixed64.Zero)); + Assert.Throws(() => rng.NextFixed64(-Fixed64.One)); + } + + [Fact] + public void Fixed64_MinMax_RespectsBounds_AndThrowsOnInvalidRange() + { + var rng = new DeterministicRandom(999UL); + + var min = -Fixed64.One; + var max = 2 * Fixed64.One; + + for (int i = 0; i < 2048; i++) + { + var v = rng.NextFixed64(min, max); + Assert.True(v >= min && v < max); + } + + Assert.Throws(() => rng.NextFixed64(Fixed64.One, Fixed64.One)); + Assert.Throws(() => rng.NextFixed64(Fixed64.One, Fixed64.Zero)); + } + + [Fact] + public void Interleaved_APIs_Stay_Deterministic() + { + // Ensure mixing different API calls doesn't desynchronize deterministic equality + var a1 = new DeterministicRandom(1234UL); + var a2 = new DeterministicRandom(1234UL); + + // Apply the same interleaving sequence on both instances + int i1 = a1.Next(100); + int i2 = a2.Next(100); + Assert.Equal(i1, i2); + + double d1 = a1.NextDouble(); + double d2 = a2.NextDouble(); + Assert.Equal(d1, d2, 14); + + var buf1 = new byte[13]; + var buf2 = new byte[13]; + a1.NextBytes(buf1); + a2.NextBytes(buf2); + Assert.Equal(buf1, buf2); + + var f1 = a1.NextFixed64(-Fixed64.One, Fixed64.One); + var f2 = a2.NextFixed64(-Fixed64.One, Fixed64.One); + Assert.Equal(f1, f2); + + // And the streams continue to match: + Assert.Equal(a1.NextU64(), a2.NextU64()); + Assert.Equal(a1.Next(), a2.Next()); + } + + [Fact] + public void Next_Int_Bounds_Cover_CommonAndEdgeRanges() + { + var rng = new DeterministicRandom(3141592653UL); + + // Small bounds including 1 (degenerate but valid) + foreach (int bound in new[] { 1, 2, 3, 4, 5, 7, 16, 31, 32, 33, 64, 127, 128, 129, 255, 256, 257 }) + { + for (int i = 0; i < 512; i++) + { + int v = rng.Next(bound); + Assert.InRange(v, 0, bound - 1); + } + } + + // Wide range near int.MaxValue to exercise rejection logic frequently + for (int i = 0; i < 1024; i++) + { + int v = rng.Next(int.MaxValue); + Assert.InRange(v, 0, int.MaxValue - 1); + } + } + + [Fact] + public void Next_Fixed64_Covers_TypicalGameRanges() + { + var rng = new DeterministicRandom(0xFEEDFACEUL); + + // [0, 10) + var ten = 10 * Fixed64.One; + for (int i = 0; i < 2048; i++) + { + var v = rng.NextFixed64(ten); + Assert.True(v >= Fixed64.Zero && v < ten); + } + + // [-5, 5) + var neg5 = -5 * Fixed64.One; + var pos5 = 5 * Fixed64.One; + for (int i = 0; i < 2048; i++) + { + var v = rng.NextFixed64(neg5, pos5); + Assert.True(v >= neg5 && v < pos5); + } + } + } +} \ No newline at end of file