Skip to content

Commit e03deb4

Browse files
authored
Merge pull request #48 from MHFU-ModKit/blender/70
Blender extension on mhp-formats and mhfu-port
2 parents 9fc1825 + 987a639 commit e03deb4

57 files changed

Lines changed: 4088 additions & 616 deletions

Some content is hidden

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

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ dist/
88
.env
99
*.gen.h
1010
*.gen.lua
11+
# the Blender extension's pytest for Blender's Python (dist/ is above)
12+
apps/blender/.cache/
1113

1214
# game data never enters the repo
1315
*.iso

‎REUSE.toml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +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, and the porter and the studio are built
16-
# on it; their sources also carry SPDX headers
15+
# the file-format library contains code derived from mhff, and the porter, the studio and the
16+
# Blender extension are built on it; their sources also carry SPDX headers
1717
[[annotations]]
18-
path = ["packages/mhp-formats/**", "packages/mhfu-port/**", "apps/studio/**"]
18+
path = ["packages/mhp-formats/**", "packages/mhfu-port/**", "apps/studio/**", "apps/blender/**"]
1919
SPDX-FileCopyrightText = "2026 sp00ktober"
2020
SPDX-License-Identifier = "GPL-3.0-or-later"

‎apps/blender/README.md‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# mhfu-blender
2+
3+
A Blender 4.2+ extension for MHFU monster models: import a PAC, edit it, export it as a PAC and
4+
push it into the running game. It reads and writes through `mhp-formats` and `mhfu-port`.
5+
6+
```bash
7+
uv run apps/blender/build.py # apps/blender/dist/mhfu_blender-<version>.zip
8+
uv run apps/blender/build.py test # build, install into a throwaway profile, run tests/ in Blender
9+
```
10+
11+
`--blender PATH` (or `$BLENDER`) picks the Blender; the default is
12+
`/Applications/Blender.app` on macOS and `blender` on `PATH` elsewhere. Install the zip from
13+
Blender's Preferences > Get Extensions > Install from Disk.
14+
15+
## Import
16+
17+
File > Import > MHFU Model (.bin), or the MHFU tab of the 3D view's sidebar, reads an MHFU
18+
monster model PAC (`file_0{em + 6110}`), a built port, or an MHP3rd model PAC. An MHP3rd model
19+
finds its geometry (`file_N+1`) and moveset (`file_N+2`) next to it unless you name them, and its
20+
em id, which picks the record map, from its file name unless you give it.
21+
22+
- **Units.** Everything inside the armature is in game units with Y up. The armature object is
23+
turned Z up and scaled 0.01 for show; that transform is not part of the model, so edit in Edit
24+
and Pose Mode and leave the mesh objects' own transforms alone.
25+
- **Bones** are `jNN` (joint NN, plus the MHP3rd name) and all rest pointing up, so a pose bone's
26+
channels are the game's: rotation is Euler XYZ in radians, location is the offset from the bind
27+
position. Scale and the unnamed channels ride on the bone's `mhfu_ch_*` custom properties and
28+
move nothing: the game ignores scale and MHFU crashes on it.
29+
- **Meshes**: one object per PMO group, weighted by the file's own skin; keep each group's vertex
30+
order.
31+
- **Clips**: one Action per animation slot, named `clip_NN`. Pick one in the MHFU tab, which also
32+
resets the pose: switching in the Action Editor leaves a channel the new clip does not key
33+
where the old one left it, while the game plays it at bind.
34+
- **Keys.** Between two keys the game draws a cubic whose slopes are the keys' eases; Blender
35+
draws the same curve from Bezier keys with Free handles a third of the way to the next key.
36+
What reaches the game is each key's frame (whole frames), value (rotation in 1/4096 of 90
37+
degrees, location in 1/16 unit) and the slope of each handle, not its length; a Linear segment
38+
goes straight, other modes are read by their handles.
39+
- The .blend keeps the source files' bytes, so export does not need them again.
40+
41+
## Export
42+
43+
File > Export > MHFU PAC (.bin), or Export PAC in the MHFU tab, writes the selected monster as a
44+
model PAC. An untouched monster comes out byte for byte.
45+
46+
- **Groups.** A group whose vertex count and faces are unchanged is written in place: positions,
47+
UVs, the normals you changed, and weights over the joints its palette already holds. The PAC keeps its size. Adding or deleting vertices or faces, or weighting a vertex to a
48+
joint the group has no palette slot for, rebuilds that group (8 joints at most) and the PAC
49+
grows. A group whose object is deleted draws nothing; a vertex with no weight is refused.
50+
- **Objects.** Moving a mesh object in Object Mode moves its vertices in the game. Modifiers and
51+
shape keys are not applied. A PMO vertex has one UV, so a vertex on a UV seam is split into one
52+
per UV, which rebuilds its group.
53+
- **Skeleton.** The bones' rest heads are the bind pose, so Edit Mode moves ship. Adding, deleting
54+
or re-parenting a bone is refused; a bone's rest rotation and length are not part of the model.
55+
- **Clips.** Every slot's Action is written back and a slot nobody edited stays byte for byte; a
56+
key keeps what the Keys item above lists. A bone whose keys are all deleted holds its bind pose
57+
(an empty track would collapse its mesh).
58+
- **Checks.** `mhfu-port`'s engine rules run on the result: an error the source does not have
59+
stops the export, the rest are reported.
60+
- **MHP3rd.** An MHP3rd model exports as an MHFU PAC with its geometry, skeleton and textures and
61+
no clips. An in-game monster from it is a port: build it from a manifest (`mhfu-port build`).
62+
63+
## Push to Game
64+
65+
Push to Game in the MHFU tab exports into the memory stick's inject folder,
66+
`PSP/PLUGINS/mhfu_framework/inject/`, with the source as the `.orig` the framework recognises the
67+
loaded file by; the file id comes from the imported `file_NNNNN` name. A PAC of the source's size
68+
replaces the file in place (`file_NNNNN.bin`), a larger one takes the relocate path
69+
(`file_NNNNN_grown.bin`). A Lua mod loads it; the report gives the call. The memory stick is the
70+
extension's preference, else `$MHFU_MEMSTICK`, else PPSSPP's default folder.
71+
72+
The zip carries its dependencies as pure-Python wheels at their `uv.lock` versions, so one zip
73+
serves every OS. numpy is Blender's own; packages with no pure wheel (psutil, rabbitizer) are left
74+
out, so the extension must not import the live-game chain that needs them.
75+
76+
Tests that need Blender take the `bpy` fixture and skip anywhere else; `build.py test` runs them
77+
inside Blender with pytest from a cached directory under `.cache/`, never inside Blender itself.
78+
79+
Licensed GPL-3.0-or-later: it is built on `mhp-formats`, which contains code derived from
80+
[mhff](https://github.com/svanheulen/mhff).

‎apps/blender/build.py‎

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# SPDX-License-Identifier: GPL-3.0-or-later
2+
# SPDX-FileCopyrightText: 2026 sp00ktober
3+
"""Build the extension zip into dist/; `test` also runs tests/ inside Blender.
4+
5+
uv run apps/blender/build.py [build | test [PYTEST_ARGS]] [--blender PATH]
6+
"""
7+
8+
import argparse
9+
import json
10+
import os
11+
import re
12+
import shutil
13+
import subprocess
14+
import sys
15+
import tempfile
16+
import tomllib
17+
from pathlib import Path
18+
from typing import Any
19+
20+
HERE = Path(__file__).resolve().parent
21+
ROOT = HERE.parents[1]
22+
SOURCE = HERE / "src" / "mhfu_blender"
23+
DIST = HERE / "dist"
24+
CACHE = HERE / ".cache"
25+
PROJECT = tomllib.loads((HERE / "pyproject.toml").read_text())["project"]
26+
ID = tomllib.loads((SOURCE / "blender_manifest.toml").read_text())["id"]
27+
REPO = "user_default" # Blender's built-in local repository
28+
PROVIDED = {"numpy"} # Blender's own; a bundled copy would shadow it for every extension
29+
MACOS = "/Applications/Blender.app/Contents/MacOS/Blender"
30+
31+
32+
def run(*args: str | Path, env: dict[str, str] | None = None, capture: bool = False) -> str:
33+
sys.stdout.flush() # keeps our lines in order with the command's
34+
pipe = subprocess.PIPE if capture else None
35+
done = subprocess.run(
36+
[str(a) for a in args], check=True, text=True, cwd=ROOT, env=env, stdout=pipe
37+
)
38+
return done.stdout or ""
39+
40+
41+
def extension(blender: str, *args: str | Path, env: dict[str, str] | None = None) -> None:
42+
run(blender, "--command", "extension", *args, env=env)
43+
44+
45+
def locked(*args: str) -> list[dict[str, Any]]:
46+
"""uv.lock's packages for these `uv export` arguments, as pylock.toml entries."""
47+
out = run(
48+
"uv", "export", "--frozen", "--format=pylock.toml", "--no-emit-project", *args, capture=True
49+
)
50+
packages: list[dict[str, Any]] = tomllib.loads(out)["packages"]
51+
return packages
52+
53+
54+
def stage_wheels(wheels: Path) -> None:
55+
"""The dependency closure as pure wheels: workspace members built, the rest as uv.lock pins."""
56+
members, pins, skipped = [], [], set()
57+
for package in locked("--package", PROJECT["name"], "--no-default-groups"):
58+
name = package["name"]
59+
pure = [w for w in package.get("wheels", []) if w["url"].endswith("-none-any.whl")]
60+
if "directory" in package:
61+
members.append(name)
62+
elif pure and name not in PROVIDED:
63+
pins.append(f"{name} @ {pure[0]['url']} --hash=sha256:{pure[0]['hashes']['sha256']}\n")
64+
else:
65+
skipped.add(name)
66+
for name in members:
67+
run("uv", "build", "--quiet", "--package", name, "--wheel", "--out-dir", wheels)
68+
requirements = wheels.parent / "requirements.txt"
69+
requirements.write_text("".join(pins))
70+
run("uvx", "pip", "download", "--quiet", "--no-deps", "--dest", wheels, "-r", requirements)
71+
print("skipped (Blender's own, or no pure wheel):", ", ".join(sorted(skipped)))
72+
73+
74+
def build(blender: str) -> Path:
75+
"""Stage the source with its wheels, build the zip and validate it."""
76+
archive = DIST / f"{ID}-{PROJECT['version']}.zip"
77+
with tempfile.TemporaryDirectory() as tmp:
78+
stage = Path(tmp) / ID
79+
shutil.copytree(SOURCE, stage, ignore=shutil.ignore_patterns("__pycache__"))
80+
stage_wheels(stage / "wheels")
81+
wheels = sorted(f"./wheels/{w.name}" for w in (stage / "wheels").glob("*.whl"))
82+
print("bundled:", ", ".join(w.removeprefix("./wheels/") for w in wheels))
83+
manifest = stage / "blender_manifest.toml"
84+
text = manifest.read_text()
85+
table = text.index("\n[") + 1 # top-level keys go before the first table
86+
keys = f'version = "{PROJECT["version"]}"\nwheels = {json.dumps(wheels)}\n\n'
87+
manifest.write_text(text[:table] + keys + text[table:])
88+
DIST.mkdir(exist_ok=True)
89+
extension(blender, "build", "--source-dir", stage, "--output-filepath", archive)
90+
extension(blender, "validate", archive)
91+
return archive
92+
93+
94+
def _marker(package: dict[str, Any]) -> str:
95+
return f"; {package['marker']}" if "marker" in package else ""
96+
97+
98+
def pytest_dir(python: str) -> Path:
99+
"""pytest at uv.lock's pins for Blender's Python, installed once under .cache/."""
100+
pins = [p for p in locked("--only-group", "dev", "--no-emit-workspace") if "version" in p]
101+
version = next(p["version"] for p in pins if p["name"] == "pytest")
102+
target = CACHE / f"pytest-{version}-py{python}"
103+
if target.is_dir():
104+
return target
105+
CACHE.mkdir(exist_ok=True)
106+
with tempfile.TemporaryDirectory(dir=CACHE) as tmp: # renamed into place once complete
107+
constraints = Path(tmp) / "constraints.txt"
108+
constraints.write_text("".join(f"{p['name']}=={p['version']}{_marker(p)}\n" for p in pins))
109+
install = Path(tmp) / "pytest"
110+
run("uv", "pip", "install", "--quiet", "--only-binary=:all:", "--target", install,
111+
"--python-version", python, "-c", constraints, "pytest") # fmt: skip
112+
install.rename(target)
113+
return target
114+
115+
116+
def test(blender: str, archive: Path, pytest_args: list[str]) -> int:
117+
"""Install the zip into a throwaway profile and run pytest inside Blender; pytest's code."""
118+
with tempfile.TemporaryDirectory() as profile:
119+
env = {**os.environ, "BLENDER_USER_RESOURCES": profile}
120+
extension(blender, "install-file", "-r", REPO, "-e", archive, env=env)
121+
probe = "import sys; print('PYTHON=%d.%d' % sys.version_info[:2])"
122+
out = run(blender, "-b", "--factory-startup", "--python-expr", probe, env=env, capture=True)
123+
python = re.search(r"^PYTHON=(\S+)$", out, re.M)
124+
assert python, out
125+
paths = [str(pytest_dir(python[1])), str(ROOT / "packages" / "modkit-testing" / "src")]
126+
args = [str(HERE / "tests"), "-p", "modkit_testing", "-p", "no:cacheprovider"]
127+
args += ["-W", "ignore::pytest.PytestConfigWarning", *pytest_args] # root's asyncio keys
128+
expr = f"import sys; sys.path[:0] = {paths}; import pytest; sys.exit(pytest.main({args}))"
129+
env["MHFU_BLENDER_EXTENSION"] = f"bl_ext.{REPO}.{ID}"
130+
command = [blender, "-b", "--factory-startup", "--python-exit-code", "1"]
131+
return subprocess.run([*command, "--python-expr", expr], cwd=ROOT, env=env).returncode
132+
133+
134+
def main() -> int:
135+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
136+
parser.add_argument("command", nargs="?", choices=["build", "test"], default="build")
137+
default = os.environ.get("BLENDER") or (MACOS if sys.platform == "darwin" else "blender")
138+
parser.add_argument("--blender", default=default, help="its binary (default: %(default)s)")
139+
args, pytest_args = parser.parse_known_args()
140+
if pytest_args and args.command != "test":
141+
parser.error(f"unrecognized arguments: {' '.join(pytest_args)}")
142+
archive = build(args.blender)
143+
print(f"{archive} ({archive.stat().st_size / 1e6:.1f} MB)")
144+
return test(args.blender, archive, pytest_args) if args.command == "test" else 0
145+
146+
147+
if __name__ == "__main__":
148+
try:
149+
sys.exit(main())
150+
except subprocess.CalledProcessError as error: # the command has printed why
151+
sys.exit(error.returncode)

‎apps/blender/pyproject.toml‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[project]
2+
name = "mhfu-blender"
3+
version = "0.1.0"
4+
description = "Blender extension: import MHFU monster PACs, edit them, export them and push them into the running game."
5+
readme = "README.md"
6+
requires-python = ">=3.11"
7+
license = "GPL-3.0-or-later"
8+
dependencies = ["mhp-formats", "mhfu-port", "mhfu"]
9+
10+
[build-system]
11+
requires = ["uv_build>=0.11,<0.12"]
12+
build-backend = "uv_build"
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# SPDX-License-Identifier: GPL-3.0-or-later
2+
# SPDX-FileCopyrightText: 2026 sp00ktober
3+
"""MHFU ModKit for Blender: import MHFU monster PACs, export them and push them into the game.
4+
5+
bpy is imported only on registering, so the modules that need none (`curves`, `stored`, `write`)
6+
import anywhere.
7+
"""
8+
9+
from typing import Any
10+
11+
12+
def classes() -> tuple[type, ...]:
13+
"""Operators and panels, registered in this order; bl_idname and prefs keyed on __package__."""
14+
from . import exporter, importer
15+
16+
return importer.CLASSES + exporter.CLASSES
17+
18+
19+
def menus() -> tuple[tuple[str, Any], ...]:
20+
"""`(menu type, draw function)` pairs appended to Blender's menus."""
21+
from . import exporter, importer
22+
23+
return importer.MENUS + exporter.MENUS
24+
25+
26+
def register() -> None:
27+
import bpy
28+
29+
for cls in classes():
30+
bpy.utils.register_class(cls)
31+
for menu, draw in menus():
32+
getattr(bpy.types, menu).append(draw)
33+
34+
35+
def unregister() -> None:
36+
import bpy
37+
38+
for menu, draw in reversed(menus()):
39+
getattr(bpy.types, menu).remove(draw)
40+
for cls in reversed(classes()):
41+
bpy.utils.unregister_class(cls)

0 commit comments

Comments
 (0)