extends SceneTree ## THE TABLE GATE. Builds every table in Tables.ORDER headless and measures the six things ## that have to be true before a layout is playable at all: ## ## 1 IT BUILDS every part in the spec produced a node ## 2 FLIPPERS SWING the motors actually move the bats, with this engine's sign ## 3 NO OVERLAPS no two solid parts interpenetrate — a wedge is a stuck ball ## 4 INSIDE THE FIELD nothing sits outside the playfield rectangle ## 5 THE BALL DRAINS a released ball reaches the drain instead of being lost or trapped ## 6 IT SETTLES at rest, nothing is moving except the ball ## ## Same job as Destroyulator's dev/probe_levels.gd — measure the level instead of squinting at ## a screenshot. A table can look perfect and still be unplayable because two colliders share ## a millimetre, or because a ball that reaches the bottom is never reported as drained. ## ## The numbers legitimately DIFFER between Box3D / Jolt / GodotPhysics — that divergence is ## the entire point of this testbed — so the active engine is printed with every run and ## belongs in any report of a failure. Exits 1 if any table fails. ## ## Godot --headless --path godot --script dev/probe_tables.gd ## ## Two lines of stderr noise are expected and are NOT probe failures: Box3D logs "Parameter ## joint is null" while each flipper hinge is being wired (the spine sets joint params before ## node_a/node_b resolve), and it reports unfreed RIDs at shutdown — the spine's own ## PINBALL_AUTOTEST does both too. Add 2>/dev/null for a clean read. const SWING_FRAMES := 30 ## half a second of motor, same window as Main's autotest const SWING_MIN_DEG := 8.0 ## below this the bat is unjointed or jammed const SWING_SLOP_DEG := 25.0 ## past the spec'd limit range = the stop is not holding const SETTLE_FRAMES := 30 const LANE_DRAIN_S := 4.0 ## a parked ball rolls the shooter lane in well under this const FIELD_DRAIN_S := 10.0 ## a loose ball may sit in a saucer and rattle the pops first const STALL_FRAMES := 120 ## 2 s under STALL_V and the table is holding the ball const STALL_V := 0.02 const OVERLAP_CLEAR := 0.002 ## metres of slop before a pair counts as interpenetrating const EDGE_SLOP := 0.001 const STILL_LIN := 0.02 ## m/s const STILL_ANG := 0.60 ## rad/s — a motored flipper on its stop still creeps var _flip_sign := 1.0 var _frame := 0 ## physics frames since boot; the probe's only clock var _drain_frame := -1 var _drained_ball: RigidBody3D = null func _initialize() -> void: # Table.add_ball() spreads new balls with randf, and a gate that reports a different # verdict on every run is worthless. Pin the stream. seed(20260809) # _initialize() runs BEFORE the SceneTree attaches its root, so anything add_child'd here # is not actually inside the tree: global_transform reads back as identity and physics # nodes register late. One frame fixes it. (Measured, not folklore — building a table # before this line gives every part a global position of (0,0,0).) await process_frame var engine := String(ProjectSettings.get_setting("physics/3d/physics_engine", "?")) # Resolved exactly as Main._ready does it: under Box3D a positive motor velocity swings a # hinge the opposite way to Jolt/GodotPhysics. Probing with the wrong sign reads as "the # flipper does not swing" when in fact it is swinging hard into its own stop. _flip_sign = -1.0 if engine.begins_with("Box3D") else 1.0 print("PROBE_TABLES engine=\"%s\" flip_sign=%.0f tick=%d gravity=%s" % [ engine, _flip_sign, Engine.physics_ticks_per_second, ProjectSettings.get_setting("physics/3d/default_gravity_vector", Vector3.DOWN)]) var failed: Array[String] = [] for id in Tables.ORDER: if not await _probe(String(id)): failed.append(String(id)) var n: int = Tables.ORDER.size() print("\nPROBE_TABLES result: %s (%d/%d tables passed, engine %s)" % [ "PASS" if failed.is_empty() else "FAIL [" + ", ".join(failed) + "]", n - failed.size(), n, engine]) quit(1 if failed.size() > 0 else 0) # ---------------------------------------------------------------- one table func _probe(id: String) -> bool: var table := Table.new() table.name = "T_%s" % id # In the tree before build(), the way Main does it: the flipper hinges resolve node_a and # node_b by PATH, and every part position this probe measures is a global_transform read. get_root().add_child(table) table.build(Tables.get_table(id)) table.drained.connect(_on_drained) var spec: Dictionary = table.spec var w: float = float(spec.get("width", Table.PLAYFIELD_W)) var l: float = float(spec.get("length", Table.PLAYFIELD_L)) print("\n=== %-9s %s" % [id, table.table_name()]) var fails: Array[String] = [] # --- 1 IT BUILDS --------------------------------------------------------- var want: Dictionary = {} var missing: Array[String] = [] for p in spec.get("parts", []): var pd: Dictionary = p want[String(pd.get("kind", "?"))] = int(want.get(pd.get("kind", "?"), 0)) + 1 if table.part(String(pd.get("id", ""))) == null: missing.append(String(pd.get("id", "?"))) var kinds := want.keys() kinds.sort() var tally: Array[String] = [] var gaps: Array[String] = [] for k in kinds: var got: int = table.parts_of(String(k)).size() tally.append("%s %d" % [k, got]) if got != int(want[k]): gaps.append("%s %d/%d" % [k, got, int(want[k])]) var total: int = spec.get("parts", []).size() print(" build %d/%d parts · %s" % [total - missing.size(), total, " ".join(tally)]) if total == 0 or not missing.is_empty() or not gaps.is_empty(): fails.append("build") print(" MISSING ids: %s · kind gaps: %s" % [ ", ".join(missing) if missing.size() > 0 else "-", ", ".join(gaps) if gaps.size() > 0 else "-"]) # --- 5a THE BALL DRAINS, from the shooter lane --------------------------- # Rest the motors first: Main drives the flippers onto their stops whenever no key is # held, so "no flipper input" means driven-to-rest, not limp. A limp bat hangs loose in # the tilted gravity and the ball would meet a flipper that never exists in the game. _motors(table, false) var lane := await _chase_drain(table, table.balls[0] if table.balls.size() > 0 else null, w, l, int(LANE_DRAIN_S * Engine.physics_ticks_per_second)) if String(lane["code"]) != "drained": fails.append("lane-drain") elif _drained_ball != null: table.remove_ball(_drained_ball) # keep it out of the settle and overlap reads # --- 6 IT SETTLES -------------------------------------------------------- await _step(SETTLE_FRAMES) var rest := _residual(table) var still: bool = float(rest["lin"]) < STILL_LIN and float(rest["ang"]) < STILL_ANG # --- 3 NO OVERLAPS + 4 INSIDE THE FIELD ---------------------------------- # Measured at rest, not at t=0: a flipper is only in its real playing pose once the motor # has pushed it onto its stop, and that pose is what a ball actually meets. var boxes := _colliders(table) var pairs := _overlaps(boxes) var outside := _outside(boxes, w, l) print(" overlaps %d solid pair(s) of %d collider(s)" % [pairs.size(), boxes.size()]) for h in pairs.slice(0, 6): print(" %.4f m into %-16s / %-16s at %s" % [ h["pen"], h["a"], h["b"], (h["at"] as Vector3).snappedf(0.001)]) if not pairs.is_empty(): fails.append("overlap") print(" bounds %d part(s) outside the %.2f x %.2f playfield" % [outside.size(), w, l]) for o in outside.slice(0, 6): print(" %-16s reaches %s" % [o["id"], (o["at"] as Vector3).snappedf(0.001)]) if not outside.is_empty(): fails.append("bounds") # --- 2 FLIPPERS SWING ---------------------------------------------------- var before: Array[float] = [] for b in table.flipper_bodies: before.append(b.rotation.y) _motors(table, true) await _step(SWING_FRAMES) var swings: Array[String] = [] var worst := 999.0 var burst: Array[String] = [] for i in table.flipper_bodies.size(): var d := rad_to_deg(absf(angle_difference(before[i], table.flipper_bodies[i].rotation.y))) swings.append("%.1f" % d) worst = minf(worst, d) # A bat that travels much further than its own limit range has punched through its # stop. Not fatal to the gate, but it sweeps playfield it was never meant to reach, # and which engine does it is exactly what this testbed is for (see README). var lim := _limit_range(spec, String(table.flipper_bodies[i].get_meta("id", ""))) if lim > 0.0 and d > lim + SWING_SLOP_DEG: burst.append("%s %.0f>%.0f" % [table.flipper_bodies[i].get_meta("id", "?"), d, lim]) var flips_ok: bool = table.flipper_bodies.size() > 0 and worst > SWING_MIN_DEG print(" flippers %d · swing_deg [%s] · min %.1f %s%s" % [ table.flipper_bodies.size(), ", ".join(swings), 0.0 if worst > 900.0 else worst, "OK" if flips_ok else "FAIL", " NOTE past limit: " + ", ".join(burst) if not burst.is_empty() else ""]) if not flips_ok: fails.append("flippers") _motors(table, false) await _step(SETTLE_FRAMES) # --- 5b THE BALL DRAINS, from out on the playfield ------------------------ # The lane roll only proves the lane slopes. This is the real question: dropped into the # open field with no flipper help, does the ball find its way out? Release point is the # clearest spot the collider survey above could find, because a hand-picked coordinate # lands underneath a centre ramp on half these tables and measures the probe, not the table. # # Saucers are parked first. Table._saucer holds the ball on a SceneTreeTimer, and a # SceneTreeTimer counts IDLE frames — headless, the loop spins as fast as it can, so a # 0.9 s hold lasts a different number of PHYSICS steps on every machine and every load, # and every trajectory downstream of a capture moves with it. Verified: adding one print # statement flipped this test's verdict on two tables. Park them and the answer is about # the table again. (Not a bug in the game at a steady 60 fps — a gotcha for anything that # measures it. If a saucer ever needs testing, test the eject on its own.) for s in table.parts_of("saucer"): if s is Area3D: (s as Area3D).set_deferred("monitoring", false) await _step(2) var spot := _release_spot(boxes, w, l, table.lane_x) var fb := table.add_ball(Vector3(spot.x, Table.BALL_R + 0.006, spot.y)) fb.linear_velocity = Vector3.ZERO fb.angular_velocity = Vector3.ZERO var field := await _chase_drain(table, fb, w, l, int(FIELD_DRAIN_S * Engine.physics_ticks_per_second)) # "out" counts as a pass here alongside "drained", and that is not slack — it is working # around a measured engine divergence rather than blaming the table for it. # # Under Box3D the drain Area only reports a ball arriving SLOWLY. Swept at x=0 down the # open bottom edge: 0.05 / 0.10 / 0.20 / 0.30 m/s all fire body_entered, 0.40 / 0.60 / # 1.00 m/s never do — and the fast ones sit geometrically inside the 5 cm drain volume for # seven physics ticks while doing it, so this is not tunnelling. Under Jolt and # GodotPhysics the same ball at the same speed reports normally. It is not about # teleporting either: a ball moved with add_ball(at) still trips bumpers and rollovers. # # A ball only ever reaches the shooter lane's bottom at a crawl, which is why the lane # test above passes everywhere and why nothing had caught this. So: the lane test gates # the drain SIGNAL, and this test gates the geometry — does anything out on this playfield # hold a ball forever. The printed line says which way the ball actually left. if not (String(field["code"]) in ["drained", "out"]): fails.append("field-drain") print(" drain lane %s · field %s (released at %.3f, %.3f)" % [ _drain_str(lane, LANE_DRAIN_S), _drain_str(field, FIELD_DRAIN_S), spot.x, spot.y]) for d in [lane, field]: var code := String(d["code"]) if code == "out" and d == lane: # Worse than a stuck ball: it is gone AND Rules never hears about it, so the game # sits there waiting for a drain that already happened. print(" left the table without tripping the drain, at %s" % [ (d["at"] as Vector3).snappedf(0.001)]) elif code == "stall" or code == "escaped": print(" %s at %s" % [code, (d["at"] as Vector3).snappedf(0.001)]) print(" settling lin %.4f m/s · ang %.3f rad/s · worst %s %s" % [ rest["lin"], rest["ang"], rest["who"], "OK" if still else "FAIL"]) if not still: fails.append("settling") print(" VERDICT %s" % ("PASS" if fails.is_empty() else "FAIL — " + ", ".join(fails))) # Out of the tree and freed outright rather than queue_free'd: teardown then happens here # instead of at some later idle frame, so the next table never shares a physics step with # the last one's bodies. Everything above turned out to be sensitive to exactly that kind # of overlap, so it is not worth leaving to chance. get_root().remove_child(table) table.free() await process_frame return fails.is_empty() # ---------------------------------------------------------------- driving ## Exactly Main._physics_process's numbers, so the probe measures the game's flippers and not ## some idealised version of them. func _motors(table: Table, held: bool) -> void: for i in table.flipper_joints.size(): var v: float = (26.0 if held else -9.0) * float(table.flip_side(i)) * _flip_sign table.flipper_joints[i].set_param(HingeJoint3D.PARAM_MOTOR_TARGET_VELOCITY, v) func _step(n: int) -> void: for i in n: _frame += 1 await physics_frame func _on_drained(b: RigidBody3D) -> void: _drain_frame = _frame _drained_ball = b func _limit_range(spec: Dictionary, id: String) -> float: for p in spec.get("parts", []): var pd: Dictionary = p if String(pd.get("id", "")) == id: return absf(float(pd.get("limit_hi", 0.0)) - float(pd.get("limit_lo", 0.0))) return 0.0 ## Follow one ball until the table resolves it, and say HOW it resolved: ## drained — the drain Area fired, which is the only ending Rules can actually see ## out — the ball left past the bottom edge but the Area never fired ## escaped — it left sideways, over a rail ## stall — the geometry stopped it and is holding it ## timeout — still rattling when the budget ran out func _chase_drain(table: Table, ball: RigidBody3D, w: float, l: float, limit: int) -> Dictionary: _drain_frame = -1 _drained_ball = null if ball == null: return {"code": "missing", "frames": -1, "at": Vector3.ZERO} var start := _frame var stall := 0 while _frame - start < limit: _frame += 1 await physics_frame if _drain_frame >= 0: return {"code": "drained", "frames": _drain_frame - start, "at": ball.position} if not is_instance_valid(ball): return {"code": "out", "frames": _frame - start, "at": Vector3.ZERO} var p := ball.position if absf(p.x) > w * 0.5 + 0.05: return {"code": "escaped", "frames": _frame - start, "at": p} if p.z > l * 0.5 + 0.06 or p.y < -0.05: return {"code": "out", "frames": _frame - start, "at": p} # A saucer legitimately holds the ball frozen for a second or so; only a ball the # table has stopped under its own power counts as stalled. if not ball.freeze and ball.linear_velocity.length() < STALL_V: stall += 1 if stall > STALL_FRAMES: return {"code": "stall", "frames": _frame - start, "at": p} else: stall = 0 return {"code": "timeout", "frames": limit, "at": ball.position if is_instance_valid(ball) else Vector3.ZERO} func _drain_str(d: Dictionary, budget: float) -> String: var code := String(d["code"]) var t := float(d["frames"]) / float(Engine.physics_ticks_per_second) match code: "drained": return "%.2f s" % t "out": return "%.2f s off the edge, drain never fired" % t "escaped": return "ESCAPED over a rail" "stall": return "STALLED on the playfield" "missing": return "NO BALL" return "NEVER (>%.0f s)" % budget func _residual(table: Table) -> Dictionary: var lin := 0.0 var ang := 0.0 var who := "-" for n in table.get_children(): var b := n as RigidBody3D if b == null or b.freeze or table.balls.has(b): continue var lv := b.linear_velocity.length() var av := b.angular_velocity.length() if lv > lin or av > ang: who = String(b.name) lin = maxf(lin, lv) ang = maxf(ang, av) return {"lin": lin, "ang": ang, "who": who} # ---------------------------------------------------------------- geometry ## Every collider of every registered part, reduced to a box that yaws about Y. ## ## That reduction is exact for this part vocabulary: parts yaw about Y and nothing else, ## except a ramp bed whose shapes also pitch about their local X — folded in below by ## widening the Y/Z half-extents to the pitched box's own bounds, which is tight, not sloppy. func _colliders(table: Table) -> Array: var out: Array = [] for p in table.spec.get("parts", []): var pd: Dictionary = p var pid := String(pd.get("id", "")) var n := table.part(pid) if n == null or not is_instance_valid(n): continue var host := n as Node3D # Only bodies are solid. A spinner/saucer/rollover is an Area3D the ball is MEANT to # pass through, so overlapping triggers are not a fault — they are still bounds-checked. var solid: bool = n is StaticBody3D or n is RigidBody3D for c in host.get_children(): var cs := c as CollisionShape3D if cs == null or cs.disabled or cs.shape == null: continue var h := Vector3.ZERO var round_xz := false var round_y := false if cs.shape is BoxShape3D: h = (cs.shape as BoxShape3D).size * 0.5 elif cs.shape is CylinderShape3D: var cy := cs.shape as CylinderShape3D h = Vector3(cy.radius, cy.height * 0.5, cy.radius) round_xz = true elif cs.shape is SphereShape3D: var r: float = (cs.shape as SphereShape3D).radius h = Vector3(r, r, r) round_xz = true round_y = true else: continue var pitch := cs.rotation.x if absf(pitch) > 0.0001: h = Vector3(h.x, absf(h.y * cos(pitch)) + absf(h.z * sin(pitch)), absf(h.y * sin(pitch)) + absf(h.z * cos(pitch))) # A round shape boxed up over-reports at the corners, so parts that are nested but # clear would false-positive; shrink to the inscribed square for the overlap test # only. Bounds and clearance still use the true extent. var hov := h if round_xz: hov = Vector3(h.x * 0.707, hov.y, h.z * 0.707) if round_y: hov.y = h.y * 0.707 out.append({"id": pid, "solid": solid, "c": cs.global_position, "h": h, "hov": hov, "yaw": host.global_rotation.y}) return out ## Two boxes that only yaw about Y keep their Y axes parallel, so the test splits into a 1D ## interval on Y and a 2D rotated-rectangle SAT in XZ — exact, and far cheaper than a general ## OBB test. Returns the smallest overlap over all separating axes: the depth of the wedge. func _pen(a: Dictionary, b: Dictionary) -> float: var ha: Vector3 = a["hov"] var hb: Vector3 = b["hov"] var ca: Vector3 = a["c"] var cb: Vector3 = b["c"] var best: float = (ha.y + hb.y) - absf(ca.y - cb.y) if best <= 0.0: return 0.0 var ya: float = a["yaw"] var yb: float = b["yaw"] var ax := [Vector2(cos(ya), -sin(ya)), Vector2(sin(ya), cos(ya)), Vector2(cos(yb), -sin(yb)), Vector2(sin(yb), cos(yb))] var d := Vector2(cb.x - ca.x, cb.z - ca.z) for u in ax: var ra: float = absf(ha.x * ax[0].dot(u)) + absf(ha.z * ax[1].dot(u)) var rb: float = absf(hb.x * ax[2].dot(u)) + absf(hb.z * ax[3].dot(u)) var o: float = ra + rb - absf(d.dot(u)) if o <= 0.0: return 0.0 best = minf(best, o) return best func _overlaps(boxes: Array) -> Array: var hits: Array = [] for i in boxes.size(): var a: Dictionary = boxes[i] if not a["solid"]: continue for j in range(i + 1, boxes.size()): var b: Dictionary = boxes[j] if not b["solid"] or a["id"] == b["id"]: continue var pen := _pen(a, b) if pen > OVERLAP_CLEAR: hits.append({"a": a["id"], "b": b["id"], "pen": pen, "at": ((a["c"] as Vector3) + (b["c"] as Vector3)) * 0.5}) hits.sort_custom(func(x, y) -> bool: return float(x["pen"]) > float(y["pen"])) return hits ## A part outside the playfield rectangle is unreachable at best and a ball trap at worst. ## A yawed box's footprint is its own extents projected onto X and Z. func _outside(boxes: Array, w: float, l: float) -> Array: var bad: Array = [] var seen: Dictionary = {} for e in boxes: var b: Dictionary = e var h: Vector3 = b["h"] var yaw: float = b["yaw"] var c: Vector3 = b["c"] var reach := Vector3(absf(c.x) + absf(h.x * cos(yaw)) + absf(h.z * sin(yaw)), c.y, absf(c.z) + absf(h.x * sin(yaw)) + absf(h.z * cos(yaw))) if reach.x > w * 0.5 + EDGE_SLOP or reach.z > l * 0.5 + EDGE_SLOP: if seen.has(b["id"]): continue seen[b["id"]] = true bad.append({"id": b["id"], "at": reach}) return bad ## The emptiest point in the upper playfield, as (x, z). Sweeps a grid and keeps the sample ## whose worst footprint clearance is largest, preferring higher up the table on a tie. ## Colliders that sit entirely above ball height are skipped — a ball passes under a raised ## ramp bed, it does not collide with its plan view. func _release_spot(boxes: Array, w: float, l: float, lane_x: float) -> Vector2: var rects: Array = [] for e in boxes: var b: Dictionary = e var h: Vector3 = b["h"] var c: Vector3 = b["c"] if c.y - h.y > Table.BALL_R * 2.0 + 0.01: continue var yaw: float = b["yaw"] rects.append({"c": Vector2(c.x, c.z), "e": Vector2(absf(h.x * cos(yaw)) + absf(h.z * sin(yaw)), absf(h.x * sin(yaw)) + absf(h.z * cos(yaw)))}) var best := Vector2(0.0, -l * 0.2) var best_clear := -99.0 for zi in 41: var z: float = -l * 0.36 + float(zi) / 40.0 * (l * 0.28) for xi in 33: var x: float = -w * 0.34 + float(xi) / 32.0 * (w * 0.68) if x > lane_x - 0.05: continue # never release into the shooter lane var clear := 99.0 for r in rects: var d: Vector2 = (Vector2(x, z) - (r["c"] as Vector2)).abs() - (r["e"] as Vector2) clear = minf(clear, maxf(d.x, d.y)) if clear > best_clear: best_clear = clear best = Vector2(x, z) return best