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
16 changes: 15 additions & 1 deletion docs/formats/p3rd_ai_script.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,19 @@ clip that hands to the next by `after`:
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,
a test the cursor never changed) gets the `length`, in AI frames, the donor played it for.
- **Hand-over.** A handler that ends by entering another pair (not a rest pair) hands over to it,
as MHP3rd does: the chain goes on into that pair's moves, `p3_7_12` into `p3_7_156`. The pair
plays the `[brain.pairs]` move that names it (`"7,5" = "p3_7_4"`), else the one `own_name`
gives it, derived the same way and so following its own hand-over; an authored move of that
name is used as it is. Each pair is derived once, and a hand-over that would close a ring is cut
where it closes. A hand-over to a pair the manifest plays as a host pair or a list, or the port
cannot play (no clip, a clip the donor lacks, a walk that gave up), is not followed:
`P3_OWN_NEXT`.
- **Sharing.** After every pair is derived, moves equal in every field but `label` become one
(their `after` compared as the merge renames it, so equal tails make their predecessors equal):
`p3_7_237`, which the donor dispatches as `(7, 0)`, is `p3_7_0`. The one kept is the one
`[brain.pairs]`, the behaviour graph, an effect or an authored move's `after` names, else the
first derived; two named moves stay apart.
- **Attacks.** A record's START and LEN against the cursor at the spawn give the frames; a node
that outlives a clip change is split per clip. The record's volume set is matched to the host
record whose volumes overlap it most on the port's rig (the same kind first, then power); below
Expand All @@ -183,7 +196,8 @@ clip that hands to the next by `after`:
| `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`, `P3_OWN_NEXT` | info | a branch taken one way; code movement; a pair the handler hands over to |
| `P3_OWN_BRANCH`, `P3_OWN_MOTION` | info | a branch taken one way; code movement |
| `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 |

### Spare attack records
Expand Down
2 changes: 1 addition & 1 deletion packages/mhfu-port/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ port.pac, port.summary.text()
| `mhfu-port brain ID` | An MHP3rd monster's AI script as text (`mhfu_port.brain`; [format](../../docs/formats/p3rd_ai_script.md)) |
| `mhfu-port brain --check` | Every monster's script lifted and encoded back to the game's bytes |
| `mhfu-port p3moveset ID` | The monster's (main, sub) pairs, the clips each plays and the pairs it enters, as CSV (`p3moveset`) |
| `mhfu-port own M` | The own moves a manifest's `[brain.pairs] ... = "own"` derives from the donor: each chain, its clips, its attacks as donor record to host record (a spare one where no host record matches, `brain.spare`), its turn, and the findings (`brain.own`) |
| `mhfu-port own M` | The own moves a manifest's `[brain.pairs] ... = "own"` derives from the donor: each chain, across the pairs its handlers hand over to, its clips, its attacks as donor record to host record (a spare one where no host record matches, `brain.spare`), its turn, and the findings (`brain.own`) |
| `mhfu-port splice M` | A manifest's `[brain]`: the donor's routine translated into the host's script, the findings on what did not carry over, the size and the longest run (`brain.splice`; `--source`, `-o FILE`) |

`splice` writes an edit set for the host's row, which `mhfu brain push|deploy --doc FILE` sends on.
Expand Down
192 changes: 179 additions & 13 deletions packages/mhfu-port/src/mhfu_port/brain/own.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,20 @@
instead; nothing derived is stored.

A pair with several clips is a chain, `p3_7_14`, `p3_7_14_2`, ..., each step handing to the next
through `after`; a step carries the donor's attacks that are active on its clip. A donor record
becomes the host record whose volumes overlap it most on the port's rig (`[brain.attacks]` names
one instead), and the node plays with the host record's power and life. A record nothing matches
gets a spare host record (`brain.spare`), added to the returned manifest as `[[attack]]` and
`[[hitbox]]`; the studio's hit tables write it into the game.
through `after`; a step carries the donor's attacks that are active on its clip. The last step
hands to the pair the handler hands over to (`P3_OWN_NEXT`: where it cannot); equal tails are one
move (`share`). A donor record becomes the host record whose volumes overlap it most on the port's
rig (`[brain.attacks]` names one instead), and the node plays with the host record's power and
life. A record nothing matches gets a spare host record (`brain.spare`), added to the returned
manifest as `[[attack]]` and `[[hitbox]]`; the studio's hit tables write it into the game.
"""

from __future__ import annotations

import copy
import dataclasses
import statistics
from collections.abc import Callable, Sequence
from collections.abc import Callable, Collection, Iterable, Sequence
from typing import NamedTuple

import numpy as np
Expand All @@ -33,7 +35,7 @@
from mhfu.files import em_overlay
from numpy.typing import NDArray

from .. import build, fk, layout, moves, p3hits, p3moveset
from .. import build, fk, layout, moves, p3hits, p3moveset, sequence
from ..data import Data
from ..manifest import (
MOVE_ATTACKS,
Expand All @@ -46,6 +48,7 @@
Move,
Steer,
own_name,
pair_of,
)
from ..p3hits import Plan, Step, Walk
from .spare import HOST_NO_DAMAGE, NO_DAMAGE, POWER_SCALE, Spare, Spares
Expand All @@ -61,6 +64,8 @@
SAMPLES = 100_000
"""Points the overlap of two volume sets is sampled at."""
MAX_RATE = 0xFFFF
REST = frozenset(p for ps in p3moveset.END.values() for p in ps)
"""The pairs a move ends in, to rest: no hand-over."""

Report = Callable[[Finding], None]

Expand Down Expand Up @@ -191,6 +196,17 @@ def overlap(a: Sequence[Prim], b: Sequence[Prim]) -> float:
# --- the moves ---


class Hand(NamedTuple):
"""Where a complete handler hands over to, outside the rest pairs."""

name: str | None = None
"""The own move that pair plays; None: not followed."""
make: Pair | None = None
"""The donor pair whose derivation makes `name`; None for a move the manifest authors."""
text: str = ""
"""The finding when it is not followed."""


class Derivation:
"""One pair's plan turned into moves and findings."""

Expand All @@ -204,10 +220,15 @@ def __init__(
) -> None:
self.m, self.walk, self.matcher, self.say = m, walk, matcher, say
self.pairs = list(pairs)
"""Every pair being derived: the spare records' power scale reads all their matches."""
"""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."""
self.pair: Pair = (0, 0)
self.item = ""
"""The move findings are about."""
self.made: list[str] = []
"""The moves derived, in order."""
self._authored = frozenset(m.moves)
self._done: set[Pair] = set()
self._plans: dict[Pair, Plan] = {}
self._spares: Spares | None = None
self._near: dict[int, tuple[str, str]] = {}
Expand All @@ -225,6 +246,10 @@ def _plan(self, pair: Pair) -> Plan | None:
return self._plans[pair]

def run(self, pair: Pair) -> None:
"""Derive `pair`'s moves, and those of the pairs its handler hands over to; once each."""
if pair in self._done:
return
self._done.add(pair)
head = own_name(pair)
self.pair, self.item = pair, head
plan = self._plan(pair)
Expand All @@ -236,12 +261,96 @@ def run(self, pair: Pair) -> None:
self.find("error", "P3_OWN_NOCLIPS", "its handler plays no clip")
return
names = [head, *(f"{head}_{k}" for k in range(2, len(plan.steps) + 1))]
made = []
for k, step in enumerate(plan.steps):
if names[k] in self.m.moves:
continue
self.item = names[k]
after = names[k + 1] if k + 1 < len(names) else None
self.m.moves[names[k]] = self._move(step, after)
made.append(names[k])
self.made += made
if names[-1] in made:
self._follow(pair, plan, names[-1])

def _follow(self, pair: Pair, plan: Plan, last: str) -> None:
"""Hand `last` over to the move of the pair the handler ends into. Linked before that
pair is derived, so a pair that leads back sees the ring and is the one cut."""
hand = self._hand(plan)
if hand is None:
return
name, text = hand.name, hand.text
if name is not None:
target = self.m.moves.get(name)
if target is not None and not target.own:
text = f"{self._lead(plan)}: moves.{name} is no own move"
elif last in sequence.chain(self.m, name):
text = f"{self._lead(plan)}: following it would close a ring"
if name is None or text:
self.pair, self.item = pair, own_name(pair)
self.find("info", "P3_OWN_NEXT", text)
return
self.m.moves[last].after = name
if hand.make is not None:
self.run(hand.make)

@staticmethod
def _lead(plan: Plan) -> str:
over = [f"{a},{b}" for a, b in plan.leaves if (a, b) not in REST]
return f"it hands over to pair{'s' if len(over) > 1 else ''} {', '.join(over)}"

def _hand(self, plan: Plan) -> Hand | None:
"""What a complete plan hands over to; None when it ends at rest or did not end."""
over = [p for p in plan.leaves if p not in REST]
if not plan.complete or not over:
return None
if len(plan.leaves) > 1:
ends = ", ".join(f"{a},{b}" for a, b in plan.leaves)
return Hand(text=f"it hands over to one of pairs {ends}, as the run goes: not followed")
lead = self._lead(plan)
(to,) = over
brain = self.m.brain
plays = [] if brain is None else brain.targets(to)
if plays and not (len(plays) == 1 and isinstance(plays[0], str)):
what = f"host pair {plays[0][0]},{plays[0][1]}" if len(plays) == 1 else "several moves"
return Hand(text=f"{lead}, which [brain.pairs] plays as {what}: not followed")
name = plays[0] if plays else own_name(to)
assert isinstance(name, str)
if name in self._authored:
return Hand(name)
make = brain.derived().get(name) if brain is not None else None
make = make or (to if name == own_name(to) else None)
if make is None:
return Hand(text=f"{lead}, which the port does not play: no pair derives {name}")
if why := self._unplayable(make):
return Hand(text=f"{lead}, which the port does not play: {why}")
return Hand(name, make)

def _unplayable(self, pair: Pair) -> str:
"""Why `pair` yields no moves a hand-over can go on into; empty when it does."""
plan = self._plan(pair)
if plan is None:
return "the donor has no such pair"
if not plan.steps:
return "its handler plays no clip"
if lacking := [s.clip for s in plan.steps if s.clip not in self.walk.lengths]:
return f"it plays clip {lacking[0]}, which the donor lacks"
if plan.complete:
return ""
why = next((n.text for n in plan.notes if n.code == "unresolved"), "")
return "the walk gave up before its handler ended" + (f" ({why})" if why else "")

def _reach(self, start: Sequence[Pair]) -> list[Pair]:
"""`start` and the pairs derived to follow them, in order."""
out = list(dict.fromkeys(start))
todo = list(out)
while todo:
plan = self._plan(todo.pop(0))
hand = None if plan is None else self._hand(plan)
if hand is not None and hand.make is not None and hand.make not in out:
out.append(hand.make)
todo.append(hand.make)
return out

def finish(self) -> None:
"""One `P3_OWN_SPARE` per spare record taken, once every pair has had its say: the
Expand Down Expand Up @@ -278,10 +387,6 @@ def _notes(self, plan: Plan) -> None:
)
if not plan.complete and plan.steps:
self.find("warning", "P3_OWN_PARTIAL", "the walk gave up before the handler ended")
rest = {p for ps in p3moveset.END.values() for p in ps}
if plan.complete and (over := [p for p in plan.leaves if p not in rest]):
to = ", ".join(f"{a},{b}" for a, b in over)
self.find("info", "P3_OWN_NEXT", f"it hands over to pair {to}, which the script enters")

def _clip(self, cid: int) -> str:
held = layout.holder(self.m, cid)
Expand Down Expand Up @@ -385,7 +490,7 @@ def _scale(self) -> float:
"""Host power over donor power for a spare record: the median over the records of this
port's pairs that matched (the ones `[brain.attacks]` chose left out)."""
mt = self.matcher()
plans = (self._plan(p) for p in dict.fromkeys([*self.pairs, self.pair]))
plans = (self._plan(p) for p in self._reach([*self.pairs, self.pair]))
used = {h.record for plan in plans if plan for s in plan.steps for h in s.hits}
ratios = []
for r in sorted(used):
Expand Down Expand Up @@ -422,6 +527,63 @@ def pairs(b: Brain) -> list[Pair]:
return sorted(b.derived().values())


def anchored(m: Manifest, derived: Collection[str]) -> set[str]:
"""The derived moves something outside the derived set names: `[brain.pairs]`, the behaviour
graph, an effect, or the `after` of a move that is not derived."""
named: set[str] = set()
if m.brain is not None:
for key in m.brain.pairs:
named.update(t for t in m.brain.targets(pair_of(key)) if isinstance(t, str))
for blk in m.behaviour.blocks.values():
named.update(blk.play)
named.update(m.behaviour.moves)
named.update(e.move for e in m.effects)
named.update(mv.after for n, mv in m.moves.items() if mv.after and n not in derived)
return named & set(derived)


def share(m: Manifest, derived: Iterable[str]) -> dict[str, str]:
"""Merge the derived moves equal in every field but `label` (their `after` compared as this
pass renames it) until none are: equal tails make their predecessors equal. The one kept is an
anchored move, else the first derived; two anchored moves stay apart. Every `after` that
named a dropped move names the kept one. Returns each dropped move's kept one."""
names = list(dict.fromkeys(derived))
keep = anchored(m, names)
gone: dict[str, str] = {}

def now(name: str) -> str:
while name in gone:
name = gone[name]
return name

while True:
groups: list[tuple[Move, list[str]]] = []
for n in (n for n in names if n not in gone):
mv = m.moves[n]
key = dataclasses.replace(mv, label="", after=mv.after and now(mv.after))
for first, same in groups:
if first == key:
same.append(n)
break
else:
groups.append((key, [n]))
merged = False
for _, same in groups:
kept = next((n for n in same if n in keep), same[0])
for n in same:
if n != kept and n not in keep:
gone[n] = kept
merged = True
if not merged:
break
for mv in m.moves.values():
if mv.after in gone:
mv.after = now(mv.after)
for n in gone:
del m.moves[n]
return {n: now(n) for n in gone}


def expand(m: Manifest, data: Data) -> tuple[Manifest, list[Finding]]:
"""`m` with the own moves its `[brain.pairs]` derive and the clips they play; `m` itself
where it derives none. A move already in `m.moves` under a derived name is kept."""
Expand All @@ -443,6 +605,10 @@ def matcher() -> Matcher:
for pair in wanted:
derive.run(pair)
derive.finish()
dropped = share(out, derive.made)
found[:] = dict.fromkeys(
dataclasses.replace(f, item=dropped.get(f.item or "", f.item)) for f in found
)
species = m.port.host_species
if (
any(mv.own and mv.carrier is None for mv in out.moves.values())
Expand Down
22 changes: 18 additions & 4 deletions packages/mhfu-port/src/mhfu_port/cli/own.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,12 @@ def run(args: argparse.Namespace) -> int:
games = data.from_arguments(args)
out, findings = own.expand(m, games)
slots = moves.slots(out)
print(f"own {m.port.name}: MHP3rd {m.brain.donor}, {len(own.pairs(m.brain))} pairs")
total = sum(mv.own for mv in out.moves.values())
print(
f"own {m.port.name}: MHP3rd {m.brain.donor}, {len(own.pairs(m.brain))} pairs, "
f"{total} own moves of {manifest.OWN_MOVES}"
)
listed: dict[str, str] = {} # each move listed so far, with the head that listed it
for pair in own.pairs(m.brain):
head = manifest.own_name(pair)
print(f"{pair[0]},{pair[1]}", end="")
Expand All @@ -50,16 +55,21 @@ def run(args: argparse.Namespace) -> int:
continue
steps = sequence.chain(out, head)
print(f" {head}" + (f" + {len(steps) - 1} more" if len(steps) > 1 else ""))
joined = False
for name in steps:
print(describe(out, name, slots.get(name)))
earlier = None if joined else listed.get(name)
joined = joined or earlier is not None
print(describe(out, name, slots.get(name), earlier))
listed.setdefault(name, head)
errors = [f for f in findings if f.level == "error"]
for f in findings:
print(finding_line(f))
return 1 if errors else 0


def describe(m: manifest.Manifest, name: str, slot: int | None) -> str:
"""One derived move: its slot, clip, frames, length and steer, then its attacks."""
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."""
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 @@ -71,6 +81,10 @@ def describe(m: manifest.Manifest, name: str, slot: int | None) -> str:
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 mv.label:
head += f" ({mv.label})"
if shared is not None:
head += f" shared with {shared}"
lines = [head]
spare = {a.id for a in m.attacks}
for a in mv.attacks:
Expand Down
Loading
Loading