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