OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes / demo / glsl_golden_gate.gd

549 lines · gd ref:
## The emitted density GLSL, character for character, plus what emission must
## REFUSE to do.
##
## compile_to_glsl_function is the terrain's runtime path, so its output is not
## an implementation detail: it is the shipped field. This gate exists so the
## value-plumbing work can be proved to leave that text ALONE (docs/glsl-emitter-
## design.md makes byte-identical output a hard constraint, not an aspiration).
##
## Case B is the one that must CHANGE. Today a node with no GLSL is skipped
## silently: adding a Math node to the erosion graph moves the emitted text by
## one comment line and the node's arithmetic is simply gone, so the GPU
## evaluates a different field from the CPU. After the change, emission fails
## loudly and dc-terrain keeps the shader it had.
##
## Usage: godot --headless --path demo --script res://glsl_golden_gate.gd [-- --write]
extends SceneTree

const GOLDEN_DIR := "res://golden"

var _fail := 0
var _ran := 0
var _write := false


func _erosion_graph() -> ProcNodeGraph:
	# map_dc_erosion.gd:245-278, parameters included -- socket defaults are baked
	# into the shader as literals, so they are part of the golden text.
	var g := ProcNodeGraph.new()
	var noise := g.add_node(GradientNoiseNode.new())
	g.get_node(noise).set_input_default(1, 3.0)
	g.get_node(noise).set_input_default(2, 3)
	g.get_node(noise).set_input_default(3, 0.1)
	var erosion := g.add_node(ErosionNoiseNode.new())
	g.get_node(erosion).set_input_default(1, 5.0)
	g.get_node(erosion).set_input_default(2, 0.15)
	g.get_node(erosion).set_input_default(3, 0.6)
	g.get_node(erosion).set_input_default(4, 0.7)
	g.get_node(erosion).set_input_default(5, 7)
	g.get_node(erosion).set_input_default(6, 0.5)
	g.connect_sockets(noise, 0, erosion, 7)
	g.connect_sockets(noise, 1, erosion, 8)
	return g


func _check_golden(name: String, text: String) -> void:
	_ran += 1
	var path := "%s/%s.glsl" % [GOLDEN_DIR, name]
	if _write:
		DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(GOLDEN_DIR))
		FileAccess.open(path, FileAccess.WRITE).store_string(text)
		print("  %-28s WROTE %d chars" % [name, text.length()])
		return
	if not FileAccess.file_exists(path):
		_fail += 1
		print("  %-28s FAIL  no golden at %s (run with -- --write)" % [name, path])
		return
	var want := FileAccess.get_file_as_string(path)
	if want == text:
		print("  %-28s PASS  %d chars unchanged" % [name, text.length()])
		return
	_fail += 1
	# Name the FIRST difference: a whole-shader diff is unreadable and the first
	# divergence is what actually identifies the change.
	var n: int = min(want.length(), text.length())
	var i := 0
	while i < n and want[i] == text[i]:
		i += 1
	var line := want.substr(0, i).count("\n") + 1
	print("  %-28s FAIL  differs at line %d (golden %d chars, got %d)"
			% [name, line, want.length(), text.length()])
	print("        golden: %s" % want.substr(max(0, i - 20), 70).replace("\n", "\\n"))
	print("        got   : %s" % text.substr(max(0, i - 20), 70).replace("\n", "\\n"))


func _check(name: String, ok: bool, detail: String) -> void:
	_ran += 1
	if ok:
		print("  %-28s PASS  %s" % [name, detail])
	else:
		_fail += 1
		print("  %-28s FAIL  %s" % [name, detail])


func _emits_statement(name: String, node: ProcNode) -> void:
	# These five used to emit expression FRAGMENTS -- "vec3(x, y, z)" -- with no
	# assignment and no semicolon, so a shader using one could not compile. They
	# emit statements through GlslEmitContext now: a declaration, an assignment,
	# and a terminator. The shader is compiled for real by the glslc step in
	# tests/run_glsl_gate.sh; this asserts the shape at the source.
	var g := _erosion_graph()
	var idx := g.add_node(node)
	var t: String = g.compile_to_glsl_function("proc_density", true, PackedStringArray())
	if t.is_empty():
		_check("%s emits" % name, false, "emission refused")
		return
	var body := t.substr(max(0, t.find("float proc_density")))
	var want := "n%d_s0" % idx
	var ok: bool = body.find(want + " =") != -1 and body.find(want + " =;") == -1
	_check("%s emits" % name, ok, "declares and assigns %s" % want if ok
			else "no assignment to %s in the body" % want)


func dump_value_graph(path: String) -> void:
	# The graph the glslc step compiles: every migrated node, wired to each other
	# so the resolver has to carry values rather than bake defaults.
	var g := ProcNodeGraph.new()
	var cx := g.add_node(CombineXYZNode.new())
	var sep := g.add_node(SeparateXYZNode.new())
	var clamp_n := g.add_node(ClampNode.new())
	var cmp := g.add_node(CompareNode.new())
	var bm := g.add_node(BooleanMathNode.new())
	g.connect_sockets(cx, 0, sep, 0)
	g.connect_sockets(sep, 0, clamp_n, 0)
	g.connect_sockets(clamp_n, 0, cmp, 0)
	g.connect_sockets(cmp, 0, bm, 0)
	FileAccess.open(path, FileAccess.WRITE).store_string(
			g.compile_to_glsl_function("proc_density", false, PackedStringArray()))


## H. NO EMITTED DENSITY SHADER MAY REFERENCE `pc.`.
##
## compile_to_function produces a standalone `float proc_density(vec3 pos)`
## with no push-constant block. compile_batch produces a compute shader that
## HAS one. `pc.paramN` in a node's GLSL means a push-constant SLOT to the
## second and, through a textual substitution in the first, SOCKET N -- two
## conventions, and every node that used both was wrong in the density path.
## Measured 2026-09-06: Displace shifted every argument one socket and
## multiplied its displacement by 0.0, Set Position emitted `pos += vec3(0,0,0)`,
## and Simplex Noise left `pc.param3` in a function where `pc` does not exist,
## so the shader could not compile at all.
##
## The sweep is over EVERY instantiable node that claims GPU support, not the
## three that were broken, because the next node to be written is the one this
## has to catch.
##
## ⚠ IT CATCHES THE LEAK, NOT THE SHIFT, and the mutation sweep is what said so.
## Reverting Displace to `pc.param1`/`pc.param2` leaves this case GREEN: both
## indices are inside the node's socket count, so compile_to_function's textual
## substitution consumes them and nothing survives to be found -- the emitted
## text is merely reading the wrong sockets. Only Simplex Noise's `pc.param3`
## was visible here, because it addressed a fourth socket the node has not got.
## Case J is what catches the shift. This one is the guard against the next
## node that writes a slot number it has no socket for.
func _no_push_constants_anywhere() -> void:
	var checked := 0
	var offenders: Array[String] = []
	for cls in ClassDB.get_inheriters_from_class("ProcNode"):
		if not ClassDB.can_instantiate(cls):
			continue
		var n: ProcNode = ClassDB.instantiate(cls)
		if n == null or not n.supports_gpu():
			continue
		var g := ProcNodeGraph.new()
		g.add_node(n)
		var t: String = g.compile_to_glsl_function("proc_density", false, PackedStringArray())
		if t.is_empty():
			continue  # refusal is case B's subject, not this one
		checked += 1
		if t.find("pc.") != -1:
			offenders.append("%s (%s)" % [cls, n.get_node_type()])
	_check("no emitted `pc.` anywhere", offenders.is_empty(),
			"%d GPU-capable nodes emit, none references pc." % checked if offenders.is_empty()
			else "%d of %d leave a push-constant reference: %s"
					% [offenders.size(), checked, ", ".join(offenders)])


## I. THE SLOT A NODE READS IS THE SLOT THE EXECUTOR FILLS.
##
## GlslEmitContext::param() and GraphExecutor both walk
## get_push_constant_sockets(), so they cannot disagree -- but the MAP itself
## can still be wrong, and it was: Set Position's packer read socket 1 and
## called it the offset vector, while socket 1 has been SELECTION since its
## sockets were matched to Blender's. So assert the map names sockets that
## exist, are not Geometry, and fit the seven-float block.
func _push_constant_map_is_sane(cls: String) -> void:
	var n: ProcNode = ClassDB.instantiate(cls)
	var map: PackedInt32Array = n.get_push_constant_sockets()
	var slots := 0
	var bad := ""
	for sock in map:
		if sock < 0 or sock >= n.get_input_count():
			bad = "socket %d does not exist" % sock
			break
		if n.get_input_type(sock) == 6:  # Geometry
			bad = "socket %d is Geometry and cannot cross" % sock
			break
		slots += 3 if n.get_input_type(sock) == 3 else 1
	if bad.is_empty() and slots > 7:
		bad = "%d slots, block holds 7" % slots
	_check("%s push-constant map" % n.get_node_type(), bad.is_empty(),
			"%d socket(s), %d slot(s)" % [map.size(), slots] if bad.is_empty() else bad)


## J. THE DENSITY PATH READS THE RIGHT SOCKET, socket by socket.
##
## A wrong index emits text that still compiles and still looks like noise, so
## the only way to see it is to give each socket a value nothing else has and
## look for it where it belongs. These are the values that were shifted.
func _reads_the_right_sockets() -> void:
	var g := ProcNodeGraph.new()
	var d := g.add_node(DisplaceNode.new())
	g.get_node(d).set_input_default(1, 11.0)   # Strength
	g.get_node(d).set_input_default(2, 22.0)   # Scale
	g.get_node(d).set_input_default(3, 33)     # Detail   (Int)
	g.get_node(d).set_input_default(4, 44.0)   # Roughness
	g.get_node(d).set_input_default(5, 55.0)   # Lacunarity
	var t: String = g.compile_to_glsl_function("proc_density", false, PackedStringArray())
	var body := t.substr(max(0, t.find("float proc_density")))
	_check("Displace socket order",
			body.find("pos.y) * 22.0") != -1
			and body.find("float(int(33))") != -1
			and body.find("33)), 44.0, 55.0,") != -1
			and body.find("* n%d_nval * 11.0;" % d) != -1,
			"scale 22, detail 33, roughness 44, lacunarity 55, strength 11")

	var g2 := ProcNodeGraph.new()
	var sp := g2.add_node(SetPositionNode.new())
	g2.get_node(sp).set_input_default(3, Vector3(1.5, 2.5, 3.5))   # Offset
	var t2: String = g2.compile_to_glsl_function("proc_density", false, PackedStringArray())
	_check("SetPosition reads Offset (3)",
			t2.find("pos += vec3(1.5") != -1,
			"the Offset default reaches the emitted text")

	# Selection is socket 1. It used to be what the executor packed as the
	# offset, so a gate that only checked Offset would still pass with the old
	# index if Offset happened to be zero. Set BOTH, and only Offset may appear.
	var g3 := ProcNodeGraph.new()
	var sp3 := g3.add_node(SetPositionNode.new())
	g3.get_node(sp3).set_input_default(2, Vector3(9.75, 9.75, 9.75))  # Position
	g3.get_node(sp3).set_input_default(3, Vector3(4.25, 5.25, 6.25))  # Offset
	var t3: String = g3.compile_to_glsl_function("proc_density", false, PackedStringArray())
	_check("SetPosition ignores Position (2)",
			t3.find("4.25") != -1 and t3.find("9.75") == -1,
			"Offset crosses, the unconnected Position does not")

	var g4 := ProcNodeGraph.new()
	var sx := g4.add_node(NoiseTextureNode.new())
	g4.get_node(sx).set_input_default(0, Vector3(0.5, 0.25, 0.125))  # Vector
	g4.get_node(sx).set_input_default(1, 66.0)                        # Scale
	g4.get_node(sx).set_input_default(2, 7.0)                         # Detail
	var t4: String = g4.compile_to_glsl_function("proc_density", false, PackedStringArray())
	_check("SimplexNoise socket order",
			t4.find("vec3(0.5, 0.25, 0.125) * 66.0") != -1
			and t4.find("int(7.0)") != -1,
			"Vector 0, Scale 1, Detail 2 -- and it reads socket 0, not `pos`")


## K. THE BATCH PATH STILL SPELLS SLOTS, and the right ones.
##
## The density and batch paths must emit DIFFERENT text from the same node:
## a literal or a producer variable in one, `pc.paramN` in the other, because
## compile_batch's pipeline is cached in the ExecutionPlan and
## set_input_default() emits no `changed` -- a baked literal there would freeze
## at whatever the value was when the plan was built.
##
## So this asserts the opposite of case H on the same nodes: the batch text MUST
## carry push constants, in the slots get_push_constant_sockets() declares.
## Displace's five sockets are param0..param4 in socket order; Set Position's
## single Vector socket is param0..param2. Both are the text these nodes
## emitted before the map existed, which is what makes the change a relocation
## on this side and a fix on the other.
func _batch_still_uses_push_constants() -> void:
	var g := ProcNodeGraph.new()
	var d := g.add_node(DisplaceNode.new())
	g.get_node(d).set_input_default(1, 11.0)
	g.get_node(d).set_input_default(2, 22.0)
	var bt: String = g.compile_batch_glsl(PackedInt32Array([d]))
	# Slot order is SOCKET order: strength(1)->param0, scale(2)->param1,
	# detail(3)->param2, roughness(4)->param3, lacunarity(5)->param4.
	_check("Displace batch slots",
			bt.find("pos.y) * pc.param1") != -1
			and bt.find("float(int(pc.param2))") != -1
			and bt.find("pc.param3, pc.param4,") != -1
			and bt.find("* n%d_nval * pc.param0;" % d) != -1
			and bt.find("11.0") == -1 and bt.find("22.0") == -1,
			"five slots in socket order, and no default baked in")

	var g2 := ProcNodeGraph.new()
	var sp := g2.add_node(SetPositionNode.new())
	g2.get_node(sp).set_input_default(3, Vector3(4.25, 5.25, 6.25))
	var bt2: String = g2.compile_batch_glsl(PackedInt32Array([sp]))
	# Two Displace nodes in one graph must not redefine each other's locals.
	var g2b := ProcNodeGraph.new()
	var d1 := g2b.add_node(DisplaceNode.new())
	var d2 := g2b.add_node(DisplaceNode.new())
	var bt1b: String = g2b.compile_batch_glsl(PackedInt32Array([d1, d2]))
	_check("Displace locals are per-node",
			bt1b.find("n%d_npos" % d1) != -1 and bt1b.find("n%d_npos" % d2) != -1,
			"two Displace nodes declare distinct locals")

	_check("SetPosition batch slots",
			bt2.find("pos += vec3(pc.param0, pc.param1, pc.param2);") != -1
			and bt2.find("4.25") == -1,
			"Offset occupies param0..2, and its default is not baked in")


## L. A VOLUME GRAPH CAN SAY WHAT ITS DENSITY IS, and the mechanism is a Group
## Output FLOAT -- not VolumeToMesh.
##
## hyper-games #65 NEXT 5 asked whether volume_nodes.h's last
## supports_gpu() == false has a reason or is the next debt. It has one, but the
## comment justifying it had gone stale: it said the density function "has no way
## to return a node's value", and _density_output_socket has been able to since
## the Group Output interface landed. So the honest gate is not "VolumeToMesh is
## off" -- it is that the thing it would have been FOR works without it.
##
## Case F is the negative half (no explicit output -> the displacement, with a
## warning). This is the positive half.
func _volume_density_via_group_output() -> void:
	var g := ProcNodeGraph.new()
	var s1 := g.add_node(SphereSdfNode.new())
	var s2 := g.add_node(SphereSdfNode.new())
	var bo := g.add_node(VolumeBooleanNode.new())
	g.connect_sockets(s1, 0, bo, 0)
	g.connect_sockets(s2, 0, bo, 1)
	var go := GroupOutputNode.new()
	go.set_interface([{"name": "Density", "type": 0}])   # 0 = Float
	var goi := g.add_node(go)
	g.connect_sockets(bo, 0, goi, 0)
	var t: String = g.compile_to_glsl_function("proc_density", false, PackedStringArray())
	var want := "return n%d_s0;" % bo
	_check("volume density via Group Output", t.find(want) != -1,
			"returns the chain (%s), not the displacement" % want)
	# And the comment above it must follow the branch. It read
	# "SDF: terrain_height - query_y" over a `return n2_s0;`, which describes
	# the wrong thing on the one line that says what the shader computes.
	_check("return comment follows the branch",
			t.find("The graph's OWN density") != -1
			and t.find("SDF: terrain_height - query_y") == -1,
			"names the Group Output float, not the displacement")

	# VolumeToMesh stays GPU-off, and a graph containing it still REFUSES --
	# case B depends on that, so assert the property rather than leave it
	# implied by one other case.
	# M. AN AUTHORED VOLUME COMPILES. This is the thing VolumeToMesh is FOR and
	#    it has never worked: the node emits nothing, and `!supports_gpu()` is a
	#    REFUSAL, so a graph ending in it could not be compiled to GLSL at all.
	#    It is structurally transparent now -- it names the density rather than
	#    computing one -- so the chain emits and the terminal carries it.
	var vg := ProcNodeGraph.new()
	var vs1 := vg.add_node(SphereSdfNode.new())
	var vs2 := vg.add_node(SphereSdfNode.new())
	var vbo := vg.add_node(VolumeBooleanNode.new())
	vg.connect_sockets(vs1, 0, vbo, 0)
	vg.connect_sockets(vs2, 0, vbo, 1)
	var vtm_idx := vg.add_node(VolumeToMeshNode.new())
	vg.connect_sockets(vbo, 0, vtm_idx, 0)
	var vt: String = vg.compile_to_glsl_function("proc_density", false, PackedStringArray())
	_check("volume graph emits at all", not vt.is_empty(),
			"%d chars" % vt.length() if not vt.is_empty() else "REFUSED -- the gap this closes")
	_check("VolumeToMesh names the density",
			vt.find("return n%d_s0;" % vbo) != -1,
			"returns the SDF chain, with no Group Output in the graph")
	# N. POSITION EMITS. It claimed GPU support with no compile_glsl, so any
	#    graph containing it REFUSED -- and Position is what makes a noise field
	#    vary with place, so an authored volume could not have texture at all.
	var pg := ProcNodeGraph.new()
	var ppos := pg.add_node(PositionNode.new())
	var pnz := pg.add_node(NoiseTextureNode.new())
	pg.connect_sockets(ppos, 0, pnz, 0)
	var pout := GroupOutputNode.new()
	pout.set_interface([{"name": "Density", "type": 0}])
	var pouti := pg.add_node(pout)
	pg.connect_sockets(pnz, 0, pouti, 0)
	var pt: String = pg.compile_to_glsl_function("proc_density", false, PackedStringArray())
	_check("Position emits, graph compiles", not pt.is_empty(),
			"%d chars" % pt.length() if not pt.is_empty() else "REFUSED -- the gap this closes")
	_check("Position emits `pos`", pt.find("n%d_s0 = pos;" % ppos) != -1,
			"the density function's own argument, not a socket default")

	var vtm := VolumeToMeshNode.new()
	_check("VolumeToMesh emits nothing itself",
			not vtm.supports_gpu() and vtm.gpu_transparent(),
			"transparent, not refused: it names a density, it does not compute one")


func _init() -> void:
	for a in OS.get_cmdline_user_args():
		if a == "--write":
			_write = true

	var empty := PackedStringArray()

	# A. The shipped map's field, character for character.
	_check_golden("map_dc_erosion_density", _erosion_graph().compile_to_glsl_function(
			"proc_density", true, empty))

	# C. The skip list is DELIBERATE skipping and must keep working -- dc-terrain
	#    passes ErosionNoise when the erosion toggle is off.
	var skip := PackedStringArray(["ErosionNoise"])
	var skipped: String = _erosion_graph().compile_to_glsl_function("proc_density", true, skip)
	# THE BODY, not the whole text: the noise library DEFINES
	# erosion_node_eval_full, so searching the shader for that name finds it
	# whether or not any node calls it. This check asserted nothing until it was
	# scoped to the function.
	var body := skipped.substr(max(0, skipped.find("float proc_density")))
	_check("skip list still skips", body.find("erosion_node_eval_full") == -1
			and body.find("fractal_noise_2d") != -1,
			"erosion call gone, gradient noise call kept")

	# B. A node with no GLSL must make emission FAIL, not vanish from the shader.
	# JOIN GEOMETRY, and the choice matters. This case used Math, then Map
	# Range, then VolumeToMesh, and each time that node got an implementation
	# the case stopped testing refusal and started testing emission -- it has
	# now gone obsolete THREE times rather than wrong. VolumeToMesh was retired
	# from this role deliberately: it became a legitimate terminal that names a
	# volume's density, which is exactly the "someone did the next item on the
	# list" this case keeps losing its subject to.
	#
	# Join Geometry combines GEOMETRIES. A density function is a float of a
	# position -- there is no geometry in it to join -- so this one cannot
	# acquire a GLSL implementation by anybody finishing anything.
	var g2 := _erosion_graph()
	g2.add_node(JoinGeometryNode.new())
	var with_math: String = g2.compile_to_glsl_function("proc_density", true, empty)
	_check("a node with no GLSL refuses", with_math.is_empty(),
			"emitted %d chars (a silent drop is the bug)" % with_math.length()
					if not with_math.is_empty() else "emission refused")

	# D. The five nodes that emitted expression FRAGMENTS -- "vec3(x, y, z)",
	#    with no assignment and no semicolon -- claimed GPU support, so a graph
	#    using one produced a shader that could not compile at all. They are off
	#    until they emit statements, and a graph using one must now refuse.
	# Plain statements, no class-reference metaprogramming: iterating native class
	# references and calling get_class() on them HANGS headless rather than
	# erroring -- the same shape as the inline-lambda trap.
	_emits_statement("CombineXYZ", CombineXYZNode.new())
	_emits_statement("SeparateXYZ", SeparateXYZNode.new())
	_emits_statement("Clamp", ClampNode.new())
	_emits_statement("BooleanMath", BooleanMathNode.new())
	_emits_statement("Compare", CompareNode.new())

	# E. The wired graph, for the glslc step to compile.
	dump_value_graph("res://golden/_value_graph_emitted.glsl")

	# F. WHICH VALUE THE FUNCTION RETURNS.
	#    A volume graph computes a density and the emitted body threw it away --
	#    `return pos.y - query_y` -- so the graph compiled and rendered nothing.
	#    A graph with no geometry chain and no explicit output cannot say what
	#    its density is, and must refuse rather than return a constant 0.
	var vg := ProcNodeGraph.new()
	var s1 := vg.add_node(SphereSdfNode.new())
	var s2 := vg.add_node(SphereSdfNode.new())
	var bo := vg.add_node(VolumeBooleanNode.new())
	vg.connect_sockets(s1, 0, bo, 0)
	vg.connect_sockets(s2, 0, bo, 1)
	var vt: String = vg.compile_to_glsl_function("proc_density", false, empty)
	# It still emits, and still returns the displacement: without an explicit
	# output a discarded density and a deliberately empty graph are the same
	# thing to the emitter -- dc-terrain's erosion-toggle-off path IS the second
	# one. The value is computed and the warning says so.
	_check("value-only graph emits and warns", not vt.is_empty()
			and vt.find("n2_s0 = max(") != -1
			and vt.find("return pos.y - query_y;") != -1,
			"density computed, displacement returned")

	#    And the geometry chain still takes the displacement path, unchanged.
	var eg := _erosion_graph()
	var et: String = eg.compile_to_glsl_function("proc_density", true, empty)
	_check("geometry chain still displaces", et.find("return pos.y - query_y;") != -1,
			"returns the displacement")

	# G. EVERY Mix CONFIGURATION EMITS NOW, and each emits its OWN blend.
	#    This case used to assert that colour REFUSED, which was the honest
	#    state while ramp_blend had no GLSL: emitting a plain lerp there would
	#    have turned all nineteen colour modes into MIX silently. Both halves
	#    landed 2026-09-05, so what is worth gating is no longer "does it
	#    refuse" but "does it emit the RIGHT thing" -- a regression to a lerp
	#    would still compile and still look like a blend.
	var mg := _erosion_graph()
	var mixn := MixNode.new()
	mixn.set("data_type", 2)   # RGBA
	mixn.set("blend_type", 12) # HUE, which is nothing like a lerp
	mg.add_node(mixn)
	var mt: String = mg.compile_to_glsl_function("proc_density", true, empty)
	_check("colour Mix emits ramp_blend", not mt.is_empty()
			and mt.find("br_ramp_blend(12,") != -1,
			"emitted %d chars" % mt.length() if not mt.is_empty() else "REFUSED")

	#    And it must address the COLOUR sockets, 6 and 7. The Voronoi work
	#    turned up exactly this class of bug -- a value taken from the wrong
	#    place looks like a wrong blend, not a wrong address -- so wire two
	#    distinguishable colours in and check they arrive as the operands, in
	#    that order.
	var mgc := _erosion_graph()
	var mixc := MixNode.new()
	mixc.set("data_type", 2)
	mixc.set("blend_type", 0)
	var mixc_idx := mgc.add_node(mixc)
	var ca := ColorNode.new()
	ca.set("value", Color(0.125, 0.25, 0.375, 1.0))
	var ca_idx := mgc.add_node(ca)
	var cb := ColorNode.new()
	cb.set("value", Color(0.875, 0.75, 0.625, 1.0))
	var cb_idx := mgc.add_node(cb)
	mgc.connect_sockets(ca_idx, 0, mixc_idx, 6)
	mgc.connect_sockets(cb_idx, 0, mixc_idx, 7)
	var mct: String = mgc.compile_to_glsl_function("proc_density", true, empty)
	var a_var := "n%d_s0" % ca_idx
	var b_var := "n%d_s0" % cb_idx
	_check("colour Mix reads sockets 6 and 7",
			mct.find("br_ramp_blend(0, " + a_var) != -1 and mct.find(", " + b_var + ")") != -1,
			"A and B arrive as the operands, in order")

	var mgr := _erosion_graph()
	var mixr := MixNode.new()
	mixr.set("data_type", 14)  # rotation
	mgr.add_node(mixr)
	var mrt: String = mgr.compile_to_glsl_function("proc_density", true, empty)
	_check("rotation Mix emits slerp", not mrt.is_empty()
			and mrt.find("pn_quat_slerp(") != -1,
			"emitted %d chars" % mrt.length() if not mrt.is_empty() else "REFUSED")

	var mg2 := _erosion_graph()
	var mixn2 := MixNode.new()
	mixn2.set("data_type", 1)  # vector -- this one is implemented
	mg2.add_node(mixn2)
	var mt2: String = mg2.compile_to_glsl_function("proc_density", true, empty)
	_check("vector Mix emits", not mt2.is_empty(), "emission accepted")

	# H, I, J -- the push-constant audit. See the functions above.
	_no_push_constants_anywhere()
	_push_constant_map_is_sane("DisplaceNode")
	_push_constant_map_is_sane("SetPositionNode")
	_push_constant_map_is_sane("NoiseTextureNode")
	_reads_the_right_sockets()
	_batch_still_uses_push_constants()
	_volume_density_via_group_output()

	# ⛔ HOW MANY CHECKS SHOULD HAVE RUN.
	#
	# Without this the gate reports "N/N passed" where N is however many checks
	# happened to execute -- so a check that THROWS is silently dropped rather
	# than failed. Measured 2026-09-06: asserting on gpu_transparent(), which
	# was not bound to script, errored, vanished from the run, and the gate
	# printed "28/28 passed" and exited 0. The count moved and nothing went red.
	#
	# Bumping this by hand when a case is added is the point: an intentional
	# change is a one-line edit, an accidental drop is a failure.
	const EXPECTED_CHECKS := 32
	if _ran != EXPECTED_CHECKS:
		_fail += 1
		print("  %-34s FAIL  %d checks ran, expected %d -- a check was DROPPED, "
				% ["all checks ran", _ran, EXPECTED_CHECKS]
				+ "probably by throwing. Look for SCRIPT ERROR above.")

	print("\n%d/%d passed" % [_ran - _fail, _ran])
	quit(1 if _fail > 0 else 0)