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