OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / shell_async_gate.gd

139 lines · gd ref:
extends SceneTree
## HEADLESS GATE — the shell built on a worker is the shell built on the frame.
##
## Run: "$HYPER_TPS_GODOT" --headless --script scenes/test/shell_async_gate.gd
##
## build_shell() is 1355 ms headless and 2206 ms in the game, and since the shell
## started following live edits that cost lands on the frame of every edit — a
## measured 2.7-2.8 s freeze per brush created or face moved. DCLevel now offers
## the same build off-thread: snapshot on the caller, flood on a worker, pack when
## taken.
##
## The claim that needs gating is not "it returns something". It is that the
## threaded path returns the IDENTICAL mesh — the seal checks that consume it are
## four load-bearing invariants found by failing measurements, and a shell that is
## subtly different on one path would pass them and be wrong in the world. So this
## builds the same fixture both ways and compares the arrays, not just the stats.
##
## It also pins the ownership contract, because the caller polls: take hands the
## result over exactly once, and a fresh build can start afterwards.

var fails := 0


func _ok(what: String, cond: bool) -> void:
	if not cond:
		fails += 1
	print("  %-58s %s" % [what, "OK" if cond else "**FAIL**"])


func _init() -> void:
	_start.call_deferred()


func _box(lo: Vector3, hi: Vector3) -> PackedFloat32Array:
	return PackedFloat32Array([
		-1, 0, 0, -lo.x,   1, 0, 0, hi.x,
		0, -1, 0, -lo.y,   0, 1, 0, hi.y,
		0, 0, -1, -lo.z,   0, 0, 1, hi.z])


func _start() -> void:
	print("=== Shell async ===\n")

	if not ClassDB.class_exists("DCLevel"):
		print("  no DCLevel in ClassDB — rebuild the extension")
		quit(1)
		return
	var lvl: Node3D = ClassDB.instantiate("DCLevel")
	root.add_child(lvl)

	if not lvl.has_method("build_shell_start"):
		print("  **FAIL**  libdc has no build_shell_start — the .so is older than its source")
		quit(1)
		return

	# A ROOM, not a solid: the shell is the boundary of what the level ENCLOSES, so
	# a fixture with no interior seals nothing and the comparison below would be
	# between two empty meshes agreeing perfectly.
	var o := 5.0
	var w := 1.0
	for b: Array in [
			[Vector3(-o, -o, -o), Vector3(o, -o + w, o)],      # floor
			[Vector3(-o, o - w, -o), Vector3(o, o, o)],        # ceiling
			[Vector3(-o, -o, -o), Vector3(-o + w, o, o)],      # -x
			[Vector3(o - w, -o, -o), Vector3(o, o, o)],        # +x
			[Vector3(-o, -o, -o), Vector3(o, o, -o + w)],      # -z
			[Vector3(-o, -o, o - w), Vector3(o, o, o)]]:       # +z
		lvl.add_brush(_box(b[0], b[1]), 0, PackedInt32Array(), 0, false, "")
	await process_frame

	# ---- the frame path, as the reference ----
	var sync: Dictionary = lvl.build_shell()
	var sv: PackedVector3Array = sync.get("vertices", PackedVector3Array())
	_ok("the fixture builds a shell on the frame (%d verts)" % sv.size(), sv.size() > 8)
	_ok("and it SEALS — otherwise this compares two failures", bool(sync.get("sealed", false)))

	# ---- the worker path ----
	_ok("nothing is building before it is asked", not lvl.shell_building())
	_ok("and nothing is ready", not lvl.shell_ready())
	_ok("take before a build returns nothing, rather than a stale answer",
		(lvl.build_shell_take() as Dictionary).is_empty())

	# WHAT THE CALLER PAYS, which is the whole point of the change: the frame path
	# is held for the length of the flood, the worker path only for the snapshot.
	var t_sync := Time.get_ticks_usec()
	lvl.build_shell()
	var us_sync := Time.get_ticks_usec() - t_sync
	var t_start := Time.get_ticks_usec()
	var started: bool = lvl.build_shell_start()
	var us_start := Time.get_ticks_usec() - t_start
	_ok("start accepts the job", started)
	print("    [caller held: %.1f ms on the frame path, %.1f ms to start a worker]"
			% [us_sync / 1000.0, us_start / 1000.0])
	_ok("starting costs a fraction of building (%.1f ms vs %.1f ms)"
			% [us_start / 1000.0, us_sync / 1000.0],
		us_start * 4 < us_sync)

	var waited := 0
	while not lvl.shell_ready() and waited < 600:
		await process_frame
		waited += 1
	_ok("it finished on its own thread (%d frames)" % waited, lvl.shell_ready())

	var async_shell: Dictionary = lvl.build_shell_take()
	var av: PackedVector3Array = async_shell.get("vertices", PackedVector3Array())
	var ai: PackedInt32Array = async_shell.get("indices", PackedInt32Array())
	var si: PackedInt32Array = sync.get("indices", PackedInt32Array())

	# THE ASSERTION. Same brushes, same builder, so the same mesh — byte for byte,
	# not merely the same summary. A stats-only comparison would pass on a mesh
	# that was translated, reordered or reversed, and the winding is the one thing
	# here that every other invariant is blind to.
	_ok("same vertex count (%d vs %d)" % [av.size(), sv.size()], av.size() == sv.size())
	_ok("same index count (%d vs %d)" % [ai.size(), si.size()], ai.size() == si.size())
	_ok("IDENTICAL vertices", av == sv)
	_ok("IDENTICAL indices", ai == si)
	_ok("same centre, so nothing is translated", async_shell.get("center") == sync.get("center"))
	_ok("same enclosed volume", is_equal_approx(
		float(async_shell.get("volume", -1.0)), float(sync.get("volume", -2.0))))
	_ok("and it seals too", bool(async_shell.get("sealed", false)))

	# ---- the ownership contract the poller depends on ----
	_ok("taking it once hands it over", (lvl.build_shell_take() as Dictionary).is_empty())
	_ok("and the next build may start", lvl.build_shell_start())
	waited = 0
	while not lvl.shell_ready() and waited < 600:
		await process_frame
		waited += 1
	_ok("which also finishes", lvl.shell_ready())
	# Freed with a job in flight on the FIRST run of this gate — ShellJob's
	# destructor joins, so this is the case where a level closed mid-build must
	# wait for its own worker rather than let it outlive the object it writes into.
	lvl.build_shell_start()
	lvl.queue_free()
	await process_frame

	print("\n%s" % ("PASS" if fails == 0 else "FAILED (%d)" % fails))
	quit(0 if fails == 0 else 1)