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)