class_name ScatterField extends RefCounted ## Deterministic, seeded, position-keyed scatter for CLIENT-SIDE PAINT. ## ## The one job: answer "what belongs at this spot" the same way every time, ## without asking the server. Presentation decisions that must look identical ## when the player comes back to a place, but which the simulation has no ## opinion about and should never be burdened with — stipple marks, graffiti ## placement, cracks in a wall texture, drifting cloud cover. ## ## WHAT THIS IS NOT. It does not decide what EXISTS. A cell's biome, a ## settlement's position, whether a wall is there at all — those are world data, ## derived once by the server and sampled everywhere (D-255(f) mechanism B), and ## the player will eventually stand on them. Inventing world data here would put ## the map and the ground in disagreement. The rule that keeps the two apart: ## **if the answer changes what is there, it is not a ScatterField question; if ## it only changes how it is drawn, it is.** ## ## Bit-identity with the server's Rust noise is explicitly NOT a requirement ## (Jeroen, 2026-08-07: *"a seed is a seed and the functional intended outcome ## is repetition here"*). Nothing here is compared against a server value or ## round-tripped through a save; the contract is stability across sessions, not ## agreement across languages. That is exactly why paint belongs on this side — ## it buys visual density with no cross-language determinism burden. ## ## SEEDED FROM THE WORLD. Every value mixes `GameState.world_seed`, so two ## playthroughs scatter differently while one playthrough is stable forever. ## A world seed of 0 (pre-connection) still works and is still deterministic — ## it simply is not world-specific yet. ## ## PERFORMANCE. The first consumer runs per canvas cell — ~700,000 calls per ## rebuild at 1290x540. So the hot calls take an int `salt`, resolved ONCE via ## [method domain], never a string per call. Everything is integer hashing; no ## allocation, no RNG object, no state. ## ## USAGE: ## [codeblock] ## const RELIEF := preload("res://scripts/scatter_field.gd") ## var salt: int = RELIEF.domain(&"atlas/relief_stipple") # once ## if RELIEF.chance(salt, col, row, 0.3): # per cell ## draw_mark() ## [/codeblock] ## Per-domain salts, resolved once per name. Domains keep independent consumers ## UNCORRELATED: without one, graffiti and cracks seeded at the same wall ## coordinate would mark the same spots and read as a single artefact rather ## than two. Cached because [method domain] hashes a string and the hot path ## must not. static var _domains: Dictionary = {} ## Mixed into every value so one playthrough is stable and two differ. Refreshed ## from GameState lazily — see [method _seed]. static var _world_seed: int = 0 static var _world_seed_read: bool = false ## Resolve a domain name to its salt. Call once, keep the int, pass that to the ## hot functions. Names are free-form; the convention is `subsystem/purpose` ## (`atlas/relief_stipple`, `world/graffiti`, `sky/cloud_cover`). static func domain(name: StringName) -> int: if _domains.has(name): return _domains[name] # FNV-1a over the name's UTF-8 — a stable string hash rather than Godot's # `hash()`, whose value is not contracted across engine versions and would # silently re-scatter every mark on an engine upgrade. var h: int = 0x811C9DC5 for b: int in String(name).to_utf8_buffer(): h = ((h ^ b) * 0x01000193) & 0xFFFFFFFF _domains[name] = h return h ## Re-read the world seed on the next call — invoke when a new world loads. static func invalidate_world_seed() -> void: _world_seed_read = false static func _seed() -> int: if not _world_seed_read: # Read via the tree rather than a direct autoload reference: this file # carries a `class_name`, and an autoload touching a class_name symbol # at parse time is the documented parse-order hazard (CLAUDE.md). var loop := Engine.get_main_loop() if loop is SceneTree: var gs: Variant = (loop as SceneTree).root.get_node_or_null("/root/GameState") if gs != null: _world_seed = int(gs.world_seed) _world_seed_read = true return _world_seed ## The core: a stable value in [0, 1) for (domain, x, y, t). ## ## `t` is an optional third axis — a time step for animation (cloud cover), or ## a layer index when one consumer needs several uncorrelated fields at the ## same position. Omit it for static paint. static func value(salt: int, x: int, y: int, t: int = 0) -> float: return float(_mix(salt, x, y, t) % 16777216) / 16777216.0 ## Does a mark land here? `p` is the probability in [0, 1]. ## ## The workhorse: stipple dots, graffiti tags, crack seeds — anything that is ## present-or-absent at a position. Uses its own value stream, so a consumer can ## call [method chance] and [method pick] at one position without the two ## correlating. static func chance(salt: int, x: int, y: int, p: float, t: int = 0) -> bool: return value(salt, x, y, t) < p ## Choose one of `count` variants at this position — which graffiti sprite, ## which crack pattern, which of four grass tufts. Returns 0 when `count <= 1`. static func pick(salt: int, x: int, y: int, count: int, t: int = 0) -> int: if count <= 1: return 0 return int(_mix(salt ^ 0x5BF03635, x, y, t) % count) ## A sub-cell offset in [-0.5, 0.5] on both axes — so scattered marks sit off ## the lattice instead of betraying the grid they were chosen on. Two ## independent streams, one per axis. static func jitter(salt: int, x: int, y: int, t: int = 0) -> Vector2: return Vector2( value(salt ^ 0x9E3779B1, x, y, t) - 0.5, value(salt ^ 0x7F4A7C15, x, y, t) - 0.5 ) ## Smooth value noise in [0, 1] at world position (wx, wy) for one wavelength — ## the continuous counterpart to [method value]'s per-cell hash. ## ## For anything that must read as a FIELD rather than as speckle: cloud cover, ## damp patches on a floor, rust blooms. Animate by advancing `t`. static func smooth(salt: int, wx: float, wy: float, wavelength: float, t: int = 0) -> float: if wavelength <= 0.0: return value(salt, int(wx), int(wy), t) var fx: float = wx / wavelength var fy: float = wy / wavelength var x0: int = int(floor(fx)) var y0: int = int(floor(fy)) var tx: float = fx - float(x0) var ty: float = fy - float(y0) # Smoothstep so lattice cell edges are crease-free — the same shaping the # server's own value_noise uses, for the same reason. var sx: float = tx * tx * (3.0 - 2.0 * tx) var sy: float = ty * ty * (3.0 - 2.0 * ty) var c00: float = value(salt, x0, y0, t) var c10: float = value(salt, x0 + 1, y0, t) var c01: float = value(salt, x0, y0 + 1, t) var c11: float = value(salt, x0 + 1, y0 + 1, t) var a: float = c00 + (c10 - c00) * sx var b: float = c01 + (c11 - c01) * sx return a + (b - a) * sy ## Integer avalanche over (world seed, salt, x, y, t). Splitmix-style finalizer: ## every input bit reaches every output bit, so adjacent coordinates do not ## produce adjacent values — which is the whole point, since the consumers walk ## coordinates in order. static func _mix(salt: int, x: int, y: int, t: int) -> int: var h: int = _seed() h = (h ^ salt) * 0x9E3779B1 h = (h ^ (_zig(x) * 0x85EBCA6B)) & 0x7FFFFFFFFFFFFFFF h = (h ^ (_zig(y) * 0xC2B2AE35)) & 0x7FFFFFFFFFFFFFFF if t != 0: h = (h ^ (_zig(t) * 0x27D4EB2F)) & 0x7FFFFFFFFFFFFFFF h = (h ^ (h >> 15)) * 0x2545F491 h = (h ^ (h >> 13)) * 0x27220A95 return absi(h ^ (h >> 16)) ## Zigzag-encode a signed coordinate to a distinct non-negative one ## (0, -1, 1, -2, 2 -> 0, 1, 2, 3, 4). ## ## Required, not cosmetic. Without it `(-x, -y)` collided with `(x, y)`: the ## products of negated coordinates are themselves negations, and the sign-bit ## mask above then folded the pair together — so every mark west and south of ## the world origin mirrored its counterpart to the north-east. World ## coordinates are routinely negative (the descent ladder's own anchor is at ## y = -5,675,959), so this was on the common path, not an edge case. Caught by ## `test_negative_coordinates_are_supported`. static func _zig(v: int) -> int: return (v << 1) ^ (v >> 63)