## 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