extends SceneTree ## Does the character actually hold each weapon DIFFERENTLY? ## ## godot --path . -s res://debug/weapon_hold_check.gd ## ## Every weapon used to be solved as a rifle: stock in the shoulder pocket, ## support hand out along the barrel, muzzle on the aim line. So a knife, an AWP ## and a rocket launcher produced the same pose, and the only thing telling a ## viewer what was being carried was the weapon mesh itself — which at the ## distance an enemy is usually seen is a few pixels. ## ## This asserts the consequence, not the plumbing. Setting `support_mode` and ## reading it back proves nothing; a hold system is easy to build so that the ## profile loads, the enum is stored and the JSON round-trips while the arms do ## not move. So it measures WHERE THE HANDS AND HEAD ACTUALLY ARE, per weapon, ## and requires the poses to be distinguishable from each other. ## ## ── Measured from inside the modifier pass ────────────────────────────────── ## ## Godot restores every bone's local pose after `SkeletonModifier3D` runs, so ## reading `get_bone_global_pose` from a SceneTree script recomputes the globals ## from the ANIMATION alone — the shooter hold is not in what you measure, and ## every weapon would report an identical pose whether or not this feature ## exists. That is the single most expensive trap in this repo; see ## `references/verification.md`. The `PoseProbe` below is the fix. ## ## ── Measured in the SHOULDER's frame ──────────────────────────────────────── ## ## Not in world space, and not even in skeleton space. The hold breathes — a ## `sin(_time * 2.2) * 0.012` on the muzzle pitch — and the idle clip moves the ## whole torso, so no hand is ever at the same place twice and two samples of the ## SAME weapon would differ by more than two different weapons do. Taking each ## hand relative to the right shoulder joint, in the chest's own basis, cancels ## both, because they move the shoulder and the hand together. const LAB := "res://debug/rig_lab.tscn" ## One weapon per style, plus the two rifles, so the table covers every branch ## and also shows that two weapons of the SAME style stay close together. const CASES := [ ["ak47", WeaponHoldProfiles.RIFLE], ["m4", WeaponHoldProfiles.RIFLE], ["mp7", WeaponHoldProfiles.SMG], ["awp", WeaponHoldProfiles.SNIPER], ["double_barrel_shotgun", WeaponHoldProfiles.SHOTGUN], ["rocket_launcher", WeaponHoldProfiles.LAUNCHER], ["knife", WeaponHoldProfiles.BLADE], ] ## Two weapons of DIFFERENT styles must place their hands at least this far ## apart, in metres, measured in the shoulder frame. Small — these are stylised ## characters with ~0.47 m arms — but far above the millimetre of noise the ## shoulder-frame measurement leaves behind. const MIN_STYLE_SEPARATION := 0.045 ## Two weapons of the SAME style should agree to within this. const MAX_SAME_STYLE := 0.06 var _fails := 0 var _probe: PoseProbe = null ## Snapshot the pose from INSIDE the modifier pass. See the note above. class PoseProbe extends SkeletonModifier3D: var pose: Array = [] func _process_modification() -> void: var skel := get_skeleton() if skel == null: return pose.resize(skel.get_bone_count()) for i in skel.get_bone_count(): pose[i] = skel.get_bone_global_pose(i) func _init() -> void: await process_frame var lab: Node = load(LAB).instantiate() root.add_child(lab) for _i in 200: await process_frame if lab._model == null or lab._model._pose_mod == null: _expect(false, "the lab built a character with a pose layer") _done() return var skel: Skeleton3D = lab._model.skeleton _probe = PoseProbe.new() skel.add_child(_probe) # AFTER the pose layer, so what it reads is what renders. skel.move_child(_probe, skel.get_child_count() - 1) var samples := {} for case in CASES: var weapon_id: String = case[0] var want_style: String = case[1] _expect(WeaponHoldProfiles.style_for(weapon_id) == want_style, "%s is held as a %s" % [weapon_id, want_style]) # Whether this character has a SAVED hold for this weapon, which is # allowed to disagree with the style profile. See `_compare`. var tuned: bool = not WeaponHoldTuning.resolve( WeaponHoldTuning.load_all(), lab._skins[lab._skin].id, weapon_id).is_empty() lab._model.hold_tune = {} lab._model.set_weapon("res://weapons/%s.gd" % weapon_id) # Long enough for HOLD_SMOOTH to arrive and for the weapon's `ready` to # have measured it. The hold blends in at ~8/s, so ~60 frames is several # time constants. for _i in 90: await process_frame samples[weapon_id] = _sample(skel, lab._model) if not samples[weapon_id].is_empty(): samples[weapon_id]["tuned"] = tuned _report(samples) _compare(samples) _done() ## Both hands and the head, relative to the right shoulder, in the chest's basis. func _sample(skel: Skeleton3D, model) -> Dictionary: var mod = model._pose_mod var sh_i: int = mod._idx.get("DEF-upper_arm.R", -1) var chest_i: int = mod._idx.get("DEF-spine.003", -1) if chest_i < 0: chest_i = mod._idx.get("DEF-spine.002", -1) var hand_r: int = mod._idx.get("DEF-hand.R", -1) var hand_l: int = mod._idx.get("DEF-hand.L", -1) var head_i: int = mod._idx.get("DEF-head", -1) if sh_i < 0 or hand_r < 0 or hand_l < 0: return {} var pose: Array = _probe.pose if pose.size() <= maxi(maxi(sh_i, hand_r), hand_l): return {} var shoulder: Vector3 = pose[sh_i].origin # The chest's rotation, so a torso lean does not read as a moved hand. var frame: Basis = Basis.IDENTITY if chest_i >= 0 and chest_i < pose.size(): frame = pose[chest_i].basis.orthonormalized() var inv := frame.inverse() var out := { "r": inv * (pose[hand_r].origin - shoulder), "l": inv * (pose[hand_l].origin - shoulder), "style": model.hold_style, "support": mod.support_mode, "hold_l": mod._hold_l, } if head_i >= 0 and head_i < pose.size(): # The head's TILT, which is what a cheek weld is. Taken as the angle # between the head's up axis and the chest's, signed about forward, so a # weld (toward the weapon) and a lean-away come out opposite. var head_up: Vector3 = pose[head_i].basis.orthonormalized().y var local := inv * head_up out["cheek_deg"] = rad_to_deg(atan2(local.x, local.y)) return out func _report(samples: Dictionary) -> void: print("\n=== HOLD POSE PER WEAPON (metres, in the shoulder's frame) ===") for id in samples: var s: Dictionary = samples[id] if s.is_empty(): continue print(" %-22s %-9s support=%-7s handR=(%.3f %.3f %.3f) handL=(%.3f %.3f %.3f) offhand=%.2f tilt=%+.1f deg" % [id, s["style"], s["support"], s["r"].x, s["r"].y, s["r"].z, s["l"].x, s["l"].y, s["l"].z, s["hold_l"], s.get("cheek_deg", 0.0)]) func _compare(samples: Dictionary) -> void: # Every pair of DIFFERENT styles must be distinguishable. var ids: Array = samples.keys() for i in ids.size(): for j in range(i + 1, ids.size()): var a: Dictionary = samples[ids[i]] var b: Dictionary = samples[ids[j]] if a.is_empty() or b.is_empty(): continue # The blade releases its off hand to the animation, so comparing its # left hand measures the idle clip, not the hold. Its trigger hand # and its free offhand weight are what distinguish it. var d: float = float(a["r"].distance_to(b["r"])) if a["hold_l"] > 0.5 and b["hold_l"] > 0.5: d = maxf(d, a["l"].distance_to(b["l"])) if a["style"] == b["style"]: # ...unless an artist has tuned one of them for THIS character. # The profile is only a defaults layer; a saved hold is meant to # be able to disagree with it, and aria's hand-tuned AK-47 sits # 0.22 m from the untuned M4 for exactly that reason. Asserting # they match would be asserting that the rig lab does nothing. if a["tuned"] or b["tuned"]: print(" -- %s and %s are both %s but %s is hand-tuned (%.3f m apart)" % [ids[i], ids[j], a["style"], ids[i] if a["tuned"] else ids[j], d]) continue _expect(d <= MAX_SAME_STYLE, "%s and %s are both %s and hold alike (%.3f m apart)" % [ids[i], ids[j], a["style"], d]) else: _expect(d >= MIN_STYLE_SEPARATION, "%s (%s) and %s (%s) are held differently (%.3f m apart)" % [ids[i], a["style"], ids[j], b["style"], d]) # The blade must actually let go of the off arm — that released arm is most # of what makes a one-handed weapon read as one-handed. if samples.has("knife"): _expect(samples["knife"]["hold_l"] < 0.05, "the knife releases the off arm to the animation (%.2f)" % samples["knife"]["hold_l"]) for id in ["ak47", "awp", "rocket_launcher"]: if samples.has(id): _expect(samples[id]["hold_l"] > 0.9, "%s keeps both hands on the weapon" % id) # The cheek weld, and its inverse on a shouldered tube. These are the two # poses that read at the greatest distance, so they get their own assertion # rather than relying on the pairwise distance. if samples.has("awp") and samples.has("ak47"): var d: float = float(samples["awp"].get("cheek_deg", 0.0)) - float(samples["ak47"].get("cheek_deg", 0.0)) _expect(absf(d) > 1.5, "the sniper welds its head to the stock (%+.1f deg vs the rifle)" % d) if samples.has("rocket_launcher") and samples.has("ak47"): var dl: float = float(samples["rocket_launcher"].get("cheek_deg", 0.0)) var dr: float = float(samples["ak47"].get("cheek_deg", 0.0)) var da: float = float(samples["awp"].get("cheek_deg", 0.0)) _expect((dl - dr) * (da - dr) < 0.0, "the launcher leans the head AWAY, opposite the sniper (%+.1f vs %+.1f)" % [dl - dr, da - dr]) func _expect(ok: bool, what: String) -> void: if ok: print(" OK: ", what) else: print(" FAIL: ", what) _fails += 1 func _done() -> void: print("\n=== WEAPON HOLD SUMMARY ===") print("Failures: %d" % _fails) quit(1 if _fails > 0 else 0)