Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/studio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ One Qt window for authoring MHFU mods offline, with the commands behind it. Two
- **Monster**: a ported monster's clips, labels, parts, hitboxes and moves, kept in its manifest
in [`ports/`](../../ports), checked against the base monster's data from the game. Its hit
tables are one set of writes over the base monster's, exported as a Lua module the framework
loads (`<name>_hit.lua`) or pushed into the running game through PPSSPP's debugger. Its Brain
tab reads the base monster's AI script.
loads (`<name>_hit.lua`) or pushed into the running game through PPSSPP's debugger; a port
with a `[brain]` also ships the spare attack records its own moves play, derived from both games
each time and never saved. Its Brain tab reads the base monster's AI script.
- **Map**: edits to a stage's mesh, collision and textures, kept as edit lists in a `map.toml`
document and pushed into the running game through PPSSPP's debugger; and named points (the
Point tool, the Points panel), which `mhfu rig goto|walk` takes the hunter to.
Expand Down
36 changes: 28 additions & 8 deletions apps/studio/src/mhfu_studio/monster/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import argparse
import dataclasses
import sys
from pathlib import Path
from typing import TYPE_CHECKING

Expand All @@ -18,6 +19,7 @@

if TYPE_CHECKING:
from mhfu.em.intel import SpeciesIntel
from mhfu.em.script.check import Finding
from mhfu_port.data import Data
from mhfu_port.manifest import Manifest

Expand Down Expand Up @@ -377,24 +379,42 @@ def _host(args: argparse.Namespace, m: Manifest) -> Host | None:
return host


def _expanded(args: argparse.Namespace, m: Manifest) -> tuple[Manifest, str]:
"""`runtime.expanded`: a port with a `[brain]` also ships the spare attack records its own
moves play; what the derivation says about them goes to stderr."""
from mhfu_port.cli import finding_line

from mhfu_studio.monster import runtime

def say(f: Finding) -> None:
if f.level != "info" or f.code == "P3_OWN_SPARE":
print(finding_line(f), file=sys.stderr)

return runtime.expanded(m, lambda: _games(args), report=say)


def run_hit(args: argparse.Namespace) -> int:
from mhfu_studio.monster import runtime

m = _manifest(args.manifest)
m, source = _expanded(args, _manifest(args.manifest))
host = _host(args, m)
if args.print:
print(runtime.lua_hit_module(m, host), end="")
print(runtime.lua_hit_module(m, host, source), end="")
return 0
path = args.out or Path(runtime.module_name(m))
dep = runtime.ship(m, path, host, library_path=args.library) if args.deploy else None
dep = (
runtime.ship(m, path, host, library_path=args.library, source=source)
if args.deploy
else None
)
if dep is None:
runtime.export(m, path, host)
runtime.export(m, path, host, source)
print(
f"wrote {path} ({len(m.hurtboxes)} volume(s), {len(m.hitzones)} state(s), "
f"{len(runtime.sets_of(m))} attack set(s), {len(m.attacks)} attack record(s), "
f"id {runtime.content_id(m)})"
)
for note in runtime.plan(m, host).notes:
for note in runtime.plan(m, host, source).notes:
print(note)
if dep is not None:
print(f"deployed {dep.describe()}")
Expand All @@ -404,14 +424,14 @@ def run_hit(args: argparse.Namespace) -> int:
def run_push(args: argparse.Namespace) -> int:
from mhfu_studio.monster import push, runtime

m = _manifest(args.manifest)
m, source = _expanded(args, _manifest(args.manifest))
host = _host(args, m)
if args.dry:
for w in runtime.plan(m, host).writes:
for w in runtime.plan(m, host, source).writes:
print(f"0x{w.at:08X} {len(w.data):5d} B {w.what} ({len(w.guards)} guard(s))")
return 0
try:
print(push.to_game(m, host).describe())
print(push.to_game(m, host, source).describe())
except Refused as e:
print(f"refused: {e}")
return 1
Expand Down
40 changes: 37 additions & 3 deletions apps/studio/src/mhfu_studio/monster/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
hold (the pointer that leads to the table, the host set's own sentinel) that no write changes:
another or a relocated overlay fails them, and then nothing is written.

A port with a `[brain]` is planned from `expanded`: the manifest with the spare attack records
its own moves play (`mhfu_port.brain.own`), which are never saved.

The routes: `<name>_hit.lua` on the memory stick, which the framework's `mhfu_port.lua` applies
once the port is live in-area and re-applies after a reload (`export`, `ship`), and `push`,
which writes through the debugger and reads back. The module is regenerated, never edited, and
Expand All @@ -21,14 +24,17 @@
import hashlib
import shutil
import struct
from collections.abc import Mapping
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from pathlib import Path

from mhfu import addresses as a
from mhfu import hitzone
from mhfu.em.intel import SpeciesIntel
from mhfu.writes import Plan, Write
from mhfu_port import manifest
from mhfu_port.brain import own
from mhfu_port.data import Data
from mhfu_port.manifest import Hitbox, Hurtbox, Manifest, ManifestError

from mhfu_studio.shell import places
Expand All @@ -43,8 +49,11 @@
"power": a.ATTACK_RECORD.POWER,
"element": a.ATTACK_RECORD.ELEMENT,
"volume": a.ATTACK_RECORD.VOLUME_SET,
"kind": a.ATTACK_RECORD.KIND,
"active": a.ATTACK_RECORD.UNKNOWN_01,
}
"""The attack record bytes a manifest may set."""
"""The attack record bytes a manifest may set; `active` is the byte the struct leaves unnamed at
+0x01, which counts the frames the node is active (measured)."""
WORD = 4
"""Bytes per group in the module's hex."""

Expand Down Expand Up @@ -162,7 +171,8 @@ def content_id(m: Manifest) -> str:
for hb in vols:
h.update(attack_volume(hb))
for at in m.attacks:
h.update(f"atk{at.id}:{at.power},{at.element},{at.volume};".encode())
more = "" if at.kind is None and at.active is None else f",{at.kind},{at.active}"
h.update(f"atk{at.id}:{at.power},{at.element},{at.volume}{more};".encode())
return h.hexdigest()[:8]


Expand All @@ -171,6 +181,30 @@ def has_tables(m: Manifest) -> bool:
return bool(m.hurtboxes or m.hitzones or m.hitboxes or m.attacks)


def derives(m: Manifest) -> bool:
"""`m`'s `[brain]` has own moves to derive (one the manifest defines itself is not), whose
spare attack records `expanded` adds."""
return m.brain is not None and any(
manifest.own_name(p) not in m.moves for p in own.pairs(m.brain)
)


def expanded(
m: Manifest,
games: Callable[[], Data],
source: str = "",
report: own.Report | None = None,
) -> tuple[Manifest, str]:
"""`m` as the plan takes it, and the source line for the module's header. A port with a
`[brain]` gets the spare attack records and volumes its own moves play, derived from `games()`
(read only then); `ManifestError` where the derivation fails. Any other `m` is returned as
it is."""
if not derives(m):
return m, source
out = own.expanded(m, games(), report)
return out, (source or source_of(m)) + " + the donor's spare attack records"


def module_name(m: Manifest) -> str:
return f"{m.port.name}_hit.lua"

Expand Down
24 changes: 16 additions & 8 deletions apps/studio/src/mhfu_studio/monster/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -1031,19 +1031,25 @@ def revert(self) -> None:
# the runtime module: the manifest as it is now, saved or not (its header says which)

def exportable(self) -> bool:
"""The manifest has tables the runtime module carries."""
"""The manifest has tables the runtime module carries, or its `[brain]` derives some."""
return self.manifest is not None and self._tables(self.manifest)

@staticmethod
def _tables(m: Manifest) -> bool:
from mhfu_studio.monster import runtime

return self.manifest is not None and runtime.has_tables(self.manifest)
return runtime.has_tables(m) or runtime.derives(m)

def _module(self) -> tuple[Manifest, Host | None, str]:
"""What `runtime.export` takes for the document as it is now."""
"""What `runtime.export` takes for the document as it is now: with a `[brain]`, the
spare attack records its own moves play are derived from the games (a few seconds)."""
from mhfu_studio.monster import runtime

if self.doc is None:
raise ValueError("no port manifest open")
m, intel = self.doc.manifest, self.host_intel()
source = f"{runtime.source_of(m)}, unsaved edits" if self.doc.dirty else ""
m, source = runtime.expanded(m, self.games, source)
return m, runtime.host(intel), source

def export_hit(self) -> None:
Expand All @@ -1062,7 +1068,7 @@ def deploy_hit(self) -> None:
m, host, source = self._module()
out = runtime.cache_dir() / runtime.module_name(m)
dep = runtime.ship(m, out, host, mods_dir=self.mods_dir(), source=source)
self._sent = m
self._sent = self.manifest # not `m`: with a `[brain]` that is a derived copy
lib = "" if dep.library is None else f" and {runtime.LIB_SUBDIR}/{runtime.LIBRARY}"
self.message = (
f"sent {dep.module.name}{lib} to {_home(dep.module.parent)}; it applies when a mod"
Expand All @@ -1085,13 +1091,14 @@ def send_blocker(self) -> str | None:
if self.scene is None:
return "no port open: open a port manifest (ports/<name>.toml)"
return "a bare PAC has no hit tables: open its port manifest (.toml) instead"
if not runtime.has_tables(m):
if not self._tables(m):
return (
"nothing to send yet: your port has no hitboxes, hurtboxes or damage grid. Copy"
" the base monster's in Hitboxes or Parts first."
)
try:
runtime.check(m, runtime.host(self.host_intel()))
if runtime.has_tables(m): # a derived table is checked when it is made
runtime.check(m, runtime.host(self.host_intel()))
except ManifestError as e:
return self._cannot("send", m, e)
try:
Expand Down Expand Up @@ -1125,10 +1132,11 @@ def push_blocker(self) -> str | None:
from mhfu_studio.monster import runtime

m = self.manifest
if m is None or not runtime.has_tables(m):
if m is None or not self._tables(m):
return self.send_blocker()
try:
runtime.check(m, runtime.host(self.host_intel()))
if runtime.has_tables(m):
runtime.check(m, runtime.host(self.host_intel()))
except ManifestError as e:
return self._cannot("push", m, e)
return None
Expand Down
13 changes: 13 additions & 0 deletions apps/studio/tests/monster/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,19 @@ def test_hit(offline, games, tmp_path, capsys, monkeypatch):
assert capsys.readouterr().out.endswith("80 B hurtboxes (2 guard(s))\n")


def test_hit_of_a_port_with_a_brain(games, ports, capsys):
data = ["--data", str(games.fu.root), "--p3rd-data", str(games.p3rd.root)]
path = str(ports / "brute_tigrex.toml")
assert main(["port", "hit", path, "--print", *data]) == 0
got = capsys.readouterr()
assert 'P.hit("brute_tigrex", {' in got.out and "-- donor 26" in got.out
assert "+ the donor's spare attack records" in got.out
assert "P3_OWN_SPARE" in got.err and "P3_OWN_BRANCH" not in got.err, "warnings and spares"
assert main(["port", "push", path, "--dry", *data]) == 0
whats = capsys.readouterr().out
assert " attack set " in whats and " kind " in whats and " active " in whats


def test_clips(games, ports, tmp_path, capsys):
data = ["--data", str(games.fu.root), "--p3rd-data", str(games.p3rd.root)]
assert main(["port", "clips", str(ports / "zinogre.toml"), *data]) == 0
Expand Down
Loading
Loading