Skip to content

Commit 2776123

Browse files
sp00ktoberclaude
andauthored
feat(mhfu-port): the MHP3rd porter, built from a manifest on mhp-formats (#8)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent f6752e1 commit 2776123

42 files changed

Lines changed: 5641 additions & 13 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ release ULES01213), and for porting monsters to it from Monster Hunter Portable
1818
| `packages/` | Python libraries: the debugger client, the file formats, the game (`mhfu`, which also holds the address map every language is generated from), the porter |
1919
| `framework/` | The PRX plugin and its Lua host |
2020
| `apps/` | The studio (monster and map editor), the HUD, the Blender extension |
21+
| `ports/` | Manifests of the monsters ported from MHP3rd: the Brute Tigrex and the Zinogre |
2122
| `ppsspp/` | PPSSPP patched for automation, its build, and a headless container to run it in |
2223
| `examples/` | Example mods |
2324
| `docs/` | Guides and format references |

‎REUSE.toml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ path = "ppsspp/patches/**"
1212
SPDX-FileCopyrightText = ["2026 sp00ktober", "PPSSPP Project"]
1313
SPDX-License-Identifier = "GPL-2.0-or-later"
1414

15-
# the file-format library contains code derived from mhff; its sources also carry SPDX headers
15+
# the file-format library contains code derived from mhff, and the porter is built on it; their
16+
# sources also carry SPDX headers
1617
[[annotations]]
17-
path = "packages/mhp-formats/**"
18+
path = ["packages/mhp-formats/**", "packages/mhfu-port/**"]
1819
SPDX-FileCopyrightText = "2026 sp00ktober"
1920
SPDX-License-Identifier = "GPL-3.0-or-later"

‎packages/mhfu-port/README.md‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# mhfu-port
2+
3+
Ports Monster Hunter Portable 3rd big monsters into Monster Hunter Freedom Unite (EU). A port
4+
rides an MHFU monster, the host: it keeps the host's AI and replaces its model PAC with the
5+
donor's own skeleton, skinned model, textures and moveset. The framework injects that PAC into
6+
the running game.
7+
8+
## A port
9+
10+
A manifest in `ports/` names the donor's files, the host and the build settings (see
11+
`ports/README.md`), and is the only input:
12+
13+
```bash
14+
mhfu-port build ports/zinogre.toml # zinogre.bin, the model PAC
15+
mhfu-port inject ports/zinogre.toml # the same, placed for the framework to load
16+
```
17+
18+
Both read the extracted games from `--data` / `--p3rd-data`, or `MHFU_DATA` / `MHP3RD_DATA`.
19+
20+
```python
21+
from mhfu_port import build, manifest
22+
from mhfu_port.data import Data
23+
24+
port = build.build(manifest.load("ports/zinogre.toml"), Data.find())
25+
port.pac, port.summary.text()
26+
```
27+
28+
`build` runs as steps (`donor`, `host`, `record_map`, `binding`, `parts`, `skin`, `animation`,
29+
`pac`), each a function a tool can stop at:
30+
31+
| Module | Does |
32+
|---|---|
33+
| `records` | Which donor bone each MHP3rd animation record drives; the records are not positional |
34+
| `rig` | The skeleton the port ships: the donor's, reordered so each animation stream is one run of joints |
35+
| `retarget` | Matches the donor's bones to the host's, to ship the host's skeleton instead |
36+
| `mesh`, `skin` | The donor's geometry, bound to the rig by the donor's own weights, by distance, or by the host's |
37+
| `motion` | The donor's moveset as MHFU's in-game animation, on the host's slots |
38+
| `fk` | The engine's forward kinematics: joint matrices and skinned positions for any clip and frame |
39+
40+
## Checking a build
41+
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.
44+
45+
## Licence
46+
47+
GPL-3.0-or-later.

‎packages/mhfu-port/pyproject.toml‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[project]
2+
name = "mhfu-port"
3+
version = "0.1.0"
4+
description = "Port Monster Hunter Portable 3rd monsters into Monster Hunter Freedom Unite."
5+
readme = "README.md"
6+
requires-python = ">=3.11"
7+
license = "GPL-3.0-or-later"
8+
dependencies = ["mhp-formats", "mhfu", "numpy>=2", "tomli-w>=1.2"]
9+
classifiers = ["Typing :: Typed"]
10+
11+
[project.scripts]
12+
mhfu-port = "mhfu_port.cli:main"
13+
14+
[build-system]
15+
requires = ["uv_build>=0.11,<0.12"]
16+
build-backend = "uv_build"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# SPDX-License-Identifier: GPL-3.0-or-later
2+
# SPDX-FileCopyrightText: 2026 sp00ktober
3+
"""Port Monster Hunter Portable 3rd big monsters into Monster Hunter Freedom Unite (EU)."""
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
# SPDX-License-Identifier: GPL-3.0-or-later
2+
# SPDX-FileCopyrightText: 2026 sp00ktober
3+
"""Building a port from its manifest: the donor's model, rig and moveset on the host's PAC.
4+
5+
`build` runs the steps below in order; each is a function of the ones before, so a tool or a
6+
test can stop at any of them.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from collections.abc import Mapping, Sequence
12+
from dataclasses import dataclass
13+
from typing import Literal
14+
15+
from mhp_formats import fu, p3rd, pmo
16+
from mhp_formats.anim import Clip
17+
from mhp_formats.pac import Pac
18+
from mhp_formats.skeleton import Skeleton
19+
from mhp_formats.tmh import Tmh
20+
21+
from . import constraints, mesh, motion, records, retarget
22+
from . import rig as rigs
23+
from . import skin as skins
24+
from .data import Data
25+
from .manifest import Build, Manifest, Skin
26+
from .mesh import Part, Skinned
27+
from .rig import Rig
28+
29+
SKELETON, MODEL, TEXTURES, ANIMATION = range(4)
30+
"""The host PAC's entries a port replaces; the others and the tail ride along."""
31+
32+
Mode = Literal["source_skeleton", "retarget"]
33+
34+
35+
@dataclass(frozen=True)
36+
class Donor:
37+
"""The MHP3rd monster."""
38+
39+
model: p3rd.Pmo
40+
skeleton: Skeleton
41+
textures: bytes | None
42+
"""Its TMH as stored; None keeps the host's."""
43+
clips: list[Clip | None]
44+
"""Its moveset by slot."""
45+
em: int | None
46+
"""Its species, which picks the record map; None builds unmapped."""
47+
48+
49+
@dataclass(frozen=True)
50+
class Host:
51+
"""The MHFU model PAC the port rides."""
52+
53+
pac: Pac
54+
skeleton: Skeleton
55+
model: pmo.Pmo | None
56+
"""None where the PAC carries no PMO, which rules out a transferred skin."""
57+
anim: fu.Anim
58+
59+
60+
@dataclass(frozen=True)
61+
class Binding:
62+
"""The rig the port ships and how the donor's bones land on it."""
63+
64+
mode: Mode
65+
rig: Rig
66+
bone_of: dict[int, int | None]
67+
"""Output joint -> the donor bone whose motion it plays; None or missing rests."""
68+
joint_of: dict[int, int]
69+
"""Donor bone -> the output joint its skin rides; a bone missing here loses its weight."""
70+
dead: frozenset[int]
71+
"""Animated joints nothing drives, which no guessed skin may ride."""
72+
73+
74+
@dataclass(frozen=True)
75+
class Summary:
76+
mode: Mode
77+
skin: Skin
78+
"""The skin built, after the upgrade and the fallbacks."""
79+
em: int | None
80+
lead_pad: int
81+
streams: tuple[int, ...]
82+
groups: int
83+
vertices: int
84+
clips: int
85+
"""The donor's distinct clips."""
86+
size: int
87+
host_size: int
88+
89+
def text(self) -> str:
90+
em = f"em {self.em}" if self.em is not None else "unmapped (default record offset)"
91+
return "\n".join(
92+
(
93+
f"{self.mode}, skin {self.skin}, {em}",
94+
f"streams {'+'.join(map(str, self.streams))}, lead pad {self.lead_pad}",
95+
f"{self.groups} groups, {self.vertices} vertices, {self.clips} clips",
96+
f"{self.size} bytes, host {self.host_size} ({self.size - self.host_size:+d})",
97+
)
98+
)
99+
100+
101+
@dataclass(frozen=True)
102+
class Built:
103+
pac: bytes
104+
summary: Summary
105+
106+
107+
# 1. the inputs
108+
109+
110+
def em_of(m: Manifest) -> int | None:
111+
"""The manifest's em id, else the model file's; a negative one opts out of the map."""
112+
em = m.source.em_id if m.source.em_id is not None else records.em_for_model(m.source.model)
113+
return em if em is not None and em >= 0 else None
114+
115+
116+
def donor(m: Manifest, data: Data) -> Donor:
117+
"""The donor's model PAC, geometry and moveset."""
118+
raw = data.p3rd.read(m.source.model)
119+
entries = Pac.from_bytes(raw).entries
120+
skeleton = next((Skeleton.from_bytes(e) for e in entries if Skeleton.sniff(e)), None)
121+
if skeleton is None:
122+
raise ValueError(f"MHP3rd file {m.source.model} carries no skeleton")
123+
return Donor(
124+
model=mesh.donor(raw, data.p3rd.read(m.source.geo)),
125+
skeleton=skeleton,
126+
textures=next((e for e in entries if Tmh.sniff(e)), None),
127+
clips=motion.moveset(data.p3rd.read(m.source.anim)),
128+
em=em_of(m),
129+
)
130+
131+
132+
def host(m: Manifest, data: Data) -> Host:
133+
"""The host species' model PAC."""
134+
pac = Pac.from_bytes(data.fu.read(m.port.host_frame))
135+
e = pac.entries
136+
return Host(
137+
pac=pac,
138+
skeleton=Skeleton.from_bytes(e[SKELETON]),
139+
model=pmo.Pmo.from_bytes(e[MODEL]) if pmo.Pmo.sniff(e[MODEL]) else None,
140+
anim=fu.Anim.from_bytes(e[ANIMATION]),
141+
)
142+
143+
144+
# 2. the record map
145+
146+
147+
def record_map(d: Donor, b: Build) -> dict[int, int]:
148+
"""`{donor bone: record}`; the manifest's `bone_offset` and `skip_bones` override the
149+
species' table rows."""
150+
counts = {len(c.tracks) for c in d.clips if c is not None}
151+
if len(counts) != 1:
152+
raise ValueError(f"the moveset's clips disagree on their record count: {sorted(counts)}")
153+
n = counts.pop()
154+
return records.for_monster(d.em, n, len(d.skeleton.bones), b.bone_offset, b.skip_bones)
155+
156+
157+
def animated(b: Build, record_of: Mapping[int, int], bones: int) -> int:
158+
"""The donor bones the port animates: the manifest's count, else up to the last driven
159+
bone, else (or out of range) the whole rig."""
160+
n = b.animated if b.animated is not None else max(record_of, default=bones - 1) + 1
161+
return n if 0 < n <= bones else bones
162+
163+
164+
# 3. the rig
165+
166+
167+
def binding(b: Build, d: Donor, h: Host, animated: int) -> Binding:
168+
"""The donor's own rig with `animated` bones (`source_skeleton`), or the host's with each
169+
host joint matched to a donor bone and the animated joints left unmatched dead."""
170+
if b.source_skeleton:
171+
rig = rigs.from_donor(d.skeleton, h.skeleton, animated)
172+
bone_of: dict[int, int | None] = {j: bone for bone, j in rig.joint_of.items()}
173+
return Binding("source_skeleton", rig, bone_of, dict(rig.joint_of), frozenset())
174+
rig = rigs.from_host(h.skeleton)
175+
match = retarget.match(d.skeleton, h.skeleton)
176+
joint_of: dict[int, int] = {}
177+
for j in sorted(match):
178+
bone = match[j]
179+
if bone is not None:
180+
joint_of.setdefault(bone, j)
181+
dead = frozenset(j for j in range(rig.animated) if match.get(j) is None)
182+
return Binding("retarget", rig, dict(match), joint_of, dead)
183+
184+
185+
# 4. the geometry
186+
187+
188+
def parts(d: Donor, b: Build) -> list[Part]:
189+
"""The donor's draw groups, less those riding a `drop_joints` bone."""
190+
out = mesh.parts(d.model)
191+
return mesh.drop(out, b.drop_joints) if b.drop_joints else out
192+
193+
194+
def skin(parts: Sequence[Part], b: Build, bind: Binding, h: Host) -> tuple[list[Skinned], Skin]:
195+
"""The parts bound to the rig, and the skin used: `auto` means `source` on the donor's own
196+
rig when the donor carries weights; `source` without weights and `transfer` without a host
197+
PMO fall back to `auto`."""
198+
weighted = any(v for p in parts for v in p.influences)
199+
mode = b.skin
200+
if mode == "auto" and bind.mode == "source_skeleton" and weighted:
201+
mode = "source"
202+
if mode == "source" and weighted:
203+
return skins.source(parts, bind.joint_of), "source"
204+
if mode == "transfer" and h.model is not None:
205+
return skins.transfer(parts, h.model, bind.rig.parents, dead=bind.dead), "transfer"
206+
rig = bind.rig
207+
out = skins.auto(parts, rig.bind, rig.parents, exclude=bind.dead, nb=b.nb)
208+
skins.weld(out, rig.bind)
209+
return out, "auto"
210+
211+
212+
# 5. the motion
213+
214+
215+
def animation(
216+
d: Donor, h: Host, bind: Binding, record_of: Mapping[int, int], lift: float = 0.0
217+
) -> fu.Anim:
218+
"""The donor's moveset on the rig's joints, its pelvis raised by `lift` world units."""
219+
clips: Sequence[Clip | None] = d.clips
220+
if lift:
221+
bone_of_record = {r: bone for bone, r in record_of.items()}
222+
parents = [bone.parent for bone in d.skeleton.bones]
223+
pelvis = motion.pelvis(clips, bone_of_record, parents)
224+
if pelvis is not None:
225+
clips = motion.lift(clips, lift, pelvis)
226+
# records are not positional in either mode: joint -> donor bone -> record
227+
track_of = {j: record_of.get(bone) for j, bone in bind.bone_of.items() if bone is not None}
228+
return motion.build(clips, h.anim, bind.rig.streams, track_of)
229+
230+
231+
# 6. the PAC
232+
233+
234+
def pac(h: Host, rig: Rig, model: pmo.Pmo, textures: bytes | None, anim: fu.Anim) -> bytes:
235+
"""The host's PAC with the port's skeleton, PMO, textures and animation in their entries.
236+
237+
Raises `constraints.ConstraintError` where they break an engine rule."""
238+
constraints.check(model, rig.skeleton, anim)
239+
entries = list(h.pac.entries)
240+
entries[SKELETON] = rig.skeleton.to_bytes()
241+
entries[MODEL] = model.to_bytes()
242+
if textures is not None:
243+
entries[TEXTURES] = textures
244+
entries[ANIMATION] = anim.to_bytes()
245+
return Pac(entries, h.pac.align, h.pac.tail).to_bytes()
246+
247+
248+
def build(m: Manifest, data: Data) -> Built:
249+
"""The port's model PAC."""
250+
d, h = donor(m, data), host(m, data)
251+
record_of = record_map(d, m.build)
252+
bind = binding(m.build, d, h, animated(m.build, record_of, len(d.skeleton.bones)))
253+
kept = parts(d, m.build)
254+
skinned, skin_used = skin(kept, m.build, bind, h)
255+
model = mesh.build(skinned, d.model.scale)
256+
anim = animation(d, h, bind, record_of, m.build.ground_lift)
257+
out = pac(h, bind.rig, model, d.textures, anim)
258+
summary = Summary(
259+
mode=bind.mode,
260+
skin=skin_used,
261+
em=d.em,
262+
lead_pad=bind.rig.lead_pad,
263+
streams=tuple(bind.rig.streams),
264+
groups=len(kept),
265+
vertices=sum(len(p.positions) for p in kept),
266+
clips=len({id(c) for c in d.clips if c is not None}),
267+
size=len(out),
268+
host_size=len(h.pac.to_bytes()),
269+
)
270+
return Built(out, summary)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# SPDX-License-Identifier: GPL-3.0-or-later
2+
# SPDX-FileCopyrightText: 2026 sp00ktober
3+
"""The `mhfu-port` command. Each module in this package adds its subcommands through `register`."""
4+
5+
from __future__ import annotations
6+
7+
import argparse
8+
import pkgutil
9+
import sys
10+
from importlib import import_module
11+
from typing import TYPE_CHECKING, TypeAlias
12+
13+
if TYPE_CHECKING:
14+
Subparsers: TypeAlias = argparse._SubParsersAction[argparse.ArgumentParser]
15+
16+
17+
def parser() -> argparse.ArgumentParser:
18+
ap = argparse.ArgumentParser(
19+
prog="mhfu-port", description="Port MHP3rd monsters into MHFU (EU)."
20+
)
21+
sub = ap.add_subparsers(dest="command", required=True, metavar="COMMAND")
22+
for module in sorted(m.name for m in pkgutil.iter_modules(__path__)):
23+
import_module(f"{__name__}.{module}").register(sub)
24+
return ap
25+
26+
27+
def main(argv: list[str] | None = None) -> int:
28+
args = parser().parse_args(argv)
29+
try:
30+
return int(args.run(args) or 0)
31+
except (FileNotFoundError, ValueError) as e:
32+
print(f"mhfu-port {args.command}: {e}", file=sys.stderr)
33+
return 1

0 commit comments

Comments
 (0)