diff --git a/apps/studio/src/mhfu_studio/monster/panels/moves.py b/apps/studio/src/mhfu_studio/monster/panels/moves.py index 27a81165..ba0e77a0 100644 --- a/apps/studio/src/mhfu_studio/monster/panels/moves.py +++ b/apps/studio/src/mhfu_studio/monster/panels/moves.py @@ -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 @@ -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." @@ -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) @@ -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) diff --git a/apps/studio/src/mhfu_studio/monster/turn.py b/apps/studio/src/mhfu_studio/monster/turn.py index 3c24a430..9f74ccad 100644 --- a/apps/studio/src/mhfu_studio/monster/turn.py +++ b/apps/studio/src/mhfu_studio/monster/turn.py @@ -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 diff --git a/apps/studio/src/mhfu_studio/monster/validate.py b/apps/studio/src/mhfu_studio/monster/validate.py index 13205fba..d4736e3a 100644 --- a/apps/studio/src/mhfu_studio/monster/validate.py +++ b/apps/studio/src/mhfu_studio/monster/validate.py @@ -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: diff --git a/apps/studio/tests/monster/test_behaviour.py b/apps/studio/tests/monster/test_behaviour.py index 68eaf90e..99789552 100644 --- a/apps/studio/tests/monster/test_behaviour.py +++ b/apps/studio/tests/monster/test_behaviour.py @@ -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", diff --git a/apps/studio/tests/monster/test_turn.py b/apps/studio/tests/monster/test_turn.py index 3ad36b6a..47512f27 100644 --- a/apps/studio/tests/monster/test_turn.py +++ b/apps/studio/tests/monster/test_turn.py @@ -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 @@ -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) diff --git a/apps/studio/tests/monster/ui/test_moves.py b/apps/studio/tests/monster/ui/test_moves.py index 8748ec06..a42fee20 100644 --- a/apps/studio/tests/monster/ui/test_moves.py +++ b/apps/studio/tests/monster/ui/test_moves.py @@ -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") diff --git a/docs/formats/p3rd_ai_script.md b/docs/formats/p3rd_ai_script.md index 15f7edb8..039626db 100644 --- a/docs/formats/p3rd_ai_script.md +++ b/docs/formats/p3rd_ai_script.md @@ -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 | @@ -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 @@ -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, @@ -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 diff --git a/framework/README.md b/framework/README.md index f73fa65a..b8772d1f 100644 --- a/framework/README.md +++ b/framework/README.md @@ -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 diff --git a/packages/mhfu-port/src/mhfu_port/brain/charge.py b/packages/mhfu-port/src/mhfu_port/brain/charge.py index ec0bea0b..b3df9966 100644 --- a/packages/mhfu-port/src/mhfu_port/brain/charge.py +++ b/packages/mhfu-port/src/mhfu_port/brain/charge.py @@ -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] diff --git a/packages/mhfu-port/src/mhfu_port/brain/fatigue.py b/packages/mhfu-port/src/mhfu_port/brain/fatigue.py index cdf74383..54cd335e 100644 --- a/packages/mhfu-port/src/mhfu_port/brain/fatigue.py +++ b/packages/mhfu-port/src/mhfu_port/brain/fatigue.py @@ -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: @@ -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", @@ -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))) ) @@ -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] diff --git a/packages/mhfu-port/src/mhfu_port/brain/own.py b/packages/mhfu-port/src/mhfu_port/brain/own.py index 1823ebf4..f0af4d00 100644 --- a/packages/mhfu-port/src/mhfu_port/brain/own.py +++ b/packages/mhfu-port/src/mhfu_port/brain/own.py @@ -16,10 +16,19 @@ 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. 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. +move (`share`). + +A handler that plays other plans by the target's bearing (`Walk.arms`) is a picker, `p3_2_26`, +over one chain per arm, `p3_2_26_a0`, ... (its variants). A variant hands over like any chain, +except where the pair's `[brain.pairs]` list names the move that follows: then the list does, and +the variant ends where the donor hands over, the residual angle slid into that move +(`Move.aim_out`). A pair another chain hands over to is one move, not a picker. A turn by code is +the step's `code_turn`, an `aim` one where it goes by the bearing. + +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 @@ -53,7 +62,7 @@ own_name, pair_of, ) -from ..p3hits import Plan, Step, Walk +from ..p3hits import Arm, Plan, Step, Walk from ..travel import KEY_STEP from .spare import HOST_NO_DAMAGE, NO_DAMAGE, POWER_SCALE, Spare, Spares @@ -235,6 +244,7 @@ def __init__( self._authored = frozenset(m.moves) self._done: set[Pair] = set() self._plans: dict[Pair, Plan] = {} + self._into: set[Pair] | None = None self._spares: Spares | None = None self._near: dict[int, tuple[str, str]] = {} """Spare donor record -> what it came closest to, and the move that first needed it.""" @@ -261,10 +271,22 @@ def run(self, pair: Pair) -> None: if plan is None: self.find("error", "P3_OWN_NOCLIPS", "the donor has no such pair") return + arms: tuple[Arm, ...] = (Arm(((0, 255),), plan),) + if pair not in self._handed(): + donor = self.walk.ms.pairs[pair] + arms = self.walk.arms(donor, plan) + if len(arms) > 1: + self._pick(pair, head, arms) + elif (last := self._chain(head, plan)) is not None: + self._follow(pair, plan, last) + + def _chain(self, head: str, plan: Plan, variant: bool = False) -> str | None: + """The moves of `plan`'s steps, `head`, `head_2`, ...; the name of the last where it was + made, else None.""" self._notes(plan) if not plan.steps: self.find("error", "P3_OWN_NOCLIPS", "its handler plays no clip") - return + return None names = [head, *(f"{head}_{k}" for k in range(2, len(plan.steps) + 1))] made = [] for k, step in enumerate(plan.steps): @@ -272,11 +294,30 @@ def run(self, pair: Pair) -> None: 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) + self.m.moves[names[k]] = self._move(step, after, variant) made.append(names[k]) self.made += made - if names[-1] in made: - self._follow(pair, plan, names[-1]) + return names[-1] if names[-1] in made else None + + def _pick(self, pair: Pair, head: str, arms: Sequence[Arm]) -> None: + """A picker `head` over a variant per arm. Each variant hands over as any chain does, + unless the pair's list names the move after it: that is the combo the splice enters.""" + brain = self.m.brain + listed = brain is not None and len(brain.targets(pair)) > 1 + picks = [] + for k, arm in enumerate(arms): + name = f"{head}_a{k}" + self.item = name + if (last := self._chain(name, arm.plan, listed)) is None: + self.item = head + self.find("error", "P3_OWN_NOCLIPS", f"its handler plays no clip at arc {arm.arcs}") + return + if not listed: + self._follow(pair, arm.plan, last) + picks += [(lo, hi, name) for lo, hi in arm.arcs] + self.pair, self.item = pair, head + self.m.moves[head] = Move(pick=sorted(picks), label=f"MHP3rd {pair[0]},{pair[1]}") + self.made.append(head) 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 @@ -289,6 +330,8 @@ def _follow(self, pair: Pair, plan: Plan, last: str) -> 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 target is not None and target.picker: + text = f"{self._lead(plan)}: moves.{name} picks by the target's bearing" 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: @@ -297,6 +340,7 @@ def _follow(self, pair: Pair, plan: Plan, last: str) -> None: return self.m.moves[last].after = name if hand.make is not None: + self._handed().add(hand.make) self.run(hand.make) @staticmethod @@ -347,15 +391,29 @@ def _unplayable(self, pair: Pair) -> str: def _reach(self, start: Sequence[Pair]) -> list[Pair]: """`start` and the pairs derived to follow them, in order.""" + return self._links(start)[0] + + def _links(self, start: Sequence[Pair]) -> tuple[list[Pair], set[Pair]]: + """`start` and the pairs derived to follow them, in order; and the pairs some chain hands + over to.""" out = list(dict.fromkeys(start)) + into: set[Pair] = set() 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 + if hand is not None and hand.make is not None: + into.add(hand.make) + if hand.make not in out: + out.append(hand.make) + todo.append(hand.make) + return out, into + + def _handed(self) -> set[Pair]: + """The pairs a chain hands over to: each is the `after` of a step, so one move.""" + if self._into is None: + self._into = self._links(self.pairs)[1] + return self._into def finish(self) -> None: """One `P3_OWN_SPARE` per spare record taken, once every pair has had its say: the @@ -403,32 +461,61 @@ def _clip(self, cid: int) -> str: self.m.clips[name] = Clip(source=cid) return name - def _move(self, step: Step, after: str | None) -> Move: - """The step as a move, from the clip frame the donor started it at to the one its handler - tested when it ended it (`Step.stop`); `length`, in AI frames, only for a step that ended - on no frame of its clip: a pair, a loop, a test the cursor never changed.""" + def _move(self, step: Step, after: str | None, variant: bool = False) -> Move: + """The step as a move, from the clip frame the donor started it at to the one it ended + at (`_stop`); `length`, in AI frames, only for a step on a looping clip. A `variant` of a + picker that a list follows also carries the angle it still has to turn at the end + (`aim_out`: the blend the donor hands over with).""" length = self.walk.lengths[step.clip] frames, start = round(length.frames), round(step.start) - 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) + code = self._code(step) if not air else None + if step.aim and code is None: + step = dataclasses.replace(step, aim=False) # 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) + stop = self._stop(step, length.loop and not air, start, frames, off if air else None) played = (start - off, stop - off if stop else size) # the clip frames it plays return Move( 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, + steer=self._steer(step, length.loop and not step.lift, off, played), + length=max(1, step.ticks) if length.loop and not air and not stop else None, start=start - off, stop=stop - off if stop else 0, travel=self._travel(step, size, start - off), lunge=self._lunge(step, off, played), + code_turn=code, + aim_out=min(step.hand or 0, 0xFF) if variant else 0, after=after, label=f"MHP3rd {self.pair[0]},{self.pair[1]}", ) + def _code(self, step: Step) -> list[int] | None: + """The step's code turn, if a move holds it.""" + if len(step.code) > moves.TURNS: + text = f"its code turn has {len(step.code)} values, a move holds {moves.TURNS}" + self.find("info", "P3_OWN_MOTION", f"{text}: left out") + return None + return list(step.code) or None + + def _stop(self, step: Step, loop: bool, start: int, frames: int, lifted: int | None) -> int: + """The clip frame a step ends at, 0 for its clip's end: where the handler's test of the + cursor was crossed, else where the walk's cursor stood when it handed over (the engine + holds the cursor through a blend, so AI frames would end it early). A lifted copy + (`lifted`: its first clip frame) also ends where its height last is at rest or above, so + the landing clip takes the fall. A loop has none: it ends by `length`.""" + stop = round(step.stop) if step.stop is not None else 0 + if not start < stop <= frames: + stop = 0 + if not stop and not loop and step.ended != "clip": + stop = min(round(start + step.ticks * self.walk.step), frames) + if lifted is not None: + last = max((i for i, h in enumerate(step.lift) if h >= 0), default=len(step.lift) - 1) + if last < len(step.lift) - 1: + stop = min(stop or frames, lifted + last) + return stop if start < stop else 0 + 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 @@ -474,11 +561,10 @@ def _travel(self, step: Step, frames: int, start: int) -> list[tuple[float, floa """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 + room = min((frames - start) // KEY_STEP + 1, moves.SAMPLES) if len(found) > room: - self.find( - "info", "P3_OWN_MOTION", f"its travel outruns its clip: kept to {room} samples" - ) + text = f"its travel outruns its clip or what a move holds: kept to {room} samples" + self.find("info", "P3_OWN_MOTION", text) found = found[:room] while len(found) > 2 and found[-1] == found[-2]: found.pop() @@ -600,9 +686,11 @@ 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).""" + handler spreads a total over a window, else the longest toward the target. A wall ends + only a step on a looping clip that is no jump, a run (the donor ends no move at a wall: a + one-shot stop, blow or leap plays out where it stands).""" + if step.aim: + return Steer(turn="aim", walls=walls) if step.spreads: return self._spread(step, walls, off, played) if not step.turns: @@ -615,7 +703,9 @@ def _steer( f"{len(step.turns)} turn rates on one clip: kept the longest", ) rate = turn.rate - if rate is not None and not 1 <= rate <= MAX_RATE: + if rate is None: + self.find("info", "P3_OWN_MOTION", "its turn rate is not known: the host's") + elif 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 = max(round(turn.window[0]) - off, 0) @@ -661,6 +751,19 @@ def anchored(m: Manifest, derived: Collection[str]) -> set[str]: return named & set(derived) +def _picks(mv: Move, now: Callable[[str], str]) -> list[tuple[int, int, str]] | None: + """A picker's arcs with each variant named as `now` has it, neighbours on one variant joined.""" + if mv.pick is None: + return None + out: list[tuple[int, int, str]] = [] + for lo, hi, name in sorted(mv.pick): + if out and out[-1][2] == now(name) and out[-1][1] + 1 == lo: + out[-1] = (out[-1][0], hi, out[-1][2]) + else: + out.append((lo, hi, now(name))) + return out + + 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 @@ -679,7 +782,9 @@ def now(name: str) -> str: 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)) + key = dataclasses.replace( + mv, label="", after=mv.after and now(mv.after), pick=_picks(mv, now) + ) for first, same in groups: if first == key: same.append(n) @@ -695,9 +800,19 @@ def now(name: str) -> str: merged = True if not merged: break + for n in names: + # a picker whose variants all came to one is that move + pick = None if n in gone else m.moves[n].pick + only = {now(v) for _, _, v in pick or ()} + if len(only) == 1 and (v := next(iter(only))) not in keep: + others = (o for k, o in m.moves.items() if k != n and o.pick) + if not any(now(x) == v for o in others for _, _, x in o.pick or ()): + m.moves[n] = dataclasses.replace(m.moves[v], label=m.moves[n].label) + gone[v] = n for mv in m.moves.values(): if mv.after in gone: mv.after = now(mv.after) + mv.pick = _picks(mv, now) for n in gone: del m.moves[n] return {n: now(n) for n in gone} @@ -737,13 +852,13 @@ def spare() -> list[int]: ) species = m.port.host_species if ( - any(mv.own and mv.carrier is None for mv in out.moves.values()) + any(mv.played and mv.carrier is None for mv in out.moves.values()) and moves.hub(species) is None ): found.append( Finding("error", "P3_OWN_CARRIER", f"no carrier is known for host species {species}") ) - if (n := sum(mv.own for mv in out.moves.values())) > OWN_MOVES: + if (n := sum(mv.played for mv in out.moves.values())) > OWN_MOVES: found.append( Finding("error", "P3_OWN_SLOTS", f"{n} own moves, the framework holds {OWN_MOVES}") ) diff --git a/packages/mhfu-port/src/mhfu_port/brain/splice.py b/packages/mhfu-port/src/mhfu_port/brain/splice.py index 57aa441b..189a2908 100644 --- a/packages/mhfu-port/src/mhfu_port/brain/splice.py +++ b/packages/mhfu-port/src/mhfu_port/brain/splice.py @@ -19,7 +19,10 @@ A donor pair that `[brain.pairs]` maps to an own move becomes `enter OWN_MOVE_MAIN k 0`, `k` the move's slot in `slots` (`moves.slots`, the one numbering the moves module and `mhfu_port.lua` -use); em_vhook plays that move in place of a host pair. A list there enters each item in turn. +use); em_vhook plays that move in place of a host pair. A list there enters each item in turn. A +picker (`Move.pick`, `moves.picks`) becomes nested `if_target_in_arc lo hi`, one enter per +variant; with an own move after it in the list, each enter is the combo of the variant and that +move (`moves.combo_ids`: `enter OWN_MOVE_MAIN id 0`, the id past the slots). A donor `random` of more live arms than MHFU's 13 branches is split: an outer `random` over groups of arms and an inner one per group. MHFU's `random` reads the entity's counter without advancing it @@ -195,10 +198,16 @@ def __init__( spec: manifest.Brain, survey: Mapping[int, script_vars.Var] | None = None, slots: Mapping[str, int] | None = None, + picks: Mapping[str, Sequence[tuple[int, int, str]]] | None = None, + combos: Mapping[tuple[str, str], int] | None = None, ) -> None: self.donor, self.spec, self.survey = donor, spec, survey self.slots: Mapping[str, int] = slots or {} """The registry slot of each own move, by name.""" + self.picks: Mapping[str, Sequence[tuple[int, int, str]]] = picks or {} + """The arcs of each picker, `(lo, hi, variant)`, by name.""" + self.combos: Mapping[tuple[str, str], int] = combos or {} + """The enter id of each combo of two own moves.""" self.findings: list[Finding] = [] self.routines: dict[str, list[ir.Node]] = {} """The new routines, by name.""" @@ -454,25 +463,61 @@ def _yields(self, n: ir.Node) -> Yields: # ---- ops def _enter(self, n: p3.Insn, w: Where) -> list[ir.Node]: - """One `enter` per target of the pair, in order.""" + """One `enter` per target of the pair, in order; a picker is the arcs of its variants, and + takes the own move after it in the list along as the combo of each.""" pair = (n.args["main"], n.args["sub"]) targets = self.spec.targets(pair) if not targets: self.add(w, "error", "P3_PAIR_UNMAPPED", f"enter {pair[0]},{pair[1]} has no host pair") out: list[ir.Node] = [] - for target in targets: - if isinstance(target, str): - slot = self.slots.get(target) - if slot is None: - text = f"enter {pair[0]},{pair[1]} plays own move {target}, which has no slot" - self.add(w, "error", "P3_OWN_MISSING", text) - continue - out.append(edit.insn("enter", addresses.OWN_MOVE_MAIN, slot, 0)) + k = 0 + while k < len(targets): + target = targets[k] + if isinstance(target, str) and target in self.picks: + then = targets[k + 1] if k + 1 < len(targets) else None + nodes, took = self._pick(pair, target, then, w) + out += nodes + k += 1 + took else: - out.append(edit.insn("enter", *target, 0)) - self.uses[pair, target] += 1 + out += self._play(pair, target, w) + k += 1 return out + def _play(self, pair: Pair, target: Pair | str, w: Where) -> list[ir.Node]: + """The `enter` of a host pair or an own move.""" + if isinstance(target, str): + slot = self.slots.get(target) + if slot is None: + text = f"enter {pair[0]},{pair[1]} plays own move {target}, which has no slot" + self.add(w, "error", "P3_OWN_MISSING", text) + return [] + self.uses[pair, target] += 1 + return [edit.insn("enter", addresses.OWN_MOVE_MAIN, slot, 0)] + self.uses[pair, target] += 1 + return [edit.insn("enter", *target, 0)] + + def _pick( + self, pair: Pair, picker: str, then: Pair | str | None, w: Where + ) -> tuple[list[ir.Node], int]: + """A picker as nested arcs, each entering its variant, and how many targets after it that + took in: one where every variant has a combo with `then`, else none.""" + arcs = sorted(self.picks[picker]) + ids = {v: self.combos.get((v, then)) for _, _, v in arcs} if isinstance(then, str) else {} + together = bool(ids) and all(i is not None for i in ids.values()) + + def enter(variant: str) -> list[ir.Node]: + if not together: + return self._play(pair, variant, w) + assert isinstance(then, str) and (ident := ids[variant]) is not None + self.uses[pair, variant] += 1 + self.uses[pair, then] += 1 + return [edit.insn("enter", addresses.OWN_MOVE_MAIN, ident, 0)] + + nodes = enter(arcs[-1][2]) + for lo, hi, variant in reversed(arcs[:-1]): + nodes = self.if_(w, "enter", "if_target_in_arc", (lo, hi), enter(variant), nodes) + return nodes, int(together) + def _end(self, n: p3.Insn, w: Where) -> list[ir.Node]: if not w.tail: self.add(w, "error", "P3_UNSUPPORTED", "end before the end of the routine") @@ -868,11 +913,14 @@ def splice( slots: Mapping[str, int] | None = None, overlay: Overlay | None = None, named: Collection[Pair] = (), + picks: Mapping[str, Sequence[tuple[int, int, str]]] | None = None, + combos: Mapping[tuple[str, str], int] | None = None, ) -> Spliced: """The host's `spec.splice` routine with the donor's `spec.entry` in its place, as edits to `host`; `findings` say what could not carry over, an error that it cannot be built. `survey` (`vars.survey` of the host) picks the call history's variable when `spec.history` is None; - `slots` (`moves.slots`) numbers the own moves that `spec.pairs` plays; `overlay` (the host's + `slots` (`moves.slots`) numbers the own moves that `spec.pairs` plays, `picks` and `combos` + (`moves.picks`, `moves.combo_ids`) the pickers and the combos of them; `overlay` (the host's species overlay) lets `spec.fatigue` see whether its code drains gauge A.""" es = EditSet.of(host) target = next((r for r in host.routines() if r.name == spec.splice), None) @@ -888,7 +936,7 @@ def splice( keep += 1 for n in target.body[keep:-1]: es.node(n.id).delete = True - t = Translator(donor, spec, survey, slots) + t = Translator(donor, spec, survey, slots, picks, combos) nodes = t.paste(spec.entry, spec.splice) for label, body in t.routines.items(): es.routines[label] = body diff --git a/packages/mhfu-port/src/mhfu_port/cli/own.py b/packages/mhfu-port/src/mhfu_port/cli/own.py index da5a4ea8..3d8c85e5 100644 --- a/packages/mhfu-port/src/mhfu_port/cli/own.py +++ b/packages/mhfu-port/src/mhfu_port/cli/own.py @@ -41,7 +41,7 @@ def run(args: argparse.Namespace) -> int: games = data.from_arguments(args) out, findings = own.expand(m, games) slots = moves.slots(out) - total = sum(mv.own for mv in out.moves.values()) + total = sum(mv.played 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}" @@ -53,14 +53,22 @@ def run(args: argparse.Namespace) -> int: if head in m.moves: print(f" {head}: the manifest's own move") 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: - 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) + picker = out.moves[head].pick if head in out.moves else None + if picker is not None: + arcs = ", ".join(f"{lo}-{hi} {name}" for lo, hi, name in picker) + print(f" {head}: picks by the target's bearing, in 256ths of a turn: {arcs}") + heads = list(dict.fromkeys(v for _, _, v in picker)) + else: + steps = sequence.chain(out, head) + print(f" {head}" + (f" + {len(steps) - 1} more" if len(steps) > 1 else "")) + heads = [head] + for first in heads: + joined = False + for name in sequence.chain(out, first): + 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)) @@ -83,6 +91,12 @@ def describe(m: manifest.Manifest, name: str, slot: int | None, shared: str | No head += f" turns to the target 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 s.turn == "aim": + head += " turns to the target along its code turn" + if mv.code_turn: + head += f" code turn {mv.code_turn[-1] * 360 / 65536:+.0f} degrees" + if mv.aim_out: + head += f" slides the rest of the angle over {mv.aim_out} AI frames after" if mv.travel: x, z = mv.travel[-1] head += f" travels {x:g}, {z:g} over {len(mv.travel)} samples" diff --git a/packages/mhfu-port/src/mhfu_port/cli/splice.py b/packages/mhfu-port/src/mhfu_port/cli/splice.py index 4058d822..9a191fc3 100644 --- a/packages/mhfu-port/src/mhfu_port/cli/splice.py +++ b/packages/mhfu-port/src/mhfu_port/cli/splice.py @@ -60,7 +60,9 @@ def run(args: argparse.Namespace) -> int: stamina, helper = fatigue.table(m, games) charging, powered = charge.table(m, games) named = charging.named if charging is not None else () - spliced = splice.splice(donor, host, b, survey, moves.slots(m), overlay, named) + spliced = splice.splice( + donor, host, b, survey, moves.slots(m), overlay, named, moves.picks(m), moves.combo_ids(m) + ) built = Built.of(host, spliced.edits) findings = [*derived, *helper, *powered, *spliced.findings, *built.findings] print(f"splice {m.port.name}: MHP3rd {b.donor} {b.entry} into em{row} {b.splice}") diff --git a/packages/mhfu-port/src/mhfu_port/manifest.py b/packages/mhfu-port/src/mhfu_port/manifest.py index f86d0e66..76e002fe 100644 --- a/packages/mhfu-port/src/mhfu_port/manifest.py +++ b/packages/mhfu-port/src/mhfu_port/manifest.py @@ -221,8 +221,8 @@ class Steer: """`clip` follows the clip's turn, `hunter` and `away` turn toward or away from the target (what the monster aims at: its AI target, the player without one) at `rate`, `fixed` turns `angle` degrees over `frames` AI frames or across `window` by cursor, `still` not at all. - `aim` scales the move's `code_turn` to turn exactly the angle to the target at its first - playing frame, and needs one.""" + `aim` scales the move's `code_turn` to turn exactly the angle to the target (the AI target's, as + for `hunter`) at its first playing frame, and needs one.""" rate: int | None = None """YAW units an AI frame at clip speed 1 for `hunter` and `away`, times the speed / 2 in play; None: the host charge's.""" @@ -301,6 +301,11 @@ class Move: aim_out: int = 0 """AI frames: when this move hands over to its `after` in place, the angle still to turn to the target at the switch is spread over the successor's first `aim_out` AI frames; 0: none.""" + pick: list[tuple[int, int, str]] | None = None + """A picker: no clip, no slot and no play of its own. Where the target stands to the left of the + move's facing, `(lo, hi, name)` in 256ths of a turn (0 to 127 the left half, as + `if_target_in_arc`), the AI script enters the own move `name`, a variant. The arcs cover 0 to + 255 once; a variant is an own move that is no picker.""" carrier: tuple[int, int] | None = None host_attacks: bool = False label: str = "" @@ -310,6 +315,16 @@ def own(self) -> bool: """Played by the move player, not on a host pair.""" return self.main is None + @property + def picker(self) -> bool: + """Picks among own moves by the target's bearing: no play of its own.""" + return self.pick is not None + + @property + def played(self) -> bool: + """An own move with a clip: it has a registry slot.""" + return self.main is None and self.pick is None + @property def pair(self) -> tuple[int, int] | None: """The host pair; None for an own move.""" @@ -456,8 +471,11 @@ class Brain: pairs: dict[str, Plays] = field(default_factory=dict) """What each donor pair, keyed `"main,sub"`, plays: a host pair, the name of an own move in `[moves]`, or `OWN`, or the name `OWN` gives another pair (`derived`); or a list of those, each - entered in turn (`[[1, 2], "own"]`: the host's turn, then the move). The splice enters an own - move as `enter OWN_MOVE_MAIN k 0`, `k` its `moves.slots` slot.""" + entered in turn (`["own", "p3_7_0"]`: the pair's own move, here a picker of turns by the + target's bearing, then the move). The splice enters an own move as `enter OWN_MOVE_MAIN k 0`, + `k` its `moves.slots` slot; a picker as `if_target_in_arc` over its variants, with an own move + after it in the list as one combo per variant (`moves.combos`); a host pair as `enter main sub + 0`.""" when: dict[str, bool] = field(default_factory=dict) """The arm a donor test with no host counterpart takes, by its MHP3rd opcode name.""" attacks: dict[str, int] = field(default_factory=dict) @@ -505,9 +523,9 @@ def rename_move(self, old: str, new: str) -> None: self.pairs[key] = renamed if isinstance(plays, list) else renamed[0] def derived(self) -> dict[str, Pair]: - """The own moves `OWN` stands for, by name, each with the donor pair it comes from. Another - pair plays one by naming it: `"7,23" = [[1, 2], "p3_7_0"]` turns, then plays pair (7, 0)'s - move.""" + """The own moves `OWN` stands for, by name, each with the donor pair it comes from (a picker + or the head of a chain). Another pair plays one by naming it: `"7,23" = ["own", "p3_7_0"]` + turns by its own picker, then plays pair (7, 0)'s move.""" pairs = {pair_of(key): plays for key, plays in self.pairs.items()} return {own_name(p): p for p, plays in pairs.items() if OWN in targets_of(plays)} @@ -743,6 +761,24 @@ def _own_move(mv: Move, w: str) -> None: _need(0 <= s.window[0] < s.window[1], f"{w}.steer", "window is 0 <= from < to") +def _picker(m: Manifest, mv: Move, w: str) -> None: + """A picker's arcs cover 0 to 255 once and name own moves that are no pickers; it has nothing + else a move has.""" + assert mv.pick is not None + bare = Move() + others = (f.name for f in dataclasses.fields(Move) if f.name not in ("pick", "label")) + _need(all(getattr(mv, k) == getattr(bare, k) for k in others), w, "a picker has only pick") + _need(bool(mv.pick), f"{w}.pick", "needs an arc") + at = 0 + for lo, hi, name in sorted(mv.pick): + _need(0 <= lo <= hi < 256, f"{w}.pick", f"arc {lo}-{hi} is not within 0 to 255") + _need(lo == at, f"{w}.pick", f"the arcs leave a gap or overlap at {lo}") + at = hi + 1 + target = m.moves.get(name) + _need(target is not None and target.played, f"{w}.pick", f"{name!r} is no own move") + _need(at == 256, f"{w}.pick", f"the arcs end at {at - 1}, not 255") + + def _brain(b: Brain) -> None: _need(b.donor > 0 and b.slot >= 0, "brain", "donor is a monster id and slot 0 or more") _need(b.history in (None, *script_vars.BYTE), "brain.history", "is a script variable, 1 to 4") @@ -791,13 +827,15 @@ def _validate(m: Manifest) -> None: claimed: dict[tuple[int, int | None], str] = {} for name, mv in m.moves.items(): w = f"moves.{name}" - _need(mv.clip is not None or mv.anim is not None, w, "needs clip or anim") + _need(mv.picker or mv.clip is not None or mv.anim is not None, w, "needs clip or anim") _need(mv.clip is None or mv.clip in m.clips, w, f"clip {mv.clip!r} is not in clips") _need(mv.after is None or mv.after in m.moves, w, f"after {mv.after!r} is not in moves") _need(mv.after != name, w, "after names the move itself") _need(mv.hold_max is None or mv.hold_max >= 1, w, "hold_max is at least 1 tick") _need((mv.main is None) == (mv.sub is None), w, "needs both main and sub, or neither") - if mv.own: + if mv.picker: + _need(mv.main is None and mv.sub is None, w, "a picker is an own move") + elif mv.own: _own_move(mv, w) else: plain = Move(mv.main, mv.sub) @@ -875,7 +913,10 @@ def _validate(m: Manifest) -> None: _need(lever in (None, *BYTE), w, f"{lever} is not a byte") for i, e in enumerate(m.effects): _need(e.move in m.moves, f"effect[{i}]", f"move {e.move!r} is not in moves") - own = sum(mv.own for mv in m.moves.values()) + for name, mv in m.moves.items(): + if mv.picker: # after the loop above: a variant may come later + _picker(m, mv, f"moves.{name}") + own = sum(mv.played for mv in m.moves.values()) _need(own <= OWN_MOVES, "moves", f"{own} own moves, the framework holds {OWN_MOVES}") validate(m) diff --git a/packages/mhfu-port/src/mhfu_port/moves.py b/packages/mhfu-port/src/mhfu_port/moves.py index 615ff93c..c176c800 100644 --- a/packages/mhfu-port/src/mhfu_port/moves.py +++ b/packages/mhfu-port/src/mhfu_port/moves.py @@ -9,9 +9,10 @@ A pair move goes as the runtime paints it on its host pair. An own move goes as the move player takes it (`mhfu.move_play`): its `slot` in the framework's registry (`slots`), its clip's executor -entry and, for `turn = "clip"`, its turn keys, both from the build's layout; the attack windows, -the carrier (`hub` when it names none), the steer and `after`, and `start` and `stop`, the clip -frames it plays from and to, when it sets them (`length` counts AI frames instead), and `aim_out`. +entry and its turn keys (the clip's facing, in every turn mode), both from the build's layout; the +attack windows, the carrier (`hub` when it names none), the steer and `after`, and `start` and +`stop`, the clip frames it plays from and to, when it sets them (`length` counts AI frames +instead), and `aim_out`. The steer carries the move's own `travel` (`mhfu.steering.hex_of`), `lunge` and `code_turn` (`mhfu.steering.turns_hex`, with `turn = "aim"` where the manifest says so) when the manifest has them, and a fixed turn's `window`. `tip` is the tail tip's `{ joint, carrier }` pairs @@ -26,9 +27,11 @@ and the brain splice's `enter OWN_MOVE_MAIN k 0` names it. `combos` is the same for the enter ids past the slots: each (first, then) pair of own moves a `[brain.pairs]` list names is one id (`combo_id`), written as `combos = { { id, "first", "then" } }`, which `mhfu_port.lua` registers -(`mhfu.em_combo`) and the splice enters as `enter OWN_MOVE_MAIN id 0`. The manifest it is given -has the own moves `[brain.pairs]` derive from the donor (`brain.own.expand`), which -`cli.build.modules` adds. +(`mhfu.em_combo`) and the splice enters as `enter OWN_MOVE_MAIN id 0`. A picker (`Move.pick`) has +no slot and is not in the module: the splice picks its variants by the target's bearing, and a list +that names a picker and an own move is one combo per variant (`picks`, `combo_ids`: what the splice +takes). The manifest it is given has the own moves `[brain.pairs]` derive from the donor +(`brain.own.expand`), which `cli.build.modules` adds. """ from __future__ import annotations @@ -129,34 +132,46 @@ def add( def slots(m: Manifest) -> dict[str, int]: """Each own move's registry slot: sorted by name, numbered from 0, at most `SLOTS` (`problems` names the rest).""" - own = sorted(name for name, mv in m.moves.items() if mv.own) + own = sorted(name for name, mv in m.moves.items() if mv.played) return {name: k for k, name in enumerate(own[:SLOTS])} +def picks(m: Manifest) -> dict[str, list[tuple[int, int, str]]]: + """Each picker's arcs, `(lo, hi, variant)` in order: what the splice compiles a pick to.""" + return {n: sorted(mv.pick) for n, mv in m.moves.items() if mv.pick is not None} + + def combos(m: Manifest) -> list[tuple[str, str]]: """The (first, then) own-move pairs a manifest's combos are: every `[brain.pairs]` list value - `[x, y]` whose x and y are both own moves with a slot, each pair once, in sorted order. The - framework holds `COMBOS` (`problems` names the rest); a pair's place here is its enter id.""" + `[x, y]` whose y is an own move with a slot and whose x is one too, or a picker, whose variants + are each the first of one (the list names a turn that picks by bearing, then the move). Each + pair once, in sorted order. The framework holds `COMBOS` (`problems` names the rest); a pair's + place here is its enter id.""" if m.brain is None: return [] - own = slots(m) - found: set[tuple[str, str]] = set() + own, found = slots(m), set[tuple[str, str]]() for key in m.brain.pairs: names = m.brain.targets(pair_of(key)) - if len(names) == 2 and isinstance(names[0], str) and isinstance(names[1], str): - if names[0] in own and names[1] in own: - found.add((names[0], names[1])) + if len(names) != 2 or not isinstance(names[0], str) or not isinstance(names[1], str): + continue + first, then = names + pick = m.moves[first].pick if first in m.moves else None + for one in {v for _, _, v in pick} if pick is not None else [first]: + if one in own and then in own: + found.add((one, then)) return sorted(found) +def combo_ids(m: Manifest) -> dict[tuple[str, str], int]: + """Every combo with its enter id: `SLOTS` plus its place in `combos`, what the splice enters + (`enter OWN_MOVE_MAIN id 0`); the framework's table holds no more than `COMBOS`.""" + return {pair: SLOTS + k for k, pair in enumerate(combos(m)[:COMBOS])} + + def combo_id(m: Manifest, first: str, then: str) -> int | None: - """The enter id of the combo (first, then): `SLOTS` plus its place in `combos`, what the - splice enters (`enter OWN_MOVE_MAIN id 0`); None for a pair that is none or past the + """The enter id of the combo (first, then); None for a pair that is none or past the framework's table.""" - try: - return SLOTS + combos(m)[:COMBOS].index((first, then)) - except ValueError: - return None + return combo_ids(m).get((first, then)) def hub(species: int) -> Pair | None: @@ -208,10 +223,8 @@ def entry(m: Manifest, name: str, layout: Layout) -> int: def curve(m: Manifest, name: str, layout: Layout) -> str | None: - """The turn keys YAW follows, for `turn = "clip"` on a clip that turns.""" - mv = m.moves[name] - if mv.steer.turn != "clip": - return None + """The turn keys YAW follows, for a clip that turns: the clip's facing, which `carry` took + out of its pose, whatever the move's own turn is.""" t = layout.turns.get(entry(m, name, layout)) return None if t is None else t.lua() @@ -314,10 +327,10 @@ def problems(m: Manifest, layout: Layout, known: Collection[int] | None = None) holds, a combo whose first move does not ride the hub; rules on the flinch whose moves ride different carriers.""" out: list[str] = [] - if (n := sum(mv.own for mv in m.moves.values())) > SLOTS: + if (n := sum(mv.played for mv in m.moves.values())) > SLOTS: out.append(f"moves: {n} own moves, the framework holds {SLOTS}") for name, mv in m.moves.items(): - if not mv.own: + if not mv.played: continue w = f"moves.{name}" try: @@ -436,7 +449,7 @@ def _steer(m: Manifest, name: str, layout: Layout) -> dict[str, Any]: out |= {"window": list(s.window)} if s.window is not None else {"frames": s.frames} elif s.turn == "aim": out["turn"] = "aim" - elif (keys := curve(m, name, layout)) is not None: + if (keys := curve(m, name, layout)) is not None: out["curve"] = keys mv = m.moves[name] if mv.travel: @@ -565,6 +578,8 @@ def lua( ] at = slots(m) for name, mv in m.moves.items(): + if mv.picker: + continue body = _own_move(m, name, layout, at[name]) if mv.own else pair_move(mv) out.append(f" {lua_key(name)} = {lua_value(body)},") out += [" },"] diff --git a/packages/mhfu-port/src/mhfu_port/p3hits.py b/packages/mhfu-port/src/mhfu_port/p3hits.py index 665b6484..b1c65806 100644 --- a/packages/mhfu-port/src/mhfu_port/p3hits.py +++ b/packages/mhfu-port/src/mhfu_port/p3hits.py @@ -25,6 +25,14 @@ acceleration, the floor), which the float loads, stores and helpers of the code act on, so the tests of the clip cursor and of the height come out as they do in the game. Each step carries what that motion came to in its own frame: `travel`, `lift`, `lunge` and `spreads`. + +A handler that reads the bearing of its target (`ANGLE`, the side rule `SIDE`) has the walk answer +it for a bearing `D` (a `plan` with `bearing`): the target stands D YAW units to the left of the +monster's facing. The walk then also carries every entity cell the handler stores from one AI +frame to the next, so its phases, flags and yaw come out as they do in the game, and records the +yaw the handler's code adds (`Step.code`). `arms` walks such a handler at 45 degree steps of D, +finds each change of plan to 1/256 of a turn (the script's arc resolution) and gives the arcs +that play one plan alike: `Arm`. """ from __future__ import annotations @@ -32,8 +40,8 @@ import bisect import math import struct -from collections.abc import Mapping, Sequence -from dataclasses import dataclass, field +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field, replace from typing import Any, Literal, NamedTuple import rabbitizer @@ -47,6 +55,7 @@ from . import motion, p3motion from . import p3moveset as p3 from .brain.p3 import Species +from .brain.splice import GLOBAL_540 from .p3motion import Lunge, Motion, Pose, Spread from .p3moveset import Moveset, Pair, Pairs from .travel import KEY_STEP @@ -93,6 +102,23 @@ TURN = 0x09CA12F4 # noaddr: (entity, rate) turns toward the target by at most rate YAW units MOTION = (*sorted(p3motion.TARGETS), 0x0889F098) # noaddr: a curve table read at the cursor """Calls that move the monster by code rather than by the clip.""" +GLOBALS = 0x09DA9600 # noaddr: a pointer to the global object a handler tests a word of +GLOBAL_WORD = 0x540 +"""That word's offset: the handlers compare it with `GLOBAL_540` as the script does.""" +ANGLE = 0x08876E84 # noaddr: (from, to) the bearing of a point from another, in YAW units +SIDE = 0x09DB41B8 # noaddr: (entity) 1 when the target's bearing minus the yaw is 0 to a half turn +CARRY = 0x09DB426C # noaddr: (entity, main, sub, blend) hands over to a pair, the blend carried +BLEND_IN = 0x09CA67D8 # noaddr: (entity) 1 while the playing clip is blending in +YAW = (0x74, 4) +"""The entity's yaw, as a cell.""" +HALF = 0x8000 +"""YAW units in half a turn; bit 15 of a bearing minus the yaw is the side.""" +BUCKETS = 256 +"""The script's arc resolution: a turn in 256ths. Bucket `k` is judged at bearing `256 k + 1`.""" +SAMPLE = 32 +"""Buckets between the bearings a handler is first walked at: 45 degrees.""" +MIN_CODE = 16 +"""YAW units a code turn comes to under which it is left out.""" STEP_FRAMES = 2.0 """Clip frames the cursor advances in an AI frame at normal speed.""" MIN_TRAVEL, MIN_LIFT = 0.5, 1.0 @@ -134,6 +160,8 @@ p3.ATTACK0: "spawn0", KILL: "kill", TURN: "turn", + SIDE: "side", + CARRY: "carry", **dict.fromkeys(MOTION, "motion"), } """What the walk records, by call target; a kind that needs its target carries it after a colon.""" @@ -297,6 +325,15 @@ class Step: lunge: Lunge | None = None spreads: tuple[Spread, ...] = () """The turns of a fixed total it makes.""" + code: tuple[int, ...] = () + """The yaw its code adds, cumulative YAW units at the clip frames `start`, `start + 2`, ... + (the first is 0): walked at a bearing only. Empty: none.""" + aim: bool = False + """`code` is the curve for an `aim` turn: its total is the angle its arm ends at, and the + handler turns exactly the bearing.""" + hand: int | None = None + """The blend, in AI frames, the pair hands over with, when this is its last step and the + handler says it.""" @dataclass(frozen=True) @@ -320,6 +357,19 @@ class Plan: notes: tuple[Note, ...] = () alternatives: tuple[tuple[int, ...], ...] = () """The clips of the other paths its branches could take.""" + reads: bool = False + """A run asked for the bearing of the target (`ANGLE`, `SIDE`).""" + bearing: int | None = None + """The bearing it was walked at; None: not at one.""" + + +class Arm(NamedTuple): + """The bearings a handler plays one plan at.""" + + arcs: tuple[tuple[int, int], ...] + """Runs of buckets (lo, hi), 256ths of a turn to the left of the facing, both ends in.""" + plan: Plan + """The plan, walked at `plan.bearing`.""" @dataclass(frozen=True) @@ -372,6 +422,12 @@ class _Step: """The position after each AI frame by `elapsed`, x and z from `origin`, y over rest.""" lunge: Lunge | None = None spreads: list[Spread] = field(default_factory=list) + yaw: int | None = None + """The yaw cell as the last AI frame left it; None: not followed.""" + turned: int = 0 + """The YAW units the code added since the step began.""" + yaws: dict[float, int] = field(default_factory=dict) + """`turned` after each AI frame by `elapsed`.""" @dataclass @@ -421,6 +477,15 @@ class _Run: """Where the monster was at each clip start of this tick, by call site.""" events: list[tuple[int, Lunge | Spread]] = field(default_factory=list) """What the helpers asked for this tick, with the clip starts that came before it.""" + bearing: int | None = None + """Where the target is, YAW units to the left of the facing the run began with; None: not + known.""" + read: bool = False + """The handler asked for the bearing.""" + blend_in: int = 0 + """AI frames of the playing clip's blend that are left.""" + hand: int | None = None + """The blend the pair handed over with.""" @property def current(self) -> _Step | None: @@ -463,10 +528,12 @@ def __init__( depth=3, guards=False, args=ARGS, + pointers={GLOBALS: {GLOBAL_WORD: GLOBAL_540}}, probes=stores, returns={CURSOR_STEP: step}, max_paths=512, ) + self._armed: dict[int | None, list[tuple[tuple[object, ...], tuple[Arm, ...]]]] = {} self._hook = self._hook_function() self._hook_walker = Walker( ms.code, @@ -502,19 +569,22 @@ def _hook_function(self) -> int | None: # --- the plan --- - def plan(self, pair: Pair) -> Plan: + def plan(self, pair: Pair, bearing: int | None = None) -> Plan: """The default path through `pair`'s handler. Every branch the walk cannot decide falls through; then each is tried alone another way (`BRANCHES`). The outcome that spawns the - most attacks wins, then the one that plays the fewest clips, then the first tried.""" + most attacks wins, then the one that plays the fewest clips, then the first tried. + + With a `bearing` (YAW units, the target to the left of the facing) the handler's reads of + the target's bearing are answered, and what it stores carried from frame to frame.""" at = (pair.main, pair.sub) if pair.handler is None or self.ms.tick_entry is None: return Plan(at, (), (), False, (Note("unresolved", "it has no handler of its own"),)) - first = self._run(pair, {}) + first = self._run(pair, {}, bearing=bearing) runs = [first] for how in BRANCHES: for pc in first.trail: if len(runs) < MAX_RUNS: - runs.append(self._run(pair, {**first.vector, pc: how})) + runs.append(self._run(pair, {**first.vector, pc: how}, bearing=bearing)) best = min(runs, key=lambda r: (not r.left, -_spawns(r), len(r.done))) chain = tuple(s.clip for s in best.done) others: list[tuple[int, ...]] = [] @@ -530,7 +600,17 @@ def plan(self, pair: Pair) -> Plan: ] if len(first.trail) * len(BRANCHES) >= MAX_RUNS: notes.append(Note("limit", f"only {len(runs) - 1} runs of its branches were tried")) - return Plan(at, tuple(best.done), best.leaves, best.left, tuple(notes), tuple(others)) + reads = any(r.read for r in runs) + return Plan( + at, + tuple(best.done), + best.leaves, + best.left, + tuple(notes), + tuple(others), + reads, + bearing, + ) def settle(self, pair: Pair, hp: float | None = None) -> Settled: """`pair`'s handler run to its end on the default path, with the float tests of the HP @@ -539,10 +619,90 @@ def settle(self, pair: Pair, hp: float | None = None) -> Settled: run = self._run(pair, {}, hp) return Settled(dict(run.cells), run.left, run.ticks, tuple(run.trail)) - def _run(self, pair: Pair, vector: dict[int, int], hp: float | None = None) -> _Run: + # --- the arms --- + + def arms(self, pair: Pair, base: Plan | None = None) -> tuple[Arm, ...]: + """The plans `pair`'s handler plays by the target's bearing: where it asks for it, the + handler is walked with the target at every 45 degrees, each change of plan found to a + bucket (1/256 of a turn, `BUCKETS`) by bisection, and the runs of buckets that play one + plan are an arm. Plans are compared by their clips, where they start and stop, and where + they hand over to. A handler that plays one plan everywhere has one arm, its plan walked + with no bearing (`base`, if the caller has it). An arm's plan is walked at the middle of + its widest run; where it turns by code, a second bearing in the run tells a turn that goes + by the bearing (the curve scaled to it) from one that does not. + + Pairs of one handler that differ in what they hand over to alone (the wrappers of a turn, + each leading to another pair) walk alike at every bearing sampled: they share the arms.""" + base = base if base is not None else self.plan(pair) + whole = ((0, BUCKETS - 1),) + if not base.reads or pair.handler is None or self.ms.tick_entry is None: + return (Arm(whole, base),) + runs: dict[int, _Run] = {} + + def probe(k: int) -> _Run: + if k not in runs: + runs[k] = self._run(pair, {}, bearing=_bearing_of(k)) + return runs[k] + + def sig(k: int) -> tuple[object, ...]: + return _signature(probe(k)) + + samples = [*range(0, BUCKETS, SAMPLE), BUCKETS - 1] + seen = tuple((tuple(probe(k).done), probe(k).left) for k in samples) + if len({sig(k) for k in samples}) == 1: + return (Arm(whole, base),) + for earlier, held in self._armed.get(pair.handler, []): + if earlier == seen: + return tuple(_rehanded(arm, pair, probe, samples) for arm in held) + edges = [0] + for a, b in zip(samples, samples[1:], strict=False): + if sig(a) == sig(b): + continue + lo, hi = a, b + while hi - lo > 1: + mid = (lo + hi) // 2 + lo, hi = (mid, hi) if sig(mid) == sig(lo) else (lo, mid) + edges.append(hi) + spans = [(lo, hi - 1) for lo, hi in zip(edges, [*edges[1:], BUCKETS], strict=True)] + groups: dict[tuple[object, ...], list[tuple[int, int]]] = {} + for span in spans: + groups.setdefault(sig(span[0]), []).append(span) + planned: dict[tuple[object, ...], Arm] = {} + for arcs in groups.values(): + lo, hi = max(arcs, key=lambda a: a[1] - a[0]) + at = (lo + hi) // 2 + alt = lo if at - lo >= hi - at else hi + plan = self.plan(pair, _bearing_of(at)) + aimed = _aimed(plan, (lo, hi), at, alt, probe(alt)) + same = planned.get(_plan_signature(aimed)) + joined = (*(same.arcs if same else ()), *arcs) + planned[_plan_signature(aimed)] = Arm( + tuple(sorted(joined)), same.plan if same else aimed + ) + out = tuple(planned.values()) + if len(out) == 1: + out = (Arm(whole, base),) + self._armed.setdefault(pair.handler, []).append((seen, out)) + return out + + def _run( + self, + pair: Pair, + vector: dict[int, int], + hp: float | None = None, + bearing: int | None = None, + ) -> _Run: tick = self.ms.tick_entry assert tick is not None - run = _Run(dict(vector), Motion(self.ms.ovl, self.rest), cells=dict(self.facts), hp=hp) + run = _Run( + dict(vector), + Motion(self.ms.ovl, self.rest), + cells=dict(self.facts), + hp=hp, + bearing=bearing, + ) + if bearing is not None: + run.cells[YAW] = 0 decide = self._decider(run) at = self._dispatched(pair) while run.ticks < MAX_TICKS: @@ -554,7 +714,9 @@ def _run(self, pair: Pair, vector: dict[int, int], hp: float | None = None) -> _ **known({PHASE: run.phase, p3.MAIN: at[0], p3.SUB: at[1]}), **run.cells, } - sites = self._walker.run(tick, None, facts, decide, run.kin.cells) + sites = self._walker.run( + tick, None, facts, decide, run.kin.cells, persist=bearing is not None + ) if self._hook is not None and run.clip is not None: sites += self._hook_walker.run(self._hook, None, None, decide) phase = run.phase @@ -592,6 +754,11 @@ def decide(pc: int, target: int | None, args: tuple[int | None, ...]) -> int | f return None # a tail call: only the motion is told of those if target in CURSOR_GATES: return self._gate(run, args) + if target in (ANGLE, SIDE): + return self._bearing(run, target) + if target == BLEND_IN: + run.moved = run.moved or run.blend_in > 0 # the answer changes: no skipping + return int(run.blend_in > 0) if target == CLIP_ENDED: return self._ask(run, _Query(None)) if target == CLIP_ID: @@ -608,6 +775,16 @@ def decide(pc: int, target: int | None, args: tuple[int | None, ...]) -> int | f return decide + def _bearing(self, run: _Run, target: int) -> int | None: + """The bearing of the target (`ANGLE`: the world's, as it stood when the run began) or its + side of the yaw (`SIDE`), for the bearing the run is at; None where it is at none.""" + run.read = True + if run.bearing is None: + return None + if target == ANGLE: + return run.bearing & 0xFFFF + return int(not (run.bearing - run.cells.get(YAW, 0)) & HALF) + def _act(self, run: _Run, target: int, args: tuple[int | None, ...]) -> float | None: """A helper the motion acts for, or a read of the clip it answers.""" run.moved = True @@ -627,6 +804,11 @@ def _sample(self, run: _Run) -> None: step = run.current if step is not None: step.trace[step.elapsed] = _relative(step, run.kin.now(), self.rest) + if (yaw := run.cells.get(YAW)) is not None: + if step.yaw is not None: + step.turned += _signed16(yaw - step.yaw) + step.yaw = yaw + step.yaws[step.elapsed] = step.turned def _branch(self, run: _Run, pc: int) -> int: if run.hp is not None and (said := self._test_hp(pc, run.hp)) is not None: @@ -773,6 +955,13 @@ def _apply_sites(self, run: _Run, pair: Pair, sites: Sequence[Site]) -> None: self._spawn(run, s, kind == "spawn") elif kind == "kill": self._kill(run, s.args[0]) + elif kind == "store": + off, width, value = s.args + assert off is not None and width is not None + for cell in [c for c in run.cells if c[0] < off + width and off < c[0] + c[1]]: + del run.cells[cell] + if value is not None: + run.cells[off, width] = value elif kind == "probe" and s.site in self._cells: cell = self._cells[s.site] if s.args[0] is not None: @@ -787,9 +976,11 @@ def _apply_sites(self, run: _Run, pair: Pair, sites: Sequence[Site]) -> None: run.notes.append(Note("motion", "the handler moves the monster by code")) elif kind == "end": self._leave(run, p3.END[int(target, 16)]) - elif kind == "enter": + elif kind in ("enter", "carry"): a = s.args self._leave(run, ((a[0], a[1]),) if a[0] is not None and a[1] is not None else ()) + if kind == "carry": + run.hand = a[2] def _play(self, run: _Run, pair: Pair, s: Site) -> None: clip = s.args[0] @@ -835,10 +1026,11 @@ def _start(self, run: _Run, clip: int, blend: int | None, start: int, site: int) hit = Hit(slot, node.hit.record, node.hit.flags, float(start), end, True) carried[slot] = _Node(hit, node.left) run.nodes = carried - step = _Step(clip, blend, float(start), origin=where) + step = _Step(clip, blend, float(start), origin=where, yaw=run.cells.get(YAW)) step.hits.extend(n.hit for n in carried.values()) run.steps.append(step) run.clip, run.cursor, run.played = clip, float(start), True + run.blend_in = blend or 0 def _at(self, run: _Run) -> float: """The clip frame an event of this tick happens at: the frame of the crossing that let it @@ -886,6 +1078,7 @@ def _advance(self, run: _Run, n: int) -> None: return step.ticks += n step.elapsed += n * self.step + run.blend_in = max(0, run.blend_in - n) length = self.lengths.get(step.clip) run.cursor += n * self.step for node in run.nodes.values(): @@ -911,7 +1104,9 @@ def _crossed(self, run: _Run) -> float | None: return q.hi if was and q.mode in WINDOWS else q.lo return None - def _close_step(self, run: _Run, how: How, stop: float | None = None) -> None: + def _close_step( + self, run: _Run, how: How, stop: float | None = None, hand: int | None = None + ) -> None: step = run.current if step is None: return @@ -936,13 +1131,16 @@ def _close_step(self, run: _Run, how: How, stop: float | None = None) -> None: _height(step.trace, round(ticks * self.step)), step.lunge, tuple(step.spreads), + _code(step.yaws), + False, + hand, ) ) run.steps.pop() def _close(self, run: _Run) -> None: how: How = "clip" if run.ended else "pair" if run.left else "cursor" - self._close_step(run, how) + self._close_step(run, how, hand=run.hand if run.left else None) if run.kin.lost: run.notes.append(Note("motion", f"its motion could not be followed: {run.kin.lost}")) @@ -977,6 +1175,111 @@ def _turns(step: _Step) -> tuple[Turn, ...]: return tuple(out) +def _bearing_of(bucket: int) -> int: + """The bearing, YAW units to the left of the facing, a bucket is judged at: one unit past its + lower edge, so that a compare at a whole fraction of a turn judges it by the bearings above.""" + d = bucket * 256 + 1 + return d - 0x10000 if d >= HALF else d + + +def _signature(run: _Run) -> tuple[object, ...]: + """What makes two runs one plan: the clips, from where to where, and what they hand over to.""" + return tuple((s.clip, s.start, s.stop) for s in run.done), run.leaves, run.left + + +def _plan_signature(plan: Plan) -> tuple[object, ...]: + """What makes two plans one arm: their clips, from where to where, and where they lead.""" + return tuple((s.clip, s.start, s.stop) for s in plan.steps), plan.leaves, plan.complete + + +def _rehanded(arm: Arm, pair: Pair, probe: Callable[[int], _Run], samples: Sequence[int]) -> Arm: + """`arm` of another pair of the handler, with what `pair` hands over to at a bearing in it.""" + k = next(k for k in samples if any(lo <= k <= hi for lo, hi in arm.arcs)) + plan = replace(arm.plan, pair=(pair.main, pair.sub), leaves=probe(k).leaves) + return Arm(arm.arcs, plan) + + +def _outer(span: tuple[int, int]) -> int: + """The largest angle, in YAW units, a bearing of the buckets `span` is off the facing.""" + lo, hi = span + if hi < BUCKETS // 2: + return (hi + 1) * 256 + return (BUCKETS - lo) * 256 if lo >= BUCKETS // 2 else HALF + + +def _near(t: int, d: int) -> bool: + """`t` is `d` YAW units, within what rounding along a curve costs.""" + return abs(t - d) <= 64 + abs(d) // 32 + + +def _aimed(plan: Plan, span: tuple[int, int], at: int, alt: int, other: _Run) -> Plan: + """`plan`, walked at bucket `at`, with each step's turns told by the bearing, from a run + (`other`) at bucket `alt` of the same arm: + + - a turn by code that comes to the bearing itself at both is one that goes by the bearing: + `Step.code` is the curve scaled to the angle `span` ends at, and `aim` is set; one that comes + to the same at both is a curve that stands as it is; any other is dropped, with a note; + - a fixed turn (`spreads`) the other run does not make too depends on the bearing: it is + dropped, with a note, for the plan to carry the same turn wherever the target is.""" + d1, d2 = _bearing_of(at), _bearing_of(alt) + steps, notes = [], list(plan.notes) + for i, step in enumerate(plan.steps): + twin = other.done[i] if i < len(other.done) and other.done[i].clip == step.clip else None + if twin is None or abs(d1 - d2) < 512: + steps.append(step) + continue + if gone := [sp for sp in step.spreads if sp not in twin.spreads]: + step = replace(step, spreads=tuple(sp for sp in step.spreads if sp not in gone)) + text = ( + f"clip {step.clip}: a turn of {gone[0].degrees:.0f} degrees depends on the bearing" + ) + notes.append(Note("motion", text)) + if step.code: + t1, t2 = step.code[-1], twin.code[-1] if twin.code else 0 + if _near(t1, t2): + pass + elif _near(t1, d1) and _near(t2, d2): + size = min(_outer(span), 0x7FFF) * (1 if t1 > 0 else -1) + code = tuple(max(-0x7FFF, min(0x7FFF, round(c * size / t1))) for c in step.code) + step = replace(step, code=code, aim=True) + else: + step = replace(step, code=()) + text = f"clip {step.clip}: a turn by code depends on the bearing, not as an aim" + notes.append(Note("motion", text)) + steps.append(step) + return replace(plan, steps=tuple(steps), notes=tuple(notes)) + + +def _signed16(x: int) -> int: + """The low 16 bits of `x` as a signed YAW difference.""" + x &= 0xFFFF + return x - 0x10000 if x >= HALF else x + + +def _interp(keys: Sequence[float], series: Mapping[float, int], frame: float) -> float: + """The series at clip frame `frame`: straight lines between its points (`keys`, in order), + held past them.""" + if frame <= keys[0]: + return series[keys[0]] + if frame >= keys[-1]: + return series[keys[-1]] + hi = bisect.bisect_right(keys, frame) + a, b = keys[hi - 1], keys[hi] + return series[a] + (frame - a) / (b - a) * (series[b] - series[a]) + + +def _code(turns: Mapping[float, int]) -> tuple[int, ...]: + """The code turn: the cumulative yaw every KEY_STEP clip frames from the step's start, the + first 0, each held within a signed 16 bits; empty where it comes to nothing.""" + if not turns: + return () + keys = sorted(turns) + n = math.ceil(keys[-1] / KEY_STEP - 1e-9) + out = [0] + [round(_interp(keys, turns, KEY_STEP * k)) for k in range(1, n + 1)] + out = [max(-0x7FFF, min(0x7FFF, v)) for v in out] + return tuple(out) if max(abs(v) for v in out) >= MIN_CODE else () + + def _relative(step: _Step, at: Pose, rest: float) -> Pose: """`at` as a step's trace holds it: x and z from where the step began, y over `rest`.""" return Pose(at.x - step.origin.x, at.y - rest, at.z - step.origin.z) diff --git a/packages/mhfu-port/src/mhfu_port/p3motion.py b/packages/mhfu-port/src/mhfu_port/p3motion.py index 22b1295d..7a992a86 100644 --- a/packages/mhfu-port/src/mhfu_port/p3motion.py +++ b/packages/mhfu-port/src/mhfu_port/p3motion.py @@ -58,6 +58,7 @@ ROT_Y = 0x08875BD8 # noaddr: (vector, $f12 radians) turns x and z about y YAW_SPREAD = 0x09CA15AC # noaddr: (entity, total, $f12 lo, $f13 hi) yaw += total over (lo, hi] CURVE_DELTA = 0x09DB3FB4 # noaddr: (entity, curve, $f12 t) $f0 the curve's change over a step +CURVE_DELTA_RAW = 0x09DB3EAC # noaddr: the same unscaled by the monster's size: the yaw curves' LUNGE = 0x09DB44F0 # noaddr: (entity, bearing, $f12..$f18) travel toward the target CURVE_MOVE = 0x09DB4030 # noaddr: (entity, x, y, z, yaw curves, bias) a curve walk CURVE_MOVE_INIT = 0x09DB3FFC # noaddr: (entity) zero the velocity, keep the yaw @@ -66,6 +67,8 @@ """The entity's position cells.""" VELOCITY, ACCELERATION, GROUND = 0xB0, 0xC0, 0xD8 """Velocity (x, y, z), acceleration (x, y, z) and the floor height, in cells 4 bytes apart.""" +SPEED = 0xAC4 +"""The cell of the clip's speed, the factor a handler's turn rates go by: 1.0 in a walk.""" REST = 298.0 """The height over the floor a monster stands at: the value the donor's stamp snaps to when it lands, and the one its landing test reads (`Y - D8 <= 298`). Not read from a cell: MOVE_MODE(0) @@ -214,6 +217,7 @@ def __post_init__(self) -> None: self.cells.update({base: 0.0, base + 4: 0.0, base + 8: 0.0}) self.cells[Y] = self.rest self.cells[GROUND] = 0.0 + self.cells[SPEED] = 1.0 # --- a step and an AI frame --- @@ -392,6 +396,7 @@ def _lunge(self, args: Args) -> float | None: ROT_Y: Motion._rot_y, YAW_SPREAD: Motion._yaw_spread, CURVE_DELTA: Motion._curve_delta, + CURVE_DELTA_RAW: Motion._curve_delta, LUNGE: Motion._lunge, CURVE_MOVE: Motion._curve_move, CURVE_MOVE_INIT: Motion._curve_move_init, diff --git a/packages/mhfu-port/src/mhfu_port/travel.py b/packages/mhfu-port/src/mhfu_port/travel.py index 1797b121..f537258d 100644 --- a/packages/mhfu-port/src/mhfu_port/travel.py +++ b/packages/mhfu-port/src/mhfu_port/travel.py @@ -57,6 +57,11 @@ KEY_STEP = int(addresses.STEER_KEY_STEP) """Clip frames between the keys `carry` bakes and `Turn` holds, and between travel samples: the engine's clip speed, so its cursor lands on them; the framework's constant.""" +FLIP = 0.7 +"""The length of the children's summed (w, y), per child, under which `body_turn` takes the body +for near upside down (tipped past vertical is 1/sqrt 2) and its twist about y for ill-defined. +Clip 225's somersault is under it from frame 32 to the end, where the twist runs on from 12 to +171 degrees.""" _ROT_Y = next(bit for bit, kind in CHANNEL_BITS.items() if kind == ("rot", Y)) Vec2 = tuple[float, float] @@ -227,7 +232,9 @@ def body_turn(rig: fk.Rig, clip: Clip, skeleton: Skeleton, frames: ArrayLike) -> """Radians the body (the root's rotating children, averaged) faces off the model's forward about y at each of `frames` (whole clip frames, one apart, for the unwrap): the twist of its rotation, from within half a turn at the first, ending within half a turn of the - turn between its first and last pose.""" + turn between its first and last pose. Where the body is near upside down (`FLIP`) its twist + is ill-defined: those frames take the facing interpolated between the nearest frames that + are not, held beyond the first and last; a clip with no such frame keeps its raw twist.""" f = np.asarray(frames, dtype=np.float64) r = root(skeleton) kids = [ @@ -242,12 +249,24 @@ def body_turn(rig: fk.Rig, clip: Clip, skeleton: Skeleton, frames: ArrayLike) -> flip = np.cumprod(np.where(np.einsum("tkq,tkq->tk", q[1:], q[:-1]) < 0, -1.0, 1.0), axis=0) q[1:] *= flip[..., None] w, y = q[..., 0].sum(axis=1), q[..., 2].sum(axis=1) - twist = np.unwrap(_wrap(2 * np.arctan2(y, w))) + twist = _twist(w, y, len(kids)) net = _wrap(twist[-1] - twist[0]) out: fk.Floats = twist - (twist[-1] - twist[0] - net) * (f - f[0]) / (f[-1] - f[0]) return out +def _twist(w: fk.Floats, y: fk.Floats, kids: int) -> fk.Floats: + """`2 atan2(y, w)` unwrapped, the frames where `hypot(w, y)` is under `FLIP` per child + interpolated from those where it is not.""" + raw = 2 * np.arctan2(y, w) + sure = np.flatnonzero(np.hypot(w, y) >= FLIP * kids) + if not len(sure) or len(sure) == len(raw): + out: fk.Floats = np.unwrap(_wrap(raw)) + return out + out = np.interp(np.arange(len(raw)), sure, np.unwrap(_wrap(raw[sure]))) + return out + + def _wrap(a: ArrayLike) -> fk.Floats: """Radians within half a turn.""" out: fk.Floats = (np.asarray(a, dtype=np.float64) + math.pi) % math.tau - math.pi diff --git a/packages/mhfu-port/tests/test_brute_brain.py b/packages/mhfu-port/tests/test_brute_brain.py index f15ab17d..f04c2419 100644 --- a/packages/mhfu-port/tests/test_brute_brain.py +++ b/packages/mhfu-port/tests/test_brute_brain.py @@ -34,7 +34,10 @@ def spliced(data): host = build.lift_row(data.fu, m.port.host_species, m.port.host_species) survey = script_vars.survey(host, data.fu.em(m.port.host_species)) donor = p3.lift(data.p3rd, m.brain.donor, m.brain.slot) - return m, derived, host, splice.splice(donor, host, m.brain, survey, moves.slots(m)) + s = splice.splice( + donor, host, m.brain, survey, moves.slots(m), None, (), moves.picks(m), moves.combo_ids(m) + ) + return m, derived, host, s def test_pins_land(data): @@ -54,8 +57,12 @@ def test_brain_splices_into_its_host(data): found = [*derived, *s.findings] assert not [f for f in found if f.level == "error" or f.code == "P3_PAIR_UNUSED"] assert build.Built.of(host, s.edits).ok - names = {own.own_name(p) for p in OWN} - assert names <= set(s.slots) and {n for _, n in s.uses if isinstance(n, str)} == names + heads = {own.own_name(p) for p in OWN} + pickers = {n for n, mv in m.moves.items() if mv.picker} + assert pickers == {"p3_2_19", "p3_7_14"}, "the handlers that play other clips by the bearing" + variants = {v for n in pickers for _, _, v in m.moves[n].pick} + assert (heads - pickers) | variants <= set(s.slots) + assert {n for _, n in s.uses if isinstance(n, str)} == (heads - pickers) | variants def test_brain_has_no_split_and_one_draw_after_a_draw(data): diff --git a/packages/mhfu-port/tests/test_build.py b/packages/mhfu-port/tests/test_build.py index d65dcf19..b509837e 100644 --- a/packages/mhfu-port/tests/test_build.py +++ b/packages/mhfu-port/tests/test_build.py @@ -17,8 +17,8 @@ PORTS = Path(__file__).parents[3] / "ports" BUILT = { - "brute_tigrex": "8f6e613b7fd54a3aae855d4c8406211d49ea895c8410be9a250d39cfc2009732", - "zinogre": "31ff8e2018efb28853f388396c39304163011f0d965acd42dee4cdbfbee79024", + "brute_tigrex": "0978d335f7efd104ab3202be5255ec6dc91ff3db6109dbb85a0453cb6e63f0c9", + "zinogre": "cdc988ff2d0d439ef543a632416a128060034ff16d3e574331babd5ac08788a6", } """sha256 of each manifest's build: changes only with an intended change to the port.""" HEAD = '[port]\nname = "t"\nhost_species = 75\npac = "t.bin"\n[source]\nmodel = 5248\n' diff --git a/packages/mhfu-port/tests/test_fatigue.py b/packages/mhfu-port/tests/test_fatigue.py index ca41f92f..8b1728a0 100644 --- a/packages/mhfu-port/tests/test_fatigue.py +++ b/packages/mhfu-port/tests/test_fatigue.py @@ -368,4 +368,7 @@ def test_the_moves_module_carries_the_table(data, tmp_path, capsys): module = (tmp_path / moves.module_name(manifest.load(path))).read_text() assert " fatigue = { max = 1000, starts = { 400, 600 }, tired = 2250, refill = 500," in module assert "low_hp = 15, calm = true, pairs = { { 1, 4, 15 }" in module + assert "p3_7_14_a0 = 15, p3_7_14_a1 = 15, p3_7_14_a2 = 15" in module, ( + "a picker is charged by its variants" + ) assert "P3_FATIGUE_SHARED" in capsys.readouterr().err diff --git a/packages/mhfu-port/tests/test_manifest.py b/packages/mhfu-port/tests/test_manifest.py index e4398ff3..8c922bbc 100644 --- a/packages/mhfu-port/tests/test_manifest.py +++ b/packages/mhfu-port/tests/test_manifest.py @@ -789,3 +789,56 @@ def test_aim_is_derived_not_picked(): def test_a_code_turn_and_aim_out_are_checked(old, new, why): with pytest.raises(ManifestError, match=why): M.loads(MINIMAL + AIMED.replace(old, new)) + + +PICKED = """ +[moves.left] +anim = 9 +[moves.right] +anim = 8 +[moves.turn] +pick = [[0, 127, "left"], [128, 255, "right"]] +""" + + +def test_a_picker_round_trips_and_is_no_move_of_its_own(): + m = M.loads(MINIMAL + PICKED) + turn = m.moves["turn"] + assert turn.pick == [(0, 127, "left"), (128, 255, "right")] + assert turn.picker and turn.own and not turn.played and m.moves["left"].played + assert M.loads(M.dumps(m)) == m + assert M.loads(M.dumps(m)).moves["left"].pick is None + + +@pytest.mark.parametrize( + ("old", "new", "why"), + [ + ('[0, 127, "left"], ', '[0, 100, "left"], [102, 127, "left"], ', "gap or overlap at 102"), + ("[128, 255", "[120, 255", "gap or overlap at 120"), + ('255, "right"]', '256, "right"]', "is not within 0 to 255"), + ('[0, 127, "left"], ', "", "gap or overlap at 128"), + ('127, "left"', '127, "turn"', "'turn' is no own move"), + ('127, "left"', '127, "gone"', "'gone' is no own move"), + ('pick = [[0, 127, "left"], [128, 255, "right"]]', "pick = []", "needs an arc"), + ("pick = [", "anim = 4\npick = [", "a picker has only pick"), + ('[128, 255, "right"]', '[128, 254, "right"]', "end at 254, not 255"), + ], +) +def test_a_picker_is_checked(old, new, why): + with pytest.raises(ManifestError, match=why): + M.loads(MINIMAL + PICKED.replace(old, new)) + + +def test_a_picker_does_not_count_among_the_own_moves(): + text = "".join(f"[moves.m{k}]\nanim = 9\n" for k in range(M.OWN_MOVES)) + PICKED + with pytest.raises(ManifestError, match="own moves, the framework holds"): + M.loads(MINIMAL + text) + few = "".join(f"[moves.m{k}]\nanim = 9\n" for k in range(M.OWN_MOVES - 2)) + assert len(M.loads(MINIMAL + few + PICKED).moves) == M.OWN_MOVES + 1 + + +def test_a_host_pair_move_has_no_pick(): + with pytest.raises(ManifestError, match="a picker is an own move"): + M.loads( + MINIMAL + PICKED + '[moves.p]\nmain = 1\nsub = 2\nanim = 4\npick = [[0, 255, "left"]]\n' + ) diff --git a/packages/mhfu-port/tests/test_moves.py b/packages/mhfu-port/tests/test_moves.py index f087c1cb..52a6274e 100644 --- a/packages/mhfu-port/tests/test_moves.py +++ b/packages/mhfu-port/tests/test_moves.py @@ -531,7 +531,20 @@ def test_curve_is_the_turns_module_entry(): """The keys an own move turns by are the turns module's for its entry.""" turns = layout.turns_lua(_m(), LAYOUT) assert f'[46] = "{moves.curve(_m(), "stamp", LAYOUT)}"' in turns - assert moves.curve(_m(), "dash", LAYOUT) is None # turns toward the hunter instead + assert moves.curve(_m(), "dash", LAYOUT) is None # its entry does not turn + + +@pytest.mark.parametrize("turn", ["clip", "still", "hunter", "away", "fixed"]) +def test_a_move_plays_its_clips_facing_whatever_its_turn_is(turn): + """The clip's facing was taken out of its pose, so YAW has to play it in every mode.""" + m = _m() + m.moves["stamp"].steer = manifest.Steer( + turn=turn, angle=90.0 if turn == "fixed" else None, frames=20 if turn == "fixed" else None + ) + (line,) = [x for x in _body(moves.lua(m, LAYOUT, {6})) if x.startswith("stamp = ")] + assert f'curve = "{TURN.lua()}"' in line and ( + turn in ("clip", "still") or f'turn = "{turn}"' in line + ) def test_slots_are_the_sorted_names(): @@ -773,3 +786,53 @@ def test_problems_lists_every_one(): found = moves.problems(_m(bad), LAYOUT, {7}) assert [p.split(":")[0] for p in found] == ["moves.stamp.attack[0]"] * 2 assert moves.problems(_m(), LAYOUT, {6}) == [] + + +def _picked() -> manifest.Manifest: + text = ( + OWN + + PAIRS + + """ +[moves.turn_left] +anim = 9 +[moves.turn_right] +anim = 8 +[moves.turn] +pick = [[0, 127, "turn_left"], [128, 255, "turn_right"]] +""" + ) + m = _m(text) + m.brain.pairs["7,23"] = ["turn", "stamp"] # type: ignore[union-attr] + m.brain.pairs["7,24"] = ["turn", "dash"] # type: ignore[union-attr] + return m + + +def test_a_picker_has_no_slot_and_is_no_part_of_the_module(): + m = _picked() + assert "turn" not in moves.slots(m) and {"turn_left", "turn_right"} <= set(moves.slots(m)) + assert moves.picks(m) == {"turn": [(0, 127, "turn_left"), (128, 255, "turn_right")]} + lay = layout.Layout( + {20: 20, 21: 21, 46: 200, 9: 9, 8: 8}, + turns={46: TURN}, + frames={8: 100, 9: 100, 46: 228, 20: 82, 21: 66}, + ) + assert moves.problems(m, lay, {6}) == [] + text = moves.lua(m, lay, {6}) + assert "turn_left = " in text and "turn_right = " in text and " turn = " not in text + assert "pick" not in text + + +def test_a_list_of_a_picker_and_an_own_move_is_a_combo_of_each_variant(): + m = _picked() + assert [c for c in moves.combos(m) if "stamp" in c or "dash" in c] == [ + ("dash", "stamp"), + ("stamp", "spin"), + ("turn_left", "dash"), + ("turn_left", "stamp"), + ("turn_right", "dash"), + ("turn_right", "stamp"), + ] + ids = moves.combo_ids(m) + assert ids == {c: moves.combo_id(m, *c) for c in moves.combos(m)} + assert ids["turn_left", "stamp"] < ids["turn_right", "dash"] + assert min(ids.values()) == moves.SLOTS diff --git a/packages/mhfu-port/tests/test_own.py b/packages/mhfu-port/tests/test_own.py index 510cecd5..6188ed38 100644 --- a/packages/mhfu-port/tests/test_own.py +++ b/packages/mhfu-port/tests/test_own.py @@ -59,9 +59,16 @@ def test_overlap(): class Fake: """A walk that plans what it is given, and a matcher with fixed answers.""" - def __init__(self, plan: Plan | Sequence[Plan], hosts=HOSTS, active=10) -> None: + def __init__( + self, + plan: Plan | Sequence[Plan], + hosts=HOSTS, + active=10, + arms: dict[Pair, tuple[p3hits.Arm, ...]] | None = None, + ) -> None: plans = [plan] if isinstance(plan, Plan) else list(plan) - self.lengths = LENGTHS + self.armed = arms or {} + self.lengths, self.step = LENGTHS, p3hits.STEP_FRAMES self.plans = {p.pair: p for p in plans} self.ms = SimpleNamespace(pairs={p: p for p in self.plans}) self.hosts, self.active = hosts, active @@ -73,6 +80,9 @@ def __init__(self, plan: Plan | Sequence[Plan], hosts=HOSTS, active=10) -> None: def plan(self, pair: Pair) -> Plan: return self.plans[pair] + def arms(self, pair: Pair, base: Plan) -> tuple[p3hits.Arm, ...]: + return self.armed.get(pair) or (p3hits.Arm(((0, 255),), base),) + def match(self, record: int) -> own.Match: host = self.hosts.get(record) return own.Match(record, host, 0.5 if host is not None else 0.1, False, 4) @@ -118,6 +128,14 @@ def test_a_step_is_a_move(): manifest.check(m) +@pytest.mark.parametrize(("rate", "said"), [(None, "not known"), (0x10000, "out of range")]) +def test_a_turn_rate_the_walk_lacks_is_the_hosts_and_said(rate, said): + m, found = derive(plan(step(61, turns=[Turn(rate, (10.0, 40.0))]))) + assert m.moves["p3_7_13"].steer.rate is None + (f,) = [f for f in found if f.code == "P3_OWN_MOTION"] + assert f.level == "info" and said in f.text and "the host's" in f.text + + def test_a_chain_hands_on_and_a_loop_is_cut(): carried = Hit(0, 19, 3, 0.0, 14.0, carried=True) p = plan( @@ -163,9 +181,19 @@ def test_a_start_is_carried_and_the_clip_plays_on_to_its_end(): @pytest.mark.parametrize( ("ended", "stop"), [("cursor", None), ("pair", None), ("cursor", 500.0), ("cursor", 24.0)] ) -def test_a_step_with_no_frame_to_stop_on_keeps_its_length(ended, stop): - """No frame, or one outside the clip or not after the start.""" +def test_a_step_with_no_frame_to_stop_on_stops_where_the_walks_cursor_was(ended, stop): + """No frame, or one outside the clip or not after the start: 55 AI frames from 24.""" m, _ = derive(plan(step(61, ended=ended, ticks=55, start=24.0, stop=stop))) + assert spans(m) == (24, 134, None) + + +def test_a_cursor_past_the_clip_stops_at_its_end(): + m, _ = derive(plan(step(62, ended="pair", ticks=80, start=24.0))) + assert spans(m) == (24, 100, None) + + +def test_a_loop_is_cut_by_length_not_by_a_cursor_it_wraps(): + m, _ = derive(plan(step(59, ended="pair", ticks=55, start=24.0))) assert spans(m) == (24, 0, 55) @@ -197,7 +225,7 @@ def test_a_jump_plays_a_lifted_copy_of_its_clip_in_a_free_entry(): plan(dataclasses.replace(moving(lift=JUMP), hits=tuple(hits))), spare=[90, 91] ) mv = m.moves["p3_7_13"] - assert (mv.clip, mv.start, mv.stop, mv.length) == ("p3_61_air", 0, 0, 22) + assert (mv.clip, mv.start, mv.stop, mv.length) == ("p3_61_air", 0, 44, None) assert m.clips["p3_61_air"] == manifest.Clip( slot=90, source=61, start=0, frames=44, lift=list(JUMP) ) @@ -208,6 +236,25 @@ def test_a_jump_plays_a_lifted_copy_of_its_clip_in_a_free_entry(): manifest.check(m) +def test_a_jump_stops_at_its_last_frame_at_rest_or_above_so_the_landing_takes_the_fall(): + sunk = (*JUMP[:40], -5.0, -9.0, -9.0, -9.0, -9.0) + m, _ = derive(plan(moving(lift=sunk)), spare=[90]) + mv = m.moves["p3_7_13"] + assert (mv.clip, mv.start, mv.stop, mv.length) == ("p3_61_air", 0, 39, None) + cut = moving(start=100.0, lift=sunk) + m, _ = derive(plan(cut), spare=[90]) + assert (m.moves["p3_7_13"].start, m.moves["p3_7_13"].stop) == (0, 39) # counted from the copy + manifest.check(m) + + +def test_a_jump_ends_by_no_wall_and_a_run_does(): + m, _ = derive(plan(moving(clip=59, lift=JUMP)), spare=[90]) + mv = m.moves["p3_7_13"] + assert (mv.clip, mv.steer.walls, mv.length) == ("p3_59_air", False, None) + m, _ = derive(plan(moving(clip=59, ticks=40))) + assert (m.moves["p3_7_13"].steer.walls, m.moves["p3_7_13"].length) == (True, 40) + + def test_a_copy_counts_its_frames_from_where_the_step_starts(): hits = (Hit(0, 21, 0, 110.0, 130.0),) got = moving( @@ -276,7 +323,7 @@ def test_travel_past_the_clips_end_is_cut_there(): def test_a_window_is_cut_to_the_frames_the_move_plays_and_its_total_by_the_share_left(): lunge = p3motion.Lunge((0.0, 30.0), 400.0, 1800.0, -800.0, 1000.0) spread = p3motion.Spread(0x4000, (0.0, 60.0)) - m, found = derive(plan(moving(start=6.0, lunge=lunge, spreads=(spread,)))) + m, found = derive(plan(moving(start=6.0, ticks=100, lunge=lunge, spreads=(spread,)))) mv = m.moves["p3_7_13"] assert mv.lunge == Lunge((6, 30), 400.0, 1800.0, -640.0, 800.0) # 24 frames of the 30 assert (mv.steer.window, mv.steer.angle) == ((6, 60), 81.0) # 54 of 60 frames of 90 degrees @@ -296,13 +343,13 @@ def test_a_window_the_move_never_plays_is_left_out(): def test_a_fixed_turn_is_the_steps_and_beats_a_turn_toward_the_hunter(): spread = p3motion.Spread(0x2666, (60.0, 98.0)) - m, found = derive(plan(moving(spreads=(spread,), turns=(Turn(546, (10.0, 40.0)),)))) + m, found = derive(plan(moving(ticks=60, spreads=(spread,), turns=(Turn(546, (10.0, 40.0)),)))) s = m.moves["p3_7_13"].steer assert (s.turn, s.window, round(s.angle or 0)) == ("fixed", (60, 98), 54) assert any("kept the fixed" in f.text for f in found) manifest.check(m) whole = p3motion.Spread(0x10000, (0.0, 10.0)) # a whole turn is no turn the player can make - m, found = derive(plan(moving(spreads=(whole,)))) + m, found = derive(plan(moving(ticks=60, spreads=(whole,)))) assert m.moves["p3_7_13"].steer.turn == "clip" and any("left out" in f.text for f in found) @@ -359,11 +406,11 @@ def hand(pair: Pair, *steps: Step, to: Pair = (0, 0), complete: bool = True) -> return Plan(pair, steps, (to,), complete) -def chained(plans: Sequence[Plan], pairs: str = "", heads=((7, 13),), tables: str = ""): +def chained(plans: Sequence[Plan], pairs: str = "", heads=((7, 13),), tables: str = "", arms=None): """The heads derived from a fake walk over `plans`; `pairs` and `tables` are added to TEXT.""" m = manifest.loads(TEXT + pairs + tables) found: list = [] - fake = Fake(plans) + fake = Fake(plans, arms=arms) d = own.Derivation(m, fake, lambda: fake, found.append, heads) # type: ignore[arg-type] for h in heads: d.run(h) @@ -489,6 +536,80 @@ def test_each_pair_is_derived_once(): assert m.moves["p3_7_13"].after == "p3_7_14" +# --- arms --- + + +def arm(lo: int, hi: int, *steps: Step, pair: Pair = (7, 13), to: Pair = (0, 0)) -> p3hits.Arm: + return p3hits.Arm(((lo, hi),), hand(pair, *steps, to=to)) + + +def two_arms(*first: Step, pair: Pair = (7, 13), then: Sequence[Step] = (), **kw) -> dict: + """Arms of `pair`, left and right: the first's steps, then the others'.""" + left = arm(0, 127, *first, pair=pair, to=kw.get("left", (0, 0))) + right = arm(128, 255, *(then or first), pair=pair, to=kw.get("right", (0, 0))) + return {pair: (left, right)} + + +def test_a_pair_with_arms_is_a_picker_over_a_chain_per_arm(): + arms = two_arms(step(61), step(62), then=[step(63)]) + m, found, d = chained([hand((7, 13), step(61))], arms=arms) + pick = m.moves["p3_7_13"] + assert pick.picker and pick.pick == [(0, 127, "p3_7_13_a0"), (128, 255, "p3_7_13_a1")] + assert sequence.chain(m, "p3_7_13_a0") == ["p3_7_13_a0", "p3_7_13_a0_2"] + assert m.moves["p3_7_13_a1"].clip == "p3_63" and not m.moves["p3_7_13_a1"].after + assert d.made == ["p3_7_13_a0", "p3_7_13_a0_2", "p3_7_13_a1", "p3_7_13"] + manifest.check(m) + + +def test_an_arm_that_turns_by_the_bearing_is_an_aim_move_and_one_that_does_not_is_not(): + aimed = dataclasses.replace(step(61), code=(0, 10, 30, 60), aim=True) + fixed = dataclasses.replace(step(62), code=(0, -5, -20)) + arms = {(7, 13): (arm(0, 127, aimed), arm(128, 255, fixed))} + m, _, _ = chained([hand((7, 13), step(61))], arms=arms) + left, right = m.moves["p3_7_13_a0"], m.moves["p3_7_13_a1"] + assert left.steer == Steer(turn="aim", walls=False) and left.code_turn == [0, 10, 30, 60] + assert right.steer == Steer(walls=False) and right.code_turn == [0, -5, -20] + manifest.check(m) + + +def test_a_variant_a_list_follows_ends_where_the_donor_hands_over_and_slides_the_blend(): + last = dataclasses.replace(step(61, ended="pair", ticks=26), hand=6) + arms = { + (7, 14): ( + arm(0, 127, last, pair=(7, 14), to=(7, 20)), + arm(128, 255, step(62), pair=(7, 14)), + ) + } + plans = [hand((7, 14), last, to=(7, 20)), hand((7, 20), step(59))] + m, found, _ = chained( + plans, '"7,14" = ["own", "stamp"]\n', [(7, 14)], "[moves.stamp]\nanim = 9\n", arms + ) + first, second = m.moves["p3_7_14_a0"], m.moves["p3_7_14_a1"] + assert first.after is None and first.aim_out == 6 and second.aim_out == 0 + assert "p3_7_20" not in m.moves and not next_findings(found) + assert first.stop == 52 # where the donor handed over, not a wall or a count of frames + assert moves.combos(m) == [("p3_7_14_a0", "stamp"), ("p3_7_14_a1", "stamp")] + manifest.check(m) + + +def test_the_variants_of_a_picker_nothing_lists_hand_over_as_a_chain_does(): + arms = two_arms(step(61), pair=(7, 13), then=[step(62)], left=(7, 14), right=(7, 15)) + plans = [hand((7, 13), step(61), to=(7, 14)), hand((7, 14), step(59)), hand((7, 15), step(60))] + m, found, _ = chained(plans, arms=arms) + assert m.moves["p3_7_13_a0"].after == "p3_7_14" and m.moves["p3_7_13_a1"].after == "p3_7_15" + assert m.moves["p3_7_14"].clip == "p3_59" and m.moves["p3_7_15"].clip == "p3_60" + assert m.moves["p3_7_13_a0"].aim_out == 0 and not next_findings(found) + + +def test_a_pair_a_chain_hands_over_to_is_one_move_and_no_picker(): + plans = [hand((7, 13), step(61), to=(7, 14)), hand((7, 14), step(59))] + m, _, d = chained( + plans, '"7,14" = "own"\n', [(7, 13), (7, 14)], arms=two_arms(step(59), pair=(7, 14)) + ) + assert not m.moves["p3_7_14"].picker and m.moves["p3_7_13"].after == "p3_7_14" + assert "p3_7_14_a0" not in m.moves and sorted(d.made) == ["p3_7_13", "p3_7_14"] + + # --- sharing --- @@ -539,6 +660,33 @@ def test_two_anchored_moves_stay_apart(): assert set(m.moves) == {"a1", "a2", "b", "x", "y"} +def pickers(*picks: str, **after: str | None) -> manifest.Manifest: + """Own moves as `own_moves`, and a picker `p` over the arcs `0-127 a0` and `128-255 b0`.""" + m = own_moves(**after) + assert m.brain is not None + for name in picks: + m.moves[name] = manifest.Move(pick=[(0, 127, f"a{name}"), (128, 255, f"b{name}")]) + m.brain.pairs[f"7,{ord(name)}"] = name + return m + + +def test_pickers_share_the_variants_they_have_alike_and_keep_their_own_names(): + m = pickers("p", "q", ap=None, bp=None, aq=None, bq=None) + m.moves["bp"].start = m.moves["bq"].start = 5 + dropped = own.share(m, ["ap", "bp", "aq", "bq", "p", "q"]) + assert dropped == {"aq": "ap", "bq": "bp"} and set(m.moves) == {"ap", "bp", "p", "q"} + assert m.moves["q"].pick == m.moves["p"].pick == [(0, 127, "ap"), (128, 255, "bp")] + manifest.check(m) + + +def test_a_picker_whose_variants_came_to_one_is_that_move(): + m = pickers("p", ap=None, bp=None) + dropped = own.share(m, ["ap", "bp", "p"]) + assert set(m.moves) == {"p"} and not m.moves["p"].picker and m.moves["p"].clip == "charge" + assert dropped == {"ap": "p", "bp": "p"} + manifest.check(m) + + @pytest.mark.parametrize("how", ["pairs", "play", "node", "effect", "after"]) def test_what_names_a_move_from_outside_anchors_it(how): m = own_moves(a="c", b="c", c=None, outside="b") @@ -588,32 +736,56 @@ def test_brute_derives_its_seven_pairs(derived): m, found = derived assert not [f for f in found if f.level == "error"] manifest.check(m) - own_moves = sorted(n for n, mv in m.moves.items() if mv.own and n.startswith("p3_")) + own_moves = sorted(n for n, mv in m.moves.items() if mv.played and n.startswith("p3_")) assert own_moves == [ "p3_1_15", - "p3_2_19", - "p3_2_19_2", - "p3_2_19_3", + "p3_2_19_a0", + "p3_2_19_a0_2", + "p3_2_19_a0_3", + "p3_2_19_a1", + "p3_2_19_a1_2", + "p3_2_19_a1_3", "p3_2_20", "p3_2_23", "p3_7_13", - "p3_7_14", - "p3_7_14_2", + "p3_7_14_a0", + "p3_7_14_a0_2", + "p3_7_14_a1", + "p3_7_14_a1_2", + "p3_7_14_a2", "p3_7_15", ] - assert sequence.chain(m, "p3_7_14") == ["p3_7_14", "p3_7_14_2"] + assert sequence.chain(m, "p3_7_14_a0") == ["p3_7_14_a0", "p3_7_14_a0_2"] + assert sequence.chain(m, "p3_7_14_a2") == ["p3_7_14_a2", "p3_7_14_a1_2", "p3_7_14_a0_2"] assert not [f for f in found if f.code == "P3_OWN_NEXT"], "every handler ends at rest" +def test_brute_picks_by_the_bearing_where_its_handlers_do(derived): + """(2,19) by side, (7,14) by how far round the target is: the arcs the walk found.""" + m, _ = derived + assert m.moves["p3_2_19"].pick == [(0, 63, "p3_2_19_a0"), (64, 255, "p3_2_19_a1")] + assert m.moves["p3_7_14"].pick == [ + (0, 63, "p3_7_14_a0"), + (64, 127, "p3_7_14_a1"), + (128, 191, "p3_7_14_a2"), + (192, 255, "p3_7_14_a0"), + ] + assert [n for n, mv in m.moves.items() if mv.picker] == ["p3_2_19", "p3_7_14"] + + def test_brute_windows_lie_inside_their_spans(derived, data): m, _ = derived made = {n: mv for n, mv in m.moves.items() if n.startswith("p3_")} assert {n: (mv.start, mv.stop, mv.length) for n, mv in made.items() if mv.start or mv.stop} == { - "p3_2_19_3": (6, 0, None) + "p3_2_19_a0_3": (6, 0, None), + "p3_2_19_a1_3": (6, 0, None), + "p3_7_14_a1_2": (76, 0, 34), } assert {n: mv.length for n, mv in made.items() if mv.length is not None} == { - "p3_2_19_2": 39, - "p3_7_14": 72, + "p3_2_19_a0_2": 39, + "p3_2_19_a1_2": 39, + "p3_7_14_a0": 72, + "p3_7_14_a1_2": 34, } for name, mv in made.items(): for a in mv.attacks: @@ -648,15 +820,14 @@ def test_brute_charge(derived): def test_brute_rush_and_leap(derived): m, found = derived feet = m.attacks[0].id # donor record 26 plays its spare record - assert [m.moves[n].clip for n in ("p3_7_14", "p3_7_14_2")] == ["p3_59", "p3_60"] - assert windows(m, "p3_7_14") == [(15, 85, 144), (feet, 85, 144)] - assert windows(m, "p3_7_14_2") == [(15, 0, 14), (feet, 0, 14), (7, 94, 124), (8, 96, 106)] - assert m.moves["p3_7_14"].steer.rate == 364 and m.moves["p3_7_14"].length == 72 - assert m.moves["p3_7_14"].steer.walls and not m.moves["p3_7_14_2"].steer.walls + assert [m.moves[n].clip for n in ("p3_7_14_a0", "p3_7_14_a0_2")] == ["p3_59", "p3_60"] + assert windows(m, "p3_7_14_a0") == [(15, 85, 144), (feet, 85, 144)] + assert windows(m, "p3_7_14_a0_2") == [(15, 0, 14), (feet, 0, 14), (7, 94, 124), (8, 96, 106)] + assert m.moves["p3_7_14_a0"].steer.rate == 364 and m.moves["p3_7_14_a0"].length == 72 + assert m.moves["p3_7_14_a0"].steer.walls and not m.moves["p3_7_14_a0_2"].steer.walls assert windows(m, "p3_7_15") == [(4, 75, 100)] assert m.moves["p3_7_15"].steer == Steer(turn="hunter", rate=273, walls=False, window=(0, 20)) assert not [f for f in found if f.code == "P3_OWN_ATTACK"] - assert {f.item for f in found if f.code == "P3_OWN_BRANCH"} >= {"p3_7_14"} def host_table(data): @@ -672,7 +843,7 @@ def test_brute_hind_feet_get_a_spare_record(derived, data): assert sorted(h.bone for h in feet) == [24, 29], "the donor's two volumes, on the port's joints" assert {(h.radius, h.shape) for h in feet} == {(130.0, "sphere")} # power 25 scaled by the median of 1.0, 0.8, 0.67 and 0.42, the matched records' ratios - assert a.power == 18 and a.active == 59, "its node is held from frame 85 to the clip's end" + assert a.power == 18 and a.active == 68, "its node is held from frame 76 to the clip's end" (note,) = [f for f in found if f.code == "P3_OWN_SPARE"] assert "donor record 26" in note.text and f"spare record {a.id}" in note.text t = host_table(data) @@ -682,13 +853,18 @@ def test_brute_hind_feet_get_a_spare_record(derived, data): def test_brute_pairs_without_attacks(derived): m, _ = derived - for name in ("p3_1_15", "p3_2_20", "p3_2_23", "p3_2_19", "p3_2_19_2", "p3_2_19_3"): + for name in ("p3_1_15", "p3_2_20", "p3_2_23", "p3_2_19_a0", "p3_2_19_a1_2", "p3_2_19_a1_3"): assert m.moves[name].attacks == [] - assert [m.moves[n].clip for n in ("p3_2_19", "p3_2_19_2", "p3_2_19_3")] == [ + assert [m.moves[n].clip for n in ("p3_2_19_a1", "p3_2_19_a1_2", "p3_2_19_a1_3")] == [ "p3_32", "p3_107", "p3_108", ] + assert [m.moves[n].clip for n in ("p3_2_19_a0", "p3_2_19_a0_2", "p3_2_19_a0_3")] == [ + "p3_31", + "p3_110", + "p3_111", + ] def test_an_attacks_override_beats_the_match(data): @@ -696,7 +872,7 @@ def test_an_attacks_override_beats_the_match(data): assert m.brain is not None m.brain.attacks["26"] = 25 out, found = own.expand(m, data) - assert [a.id for a in out.moves["p3_7_14"].attacks] == [15, 25] + assert [a.id for a in out.moves["p3_7_14_a0"].attacks] == [15, 25] assert not [f for f in found if f.code in ("P3_OWN_ATTACK", "P3_OWN_SPARE")] assert out.attacks == [] and out.hitboxes == [], "a chosen record needs no spare" @@ -704,7 +880,7 @@ def test_an_attacks_override_beats_the_match(data): def test_slots_follow_the_expanded_manifest(derived): m, _ = derived slots = moves.slots(m) - assert len(slots) == len([n for n, mv in m.moves.items() if mv.own]) + assert len(slots) == len([n for n, mv in m.moves.items() if mv.played]) assert slots["p3_7_13"] == sorted(slots).index("p3_7_13") diff --git a/packages/mhfu-port/tests/test_own_cli.py b/packages/mhfu-port/tests/test_own_cli.py index 2b35fad0..6e03542c 100644 --- a/packages/mhfu-port/tests/test_own_cli.py +++ b/packages/mhfu-port/tests/test_own_cli.py @@ -74,7 +74,7 @@ def test_the_cli_lists_the_chains_across_hand_overs(data, capsys): path = PORTS / "zinogre.toml" code = main(["own", str(path), "--data", str(data.fu.root), "--p3rd-data", str(data.p3rd.root)]) out = capsys.readouterr().out - assert code == 0 and f"49 pairs, 125 own moves of {manifest.OWN_MOVES}" in out + assert code == 0 and f"91 pairs, 137 own moves of {manifest.OWN_MOVES}" in out lines = out.splitlines() head = lines.index("7,12 p3_7_12 + 3 more") chain = [line.split()[0] for line in lines[head + 1 :] if line.startswith(" p3_")][:4] @@ -93,7 +93,10 @@ def test_the_cli_lists_the_moves(data, capsys): path = PORTS / "brute_tigrex.toml" code = main(["own", str(path), "--data", str(data.fu.root), "--p3rd-data", str(data.p3rd.root)]) out = capsys.readouterr().out - assert code == 0 and "p3_7_13 (slot 6) clip 61 charge" in out + assert code == 0 and "p3_7_13 (slot 9) clip 61 charge" in out + assert ( + "2,19 p3_2_19: picks by the target's bearing" in out and "0-63 p3_2_19_a0, 64-255" in out + ) assert "donor 21 -> host record 7, frames 135-165" in out assert "donor 26 -> host record 34 (spare), frames 85-144" in out @@ -106,17 +109,19 @@ def test_the_moves_module_carries_the_derived_moves(data, tmp_path): modules(m, SimpleNamespace(layout=placed, tip=None), tmp_path, data, seen.append) # type: ignore[arg-type] text = (tmp_path / moves.module_name(m)).read_text() entry = placed.ids[59] - assert f'p3_7_14 = {{ entry = {entry}, slot = 7, clip = "p3_59"' in text + assert f'p3_7_14_a0 = {{ entry = {entry}, slot = 10, clip = "p3_59"' in text + assert "p3_7_14 = " not in text, "a picker has no slot, and no cost: its variants do" assert "attacks = { { 135, 7, 165 }, { 138, 8, 148 } }" in text assert 'turn = "hunter", rate = 546, window = { 10, 40 }' in text - assert 'after = "p3_7_14_2"' in text and any(f.code == "P3_OWN_SPARE" for f in seen) + assert 'after = "p3_7_14_a0_2"' in text and any(f.code == "P3_OWN_SPARE" for f in seen) def test_the_splice_enters_the_derived_moves(data, capsys): args = ["--data", str(data.fu.root), "--p3rd-data", str(data.p3rd.root)] assert main(["splice", str(PORTS / "brute_tigrex.toml"), *args]) == 0 out = capsys.readouterr().out - assert "pair 7,13 -> own p3_7_13 (slot 6)" in out and "pair 7,14 -> own p3_7_14 (slot 7)" in out + assert "pair 7,13 -> own p3_7_13 (slot 9)" in out + assert "pair 7,14 -> own p3_7_14_a0 (slot 10)" in out and "p3_7_14_a2 (slot 14)" in out def test_the_summary_sorts_a_pair_that_turns_then_plays_a_move(): diff --git a/packages/mhfu-port/tests/test_p3hits.py b/packages/mhfu-port/tests/test_p3hits.py index d971d0e4..a4c45fb2 100644 --- a/packages/mhfu-port/tests/test_p3hits.py +++ b/packages/mhfu-port/tests/test_p3hits.py @@ -436,6 +436,177 @@ def test_a_jump_starts_from_the_rest_height_it_is_given(): assert first.ticks > 7 # the landing test reads the height over the floor: a longer fall +def _r(funct: int, rd: str, rs: str, rt: str) -> int: + return asm.gpr(rs) << 21 | asm.gpr(rt) << 16 | asm.gpr(rd) << 11 | funct + + +def subu(rd: str, rs: str, rt: str) -> int: + return _r(0x23, rd, rs, rt) + + +def negu(rd: str, rt: str) -> int: + return _r(0x23, rd, "zero", rt) + + +def max_(rd: str, rs: str, rt: str) -> int: + return _r(0x2C, rd, rs, rt) + + +def seh(rd: str, rt: str) -> int: + return 0x1F << 26 | asm.gpr(rt) << 16 | asm.gpr(rd) << 11 | 0x18 << 6 | 0x20 + + +def sra(rd: str, rt: str, sa: int) -> int: + return asm.gpr(rt) << 16 | asm.gpr(rd) << 11 | sa << 6 | 0x03 + + +def slti(rt: str, rs: str, imm: int) -> int: + return 0x0A << 26 | asm.gpr(rs) << 21 | asm.gpr(rt) << 16 | imm & 0xFFFF + + +SIDES = {61: Length(100.0), 62: Length(100.0)} +"""Two clips of 100 frames: fifty AI frames.""" +EIGHT = {61: Length(16.0), 62: Length(16.0)} +"""Two clips of 16 frames: eight AI frames.""" + + +def wait_for_the_clip(a: Asm) -> None: + """Until the clip ends, then the pair's end.""" + a(asm.addu("a0", "s0", "zero")) + a.call(p3hits.CLIP_ENDED) + a.branch("beq", "v0", "zero", "done") + a.call(LEAVE) + + +def sides(a: Asm) -> None: + """Clip 61 where the side rule says left, else 62.""" + a(asm.addu("a0", "s0", "zero")) + a.call(p3hits.SIDE) + a.branch("beq", "v0", "zero", "right") + play(a, 61) + a.jump("played") + a.label("right") + play(a, 62) + a.label("played") + + +def bearing_off(a: Asm) -> None: + """The angle of the target off the yaw, a half turn either way, in v1, and its size in v0.""" + a(asm.addu("a0", "zero", "zero"), asm.addu("a1", "zero", "zero")) + a.call(p3hits.ANGLE) + a(asm.lw("v1", 0x74, "s0"), subu("v1", "v0", "v1"), seh("v1", "v1")) + a(negu("v0", "v1"), max_("v0", "v1", "v0"), seh("v0", "v0")) + + +def test_a_handler_that_asks_for_no_bearing_has_one_arm_and_its_plain_plan(): + w = walk(species(wait_for_the_clip, lambda a: play(a, 61))) + (arm,) = w.arms(pair()) + assert arm.arcs == ((0, 255),) and arm.plan == w.plan(pair()) and not arm.plan.reads + + +def test_the_side_rule_gives_a_clip_to_each_half_turn(): + left, right = walk(species(wait_for_the_clip, sides), SIDES).arms(pair()) + assert (left.arcs, right.arcs) == (((0, 127),), ((128, 255),)) + assert [s.clip for s in left.plan.steps] == [61] and [s.clip for s in right.plan.steps] == [62] + assert (left.plan.bearing, right.plan.bearing) == (63 * 256 + 1, 191 * 256 + 1 - 0x10000) + assert left.plan.reads and right.plan.complete + + +def test_a_compare_on_the_angle_splits_where_it_is_made_to_a_256th_of_a_turn(): + def quarter(a: Asm) -> None: + bearing_off(a) + a(slti("v1", "v0", 0x4000)) + a.branch("beq", "v1", "zero", "far") + play(a, 61) + a.jump("played") + a.label("far") + play(a, 62) + a.label("played") + + near, far = walk(species(wait_for_the_clip, quarter), SIDES).arms(pair()) + assert near.arcs == ((0, 63), (192, 255)) and far.arcs == ((64, 191),) + assert [s.clip for s in near.plan.steps] == [61] and [s.clip for s in far.plan.steps] == [62] + + +def test_a_handler_that_plays_one_clip_at_every_bearing_has_one_arm_and_its_plain_plan(): + def same(a: Asm) -> None: + bearing_off(a) + play(a, 61) + + w = walk(species(wait_for_the_clip, same), SIDES) + (arm,) = w.arms(pair()) + assert arm.arcs == ((0, 255),) and arm.plan == w.plan(pair()) and arm.plan.reads + + +def adds_to_the_yaw(a: Asm, shift: int | None) -> None: + """Each tick adds to the yaw: 100 units, or the angle of the target off it, which the first + tick kept at 0xF40, over 2 to the `shift` (8 ticks: a shift of 3 turns exactly it).""" + if shift is None: + a(asm.li("v0", 100)) + else: + a(asm.lw("v0", 0xF40, "s0"), sra("v0", "v0", shift)) + a(asm.lw("v1", 0x74, "s0"), asm.addu("v1", "v1", "v0"), asm.sw("v1", 0x74, "s0")) + wait_for_the_clip(a) + + +def keeps_the_angle(a: Asm) -> None: + sides(a) + bearing_off(a) + a(asm.sw("v1", 0xF40, "s0")) + + +def test_a_turn_that_comes_to_the_angle_of_the_target_is_an_aim_turn_on_its_curve(): + left, right = walk(species(lambda a: adds_to_the_yaw(a, 3), keeps_the_angle), EIGHT).arms( + pair() + ) + for arm, size in ((left, 0x7FFF), (right, -0x7FFF)): + (step,) = arm.plan.steps + assert step.aim and step.code[0] == 0 and step.code[-1] == size + # the first tick's turn is in the second value, as a step's travel is + assert list(step.code) == pytest.approx([0, *(k * size / 8 for k in range(2, 9))], abs=2) + + +def test_a_turn_that_is_the_same_at_every_bearing_is_a_curve_that_stands(): + left, right = walk(species(lambda a: adds_to_the_yaw(a, None), sides), EIGHT).arms(pair()) + for arm in (left, right): + (step,) = arm.plan.steps + assert not step.aim and step.code[-1] == 800 + + +def test_a_turn_that_goes_by_the_bearing_in_any_other_way_is_left_out_and_said(): + w = walk(species(lambda a: adds_to_the_yaw(a, 4), keeps_the_angle), EIGHT) # half the angle + for arm in w.arms(pair()): + (step,) = arm.plan.steps + assert step.code == () and not step.aim + assert [n.code for n in arm.plan.notes] == ["motion"] + assert "depends on the bearing" in arm.plan.notes[0].text + + +def test_the_blend_a_handler_hands_over_with_is_the_last_step_s(): + def hands_over(a: Asm) -> None: + a(asm.li("a1", 7), asm.li("a2", 203), asm.li("a3", 6), asm.addu("a0", "s0", "zero")) + a.call(p3hits.CARRY) + + plan = walk(species(hands_over, sides), SIDES).arms(pair())[0].plan + assert plan.leaves == ((7, 203),) and plan.complete + assert [s.hand for s in plan.steps] == [6] + + +def test_a_clip_blends_in_for_the_frames_it_was_played_with(): + def turns_while_blending(a: Asm) -> None: + a(asm.addu("a0", "s0", "zero")) + a.call(p3hits.BLEND_IN) + a.branch("beq", "v0", "zero", "settled") + a(asm.li("a1", 100), asm.addu("a0", "s0", "zero")) + a.call(p3hits.TURN) + a.label("settled") + wait_for_the_clip(a) + + plan = walk(species(turns_while_blending, lambda a: play(a, 61))).plan(pair()) + (step,) = plan.steps + assert step.blend == 4 and step.turns == (p3hits.Turn(100, (0.0, 8.0)),) + + BRUTE, BRUTE_MODEL = 23, 5248 ZINOGRE, ZINOGRE_MODEL = 40, 5339 @@ -599,6 +770,97 @@ def test_zinogre_tumbles_turn_by_the_donors_totals(plans): assert roll.travel[-1] == pytest.approx((52, 232), abs=0.5) +@pytest.mark.parametrize( + ("pair_", "clip", "rate"), + [ + ((7, 12), 203, 546), + ((7, 86), 213, 728), + ((7, 89), 247, 1365), + ((7, 99), 231, 637), + ((7, 16), 30, 182), + ((7, 150), 200, 1001), # the word at +0x540 reads 2: 5.5 degrees, not 4 + ((7, 17), 253, 1001), + ], +) +def test_zinogre_turn_rates_are_the_donors_at_clip_speed_one(plans, pair_, clip, rate): + """`trunc(deg * ent[0xAC4] * 65536 / 360 + 0.5)`, every site resolved.""" + assert step_of(plans(*pair_), clip).turns[0].rate == rate + + def test_a_step_without_code_motion_carries_none(plans): for s in plans(7, 22).steps[:1]: assert (s.travel, s.lift, s.lunge, s.spreads) == ((), (), None, ()) + + +def _d(degrees: float) -> int: + """Degrees to the left of the facing as the YAW units the walk takes.""" + d = round(degrees / 360 * 0x10000) + return d - 0x10000 if d >= 0x8000 else d + + +def arms_of(zinogre, main: int, sub: int) -> tuple[p3hits.Arm, ...]: + w, ms = zinogre + return w.arms(ms.pairs[main, sub]) + + +def test_zinogre_turns_in_place_by_the_bearing_of_the_target_with_the_clips_9_8_38_37(zinogre): + w, ms = zinogre + pair_ = ms.pairs[2, 26] + for degrees, clip in [(45, 9), (-45, 8), (150, 38), (-150, 37)]: + plan = w.plan(pair_, _d(degrees)) + assert [s.clip for s in plan.steps] == [clip], degrees + arms = arms_of(zinogre, 2, 26) + assert [a.arcs for a in arms] == [((0, 63),), ((64, 127),), ((128, 191),), ((192, 255),)] + assert [a.plan.steps[0].clip for a in arms] == [9, 38, 37, 8] + + +def test_zinogre_turns_in_place_turn_exactly_the_angle_along_the_curves_of_90_and_180(zinogre): + arms = arms_of(zinogre, 2, 26) + steps = [a.plan.steps[0] for a in arms] + assert all(s.aim for s in steps) + assert [s.code[-1] for s in steps] == [0x4000, 0x7FFF, -0x7FFF, -0x4000] + assert all(s.turns == () and s.spreads == () for s in steps) + + +def test_zinogre_wrappers_split_at_85_86_and_170_171_and_hand_over_with_the_blend(zinogre): + arms = arms_of(zinogre, 7, 23) + assert [a.arcs for a in arms] == [((0, 85),), ((86, 127),), ((128, 170),), ((171, 255),)] + assert [(a.plan.steps[0].clip, a.plan.steps[0].hand) for a in arms] == [ + (9, 6), + (38, 10), + (37, 10), + (8, 6), + ] + assert all(a.plan.leaves == ((7, 203),) and not a.plan.steps[0].aim for a in arms) + assert [a.plan.steps[0].ticks * 2 for a in arms] == [52, 62, 62, 52] # where it hands over + + +def test_zinogre_wrappers_of_one_handler_share_their_arms(zinogre): + first, other = arms_of(zinogre, 7, 23), arms_of(zinogre, 7, 38) + assert [a.arcs for a in first] == [a.arcs for a in other] + assert [a.plan.steps for a in first] == [a.plan.steps for a in other] + assert other[0].plan.leaves == ((7, 207),) and first[0].plan.leaves == ((7, 203),) + + +def test_zinogre_turns_by_the_side_rule_between_two_clips_of_a_fixed_twenty_degrees(zinogre): + left, right = arms_of(zinogre, 1, 1) + assert (left.arcs, right.arcs) == (((0, 127),), ((128, 255),)) + assert [s.plan.steps[0].clip for s in (left, right)] == [11, 39] + assert [s.plan.steps[0].spreads for s in (left, right)] == [ + (p3motion.Spread(0xE39, (0.0, 164.0)),), + (p3motion.Spread(-0xE39, (0.0, 164.0)),), + ] + assert not any(s.plan.steps[0].aim for s in (left, right)) + + +def test_zinogre_handlers_that_ignore_the_bearing_have_one_arm(zinogre): + for key in [(7, 0), (7, 22), (7, 12)]: + (arm,) = arms_of(zinogre, *key) + assert arm.arcs == ((0, 255),) and arm.plan.bearing is None + + +def test_zinogre_blends_in_for_the_frames_it_plays_a_clip_with(plans): + for key in [(7, 0), (7, 236)]: + first = plans(*key).steps[0] + assert first.blend == 4 + assert [(t.rate, t.window) for t in first.turns] == [(1092, (0, 24)), (455, (26, 38))] diff --git a/packages/mhfu-port/tests/test_p3motion.py b/packages/mhfu-port/tests/test_p3motion.py index 3b8eab19..0d5b90cd 100644 --- a/packages/mhfu-port/tests/test_p3motion.py +++ b/packages/mhfu-port/tests/test_p3motion.py @@ -77,6 +77,10 @@ def at(m: Motion, off: int) -> float: return m.cells[off] +def test_the_clip_speed_cell_starts_at_one(): + assert at(motion(), p3motion.SPEED) == 1.0 + + def test_velocity_moves_and_then_accelerates(): m = motion() m.cells.update({VELOCITY + 4: 10.0, ACCELERATION + 4: -3.0}) diff --git a/packages/mhfu-port/tests/test_spare.py b/packages/mhfu-port/tests/test_spare.py index e01f87fd..d1cf5170 100644 --- a/packages/mhfu-port/tests/test_spare.py +++ b/packages/mhfu-port/tests/test_spare.py @@ -6,7 +6,7 @@ from mhfu_port import manifest from mhfu_port.brain import own, spare from mhfu_port.manifest import AttackWindow -from mhfu_port.p3hits import Hit, Hits, Length, Plan, Record, Step, Volume +from mhfu_port.p3hits import Arm, Hit, Hits, Length, Plan, Record, Step, Volume TEXT = """ schema = 2 @@ -250,6 +250,9 @@ def __init__(self, plan: Plan, hits: Hits, hosts: dict[int, int], tbl=None) -> N def plan(self, pair: object) -> Plan: return self._plan + def arms(self, pair: object, base: Plan) -> tuple[Arm, ...]: + return (Arm(((0, 255),), base),) + def match(self, record: int) -> own.Match: host = self.hosts.get(record) return own.Match(record, host, 0.5 if host is not None else 0.12, False, 25) diff --git a/packages/mhfu-port/tests/test_splice.py b/packages/mhfu-port/tests/test_splice.py index 90ee5aa6..9bf9f05c 100644 --- a/packages/mhfu-port/tests/test_splice.py +++ b/packages/mhfu-port/tests/test_splice.py @@ -75,9 +75,11 @@ def translate( sp: manifest.Brain | None = None, survey: dict[int, script_vars.Var] | None = None, slots: dict[str, int] | None = None, + picks: dict[str, list[tuple[int, int, str]]] | None = None, + combos: dict[tuple[str, str], int] | None = None, **routines: str, ) -> tuple[list[str], splice.Translator]: - t = splice.Translator(donor(**routines), sp or spec(), survey, slots) + t = splice.Translator(donor(**routines), sp or spec(), survey, slots, picks, combos) return lines(t.paste("base_0", "base_3")), t @@ -734,6 +736,78 @@ def test_every_enter_of_a_list_yields_for_the_latch(): assert got == ["enter 1 2 0", f"enter {OWN} 5 0", "enter 1 2 0"] and not t.findings +# ---- pickers + +TURN = {"turn": [(0, 63, "a"), (64, 127, "b"), (128, 255, "c")]} +VARIANTS = {"a": 1, "b": 2, "c": 3, "slam": 5} + + +def test_a_picker_is_nested_arcs_that_enter_a_variant_each(): + got, t = translate( + spec(pairs={"7,13": "turn"}), slots=VARIANTS, picks=TURN, base_0=enter(7, 13) + ) + assert got == [ + "if_target_in_arc 0 63", + f" enter {OWN} 1 0", + "else", + " if_target_in_arc 64 127", + f" enter {OWN} 2 0", + " else", + f" enter {OWN} 3 0", + ] + assert not t.findings + assert dict(t.uses) == {((7, 13), "a"): 1, ((7, 13), "b"): 1, ((7, 13), "c"): 1} + + +def test_an_own_move_after_a_picker_is_a_combo_with_each_variant(): + ids = {("a", "slam"): 160, ("b", "slam"): 161, ("c", "slam"): 162} + got, t = translate( + spec(pairs={"7,13": ["turn", "slam"]}), + slots=VARIANTS, + picks=TURN, + combos=ids, + base_0=enter(7, 13), + ) + assert [g.strip() for g in got if "enter" in g] == [ + f"enter {OWN} {i} 0" for i in (160, 161, 162) + ] + assert sum(g.startswith("if_target_in_arc") or "if_target_in_arc" in g for g in got) == 2 + assert not t.findings + assert dict(t.uses) == { + ((7, 13), "a"): 1, + ((7, 13), "b"): 1, + ((7, 13), "c"): 1, + ((7, 13), "slam"): 3, + } + + +def test_what_follows_a_picker_with_no_combo_is_a_second_enter(): + ids = {("a", "slam"): 160} # not every variant: no combo + sp = spec(pairs={"7,13": ["turn", "slam"], "2,3": ["turn", (1, 3)]}) + got, t = translate( + sp, slots=VARIANTS, picks=TURN, combos=ids, base_0=f"{enter(7, 13)} {enter(2, 3)}" + ) + enters = [g.strip() for g in got if "enter" in g] + assert enters == [ + f"enter {OWN} 1 0", + f"enter {OWN} 2 0", + f"enter {OWN} 3 0", + f"enter {OWN} 5 0", + f"enter {OWN} 1 0", + f"enter {OWN} 2 0", + f"enter {OWN} 3 0", + "enter 1 3 0", + ] + assert not t.findings + + +def test_a_picker_with_a_variant_that_has_no_slot_is_an_error(): + got, t = translate( + spec(pairs={"7,13": "turn"}), slots={"a": 1, "b": 2}, picks=TURN, base_0=enter(7, 13) + ) + assert found(t) == [("error", "P3_OWN_MISSING")] and "c" in t.findings[0].text + + # ---- the call history diff --git a/packages/mhfu-port/tests/test_travel.py b/packages/mhfu-port/tests/test_travel.py index 878b149f..01e4e60b 100644 --- a/packages/mhfu-port/tests/test_travel.py +++ b/packages/mhfu-port/tests/test_travel.py @@ -235,6 +235,49 @@ def test_port_carries_its_turns_exactly(data, monkeypatch, name, between): assert np.abs(moved - (src[1:] - src[0])).max() < 0.2, e +def test_a_twist_through_a_flip_is_interpolated_between_the_frames_that_hold_one(): + facing = np.radians([0.0, 10, 20, 30, 170, -170, 160, 50, 60]) # the middle is noise + length = np.array([1.0, 1, 1, 0.9, 0.2, 0.1, 0.2, 0.9, 1]) # per child, of (w, y) + w, y = length * np.cos(facing / 2), length * np.sin(facing / 2) + got = np.degrees(travel._twist(w, y, 1)) + assert got == pytest.approx([0, 10, 20, 30, 35, 40, 45, 50, 60], abs=0.01) + kids = np.degrees(travel._twist(2 * w, 2 * y, 2)) # the length is of each child + assert kids == pytest.approx(got) + + +def test_a_twist_is_held_beyond_the_first_and_last_frame_that_holds_one(): + facing = np.radians([-120.0, 130, 20, 30, 40, 90, -150]) + length = np.array([0.1, 0.2, 1, 1, 1, 0.3, 0.1]) + w, y = length * np.cos(facing / 2), length * np.sin(facing / 2) + got = np.degrees(travel._twist(w, y, 1)) + assert got == pytest.approx([20, 20, 20, 30, 40, 40, 40], abs=0.01) + + +def test_a_clip_with_no_frame_that_holds_a_twist_keeps_its_raw_one(): + facing = np.radians([-120.0, 130, 100]) + w, y = 0.1 * np.cos(facing / 2), 0.1 * np.sin(facing / 2) + raw = np.unwrap(travel._wrap(2 * np.arctan2(y, w))) + assert travel._twist(w, y, 1) == pytest.approx(raw) + + +def test_the_zinogres_somersault_keeps_its_facing_through_the_flip(data): + """Clip 225 rolls upside down for good from frame 32: its body's twist reads -69 degrees, + then +171, which landed the monster facing backwards. Now it turns 16 degrees a key at most + and ends within 30 of where it began.""" + m = manifest.load(PORTS / "zinogre.toml") + d, h = build.donor(m, data), build.host(m, data) + rec = build.record_map(d, m.build) + bind = build.binding(m.build, d, h, build.animated(m.build, rec, len(d.skeleton.bones))) + placed = build.layout(m, d, h) + anim = build.animation(d, h, bind, rec, placed, m.build.ground_lift) + roll = next(c for c in m.clips.values() if c.source == 225 and c.lift is None) + entry = placed.entry(roll) + assert entry is not None + keys = np.array(travel.turns(anim, bind.rig.skeleton)[entry].keys) / travel.TURN * 360 + assert np.abs(np.diff(keys)).max() < 16.0 + assert keys.max() - keys.min() < 60.0 and abs(keys[-1] - keys[0]) < 30.0 + + def test_carry_is_idempotent(): anim = pack(dash([loc(2, (0, 0), (40, 1000))], [HIP, loc(0, (0, -30), (40, 5))], 45.0)) once = travel.carry(anim, rig()) diff --git a/packages/mhfu-port/tests/test_zinogre_brain.py b/packages/mhfu-port/tests/test_zinogre_brain.py index dd961943..d3e6735e 100644 --- a/packages/mhfu-port/tests/test_zinogre_brain.py +++ b/packages/mhfu-port/tests/test_zinogre_brain.py @@ -20,6 +20,7 @@ from mhfu_port.build import build as build_port from mhfu_port.cli import main from mhfu_port.cli.build import modules +from mhfu_port.cli.splice import routines from mhfu_port.data import Data from mhfu_port.model import ANIMATION, SKELETON from mhp_formats import fu @@ -28,7 +29,6 @@ PORT = Path(__file__).parents[3] / "ports" / "zinogre.toml" OWN = addresses.OWN_MOVE_MAIN -TURN = (1, 2) KEPT = {f"b{i}" for i in [*range(1, 7), *range(12, 22), 36]} """The hand-built rules that stay: the lunge's skids, the notice, the combat flag, the part breaks and the roar signal.""" @@ -55,7 +55,8 @@ def zinogre(data: Data) -> SimpleNamespace: def splice_of(z: SimpleNamespace, b: manifest.Brain): named = z.charge.named if z.charge is not None else () - s = splice.splice(z.donor, z.host, b, z.survey, z.slots, z.overlay, named) + picks, combos = moves.picks(z.m), moves.combo_ids(z.m) + s = splice.splice(z.donor, z.host, b, z.survey, z.slots, z.overlay, named, picks, combos) return s, build.Built.of(z.host, s.edits) @@ -69,7 +70,7 @@ def test_the_manifest_carries_its_brain(zinogre): assert (b.donor, b.splice, b.entry, b.fatigue, b.charge) == (40, "base_3", "base_3", True, True) assert b.when == {"if_target_set": True, "if_target_state": True} assert manifest.loads(manifest.dumps(m)) == m - assert len(b.pairs) == 107 and len(b.derived()) == len(own.pairs(b)) == 49 + assert len(b.pairs) == 107 and len(b.derived()) == len(own.pairs(b)) == 91 def test_a_donor_pair_plays_the_authored_move_where_it_is_the_donors(zinogre): @@ -77,19 +78,19 @@ def test_a_donor_pair_plays_the_authored_move_where_it_is_the_donors(zinogre): assert b.targets((1, 16)) == ["charge_up"] and b.targets((1, 15)) == ["p3_1_15"] assert b.targets((1, 2)) == ["break_howl"] assert [b.targets(p) for p in [(2, 3), (2, 4)]] == [["dash_stop"], ["dash"]] - assert b.targets((7, 29)) == b.targets((7, 30)) == [TURN, "dash"] + assert [b.targets((7, 29)), b.targets((7, 30))] == [["p3_7_29", "dash"], ["p3_7_30", "dash"]] assert zinogre.m.moves["dash"].after is None def test_a_wrapper_turns_and_plays_the_move_of_the_pair_it_leads_to(zinogre): b = zinogre.m.brain - assert b.targets((7, 23)) == [TURN, "p3_7_0"] and b.targets((7, 0)) == ["p3_7_0"] - assert b.targets((7, 110)) == [TURN, "p3_7_109"] and b.targets((7, 109)) == ["p3_7_109"] + assert b.targets((7, 23)) == ["p3_7_23", "p3_7_0"] and b.targets((7, 0)) == ["p3_7_0"] + assert b.targets((7, 110)) == ["p3_7_110", "p3_7_109"] and b.targets((7, 109)) == ["p3_7_109"] twins = [b.targets((2, 11)), b.targets((7, 5))] assert twins == [["p3_2_10"], ["p3_7_4"]] assert set(b.derived().values()) >= {(7, 0), (7, 109), (2, 10), (7, 4), (1, 4), (7, 13)} assert b.shared() <= set(b.derived().values()) and (7, 0) in b.shared() - assert (7, 13) in b.shared() and b.targets((7, 38)) == [TURN, "p3_7_13"] + assert (7, 13) in b.shared() and b.targets((7, 38)) == ["p3_7_38", "p3_7_13"] WALKS = { @@ -139,10 +140,10 @@ def test_the_d2_steps_start_where_the_donor_did_and_stop_past_their_attack(zinog def test_every_derived_window_lies_inside_its_move_s_frames(zinogre, module_text): _, built = module_text - made = {n: mv for n, mv in zinogre.m.moves.items() if n.startswith("p3_")} + made = {n: mv for n, mv in zinogre.m.moves.items() if n.startswith("p3_") and mv.played} assert sum(bool(mv.start) for mv in made.values()) == 26 - assert sum(bool(mv.stop) for mv in made.values()) == 26 - assert sum(mv.length is not None for mv in made.values()) == 16 + assert sum(bool(mv.stop) for mv in made.values()) == 44 + assert sum(mv.length is not None for mv in made.values()) == 2 for name, mv in made.items(): frames = built.layout.frames[moves.entry(zinogre.m, name, built.layout)] assert mv.start < frames and mv.stop <= frames, name @@ -166,13 +167,14 @@ def test_the_jumps_are_lifted_copies_in_entries_nothing_else_holds(zinogre, data assert lay.entries[c.slot] == source and lay.cuts[c.slot] == c.cut assert lay.lifts[c.slot] == tuple(c.lift) and c.slot not in lay.partial assert lay.ids[source] != c.slot, "the whole clip keeps its own entry" - assert sum(mv.own for mv in m.moves.values()) <= manifest.OWN_MOVES + assert sum(mv.played for mv in m.moves.values()) <= manifest.OWN_MOVES def test_the_air_roll_flies_and_lunges_as_the_donor_does(zinogre): m = zinogre.m roll = m.moves["p3_7_22_2"] - assert (m.clips[roll.clip].source, roll.length, roll.start) == (225, 22, 0) + assert (m.clips[roll.clip].source, roll.length, roll.start) == (225, None, 0) + assert (roll.stop, roll.steer.walls) == (39, False), "the fall past rest is the landing's" assert roll.travel[-1] == pytest.approx((120, 852), rel=0.01) assert roll.lunge == manifest.Lunge((0, 32), 400.0, 1800.0, -700.0, 1800.0) assert [m.moves[n].clip for n in sequence.chain(m, "p3_7_22")][1] == roll.clip @@ -188,10 +190,40 @@ def test_the_stamp_waits_16_or_39_frames_and_hops_for_18(zinogre): 78, ) # 16 and 39 AI frames hop = m.moves[short.after] - assert (m.clips[hop.clip].source, hop.length) == (209, 18) + assert (m.clips[hop.clip].source, hop.length, hop.stop) == (209, None, 36) + assert not hop.steer.walls assert hop.travel[-1] == pytest.approx((83, 1401), rel=0.01, abs=1) +def test_a_step_no_test_ended_stops_where_the_walks_cursor_was(zinogre): + """Clip 25 has 118 frames; the donor leaves it at 60. 30 AI frames stopped it at 54 live, + the blend having held the cursor.""" + m = zinogre.m + back = m.moves["p3_7_33"] + assert (m.clips[back.clip].source, back.start, back.stop, back.length) == (25, 0, 60, None) + + +def test_only_a_loop_is_cut_by_length(zinogre, data): + d = builds.donor(zinogre.m, data) + cut = [n for n, mv in zinogre.m.moves.items() if n.startswith("p3_") and mv.length is not None] + assert cut and all(d.clips[zinogre.m.clips[zinogre.m.moves[n].clip].source].loop for n in cut) + + +def test_every_hunter_turn_has_the_donors_rate(zinogre): + hunters = { + n: mv.steer.rate + for n, mv in zinogre.m.moves.items() + if n.startswith("p3_") and mv.steer.turn == "hunter" + } + assert hunters and None not in hunters.values() + assert [hunters[n] for n in ("p3_7_12", "p3_7_86", "p3_7_89", "p3_7_150")] == [ + 546, + 728, + 1365, + 1001, + ] + + def test_the_sidesteps_travel_across_their_window_and_turn_half_way(zinogre): m = zinogre.m left, right = m.moves["p3_2_16"], m.moves["p3_2_17"] @@ -278,6 +310,9 @@ def test_the_pairs_that_share_a_move_derive_the_same_one(zinogre, data): again, _ = own.expand(apart, data) for key, first in twins.items(): name = manifest.own_name(manifest.pair_of(key)) + if again.moves[first].picker: # a pair a chain hands over to is one move: no picker + assert not again.moves[name].picker, key + continue assert chain_of(again, name) == chain_of(again, first), key @@ -285,7 +320,7 @@ def test_the_wrappers_play_the_moves_of_the_twins_they_lead_to(zinogre): b = zinogre.m.brain wrappers = {72: 70, 73: 71, 83: 81, 84: 82, 120: 118, 121: 119} assert {w: b.targets((7, w)) for w in wrappers} == { - w: [TURN, manifest.own_name((7, p))] for w, p in wrappers.items() + w: [manifest.own_name((7, w)), manifest.own_name((7, p))] for w, p in wrappers.items() } assert all(b.pairs[f"7,{p}"] == manifest.OWN for p in wrappers.values()) @@ -315,7 +350,7 @@ def test_zinogre_splices_into_em75(zinogre): assert built.ok, built.findings assert built.budget.worst is not None and built.budget.worst.count <= min(LIMIT, 150) grown = len(built.image.data) - (built.lift.end - built.lift.base) - assert 8000 < grown < 14000 and 50 <= len(s.routines) <= 65 + assert 8000 < grown < 17000 and 50 <= len(s.routines) <= 65 got = codes(s.findings) assert got["info P3_SPLIT"] == 10 and got["warning P3_FOLDED"] == 3 assert got["info P3_HISTORY"] == 1 and got["warning P3_APPROX"] == 1 @@ -407,7 +442,10 @@ def test_zinogre_enters_its_own_moves(zinogre): for n in ir.walk(r.body) if isinstance(n, ir.Insn) and n.op.name == "enter" and n.args["main"] == OWN } - assert own_enters == {zinogre.slots[n] for _, n in s.uses if isinstance(n, str)} + ids = set(moves.combo_ids(zinogre.m).values()) + assert own_enters & ids, "the wrappers enter combos" + assert own_enters - ids <= {zinogre.slots[n] for _, n in s.uses if isinstance(n, str)} + assert {n for _, n in s.uses if isinstance(n, str)} <= set(zinogre.slots) assert {"p3_7_0", "p3_7_12", "p3_1_19", "charge_up", "dash", "break_howl"} <= set(s.slots) @@ -440,14 +478,43 @@ def expanded(brain_: p3.Brain, nodes: Sequence[p3.Node]) -> list[tuple[int, int] return out +def enters_of(z: SimpleNamespace, targets: Sequence[manifest.Target]) -> list[tuple[int, int]]: + """The enters a pair's targets come to: a host pair as it is, an own move by its slot, a + picker one a variant, or one of the combo of the variant and the own move that follows it.""" + ids = moves.combo_ids(z.m) + out: list[tuple[int, int]] = [] + k = 0 + while k < len(targets): + t = targets[k] + if isinstance(t, tuple): + out.append(t) + elif z.m.moves[t].picker: + variants = [v for _, _, v in z.m.moves[t].pick] + then = targets[k + 1] if k + 1 < len(targets) else None + if isinstance(then, str) and all((v, then) in ids for v in variants): + out += [(OWN, ids[v, then]) for v in variants] + k += 1 + else: + out += [(OWN, z.slots[v]) for v in variants] + else: + out.append((OWN, z.slots[t])) + k += 1 + return out + + def test_zinogre_turns_then_moves(zinogre): b = zinogre.m.brain s, _ = splice_of(zinogre, b) turned = [manifest.pair_of(k) for k in b.pairs if len(b.targets(manifest.pair_of(k))) == 2] assert len(turned) == 38 + shared = {v for _, _, v in zinogre.m.moves["p3_7_23"].pick} + assert len(shared) == 4 and shared <= set(zinogre.slots) for pair in turned: - move = b.targets(pair)[-1] - assert s.uses[pair, TURN] > 0 and s.uses[pair, move] == s.uses[pair, TURN] + picker, move = b.targets(pair) + assert picker == manifest.own_name(pair) and zinogre.m.moves[picker].picker + assert {v for _, _, v in zinogre.m.moves[picker].pick} == shared + assert s.uses[pair, move] > 0 and s.uses[pair, move] % 4 == 0 + assert {s.uses[pair, v] for v in shared} == {s.uses[pair, move] // 4} for label, name in s.routines.items(): got = Counter( (n.args["main"], n.args["sub"]) @@ -456,11 +523,85 @@ def test_zinogre_turns_then_moves(zinogre): ) want: Counter[tuple[int, int]] = Counter() for pair in expanded(zinogre.donor, zinogre.donor.routines[name].body): - for t in b.targets(pair): - want[(OWN, zinogre.slots[t]) if isinstance(t, str) else t] += 1 + for sub in enters_of(zinogre, b.targets(pair)): + want[sub] += 1 assert got == want, label +def test_the_turns_in_place_aim_along_the_curves_of_90_and_180_degrees(zinogre): + m = zinogre.m + names = [f"p3_2_26_a{k}" for k in range(4)] + assert [m.moves["p3_2_26"].pick[k][2] for k in range(4)] == names + assert [m.clips[m.moves[n].clip].source for n in names] == [9, 38, 37, 8] + assert [m.moves[n].steer.turn for n in names] == ["aim"] * 4 + assert [m.moves[n].code_turn[-1] for n in names] == [0x4000, 0x7FFF, -0x7FFF, -0x4000] + assert [(lo, hi) for lo, hi, _ in m.moves["p3_2_26"].pick] == [ + (0, 63), + (64, 127), + (128, 191), + (192, 255), + ] + sides = [m.moves[n] for n in ("p3_1_1_a0", "p3_1_1_a1")] + assert [m.clips[mv.clip].source for mv in sides] == [11, 39] + assert [(mv.steer.turn, round(mv.steer.angle or 0)) for mv in sides] == [ + ("fixed", 20), + ("fixed", -20), + ] + sidestep = [m.moves[n] for n in ("p3_2_5", "p3_2_6")] + assert [mv.steer.angle for mv in sidestep] == [-90.0, 90.0] + assert all(mv.steer.window == (30, 100) and mv.travel for mv in sidestep) + + +def test_the_wrappers_share_four_variants_that_stop_where_the_donor_hands_over(zinogre): + m = zinogre.m + arcs = [(lo, hi) for lo, hi, _ in m.moves["p3_7_23"].pick] + assert arcs == [(0, 85), (86, 127), (128, 170), (171, 255)] + first = [v for _, _, v in m.moves["p3_7_23"].pick] + assert first == [f"p3_7_23_a{k}" for k in range(4)] + assert all(m.moves[p].pick == m.moves["p3_7_23"].pick for p in ("p3_7_24", "p3_7_120")) + assert [m.clips[m.moves[v].clip].source for v in first] == [9, 38, 37, 8] + assert [(m.moves[v].stop, m.moves[v].aim_out) for v in first] == [ + (52, 6), + (62, 10), + (62, 10), + (52, 6), + ] + assert all(m.moves[v].steer.turn == "clip" and m.moves[v].after is None for v in first) + assert not [n for n in m.moves if n.startswith("p3_7_24_a")], "twins are the kept variants" + + +def test_the_spliced_wrapper_branches_on_the_arc_and_enters_a_combo_of_each_variant(zinogre): + m = zinogre.m + _, built = splice_of(zinogre, m.brain) + text = "\n".join(routines(built)) + ids = [moves.combo_id(m, f"p3_7_23_a{k}", "p3_7_0") for k in range(4)] + assert all(i is not None and i >= moves.SLOTS for i in ids) and len(set(ids)) == 4 + for lo, hi in [(0, 85), (86, 127), (128, 170)]: + assert f"if_target_in_arc {lo} {hi}" in text, (lo, hi) + for i in ids: + assert f"enter {OWN} {i} 0" in text, i + assert "enter 1 2 0" not in text, "the host's turn is no longer entered" + + +def test_the_module_the_check_and_the_ids_agree_on_the_variants_and_combos(zinogre, module_text): + text, built = module_text + m = zinogre.m + assert not moves.problems(m, built.layout, moves.records(Data.find().fu, 75)) + slotted = moves.slots(m) + assert len(slotted) == 137 and "p3_7_23" not in slotted and "p3_7_23_a0" in slotted + assert " p3_7_23 = " not in text and " p3_7_23_a0 = " in text + combos = [line for line in text.splitlines() if line.startswith(" combos = ")] + (line,) = combos + ids = line.count("{ ") - 1 + assert ids == len(moves.combos(m)) == 88 <= moves.COMBOS + assert moves.combo_ids(m) == {c: moves.combo_id(m, *c) for c in moves.combos(m)} + first = moves.combos(m)[0] + assert f'{{ {moves.SLOTS}, "{first[0]}", "{first[1]}" }}' in line + held = moves.pools(m, built.layout) + assert len(held.keys) <= moves.POOL and len(held.samples) <= moves.SAMPLE_POOL + assert len(held.turns) <= moves.TURN_POOL and len(held.lunges) <= moves.LUNGES + + def test_zinogre_brain_file_round_trip(zinogre, data, tmp_path): s, built = splice_of(zinogre, zinogre.m.brain) bf = brainfile.new(75) @@ -518,10 +659,15 @@ def test_the_moves_module_carries_fatigue_and_charge(module_text): def test_the_own_moves_fit_the_framework(zinogre, module_text): """The slots and the pools the registry holds: sharing equal runs is what fits the port.""" _, built = module_text - names = [n for n, mv in zinogre.m.moves.items() if mv.own] + names = [n for n, mv in zinogre.m.moves.items() if mv.played] held = moves.pools(zinogre.m, built.layout) - assert len(names) == 125 - assert (len(held.keys), len(held.samples), len(held.lunges)) == (2432, 1052, 10) + assert len(names) == 137 and len(moves.combos(zinogre.m)) == 88 + assert (len(held.keys), len(held.samples), len(held.turns), len(held.lunges)) == ( + 4711, + 1447, + 322, + 10, + ) assert not moves.problems(zinogre.m, built.layout, moves.records(Data.find().fu, 75)) @@ -532,4 +678,10 @@ def test_the_cli_splices_the_port(data, capsys): assert "routines added" in out and "worst run" in out assert " charge: charge_up 40/55/65/100, p3_1_15 25/35/40/55, " in out assert " pair 2,12 -> own p3_2_12 (slot " in out and " pair 2,25 -> own p3_2_23 (slot " in out - assert " pair 7,23 -> 1,2 x" in out and " pair 7,23 -> own p3_7_0 (slot " in out + assert ( + " pair 7,23 -> own p3_7_23_a0 (slot " in out and " pair 7,23 -> own p3_7_0 (slot " in out + ) + assert ( + " pair 1,1 -> own p3_1_1_a0 (slot " in out + and " pair 2,26 -> own p3_2_26_a3 (slot " in out + ) diff --git a/packages/mhfu/src/mhfu/addresses.toml b/packages/mhfu/src/mhfu/addresses.toml index 5d3ac501..0eb14567 100644 --- a/packages/mhfu/src/mhfu/addresses.toml +++ b/packages/mhfu/src/mhfu/addresses.toml @@ -1235,10 +1235,10 @@ OWN_MOVE_MAIN = { value = 16, doc = "an AI script `enter` main no species has (t CHARGE_QUERY_CHARGED = { value = 0, doc = "the AI script `switch_query` arg that asks whether a port with `[brain] charge` is charged (MHP3rd's own numbering of the query): the framework answers 1 or 0" } CHARGE_QUERY_COOLDOWN = { value = 6, doc = "the `switch_query` arg that asks whether the charge cooldown runs (MHP3rd's numbering): the framework answers 1 or 0" } OWN_MOVES = { value = 160, doc = "own moves the framework's registry holds, EM_MOVES slots 0..OWN_MOVES-1: every table keyed by slot, and the offsets after one, follow it; the enter ids from OWN_MOVES to 255 name combos (EM_MOVES.COMBOS)" } -EM_KEYS = { value = 2944, doc = "turn keys all own moves share in EM_MOVES.KEYS, a run held twice once: the Zinogre's 125 moves hold 2432" } -EM_SAMPLES = { value = 1280, doc = "travel samples all own moves share in EM_MOVES.SAMPLES, a run held twice once: the Zinogre's hold 1052" } -EM_TURNS = { value = 1024, doc = "code-turn values all own moves share in EM_MOVES.TURNS, a run held twice once (provisional: the port derivation sizes it)" } -EM_LUNGES = { value = 12, doc = "lunges the registry holds, an equal one of two moves once: the Zinogre's hold 10" } +EM_KEYS = { value = 5696, doc = "turn keys all own moves share in EM_MOVES.KEYS, a run held twice once: the Zinogre's 137 moves hold 4711, and this is that and a fifth, up to a multiple of 64" } +EM_SAMPLES = { value = 1792, doc = "travel samples all own moves share in EM_MOVES.SAMPLES, a run held twice once: the Zinogre's hold 1447, and this is that and a fifth, up to a multiple of 64" } +EM_TURNS = { value = 448, doc = "code-turn values all own moves share in EM_MOVES.TURNS, a run held twice once: the Zinogre's hold 322, and this is that and a fifth, up to a multiple of 64" } +EM_LUNGES = { value = 12, doc = "lunges the registry holds, an equal one of two moves once: the Zinogre's hold 10, and this is that and a fifth, up to a multiple of 4" } STEER_KEYS = { value = 256, doc = "turn keys one move holds (STEER_SPEC.KEYS)" } STEER_KEY_STEP = { value = 2, doc = "clip frames between a move's turn keys and between its travel samples: the cursor's step at speed 2, so it lands on them" } STEER_TURNS = { value = 132, doc = "code-turn values one move holds (STEER_SPEC.TURNS): one every STEER_KEY_STEP clip frames, as STEER_SAMPLES" } diff --git a/packages/mhfu/src/mhfu/em/moveset.py b/packages/mhfu/src/mhfu/em/moveset.py index 188c8723..dfa19ceb 100644 --- a/packages/mhfu/src/mhfu/em/moveset.py +++ b/packages/mhfu/src/mhfu/em/moveset.py @@ -201,9 +201,10 @@ class Tag: """A value the walker knows by origin, not by number.""" kind: str - """entity, vtable, method (a vtable slot), cell (a load from the entity), result.""" + """entity, vtable, method (a vtable slot), cell (a load from the entity), result, global (a + pointer the walker was told of).""" n: int | None = None - """The slot, the cell's offset, or the callee a result came from.""" + """The slot, the cell's offset, the callee a result came from, or the global's address.""" frame: float | None = None """For a clip predicate's result, the frame it tested.""" size: int = 0 @@ -214,6 +215,8 @@ class Tag: Value = int | Tag ENTITY = Tag("entity") +Pointers = Mapping[int, Mapping[int, int]] +"""Global pointers by the address they sit at: the words a load off the pointer reads, by offset.""" VTABLE = Tag("vtable") Cell = tuple[int, int] """An entity cell: (offset, width in bytes).""" @@ -359,8 +362,9 @@ class Walker: arithmetic on them; a call to a `returns` target leaves that float in $f0. A float register is held as its bits, so `args` can name one (`Fpr.fa0`). With `cells` (see `run`) it also reads and writes the entity's float cells and decides a branch on a float compare of two - values it knows. A pc runs at most once per path; - `max_paths` path ends and `max_steps` per path bound a walk, and `truncated` names the + values it knows. With `pointers` a load of a word at a listed address is that pointer, and a + word loaded off it is the one listed (an unlisted one is unknown). A pc runs at most once per + path; `max_paths` path ends and `max_steps` per path bound a walk, and `truncated` names the functions that hit one. """ @@ -373,6 +377,7 @@ def __init__( follow: Collection[int] | None = None, depth: int = 1, facts: Facts | None = None, + pointers: Pointers | None = None, names: Mapping[int, str] | None = None, args: tuple[Register, ...] = (Gpr.a1, Gpr.a2, Gpr.a3), probes: Mapping[int, Register] | None = None, @@ -385,6 +390,7 @@ def __init__( self.methods = dict(methods or {}) self.follow = follow self.depth, self.facts, self.names = depth, dict(facts or {}), dict(names or {}) + self.pointers = {at: dict(words) for at, words in (pointers or {}).items()} self.args, self.probes = args, dict(probes or {}) self.returns = {target: _float_bits(v) for target, v in (returns or {}).items()} self._found: list[tuple[dict[Site, None], tuple[int, ...]]] = [] @@ -393,6 +399,7 @@ def __init__( self._memo: dict[tuple[object, ...], tuple[Site, ...]] = {} self._decide: Decide | None = None self._cells: MutableMapping[int, float] | None = None + self._persist = False self._operands = {s.operand.vram: s.jr for s in code.switches.values() if s.operand} self._tables = { s.table + 4 * i: t for s in code.switches.values() for i, t in enumerate(s.targets) @@ -405,34 +412,40 @@ def run( facts: Facts | None = None, decide: Decide | None = None, cells: MutableMapping[int, float] | None = None, + persist: bool = False, ) -> list[Site]: """Every watched call some path from `fn` reaches, once per distinct Site. `decide(pc, target, args)` answers for the caller: the $v0 a call at `pc` to `target` leaves (`args`: the walker's argument registers there; a float answers $f0 instead) or the outcome of a branch at `pc` the walk cannot decide (1 taken, 0 not; no target, `args` - empty). None leaves it to the walk, which forks. A run with `decide` or `cells` is not - memoised. + empty). None leaves it to the walk, which forks. A run with `decide`, `cells` or + `persist` is not memoised. `cells` are the entity's float cells by offset: a load from the entity reads one it holds, a store writes it (an unknown value removes it), and `decide` may change them between calls. They are shared by every path and callee, in the order they are walked, so they are exact for a walk `decide` keeps on one path. They also let a branch on a float compare of two known values decide itself, and `decide` hear of a tail call, its answer - unused.""" + unused. + + With `persist`, every store of a byte, half or word to the entity is also a Site of kind + "store", `args` (offset, width, value): what a caller that carries cells from one run to + the next (as `facts`) reads off it; the value is None where it is not known.""" env: _Env = {r: v for r, v in (seed or {}).items()} before, self._decide = self._decide, decide held, self._cells = self._cells, cells + kept, self._persist = self._persist, persist try: return list(self._run(fn, env, {**self.facts, **(facts or {})}, 0, ())) finally: - self._decide, self._cells = before, held + self._decide, self._cells, self._persist = before, held, kept def _run( self, fn: int, seed: _Env, facts: dict[Cell, Known], depth: int, via: tuple[int, ...] ) -> tuple[Site, ...]: key = (fn, frozenset(seed.items()), frozenset(facts.items()), depth, via) - if self._decide is not None or self._cells is not None: + if self._decide is not None or self._cells is not None or self._persist: return self._walk(_Path(fn, {Gpr.a0: ENTITY, **seed}, facts), depth, via) if key not in self._memo: start = _Path(fn, {Gpr.a0: ENTITY, **seed}, facts) @@ -728,6 +741,10 @@ def _store(self, ins: Instruction, u: rabbitizer.Enum, p: _Path) -> None: value = _value(p.env, ins.rt) if isinstance(value, int): p.facts[off, width] = value & ((1 << 8 * width) - 1) + if self._persist: + out, via = self._found[-1] + got = value & ((1 << 8 * width) - 1) if isinstance(value, int) else None + out.setdefault(Site(ins.vram, "store", (off, width, got), p.guards, via)) if self._cells is not None and width == 4 and off in self._cells: if isinstance(value, int): self._cells[off] = _bits(value) @@ -744,6 +761,8 @@ def _eval(self, ins: Instruction, u: rabbitizer.Enum, p: _Path) -> Value | None: return VTABLE if u == InstrId.cpu_lwc1 and self._cells is not None and off in self._cells: return _float_bits(self._cells[off]) + if u == InstrId.cpu_lwc1 and isinstance(word := p.facts.get((off, 4)), int): + return word # the bits of a word the path stored width = _WIDTH.get(u, 0) fact = p.facts.get((off, width)) if isinstance(fact, int): @@ -752,8 +771,11 @@ def _eval(self, ins: Instruction, u: rabbitizer.Enum, p: _Path) -> Value | None: return Tag("cell", off, size=width) if base == VTABLE and u == InstrId.cpu_lw: return Tag("method", off) + if isinstance(base, Tag) and base.kind == "global" and u == InstrId.cpu_lw: + return self.pointers.get(base.n or 0, {}).get(off) if isinstance(base, int) and u == InstrId.cpu_lw: - return self._tables.get((base + off) & _MASK) + at = (base + off) & _MASK + return Tag("global", at) if at in self.pointers else self._tables.get(at) if isinstance(base, int) and u == InstrId.cpu_lwc1: try: return self.code.mem.u32((base + off) & _MASK) @@ -779,6 +801,14 @@ def _eval(self, ins: Instruction, u: rabbitizer.Enum, p: _Path) -> Value | None: if u in _EXTEND: x = _value(env, ins.rt) return _extend(x, _EXTEND[u]) & _MASK if isinstance(x, int) else x + if u in (InstrId.cpu_negu, InstrId.cpu_neg): + x = _value(env, ins.rt) + return -x & _MASK if isinstance(x, int) else None + if u in _MINMAX: + x, y = _value(env, ins.rs), _value(env, ins.rt) + if isinstance(x, int) and isinstance(y, int): + return _MINMAX[u](_signed(x & _MASK), _signed(y & _MASK)) & _MASK + return None if u == InstrId.cpu_move: return _value(env, ins.rs) if u == InstrId.cpu_mtc1: @@ -797,6 +827,9 @@ def _eval(self, ins: Instruction, u: rabbitizer.Enum, p: _Path) -> Value | None: except ArithmeticError: return None return None + if u == InstrId.cpu_cvt_s_w: + x = env.get(ins.fs) + return _float_bits(float(_signed(x & _MASK))) if isinstance(x, int) else None if u == InstrId.cpu_trunc_w_s: x = env.get(ins.fs) try: @@ -876,6 +909,10 @@ def _sltu(x: int, y: int) -> int: InstrId.cpu_srl: lambda x, s: (x & _MASK) >> s, InstrId.cpu_sra: lambda x, s: _signed(x & _MASK) >> s, } +_MINMAX: dict[rabbitizer.Enum, Callable[[int, int], int]] = { + InstrId.r4000allegrex_max: max, + InstrId.r4000allegrex_min: min, +} _EXTEND: dict[rabbitizer.Enum, int] = {InstrId.r4000allegrex_seb: 1, InstrId.r4000allegrex_seh: 2} _TWO: dict[rabbitizer.Enum, bool] = { InstrId.cpu_beq: True, diff --git a/packages/mhfu/tests/test_moveset.py b/packages/mhfu/tests/test_moveset.py index 05ec9924..fb01cca8 100644 --- a/packages/mhfu/tests/test_moveset.py +++ b/packages/mhfu/tests/test_moveset.py @@ -320,12 +320,142 @@ def decide(pc, target, args): assert heard == [(helper, (5,))] +GLOBAL = 0x0090_9600 # a word the code does not own, where a global pointer sits + + +def global_word() -> Code: + """Plays 54 where the word at +0x540 off the pointer at GLOBAL is 2, else 46.""" + return code( + asm.lui("v0", asm.hi(GLOBAL)), + asm.lw("v1", asm.lo(GLOBAL), "v0"), + asm.lw("t0", 0x540, "v1"), + asm.li("v0", 2), + asm.bne("t0", "v0", va(7), va(4)), + asm.li("a1", 46), # the delay slot runs either way + asm.li("a1", 54), + asm.jal(a.ACTION_EXECUTOR), # 7 + asm.NOP, + asm.RET, + asm.NOP, + ) + + +def test_a_word_off_a_global_pointer_is_the_one_told(): + def played(**kw): + w = Walker(global_word(), {a.ACTION_EXECUTOR: "anim"}, **kw) + return sorted(s.args[0] for s in w.run(BASE)) + + assert played() == [46, 54] # unknown: it forks + assert played(pointers={GLOBAL: {0x540: 2}}) == [54] + assert played(pointers={GLOBAL: {0x540: 3}}) == [46] + assert played(pointers={GLOBAL: {0x544: 2}}) == [46, 54] # another word is not told + + def test_a_branch_on_the_compare_not_holding(): w = Walker(float_branch(asm.bc1f), {a.ACTION_EXECUTOR: "anim"}) assert [s.args[0] for s in w.run(BASE, cells={0x84: 5.0})] == [1] assert [s.args[0] for s in w.run(BASE, cells={0x84: 20.0})] == [2] +def _r(funct, rd, rs, rt): + return asm.gpr(rs) << 21 | asm.gpr(rt) << 16 | asm.gpr(rd) << 11 | funct + + +def negu(rd, rt): + return _r(0x23, rd, 0, rt) + + +def max_(rd, rs, rt): + return _r(0x2C, rd, rs, rt) + + +def min_(rd, rs, rt): + return _r(0x2D, rd, rs, rt) + + +def cvt_s_w(fd, fs): + return 0x11 << 26 | 0x14 << 21 | fs << 11 | fd << 6 | 0x20 + + +def test_negate_maximum_and_minimum_of_known_values(): + thing = a.ACTION_EXECUTOR + c = code( + asm.li("v0", -5), + negu("v1", "v0"), # 5 + max_("a1", "v0", "v1"), + asm.jal(thing), + asm.NOP, + asm.li("v0", -5), + negu("v1", "v0"), + min_("a1", "v0", "v1"), + asm.jal(thing), + asm.NOP, + asm.RET, + asm.NOP, + ) + sites = Walker(c, {thing: "anim"}).run(BASE) + assert [s.args[0] for s in sites] == [5, 0xFFFF_FFFB] + + +def test_an_integer_converts_to_a_float(): + thing = a.ACTION_EXECUTOR + c = code( + asm.li("v0", 3), + asm.mtc1("v0", 1), + cvt_s_w(2, 1), + asm.add_s(2, 2, 2), + asm.trunc_w_s(0, 2), + asm.mfc1("a1", 0), + asm.jal(thing), + asm.NOP, + asm.RET, + asm.NOP, + ) + assert [s.args[0] for s in Walker(c, {thing: "anim"}).run(BASE)] == [6] + + +def test_a_float_load_reads_the_word_the_path_stored_before(): + thing = a.ACTION_EXECUTOR + c = code( + asm.addu("s0", "a0", "zero"), + asm.lui("v0", 0x4040), # 3.0 + asm.sw("v0", 0xB0, "s0"), + asm.lwc1(1, 0xB0, "s0"), + asm.add_s(1, 1, 1), + asm.trunc_w_s(0, 1), + asm.mfc1("a1", 0), + asm.jal(thing), + asm.NOP, + asm.RET, + asm.NOP, + ) + w = Walker(c, {thing: "anim"}) + assert [s.args[0] for s in w.run(BASE, cells={})] == [6] + assert [s.args[0] for s in w.run(BASE)] == [6] + + +def test_persist_reports_the_stores_to_the_entity(): + c = code( + asm.addu("s0", "a0", "zero"), + asm.li("v0", 7), + asm.sb("v0", 0x5E, "s0"), + asm.sw("v0", 0xFC0, "s0"), + asm.lbu("v1", 0xB0, "s0"), # a cell it holds no value of + asm.sh("v1", 0xFC8, "s0"), + asm.sw("v0", 0x10, "sp"), # not the entity + asm.RET, + asm.NOP, + ) + w = Walker(c, {}) + assert w.run(BASE) == [] + stores = [(s.kind, s.args) for s in w.run(BASE, persist=True)] + assert stores == [ + ("store", (0x5E, 1, 7)), + ("store", (0xFC0, 4, 7)), + ("store", (0xFC8, 2, None)), + ] + + def test_game(game): for species in files.EM_SPECIES: ms = Moveset(game.em(species)) diff --git a/ports/README.md b/ports/README.md index fe6750ab..b4ae18bc 100644 --- a/ports/README.md +++ b/ports/README.md @@ -95,6 +95,31 @@ frames from the dispatch, which the blend and the rate change. An attack must li `[start, stop]`. A move derived from a donor pair gets its `start` and `stop` from the donor's own clip frames (`mhfu-port own` lists them). +What the donor's code does besides its clip is the move's too: `travel` (cumulative x, z every 2 +clip frames from `start`), `lunge` (toward the target), and `code_turn`, the yaw its code adds: +cumulative YAW units (65536 a turn), a value every 2 clip frames from `start`. Each AI frame the +move's own yaw moves by the curve's change since the cursor's last frame, the clip's facing riding +on top. With `steer.turn = "aim"` (derived moves only) the curve is scaled to turn exactly the angle +to the target at the move's first frame. `aim_out = N` says that when the move hands over in place +(to its `after`, or as the first of a combo) the angle still to turn at the switch is slid into the +next move's first N AI frames, as the donor's turn wrappers blend it. + +A picker, `pick`, is a move with no clip and no slot of its own: + +```toml +[moves.p3_2_26] +pick = [[0, 63, "p3_2_26_a0"], [64, 127, "p3_2_26_a1"], [128, 191, "p3_2_26_a2"], [192, 255, "p3_2_26_a3"]] +``` + +Each entry is an arc of the target's bearing to the left of the move's facing, in 256ths of a turn +(0 to 127 the left half, as `if_target_in_arc`), and the own move, a variant, played in it; the arcs +cover 0 to 255 once. A donor pair that `[brain.pairs]` maps to a picker is entered as nested +`if_target_in_arc` over its variants. A list that names a picker and an own move, +`"7,23" = ["own", "p3_7_0"]`, enters one combo per variant: `enter 16 id 0` with an id from 160 up +(`mhfu_port.moves.combos`, written as the module's `combos`), which plays the variant, then the move +in place. The build makes a picker where a donor handler plays other plans by the bearing of the +target (`p3_7_23_a0` ... are its variants). + `[behaviour]` decides when a move plays, as a graph. A block is `[behaviour.blocks.]`: its `kind`, the kind's params flat beside it, `at` (canvas x, y), `next` (the blocks it feeds), `play` (the moves a path that gets here plays) and `label`. A move on the canvas is @@ -213,18 +238,20 @@ picker `base_6` and its normal one `base_5`) into em75's brain, so what he picks up (the helper's gauge, `[brain] charge`) and when he is tired (`[brain] fatigue`) is the donor's. A donor pair plays a host pair (a turn), the authored move that is the donor's (`charge_up`, `dash`, `dash_stop`, `break_howl`) or an own move derived from the donor -(`"own"`); a pair that only turns, then enters another, turns on em75's (1, 2) and plays the -move of the pair it leads to; pairs whose derived moves come out identical (twins) share the -first one's, and the ten whose donor handler hands over elsewhere derive their own chains (a -wrapper plays that of the twin of the pair it leads to). A derived +(`"own"`); a pair that only turns, then enters another (a wrapper), turns by its own picker, four +variants the donor's wrappers share (`p3_7_23_a0` to `a3`: the clips 9, 38, 37 and 8 by the side and +how far round the target is), and plays the move of the pair it leads to, as a combo of the two; +pairs whose derived moves come out identical (twins) share the first one's, and the ten whose donor +handler hands over elsewhere derive their own chains. A derived move goes on into the pair the donor's handler hands over to (`p3_7_12` into `p3_7_156`), and equal tails are one move. What a handler does by code, not by its clips, is derived with them: its travel, a lunge, a half turn, and a jump's height as a lifted copy of its clip in a free entry -(`Move.travel`, `Move.lunge`, a fixed `steer`, `Clip.lift`; docs/formats/p3rd_ai_script.md). The -donor's walks are derived moves on their own walk clips: em75's -(1, 0) walks to a destination its own script sets first (docs/formats/p3rd_ai_script.md). His 125 -own moves take 2432 of the registry's 2944 turn keys (a run the registry holds twice is held -once), 1052 of its 1280 travel samples, 10 of its 12 lunges and 125 of its 128 slots. +(`Move.travel`, `Move.lunge`, a fixed `steer`, `Move.code_turn` and `aim`, `Clip.lift`; +docs/formats/p3rd_ai_script.md). The donor's walks are derived moves on their own walk clips: em75's +(1, 0) walks to a destination its own script sets first (docs/formats/p3rd_ai_script.md). His 137 +own moves take 4711 of the registry's 5696 turn keys (a run the registry holds twice is held +once), 1447 of its 1792 travel samples, 322 of its 448 code-turn values, 10 of its 12 lunges, 137 of +its 160 slots and 88 of its 96 combos. What stays of the hand-built rules, after the brain's own picks (2 plays nothing and so runs first): diff --git a/ports/zinogre.toml b/ports/zinogre.toml index 2b776b08..b20688ab 100644 --- a/ports/zinogre.toml +++ b/ports/zinogre.toml @@ -1088,10 +1088,7 @@ charge = true 0, 1, ] -"1,1" = [ - 1, - 2, -] +"1,1" = "own" "1,2" = "break_howl" "1,3" = "own" "1,4" = "own" @@ -1103,14 +1100,8 @@ charge = true "1,20" = "own" "2,3" = "dash_stop" "2,4" = "dash" -"2,5" = [ - 1, - 2, -] -"2,6" = [ - 1, - 2, -] +"2,5" = "own" +"2,6" = "own" "2,10" = "own" "2,11" = "p3_2_10" "2,12" = "own" @@ -1123,10 +1114,7 @@ charge = true "2,23" = "own" "2,24" = "p3_2_23" "2,25" = "p3_2_23" -"2,26" = [ - 1, - 2, -] +"2,26" = "own" "7,0" = "own" "7,4" = "own" "7,5" = "p3_7_4" @@ -1138,132 +1126,78 @@ charge = true "7,19" = "own" "7,22" = "own" "7,23" = [ - [ - 1, - 2, - ], + "own", "p3_7_0", ] "7,24" = [ - [ - 1, - 2, - ], + "own", "p3_7_0", ] "7,27" = [ - [ - 1, - 2, - ], + "own", "p3_7_86", ] "7,28" = [ - [ - 1, - 2, - ], + "own", "p3_7_86", ] "7,29" = [ - [ - 1, - 2, - ], + "own", "dash", ] "7,30" = [ - [ - 1, - 2, - ], + "own", "dash", ] "7,33" = "own" "7,35" = "own" "7,38" = [ - [ - 1, - 2, - ], + "own", "p3_7_13", ] "7,39" = [ - [ - 1, - 2, - ], + "own", "p3_7_12", ] "7,40" = "own" "7,43" = [ - [ - 1, - 2, - ], + "own", "p3_7_40", ] "7,44" = [ - [ - 1, - 2, - ], + "own", "p3_7_40", ] "7,45" = [ - [ - 1, - 2, - ], + "own", "p3_7_35", ] "7,46" = [ - [ - 1, - 2, - ], + "own", "p3_7_35", ] "7,55" = [ - [ - 1, - 2, - ], + "own", "p3_7_22", ] "7,56" = [ - [ - 1, - 2, - ], + "own", "p3_7_22", ] "7,57" = [ - [ - 1, - 2, - ], + "own", "p3_2_10", ] "7,58" = [ - [ - 1, - 2, - ], + "own", "p3_2_10", ] "7,59" = [ - [ - 1, - 2, - ], + "own", "p3_2_10", ] "7,60" = [ - [ - 1, - 2, - ], + "own", "p3_2_10", ] "7,65" = "own" @@ -1274,31 +1208,19 @@ charge = true "7,70" = "own" "7,71" = "own" "7,72" = [ - [ - 1, - 2, - ], + "own", "p3_7_70", ] "7,73" = [ - [ - 1, - 2, - ], + "own", "p3_7_71", ] "7,74" = [ - [ - 1, - 2, - ], + "own", "p3_7_67", ] "7,75" = [ - [ - 1, - 2, - ], + "own", "p3_7_67", ] "7,79" = "own" @@ -1306,17 +1228,11 @@ charge = true "7,81" = "own" "7,82" = "own" "7,83" = [ - [ - 1, - 2, - ], + "own", "p3_7_81", ] "7,84" = [ - [ - 1, - 2, - ], + "own", "p3_7_82", ] "7,85" = "own" @@ -1324,93 +1240,57 @@ charge = true "7,87" = "own" "7,89" = "own" "7,92" = [ - [ - 1, - 2, - ], + "own", "p3_1_19", ] "7,93" = [ - [ - 1, - 2, - ], + "own", "p3_1_19", ] "7,94" = "own" "7,95" = [ - [ - 1, - 2, - ], + "own", "p3_2_22", ] "7,96" = [ - [ - 1, - 2, - ], + "own", "p3_2_22", ] "7,97" = [ - [ - 1, - 2, - ], + "own", "p3_7_89", ] "7,98" = [ - [ - 1, - 2, - ], + "own", "p3_7_89", ] "7,99" = "own" "7,100" = "own" "7,101" = [ - [ - 1, - 2, - ], + "own", "p3_7_99", ] "7,102" = [ - [ - 1, - 2, - ], + "own", "p3_7_99", ] "7,103" = [ - [ - 1, - 2, - ], + "own", "p3_7_100", ] "7,104" = [ - [ - 1, - 2, - ], + "own", "p3_7_100", ] "7,107" = "p3_7_87" "7,108" = "p3_7_87" "7,109" = "own" "7,110" = [ - [ - 1, - 2, - ], + "own", "p3_7_109", ] "7,111" = [ - [ - 1, - 2, - ], + "own", "p3_7_109", ] "7,113" = "p3_7_94" @@ -1421,17 +1301,11 @@ charge = true "7,118" = "own" "7,119" = "own" "7,120" = [ - [ - 1, - 2, - ], + "own", "p3_7_118", ] "7,121" = [ - [ - 1, - 2, - ], + "own", "p3_7_119", ]