Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0675534
mhfu-port: a move plays its clip's facing in every turn mode, and the…
sp00ktober Oct 26, 2026
e9da897
mhfu, mhfu-port: the walk answers the donor's turn rates
sp00ktober Oct 26, 2026
4f4fed3
mhfu-port: a step ends at the walk's cursor, a jump on no wall and at…
sp00ktober Oct 26, 2026
f70ce44
studio: the Moves panel shows and keeps an aim turn
sp00ktober Oct 26, 2026
0915222
mhfu-port: a turn rate the walk cannot work out is said
sp00ktober Oct 26, 2026
aba29bb
test: new build digests
sp00ktober Oct 26, 2026
247b78c
mhfu: the walker negates, takes the larger and converts, and can repo…
sp00ktober Oct 26, 2026
57bcf59
mhfu-port: the walk answers the target's bearing and finds the arms o…
sp00ktober Oct 26, 2026
2c6a8b0
mhfu-port: a picker among own moves, entered as arcs of its variants
sp00ktober Oct 26, 2026
f287486
mhfu-port: a handler that plays by the bearing derives a picker over …
sp00ktober Oct 26, 2026
008ea94
ports: the zinogre's turns in place are the donor's, and the registry…
sp00ktober Oct 26, 2026
fc1810e
mhfu-port: a turn by code that depends on the bearing in no way an ai…
sp00ktober Oct 26, 2026
13a1406
docs, studio: pickers, code turns and the combo form; an aim steer is…
sp00ktober Oct 26, 2026
84fc6da
test: the spare records' walk stub has arms
sp00ktober Oct 26, 2026
73d117e
test: a wrapper's targets are its own move and the move it leads to
sp00ktober Oct 26, 2026
b01595a
mhfu-port: docstrings of the arms and the picker, the own listing's p…
sp00ktober Oct 26, 2026
e4da157
mhfu-port: one rule gives a combo's id
sp00ktober Oct 26, 2026
db384a9
docs(framework): the registry's sizes live in addresses.toml only
sp00ktober Oct 26, 2026
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
15 changes: 10 additions & 5 deletions apps/studio/src/mhfu_studio/monster/panels/moves.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from mhfu_port import sequence
from mhfu_port.behaviour import KINDS
from mhfu_port.manifest import MOVE_ATTACKS, TURNS, Move
from mhfu_port.manifest import DERIVED_TURNS, MOVE_ATTACKS, TURNS, Move
from PySide6.QtWidgets import QAbstractSpinBox, QSpinBox, QVBoxLayout, QWidget

from mhfu_studio.monster import validate as V
Expand All @@ -31,10 +31,13 @@
TURN_WORDS = {
"clip": ("Clip's", "Turns as the clip does: its turn in Clips, or the ring in the view"),
"still": ("Still", "Does not turn"),
"hunter": ("Hunter", "Turns toward the hunter, at most Rate a frame"),
"away": ("Away", "Turns away from the hunter, at most Rate a frame"),
"hunter": ("Hunter", "Turns toward the target, at most Rate a frame"),
"away": ("Away", "Turns away from the target, at most Rate a frame"),
"fixed": ("Fixed", "Turns Angle degrees, spread evenly over Frames AI frames"),
"aim": ("Aim", "Turns exactly onto the target along the donor's code turn; derived moves"),
}
SHOWN = (*TURNS, *DERIVED_TURNS)
"""The turn modes the row shows: the ones a person picks, then the derived ones."""
GAME_NOTE = (
"Own moves reach the game from the moves module mhfu-port generates from this file with the"
" port, not through Copy Lua; a mod or the behaviour graph plays them by name."
Expand Down Expand Up @@ -159,9 +162,9 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None:

steer = kit.Form()
self.turn = kit.Segmented(
[(t, TURN_WORDS[t][0]) for t in TURNS],
[(t, TURN_WORDS[t][0]) for t in SHOWN],
tip="How the move turns the monster",
tips={t: TURN_WORDS[t][1] for t in TURNS},
tips={t: TURN_WORDS[t][1] for t in SHOWN},
on=lambda t: act("turn", lambda: ws.set_steer(turn=t))(),
)
steer.row("Turn", self.turn)
Expand Down Expand Up @@ -498,6 +501,8 @@ def _sync_own(self, name: str, mv: Move) -> None:
kit.refill(self.after, [("", "the brain picks"), *others], mv.after or "")
s = mv.steer
kit.put(self.turn, s.turn)
for derived in DERIVED_TURNS: # one the move has no code turn for would be refused
self.turn.buttons[derived].setEnabled(bool(mv.code_turn))
steers = s.turn in ("hunter", "away")
self.rate.setVisible(steers)
self.rate_label.setVisible(steers)
Expand Down
4 changes: 3 additions & 1 deletion apps/studio/src/mhfu_studio/monster/turn.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,11 @@ def target(self) -> Target | None:
return Target(s.angle or 0.0, "steer", f"fixed: over {s.frames} AI frames")
if s.turn == "still":
return Target(0.0, None, "still: no turn")
if s.turn == "aim":
return Target(None, None, "aim: turns exactly the angle to the target by its code turn")
rate = "the charge's rate" if s.rate is None else f"{s.rate} a frame"
way = "toward" if s.turn == "hunter" else "away from"
return Target(None, None, f"turns {way} the hunter at {rate}")
return Target(None, None, f"turns {way} the target at {rate}")

# drawing

Expand Down
2 changes: 1 addition & 1 deletion apps/studio/src/mhfu_studio/monster/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@ def own_moves(
"""What the moves module refuses (`mhfu_port.moves.check` against the layout `sources` and
the PAC's clips): one finding per own move, then the rest of the manifest once those pass;
and a window that ends past its clip."""
mine = sorted((n, mv) for n, mv in m.moves.items() if mv.own)
mine = sorted((n, mv) for n, mv in m.moves.items() if mv.played)
try:
table = clips.pac_clip_table(pac) if pac is not None else {}
except ValueError:
Expand Down
2 changes: 1 addition & 1 deletion apps/studio/tests/monster/test_behaviour.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ def test_renaming_a_move_the_brain_plays_follows_its_pairs(ports: Path) -> None:
clips.rename_move(z, "dash", "rush")
assert z.manifest.brain is not None
assert z.manifest.brain.targets((2, 4)) == ["rush"]
assert z.manifest.brain.targets((7, 29)) == [(1, 2), "rush"]
assert z.manifest.brain.targets((7, 29)) == ["p3_7_29", "rush"]
assert clips.move_users(z.manifest, "rush") == [
"brain.pairs.2,4",
"brain.pairs.7,29",
Expand Down
14 changes: 13 additions & 1 deletion apps/studio/tests/monster/test_turn.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ def test_a_fixed_steer_is_dragged(carried: MonsterWorkspace) -> None:

@pytest.mark.parametrize(
("turn", "end", "text"),
[("still", 0.0, "still"), ("hunter", None, "toward the hunter"), ("away", None, "away")],
[("still", 0.0, "still"), ("hunter", None, "toward the target"), ("away", None, "away")],
)
def test_other_steers_show_and_refuse(
carried: MonsterWorkspace, turn: str, end: float | None, text: str
Expand All @@ -150,6 +150,18 @@ def test_other_steers_show_and_refuse(
assert "nothing to drag" in ws.message


def test_an_aim_steer_is_labelled_as_one(carried: MonsterWorkspace) -> None:
ws = carried
ws.play_slot(1)
ws.new_move("stamp")
mv = ws.own_move()
assert mv is not None
mv.steer, mv.code_turn = Steer("aim"), [0, 4096, 8192]
t = ws.turn.target()
assert t is not None and t.end is None and t.writes is None
assert "aim" in t.text and "target" in t.text and "away" not in t.text


def test_t_hides_it(carried: MonsterWorkspace) -> None:
ws = carried
ws.play_slot(1)
Expand Down
21 changes: 21 additions & 0 deletions apps/studio/tests/monster/ui/test_moves.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,27 @@ def test_steer(panel: MovesPanel, workspace: MonsterWorkspace) -> None:
assert workspace.own_move().steer.rate == 64 # type: ignore[union-attr]


def test_an_aim_move_shows_and_round_trips(panel: MovesPanel, workspace: MonsterWorkspace) -> None:
panel.new.click()
aim = panel.turn.buttons["aim"]
assert not aim.isEnabled(), "no code turn: the game would refuse it"
assert "target" in panel.turn.buttons["hunter"].toolTip() and "target" in aim.toolTip()
name, turn = workspace.move, [0, 2048, 4096] # a derivation gives the move its code turn
assert name is not None and workspace.doc is not None
workspace.doc.edit(lambda m: setattr(m.moves[name], "code_turn", list(turn)))
workspace.set_steer(turn="aim")
panel.sync()
assert panel.turn.value == "aim" and aim.isChecked() and aim.isEnabled()
assert not panel.rate.isVisible() and not panel.angle.isVisible()
panel.turn.buttons["hunter"].click()
mv = workspace.own_move()
assert mv is not None and mv.steer.turn == "hunter" and not aim.isChecked()
aim.click()
mv = workspace.own_move()
assert mv is not None and (mv.steer.turn, mv.steer.rate, mv.code_turn) == ("aim", None, turn)
assert panel.turn.value == "aim" and kit.missing_tips(panel) == []


def test_rename_and_delete(panel: MovesPanel, workspace: MonsterWorkspace) -> None:
panel.new.click()
panel.name.setText("stomp")
Expand Down
54 changes: 41 additions & 13 deletions docs/formats/p3rd_ai_script.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ keeps everything else: its roaming, sections, target pick and interrupts.

| Donor | Host |
|---|---|
| `enter m s` | `enter M S 0`, the pair from `[brain.pairs]`; an own move there: `enter 16 k 0`; a list there: one `enter` per item |
| `enter m s` | `enter M S 0`, the pair from `[brain.pairs]`; an own move there: `enter 16 k 0`; a list there: one `enter` per item; a picker (below): nested `if_target_in_arc`, one `enter` (or combo) per variant |
| `random` | `random`: each weight `w` becomes `w * 32 / 100` by largest remainder, at least 1; a zero arm goes. Too many live arms for MHFU's 13 branches (the padding one counts): an outer `random` over groups of at most 12 and an inner `random` per group, all reading one draw (`P3_SPLIT`, below) |
| `switch_range` | `switch_range`: case `k` is bucket `k + 1`, the default the last, a missing case copies the default |
| `switch_mode` | `if_mode2` for mode 1, the default as its else |
Expand Down Expand Up @@ -133,16 +133,19 @@ donor's `enter 7 13` becomes `enter 16 k 0`: main 16 is
`OWN_MOVE_MAIN`, which no species uses, and `k` the move's registry slot. em_vhook plays move `k`
in place of the pair and the script goes on after the `enter` when it ends, as for a host pair.

A value may be a list, played in order, each item one `enter` that yields: `"7,23" = [[1, 2], "own"]`
A value may be a list, played in order, each item one `enter` that yields: `"7,5" = [[1, 2], "own"]`
is the host's turn (1, 2), then the move. An item is a host pair, an own move's name or `"own"`; the
build charges the donor pair's stamina to the last item.

A pair can also name the move another pair derives, `"7,23" = [[1, 2], "p3_7_0"]` where `"7,0" =
A pair can also name the move another pair derives, `"7,23" = ["own", "p3_7_0"]` where `"7,0" =
"own"`: a turn wrapper (a donor pair that turns toward the target, then enters another pair)
plays the move of the pair it leads to rather than a duplicate of its own handler, and twin pairs
(same clips, attacks and steer) share one move, which saves slots and turn keys. The name must be
`own_name` of a pair mapped to `"own"` (`Brain.derived`), and that pair is not reported unused
while another pair plays its move (`Brain.shared`).
turns by its own derived move, then plays the move of the pair it leads to rather than a duplicate
of its own handler, and twin pairs (same clips, attacks and steer) share one move, which saves
slots and turn keys. The name must be `own_name` of a pair mapped to `"own"` (`Brain.derived`), and
that pair is not reported unused while another pair plays its move (`Brain.shared`). A wrapper's
own move plays other turns by the bearing of the target, so it is a picker (below): the script
enters one combo of a variant and the move after it for each arc, `enter 16 id 0` with an id from
the framework's `OWN_MOVES` up.

The slots are `mhfu_port.moves.slots`: the own moves by sorted name, numbered from 0, at most
`EM_MOVES.MOVES.count`. The moves module `mhfu-port` writes carries each one as `slot = k` and
Expand All @@ -161,9 +164,10 @@ The donor's handler for the pair is run an AI frame at a time on the clip cursor
ask about (`mhfu_port.p3hits`), which gives a chain of steps, `p3_7_14`, `p3_7_14_2`, ..., each a
clip that hands to the next by `after`:

- **Clips.** The default path. A branch on something the build cannot know (a bearing, a counter,
a wait) is tried each way and the path that spawns most attacks and plays fewest clips is taken;
the others are named in `P3_OWN_BRANCH`. A clip the donor starts past its first frame gets that
- **Clips.** The default path. A branch on something the build cannot know (a counter, a wait) is
tried each way and the path that spawns most attacks and plays fewest clips is taken; the others
are named in `P3_OWN_BRANCH`. A branch on the bearing of the target (the side rule, a compare of
the angle to it) is not tried: see Arms. A clip the donor starts past its first frame gets that
`start`; one the handler leaves at a frame it tests gets that frame as `stop`. Both are clip
frames, so they hold at any blend and cursor rate (a blend holds the cursor 2 to 5 AI frames,
enraged it runs 2.8 a frame). Only a step that ended on no frame of its clip (a pair, a loop,
Expand All @@ -187,9 +191,33 @@ clip that hands to the next by `after`:
0.25 there is no match and the record takes a spare host record (below). `[brain.attacks]
"26" = 40` names the host record for donor record 26; the port may define it with `[[attack]]`
and `[[hitbox]]`.
- **Turn.** A `hunter` steer with the donor's rate and the cursor window it turned in
(`steer.window`); where the handler spreads a total over a window (`yaw_spread`) a `fixed` one
with that total as `angle` and the window, which wins over the other.
- **Turn.** A `hunter` steer toward the target (the AI target's position, as `TURN` aims at the
target it refreshes each frame) with the donor's rate (`ent[0xAC4]` 1.0 in the walk; the move
player scales it by the clip speed) and the cursor window it turned in (`steer.window`), the
longest of them if the handler turns more than once; where the handler spreads a total over a
window (`yaw_spread`) a `fixed` one with that total as `angle` and the window, which wins over
the other. A handler that adds to its yaw along a curve (`yaw += F * d(curve)` each AI frame) has
the sum as `Move.code_turn`: with F = |D| / 90 or / 180 degrees for the angle D to the target
(the donor's turn that turns exactly D) a `turn = "aim"` steer and the curve at 90 or 180
degrees; with F = 1 the curve as it is, and the steer stays. A turn that depends on the bearing
in any other way is left out (`P3_OWN_MOTION`).
- **Arms.** A handler that asks for the bearing of the target (`ANGLE`, and the side rule built on
it) is walked with the target at bearings D from the facing, every 45 degrees, each change of
plan found to 1/256 of a turn (the script's arc resolution, a bucket judged at its first bearing
plus one unit). The runs of buckets that play one plan, compared by their clips, where they start
and stop and where they hand over to, are its arms; one arm is the handler's plain plan, as
without this. With more, the pair's move is a picker (`Move.pick`) over a variant per arm,
`p3_2_26_a0`, ..., each a chain as above; the zinogre's (2,26) has four (clips 9, 38, 37, 8 at
arcs 0-63, 64-127, 128-191, 192-255, each an aim turn), (1,1) two (11 and 39, a fixed 20 degrees),
and the wrappers four (arcs 0-85, 86-127, 128-170, 171-255, the curve as it is). A variant hands
over as any chain does, except that where the pair's list names the move that follows, the list
does: the variant stops where the donor hands over (clip frame 52 for a turn under 120 degrees,
62 for more), with `aim_out` the blend the donor hands over with (6 and 10 AI frames). Pairs of
one handler that differ only in what they hand over to share one set of variants. A pair another
chain hands over to is one move, not a picker: `after` cannot pick.
- **Blend.** The test that a clip is blending in is answered true for the AI frames the donor
played the clip with, not all clip long: a clip played with a blend of 4 turns at the blend-in
rate for its first 4 AI frames.
- **Motion by code.** The walk keeps the cells a handler moves the monster by (position, velocity,
acceleration, the floor: `mhfu_port.p3motion`), which the handler's float loads and stores and
its helpers (`VEL_APPLY`, `VEL_ACC`, `VEL_WORLD`, `CURVE_DELTA`, `curve_move`, `LIFT`, ...) act
Expand Down
8 changes: 4 additions & 4 deletions framework/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,10 @@ the engine counts the flinch and applies the damage, then enters the move's carr

## Own moves from a manifest

A port's own moves sit in a registry by slot (`mhfu.em_move`): `mhfu.addr.OWN_MOVES` (160) of
them, and the turn keys (`EM_KEYS`, 2944), travel samples (`EM_SAMPLES`, 1280), code-turn values
(`EM_TURNS`, 1024) and lunges (`EM_LUNGES`, 12) they share, in one partition block. These are consts in
`addresses.toml`: raising one is that one line, every offset after it is laid out from it. A run
A port's own moves sit in a registry by slot (`mhfu.em_move`): `mhfu.addr.OWN_MOVES` of them, and
the turn keys (`EM_KEYS`), travel samples (`EM_SAMPLES`), code-turn values (`EM_TURNS`) and lunges
(`EM_LUNGES`) they share, in one partition block. These are consts in `addresses.toml`, sized there:
raising one is that one line, every offset after it is laid out from it. A run
a pool holds already, anywhere in it, word for word (a lunge byte for byte), is shared, not
appended: `mhfu_port.moves.Pools` counts the same way, so a module its check passes fits. The seam's
brain, in C on the AI step, plays one through the move player when asked (`mhfu.em_play`), when the
Expand Down
2 changes: 1 addition & 1 deletion packages/mhfu-port/src/mhfu_port/brain/charge.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ def table(m: Manifest, data: Data) -> tuple[Charge | None, list[Finding]]:
f"no reaction table is known for host species {species}: a knockout does not discharge"
)
found.append(Finding("warning", "P3_CHARGE_REACTIONS", text, None, "charge"))
ch, more = derive(donor, hosts, b, {n: mv.own for n, mv in m.moves.items()})
ch, more = derive(donor, hosts, b, {n: mv.played for n, mv in m.moves.items()})
return ch, [*found, *more]


Expand Down
14 changes: 9 additions & 5 deletions packages/mhfu-port/src/mhfu_port/brain/fatigue.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,10 +322,12 @@ def derive(
reactions: Mapping[Pair, str],
b: Brain,
hold: Collection[Pair] = (),
pickers: Mapping[str, Collection[str]] | None = None,
) -> tuple[Fatigue, list[Finding]]:
"""The helper's table from the donor's row: each `[brain.pairs]` donor pair's cost on what it
plays, each host reaction pair's class cost. Two sources with different costs on one target:
the larger. `hold` are pairs that carry own moves, which cost nothing."""
the larger. `hold` are pairs that carry own moves, which cost nothing; a picker (`pickers`
names its variants) is charged on each of them."""
found: list[Finding] = []

def say(level: str, code: str, text: str) -> None:
Expand Down Expand Up @@ -357,7 +359,8 @@ def say(level: str, code: str, text: str) -> None:
if not top:
continue
if isinstance(target, str):
own[target] = top
for name in (pickers or {}).get(target, [target]):
own[name] = max(own.get(name, 0), top)
elif target in hold:
say(
"warning",
Expand Down Expand Up @@ -389,9 +392,9 @@ def carriers(m: Manifest) -> set[Pair]:
"""The pairs own moves ride: the hub for a move that names none, and `[brain.pairs]` playing an
own move at all rides it."""
species = m.port.host_species
out = {p for mv in m.moves.values() if mv.own and (p := mv.carrier) is not None}
out = {p for mv in m.moves.values() if mv.played and (p := mv.carrier) is not None}
hub = moves.hub(species)
uses = any(mv.own and mv.carrier is None for mv in m.moves.values()) or (
uses = any(mv.played and mv.carrier is None for mv in m.moves.values()) or (
m.brain is not None
and any(isinstance(t, str) for k in m.brain.pairs for t in m.brain.targets(_pair(k)))
)
Expand Down Expand Up @@ -420,7 +423,8 @@ def table(m: Manifest, data: Data) -> tuple[Fatigue | None, list[Finding]]:
if not hosts:
text = f"no reaction table is known for host species {species}: hits cost nothing"
found.append(Finding("warning", "P3_FATIGUE_REACTIONS", text, None, "fatigue"))
fat, more = derive(donor, hosts, b, carriers(m))
picks = {n: {v for _, _, v in mv.pick} for n, mv in m.moves.items() if mv.pick is not None}
fat, more = derive(donor, hosts, b, carriers(m), picks)
return fat, [*found, *more]


Expand Down
Loading
Loading