Skip to content

Commit 7fdb5ae

Browse files
sp00ktoberclaude
andcommitted
Merge branch 'tail/tip' into tail/attach
The Zinogre's build digest covers both the tip and the flinch slice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 parents d5ef3fa + fd03294 commit 7fdb5ae

39 files changed

Lines changed: 800 additions & 200 deletions

‎apps/blender/src/mhfu_blender/write.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
What Blender holds comes in as plain data: the bind joints, each group's geometry and each slot's
66
clip. An MHFU model keeps every byte no edit reaches: a group whose vertex count and triangles are
77
unchanged is written in place, at the PMO's own size; any other is rebuilt and the PMO laid out
8-
again. An MHP3rd model comes out as an MHFU PAC with geometry, skeleton and textures, no clips.
8+
again. An MHP3rd model comes out as an MHFU PAC with geometry, skeleton and textures, no clips;
9+
its tail tip, when its skeleton has one, in mesh record 1 (`mhfu_port.mesh.build`).
910
"""
1011

1112
from __future__ import annotations
@@ -16,7 +17,7 @@
1617

1718
import numpy as np
1819
from mhfu import inject
19-
from mhfu_port import constraints, fk, mesh, motion
20+
from mhfu_port import constraints, fk, mesh, motion, rig
2021
from mhfu_port.mesh import Part, Skinned
2122
from mhfu_port.model import ANIMATION, MHFU, MODEL, SKELETON, MeshGroup, Model
2223
from mhp_formats import fu, pmo
@@ -328,13 +329,13 @@ def _donor(model: Model, skeleton: Skeleton, groups: Mapping[int, Geometry]) ->
328329
f"{len(over)} positions outside the model's {_box(scale)} box, first group "
329330
f"{kept[o.group]} vertex {o.vertex}"
330331
)
332+
bones = [replace(b, name=None) for b in skeleton.bones]
333+
fu_skeleton = Skeleton(bones, list(skeleton.params), magic=FU_MAGIC)
331334
try:
332-
out = mesh.build(skinned, scale)
335+
out = mesh.build(skinned, scale, tip=rig.tip_of(fu_skeleton))
333336
except ValueError as e:
334337
raise ExportError(str(e)) from None
335338
notes[:0] = ["no clips: an in-game monster from an MHP3rd one is a port, built from a manifest"]
336-
bones = [replace(b, name=None) for b in skeleton.bones]
337-
fu_skeleton = Skeleton(bones, list(skeleton.params), magic=FU_MAGIC)
338339
results = _judge(constraints.validate(out, fu_skeleton), [])
339340
tmh = next((e for e in entries if Tmh.sniff(e)), b"")
340341
pac = [fu_skeleton.to_bytes(), out.to_bytes(), tmh, EMPTY_ANIM.to_bytes()]

‎apps/blender/tests/conftest.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ def synthetic_pac() -> bytes:
9797
uvs = [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0)]
9898
influences = [[(1, 1.0)], [(1, 0.25), (2, 0.75)], [(1, 1.0)]]
9999
part = Part(verts, [], uvs, [], [(0, 1, 2)], influences, 0)
100-
model = mesh.build([Skinned(part, part.influences)], (256.0, 256.0, 256.0))
100+
model = mesh.build([Skinned(part, part.influences)], (256.0, 256.0, 256.0), tip=None)
101101
root = Track(
102102
[
103103
Channel(0x008, keys((0, 0, 0, 300), (10, 2048, -100, 50), (20, 0))),
@@ -159,7 +159,7 @@ def donor_pac() -> bytes:
159159
weights = [[(1, 1.0)], [(1, 0.25), (2, 0.75)], [(1, 1.0)], [(2, 1.0)]]
160160
part = Part(verts, normals, uvs, [], [(0, 1, 2), (2, 1, 3)], weights, 0)
161161
scale = (256.0, 256.0, 256.0)
162-
built = mesh.build([Skinned(part, part.influences)], scale)
162+
built = mesh.build([Skinned(part, part.influences)], scale, tip=None)
163163
model = p3rd.Pmo(
164164
[p3rd.Mesh(built.groups(), [0], scale=scale)], built.materials, scale, remap_table=False
165165
)

‎apps/blender/tests/test_write.py‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99

1010
import numpy as np
1111
import pytest
12-
from mhfu_port import motion
12+
from mhfu_port import build, manifest, motion, verify
13+
from mhfu_port.data import Data
1314
from mhfu_port.model import Model
1415
from mhp_formats import Pac, Pmo, Tmh, fu
1516
from mhp_formats.anim import Channel, Clip, Keyframe, Track
@@ -209,6 +210,27 @@ def test_donor(write: ModuleType, donor: bytes) -> None:
209210
write.place(out.pac, model)
210211

211212

213+
@pytest.mark.parametrize("side", ["source", "port"])
214+
def test_zinogre_tip(write: ModuleType, mhfu_data: Path, mhp3rd_data: Path, side: str) -> None:
215+
"""An export of the Zinogre, its MHP3rd model or its port edited, keeps the tail tip in mesh
216+
record 1, which the Tigrex's dropped tail draws: verify's tip check passes."""
217+
if side == "source":
218+
model = Model.from_path(mhp3rd_data / "file_05339.bin")
219+
groups = None
220+
else:
221+
games = Data.find(mhfu_data, mhp3rd_data)
222+
ports = Path(__file__).parents[3] / "ports"
223+
pac = build.build(manifest.load(ports / "zinogre.toml"), games).pac
224+
model = Model.from_bytes(pac, "file_06185")
225+
groups = held(write, model)
226+
groups[0].positions[0] += (3.0, -2.0, 1.5)
227+
port = verify.Port(export(write, model, groups).pac)
228+
host = verify.Port((mhfu_data / "file_06185.bin").read_bytes())
229+
check = verify.tip_check(port, host)
230+
assert check.ok, check.detail
231+
assert len(port.model.meshes[1].groups) == 6
232+
233+
212234
def test_place(write: ModuleType, model: Model, tmp_path: Path) -> None:
213235
root = tmp_path / "memstick"
214236
(root / "PSP").mkdir(parents=True)

‎apps/studio/src/mhfu_studio/monster/commands.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ def _render(groups: Groups) -> None:
117117
p.add_argument("--no-grid", action="store_true", help="hide the ground plane")
118118
p.add_argument("--hilite", type=_ints, default=(), metavar="J,J,..", help="paint joints red")
119119
p.add_argument("--only", choices=("tagged", "rest"), help="draw only --hilite, or the rest")
120+
p.add_argument("--severed", action="store_true", help="the stump: the tail tip hidden")
120121
data.add_arguments(p)
121122
flags.add_flags(p)
122123
p.set_defaults(run=run_render)
@@ -168,6 +169,7 @@ def run_render(args: argparse.Namespace) -> int:
168169
grid=not args.no_grid,
169170
hilite=args.hilite,
170171
only=args.only,
172+
severed=args.severed,
171173
)
172174
return flags.finish(args, shots.render(sc, o, args.size, args.samples))
173175

‎apps/studio/src/mhfu_studio/monster/core/scene.py‎

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
# SPDX-License-Identifier: GPL-3.0-or-later
22
# SPDX-FileCopyrightText: 2026 sp00ktober
33
"""A monster PAC as the studio opens it: `mhfu_port.model.Model` with a port manifest's clip
4-
names, the file's build id and `pose()`."""
4+
names, the file's build id and `pose()`, its tail tip on the tail unless `severed`."""
55

66
from __future__ import annotations
77

88
from collections.abc import Sequence
9+
from functools import cached_property
910
from pathlib import Path
1011
from typing import Any, Self
1112

12-
from mhfu_port import build, layout
13+
from mhfu_port import build, fk, layout, mesh, rig
1314
from mhfu_port.data import Data
1415
from mhfu_port.manifest import Manifest
1516
from mhfu_port.model import MHP3RD, Clip, Model
@@ -32,6 +33,8 @@ def __init__(self, *args: Any, **kw: Any) -> None:
3233
None if self.path is None or self.pac is None else build_id(self.path.name, self.pac)
3334
)
3435
"""`name@digest` of the file, what clip labels are keyed to."""
36+
self.severed = False
37+
"""Draw the stump: the tail tip hidden, not posed on the tail."""
3538

3639
@classmethod
3740
def from_bytes(
@@ -97,17 +100,51 @@ def attach_manifest(self, m: Manifest) -> None:
97100
by_slot.setdefault(at, []).append(name)
98101
self.rename(by_slot)
99102

103+
# the tail tip
104+
105+
@cached_property
106+
def tip(self) -> rig.Tip | None:
107+
"""`rig.tip_of` the skeleton; None, with a note, where no offset pairs the chain."""
108+
try:
109+
return rig.tip_of(self.skeleton)
110+
except ValueError as e:
111+
self.notes.append(f"the tail tip stays where it is stored: {e}")
112+
return None
113+
114+
def tip_groups(self) -> list[int]:
115+
"""The groups riding only the tip's chain (`mesh.tip_parts`)."""
116+
if self.tip is None:
117+
return []
118+
rows = [
119+
[
120+
list(zip(js, ws, strict=True))
121+
for js, ws in zip(g.skin.joints.tolist(), g.skin.weights.tolist(), strict=True)
122+
]
123+
for g in self.groups
124+
]
125+
return mesh.tip_parts(rows, self.tip.joints)
126+
127+
def hidden(self) -> set[int]:
128+
"""The groups not drawn: the tip's, when `severed`."""
129+
return set(self.tip_groups()) if self.severed else set()
130+
131+
def world(self, rot: fk.Floats | None = None, loc: fk.Floats | None = None) -> fk.Floats:
132+
"""`fk.Rig.world`, the tip's chain posed as its carriers unless `severed`, as the game
133+
does while the tail is whole."""
134+
world = self.rig.world(rot, loc)
135+
return world if self.severed or self.tip is None else fk.attach(world, self.tip.pairs)
136+
100137
def clip_table(self) -> dict[int, tuple[int, bool]]:
101138
return {c.slot: (c.frames, c.loop) for c in self.clips}
102139

103140
def pose(self, key: int | str | Clip, frame: float = 0.0) -> Pose:
104141
"""Frames outside the clip hold per channel."""
105142
c = self.clip(key)
106143
rot, loc = self.curves(c).at(float(frame))
107-
return Pose(self.rig, float(frame), self.rig.world(rot, loc), c.slot)
144+
return Pose(self.rig, float(frame), self.world(rot, loc), c.slot)
108145

109146
def bind_pose(self) -> Pose:
110-
return Pose(self.rig, 0.0, self.rig.bind_world)
147+
return Pose(self.rig, 0.0, self.world())
111148

112149
# reporting
113150

‎apps/studio/src/mhfu_studio/monster/render/mesh.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,10 +258,12 @@ def _upload_textures(ctx: moderngl.Context, scene: Scene) -> dict[int, moderngl.
258258

259259

260260
def _index_batches(ctx: moderngl.Context, scene: Scene) -> dict[int | None, moderngl.Buffer]:
261-
"""One index buffer per texture position; None holds the untextured groups."""
261+
"""One index buffer per texture position, `Scene.hidden` left out; None holds the
262+
untextured groups."""
262263
parts: dict[int | None, list[npt.NDArray[np.int64]]] = {}
264+
hidden = scene.hidden()
263265
for i, g in enumerate(scene.groups):
264-
if g.n_faces:
266+
if g.n_faces and i not in hidden:
265267
lo, _ = scene.group_range(i)
266268
parts.setdefault(g.texture, []).append(g.triangles.astype(np.int64) + lo)
267269
return {t: ctx.buffer(np.concatenate(p).astype("i4").tobytes()) for t, p in parts.items()}

‎apps/studio/src/mhfu_studio/monster/render/playback.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ def pose_at(
191191
if js:
192192
loc = loc.copy()
193193
loc[js] = curves.at(0.0)[1][js]
194-
world = scene.rig.world(rot, loc)
194+
world = scene.world(rot, loc)
195195
th = yaw_at(scene, clip, frame, steer, speed)
196196
if th:
197197
world = yaw_matrix(th) @ world

‎apps/studio/src/mhfu_studio/monster/render/shots.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ class Options:
3636
grid: bool = True
3737
hilite: Sequence[int] = ()
3838
only: str | None = None
39+
severed: bool = False
40+
"""The stump: the tail tip hidden (`Scene.severed`)."""
3941

4042

4143
def configure(vp: MonsterViewport, o: Options) -> None:
@@ -77,6 +79,7 @@ def render(
7779
"""`<clip>_f<frame>_<view>` per frame and view (`bind_<view>` for the bind pose), each framed
7880
on its own pose."""
7981
images: dict[str, Image8] = {}
82+
scene.severed = o.severed
8083
with offscreen(lambda c: MonsterViewport(c, size, samples), ctx) as vp:
8184
vp.set_scene(scene)
8285
configure(vp, o)

‎apps/studio/src/mhfu_studio/monster/render/viewport.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def pose(self, clip: Clip | None, frame: float = 0.0) -> None:
7676
steer=self.steer,
7777
speed=self.playback.speed,
7878
)
79-
self.mesh.set_pose(None if clip is None else p)
79+
self.mesh.set_pose(p) # bind included: the tail tip moves onto the tail
8080
self.skeleton.set_positions(p.joints)
8181
for ov in (self.hitboxes, self.attacks):
8282
if ov is not None:

‎apps/studio/tests/monster/conftest.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ def _pac(carried: bool = False) -> bytes:
140140
[[(1, 1.0)], [(2, 1.0)], [(1, 1.0)]],
141141
0,
142142
)
143-
model = mesh.build([Skinned(part, part.influences)], (256.0, 256.0, 256.0))
143+
model = mesh.build([Skinned(part, part.influences)], (256.0, 256.0, 256.0), tip=None)
144144
image = TmhImage(3, 2, 2, bytes(range(16)))
145145
turn = [Keyframe(0, 0), Keyframe(0 if carried else quantize("rot", 1.5707963), 10)]
146146
body = Clip(

0 commit comments

Comments
 (0)