OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scripts / gate_terrain_graph.gd

139 lines · gd ref:
## Gate: every res://graphs/*.tres must load with all nodes present, and mesh.
##
## graphs/terrain.tres was committed on 2026-03-27 with node 3 stored as `null`
## while `connections` still referenced it, so the graph evaluated to no mesh
## and had done ever since. Nothing noticed, because nothing loaded it outside
## the editor and a null node is not an error -- ProcNodeGraph::_set("nodes")
## pushes whatever the array holds.
##
## Walks the DIRECTORY rather than naming one file: a gate that knows a single
## filename does not cover the second graph anyone saves.
##
## Reports rather than merely asserting. A graph that will not load has several
## possible causes -- an unregistered class, a stale .so, a missing gdextension
## entry, a lost sub-resource -- and the lines below say which.
##
## Runs in CI between import-godot and export-godot; see .pear-git/ci.yaml.
##
## Usage: godot --headless --path <repo> --script res://scripts/gate_terrain_graph.gd
##
## ⚠ RUNNING GODOT DIRECTLY REWRITES project.godot. The editor strips every
## comment and drops any setting that equals its default -- it ate the 30-line
## Jolt block and `screen_space_roughness_limiter/enabled` during this script's
## own development. scripts/dev-editor.sh restores it; a bare `godot --headless
## --path .` does not. Check `git status project.godot` after running this
## locally. CI is unaffected: it runs in a throwaway git-archive extraction.
extends SceneTree

const DIR := "res://graphs"


func _init() -> void:
	var fail := 0

	# Checked first and separately: if proc-nodes is not registered then EVERY
	# graph below fails, and the fix is the extension, not the graphs.
	var ok_ext := ClassDB.can_instantiate("ErosionNoiseNode")
	print("proc-nodes registered: ", ok_ext)
	if not ok_ext:
		print("FAIL: proc-nodes is not loaded — fix bin/linux/proc_nodes.gdextension,")
		print("      not the .tres files. Every graph would fail from here.")
		quit(1)
		return

	var files := DirAccess.get_files_at(DIR)
	if files.is_empty():
		print("FAIL: no files under ", DIR)
		quit(1)
		return

	var checked := 0
	for f in files:
		# Godot exports .tres as .tres; .remap only appears in an exported pck.
		if not f.ends_with(".tres"):
			continue
		checked += 1
		fail += _check("%s/%s" % [DIR, f])

	if checked == 0:
		print("FAIL: no .tres graphs found under ", DIR)
		fail += 1

	print("\nRESULT: ", "PASS (%d graph(s))" % checked if fail == 0
			else "FAIL (%d problem(s) across %d graph(s))" % [fail, checked])
	quit(0 if fail == 0 else 1)


func _check(path: String) -> int:
	var fail := 0
	print("\n", path)

	var g: ProcNodeGraph = ResourceLoader.load(path)
	if g == null:
		print("  FAIL: did not load as a ProcNodeGraph")
		return 1

	var n := g.get_node_count()
	for i in range(n):
		var node: ProcNode = g.get_node(i)
		if node == null:
			print("  node %d: NULL — a sub-resource was lost on save" % i)
			fail += 1
		else:
			print("  node %d: %s" % [i, node.get_node_type()])
	print("  connections: ", g.get_connection_count())

	if fail > 0:
		# Baking a graph with a null node reports the same "no mesh" whatever
		# the cause, so stop here and let the node list be the diagnosis.
		return fail

	# TWO KINDS OF GRAPH. A DENSITY graph (a Group Output with a Float on its
	# interface — the undersea graph, §4) produces a GLSL density for dc-terrain, not
	# a mesh, so "bakes to a mesh" is the wrong test and failed it by construction.
	# Its equivalent: the declared density compiles. An interface lost on save would
	# fall back to the pos.y displacement and still compile, so the result must
	# contain the function AND the graph must still declare the Float.
	if _declares_density(g):
		var glsl: String = g.compile_to_glsl_function("proc_density", false, PackedStringArray())
		if not glsl.contains("float proc_density"):
			print("  FAIL: density graph did not compile to proc_density")
			return 1
		print("  density: compiles to proc_density (%d bytes)" % glsl.length())
		return 0

	var host := ProcGeometryNode.new()
	host.use_gpu = false      # no RenderingDevice under --headless
	host.set_graph(g)
	get_root().add_child(host)
	var mesh: ArrayMesh = host.bake_mesh()
	if mesh == null or mesh.get_surface_count() == 0:
		print("  FAIL: evaluated to no mesh")
		return 1
	var verts: int = mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX].size()
	print("  mesh: %d surface(s), %d vertices" % [mesh.get_surface_count(), verts])
	if verts == 0:
		print("  FAIL: mesh has no vertices")
		return 1
	return 0


## True if a Group Output carries a connected Float on its interface — the graph
## declares a density (proc-nodes ProcNodeGraph::find_density_output's rule).
func _declares_density(g: ProcNodeGraph) -> bool:
	for i in g.get_node_count():
		var node: ProcNode = g.get_node(i)
		if node == null or node.get_node_type() != "GroupOutput":
			continue
		var spec: Array = node.get("interface")
		# `connections` is the serialized flat [from, from_sock, to, to_sock, ...]
		# (get_input_source_node is not bound to GDScript).
		var conns: PackedInt32Array = g.get("connections")
		for s in spec.size():
			if int((spec[s] as Dictionary).get("type", -1)) != 0:
				continue
			for c in range(0, conns.size(), 4):
				if conns[c + 2] == i and conns[c + 3] == s:
					return true
	return false