Volver al índice

docs/validate-parasite-preview.py

"""Audit the Java rig independently of the renderer using transformed box vertices.

Run after render-parasite-preview.py has compiled its exact Java source. No game,
image library, duplicated gait equations or network/model calls are required.
"""
from __future__ import annotations

import argparse
import hashlib
import json
import math
import os
import subprocess
from pathlib import Path


# INPUTS: consume the same compiled exporter; validation never modifies Java.
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "src/main/java/ar/com/companeros/horror/client/ParasiteRig.java"
DEFAULT_CLASSES = ROOT / "build/parasite-preview-classes"
DEFAULT_JAVA = Path("C:/Program Files/Eclipse Adoptium/jdk-17.0.20.8-hotspot/bin/java.exe")
CLASS_NAME = "ar.com.companeros.horror.client.ParasiteRig"
TOLERANCE = 0.002  # Model units: 0.000125 of a Minecraft block.
FPS = 12
SEQUENCES = {"chase": 4, "walk": 4, "compact": 3, "crawl2": 3,
             "crawl3": 3, "crawl4": 3, "chase4": 3, "drag": 3,
             "lore": 9, "stalk": 8, "fold": 4, "turn": 3, "climb": 3, "attack": 3}


def require(condition: bool, message: str) -> None:
    if not condition:
        raise AssertionError(message)


def samples(java: Path, classes: Path, mode: str, stage: int, duration: float = 0) -> list[dict]:
    count = max(1, round(duration * FPS))
    completed = subprocess.run(
        [str(java), "-cp", str(classes), CLASS_NAME, mode, str(stage), "0", str(count), str(FPS)],
        check=True, capture_output=True, text=True, encoding="utf-8", timeout=240,
    )
    result = json.loads(completed.stdout)
    result = result if isinstance(result, list) else [result]
    require(len(result) == count, f"{mode}: exporter returned {len(result)}/{count} frames")
    return result


# TRANSFORMS: generic ModelPart translation, Z/Y/X rotations and scale only.
# These calculations do not import or reuse the preview renderer's math.
def local_matrix(position: list, rotation: list, scale: list) -> list[list[float]]:
    sx, sy, sz = scale
    cx, cy, cz = (math.cos(value) for value in rotation)
    ax, ay, az = (math.sin(value) for value in rotation)
    return [
        [cz * cy * sx, (cz * ay * ax - az * cx) * sy, (cz * ay * cx + az * ax) * sz, position[0]],
        [az * cy * sx, (az * ay * ax + cz * cx) * sy, (az * ay * cx - cz * ax) * sz, position[1]],
        [-ay * sx, cy * ax * sy, cy * cx * sz, position[2]],
        [0.0, 0.0, 0.0, 1.0],
    ]


def multiply(left: list[list], right: list[list]) -> list[list[float]]:
    return [[sum(left[row][i] * right[i][column] for i in range(4)) for column in range(4)] for row in range(4)]


def point(matrix: list[list], x: float, y: float, z: float) -> list[float]:
    return [matrix[row][0] * x + matrix[row][1] * y + matrix[row][2] * z + matrix[row][3] for row in range(3)]


def transformed_mesh(sample: dict) -> tuple[list[float], dict[str, list[list]], dict[str, bool]]:
    bones = {bone["name"]: bone for bone in sample["bones"]}
    matrices: dict[str, list[list]] = {}
    visible: dict[str, bool] = {}
    resolving: set[str] = set()

    def resolve(name: str) -> list[list]:
        if name in matrices:
            return matrices[name]
        require(name not in resolving, f"Bone cycle: {name}")
        resolving.add(name)
        bone, pose = bones[name], sample["pose"][name]
        values = pose["position"] + pose["rotation"] + pose["scale"]
        require(all(math.isfinite(value) for value in values), f"Non-finite transform: {name}")
        own = local_matrix(pose["position"], pose["rotation"], pose["scale"])
        parent = bone["parent"]
        if parent:
            require(parent in bones, f"Missing parent {parent} of {name}")
            matrices[name] = multiply(resolve(parent), own)
            visible[name] = pose["visible"] and visible[parent]
        else:
            matrices[name] = own
            visible[name] = pose["visible"]
        resolving.remove(name)
        return matrices[name]

    vertices = []
    for name, bone in bones.items():
        world = resolve(name)
        if not visible[name]:
            continue
        for box in bone["boxes"]:
            x, y, z, width, height, depth = box["bounds"]
            require(all(math.isfinite(value) for value in box["bounds"]), f"Non-finite box: {name}")
            require(min(width, height, depth) > 0, f"Collapsed box: {name}")
            for corner in range(8):
                vertices.append(point(world, x + (width if corner & 1 else 0),
                                      y + (height if corner & 2 else 0), z + (depth if corner & 4 else 0)))
    require(bool(vertices), "Empty visible mesh")
    bounds = [min(vertex[axis] for vertex in vertices) for axis in range(3)]
    bounds += [max(vertex[axis] for vertex in vertices) for axis in range(3)]
    return bounds, matrices, visible


def validate_frame(sample: dict, label: str, reference_floor: bool = True) -> tuple[list[float], dict[str, list[list]]]:
    bounds, matrices, _ = transformed_mesh(sample)
    require(max(abs(actual - advertised) for actual, advertised in zip(bounds, sample["bounds"])) < TOLERANCE,
            f"{label}: transformed vertices differ from exported bounds")
    dimensions = [(bounds[axis + 3] - bounds[axis]) / 16 for axis in range(3)]
    require(max(abs(actual - advertised) for actual, advertised in zip(dimensions, sample["dimensions_blocks"])) < TOLERANCE / 16,
            f"{label}: exported dimensions differ from transformed vertices")
    if reference_floor:
        require(abs(bounds[4] - 24) < TOLERANCE, f"{label}: geometry crosses/leaves reference floor")
    for foot in ("leg_l_hand", "leg_r_hand"):
        actual = point(matrices[foot], 0, 2, 0)
        require(max(abs(value - sample["feet"][foot][i]) for i, value in enumerate(actual)) < TOLERANCE,
                f"{label}: exported {foot} differs from independently transformed foot")
    return dimensions, matrices


# ESQUELETO: una postura dobla articulaciones; no achica todo el cuerpo.
# El crecimiento normal se compara dentro de cada etapa. La mandíbula y el
# grosor respiratorio del pecho quedan fuera de esta comprobación de longitudes.
SKELETON_NODES = ("body", "world", "root", "spine_lower", "spine_mid", "spine_upper",
                  "neck0", "neck1", "neck2", "arm_l", "arm_r", "leg_l", "leg_r",
                  "arm_l_joint", "arm_r_joint", "leg_l_joint", "leg_r_joint")


def axis_length(matrix: list[list], axis: int) -> float:
    return math.sqrt(sum(matrix[row][axis] ** 2 for row in range(3)))


def head_tracking(matrices: dict[str, list[list]], yaw: float = 0, pitch: float = 0) -> dict:
    """La normal frontal debe mirar al testigo aunque el cráneo esté invertido.

    Se mide en mundo después de todos los padres. No se recrean las curvas
    temporales de giro del Java: el objetivo es el yaw/pitch de entrada del CLI.
    """
    def direction(name: str, axis: int, sign: float) -> list[float]:
        length = axis_length(matrices[name], axis)
        require(length > 0, f"{name}: collapsed orientation axis")
        return [sign * matrices[name][row][axis] / length for row in range(3)]

    yaw_r, pitch_r = math.radians(yaw), math.radians(pitch)
    target = [-math.sin(yaw_r) * math.cos(pitch_r), math.sin(pitch_r),
              -math.cos(yaw_r) * math.cos(pitch_r)]
    forward = direction("head", 2, -1)
    cosine = max(-1.0, min(1.0, sum(a * b for a, b in zip(forward, target))))
    up = direction("head", 1, -1)
    torso_up = direction("spine_upper", 1, -1)
    eye = point(matrices["head"], 0, -5.5, -4)
    mouth = point(matrices["jaw"], 0, 1, -4)
    return {"forward_world": forward, "target_world": target,
            "error_degrees": math.degrees(math.acos(cosine)),
            "head_up_world_up_dot": -up[1], "torso_up_world_up_dot": -torso_up[1],
            "mouth_above_eyes": mouth[1] < eye[1]}


def limb_lengths(sample: dict, matrices: dict[str, list[list]], label: str) -> dict[str, float]:
    values = {}
    bones = {bone["name"]: bone for bone in sample["bones"]}
    values["neck2_to_head_joint"] = math.dist(point(matrices["neck2"], 0, 0, 0), point(matrices["head"], 0, 0, 0))
    for name in SKELETON_NODES:
        if name not in sample["pose"]: continue
        scales = sample["pose"][name]["scale"]
        require(all(abs(value - 1) < TOLERANCE for value in scales),
                f"{label}: global/skeleton compression on {name}: scale={scales}")
        require(all(abs(axis_length(matrices[name], axis) - 1) < TOLERANCE for axis in range(3)),
                f"{label}: inherited world compression on {name}")
    for limb in ("arm_l", "arm_r", "leg_l", "leg_r"):
        positions = [point(matrices[name], 0, 0, 0) for name in (limb, limb + "_joint", limb + "_hand")]
        values[limb + "_upper_joint"] = math.dist(positions[0], positions[1])
        values[limb + "_lower_joint"] = math.dist(positions[1], positions[2])
        for segment in ("upper", "lower"):
            name = limb + "_" + segment
            # Medir su eje físico transformado, no el bounding box que cambia al rotar.
            for index, box in enumerate(bones[name]["boxes"]):
                values[f"{name}_mesh_{index}"] = box["bounds"][4] * axis_length(matrices[name], 1)
    for name, bone in bones.items():
        structural_mesh = name in ("spine_lower_mesh", "spine_mid_mesh", "chest", "neck0_mesh", "neck1_mesh", "neck2_mesh")
        finger_mesh = name.startswith("arm_") and ("_finger" in name or "_thumb" in name)
        if not structural_mesh and not finger_mesh: continue
        for index, box in enumerate(bone["boxes"]):
            # Comprobar longitud de columna, cuello y dedos sin bloquear la
            # respiración del pecho, que sólo modifica su profundidad.
            values[f"{name}_mesh_{index}"] = box["bounds"][4] * axis_length(matrices[name], 1)
    require(all(math.isfinite(value) and value > 0 for value in values.values()), f"{label}: collapsed physical limb")
    return values


def preserve_lengths(current: dict[str, float], baseline: dict[str, float], label: str) -> float:
    require(current.keys() == baseline.keys(), f"{label}: limb topology changed within the stage")
    maximum = 0.0
    for name, expected in baseline.items():
        error = abs(current[name] - expected)
        require(error < TOLERANCE,
                f"{label}: {name} changed physical length {expected:.5f}→{current[name]:.5f} model units")
        maximum = max(maximum, error)
    return maximum / 16


def limb_contact(sample: dict, matrices: dict[str, list[list]]) -> float:
    """Contacto de extremidades, no del abdomen ni de una esquina del torso."""
    supports = []
    for bone in sample["bones"]:
        name = bone["name"]
        if not (name.endswith("_hand") or "_finger" in name): continue
        if not sample["pose"][name].get("visible", True): continue
        for box in bone["boxes"]:
            x, y, z, width, height, depth = box["bounds"]
            for corner in range(8):
                supports.append(point(matrices[name], x + (width if corner & 1 else 0),
                                      y + (height if corner & 2 else 0), z + (depth if corner & 4 else 0))[1])
    require(bool(supports), "No terminal limb geometry")
    return abs(24 - max(supports)) / 16


def individual_hand_contacts(sample: dict, matrices: dict[str, list[list]]) -> dict[str, float]:
    """Audit each terminal hand separately; one grounded hand cannot hide the other."""
    contacts = {}
    for arm in ("arm_l", "arm_r"):
        heights = []
        for bone in sample["bones"]:
            name = bone["name"]
            if not name.startswith(arm) or not (name.endswith("_hand") or "_finger" in name):
                continue
            if not sample["pose"][name].get("visible", True):
                continue
            for box in bone["boxes"]:
                x, y, z, width, height, depth = box["bounds"]
                for corner in range(8):
                    heights.append(point(matrices[name], x + (width if corner & 1 else 0),
                                         y + (height if corner & 2 else 0),
                                         z + (depth if corner & 4 else 0))[1])
        require(bool(heights), f"No visible terminal hand geometry: {arm}")
        contacts[arm] = abs(24 - max(heights)) / 16
    return contacts


def validate_hand_uv(sample: dict) -> dict:
    """The cube net must remain inside its host-skin arm rectangle.

    Test coordinates, never skin colour: a player's sleeves/tattoos remain valid.
    Fingernail tips deliberately use the mod's own detail atlas, outside host UVs.
    """
    regions = {"arm_l_hand": (32, 48, 48, 64), "arm_r_hand": (40, 16, 56, 32)}
    bones = {bone["name"]: bone for bone in sample["bones"]}
    results = {}
    for name, (left, top, right, bottom) in regions.items():
        require(name in bones and bones[name]["boxes"], f"Missing palm UV geometry: {name}")
        rectangles = []
        for box in bones[name]["boxes"]:
            u, v = box["uv"]
            _, _, _, width, height, depth = box["bounds"]
            uv_right, uv_bottom = u + 2 * (width + depth), v + depth + height
            require(u >= left and v >= top and uv_right <= right and uv_bottom < bottom,
                    f"{name}: cube UV ({u},{v})→({uv_right},{uv_bottom}) leaves host arm region {regions[name]}")
            rectangles.append([u, v, uv_right, uv_bottom])
        results[name] = {"host_arm_region": list(regions[name]), "cube_uv_rectangles": rectangles}
    return results


# SELF-CONTACT: test the real oriented skin boxes, rather than overlapping world AABBs.
# A bent knee/elbow may overlap at its hinge; its two entire segments may not occupy one volume.
def dot(left: list[float], right: list[float]) -> float:
    return sum(a * b for a, b in zip(left, right))


def subtract(left: list[float], right: list[float]) -> list[float]:
    return [a - b for a, b in zip(left, right)]


def cross(left: list[float], right: list[float]) -> list[float]:
    return [left[1] * right[2] - left[2] * right[1],
            left[2] * right[0] - left[0] * right[2],
            left[0] * right[1] - left[1] * right[0]]


def collision_boxes(sample: dict, matrices: dict[str, list[list]], visible: dict[str, bool]) -> list[dict]:
    names = {"head", "jaw", "chest", "spine_lower_mesh", "spine_mid_mesh"}
    for limb in ("arm_l", "arm_r", "leg_l", "leg_r"):
        names.update(limb + suffix for suffix in ("_upper", "_lower", "_hand"))
    boxes = []
    for bone in sample["bones"]:
        name = bone["name"]
        if name not in names or not visible[name]: continue
        matrix = matrices[name]
        axes = [[matrix[row][axis] / axis_length(matrix, axis) for row in range(3)] for axis in range(3)]
        for index, box in enumerate(bone["boxes"]):
            x, y, z, width, height, depth = box["bounds"]
            center = point(matrix, x + width / 2, y + height / 2, z + depth / 2)
            extents = [size * axis_length(matrix, axis) / 2 for axis, size in enumerate((width, height, depth))]
            vertices = [point(matrix, x + (width if corner & 1 else 0),
                              y + (height if corner & 2 else 0), z + (depth if corner & 4 else 0)) for corner in range(8)]
            boxes.append({"name": name, "index": index, "center": center, "axes": axes,
                          "extents": extents, "vertices": vertices})
    return boxes


def penetration(left: dict, right: dict) -> float:
    """Separating-axis theorem: fifteen box axes, including cross products."""
    axes = left["axes"] + right["axes"] + [cross(a, b) for a in left["axes"] for b in right["axes"]]
    displacement = subtract(right["center"], left["center"])
    minimum = math.inf
    for axis in axes:
        length = math.sqrt(dot(axis, axis))
        if length < 1e-10: continue
        axis = [value / length for value in axis]
        radii = [sum(extent * abs(dot(direction, axis)) for extent, direction in zip(box["extents"], box["axes"]))
                 for box in (left, right)]
        overlap = sum(radii) - abs(dot(displacement, axis))
        if overlap <= TOLERANCE: return 0
        minimum = min(minimum, overlap)
    return minimum


def intersection_vertices(left: dict, right: dict) -> list[list[float]]:
    """Vertices of the shared volume: clip each box edge against the other box."""
    result = []
    for first, second in ((left, right), (right, left)):
        for corner in range(8):
            for bit in (1, 2, 4):
                other = corner ^ bit
                if corner > other: continue
                start, end = first["vertices"][corner], first["vertices"][other]
                displacement = subtract(start, second["center"])
                direction = subtract(end, start)
                low, high = 0.0, 1.0
                for axis, extent in zip(second["axes"], second["extents"]):
                    origin, travel = dot(displacement, axis), dot(direction, axis)
                    if abs(travel) < 1e-10:
                        if abs(origin) > extent + TOLERANCE: low, high = 1, 0; break
                    else:
                        enter, leave = sorted(((-extent - origin) / travel, (extent - origin) / travel))
                        low, high = max(low, enter), min(high, leave)
                if low <= high:
                    result.extend([[a + t * b for a, b in zip(start, direction)] for t in (low, high)])
    return result


def adjacent_joint(left: dict, right: dict, matrices: dict[str, list[list]]) -> tuple[list[float], float] | None:
    pair = {left["name"], right["name"]}
    torso_joint = {frozenset(("spine_lower_mesh", "spine_mid_mesh")): "spine_mid",
                   frozenset(("spine_mid_mesh", "chest")): "spine_upper"}.get(frozenset(pair))
    if torso_joint:
        radius = max(2 * math.hypot(box["extents"][0], box["extents"][2]) for box in (left, right))
        return point(matrices[torso_joint], 0, 0, 0), radius
    for limb in ("arm_l", "arm_r", "leg_l", "leg_r"):
        joint = limb + "_joint" if pair == {limb + "_upper", limb + "_lower"} else (
            limb + "_hand" if pair == {limb + "_lower", limb + "_hand"} else None)
        if pair == {limb + "_upper", "chest" if limb.startswith("arm_") else "spine_lower_mesh"}:
            joint = limb
        if joint:
            # A local skin-sized joint allowance, derived from the physical cross section.
            radius = max(2 * math.hypot(box["extents"][0], box["extents"][2]) for box in (left, right))
            return point(matrices[joint], 0, 0, 0), radius
    return None


def non_articular_pair(left: dict, right: dict) -> bool:
    pair = {left["name"], right["name"]}
    # The animated jaw is intentionally part of the same deforming facial surface.
    if pair == {"head", "jaw"}: return False
    return True


def self_intersections(sample: dict, matrices: dict[str, list[list]], visible: dict[str, bool]) -> list[dict]:
    boxes = collision_boxes(sample, matrices, visible)
    problems = []
    for index, left in enumerate(boxes):
        for right in boxes[index + 1:]:
            if left["name"] == right["name"] or not non_articular_pair(left, right): continue
            depth = penetration(left, right)
            if not depth: continue
            joint = adjacent_joint(left, right, matrices)
            if joint:
                vertices = intersection_vertices(left, right)
                if vertices and all(math.dist(vertex, joint[0]) <= joint[1] + TOLERANCE for vertex in vertices): continue
            problems.append({"left": left["name"], "right": right["name"], "penetration_blocks": depth / 16})
    return problems


def coplanar_surface_overlap(left: dict, right: dict) -> bool:
    """Same outward-facing plane and overlapping rectangles cause a real depth tie.

    Opposite normals are a closed internal seam, not two competing skin surfaces.
    Use the audit's numerical coordinate tolerance; never relax it per animation.
    """
    for left_axis in range(3):
        for right_axis in range(3):
            for left_sign in (-1, 1):
                normal = [left_sign * value for value in left["axes"][left_axis]]
                for right_sign in (-1, 1):
                    other_normal = [right_sign * value for value in right["axes"][right_axis]]
                    if dot(normal, other_normal) < 1 - 1e-10: continue
                    first = [a + b * left["extents"][left_axis] for a, b in zip(left["center"], normal)]
                    second = [a + b * right["extents"][right_axis] for a, b in zip(right["center"], other_normal)]
                    displacement = subtract(second, first)
                    if abs(dot(displacement, normal)) > TOLERANCE: continue
                    directions = [left["axes"][i] for i in range(3) if i != left_axis]
                    directions += [right["axes"][i] for i in range(3) if i != right_axis]
                    for axis in directions:
                        radii = [sum(extent * abs(dot(direction, axis))
                                     for i, (extent, direction) in enumerate(zip(box["extents"], box["axes"]))
                                     if i != face_axis)
                                 for box, face_axis in ((left, left_axis), (right, right_axis))]
                        if sum(radii) - abs(dot(displacement, axis)) <= TOLERANCE: break
                    else:
                        return True
    return False


def coplanar_surfaces(sample: dict, matrices: dict[str, list[list]], visible: dict[str, bool]) -> list[dict]:
    boxes = collision_boxes(sample, matrices, visible)
    problems = []
    for index, left in enumerate(boxes):
        for right in boxes[index + 1:]:
            if left["name"] == right["name"] or not non_articular_pair(left, right): continue
            if not coplanar_surface_overlap(left, right): continue
            joint = adjacent_joint(left, right, matrices)
            if joint:
                vertices = intersection_vertices(left, right)
                if vertices and all(math.dist(vertex, joint[0]) <= joint[1] + TOLERANCE for vertex in vertices): continue
            problems.append({"left": left["name"], "right": right["name"]})
    return problems


def validate_encounter_directions(java: Path, classes: Path) -> dict:
    """Moderate human gaze can move a safe frontal head pose into the chest."""
    harness = ROOT / "build/head-contact-audit"
    harness.mkdir(parents=True, exist_ok=True)
    source = harness / "HeadContactAudit.java"
    source.write_text('''import ar.com.companeros.horror.client.ParasiteRig;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
public class HeadContactAudit {
 public static void main(String[] args) throws Exception {
  Method export=ParasiteRig.class.getDeclaredMethod("export",List.class,Map.class);
  export.setAccessible(true);
  float[][] directions={{-30,15},{35,-20},{10,10}};
  int[] ticks={0,55,75,95,115,135,155,175};
  System.out.print("["); boolean first=true;
  for(float[] d:directions) for(int tick:ticks) {
   var state=new ParasiteRig.State(4,tick*.5F,0,tick,d[0],d[1],false,false,false,false,tick,0,
       ParasiteRig.encounterCompact(tick,4));
   if(!first) System.out.print(","); first=false;
   System.out.print("{\\\"yaw\\\":"+d[0]+",\\\"pitch\\\":"+d[1]+",\\\"tick\\\":"+tick+",\\\"sample\\\":"
       +export.invoke(null,ParasiteRig.bones(false),ParasiteRig.poses(state,false))+"}");
  }
  System.out.print("]");
 }
}
''', encoding="utf-8")
    subprocess.run([str(java.with_name("javac.exe")), "-encoding", "UTF-8", "-cp", str(classes),
                    "-d", str(harness), str(source)], check=True, capture_output=True, timeout=45)
    output = subprocess.run([str(java), "-cp", str(classes) + os.pathsep + str(harness), "HeadContactAudit"],
                            check=True, capture_output=True, text=True, encoding="utf-8", timeout=45)
    cases = json.loads(output.stdout)
    maximum = 0
    hand_gaps = []
    foot_gaps = []
    for case in cases:
        label = f"lore yaw {case['yaw']} pitch {case['pitch']} tick {case['tick']}"
        sample = case["sample"]
        _, matrices, visible = transformed_mesh(sample)
        tracking = head_tracking(matrices, case["yaw"], case["pitch"])
        maximum = max(maximum, tracking["error_degrees"])
        require(tracking["error_degrees"] <= 5, f"{label}: head loses human gaze")
        crossings = self_intersections(sample, matrices, visible)
        require(not crossings, f"{label}: non-articular skin intersections: {crossings[:6]}")
        ties = coplanar_surfaces(sample, matrices, visible)
        require(not ties, f"{label}: overlapping outward coplanar skin surfaces: {ties}")
        hand_gaps.extend(individual_hand_contacts(sample, matrices).values())
        feet = [point(matrices[name], 0, 2, 0)[1] for name in ("leg_l_hand", "leg_r_hand")]
        foot_gaps.append(abs(24 - max(feet)) / 16)
        require(foot_gaps[-1] <= .08,
                f"{label}: hands lift both feet off the floor ({foot_gaps[-1]:.3f} blocks)")
    return {"checked_samples": len(cases), "maximum_gaze_error_degrees": maximum,
            "directions_degrees": [[-30, 15], [35, -20], [10, 10]],
            "ticks": [0, 55, 75, 95, 115, 135, 155, 175],
            "non_articular_intersections": 0, "coplanar_surface_overlaps": 0,
            "maximum_individual_hand_gap_blocks": max(hand_gaps),
            "maximum_grounded_foot_gap_blocks": max(foot_gaps)}


# CHECKS: actual tall-body size, physical compact envelope, grounded gait and folding continuity.
def run(java: Path, classes: Path, modes: tuple[str, ...] | None = None) -> dict:
    require(java.is_file(), f"Java not found: {java}")
    compiled = classes / "ar/com/companeros/horror/client/ParasiteRig.class"
    require(compiled.is_file(),
            "Compile the preview rig with render-parasite-preview.py before running this audit")
    require(compiled.stat().st_mtime_ns >= SOURCE.stat().st_mtime_ns,
            "The compiled rig predates its source: recompile before auditing")
    source_digest = hashlib.sha256(SOURCE.read_bytes()).hexdigest()
    checked = 0
    results: dict[str, dict] = {}
    stage_lengths = {}
    hand_uv = None
    for stage in range(5):
        sample = samples(java, classes, "idle", stage)[0]
        label = f"idle stage {stage}"
        dimensions, matrices = validate_frame(sample, label)
        stage_lengths[stage] = limb_lengths(sample, matrices, label)
        if hand_uv is None:
            hand_uv = validate_hand_uv(sample)
        ceiling = 1.8 + stage * .45
        require(ceiling * .95 <= dimensions[1] <= ceiling + TOLERANCE / 16,
                f"stage {stage}: standing mesh exceeds its body or loses its stature")
        results[f"idle_stage_{stage}"] = {"dimensions_blocks": dimensions,
                                         "physical_limb_lengths_blocks": {name: value / 16 for name, value in stage_lengths[stage].items()}}
        checked += 1

    selected = modes or tuple(SEQUENCES)
    for mode in selected:
        duration = SEQUENCES[mode]
        sequence = samples(java, classes, mode, 4, duration)
        support_errors, lift_heights, root_positions, heights = [], [], [], []
        widths, depths = [], []
        extremity_contacts = []
        length_errors = []
        gaze_errors, head_up_dots = [], []
        inverted_while_contorted = 0
        individual_hand_gaps = []
        for frame, sample in enumerate(sequence):
            label = f"{mode} frame {frame}"
            # La trepada se audita en coordenadas propias; no suponemos apoyo en suelo.
            dimensions, matrices = validate_frame(sample, label, reference_floor=mode != "climb")
            length_errors.append(preserve_lengths(limb_lengths(sample, matrices, label), stage_lengths[4], label))
            # The three formerly broken poses need both solid-volume and depth-tie checks.
            # Other locomotion contracts remain independently enforced below.
            if mode in ("compact", "drag", "lore"):
                _, _, visibility = transformed_mesh(sample)
                crossings = self_intersections(sample, matrices, visibility)
                require(not crossings,
                        f"{label}: non-articular skin intersections: {crossings[:6]}")
                ties = coplanar_surfaces(sample, matrices, visibility)
                require(not ties, f"{label}: overlapping outward coplanar skin surfaces: {ties}")
            if mode == "lore":
                individual_hand_gaps.extend(individual_hand_contacts(sample, matrices).values())
                gaze = head_tracking(matrices)
                gaze_errors.append(gaze["error_degrees"])
                head_up_dots.append(gaze["head_up_world_up_dot"])
                require(gaze["error_degrees"] <= 5,
                        f"{label}: head loses human gaze by {gaze['error_degrees']:.2f}°; forward={gaze['forward_world']}")
                if (abs(gaze["torso_up_world_up_dot"]) < .65 and
                        gaze["head_up_world_up_dot"] < -.5 and gaze["mouth_above_eyes"]):
                    inverted_while_contorted += 1
            widths.append(dimensions[0]); heights.append(dimensions[1]); depths.append(dimensions[2])
            feet = [point(matrices[name], 0, 2, 0)[1] for name in ("leg_l_hand", "leg_r_hand")]
            support_errors.append(abs(24 - max(feet)))
            lift_heights.append(24 - min(feet))
            root_positions.append(point(matrices["body"], 0, 0, 0)[1])
            extremity_contacts.append(limb_contact(sample, matrices))
            if mode in ("chase", "walk"):
                require(support_errors[-1] < TOLERANCE, f"{mode} frame {frame}: both feet float above the floor")
                require(min(feet) >= 20, f"{mode} frame {frame}: excessive airborne foot motion")
            if mode == "lore":
                require(support_errors[-1] / 16 <= .08,
                        f"{label}: hands lift both feet off the floor ({support_errors[-1] / 16:.3f} blocks)")
            if mode == "compact":
                require(dimensions[0] <= .6 + TOLERANCE / 16 and dimensions[2] <= 50 / 16 + TOLERANCE / 16,
                        f"compact frame {frame}: mesh exceeds the narrow passage envelope")
                require(dimensions[1] <= .85 + TOLERANCE / 16, f"compact frame {frame}: too tall for a 1×1 opening")
            if mode in ("crawl2", "crawl3", "chase4", "drag"):
                ceiling = .85 if mode == "drag" else 2.85 if mode == "crawl3" else 1.85
                require(dimensions[1] <= ceiling + TOLERANCE / 16, f"{mode} frame {frame}: exceeds available headroom")
                width_limit = .99 if mode == "crawl3" else .6
                require(dimensions[0] <= width_limit + TOLERANCE / 16,
                        f"{mode} frame {frame}: exceeds {width_limit} block corridor envelope")
                require(extremity_contacts[-1] <= .08, f"{mode} frame {frame}: hands and feet float ({extremity_contacts[-1]:.3f} blocks)")
            if mode == "crawl4":
                require(dimensions[1] <= 3.6 + TOLERANCE / 16, f"{mode} frame {frame}: exceeds standing body height")
                require(dimensions[0] <= .99 + TOLERANCE / 16, f"{mode} frame {frame}: exceeds the one-block passage interior")
            if mode in ("turn", "attack"):
                require(dimensions[1] <= 3.6 + TOLERANCE / 16,
                        f"{mode} frame {frame}: exceeds the established standing body envelope")
            checked += 1
        root_range = (max(root_positions) - min(root_positions)) / 16
        if mode in ("chase", "walk"):
            require(root_range < .01, f"{mode}: whole-body hopping {root_range} blocks")
        if mode == "fold":
            require(3.42 <= heights[0] <= 3.6 and 3.42 <= heights[-1] <= 3.6,
                    "fold: sequence must begin/end standing")
            require(min(heights) < .85 and max(heights) <= 3.6 + TOLERANCE / 16, "fold: failed compact/standing range")
            require(max(abs(right - left) for left, right in zip(heights, heights[1:])) <= .4,
                    "fold: abrupt height jump")
        if mode == "lore":
            require(inverted_while_contorted > 0,
                    "lore: no mouth-above-eyes inversion while the torso is contorted and looking at the human")
        results[mode] = {
            "frames": len(sequence), "fps": FPS,
            "height_range_blocks": [min(heights), max(heights)],
            "maximum_width_blocks": max(widths), "maximum_depth_blocks": max(depths),
            "maximum_support_error_blocks": max(support_errors) / 16,
            "maximum_foot_lift_blocks": max(lift_heights) / 16,
            "body_vertical_range_blocks": root_range,
            "maximum_extremity_contact_gap_blocks": max(extremity_contacts),
            "maximum_physical_limb_length_change_blocks": max(length_errors),
            "reference_floor_enforced": mode != "climb",
        }
        if mode in ("compact", "drag", "lore"):
            results[mode]["self_contact"] = {"non_articular_intersections": 0, "coplanar_surface_overlaps": 0,
                                             "method": "15-axis oriented-box SAT and coplanar face rectangle SAT",
                                             "joint_exception": "Only shared volume within one physical skin cross-section diagonal of the adjacent hinge"}
        if mode == "lore":
            results[mode]["maximum_individual_hand_gap_blocks"] = max(individual_hand_gaps)
            results[mode]["head_tracking"] = {"yaw_degrees": 0, "pitch_degrees": 0,
                "maximum_gaze_error_degrees": max(gaze_errors), "minimum_head_up_dot": min(head_up_dots),
                "inverted_contorted_frames": inverted_while_contorted}
    direction_checks = validate_encounter_directions(java, classes) if "lore" in selected else None
    if direction_checks:
        checked += direction_checks["checked_samples"]
    require(hashlib.sha256(SOURCE.read_bytes()).hexdigest() == source_digest,
            "Rig source changed during validation; recompile and repeat against a stable source")
    return {
        "status": "passed", "checked_samples": checked,
        "scope": "selected modes" if modes else "all horror rig modes",
        "selected_modes": list(selected),
        "head_direction_checks": direction_checks,
        "hand_uv": hand_uv,
        "rig_sha256": source_digest,
        "validator_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
        "method": "Independent transforms of all visible box vertices exported by the Java rig",
        "ground": "Flat reference floor; runtime terrain raycasts require in-game verification",
        "checks": ["finite transforms", "vertex/export agreement", "all five standing heights", "3.6-block final body",
                   "narrow passage width and height; longitudinal overhang needs in-game review", "at least one grounded foot while walking/running", "bounded foot lift",
                   "no whole-body chase/walk hopping", "continuous folding and unfolding",
                   "unit world/body/skeleton scales", "constant physical joint and mesh limb lengths within each stage",
                   "head gaze follows input yaw/pitch within 5 degrees throughout the encounter",
                   "mouth-above-eyes inversion while the torso contorts", "constant anatomical head attachment pivot",
                   "compact/drag/lore skin boxes do not intersect beyond adjacent joints",
                   "compact/drag/lore outward skin faces do not overlap coplanarly",
                   "encounter arm gestures preserve at least one grounded foot within the existing 0.08-block contact margin",
                   "palm cube UV nets stay inside the host skin arm rectangles without sampling the neighbouring row"],
        "envelope_notes": {"crawl3": "Width limit 0.99 instead of 0.6: hands deliberately brace against walls at x=±0.5; height remains 2.85.",
                           "crawl4": "Width limit 0.99 instead of collider width 0.6: uncompressed upright shoulders measure about 0.76 blocks and fit the one-block passage interior; body height remains 3.6.",
                           "climb": "No floor=24 equality is imposed on a wall-climbing pose; transforms and physical limb lengths are still audited."},
        "sequences": results,
    }


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--java", type=Path, default=DEFAULT_JAVA)
    parser.add_argument("--classes", type=Path, default=DEFAULT_CLASSES)
    parser.add_argument("--report", type=Path, default=ROOT / "docs/parasito-movimiento-validacion.json")
    parser.add_argument("--modes", help="Auditoría selectiva: modos separados por comas; por defecto todos")
    arguments = parser.parse_args()
    modes = tuple(arguments.modes.split(",")) if arguments.modes else None
    if modes and any(mode not in SEQUENCES for mode in modes):
        parser.error("Modos válidos: " + ",".join(SEQUENCES))
    try:
        report = run(arguments.java, arguments.classes, modes)
    except (AssertionError, KeyError, ValueError, subprocess.SubprocessError) as error:
        report = {"status": "failed", "error": str(error)}
        arguments.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        raise SystemExit(str(error))
    arguments.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    print(f"PASSED: {report['checked_samples']} independent rig samples; scope: {report['scope']}; report: {arguments.report}")


if __name__ == "__main__":
    main()