Skip to content

Commit 2fda613

Browse files
authored
Merge pull request #52 from MHFU-ModKit/hybrid/wave2
feat: hybrid ports wave 2: studio clips, walking, hit push, every clip in game, engine walls
2 parents a28862b + 4964034 commit 2fda613

99 files changed

Lines changed: 6995 additions & 1088 deletions

Some content is hidden

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

‎apps/hud/src/mhfu_hud/reader.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,8 @@ class HudEntity(Entity):
9393
stimulus_tag = u16(a.ENTITY.STIMULUS_TAG)
9494
condition_flags = u32(a.ENTITY.CONDITION_FLAGS)
9595
action_budget = u32(a.ENTITY.ACTION_BUDGET)
96-
prev_main = u8(a.ENTITY.PREV_MAIN)
97-
prev_sub = u8(a.ENTITY.PREV_SUB)
96+
tick_main = u8(a.ENTITY.TICK_MAIN)
97+
tick_sub = u8(a.ENTITY.TICK_SUB)
9898
ai_timer_a = u16(a.ENTITY.AI_TIMER_A)
9999
ai_timer_b = u16(a.ENTITY.AI_TIMER_B)
100100
stress = u16(a.ENTITY.STRESS)
@@ -120,8 +120,8 @@ class HudSpecies(Species):
120120
E.main_state,
121121
E.sub_state,
122122
E.phase,
123-
E.prev_main,
124-
E.prev_sub,
123+
E.tick_main,
124+
E.tick_sub,
125125
E.anim_input,
126126
E.anim_mode,
127127
E.anim_speed,

‎apps/studio/README.md‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
One Qt window for authoring MHFU mods offline, with the commands behind it. Two workspaces:
44

55
- **Monster**: a ported monster's clips, labels, parts, hitboxes and moves, kept in its manifest
6-
in [`ports/`](../../ports), checked against the base monster's data from the game, and
7-
exported as a Lua module the framework loads (`<name>_hit.lua`).
6+
in [`ports/`](../../ports), checked against the base monster's data from the game. Its hit
7+
tables are one set of writes over the base monster's, exported as a Lua module the framework
8+
loads (`<name>_hit.lua`) or pushed into the running game through PPSSPP's debugger.
89
- **Map**: edits to a stage's mesh, collision and textures, kept as edit lists in a `map.toml`
9-
document and pushed into the running game through PPSSPP's debugger.
10+
document and pushed into the running game through PPSSPP's debugger; and named points (the
11+
Point tool, the Points panel), which `mhfu rig goto|walk` takes the hunter to.
1012

1113
## Install
1214

@@ -29,17 +31,29 @@ uv run studio # the window
2931
uv run studio open ports/zinogre.toml # the window on a port
3032
uv run studio port check ports/zinogre.toml # the same checks, on the command line
3133
uv run studio port hit ports/zinogre.toml --deploy # zinogre_hit.lua onto the memory stick
34+
uv run studio port push ports/zinogre.toml # the same writes into the running game
3235
```
3336

3437
| Command | Does |
3538
|---|---|
3639
| `studio open [PATH]` | The window, with PATH open; `--workspace NAME`, `--size WxH` |
3740
| `studio render selftest\|map\|monster` | A stage or a monster to PNG, without a window |
38-
| `studio port scene\|clips\|align\|check\|hit` | A port's contents, clip coverage and labels, timing against the host, checks, and its Lua module |
41+
| `studio port scene\|clips\|align\|check\|hit\|push` | A port's contents, clip coverage and labels, timing against the host, checks, its Lua module, and the same writes into the running game (`MHFU_LANE` picks a lane's) |
3942
| `studio map inject\|edit\|push\|exits\|surfaces\|budget\|textures\|verify` | A map document into the running game, one edit list offline or live, and what a stage holds |
4043

4144
`--help` on any of them says more.
4245

46+
## Naming a port's clips
47+
48+
The Clips panel lists every clip of the original by MHP3rd id (`stream * 100 + slot`) with the
49+
anim (executor entry) that plays it. Click a clip or step with the arrow keys and it plays; type
50+
a name, Return, what it shows, Return: the manifest has it and the next clip plays. A name pins
51+
nothing and moves no clip. Place pins one in another anim, swapping with the clip there, and
52+
builds the port again. Play in game holds the clip's anim on the running game's big monster through
53+
`cli_bridge.lua` (`MHFU_LANE`'s PPSSPP, else the one running); Release lets it go. The game
54+
plays the build last injected, so a clip placed since the manifest was saved is refused until it
55+
is saved and injected again (`mhfu-port inject`).
56+
4357
## Tests
4458

4559
`uv run pytest apps/studio` runs offscreen; the Qt tests skip without the `qt` group, and the

‎apps/studio/src/mhfu_studio/map/document.py‎

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,15 @@
1010
number = 98
1111
ops = "st098.json" # the schema of `stage.ops`
1212
13+
[[point]] # the schema of `mhfu.points`, which `mhfu rig goto` reads
14+
name = "tigrex_wall"
15+
stage = 97
16+
at = [10900.0, 325.0, 9200.0]
17+
1318
Stages naming the same list (a night twin) share it: editing one edits both. The loaded
1419
stage's list is its edit session's own, so undo, redo and dirty go through the session. A stage
15-
whose list is empty (every edit undone) is neither saved nor a change.
20+
whose list is empty (every edit undone) is neither saved nor a change. Points are the
21+
document's own: a change to them is a change, outside undo.
1622
"""
1723

1824
from __future__ import annotations
@@ -25,7 +31,9 @@
2531
from typing import TYPE_CHECKING, Any
2632

2733
import tomli_w
34+
from mhfu import points as P
2835
from mhfu.files import Extracted
36+
from mhfu.points import Point
2937

3038
from mhfu_studio.shell.findings import Finding
3139
from mhfu_studio.stage import collision, mesh, textures
@@ -80,6 +88,7 @@ def __init__(
8088
#: the [map] table's other keys, kept for the round trip
8189
self.extra: dict[str, Any] = dict(extra or {})
8290
self.stages: list[StageEntry] = []
91+
self.points: list[Point] = []
8392
self.session: EditSession | None = None
8493
self.game: Extracted | None = None
8594
self._files: dict[int, StageFile] = {}
@@ -139,6 +148,33 @@ def touch(self) -> None:
139148
"""Something besides the session changed."""
140149
self._changes += 1
141150

151+
# the points
152+
153+
def point(self, name: str) -> Point | None:
154+
return next((p for p in self.points if p.name == name), None)
155+
156+
def put_point(self, point: Point, replacing: str | None = None) -> None:
157+
"""Add `point`, or put it in the place of the point named `replacing`; raises
158+
PointError for a bad point or a name another point has."""
159+
point.check()
160+
others = [p for p in self.points if p.name != replacing]
161+
if any(p.name == point.name for p in others):
162+
raise P.PointError(f"a point is already named {point.name}")
163+
at = next((i for i, p in enumerate(self.points) if p.name == replacing), None)
164+
if at is None:
165+
self.points.append(point)
166+
else:
167+
self.points[at] = point
168+
self.touch()
169+
170+
def remove_point(self, name: str) -> None:
171+
self.points = [p for p in self.points if p.name != name]
172+
self.touch()
173+
174+
def new_point_name(self, base: str = "point") -> str:
175+
names = {p.name for p in self.points}
176+
return next(f"{base}_{k}" for k in range(1, len(names) + 2) if f"{base}_{k}" not in names)
177+
142178
# loading and saving
143179

144180
@classmethod
@@ -176,6 +212,10 @@ def load(cls, path: Path) -> MapDocument:
176212
except ValueError as e:
177213
raise DocumentError(str(e)) from None
178214
doc.stages.append(StageEntry(n, file, lists[file]))
215+
try:
216+
doc.points = P.parse(data.get("point", []))
217+
except P.PointError as e:
218+
raise DocumentError(f"{p}: {e}") from None
179219
doc._saved = doc._state()
180220
return doc
181221

@@ -210,8 +250,12 @@ def manifest(self) -> dict[str, Any]:
210250
if self.description:
211251
table["description"] = self.description
212252
table.update(self.extra)
213-
stages = [{"number": s.number, "ops": s.ops_file} for s in self.kept()]
214-
return {"map": table, "stage": stages} if stages else {"map": table}
253+
out: dict[str, Any] = {"map": table}
254+
if stages := [{"number": s.number, "ops": s.ops_file} for s in self.kept()]:
255+
out["stage"] = stages
256+
if self.points:
257+
out["point"] = P.dump(self.points)
258+
return out
215259

216260
def _lists(self) -> dict[str, list[Op]]:
217261
return {s.ops_file: s.ops for s in self.kept()}
@@ -262,7 +306,7 @@ def findings(self) -> list[Finding]:
262306
"""The op checks of `stage.ops` per stage (with the writers when the game is there),
263307
and the document's own."""
264308
out: list[Finding] = []
265-
if not self.stages and self.directory is not None:
309+
if not self.stages and not self.points and self.directory is not None:
266310
fix = "Open an area in Areas and edit it: the document names it then."
267311
out.append(Finding("warning", "no-stages", "the document names no stage", fix=fix))
268312
for file, users in self.shared_lists().items():
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
# SPDX-License-Identifier: GPL-3.0-or-later
2+
# SPDX-FileCopyrightText: 2026 sp00ktober
3+
"""The Points panel: the document's named points, and the one picked: its name, kind, place and
4+
note. The Point tool sets new ones; `mhfu rig goto|walk` reads them from the saved map.toml.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from typing import TYPE_CHECKING, Any
10+
11+
from PySide6.QtGui import QGuiApplication
12+
from PySide6.QtWidgets import QVBoxLayout, QWidget
13+
14+
from mhfu_studio.shell.text import plain
15+
from mhfu_studio.ui import kit
16+
17+
from ..core.atlas import stage_title
18+
from ..tools import POINT, TOOL
19+
from .common import Gate
20+
21+
if TYPE_CHECKING:
22+
from mhfu_studio.shell.studio import Studio
23+
24+
from ..workspace import MapWorkspace
25+
26+
KINDS = (
27+
("point", "Point: a place to go to"),
28+
("waypoint", "Waypoint: a stop on the way"),
29+
("climb", "Climb: the foot of a ledge"),
30+
)
31+
CLIMB_TIP = (
32+
"Degrees the hunter faces to climb, square on to the face: 0 is +z, 90 is +x. A walk may"
33+
" climb here on its way, and climbs on arriving."
34+
)
35+
36+
37+
def command(name: str, folder: object) -> str:
38+
"""What takes the hunter to the point in the running game."""
39+
where = f" --map {folder}" if folder else ""
40+
return f"mhfu rig goto {name}{where}"
41+
42+
43+
class PointsPanel(kit.Panel):
44+
def __init__(self, ws: MapWorkspace, studio: Studio) -> None:
45+
super().__init__()
46+
self.ws, self.studio = ws, studio
47+
self.items = kit.Items(
48+
tip="The document's named points; this area's first. Click one to edit it (and"
49+
" load its area).",
50+
empty="No point yet: pick the Point tool (P) and click the ground.",
51+
)
52+
self.items.picked.connect(self._pick)
53+
add = kit.button(
54+
"Set a point",
55+
tip="Turns on the Point tool: the next click on the ground sets a point there",
56+
on=studio.act("point tool", lambda: ws.set_tool(TOOL, POINT)),
57+
icon="ph.map-pin",
58+
)
59+
self.name = kit.text_field(
60+
tip="The point's name, what `mhfu rig goto` takes; unique in the document",
61+
on=self._rename,
62+
)
63+
self.kind = kit.choice(
64+
KINDS,
65+
tip="A point is somewhere to go; a waypoint a stop on the way; a climb the foot of"
66+
" a ledge, which a walk may climb",
67+
on=lambda k: self._edit("kind", kind=k),
68+
)
69+
self.heading = kit.number(
70+
tip=CLIMB_TIP, lo=-180, hi=360, step=5, suffix=" deg",
71+
on=lambda v: self._edit("heading", heading=float(v)),
72+
) # fmt: skip
73+
self.at = kit.Vec3(
74+
tip="Where the point is, in the area's world units; y is the ground",
75+
decimals=0,
76+
step=10,
77+
on=lambda v: self._edit("place", at=(v[0], v[1], v[2])),
78+
)
79+
self.note = kit.text_field(
80+
tip="Anything to remember about the point", on=lambda t: self._edit("note", note=t)
81+
)
82+
self.where = kit.label(role="muted", selectable=True)
83+
copy = kit.button(
84+
"Copy goto command",
85+
tip="Copies the command that takes the hunter here in the running game",
86+
on=self._copy,
87+
icon="ph.copy",
88+
)
89+
remove = kit.button(
90+
"Remove",
91+
tip="Takes the point out of the document",
92+
on=self._remove,
93+
icon="ph.trash",
94+
)
95+
form = kit.Form()
96+
form.row("Name", self.name)
97+
form.row("Kind", self.kind)
98+
form.row("Climb", self.heading)
99+
form.row("At", self.at)
100+
form.row("Note", self.note)
101+
form.row("Area", self.where)
102+
self.form = QWidget()
103+
lay = QVBoxLayout(self.form)
104+
lay.setContentsMargins(0, 0, 0, 0)
105+
lay.addWidget(form)
106+
lay.addWidget(kit.row(copy, remove, stretch=True))
107+
108+
page = QWidget()
109+
lay = QVBoxLayout(page)
110+
lay.setContentsMargins(0, 0, 0, 0)
111+
lay.addWidget(self.items)
112+
lay.addWidget(kit.row(add, stretch=True))
113+
lay.addWidget(self.form)
114+
lay.addStretch(1)
115+
self.gate = Gate(page, "set points on it")
116+
self.body.addWidget(self.gate)
117+
118+
# ---- actions ---------------------------------------------------------------------- #
119+
120+
def _pick(self, name: object) -> None:
121+
if isinstance(name, str):
122+
self.studio.act(f"pick {name}", lambda: self.pick(name))()
123+
124+
def pick(self, name: str) -> None:
125+
"""Edit the point `name`, loading its area when another is loaded."""
126+
ws, p = self.ws, self.ws.doc.point(name)
127+
if p is None:
128+
return
129+
ws.point = name
130+
if p not in ws.points_here():
131+
ws.load_stage(p.stage)
132+
133+
def _edit(self, what: str, **changes: Any) -> None:
134+
name = self.ws.point
135+
if name is not None:
136+
self.studio.act(
137+
f"point {what}", lambda: self._say(self.ws.edit_point(name, **changes))
138+
)()
139+
140+
def _rename(self, text: str) -> None:
141+
if self.ws.point is not None and text.strip() != self.ws.point:
142+
self._edit("name", name=text.strip())
143+
144+
def _say(self, refusal: str | None) -> None:
145+
if refusal:
146+
self.studio.message = f"point not changed: {refusal}"
147+
148+
def _remove(self) -> None:
149+
name = self.ws.point
150+
if name is not None:
151+
self.studio.act(f"remove {name}", lambda: self.ws.remove_point(name))()
152+
153+
def _copy(self) -> None:
154+
if self.ws.point is not None:
155+
text = command(self.ws.point, self.ws.doc.directory)
156+
QGuiApplication.clipboard().setText(text)
157+
self.studio.message = f"copied: {text}"
158+
159+
# ---- sync ------------------------------------------------------------------------- #
160+
161+
def sync(self) -> None:
162+
ws = self.ws
163+
if not self.gate.check(ws, section=False):
164+
return
165+
here = {p.name for p in ws.points_here()}
166+
ordered = sorted(ws.doc.points, key=lambda p: p.name not in here)
167+
self.items.set_items(
168+
[
169+
kit.Item(
170+
f"{p.name} ({stage_title(p.stage)})"
171+
+ ("" if p.kind == "point" else f", {p.kind}"),
172+
p.name,
173+
plain(p.note) or command(p.name, ws.doc.directory),
174+
)
175+
for p in ordered
176+
]
177+
)
178+
p = ws.doc.point(ws.point) if ws.point else None
179+
self.form.setVisible(p is not None)
180+
if p is None:
181+
return
182+
kit.put(self.name, p.name)
183+
kit.put(self.kind, p.kind)
184+
kit.put(self.heading, p.heading or 0.0)
185+
self.heading.setEnabled(p.kind == "climb")
186+
kit.put(self.at, p.at)
187+
kit.put(self.note, p.note)
188+
self.where.setText(f"{stage_title(p.stage)} (st{p.stage:03d})")

0 commit comments

Comments
 (0)