OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / knife_real_map_gate.gd

291 lines · gd ref:
extends SceneTree
## THE KNIFE, AGAINST THE REAL LEVEL — a crash hunt, not an acceptance test.
##
##     "$HYPER_TPS_GODOT" --headless --script scenes/test/knife_real_map_gate.gd
##
## operator_gate cuts synthetic 8x4x1 slabs, and every one of them passes. The knife
## crashes the game on START.MAP. The difference is not the operator — it is the
## GEOMETRY: 1183 imported brushes with dormant planes, sky volumes, split pieces and
## faces that bound nothing, none of which a hand-made box has.
##
## So this drives cut_preview and split_brush over EVERY real brush and prints as it
## goes, so a segfault names the brush it died on instead of leaving a silent exit.
##
## (A segfault AFTER "RESULT" is the known godot 4.6 GDExtension atexit crash, which
## dc_level_gate documents too. One before it is the bug.)

func _press(code: int) -> InputEventKey:
	var e := InputEventKey.new()
	e.physical_keycode = code
	e.keycode = code
	e.pressed = true
	return e


## Exactly what the knife reaches for on the map, and nothing else.
class KnifeStub extends Node3D:
	const GRID_UNITS: Array[int] = [1, 2, 4, 8, 16, 32, 64]
	const WORLD_SCALE := 0.028
	var _grid_idx := 3
	var _map_center := Vector3.ZERO
	var _aligned_offset := Vector3.ZERO
	var level: Node = null
	var aim_brush := -1
	var aim_point := Vector3.ZERO
	var _persist: StubDots = StubDots.new()

	func _crosshair_hit() -> Dictionary:
		if aim_brush < 0:
			return {}
		return {"position": aim_point, "normal": Vector3.UP, "shape": 0}

	func _crosshair_ray() -> Dictionary:
		var cam := get_viewport().get_camera_3d() if get_viewport() != null else null
		if cam == null:
			return {}
		return {"from": cam.global_position, "dir": -cam.global_transform.basis.z}

	func _brush_from_hit(hit: Dictionary) -> int:
		return aim_brush if not hit.is_empty() else -1

	func _refresh_brush_view() -> void:
		pass

	static func snap_to_map_grid(d: float, _n: Vector3, units: int,
								 _mc: Vector3, _ao: Vector3) -> float:
		var s: float = float(units) * WORLD_SCALE
		return roundf(d / s) * s


class StubDots extends RefCounted:
	var n := 0
	func reserve_dot() -> String:
		n += 1
		return "opgate:%d" % n
	func record_at(_dot: String, _kind: String, _payload: Dictionary) -> bool:
		return true
	func record(_kind: String, _payload: Dictionary) -> String:
		return reserve_dot()
	func is_applying() -> bool:
		return false


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

var _lvl: Node3D = null
var _frame := 0
var _checked := 0
var _previewed := 0
var _cut := 0
var _refused := 0


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


func _process(_dt: float) -> bool:
	_frame += 1
	# Frame-staged for the same reason dc_level_gate is: collision uploads need a
	# world, and shapes are not in the broadphase until the space has stepped.
	if _frame == 2:
		var imp := QuakeMapImporter.new()
		var n: int = imp.import_file(_lvl, MAP_PATH, WORLD_SCALE, Vector3.ZERO,
									 1.0, null, false, {}, true)
		_lvl.rebuild()
		print("[knife-gate] imported %d brushes" % n)
		return false
	if _frame < 4:
		return false

	# ---- EDIT THE LEVEL FIRST, which is the condition the crash needs -----------
	# The probe caught it inside get_cut_preview on brush 944 in a session with
	# carves: 15, pockets: 2, brushes: 1200 and that brush's planes moved repeatedly.
	# Every previous run of this harness previewed cuts on a VIRGIN import — 1183
	# brushes, no carves, no moved faces — which is why it never reproduced.
	print("[knife-gate] carving and moving faces before any preview")
	var carved := 0
	for i in range(0, 15):
		var cpos := Vector3(-14.0 + float(i) * 1.7, 3.0 + float(i % 4), 24.0 + float(i % 6))
		if _lvl.apply_carve_sphere(cpos, 0.6, "gatecarve:%d" % i) >= 0:
			carved += 1
	_lvl.rebuild()
	print("[knife-gate]   %d carves, stats %s" % [carved, str(_lvl.get_stats())])

	var moved := 0
	for bid in range(900, 1000):
		var bb: Dictionary = _lvl.get_brush(bid)
		if bb.is_empty():
			continue
		var pls: PackedFloat32Array = bb.get("planes", PackedFloat32Array())
		for fi in range(pls.size() / 4):
			var nn := Vector3(pls[fi * 4], pls[fi * 4 + 1], pls[fi * 4 + 2])
			if _lvl.set_face_plane(bid, fi, nn, pls[fi * 4 + 3] + 0.224):
				moved += 1
			break
	_lvl.rebuild()
	print("[knife-gate]   %d faces moved" % moved)

	# Walk every brush. The plane is the one the knife itself would build: a world
	# axis through the brush's own centre, which is exactly where an aimed cut lands.
	var total: int = _lvl.get_brush_count()
	print("[knife-gate] cutting across %d brushes" % total)
	var id := 0
	var scanned := 0
	# Ids are not dense — brushes are addressed by id, not by index — so walk a
	# generous range and skip what is not there.
	while id < 4000 and scanned < total:
		var b: Dictionary = _lvl.get_brush(id)
		id += 1
		if b.is_empty():
			continue
		scanned += 1
		var pl: PackedFloat32Array = b.get("planes", PackedFloat32Array())
		if pl.size() < 16:
			continue
		# The brush's centre, from its own edge points — the same points the wireframe
		# draws, so this is a place the geometry really is.
		var pts: PackedVector3Array = _lvl.get_brush_edges_of(id - 1)
		if pts.size() < 2:
			continue
		var c := Vector3.ZERO
		for p: Vector3 in pts:
			c += p
		c /= float(pts.size())

		for axis: Vector3 in [Vector3.RIGHT, Vector3.UP, Vector3.BACK]:
			_checked += 1
			if _checked % 500 == 0:
				print("[knife-gate]   %d previews, brush id %d" % [_checked, id - 1])
			var d: float = axis.dot(c)
			var poly: PackedVector3Array = _lvl.get_cut_preview(id - 1, axis, d)
			if poly.size() >= 3:
				_previewed += 1

		# And actually CUT on the axis that previewed, because split_brush does more
		# than cut_preview does: it commits, renames faces and appends a brush.
		var dx: float = Vector3.RIGHT.dot(c)
		if _lvl.get_cut_preview(id - 1, Vector3.RIGHT, dx).size() >= 3:
			var made: int = _lvl.split_brush(id - 1, Vector3.RIGHT, dx,
											 "knifegate:%d" % (id - 1))
			if made > 0:
				_cut += 1
			else:
				_refused += 1

	# ---- and now the OPERATOR itself, which is what actually crashes ----------
	# The loop above proves the C++ is sound on real geometry. What the game runs is
	# the GDScript operator on top of it, and that reaches for things a raw store call
	# never touches: the aim, the camera, the journal, the preview meshes.
	print("[knife-gate] driving the real KnifeOperator over real brushes")
	var cam := Camera3D.new()
	root.add_child(cam)
	cam.current = true
	var stub := KnifeStub.new()
	stub.level = _lvl
	root.add_child(stub)
	var kn := KnifeOperator.new()
	kn.level = _lvl
	kn.map = stub
	root.add_child(kn)

	var driven := 0
	var id2 := 0
	while id2 < 400:
		var b2: Dictionary = _lvl.get_brush(id2)
		id2 += 1
		if b2.is_empty():
			continue
		var pts2: PackedVector3Array = _lvl.get_brush_edges_of(id2 - 1)
		if pts2.size() < 2:
			continue
		var c2 := Vector3.ZERO
		for p2: Vector3 in pts2:
			c2 += p2
		c2 /= float(pts2.size())
		stub.aim_brush = id2 - 1
		stub.aim_point = c2
		cam.global_position = c2 + Vector3(0, 0, 6)
		cam.look_at(c2, Vector3.UP)
		if not kn.on_activate():
			continue
		kn.update_preview()
		kn.status()
		# place points too — the free-plane path, on real faces
		kn.handle_key(_press(KEY_P))
		stub.aim_point = c2 + Vector3(0.3, 0.2, 0.0)
		kn.handle_key(_press(KEY_P))
		kn.update_preview()
		kn.confirm()
		kn.on_deactivate()
		driven += 1
		if driven % 50 == 0:
			print("[knife-gate]   operator driven on %d brushes (id %d)" % [driven, id2 - 1])
	print("[knife-gate] operator survived %d brushes" % driven)

	# ---- THE BRUSH VIEW, which every harness so far stubbed out ---------------
	# _refresh_brush_view() is a no-op in operator_gate and in KnifeStub, and
	# set_aimed() runs EVERY FRAME in the real game as the crosshair moves. So the
	# wireframe is the one editor path that has never been exercised outside the
	# running game — which is exactly the shape of a bug that only ever crashes there.
	print("[knife-gate] driving BrushView over real brushes")
	var bv := BrushView.new()
	bv.level = _lvl
	_lvl.add_child(bv)
	bv.set_shown(true)
	print("[knife-gate]   refresh ok, %d segments" % bv.segment_count())
	var aimed := 0
	for bid in range(0, 1300):
		bv.set_aimed(bid)          # includes get_brush_vertices_of for the handles
		aimed += 1
		if aimed % 400 == 0:
			print("[knife-gate]   set_aimed through %d" % bid)
	bv.set_aimed(-1)
	bv.set_aimed(99999)            # a brush id that does not exist
	print("[knife-gate]   set_aimed survived %d ids incl. out-of-range" % aimed)

	# ---- HOW MANY PLANES DOES A REAL VERTEX TOUCH? ---------------------------
	# "vertex/edge do not select individual but the whole brush." Selecting a vertex
	# selects its INCIDENT planes — three on a cube corner — and moving it moves those
	# planes. If real START.MAP brushes routinely return far more than three, the pick
	# is over-collecting and the report is a bug rather than the model.
	var hist := {}
	var sampled := 0
	var id3 := 0
	while id3 < 600 and sampled < 200:
		var b3: Dictionary = _lvl.get_brush(id3)
		id3 += 1
		if b3.is_empty():
			continue
		var verts: PackedVector3Array = _lvl.get_brush_vertices_of(id3 - 1)
		if verts.is_empty():
			continue
		var pl3: PackedFloat32Array = b3.get("planes", PackedFloat32Array())
		# The same epsilon the map uses: max(grid_step * 0.05, 1e-4) at grid 8u.
		var eps: float = maxf(8.0 * 0.028 * 0.05, 1e-4)
		for v: Vector3 in verts:
			var n_inc := 0
			for i in range(pl3.size() / 4):
				if not _lvl.face_is_live(id3 - 1, i):
					continue
				var nn := Vector3(pl3[i * 4], pl3[i * 4 + 1], pl3[i * 4 + 2])
				if absf(nn.dot(v) - pl3[i * 4 + 3]) < eps:
					n_inc += 1
			hist[n_inc] = int(hist.get(n_inc, 0)) + 1
		sampled += 1
	var keys: Array = hist.keys()
	keys.sort()
	var line := ""
	for k in keys:
		line += "%d planes:%d  " % [int(k), int(hist[k])]
	print("[knife-gate] incident planes per REAL vertex over %d brushes -> %s" % [sampled, line])

	print("[knife-gate] rebuilding after %d cuts" % _cut)
	_lvl.rebuild()
	print("[knife-gate] RESULT previews_ok=%d/%d cuts=%d refused=%d brushes_now=%d"
		% [_previewed, _checked, _cut, _refused, _lvl.get_brush_count()])
	quit(0)
	return true