Files
TheTree_mock/scripts/core/deterministic_rng.gd
T

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