Seed expansion uses SplitMix64 so all 32 seed bytes affect the stream; copying the seed directly into xoshiro state left the first draw dependent only on bytes 8-15. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
76 lines
2.2 KiB
Go
76 lines
2.2 KiB
Go
package sim
|
|
|
|
import (
|
|
"encoding/binary"
|
|
|
|
"github.com/drjones/quantum-arcade/pkg/fixed"
|
|
)
|
|
|
|
// RNG is a deterministic xoshiro256** generator seeded from 32 bytes.
|
|
// It uses only integer operations, so a browser replaying a round reproduces
|
|
// the server's stream exactly.
|
|
//
|
|
// This is the expansion function, not the entropy source: the seed itself comes
|
|
// from the commit-reveal protocol, which is what makes outcomes unriggable.
|
|
type RNG struct {
|
|
state [4]uint64
|
|
}
|
|
|
|
// NewRNG creates a reproducible generator from a 32-byte seed.
|
|
//
|
|
// The seed is run through SplitMix64 rather than copied into the state
|
|
// directly. Copying directly leaves the first output depending only on
|
|
// state[1], so seeds differing in other bytes produce identical first draws —
|
|
// which would make CrashPoint blind to most of its own seed.
|
|
func NewRNG(seed [32]byte) *RNG {
|
|
// Fold every seed byte into a single accumulator first, so all 32 bytes
|
|
// influence all four state words.
|
|
acc := uint64(0x9E3779B97F4A7C15)
|
|
for i := 0; i < 4; i++ {
|
|
acc ^= binary.LittleEndian.Uint64(seed[i*8 : i*8+8])
|
|
acc = splitMix64(&acc)
|
|
}
|
|
r := &RNG{}
|
|
for i := 0; i < 4; i++ {
|
|
r.state[i] = splitMix64(&acc)
|
|
}
|
|
// An all-zero state is a fixed point of the recurrence. SplitMix64 makes
|
|
// this vanishingly unlikely, but the guard costs nothing.
|
|
if r.state[0]|r.state[1]|r.state[2]|r.state[3] == 0 {
|
|
r.state[0] = 0x9E3779B97F4A7C15
|
|
}
|
|
return r
|
|
}
|
|
|
|
// splitMix64 advances x and returns a well-mixed 64-bit value. Every input bit
|
|
// affects every output bit, which is the property the state expansion needs.
|
|
func splitMix64(x *uint64) uint64 {
|
|
*x += 0x9E3779B97F4A7C15
|
|
z := *x
|
|
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9
|
|
z = (z ^ (z >> 27)) * 0x94D049BB133111EB
|
|
return z ^ (z >> 31)
|
|
}
|
|
|
|
// Uint64 returns the next 64 bits of the stream.
|
|
func (r *RNG) Uint64() uint64 {
|
|
s := &r.state
|
|
result := rotl(s[1]*5, 7) * 9
|
|
t := s[1] << 17
|
|
s[2] ^= s[0]
|
|
s[3] ^= s[1]
|
|
s[1] ^= s[2]
|
|
s[0] ^= s[3]
|
|
s[2] ^= t
|
|
s[3] = rotl(s[3], 45)
|
|
return result
|
|
}
|
|
|
|
func rotl(x uint64, k uint) uint64 { return (x << k) | (x >> (64 - k)) }
|
|
|
|
// Unit returns a fixed-point value uniformly distributed over [0, 1).
|
|
// Taking the top 32 bits places them exactly in the fractional field.
|
|
func (r *RNG) Unit() fixed.F {
|
|
return fixed.F(r.Uint64() >> 32)
|
|
}
|