Skip to content
Open
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
2 changes: 2 additions & 0 deletions apps/studio/src/mhfu_studio/monster/move_game.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ def deploy(m: Manifest, games: Data, mods: Path) -> tuple[list[Path], int]:
"""`m` built as it is, its modules written into `mods/lib` where they changed, and
`mhfu_port.lua` kept in step: what was written, and the build of the moves module now on the
stick, the one the port must run. The game re-runs a changed module."""
from mhfu_port.brain import own
from mhfu_port.cli.build import modules # the writer `mhfu-port inject` uses

m = own.expanded(m, games) # the lifted copies of its jumps are clips of the build
out = modules(m, build.build(m, games), mods / layout.LIB, games)
lib = runtime.sync_library(mods, runtime.library())
text = (mods / layout.LIB / moves.module_name(m)).read_text(encoding="utf-8")
Expand Down
20 changes: 18 additions & 2 deletions docs/formats/p3rd_ai_script.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,15 +188,31 @@ clip that hands to the next by `after`:
"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`). What the donor does by code, not by its clips (a slide, a curve), is not carried.
(`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.
- **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
on, so the tests of the clip cursor and of the height come out as in the donor (an air roll
lasts 22 AI frames, the stamp's hop 18, its wind-up 16 or 39). Each step's motion, in the
monster's frame at the step's first frame, becomes `Move.travel` (cumulative x, z every 2 clip
frames, the first (0, 0): what the first frames did shows from the second), `Move.lunge` (the
handler's window and distances), and, for a jump, `Clip.lift`: a one-shot copy of the clip cut to
the frames the step plays, named `p3_<clip>_air`, in a free executor entry (`layout.spare`),
whose joint 0 `travel.raised` gives the height over rest at each frame. The engine puts the
monster's Y back on the floor every frame, so a height lives only in the pose. Windows count
from the move's first clip frame and are cut to the frames it plays, the total by the share
left. The rest height is `p3motion.REST`, 298, the value the donor's stamp snaps to; the
landing tests of a jump depend on it.

| Finding | Level | What |
|---|---|---|
| `P3_OWN_NOCLIPS`, `P3_OWN_CARRIER`, `P3_OWN_SLOTS` | error | no clip for the pair; no carrier for the host; more own moves than the framework holds |
| `P3_OWN_ATTACK` | warning | a donor record with no host record and no spare, or more attacks on a clip than the move player holds: left out |
| `P3_OWN_SPARE` | info | a donor record with no host record took a spare one: its id, set, volumes, power, kind, active frames |
| `P3_OWN_PARTIAL` | warning | the walk gave up, or a clip it could not resolve |
| `P3_OWN_BRANCH`, `P3_OWN_MOTION` | info | a branch taken one way; code movement |
| `P3_OWN_BRANCH`, `P3_OWN_MOTION` | info | a branch taken one way; code movement the walk could not follow, or a window cut or left out |
| `P3_OWN_AIR` | info, error | a jump's lifted copy of its clip and its entry (an error where no entry is free), or its height left out |
| `P3_OWN_NEXT` | info | a hand-over not followed, and why |
| `P3_OWN_SPLIT`, `P3_OWN_GROUP`, `P3_OWN_ACTIVE` | info | a node split per clip; records that land one hit between them in the donor; a window longer than the host record's life |

Expand Down
160 changes: 143 additions & 17 deletions packages/mhfu-port/src/mhfu_port/brain/own.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"""The own moves a manifest's `[brain.pairs] "7,13" = "own"` stands for, derived at each build
from both games' files: the clips the donor's handler plays, from the clip frame it starts them
at to the one it ends them at, the attacks it spawns with their frames, matched to the host
species' attack records, and how it turns.
species' attack records, how it turns, and the motion it makes by code (`Move.travel`,
`Move.lunge`, a fixed turn, and a jump's height as a lifted copy of the clip in a free entry).

m, findings = own.expand(manifest, data) # the manifest with `p3_7_13` and its clips

Expand Down Expand Up @@ -33,16 +34,18 @@
from mhfu import hitbox, hitzone
from mhfu.em.script.check import Finding
from mhfu.files import em_overlay
from mhp_formats import fu
from numpy.typing import NDArray

from .. import build, fk, layout, moves, p3hits, p3moveset, sequence
from .. import build, fk, layout, moves, p3hits, p3motion, p3moveset, sequence
from ..data import Data
from ..manifest import (
MOVE_ATTACKS,
OWN_MOVES,
AttackWindow,
Brain,
Clip,
Lunge,
Manifest,
ManifestError,
Move,
Expand All @@ -51,6 +54,7 @@
pair_of,
)
from ..p3hits import Plan, Step, Walk
from ..travel import KEY_STEP
from .spare import HOST_NO_DAMAGE, NO_DAMAGE, POWER_SCALE, Spare, Spares

Pair = tuple[int, int]
Expand Down Expand Up @@ -217,8 +221,9 @@ def __init__(
matcher: Callable[[], Matcher],
say: Report,
pairs: Sequence[Pair] = (),
spare: Callable[[], list[int]] = list,
) -> None:
self.m, self.walk, self.matcher, self.say = m, walk, matcher, say
self.m, self.walk, self.matcher, self.say, self.spare = m, walk, matcher, say, spare
self.pairs = list(pairs)
"""The pairs being derived; the pairs they hand over to are derived too, and with them
feed the spare records' power scale, which reads all their matches."""
Expand Down Expand Up @@ -407,19 +412,109 @@ def _move(self, step: Step, after: str | None) -> Move:
stop = round(step.stop) if step.stop is not None else 0
stop = stop if start < stop <= frames else 0
cut = length.loop or step.ended != "clip"
air = self._air(step, start, frames)
# a lifted copy is the source cut at `start`: the move counts its frames from there
off, size = (start, len(step.lift) - 1) if air else (0, frames)
played = (start - off, stop - off if stop else size) # the clip frames it plays
return Move(
clip=self._clip(step.clip),
attacks=self._attacks(step, start, stop or frames),
steer=self._steer(step, walls=length.loop),
clip=air or self._clip(step.clip),
attacks=self._attacks(step, start, stop or frames, off),
steer=self._steer(step, length.loop, off, played),
length=max(1, step.ticks) if cut and not stop else None,
start=start,
stop=stop,
start=start - off,
stop=stop - off if stop else 0,
travel=self._travel(step, size, start - off),
lunge=self._lunge(step, off, played),
after=after,
label=f"MHP3rd {self.pair[0]},{self.pair[1]}",
)

def _attacks(self, step: Step, start: int, until: int) -> list[AttackWindow]:
"""The step's attack windows inside `[start, until]`."""
def _air(self, step: Step, start: int, frames: int) -> str | None:
"""The name of the one-shot copy of the step's clip cut to the frames it plays, which
carries its height over rest (`Clip.lift`) in an entry of its own; None where it has no
height, or the copy cannot be made."""
if not step.lift:
return None
n = len(step.lift) - 1
if start + n > frames:
self.find(
"info",
"P3_OWN_AIR",
f"clip {step.clip} is {frames} frames and the step plays {n} from {start}: its "
"height is left out",
)
return None
lift = list(step.lift)
for name, c in self.m.clips.items():
if (c.source, c.start, c.frames, c.lift) == (step.clip, start, n, lift):
return name
taken = {c.slot for c in self.m.clips.values() if c.lift is not None}
free = [e for e in self.spare() if e not in taken]
if not free:
self.find(
"error",
"P3_OWN_AIR",
f"no free executor entry for the lifted copy of clip {step.clip}",
)
return None
name = f"p3_{step.clip}_air"
while name in self.m.clips:
name += "_"
self.m.clips[name] = Clip(slot=free[0], source=step.clip, start=start, frames=n, lift=lift)
peak = max(lift, key=abs)
self.find(
"info",
"P3_OWN_AIR",
f"clip {step.clip} frames {start} to {start + n} carry a height of {peak:+.0f} over "
f"rest, as clips.{name} in entry {free[0]}",
)
return name

def _travel(self, step: Step, frames: int, start: int) -> list[tuple[float, float]] | None:
"""The step's travel by code as samples from the move's `start`, those past the clip's
`frames` and a held tail left off."""
found = list(step.travel)
room = (frames - start) // KEY_STEP + 1
if len(found) > room:
self.find(
"info", "P3_OWN_MOTION", f"its travel outruns its clip: kept to {room} samples"
)
found = found[:room]
while len(found) > 2 and found[-1] == found[-2]:
found.pop()
return found or None

def _inside(
self, what: str, window: tuple[float, float], off: int, played: tuple[int, int]
) -> tuple[int, int, float] | None:
"""`window` counted from clip frame `off`, cut to the frames `played`, and the share of
it that is left; None, said, where none is."""
lo, hi = round(window[0]) - off, round(window[1]) - off
a, b = max(lo, played[0]), min(hi, played[1])
if hi <= lo or b <= a:
self.find(
"info", "P3_OWN_MOTION", f"{what} falls outside the frames it plays: left out"
)
return None
if (a, b) != (lo, hi):
share = (b - a) / (hi - lo)
self.find(
"info", "P3_OWN_MOTION", f"{what} is cut to the frames it plays ({share:.0%})"
)
return a, b, (b - a) / (hi - lo)

def _lunge(self, step: Step, off: int, played: tuple[int, int]) -> Lunge | None:
"""The step's lunge, its window counted from the move's first clip frame and cut to the
frames the move plays, its length by the share of it that is left."""
got = step.lunge
inside = None if got is None else self._inside("a lunge", got.window, off, played)
if got is None or inside is None:
return None
lo, hi, share = inside
return Lunge((lo, hi), got.near, got.span, got.base * share, got.range * share)

def _attacks(self, step: Step, start: int, until: int, off: int = 0) -> list[AttackWindow]:
"""The step's attack windows inside `[start, until]`, counted from clip frame `off`."""
out: list[AttackWindow] = []
for h in sorted(step.hits, key=lambda h: (h.frame, h.slot)):
frame = max(start, round(h.frame))
Expand All @@ -443,7 +538,7 @@ def _attacks(self, step: Step, start: int, until: int) -> list[AttackWindow]:
"P3_OWN_ACTIVE",
f"record {h.record} is active {end - frame} frames, host record {host} {life}",
)
out.append(AttackWindow(host, frame, end, label=f"donor {h.record}"))
out.append(AttackWindow(host, frame - off, end - off, label=f"donor {h.record}"))
opens = [h for h in step.hits if h.flags & p3hits.FLAG_OPENS]
joins = [h for h in step.hits if h.flags & p3hits.FLAG_JOINS]
if opens and joins:
Expand Down Expand Up @@ -501,9 +596,15 @@ def _scale(self) -> float:
ratios.append(host.power / donor.power)
return statistics.median(ratios) if ratios else POWER_SCALE

def _steer(self, step: Step, walls: bool) -> Steer:
"""The donor's turn; a wall ends only a step on a looping clip, a run (the donor ends no
move at a wall: a one-shot stop or blow plays out where it stands)."""
def _steer(
self, step: Step, walls: bool, off: int = 0, played: tuple[int, int] = (0, 0xFFFF)
) -> Steer:
"""The donor's turn, its windows counted from clip frame `off`: a fixed one where the
handler spreads a total over a window, else the longest toward the hunter. A wall ends
only a step on a looping clip, a run (the donor ends no move at a wall: a one-shot stop or
blow plays out where it stands)."""
if step.spreads:
return self._spread(step, walls, off, played)
if not step.turns:
return Steer(walls=walls)
turn = max(step.turns, key=lambda t: t.window[1] - t.window[0])
Expand All @@ -517,10 +618,28 @@ def _steer(self, step: Step, walls: bool) -> Steer:
if rate is not None and not 1 <= rate <= MAX_RATE:
self.find("info", "P3_OWN_MOTION", f"turn rate {rate} is out of range: the host's")
rate = None
lo = round(turn.window[0])
window = (lo, max(lo + 1, round(turn.window[1])))
lo = max(round(turn.window[0]) - off, 0)
window = (lo, max(lo + 1, round(turn.window[1]) - off))
return Steer(turn="hunter", rate=rate, walls=walls, window=window)

def _spread(self, step: Step, walls: bool, off: int, played: tuple[int, int]) -> Steer:
"""The turn of a fixed total across a window: the longest of the step's, if it has more
than one."""
spread = max(step.spreads, key=lambda s: s.window[1] - s.window[0])
if len(step.spreads) > 1:
self.find("info", "P3_OWN_MOTION", f"{len(step.spreads)} fixed turns: kept the longest")
if step.turns:
self.find(
"info", "P3_OWN_MOTION", "a fixed turn and a turn toward the hunter: kept the fixed"
)
inside = self._inside("a fixed turn", spread.window, off, played)
if inside is None or not -p3motion.TURN < spread.total < p3motion.TURN:
if inside is not None:
self.find("info", "P3_OWN_MOTION", f"a turn of {spread.total} units is left out")
return Steer(walls=walls)
lo, hi, share = inside
return Steer(turn="fixed", angle=spread.degrees * share, walls=walls, window=(lo, hi))


def pairs(b: Brain) -> list[Pair]:
"""The donor pairs whose own move the build derives, in order."""
Expand Down Expand Up @@ -601,7 +720,14 @@ def matcher() -> Matcher:
cache.append(Matcher(m, data, walk.hits))
return cache[0]

derive = Derivation(out, walk, matcher, found.append, wanted)
hosted: list[fu.Anim] = []

def spare() -> list[int]:
if not hosted:
hosted.append(build.host(m, data).anim)
return layout.spare(out.clips, walk.lengths, hosted[0], m.port.host_species)

derive = Derivation(out, walk, matcher, found.append, wanted, spare)
for pair in wanted:
derive.run(pair)
derive.finish()
Expand Down
8 changes: 5 additions & 3 deletions packages/mhfu-port/src/mhfu_port/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,9 +233,9 @@ def animation(
lift: float = 0.0,
) -> fu.Anim:
"""The donor's moveset on the rig's joints in the entries `placed` gives it, cut where it
cuts, its pelvis raised by `lift` world units, and on its own rig its travel and turns where
the engine takes them (`travel.carry`). Entries it leaves keep the host's clips on the
host's rig."""
cuts, its pelvis raised by `lift` world units, the entries `placed` lifts raised frame by frame
(`travel.raised`), and on its own rig its travel and turns where the engine takes them
(`travel.carry`). Entries it leaves keep the host's clips on the host's rig."""
clips: Mapping[int, Clip] = d.clips
if lift:
bone_of_record = {r: bone for bone, r in record_of.items()}
Expand All @@ -248,6 +248,8 @@ def animation(
keep = bind.mode == "retarget"
played = layouts.clips(placed, clips)
out = motion.build(played, {e: e for e in played}, h.anim, bind.rig.streams, track_of, keep)
if placed.lifts:
out = travel.raised(out, bind.rig.skeleton, placed.lifts)
return out if keep else travel.carry(out, bind.rig.skeleton)


Expand Down
13 changes: 7 additions & 6 deletions packages/mhfu-port/src/mhfu_port/cli/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ def register(sub: Subparsers) -> None:


def run_build(args: argparse.Namespace) -> int:
m = manifest.load(args.manifest)
games = data.from_arguments(args)
m = own.expanded(manifest.load(args.manifest), games, say) # its jumps' lifted clips are built
built = build(m, games)
out: Path = args.out or Path(m.port.pac)
written = modules(m, built, out.parent, games, say)
Expand All @@ -49,8 +49,8 @@ def run_build(args: argparse.Namespace) -> int:


def run_inject(args: argparse.Namespace) -> int:
m = manifest.load(args.manifest)
games = data.from_arguments(args)
m = own.expanded(manifest.load(args.manifest), games, say)
built = build(m, games)
lib = args.lib or inject.memstick() / inject.MODS_SUBDIR / layout.LIB
written = modules(m, built, lib, games, say)
Expand All @@ -74,10 +74,11 @@ def modules(
report: own.Report | None = None,
) -> list[Path]:
"""Write the port's clips, turns and moves modules into `where`, each whole and only when it
changed, since the game re-runs a module that changed on disk; those written. The moves
module carries the own moves `[brain.pairs]` derive (`own.expand`) and, with `[brain] fatigue`
and `charge`, the stamina and charge tables (`fatigue.table`, `charge.table`), each finding of
which goes to `report`. Raises
changed, since the game re-runs a module that changed on disk; those written. `built` is of
`m` as `own.expanded` leaves it: a build of the manifest before it lacks the lifted copies of
jumps the moves play. The moves module carries the own moves `[brain.pairs]` derive (a no-op
on an expanded `m`) and, with `[brain] fatigue` and `charge`, the stamina and charge tables
(`fatigue.table`, `charge.table`), each finding of which goes to `report`. Raises
`ManifestError` for a move the moves module cannot carry, before any is written."""
m = own.expanded(m, games, report)
stamina = fatigue.expanded(m, games, report)
Expand Down
13 changes: 11 additions & 2 deletions packages/mhfu-port/src/mhfu_port/cli/own.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ def run(args: argparse.Namespace) -> int:


def describe(m: manifest.Manifest, name: str, slot: int | None, shared: str | None = None) -> str:
"""One derived move: its slot, clip, frames, length and steer, the pair it comes from, then its
attacks. `shared` is the head of an earlier chain that already holds it."""
"""One derived move: its slot, clip, frames, length, steer and motion by code, the pair it comes
from, then its attacks. `shared` is the head of an earlier chain that already holds it."""
mv = m.moves[name]
clip = m.clips[mv.clip] if mv.clip is not None else None
head = f" {name} (slot {slot}) clip {clip.id if clip else mv.anim} {mv.clip}"
Expand All @@ -81,6 +81,15 @@ def describe(m: manifest.Manifest, name: str, slot: int | None, shared: str | No
if s.turn == "hunter":
window = "" if s.window is None else f" in frames {s.window[0]}-{s.window[1]}"
head += f" turns to the hunter at {s.rate or 'the host charge'}{window}"
if s.turn == "fixed":
head += f" turns {s.angle:g} degrees" + (f" in frames {s.window}" if s.window else "")
if mv.travel:
x, z = mv.travel[-1]
head += f" travels {x:g}, {z:g} over {len(mv.travel)} samples"
if mv.lunge is not None:
head += f" lunges {mv.lunge.base:g} to {mv.lunge.base + mv.lunge.range:g}"
if clip is not None and clip.lift is not None:
head += f" rises {max(clip.lift, key=abs):+g}"
if mv.label:
head += f" ({mv.label})"
if shared is not None:
Expand Down
Loading
Loading