spaceship/tests/test_lunar_field.gd
Loïc Wisniewski f5a16a7b95 Share one backdrop between game and editor, and fix three input/economy bugs
The setup editor and the landed surface drew different backgrounds for the
same planet. Each scene carried its own hand-written ShaderMaterial, and only
the game side ever swapped in a profile's shader, so a gas giant authored in
the editor showed recoloured craters while the game showed cloud bands. Both
now instance one SurfaceBackground.tscn and configure it through one method,
and every profile names its own shader - the old "null means keep whatever
the scene already had" convention was the drift, since each scene "already
had" something different. The shared material is resource_local_to_scene, or
two live surfaces would repaint each other.

The gas giant's bands broke into disjoint segments once per cell: the second
turbulence octave doubled only the INTEGER half of a lattice coordinate,
which is a different point in every cell rather than the same point at twice
the frequency. Measured, the old form steps 0.153 at a cell boundary against
0.0002 now. A second discontinuity went with it - the camera cell index
arrives pre-wrapped, so hashing it raw rearranged the whole sky every 32
cells; hashes are now wrapped per octave and the field is exactly periodic.
Bands also gained low-frequency octaves and per-row differential drift.

WorldMap swallowed every touch in _input(), which runs before the GUI system,
so CloseButton never saw one: the map could be opened and not closed. Android
and desktop hid it behind Godot's emulated mouse event, which this never
swallowed; a phone browser gives no such second chance. Its test had been
closing the map by calling the method the button was failing to reach.

Construction now draws on the fleet hold as well as the bank. With an empty
starting bank and ore bankable only at a mothership, charging construction to
the bank alone made the first mothership cost ore that could not yet exist.

Also: a conquered planet no longer re-spawns its garrison (guarded on having
capture points, since a setup with none registers as vacuously conquered);
planets keep the map they drew, with the candidate list sorted rather than
left in filesystem order and assignments pruned rather than cleared on
rescan; and the map editor lists setups at a tappable size and can delete
one, as an explicit armed mode rather than a tap away from destroying work.

Full suite 106/106.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:16:38 +02:00

321 lines
15 KiB
GDScript

extends SceneTree
# Headless test: run with
# godot --headless -s res://tests/test_lunar_field.gd
# Forgejo #68: the lunar backdrop shader was the most expensive thing in the
# surface scene, running a 5x5 (25-sample) neighbour scan on every pixel of a
# fullscreen rect. It is now 3x3 (9 samples).
#
# A fragment shader cannot be asserted on directly from GDScript, so this
# test guards the REASONING that made the reduction safe. The 5x5 width was
# insurance against craters observed clipping at cell boundaries on-device -
# i.e. against a crater reaching further than its nominal radius allowed.
# The fix was to bound that reach at the source (clamp the centre into its
# own cell, clamp the radius), which makes a 3x3 scan provably sufficient.
# If someone later loosens either clamp or raises MAX_RADIUS past a cell, the
# clipping bug comes straight back - and looks like a GPU mystery rather than
# an edit. This test makes that edit fail loudly instead.
#
# Covers:
# A. the shader source still carries every premise the bound rests on
# (3x3 loop, clamped centre, clamped radius, MAX_RADIUS below a cell)
# B. worst case geometry: even a crater pinned at the far corner of a
# ring-2 cell at maximum radius cannot reach a threshold that changes a
# pixel. Independent of what the hash returns, so it holds on any driver
# C. differential check against the real hash: over thousands of sampled
# points, a 3x3 scan and a 5x5 scan produce the SAME rendered output
# D. the miss sentinel stays inside mediump range when squared, so it
# cannot become +Inf (and then NaN) on a mobile GPU
const SHADER_PATH := "res://lunar_surface.gdshader"
const TILED_SHADER_PATH := "res://lunar_tiled.gdshader"
const BACKGROUND_SCRIPT_PATH := "res://SurfaceBackground.gd"
# Largest normalized distance any shading term below reacts to. Past this,
# every smoothstep has saturated and the pixel is plain regolith.
const MAX_SIGNIFICANT_DISTANCE := 1.15
const MEDIUMP_MAX := 65504.0
const SAMPLE_COUNT := 4000
var source: String = ""
var min_radius: float = 0.0
var max_radius: float = 0.0
var miss_distance_sq: float = 0.0
var start_distance_sq: float = 0.0
var tile_cells: float = 0.0
func _initialize() -> void:
var file: FileAccess = FileAccess.open(SHADER_PATH, FileAccess.READ)
if file == null:
_fail("could not read %s" % SHADER_PATH)
return
source = file.get_as_text()
file.close()
min_radius = _read_const("MIN_RADIUS")
max_radius = _read_const("MAX_RADIUS")
miss_distance_sq = _read_const("MISS_DISTANCE_SQ")
start_distance_sq = _read_const("START_DISTANCE_SQ")
if is_nan(min_radius) or is_nan(max_radius) or is_nan(miss_distance_sq) or is_nan(start_distance_sq):
_fail("could not parse the shader's radius/sentinel constants")
return
if not _check_premises():
return
if not _check_tiling_setup():
return
if not _check_worst_case_geometry():
return
if not _check_sentinels():
return
if not _check_scans_agree():
return
if not _check_field_is_periodic():
return
print("TEST_LUNAR_FIELD: PASS")
quit(0)
# --- A. the premises are still in the source ---------------------------------
func _check_premises() -> bool:
# The scan itself. Both loops must be the 3x3 form; a stray -2 anywhere in
# the bounds means someone widened it back without updating this test.
if not ("for (int dx = -1; dx <= 1; dx++)" in source and "for (int dy = -1; dy <= 1; dy++)" in source):
return _fail("the neighbour scan is no longer the 3x3 loop this test reasons about")
# The centre clamp. Without it a hash outside [0,1] places a crater centre
# outside its own cell and the ring-2 bound below stops holding.
if not ("clamp(h.xy, 0.0, 1.0)" in source):
return _fail("the crater centre is no longer clamped into its own cell")
# The radius clamp. Without it mix() extrapolates into an arbitrarily
# large radius, which is the specific failure that caused the original
# on-device crater clipping.
if not ("clamp(mix(MIN_RADIUS, MAX_RADIUS," in source and "), MIN_RADIUS, MAX_RADIUS)" in source):
return _fail("the crater radius is no longer clamped to [MIN_RADIUS, MAX_RADIUS]")
# MAX_SIGNIFICANT_DISTANCE below is the furthest normalized distance any
# shading term still reacts to, and the overlap suppression is the term
# that reaches furthest. Pin it here: raising that upper bound past
# MAX_SIGNIFICANT_DISTANCE would make ring-2 craters matter again, and
# the geometry check would go on passing against a stale number.
if not ("smoothstep(0.85, %.2f, second_nearest)" % MAX_SIGNIFICANT_DISTANCE in source):
return _fail("the overlap suppression no longer saturates at %.2f, so the significant range this test assumes is stale" \
% MAX_SIGNIFICANT_DISTANCE)
print("shader still clamps centre and radius, still scans 3x3, still saturates by %.2f" % MAX_SIGNIFICANT_DISTANCE)
return true
# --- E. the tiling rig is wired to one shared period -------------------------
# The tile period appears in three places - the field shader that bakes it,
# the tiled shader that scrolls it, and the GDScript that sizes the bake and
# wraps the camera. If they ever disagree the seam stops being seamless, and
# it fails as a faint repeating discontinuity rather than as an error.
func _check_tiling_setup() -> bool:
if not ("mod(cell + offset, tile_cells)" in source):
return _fail("the field shader no longer wraps the cell index, so the baked tile would not be seamless")
var script_file: FileAccess = FileAccess.open(BACKGROUND_SCRIPT_PATH, FileAccess.READ)
if script_file == null:
return _fail("could not read %s" % BACKGROUND_SCRIPT_PATH)
var script_source: String = script_file.get_as_text()
script_file.close()
var regex := RegEx.new()
regex.compile("const\\s+TILE_CELLS\\s*:\\s*float\\s*=\\s*([0-9.]+)")
var found: RegExMatch = regex.search(script_source)
if found == null:
return _fail("SurfaceBackground.gd no longer declares TILE_CELLS, the shared tile period")
tile_cells = float(found.get_string(1))
if tile_cells < 4.0:
return _fail("a tile period of %.0f cells is too short to read as anything but a repeat" % tile_cells)
# The camera index must be wrapped on the GDScript side, in double
# precision, before it reaches the shader - the whole point of the
# existing cell/fraction split is that the raw index is too large for a
# mobile float, and the tiled shader adds it straight into a UV.
if not ("fposmod(cell.x, TILE_CELLS)" in script_source):
return _fail("SurfaceBackground.gd no longer wraps the camera cell into the tile before handing it to the shader")
var tiled_file: FileAccess = FileAccess.open(TILED_SHADER_PATH, FileAccess.READ)
if tiled_file == null:
return _fail("could not read %s" % TILED_SHADER_PATH)
var tiled_source: String = tiled_file.get_as_text()
tiled_file.close()
# repeat_enable is the mechanism that makes the canvas infinite;
# mipmaps are what stop the crater field shimmering when zoomed out.
if not ("repeat_enable" in tiled_source):
return _fail("the tiled shader no longer samples with repeat_enable, so the canvas stops repeating")
if not ("filter_linear_mipmap" in tiled_source):
return _fail("the tiled shader no longer uses mipmaps, so zooming out will alias the crater field")
print("tile period %.0f cells, wrapped in GDScript, sampled with repeat + mipmaps" % tile_cells)
return true
# --- F. the field really is seamless -----------------------------------------
# Periodicity is asserted directly rather than inferred from the wrap being
# present: the same point one whole tile away must render identically. A seam
# is exactly the case where it does not.
func _check_field_is_periodic() -> bool:
var rng := RandomNumberGenerator.new()
rng.seed = 6800
var shifts: Array = [
Vector2(tile_cells, 0.0),
Vector2(0.0, tile_cells),
Vector2(tile_cells, tile_cells),
Vector2(-tile_cells * 3.0, tile_cells * 2.0),
]
for i in range(SAMPLE_COUNT):
var cell := Vector2(rng.randi_range(-4000, 4000), rng.randi_range(-4000, 4000))
var local := Vector2(rng.randf(), rng.randf())
var here := _shade(cell, local, 1)
for shift in shifts:
var there := _shade(cell + shift, local, 1)
for k in range(here.size()):
if absf(here[k] - there[k]) > 0.0001:
return _fail("the field is not periodic: cell %s and cell %s render differently (term %d)" \
% [cell, cell + shift, k])
print("%d sampled points: the field repeats exactly every %.0f cells in both axes" % [SAMPLE_COUNT, tile_cells])
return true
# --- B. worst case geometry, independent of the hash -------------------------
func _check_worst_case_geometry() -> bool:
if max_radius >= 1.0:
return _fail("MAX_RADIUS is %.3f - a crater at least a full cell wide can reach past a 3x3 scan" % max_radius)
if min_radius <= 0.0 or min_radius > max_radius:
return _fail("MIN_RADIUS %.3f is not a sane lower bound below MAX_RADIUS %.3f" % [min_radius, max_radius])
# Closest approach between the sampled pixel and ANY crater centre two
# cells out. The pixel's local coordinate spans [0,1]; a ring-2 cell spans
# [2,3] (or [-2,-1]), and the centre clamp keeps the centre inside it. So
# the gap is at least one whole cell however the hash lands.
var closest_ring_two_distance: float = 1.0
var closest_normalized: float = closest_ring_two_distance / max_radius
if closest_normalized <= MAX_SIGNIFICANT_DISTANCE:
return _fail("a crater two cells out reaches a normalized distance of %.2f, inside the significant range (%.2f) - a 3x3 scan would clip it" \
% [closest_normalized, MAX_SIGNIFICANT_DISTANCE])
print("worst case ring-2 crater lands at %.2f, clear of the %.2f threshold (%.1fx margin)" \
% [closest_normalized, MAX_SIGNIFICANT_DISTANCE, closest_normalized / MAX_SIGNIFICANT_DISTANCE])
return true
# --- D. the sentinels cannot overflow a mobile float -------------------------
func _check_sentinels() -> bool:
# The largest value the loop can actually carry: a real squared distance
# plus the miss sentinel added on top of it.
var largest_real_sq: float = pow(sqrt(8.0) / min_radius, 2.0)
var largest_carried: float = miss_distance_sq + largest_real_sq
if largest_carried >= MEDIUMP_MAX:
return _fail("the loop can carry %.0f, past the mediump ceiling %.0f - it becomes +Inf and then NaN on a mobile GPU" \
% [largest_carried, MEDIUMP_MAX])
if miss_distance_sq <= largest_real_sq:
return _fail("MISS_DISTANCE_SQ %.0f does not beat the largest real squared distance %.0f, so an empty cell could win the scan" \
% [miss_distance_sq, largest_real_sq])
if start_distance_sq <= largest_carried:
return _fail("START_DISTANCE_SQ %.0f is not above every value the loop can produce (%.0f), so the winning offset can stay unset" \
% [start_distance_sq, largest_carried])
print("sentinels ordered correctly and %.0f stays inside the mediump ceiling %.0f" % [largest_carried, MEDIUMP_MAX])
return true
# --- C. 3x3 and 5x5 render the same thing ------------------------------------
func _check_scans_agree() -> bool:
# The hash is ported from the shader below. It will not be bit-identical
# to any given GPU's evaluation, and does not need to be: what is being
# checked is that dropping the outer ring changes nothing, which follows
# from the clamped bounds rather than from the hash's exact values. The
# port's job is to catch a mistake in that reasoning on realistic inputs,
# not to emulate a driver.
var rng := RandomNumberGenerator.new()
rng.seed = 68
var worst_delta: float = 0.0
for i in range(SAMPLE_COUNT):
# Cell indices deliberately reach into the thousands - the same
# magnitudes the camera actually produces after flying a while.
var cell := Vector2(rng.randi_range(-4000, 4000), rng.randi_range(-4000, 4000))
var local := Vector2(rng.randf(), rng.randf())
var near := _shade(cell, local, 1)
var far := _shade(cell, local, 2)
for k in range(near.size()):
var delta: float = absf(near[k] - far[k])
worst_delta = maxf(worst_delta, delta)
if delta > 0.0001:
return _fail("3x3 and 5x5 scans disagree at cell %s local %s: term %d differs by %f" \
% [cell, local, k, delta])
print("%d sampled points: 3x3 and 5x5 scans agree exactly (worst term delta %.8f)" % [SAMPLE_COUNT, worst_delta])
return true
# The three values the shader's colour is actually a function of. Comparing
# these rather than raw `nearest` is deliberate: a 3x3 scan can legitimately
# report a different nearest distance than a 5x5 one (it may find no crater
# where 5x5 found a far one), and what matters is only that the PIXEL comes
# out the same colour.
func _shade(cell: Vector2, local: Vector2, ring: int) -> Array:
var nearest_sq: float = start_distance_sq
var second_sq: float = start_distance_sq
var nearest_offset := Vector2(0.0, 1.0)
for dx in range(-ring, ring + 1):
for dy in range(-ring, ring + 1):
var offset := Vector2(dx, dy)
var neighbor: Vector2 = cell + offset
# Same wrap the shader does, and the reason the field tiles.
neighbor = Vector2(fposmod(neighbor.x, tile_cells), fposmod(neighbor.y, tile_cells))
var h: Vector4 = _hash42(neighbor)
var has_crater: float = 1.0 if h.z >= 0.35 else 0.0
var centre: Vector2 = offset + Vector2(clampf(h.x, 0.0, 1.0), clampf(h.y, 0.0, 1.0))
var radius: float = clampf(lerpf(min_radius, max_radius, h.w), min_radius, max_radius)
var to_sample: Vector2 = local - centre
var normalized_sq: float = to_sample.length_squared() / (radius * radius) \
+ (1.0 - has_crater) * miss_distance_sq
if normalized_sq < nearest_sq:
second_sq = nearest_sq
nearest_sq = normalized_sq
nearest_offset = to_sample
elif normalized_sq < second_sq:
second_sq = normalized_sq
var nearest: float = sqrt(nearest_sq)
var second: float = sqrt(second_sq)
var offset_length: float = nearest_offset.length()
var nearest_dir: Vector2 = nearest_offset / offset_length if offset_length > 0.0001 else Vector2(0.0, 1.0)
var facing: float = nearest_dir.dot(Vector2(-0.6, -0.8).normalized())
var floor_amount: float = 1.0 - smoothstep(0.45, 0.9, nearest)
var rim_band: float = smoothstep(0.55, 0.85, nearest) * (1.0 - smoothstep(0.85, 1.0, nearest))
rim_band *= smoothstep(0.85, 1.15, second)
# facing only reaches the output multiplied by rim_band, so folding it in
# here keeps an irrelevant direction difference deep inside a crater floor
# from reading as a visual difference.
return [floor_amount, rim_band * clampf(facing, 0.0, 1.0), rim_band * clampf(-facing, 0.0, 1.0)]
func _hash42(p: Vector2) -> Vector4:
var p4 := _fract4(Vector4(p.x, p.y, p.x, p.y) * Vector4(0.1031, 0.1030, 0.0973, 0.1099))
var d: float = p4.dot(Vector4(p4.w, p4.z, p4.x, p4.y) + Vector4(33.33, 33.33, 33.33, 33.33))
p4 += Vector4(d, d, d, d)
# fract((p4.xxyz + p4.yzzw) * p4.zywx)
var a := Vector4(p4.x, p4.x, p4.y, p4.z)
var b := Vector4(p4.y, p4.z, p4.z, p4.w)
var c := Vector4(p4.z, p4.y, p4.w, p4.x)
return _fract4((a + b) * c)
func _fract4(v: Vector4) -> Vector4:
return Vector4(v.x - floor(v.x), v.y - floor(v.y), v.z - floor(v.z), v.w - floor(v.w))
func _read_const(name: String) -> float:
var regex := RegEx.new()
regex.compile("const\\s+float\\s+" + name + "\\s*=\\s*([0-9.eE+-]+)")
var found: RegExMatch = regex.search(source)
if found == null:
return NAN
return float(found.get_string(1))
func _fail(message: String) -> bool:
print("TEST_LUNAR_FIELD: FAIL (%s)" % message)
quit(1)
return false