extends SceneTree
## GATE: a STANDING player does not wobble. Was: does a STANDING player wobble, how much, at which layer, and does it
## depend on frame rate? (hyper-games roadmap #93: "when the player stands it
## wobbles often, much more on the lower end system" -- ashatron runs ~40 fps.)
##
## DISPLAY=:0 "$G" --path . --rendering-driver vulkan --script res://scenes/test/gate_stand_wobble.gd
##
## The full player scene stands still, no input, on FLAT ground and on a 20 degree
## SLOPE, at each frame-rate cap in CAPS. Sampled once per RENDERED frame on
## RenderingServer.frame_pre_draw -- after every _process and skeleton modifier,
## i.e. what is drawn. Per case, over WINDOW s after SETTLE s:
## body -- the CharacterBody3D origin: what physics does (60 Hz ticks)
## hip/head -- bone world positions RELATIVE TO THE BODY: what the animator adds
## jitter -- RMS of the frame-to-frame second difference (mm): motion that
## reverses direction from one frame to the next, i.e. shaking, not
## the slow idle sway (which has a near-zero second difference)
## p2p -- peak-to-peak (mm) over the window
## |tilt| -- the animator's acceleration-tilt magnitude (degrees), max
## floor -- how many frames is_on_floor() changed value
const CAPS := [25, 40, 60, 144]
const SLOPES := [0.0, 20.0]
## Variants isolate a contributor: "all" = shipping; "no_look_ik" = chest+head IK
## off (their look rotation feeds Phase 6's COM balance offset onto the root).
const VARIANTS := ["all"] # add "no_look_ik" to isolate the look IK
const SETTLE := 2.5
## PASS bounds, per case (shipping variant). RED before the fixes (same instrument,
## 2026-10-01): hip accel 2.84-16.45 m/s^2, p2p 66-196 mm, feet sliding with it --
## a 60x-stiff idle spring (k*frames, not k*dt), a 1 cm dead zone that popped the
## body at every zero crossing, and a sway read in metres instead of a fraction of
## the stance. GREEN after: 0.01-0.03 m/s^2, 8-28 mm, every cap 25-144.
const MAX_ACCEL := 0.5 # m/s^2 RMS -- smooth idle sway is ~0.03
const MAX_P2P_MM := 40.0 # Overgrowth's sway is ~+/-1.5 cm
## THE FEET STAY PLANTED while the body sways over them -- Overgrowth's DrawLeg
## solves each leg to its unchanged foot key. Before the leg solver the hip shift
## carried both feet with it (foot p2p == hip p2p, 9-28 mm).
const MAX_FOOT_MM := 3.0
var _fails := 0
const WINDOW := 4.0
var _player: CharacterBody3D = null
var _anim: Node = null
var _skel: Skeleton3D = null
var _floor: StaticBody3D = null
var _cases: Array = []
var _ci := -1
var _rec := false
var _t_end := 0
var _s: Dictionary = {}
var _ran := false
class StubGame extends Node3D:
var current_map: Node = null
func _process(_d: float) -> bool:
if not _ran:
_ran = true
_build()
return false
func _build() -> void:
get_multiplayer().multiplayer_peer = OfflineMultiplayerPeer.new()
var game := StubGame.new()
game.name = "Game"
root.add_child(game)
var cam := Camera3D.new()
game.add_child(cam)
cam.position = Vector3(0, 3, 6)
_floor = StaticBody3D.new()
var fc := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(400, 2, 400)
fc.shape = box
_floor.add_child(fc)
game.add_child(_floor)
var ps: PackedScene = load("res://scenes/player/player_procedural.tscn")
_player = ps.instantiate()
game.add_child(_player)
_anim = _find(_player, "ProceduralAnimator")
_skel = _find_skel(_player)
if _anim == null or _skel == null:
print("PROBE: no animator/skeleton"); quit(1); return
var only := OS.get_environment("WOBBLE_VARIANTS")
for v in VARIANTS:
if not only.is_empty() and not (v in only.split(",")):
continue
for sl in SLOPES:
for cap in CAPS:
_cases.append([sl, cap, v])
# skeleton_updated, NOT frame_pre_draw: the bone poses carry the modifiers'
# output only from here until the next modifier pass. Read anywhere else they
# are the un-modified pose -- the first run read a hip identical to 6 digits in
# every case, idle sway and all.
_skel.skeleton_updated.connect(_sample)
_next()
func _find(n: Node, nm: String) -> Node:
if n.name == nm:
return n
for c in n.get_children():
var r := _find(c, nm)
if r != null:
return r
return null
func _find_skel(n: Node) -> Skeleton3D:
if n is Skeleton3D:
return n
for c in n.get_children():
var r := _find_skel(c)
if r != null:
return r
return null
func _next() -> void:
_ci += 1
if _ci >= _cases.size():
print("RESULT: %s" % ("PASS" if _fails == 0 else "FAIL"))
quit(0 if _fails == 0 else 1)
return
var sl: float = _cases[_ci][0]
var cap: int = _cases[_ci][1]
Engine.max_fps = cap
var variant: String = _cases[_ci][2]
_anim.set("enable_chest_ik", variant != "no_look_ik")
_anim.set("enable_head_ik", variant != "no_look_ik")
# Slope: tilt the floor about X; the player stands on it near the origin.
_floor.rotation = Vector3(deg_to_rad(sl), 0, 0)
_floor.position = Vector3(0, -1.0 / cos(deg_to_rad(sl)), 0)
_player.global_position = Vector3(0, 0.3, 0)
_player.velocity = Vector3.ZERO
_s = {"body": [], "hip": [], "head": [], "footl": [], "tilt": 0.0, "floor_flips": 0,
"last_floor": -1, "vmax": 0.0, "frames": 0, "extra": [], "footr": []}
_rec = false
create_timer(SETTLE).timeout.connect(func() -> void:
# THE INSTRUMENT FIRST: all-zero output is indistinguishable from a probe
# reading a frozen copy, so say what it is looking at.
print("[WOBBLE] case slope %.0f cap %d: pos %s on_floor %s authority %s phys_proc %s anim_state %s hip %s skel_modifiers_active %s" % [
sl, cap, str(_player.global_position), str(_player.is_on_floor()),
str(_player.is_multiplayer_authority()), str(_player.is_physics_processing()),
str(_anim.get("_anim_state")), str(_bone("DEF-spine")), str((_anim as SkeletonModifier3D).active)])
_rec = true
_t_end = Time.get_ticks_msec() + int(WINDOW * 1000.0))
func _bone(nm: String) -> Vector3:
var i := _skel.find_bone(nm)
return (_skel.global_transform * _skel.get_bone_global_pose(i)).origin if i >= 0 else Vector3.ZERO
func _sample() -> void:
if not _rec or _ci >= _cases.size():
return
var body: Vector3 = _player.global_position
_s["body"].append(body)
_s["hip"].append(_bone("DEF-spine") - body)
_s["head"].append(_bone("DEF-spine.006") - body)
_s["footl"].append(_bone("DEF-foot.L") - body)
_s["footr"].append(_bone("DEF-foot.R") - body)
_s["tilt"] = maxf(float(_s["tilt"]), (_anim.get("_tilt") as Vector3).length())
var fl := 1 if _player.is_on_floor() else 0
if int(_s["last_floor"]) >= 0 and fl != int(_s["last_floor"]):
_s["floor_flips"] = int(_s["floor_flips"]) + 1
_s["last_floor"] = fl
_s["vmax"] = maxf(float(_s["vmax"]), _player.velocity.length())
_s["frames"] = int(_s["frames"]) + 1
var co: Vector3 = _anim.get("_com_offset")
_s["extra"].append([co.x, co.z, float(_anim.get("_idle_stance_amount")), int(_anim.get("_anim_state"))])
if Time.get_ticks_msec() >= _t_end:
_rec = false
_report()
_next.call_deferred()
## RMS of the second difference, mm: shaking, not drift.
func _jitter(a: Array) -> float:
if a.size() < 3:
return 0.0
var acc := 0.0
for i in range(1, a.size() - 1):
acc += ((a[i + 1] as Vector3) - 2.0 * (a[i] as Vector3) + (a[i - 1] as Vector3)).length_squared()
return sqrt(acc / float(a.size() - 2)) * 1000.0
func _p2p(a: Array) -> float:
if a.is_empty():
return 0.0
var lo: Vector3 = a[0]
var hi: Vector3 = a[0]
for v: Vector3 in a:
lo = lo.min(v)
hi = hi.max(v)
return (hi - lo).length() * 1000.0
func _report() -> void:
var dump := OS.get_environment("WOBBLE_DUMP")
if not dump.is_empty():
var f := FileAccess.open("%s/wobble_%s_s%d_c%d.csv" % [dump, _cases[_ci][2], int(_cases[_ci][0]), int(_cases[_ci][1])], FileAccess.WRITE)
f.store_line("i,hx,hy,hz,fx,fy,fz,com_x,com_z,idle,anim_state")
for i in (_s["hip"] as Array).size():
var h: Vector3 = _s["hip"][i]
var ft: Vector3 = _s["footl"][i]
var e: Array = _s["extra"][i]
f.store_line("%d,%.5f,%.5f,%.5f,%.5f,%.5f,%.5f,%.5f,%.5f,%.3f,%d" % [i, h.x, h.y, h.z, ft.x, ft.y, ft.z, e[0], e[1], e[2], e[3]])
var sl: float = _cases[_ci][0]
var cap: int = _cases[_ci][1]
# jitter is per FRAME, so it scales with dt^2 for the same motion; divided by
# dt^2 it is an acceleration (m/s^2) and comparable across frame-rate caps.
var dt: float = WINDOW / maxf(float(_s["frames"]), 1.0)
var acc := func(a: Array) -> float: return _jitter(a) / 1000.0 / (dt * dt)
if _cases[_ci][2] == "all":
var ha: float = acc.call(_s["hip"])
var hp: float = _p2p(_s["hip"])
if ha > MAX_ACCEL or hp > MAX_P2P_MM:
_fails += 1
print("[WOBBLE] FAIL: hip accel %.2f (max %.2f), p2p %.1f mm (max %.0f)" % [ha, MAX_ACCEL, hp, MAX_P2P_MM])
var fl: float = _p2p(_s["footl"])
var fr: float = _p2p(_s["footr"])
if fl > MAX_FOOT_MM or fr > MAX_FOOT_MM:
_fails += 1
print("[WOBBLE] FAIL: feet slide %.1f / %.1f mm (max %.0f)" % [fl, fr, MAX_FOOT_MM])
print("[WOBBLE] leg solves out of reach so far: %d (want 0)" % int(load("res://scenes/player/procedural/chain_solver.gd").get("clamped")))
print("[WOBBLE] %-10s slope %2.0f cap %3d (%3d frames, %5.1f fps) | body p2p %5.1f mm | hip p2p %6.1f mm accel %5.2f m/s2 | head p2p %6.1f accel %5.2f | foot.L p2p %6.1f mm accel %5.2f | |tilt| max %5.2f | floor flips %d | |v| max %.3f" % [
_cases[_ci][2], sl, cap, _s["frames"], _s["frames"] / WINDOW,
_p2p(_s["body"]),
_p2p(_s["hip"]), acc.call(_s["hip"]),
_p2p(_s["head"]), acc.call(_s["head"]),
_p2p(_s["footl"]), acc.call(_s["footl"]), _s["tilt"], _s["floor_flips"], _s["vmax"]])