155 lines
4.8 KiB
GDScript
155 lines
4.8 KiB
GDScript
class_name DeterministicRng
|
|
extends RefCounted
|
|
|
|
## A small deterministic random stream for simulation code.
|
|
##
|
|
## Each subsystem must create its own stream from the shared root seed and a
|
|
## stable stream ID. Saving both state and counter allows an exact continuation
|
|
## without coupling unrelated systems through one global random sequence.
|
|
|
|
const ALGORITHM_ID: StringName = &"xorshift32-sha256-v1"
|
|
const SNAPSHOT_VERSION := 1
|
|
const UINT32_MASK := 0xffffffff
|
|
const UINT32_RANGE := 0x100000000
|
|
const FALLBACK_NON_ZERO_STATE := 0x6d2b79f5
|
|
|
|
var _root_seed := 0
|
|
var _stream_id: StringName = &"default"
|
|
var _state := FALLBACK_NON_ZERO_STATE
|
|
var _counter := 0
|
|
|
|
|
|
static func from_root_seed(root_seed: int, stream_id: StringName) -> Variant:
|
|
# Loading by resource path keeps this factory usable on a clean checkout,
|
|
# before Godot has generated its global class-name cache.
|
|
var result: Variant = load("res://scripts/core/deterministic_rng.gd").new()
|
|
result.reset(root_seed, stream_id)
|
|
return result
|
|
|
|
|
|
func reset(root_seed: int, stream_id: StringName) -> void:
|
|
_root_seed = root_seed
|
|
_stream_id = stream_id
|
|
_state = _derive_non_zero_state(root_seed, stream_id)
|
|
_counter = 0
|
|
|
|
|
|
func next_u32() -> int:
|
|
# xorshift32 operates only on non-negative 32-bit values. Intermediates stay
|
|
# below the signed 64-bit limit used by GDScript integers.
|
|
var value := _state
|
|
value ^= (value << 13) & UINT32_MASK
|
|
value ^= value >> 17
|
|
value ^= (value << 5) & UINT32_MASK
|
|
_state = value & UINT32_MASK
|
|
_counter += 1
|
|
return _state
|
|
|
|
|
|
func randi_range(minimum: int, maximum: int) -> int:
|
|
if minimum > maximum:
|
|
push_error("DeterministicRng range minimum must not exceed maximum")
|
|
return minimum
|
|
|
|
# A crossing-zero subtraction can overflow only for ranges much larger than
|
|
# this generator supports, so reject it before evaluating the subtraction.
|
|
var width := 0
|
|
if minimum < 0 and maximum > 0:
|
|
if maximum > minimum + UINT32_MASK:
|
|
push_error("DeterministicRng supports ranges of at most 2^32 values")
|
|
return minimum
|
|
width = maximum - minimum
|
|
else:
|
|
width = maximum - minimum
|
|
if width > UINT32_MASK:
|
|
push_error("DeterministicRng supports ranges of at most 2^32 values")
|
|
return minimum
|
|
|
|
var span := width + 1
|
|
if span == 1:
|
|
return minimum
|
|
|
|
# Rejection sampling avoids modulo bias for spans which do not divide 2^32.
|
|
var acceptance_limit := UINT32_RANGE - (UINT32_RANGE % span)
|
|
while true:
|
|
var sample := next_u32()
|
|
if sample < acceptance_limit:
|
|
return minimum + (sample % span)
|
|
return minimum
|
|
|
|
|
|
func snapshot() -> Dictionary:
|
|
return {
|
|
"version": SNAPSHOT_VERSION,
|
|
"algorithm": String(ALGORITHM_ID),
|
|
"root_seed": _root_seed,
|
|
"stream_id": String(_stream_id),
|
|
"state": _state,
|
|
"counter": _counter,
|
|
}
|
|
|
|
|
|
func restore(saved_state: Dictionary) -> bool:
|
|
if int(saved_state.get("version", -1)) != SNAPSHOT_VERSION:
|
|
push_error("Unsupported DeterministicRng snapshot version")
|
|
return false
|
|
if StringName(saved_state.get("algorithm", "")) != ALGORITHM_ID:
|
|
push_error("DeterministicRng snapshot algorithm does not match")
|
|
return false
|
|
|
|
var restored_state := int(saved_state.get("state", 0))
|
|
var restored_counter := int(saved_state.get("counter", -1))
|
|
if restored_state <= 0 or restored_state > UINT32_MASK:
|
|
push_error("DeterministicRng snapshot contains an invalid state")
|
|
return false
|
|
if restored_counter < 0:
|
|
push_error("DeterministicRng snapshot contains an invalid counter")
|
|
return false
|
|
|
|
_root_seed = int(saved_state.get("root_seed", 0))
|
|
_stream_id = StringName(saved_state.get("stream_id", "default"))
|
|
_state = restored_state
|
|
_counter = restored_counter
|
|
return true
|
|
|
|
|
|
func get_root_seed() -> int:
|
|
return _root_seed
|
|
|
|
|
|
func get_stream_id() -> StringName:
|
|
return _stream_id
|
|
|
|
|
|
func get_state() -> int:
|
|
return _state
|
|
|
|
|
|
func get_counter() -> int:
|
|
return _counter
|
|
|
|
|
|
static func _derive_non_zero_state(root_seed: int, stream_id: StringName) -> int:
|
|
var stream_bytes := String(stream_id).to_utf8_buffer()
|
|
var context := HashingContext.new()
|
|
if context.start(HashingContext.HASH_SHA256) != OK:
|
|
push_error("Could not initialize the deterministic RNG seed hash")
|
|
return FALLBACK_NON_ZERO_STATE
|
|
context.update("the-tree:deterministic-rng:v1\n".to_utf8_buffer())
|
|
context.update(("%d\n%d\n" % [root_seed, stream_bytes.size()]).to_utf8_buffer())
|
|
context.update(stream_bytes)
|
|
var digest := context.finish()
|
|
|
|
var derived_state := 0
|
|
for byte_index in range(4):
|
|
derived_state = (derived_state << 8) | int(digest[byte_index])
|
|
if derived_state == 0:
|
|
# The all-zero state is absorbing for xorshift32. A second digest word is
|
|
# already independently distributed; retain a literal fallback as a final
|
|
# guard for the astronomically unlikely double-zero case.
|
|
for byte_index in range(4, 8):
|
|
derived_state = (derived_state << 8) | int(digest[byte_index])
|
|
if derived_state == 0:
|
|
derived_state = FALLBACK_NON_ZERO_STATE
|
|
return derived_state
|