Skip to content

Commit 3abea9c

Browse files
sp00ktoberclaude
andcommitted
feat(mhfu-port): check a port: verify, validate, pose, stretch, floor, fidelity, slots
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 2776123 commit 3abea9c

11 files changed

Lines changed: 1775 additions & 2 deletions

File tree

‎packages/mhfu-port/README.md‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,16 @@ port.pac, port.summary.text()
3939

4040
## Checking a build
4141

42-
`mhfu-port diff OLD NEW` compares two builds group by group and clip by clip. `constraints`
43-
holds the engine's rules on a monster PAC; `build` refuses a port that breaks one.
42+
| Command | What it prints |
43+
|---|---|
44+
| `mhfu-port diff OLD NEW` | Two builds compared group by group and clip by clip |
45+
| `mhfu-port verify PAC --manifest M` | A structural audit against the donor: stream partition, driven palette joints, tears across the body's fork, every vertex's weights |
46+
| `mhfu-port validate PAC` | The engine's rules (`constraints`) on any monster PAC; `build` refuses a port that breaks one |
47+
| `mhfu-port pose PAC --manifest M` | The port's clips against the donor's, joint by joint |
48+
| `mhfu-port stretch PAC [--fork]` | How far each clip pulls the mesh apart, and the tear across the fork |
49+
| `mhfu-port floor PAC... --host 75` | Where the idle pose puts the feet by the animation alone, against the host (not yet where the game puts a port) |
50+
| `mhfu-port fidelity M` | The port's skin weights against the donor's own |
51+
| `mhfu-port slots`, `labels` | Which host move plays which ported clip; hand labels checked against a build |
4452

4553
## Licence
4654

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# SPDX-License-Identifier: GPL-3.0-or-later
2+
# SPDX-FileCopyrightText: 2026 sp00ktober
3+
"""`slots` and `labels`: what each animation slot of a built port plays."""
4+
5+
from __future__ import annotations
6+
7+
import argparse
8+
import sys
9+
from collections.abc import Iterator
10+
from contextlib import contextmanager
11+
from pathlib import Path
12+
from typing import TYPE_CHECKING, TextIO
13+
14+
from mhfu.em.moveset import Moveset
15+
from mhfu.files import monster_pac
16+
from mhp_formats import p3rd
17+
18+
from .. import data, manifest, slots
19+
20+
if TYPE_CHECKING:
21+
from . import Subparsers
22+
23+
24+
def register(sub: Subparsers) -> None:
25+
p = sub.add_parser(
26+
"slots", help="each host slot's (main, sub) pairs, lengths and donor clip in a port, as CSV"
27+
)
28+
p.add_argument("port", type=Path, nargs="?", help="a built model PAC")
29+
_common(p)
30+
p.set_defaults(run=run_slots)
31+
32+
q = sub.add_parser("labels", help="which hand labels describe a donor clip, per build, as CSV")
33+
q.add_argument("labels", type=Path, help="`N -> text` lines, N the a1 forced")
34+
q.add_argument("ports", type=Path, nargs="+", help="the builds the labels may come from")
35+
_common(q)
36+
q.set_defaults(run=run_labels)
37+
38+
39+
def _common(p: argparse.ArgumentParser) -> None:
40+
p.add_argument("--manifest", type=Path, help="the port's: host, donor and clip names")
41+
p.add_argument("--host", type=int, metavar="SPECIES", help="the MHFU host")
42+
p.add_argument("--donor", type=int, metavar="FILE", help="MHP3rd moveset file")
43+
p.add_argument("--stream", type=int, default=0, help="the donor stream (default: 0)")
44+
p.add_argument("-o", "--out", type=Path, help="CSV file (default: stdout)")
45+
data.add_arguments(p)
46+
47+
48+
def _resolve(args: argparse.Namespace) -> dict[int, str]:
49+
"""Fill `--host` and `--donor` from the manifest where not given; the manifest's clip
50+
names by slot."""
51+
names: dict[int, str] = {}
52+
if args.manifest:
53+
m = manifest.load(args.manifest)
54+
args.host = m.port.host_species if args.host is None else args.host
55+
args.donor = m.source.anim if args.donor is None else args.donor
56+
for name, clip in sorted(m.clips.items()):
57+
names[clip.slot] = f"{names[clip.slot]} {name}" if clip.slot in names else name
58+
if args.host is None:
59+
raise ValueError("give --host or --manifest")
60+
return names
61+
62+
63+
def run_slots(args: argparse.Namespace) -> int:
64+
names = _resolve(args)
65+
d = data.from_arguments(args)
66+
host = slots.anim_of(d.fu.read(monster_pac(args.host)))
67+
drivers = slots.driven(Moveset(d.fu.em(args.host)))
68+
port = slots.anim_of(args.port.read_bytes()) if args.port else None
69+
donor = p3rd.Anim.from_bytes(d.p3rd.read(args.donor)) if args.donor is not None else None
70+
rows = slots.catalog(host, drivers, port, donor, args.stream, names)
71+
with _output(args.out) as out:
72+
slots.write_catalog(rows, out)
73+
74+
driven = [r for r in rows if r.pairs]
75+
silent = [r.slot for r in driven if not r.host_streams]
76+
say(f"{len(rows)} slots, {len(driven)} driven by a (main, sub) pair")
77+
if silent:
78+
say(f" {len(silent)} driven slots have no host clip, so their pairs cannot animate:")
79+
say(f" {_ids(silent)}")
80+
size = slots.slot_count(host)
81+
past = [a1 for a1 in drivers if a1 >= size]
82+
if past:
83+
say(f" {len(past)} driven ids name no slot of the {size}: {_ids(past)}")
84+
if port is not None:
85+
most = max((r.shared for r in rows), default=0)
86+
say(f" {sum(r.shared > 1 for r in rows)} port slots share their clip (at most {most})")
87+
held = [r.source for r in rows if r.source is not None]
88+
if held:
89+
n = {m: sum(s.match == m for s in held) for m in ("same", "fill", "unknown")}
90+
say(f" port: {n['same']} own donor clip, {n['fill']} the fill, {n['unknown']} unknown")
91+
if not n["same"]:
92+
say(
93+
f" no port slot holds its own clip of donor file {args.donor} stream "
94+
f"{args.stream}: is that the moveset the port was built from?"
95+
)
96+
return 0
97+
98+
99+
def run_labels(args: argparse.Namespace) -> int:
100+
_resolve(args)
101+
if args.donor is None:
102+
raise ValueError("give --donor or --manifest")
103+
d = data.from_arguments(args)
104+
labels = slots.read_labels(args.labels.read_text())
105+
builds = {}
106+
for path in args.ports:
107+
if path.stem in builds:
108+
raise ValueError(f"two builds named {path.stem}")
109+
builds[path.stem] = slots.anim_of(path.read_bytes())
110+
donor = p3rd.Anim.from_bytes(d.p3rd.read(args.donor))
111+
drivers = slots.driven(Moveset(d.fu.em(args.host)))
112+
rows = slots.verdicts(labels, builds, donor, args.stream, drivers)
113+
with _output(args.out) as out:
114+
slots.write_verdicts(rows, list(builds), out)
115+
116+
say(f"{len(rows)} labels over {len(builds)} builds")
117+
for name in builds:
118+
say(f" {name}: {sum(name in v.transfers for v in rows)} hold their own donor clip")
119+
if len(builds) > 1:
120+
say(f" {sum(len(v.transfers) == len(builds) for v in rows)} under every build")
121+
return 0
122+
123+
124+
@contextmanager
125+
def _output(path: Path | None) -> Iterator[TextIO]:
126+
if path is None:
127+
yield sys.stdout
128+
return
129+
with path.open("w", newline="") as f:
130+
yield f
131+
132+
133+
def say(line: str) -> None:
134+
print(line, file=sys.stderr)
135+
136+
137+
def _ids(ids: list[int]) -> str:
138+
return " ".join(map(str, ids))
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# SPDX-License-Identifier: GPL-3.0-or-later
2+
# SPDX-FileCopyrightText: 2026 sp00ktober
3+
"""`verify`, `validate`, `pose`, `stretch`, `floor` and `fidelity`: offline checks of a built
4+
port."""
5+
6+
from __future__ import annotations
7+
8+
import argparse
9+
from pathlib import Path
10+
from typing import TYPE_CHECKING
11+
12+
from mhfu.files import monster_pac
13+
14+
from .. import build, constraints, data, fidelity, manifest, pose, verify
15+
from ..slots import frames
16+
17+
if TYPE_CHECKING:
18+
from . import Subparsers
19+
20+
21+
def register(sub: Subparsers) -> None:
22+
p = sub.add_parser("verify", help="the structural audit of a built port")
23+
p.add_argument("port", type=Path, help="a built model PAC")
24+
p.add_argument("--manifest", type=Path, help="also check the donor's rig and skin survived")
25+
data.add_arguments(p)
26+
p.set_defaults(run=run_verify)
27+
28+
p = sub.add_parser("validate", help="the engine's rules on any monster PAC")
29+
p.add_argument("pac", type=Path)
30+
p.add_argument("--template", type=int, metavar="SPECIES", help="keep this species' bone tree")
31+
data.add_arguments(p)
32+
p.set_defaults(run=run_validate)
33+
34+
p = sub.add_parser("pose", help="the port's clips against the donor's, joint by joint")
35+
p.add_argument("port", type=Path)
36+
p.add_argument("--manifest", type=Path, required=True)
37+
data.add_arguments(p)
38+
p.set_defaults(run=run_pose)
39+
40+
p = sub.add_parser("stretch", help="how far the port's clips pull its mesh apart")
41+
p.add_argument("port", type=Path)
42+
p.add_argument("--slot", type=int, help="every triangle at one frame of this slot")
43+
p.add_argument("--frame", type=int, help="with --slot; default: mid-clip")
44+
p.add_argument("--fork", action="store_true", help="only the tear across the body fork")
45+
p.add_argument("--top", type=int, default=15, help="rows to print (default: 15)")
46+
p.set_defaults(run=run_stretch)
47+
48+
p = sub.add_parser("floor", help="where the rest pose puts the feet")
49+
p.add_argument("pacs", type=Path, nargs="*", help="built or native model PACs")
50+
p.add_argument("--host", type=int, metavar="SPECIES", help="measure this host's PAC first")
51+
data.add_arguments(p)
52+
p.set_defaults(run=run_floor)
53+
54+
p = sub.add_parser("fidelity", help="whether the porter keeps the donor's skin")
55+
p.add_argument("manifest", type=Path)
56+
data.add_arguments(p)
57+
p.set_defaults(run=run_fidelity)
58+
59+
60+
def run_verify(args: argparse.Namespace) -> int:
61+
port = verify.Port(args.port.read_bytes())
62+
if args.manifest:
63+
m = manifest.load(args.manifest)
64+
d = build.donor(m, data.from_arguments(args))
65+
checks = verify.audit(port, d.skeleton, build.parts(d, m.build))
66+
else:
67+
checks = verify.audit(port)
68+
width = max(len(c.name) for c in checks)
69+
for c in checks:
70+
print(f"{'PASS' if c.ok else 'FAIL'} {c.name:<{width}} {c.detail}")
71+
failed = sum(not c.ok for c in checks)
72+
print(f"\n{len(checks)} checks, {failed} failed")
73+
return 1 if failed else 0
74+
75+
76+
def run_validate(args: argparse.Namespace) -> int:
77+
port = verify.Port(args.pac.read_bytes())
78+
template = None
79+
if args.template is not None:
80+
fu = data.from_arguments(args).fu
81+
template = verify.Port(fu.read(monster_pac(args.template))).skeleton
82+
results = constraints.validate(port.model, port.skeleton, port.anim, template)
83+
for r in results:
84+
print(r)
85+
errors = sum(r.level == "error" for r in results)
86+
print(f"{errors} errors, {len(results) - errors} warnings")
87+
return 1 if errors else 0
88+
89+
90+
def run_pose(args: argparse.Namespace) -> int:
91+
m = manifest.load(args.manifest)
92+
d = build.donor(m, data.from_arguments(args))
93+
port = verify.Port(args.port.read_bytes())
94+
r = pose.compare(port, d.skeleton, d.clips, build.record_map(d, m.build))
95+
print(f"{r.matched} of {r.bones} donor bones placed (pad {r.pad}), {r.compared} compared")
96+
print(f"{'slot':>4} {'joints':>6} {'median':>8} {'p90':>8} {'worst':>8} {'lift':>7}")
97+
for s in r.slots:
98+
print(
99+
f"{s.slot:>4} {s.joints:>6} {s.median:>8.2f} {s.p90:>8.2f} {s.worst:>8.2f} "
100+
f"{s.lift:>7.1f}"
101+
)
102+
if r.absent:
103+
print(f"donor slots the port does not fill: {' '.join(map(str, r.absent))}")
104+
if r.partial:
105+
shown = ", ".join(f"{s} ({a} of {b} joints)" for s, a, b in r.partial)
106+
print(f"filled in only some parts, not compared: {shown}")
107+
verdict = "plays its donor's moveset" if r.ok else "differs from its donor"
108+
print(
109+
f"{len(r.slots)} clips, worst {r.worst:.2f} units (tolerance {pose.TOLERANCE}): {verdict}"
110+
)
111+
return 0 if r.ok else 1
112+
113+
114+
def run_stretch(args: argparse.Namespace) -> int:
115+
port = verify.Port(args.port.read_bytes())
116+
if args.fork:
117+
t = verify.tear(port)
118+
print("no edge crosses the body fork" if t is None else f"worst tear {t}")
119+
print(f"gate: {verify.TEAR_LIMIT:.0f} units")
120+
return 0
121+
if args.slot is not None:
122+
clip = port.clip(args.slot)
123+
if clip is None:
124+
raise ValueError(f"slot {args.slot} is empty in this port")
125+
frame = args.frame if args.frame is not None else frames(clip) // 2
126+
rows = verify.faces(port, args.slot, frame)
127+
print(f"slot {args.slot} frame {frame}: {len(rows)} triangles grow")
128+
print(
129+
f"grown >50: {sum(s.growth > 50 for s, _ in rows)}, "
130+
f">150: {sum(s.growth > 150 for s, _ in rows)}"
131+
)
132+
for s, ratio in rows[: args.top]:
133+
print(f" +{s.growth:7.0f} {ratio:5.1f}x group {s.group:3d} joints {_pair(s)}")
134+
return 0
135+
worst = verify.worst(port)
136+
print(f"{len(worst)} clip samples, worst {worst[0].growth:.0f} units" if worst else "no clips")
137+
for s in worst[: args.top]:
138+
print(
139+
f" +{s.growth:7.0f} slot {s.slot:3d} frame {s.frame:3d} group {s.group:3d} "
140+
f"joints {_pair(s)}"
141+
)
142+
return 0
143+
144+
145+
def run_floor(args: argparse.Namespace) -> int:
146+
pacs: list[tuple[str, bytes]] = []
147+
if args.host is not None:
148+
fu = data.from_arguments(args).fu
149+
pacs.append((f"host {args.host}", fu.read(monster_pac(args.host))))
150+
pacs += [(p.name, p.read_bytes()) for p in args.pacs]
151+
if not pacs:
152+
raise ValueError("give PACs to measure, or --host")
153+
rows = [(name, pose.rest_floor(verify.Port(pac))) for name, pac in pacs]
154+
width = max(len(name) for name, _ in rows)
155+
print(f"{'':<{width}} {'floor':>7} {'vertex':>6} joint")
156+
for name, f in rows:
157+
print(f"{name:<{width}} {f.height:>7.1f} {f.vertex:>6} {f.joint}")
158+
if len(rows) > 1:
159+
(first, a), (last, b) = rows[0], rows[-1]
160+
print(f"\n{last} stands {b.height - a.height:+.1f} units against {first}")
161+
return 0
162+
163+
164+
def run_fidelity(args: argparse.Namespace) -> int:
165+
r = fidelity.of_build(manifest.load(args.manifest), data.from_arguments(args))
166+
f = r.fidelity
167+
print(f"skin {r.skin}: {f.vertices} vertices, donor {f.expected}")
168+
print(f"on other joints than the donor's: {f.wrong_set}")
169+
print(
170+
f"weight error: worst {f.max_error:.6f}, mean {f.mean_error:.6f} "
171+
f"(one u8 step {fidelity.WEIGHT_STEP:.6f})"
172+
)
173+
print("keeps the donor's skin" if f.ok else "does not keep the donor's skin")
174+
return 0 if f.ok else 1
175+
176+
177+
def _pair(s: verify.Stretch) -> str:
178+
return "-".join(map(str, s.joints))

0 commit comments

Comments
 (0)