diff --git a/client/scripts/scatter_field.gd b/client/scripts/scatter_field.gd new file mode 100644 index 000000000..8a2b30c5f --- /dev/null +++ b/client/scripts/scatter_field.gd @@ -0,0 +1,183 @@ +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) diff --git a/client/tests/test_scatter_field.gd b/client/tests/test_scatter_field.gd new file mode 100644 index 000000000..67a2ab6c7 --- /dev/null +++ b/client/tests/test_scatter_field.gd @@ -0,0 +1,193 @@ +## Tests for ScatterField — the client-side deterministic scatter service. +## +## What is worth pinning here is the CONTRACT, not the numbers: the service +## exists so paint looks the same when the player comes back, and so that two +## consumers scattering over the same coordinates do not draw on top of each +## other. Both of those are properties, and both fail silently if they break — +## a correlated domain reads as "one slightly odd texture", not as a bug. +## +## Deliberately NOT pinned: the specific values. 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"), and freezing +## the outputs here would turn any future improvement to the mixer into a +## test-breaking change for no gain. +class_name TestScatterField +extends GdUnitTestSuite + +const ScatterField := preload("res://scripts/scatter_field.gd") + + +# ============================================================================= +# The core contract: same question, same answer +# ============================================================================= + + +func test_same_position_repeats_forever() -> void: + var salt: int = ScatterField.domain(&"test/repeat") + var first: float = ScatterField.value(salt, 12, 34) + for _i in range(50): + assert_float(ScatterField.value(salt, 12, 34)).override_failure_message( + "a position must answer identically every time — this is the whole service" + ).is_equal(first) + + +func test_neighbouring_positions_decorrelate() -> void: + # Consumers walk coordinates in order, so adjacent inputs producing adjacent + # outputs would render as a gradient or a moire rather than as scatter. + var salt: int = ScatterField.domain(&"test/decorrelate") + var a: float = ScatterField.value(salt, 100, 100) + var b: float = ScatterField.value(salt, 101, 100) + var c: float = ScatterField.value(salt, 100, 101) + assert_float(absf(a - b)).is_greater(0.001) + assert_float(absf(a - c)).is_greater(0.001) + + +func test_values_stay_in_unit_range() -> void: + var salt: int = ScatterField.domain(&"test/range") + for i in range(500): + var v: float = ScatterField.value(salt, i, i * 7) + assert_float(v).is_between(0.0, 1.0) + + +func test_negative_coordinates_are_supported() -> void: + # World coordinates go negative — a consumer painting west or south of the + # origin must not crash or fold onto its mirror position. + var salt: int = ScatterField.domain(&"test/negative") + var neg: float = ScatterField.value(salt, -500, -900) + assert_float(neg).is_between(0.0, 1.0) + assert_float(neg).override_failure_message( + "(-x, -y) must not collide with (x, y) — that would mirror all paint about the origin" + ).is_not_equal(ScatterField.value(salt, 500, 900)) + + +# ============================================================================= +# Domain separation — why graffiti and cracks do not land together +# ============================================================================= + + +func test_domains_are_uncorrelated_at_the_same_position() -> void: + var graffiti: int = ScatterField.domain(&"world/graffiti") + var cracks: int = ScatterField.domain(&"world/cracks") + var agree: int = 0 + for i in range(200): + if is_equal_approx(ScatterField.value(graffiti, i, 0), ScatterField.value(cracks, i, 0)): + agree += 1 + assert_int(agree).override_failure_message( + "two domains agreeing at the same coordinates means every consumer marks the " + + "same spots — the artefacts would stack and read as one" + ).is_less(3) + + +func test_domain_resolution_is_stable_and_cached() -> void: + assert_int(ScatterField.domain(&"test/stable")).is_equal( + ScatterField.domain(&"test/stable") + ) + assert_int(ScatterField.domain(&"test/a")).is_not_equal(ScatterField.domain(&"test/b")) + + +# ============================================================================= +# The derived helpers +# ============================================================================= + + +func test_chance_honours_its_probability() -> void: + var salt: int = ScatterField.domain(&"test/chance") + var hits: int = 0 + var n: int = 4000 + for i in range(n): + if ScatterField.chance(salt, i % 64, i / 64, 0.25): + hits += 1 + var rate: float = float(hits) / float(n) + assert_float(rate).override_failure_message( + "p=0.25 produced %.3f — a biased field makes every density constant a lie" % rate + ).is_between(0.21, 0.29) + + +func test_chance_extremes_are_absolute() -> void: + var salt: int = ScatterField.domain(&"test/extremes") + for i in range(200): + assert_bool(ScatterField.chance(salt, i, 0, 0.0)).is_false() + assert_bool(ScatterField.chance(salt, i, 0, 1.0)).is_true() + + +func test_pick_covers_its_whole_range() -> void: + # A variant chooser that never returns some of its options silently reduces + # the asset set — the sprite exists, it just never appears. + var salt: int = ScatterField.domain(&"test/pick") + var seen: Dictionary = {} + for i in range(600): + seen[ScatterField.pick(salt, i, 0, 4)] = true + assert_int(seen.size()).is_equal(4) + for i in range(50): + assert_int(ScatterField.pick(salt, i, 0, 4)).is_between(0, 3) + + +func test_pick_degenerate_counts_are_safe() -> void: + var salt: int = ScatterField.domain(&"test/pick_degenerate") + assert_int(ScatterField.pick(salt, 5, 5, 1)).is_equal(0) + assert_int(ScatterField.pick(salt, 5, 5, 0)).is_equal(0) + + +func test_jitter_is_centred_and_bounded() -> void: + var salt: int = ScatterField.domain(&"test/jitter") + var sum := Vector2.ZERO + var n: int = 2000 + for i in range(n): + var j: Vector2 = ScatterField.jitter(salt, i % 50, i / 50) + assert_float(j.x).is_between(-0.5, 0.5) + assert_float(j.y).is_between(-0.5, 0.5) + sum += j + # Both axes must be independent streams; a shared one puts every mark on the + # diagonal, which reads as a hatch rather than as scatter. + assert_float(absf(sum.x / float(n))).is_less(0.05) + assert_float(absf(sum.y / float(n))).is_less(0.05) + + +# ============================================================================= +# The time axis — animation and layering +# ============================================================================= + + +func test_time_step_changes_the_field() -> void: + var salt: int = ScatterField.domain(&"sky/cloud_cover") + var t0: float = ScatterField.value(salt, 7, 7, 0) + var t1: float = ScatterField.value(salt, 7, 7, 1) + assert_float(absf(t0 - t1)).override_failure_message( + "advancing t must move the field, or cloud cover cannot animate" + ).is_greater(0.001) + # ...and each step is itself stable, so an animation loops rather than boils. + assert_float(ScatterField.value(salt, 7, 7, 1)).is_equal(t1) + + +# ============================================================================= +# smooth() — the continuous field +# ============================================================================= + + +func test_smooth_is_continuous_between_lattice_points() -> void: + # The point of smooth() over value(): neighbouring samples must be CLOSE, or + # cloud cover is speckle instead of cloud. + var salt: int = ScatterField.domain(&"test/smooth") + var prev: float = ScatterField.smooth(salt, 0.0, 0.0, 64.0) + for i in range(1, 200): + var v: float = ScatterField.smooth(salt, float(i), 0.0, 64.0) + assert_float(absf(v - prev)).override_failure_message( + "smooth() jumped %.3f in one world unit at wavelength 64 — not a field" % absf(v - prev) + ).is_less(0.2) + prev = v + + +func test_smooth_stays_in_unit_range_and_repeats() -> void: + var salt: int = ScatterField.domain(&"test/smooth_range") + for i in range(300): + var v: float = ScatterField.smooth(salt, float(i) * 3.7, float(i) * 1.3, 32.0) + assert_float(v).is_between(0.0, 1.0) + assert_float(ScatterField.smooth(salt, 12.5, 8.25, 32.0)).is_equal( + ScatterField.smooth(salt, 12.5, 8.25, 32.0) + ) + + +func test_smooth_degenerate_wavelength_does_not_divide_by_zero() -> void: + var salt: int = ScatterField.domain(&"test/smooth_zero") + assert_float(ScatterField.smooth(salt, 4.0, 4.0, 0.0)).is_between(0.0, 1.0) + assert_float(ScatterField.smooth(salt, 4.0, 4.0, -8.0)).is_between(0.0, 1.0) diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd index b31f13ac2..1cfbeaebb 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd @@ -25,6 +25,12 @@ extends RefCounted const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd") +## Preloaded rather than referenced by its `class_name`, matching this file's +## existing convention — a global class symbol is not guaranteed to be +## registered when a script is opened directly (the parse sweep does exactly +## that), whereas a preload resolves by path every time. +const ScatterField := preload("res://scripts/scatter_field.gd") + const TOGGLE_TEMP: String = "gen_dw_temp" const TOGGLE_MOISTURE: String = "gen_dw_moisture" const TOGGLE_VEGETATION: String = "gen_dw_veg" @@ -162,14 +168,17 @@ static func _texture(planes: CellPlanes, col: int, row: int, base: Color) -> Col var out: Color = base var rug: float = _ruggedness(planes, col, row) - if rug > 0.0 and _dither(col, row, 0x51F) < rug: + if rug > 0.0 and ScatterField.chance(_relief_salt, col, row, rug): out = out.darkened(RELIEF_STIPPLE_STRENGTH * rug) # Half-frequency lattice: one mark per 2x2 cells, so the vegetation grain is # visibly coarser than the relief grain rather than a second speckle at the # same pitch. var veg: int = _l8_value(planes.vegetation, col, row) - if veg >= VEGETATION_SCRUB and _dither(col >> 1, row >> 1, 0xB33F) < VEGETATION_PATCH_DENSITY: + if ( + veg >= VEGETATION_SCRUB + and ScatterField.chance(_veg_salt, col >> 1, row >> 1, VEGETATION_PATCH_DENSITY) + ): out = out.darkened(VEGETATION_PATCH_STRENGTH) return out @@ -217,14 +226,13 @@ static func _ruggedness(planes: CellPlanes, col: int, row: int) -> float: return clampf(maxf(from_relief, from_elev), 0.0, 1.0) -## Deterministic [0,1) dither for a cell, salted per mark type so the relief and -## vegetation lattices never correlate (sharing a hash would stack both marks on -## the same cells and read as one texture at double contrast). -static func _dither(col: int, row: int, salt: int) -> float: - var h: int = (col * 0x1F1F1F1F) ^ (row * 0x9E3779B1) ^ salt - h = (h ^ (h >> 15)) * 0x2545F491 - h = (h ^ (h >> 13)) * 0x27220A95 - return float(absi(h) % 4096) / 4096.0 +## Domain salts for the two marks, resolved ONCE at class load rather than per +## pixel (this file's colorize loop runs ~700,000 times per canvas rebuild at +## 1290x540). Separate domains keep the two lattices uncorrelated — sharing one +## would stack both marks on the same cells and read as a single texture at +## double contrast. +static var _relief_salt: int = ScatterField.domain(&"atlas/relief_stipple") +static var _veg_salt: int = ScatterField.domain(&"atlas/vegetation_patch") static func _base_color(planes: CellPlanes, col: int, row: int) -> Color: