Files
i-want-to-heal-mmo/scripts/blender/validate_yugioh_animations.py
T
2026-07-11 23:23:02 -04:00

241 lines
9.9 KiB
Python

"""Re-import and validate every generated creature animation GLB."""
from __future__ import annotations
import json
from pathlib import Path
import re
import struct
import bpy
from mathutils import Vector
ROOT = Path(__file__).resolve().parents[2]
MODEL_ROOT = ROOT / "game_assets/models/downloaded/yugioh"
EXPECTED_CLIPS = {"Idle", "Move", "Attack", "HitReact", "Death"}
def read_glb_json(path: Path) -> dict[str, object]:
data = path.read_bytes()
magic, version, total_length = struct.unpack_from("<4sII", data, 0)
if magic != b"glTF" or version != 2 or total_length != len(data):
raise RuntimeError(f"{path}: invalid GLB header")
chunk_length, chunk_type = struct.unpack_from("<II", data, 12)
if chunk_type != 0x4E4F534A:
raise RuntimeError(f"{path}: JSON is not the first GLB chunk")
return json.loads(data[20:20 + chunk_length].decode("utf-8"))
def read_glb_binary(path: Path) -> bytes:
data = path.read_bytes()
json_length, json_type = struct.unpack_from("<II", data, 12)
if json_type != 0x4E4F534A:
raise RuntimeError(f"{path}: JSON is not the first GLB chunk")
binary_header = 20 + json_length
binary_length, binary_type = struct.unpack_from("<II", data, binary_header)
if binary_type != 0x004E4942:
raise RuntimeError(f"{path}: binary data is not the second GLB chunk")
binary_start = binary_header + 8
return data[binary_start:binary_start + binary_length]
def float_accessor(document: dict[str, object], binary: bytes, index: int) -> list[tuple[float, ...]]:
accessor = document["accessors"][index]
if accessor["componentType"] != 5126:
raise RuntimeError(f"Accessor {index}: expected float data")
component_counts = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4}
component_count = component_counts[accessor["type"]]
view = document["bufferViews"][accessor["bufferView"]]
element_size = component_count * 4
stride = view.get("byteStride", element_size)
start = view.get("byteOffset", 0) + accessor.get("byteOffset", 0)
return [
struct.unpack_from(f"<{component_count}f", binary, start + item * stride)
for item in range(accessor["count"])
]
def values_vary(values: list[tuple[float, ...]], tolerance: float = 1e-5) -> bool:
first = values[0]
return any(
abs(component - first[index]) > tolerance
for value in values[1:]
for index, component in enumerate(value)
)
def validate_pumpking_tentacles(path: Path) -> int:
document = read_glb_json(path)
nodes = document.get("nodes", [])
animations = document.get("animations", [])
tentacle_nodes = {
index for index, node in enumerate(nodes)
if re.fullmatch(r"[LR]_arm\d+_\d+", node.get("name", ""), re.IGNORECASE)
}
if not tentacle_nodes:
raise RuntimeError(f"{path}: no Pumpking tentacle nodes")
for animation in animations:
targeted = {channel["target"]["node"] for channel in animation.get("channels", [])}
missing = tentacle_nodes - targeted
if missing:
missing_names = [nodes[index].get("name", str(index)) for index in sorted(missing)]
raise RuntimeError(f"{path}: {animation.get('name')} misses tentacles {missing_names}")
return len(tentacle_nodes)
def validate_ultimate_dragon(path: Path) -> dict[str, int]:
document = read_glb_json(path)
binary = read_glb_binary(path)
nodes = document.get("nodes", [])
animations = document.get("animations", [])
head_chains = (
{f"BEUD_LNeck{index}" for index in range(1, 7)} | {"BEUD_LHead"},
{f"BEUD_Neck{index}" for index in range(1, 7)} | {"BEUD_Head"},
{f"BEUD_RNeck{index}" for index in range(1, 7)} | {"BEUD_RHead"},
)
animated_wings = {
f"BEUD_{side}Wing{index}"
for side in ("L", "R")
for index in range(1, 4)
}
static_wings = {
f"BEUD_{side}Wing{index}"
for side in ("L", "R")
for index in range(4, 9)
}
head_tips = {"BEUD_LHead", "BEUD_Head", "BEUD_RHead"}
for animation in animations:
variation = {}
rotation_values = {}
for channel in animation.get("channels", []):
if channel["target"].get("path") != "rotation":
continue
name = nodes[channel["target"]["node"]].get("name", "")
accessor_index = animation["samplers"][channel["sampler"]]["output"]
values = float_accessor(document, binary, accessor_index)
rotation_values[name] = values
variation[name] = values_vary(values)
for chain in head_chains:
static = {name for name in chain if not variation.get(name, False)}
if static:
raise RuntimeError(f"{path}: {animation.get('name')} has static head-chain bones {sorted(static)}")
static_roots = {name for name in animated_wings if not variation.get(name, False)}
moving_tips = {name for name in static_wings if variation.get(name, False)}
if static_roots or moving_tips:
raise RuntimeError(
f"{path}: {animation.get('name')} static wing roots {sorted(static_roots)}, "
f"moving wing tips {sorted(moving_tips)}"
)
if animation.get("name") in {"Idle", "Move", "Attack"}:
peak_frames = {}
for name in head_tips:
values = rotation_values[name]
start = values[0]
distances = [1 - min(1.0, abs(sum(component * start[index] for index, component in enumerate(value)))) for value in values]
peak_frames[name] = distances.index(max(distances))
if len(set(peak_frames.values())) != len(head_tips):
raise RuntimeError(f"{path}: {animation.get('name')} head cadences overlap at {peak_frames}")
return {
"headChainBonesPerClip": sum(len(chain) for chain in head_chains),
"wingBonesPerClip": len(animated_wings),
"staggeredHeadClips": 3,
}
def reset_scene() -> None:
if bpy.context.object and bpy.context.object.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
for obj in list(bpy.data.objects):
bpy.data.objects.remove(obj, do_unlink=True)
for action in list(bpy.data.actions):
bpy.data.actions.remove(action)
for image in list(bpy.data.images):
bpy.data.images.remove(image)
def validate_red_eyes_motion(rig: bpy.types.Object) -> dict[str, float]:
"""Regression-check the paired flap and bite silhouette in the shipped GLB."""
rig.animation_data.action = bpy.data.actions["Attack"]
def sample(frame: int) -> dict[str, Vector]:
bpy.context.scene.frame_set(frame)
bpy.context.view_layer.update()
return {
"wing_l": rig.pose.bones["bone0079"].tail.copy(),
"wing_r": rig.pose.bones["bone0075"].tail.copy(),
"head": rig.pose.bones["bone0073"].tail.copy(),
}
windup = sample(8)
downstroke = sample(14)
impact = sample(17)
left_flap = windup["wing_l"].z - downstroke["wing_l"].z
right_flap = windup["wing_r"].z - downstroke["wing_r"].z
head_drop = windup["head"].z - impact["head"].z
head_lunge = windup["head"].y - impact["head"].y
if left_flap < 0.2 or right_flap < 0.2:
raise RuntimeError(f"Red-Eyes wings do not complete a paired flap: {left_flap=:.4f}, {right_flap=:.4f}")
if head_drop < 0.01 or head_lunge < 0.05:
raise RuntimeError(f"Red-Eyes bite lacks head lift/lunge: {head_drop=:.4f}, {head_lunge=:.4f}")
return {
"leftWingDrop": round(left_flap, 4),
"rightWingDrop": round(right_flap, 4),
"headDrop": round(head_drop, 4),
"headLunge": round(head_lunge, 4),
}
def validate(path: Path) -> dict[str, object]:
reset_scene()
bpy.context.scene.render.fps = 30
bpy.ops.import_scene.gltf(filepath=str(path))
armatures = [obj for obj in bpy.context.scene.objects if obj.type == "ARMATURE"]
meshes = [
obj for obj in bpy.context.scene.objects
if obj.type == "MESH" and any(
modifier.type == "ARMATURE" and modifier.object in armatures for modifier in obj.modifiers
)
]
clips = {action.name: tuple(round(value, 3) for value in action.frame_range) for action in bpy.data.actions}
if len(armatures) != 1:
raise RuntimeError(f"{path}: expected one armature, found {len(armatures)}")
if not meshes:
raise RuntimeError(f"{path}: no render meshes")
if set(clips) != EXPECTED_CLIPS:
raise RuntimeError(f"{path}: clips {sorted(clips)} != {sorted(EXPECTED_CLIPS)}")
if any(end <= start for start, end in clips.values()):
raise RuntimeError(f"{path}: empty animation range in {clips}")
if not bpy.data.images:
raise RuntimeError(f"{path}: no packed texture images")
corners = [mesh.matrix_world @ Vector(corner) for mesh in meshes for corner in mesh.bound_box]
dimensions = [round(max(point[index] for point in corners) - min(point[index] for point in corners), 4) for index in range(3)]
report = {
"asset": path.parent.name,
"sizeBytes": path.stat().st_size,
"bones": len(armatures[0].data.bones),
"meshes": len(meshes),
"images": len(bpy.data.images),
"dimensions": dimensions,
"clips": clips,
}
if path.parent.name == "pumpking-the-king-of-ghosts":
report["tentacleBonesPerClip"] = validate_pumpking_tentacles(path)
if path.parent.name == "blue-eyes-ultimate-dragon":
report.update(validate_ultimate_dragon(path))
if path.parent.name == "red-eyes-black-dragon":
report["motion"] = validate_red_eyes_motion(armatures[0])
return report
def main() -> None:
paths = sorted(MODEL_ROOT.glob("*/*-animated.glb"))
if len(paths) != 7:
raise RuntimeError(f"Expected 7 generated GLBs, found {len(paths)}")
report = [validate(path) for path in paths]
print("YUGIOH_ANIMATION_VALIDATION=" + json.dumps(report, indent=2))
if __name__ == "__main__":
main()