OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / dc_level_gate.gd

234 lines · gd ref:
extends SceneTree
## DCLevel acceptance harness — run offline before deploying a level-mesh change:
##
##     "$HYPER_TPS_GODOT" --headless --script scenes/test/dc_level_gate.gd
##
## (A segfault at exit is the known godot 4.6 GDExtension atexit crash — harmless
## after RESULT prints. .pear-git/ci.yaml documents the same thing.)
##
## Three groups of checks, and the second two exist because of what the first
## missed:
##
## 1. GATE — level-mesh-implementation.md §6.2/§10 acceptance numbers.
## 2. WINDING — every gate metric is INVARIANT under a global triangle flip, so
##    0.768.0 shipped an inside-out level with all five passing. Measured against
##    a mesh Godot generates itself rather than a remembered convention.
## 3. PICKING — needs a REAL raycast: "hit has no owner" and "hit resolves to the
##    wrong brush" are indistinguishable without firing one.
##
## Two ordering traps, both of which read as a broken feature rather than a
## too-early query, and both of which cost time here:
##   - DCLevel::_upload_collision early-returns when get_world_3d() is null, so a
##     node built during _init() silently reports 0 colliders.
##   - Shapes added this frame are not in the physics broadphase until the space
##     is stepped, so raycasting immediately after rebuild() returns 0 hits.
## Hence the frame-staged _process below.

const MAP_PATH := "/storage/projects/gamedev/quake/quakemash/START.MAP"

var _lvl: Node3D = null
var _sky: Node3D = null
var _frame := 0

func _initialize() -> void:
	_lvl = ClassDB.instantiate("DCLevel") as Node3D
	root.add_child(_lvl)

func _agree_fraction(arrays: Array) -> float:
	var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
	var norms: PackedVector3Array = arrays[Mesh.ARRAY_NORMAL]
	var idx: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
	var agree := 0
	var total := 0
	for t in range(0, idx.size(), 3):
		var geo := (verts[idx[t+1]] - verts[idx[t]]).cross(verts[idx[t+2]] - verts[idx[t]])
		if geo.length() < 1e-12:
			continue
		if geo.normalized().dot(norms[idx[t]]) > 0.0:
			agree += 1
		total += 1
	return float(agree) / float(maxi(total, 1))

var _built := false

func _process(_delta: float) -> bool:
	_frame += 1
	if _frame < 3:
		return false

	# Build once, then let SEVERAL physics steps run before raycasting: shapes
	# added this frame are not in the broadphase until the space is stepped, and
	# querying too early reads as "no hits" — indistinguishable from broken picking.
	if not _built:
		# SOLID-ONLY, matching how §6.2's oracle numbers were produced. Keeping the
		# hard gate on this configuration means it stays a regression check on the
		# csg4 port itself, independent of what the shipped map chooses to include.
		var imp := QuakeMapImporter.new()
		imp.import_file(_lvl, MAP_PATH, 0.028, Vector3.ZERO, 1.0, null, false, {}, false)
		_lvl.rebuild()
		# Second level with SKY brushes promoted to solid — the configuration
		# map_dc_level actually ships. No hard target: this REPORTS the numbers so
		# the shipped map's expectations can be set from measurement.
		_sky = ClassDB.instantiate("DCLevel") as Node3D
		root.add_child(_sky)
		# Parked far away ON PURPOSE. Two levels at the same origin put two static
		# bodies in one physics space, so the picking rays below hit whichever is
		# nearer and the owner check reads 31/60 — a broken TEST, not broken
		# picking. Colliders still build (body transform is the global one), so the
		# sky collider count below is still real.
		_sky.position = Vector3(100000.0, 0.0, 0.0)
		var imp2 := QuakeMapImporter.new()
		imp2.import_file(_sky, MAP_PATH, 0.028, Vector3.ZERO, 1.0, null, false, {}, true)
		_sky.rebuild()
		_built = true
		return false
	if _frame < 8:
		return false

	var importer := QuakeMapImporter.new()
	var n: int = _lvl.get_brush_count()
	var s: Dictionary = _lvl.get_stats()
	var fail := 0

	print("=== gate ===")
	for row in [["triangles", 12747], ["faces", 6487], ["colliders", 1164]]:
		var got: int = int(s.get(row[0], 0))
		if got != int(row[1]): fail += 1
		print("%-12s %7d  target %7d  %s" % [row[0], got, row[1], "OK" if got == int(row[1]) else "DIVERGES"])
	var dev := float(s.get("max_normal_deviation", 0.0))
	if dev > 0.034: fail += 1
	print("%-12s %7.4f  target <=0.034 deg  %s" % ["normal dev", dev, "OK" if dev <= 0.034 else "DIVERGES"])

	print("=== winding ===")
	var mi := _lvl.get_node_or_null("LevelMesh") as MeshInstance3D
	var wound_ok := absf(_agree_fraction(BoxMesh.new().surface_get_arrays(0))
					   - _agree_fraction(mi.mesh.surface_get_arrays(0))) < 0.5
	if not wound_ok: fail += 1
	print("winding: %s" % ("OK — matches Godot" if wound_ok else "INVERTED"))

	print("=== picking (step 2) ===")
	var space := _lvl.get_world_3d().direct_space_state
	var aabb: AABB = mi.get_aabb()
	var centre := aabb.get_center()
	var hits := 0
	var owned := 0
	var resolved := 0
	var seen := {}
	# Fire rays inward from well outside the level, spread over the AABB.
	for i in range(60):
		var a := TAU * float(i) / 60.0
		var r: float = aabb.size.length()
		var from := centre + Vector3(cos(a) * r, sin(a * 0.7) * r * 0.4, sin(a) * r)
		var q := PhysicsRayQueryParameters3D.create(from, centre)
		var hit := space.intersect_ray(q)
		if hit.is_empty():
			continue
		hits += 1
		var col: Object = hit.get("collider")
		if col == _lvl:
			owned += 1                      # <- the body_attach_object_instance_id fix
		# Resolve against the node that WAS HIT, never an assumed one — a shape
		# index is only meaningful relative to its own body.
		var bid := -1
		if col != null and col.has_method("get_brush_id_from_shape"):
			bid = col.get_brush_id_from_shape(int(hit.get("shape", -1)))
		if bid >= 0:
			resolved += 1
			seen[bid] = true
	print("rays hit:        %d / 60" % hits)
	print("hit.collider is the DCLevel node: %d / %d" % [owned, hits])
	print("resolved to a brush_id:           %d / %d  (%d distinct brushes)" % [resolved, hits, seen.size()])
	print("out-of-range guard returns -1:    %s" % str(_lvl.get_brush_id_from_shape(999999) == -1))
	if hits == 0 or owned != hits or resolved != hits:
		fail += 1
		print("picking: FAILED")
	else:
		print("picking: OK — every hit has an owner and a brush id")

	# ENGINE-FREE PICKING vs THE PHYSICS PATH (hyper-games #22 §5 item 3).
	#
	# DCLevel.raycast resolves a ray against the brush half-spaces directly, so
	# blender-bridge — which has no physics server — can pick. The claim that needs
	# testing in-engine is not that it runs (the offline oracle covers that) but
	# that it AGREES with the path the game uses today on the real map, ray for ray.
	# A picking layer that quietly disagreed would be worse than not having one.
	#
	# The comparison is only meaningful because _lvl sits at the origin with an
	# identity transform: raycast works in the store's own space, exactly as
	# get_face_winding does, while the physics query is in world space.
	print("=== picking, engine-free (step 2b) ===")
	var ef_hits := 0
	var agree := 0
	# COINCIDENT SURFACES ARE A TIE, NOT A DISAGREEMENT. Quake maps abut brushes on
	# shared planes, so a ray can meet two brushes at the same point with the same
	# normal — measured here on ray 9: brushes 165 and 112, gap 0.00000 m. Both
	# answers are correct; the paths order candidates differently (the broadphase
	# vs store order). Counting those as failures would be a test asserting an
	# arbitrary tie-break, so they are counted and REPORTED instead.
	var ties := 0
	for i in range(60):
		var a := TAU * float(i) / 60.0
		var r: float = aabb.size.length()
		var from := centre + Vector3(cos(a) * r, sin(a * 0.7) * r * 0.4, sin(a) * r)
		var phys := space.intersect_ray(PhysicsRayQueryParameters3D.create(from, centre))
		var ef: Dictionary = _lvl.raycast(from, centre)
		if ef.is_empty():
			continue
		ef_hits += 1
		if phys.is_empty():
			continue
		var pbid: int = _lvl.get_brush_id_from_shape(int(phys.get("shape", -1)))
		if pbid == int(ef.get("brush", -1)):
			agree += 1
		elif (phys.get("position", Vector3.ZERO) as Vector3).distance_to(
				ef.get("position", Vector3.ZERO)) < 1e-4:
			ties += 1
	# The durable names come back with the hit, so a caller never has to round-trip
	# through a local id to write a journal record or hold a selection.
	var named: Dictionary = _lvl.raycast(centre + Vector3(0, aabb.size.y, 0), centre)
	var has_keys: bool = not named.is_empty() \
		and String(named.get("brush_key", "")).begins_with("i:") \
		and String(named.get("face_key", "")) != ""
	var misses: bool = _lvl.raycast(centre + Vector3(0, 1e6, 0), centre + Vector3(0, 1e6 + 1.0, 0)).is_empty()
	print("engine-free rays hit:             %d / 60" % ef_hits)
	print("agrees with the physics pick:     %d / %d  (+%d coincident-surface ties)" % [agree, hits, ties])
	print("hit carries durable names:        %s" % str(has_keys))
	print("a ray into empty space misses:    %s" % str(misses))
	if ef_hits != hits or agree + ties != hits or not has_keys or not misses:
		fail += 1
		print("engine-free picking: FAILED")
	else:
		print("engine-free picking: OK — same answer as physics (or a tie), with no physics")

	print("=== shader ===")
	# 0.771.0 shipped untextured because the .gdshader sat in res://shaders/, which
	# carries a .gdignore — Godot never imported it, so it was absent from the .pck
	# and load() returned null. EVERY other check here passed. A shader cannot be
	# rendered headless, but it CAN be load-and-compile checked, which is the part
	# that actually broke.
	var sh: Shader = load("res://scenes/maps/dc_level.gdshader")
	if sh == null:
		fail += 1
		print("dc_level.gdshader: FAILED TO LOAD (missing, .gdignore'd, or syntax error)")
	else:
		var m := ShaderMaterial.new()
		m.shader = sh
		# Setting every uniform the map sets: a name typo here is silent at runtime.
		m.set_shader_parameter("use_textures", true)
		m.set_shader_parameter("has_projection_table", true)
		m.set_shader_parameter("projection_rows", 1)
		print("dc_level.gdshader: loads OK (%d uniforms)" % sh.get_shader_uniform_list().size())

	print("=== sky-inclusive (what map_dc_level ships) ===")
	var sk: Dictionary = _sky.get_stats()
	print("brushes    %7d  (solid-only %d)" % [int(sk.get("brushes",0)), int(s.get("brushes",0))])
	print("sky brushes%7d  (non-colliding, still clipping)" % int(sk.get("sky_brushes",0)))
	print("sky ids returned: %d" % _sky.get_sky_brush_ids().size())
	print("triangles  %7d  (solid-only %d)" % [int(sk.get("triangles",0)), int(s.get("triangles",0))])
	print("faces      %7d  (solid-only %d)" % [int(sk.get("faces",0)), int(s.get("faces",0))])
	print("colliders  %7d  (solid-only %d)" % [int(sk.get("colliders",0)), int(s.get("colliders",0))])
	print("normal dev %7.4f" % float(sk.get("max_normal_deviation",0.0)))

	print("RESULT: %s" % ("PASS" if fail == 0 else "%d CHECK(S) FAILED" % fail))
	return true