From cd501f2d7ed4ffc62c78d48598ec6ea699ad6c75 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:03:07 +0200 Subject: [PATCH 1/5] feat(em): a move walk reads and writes float cells, decides float branches and answers floats Walker.run takes cells, the entity's float cells by offset: a load reads one, a store writes it, decide may change them between calls, and a branch on a float compare of two known values decides itself. decide may answer a call with a float ($f0), and hears of a tail call when cells are given. The walk is not memoised. Co-Authored-By: Claude Sonnet 5.5 --- packages/mhfu/src/mhfu/em/moveset.py | 106 +++++++++++++++--- packages/mhfu/tests/test_moveset.py | 106 ++++++++++++++++++ .../modkit-testing/src/modkit_testing/mips.py | 18 +++ 3 files changed, 212 insertions(+), 18 deletions(-) diff --git a/packages/mhfu/src/mhfu/em/moveset.py b/packages/mhfu/src/mhfu/em/moveset.py index 77282424..188c8723 100644 --- a/packages/mhfu/src/mhfu/em/moveset.py +++ b/packages/mhfu/src/mhfu/em/moveset.py @@ -17,7 +17,7 @@ from __future__ import annotations import struct -from collections.abc import Callable, Collection, Mapping +from collections.abc import Callable, Collection, Mapping, MutableMapping from dataclasses import dataclass, field from functools import cached_property @@ -260,7 +260,7 @@ class _Operand: jr: int -Decide = Callable[[int, int | None, tuple[int | None, ...]], int | None] +Decide = Callable[[int, int | None, tuple[int | None, ...]], int | float | None] """What a caller of `Walker.run` decides: see there.""" _Env = dict["Register | _Operand", Value] _PASSED = (Gpr.a0, Gpr.a1, Gpr.a2, Gpr.a3, Fpr.fa0) @@ -289,10 +289,18 @@ class _Path: guards: tuple[str, ...] = () visited: frozenset[int] = frozenset() steps: int = 0 + fcc: bool | None = None + """The last float compare's result, when both its operands were known.""" def fork(self, pc: int, guards: tuple[str, ...] = ()) -> _Path: return _Path( - pc, dict(self.env), dict(self.facts), self.guards + guards, self.visited, self.steps + pc, + dict(self.env), + dict(self.facts), + self.guards + guards, + self.visited, + self.steps, + self.fcc, ) def learn(self, cell: Cell, value: int, equal: bool) -> None: @@ -312,6 +320,19 @@ def _float_bits(value: float) -> int: return int(struct.unpack(" 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) 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` is not memoised.""" + 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. + + `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.""" env: _Env = {r: v for r, v in (seed or {}).items()} before, self._decide = self._decide, decide + held, self._cells = self._cells, cells try: return list(self._run(fn, env, {**self.facts, **(facts or {})}, 0, ())) finally: - self._decide = before + self._decide, self._cells = before, held 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: + if self._decide is not None or self._cells is not None: 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) @@ -427,6 +461,7 @@ def _walk(self, start: _Path, depth: int, via: tuple[int, ...]) -> tuple[Site, . frozenset(p.env.items()), frozenset(p.facts.items()), p.guards if self.guards else (), + p.fcc, ) if state in states: ends += 1 @@ -465,6 +500,8 @@ def _walk(self, start: _Path, depth: int, via: tuple[int, ...]) -> tuple[Site, . for s in self._call(target, p, depth, via, ins.doesLink()): out.setdefault(s) if not ins.doesLink(): + if self._cells is not None: # a tail call is a call: `decide` hears of it + self._tell(target, p) ends += 1 break self._after_call(target, p) @@ -557,29 +594,38 @@ def _call( found = self._run(target, seed, dict(p.facts), deeper, (*via, target)) return [Site(s.site, s.kind, s.args, p.guards + s.guards, s.via, s.facts) for s in found] + def _tell(self, target: Value | None, p: _Path) -> int | float | None: + """What `decide` says of the call at `p.pc`; None without one.""" + if self._decide is None: + return None + args = tuple(v if isinstance(v := p.env.get(r), int) else None for r in self.args) + return self._decide(p.pc, target if isinstance(target, int) else None, args) + def _after_call(self, target: Value | None, p: _Path) -> None: frame = None if target in _FRAMED: bits = p.env.get(Fpr.fa0) frame = round(_bits(bits), 2) if isinstance(bits, int) else None - said = None - if self._decide is not None: - args = tuple(v if isinstance(v := p.env.get(r), int) else None for r in self.args) - said = self._decide(p.pc, target if isinstance(target, int) else None, args) + said = self._tell(target, p) for r in CALL_CLOBBERS: p.env.pop(r, None) + result = said if isinstance(said, int) else None p.env[Gpr.v0] = ( - said - if said is not None + result + if result is not None else Tag("result", target if isinstance(target, int) else None, frame, site=p.pc) ) - if isinstance(target, int) and target in self.returns: + if isinstance(said, float): + p.env[Fpr.fv0] = _float_bits(said) + elif isinstance(target, int) and target in self.returns: p.env[Fpr.fv0] = self.returns[target] # --- conditions --- def _cond(self, ins: Instruction, p: _Path) -> _Fork: u = ins.uniqueId + if u in _FCC: + return _Fork() if self._cells is None or p.fcc is None else _Fork(p.fcc == _FCC[u]) if u in _TWO: x = _value(p.env, ins.rs) y = 0 if u in (InstrId.cpu_beqz, InstrId.cpu_bnez) else _value(p.env, ins.rt) @@ -644,6 +690,13 @@ def _exec(self, ins: Instruction | None, p: _Path) -> None: if ins.doesStore(): self._store(ins, u, p) return + if u in _COMPARE and self._cells is not None: + x, y = p.env.get(ins.fs), p.env.get(ins.ft) + if isinstance(x, int) and isinstance(y, int): + p.fcc = _COMPARE[u](_bits(x), _bits(y)) + else: + p.fcc = None + return value = self._eval(ins, u, p) if ins.vram in self._operands: key = _Operand(self._operands[ins.vram]) @@ -657,14 +710,29 @@ def _exec(self, ins: Instruction | None, p: _Path) -> None: p.env[dests[0]] = value def _store(self, ins: Instruction, u: rabbitizer.Enum, p: _Path) -> None: - if _value(p.env, ins.rs) != ENTITY or u not in _WIDTH: + if _value(p.env, ins.rs) != ENTITY: + return + off = ins.getProcessedImmediate() + if u == InstrId.cpu_swc1: + bits = p.env.get(ins.ft) + if self._cells is not None and isinstance(bits, int): + self._cells[off] = _bits(bits) + elif self._cells is not None: + self._cells.pop(off, None) return - off, width = ins.getProcessedImmediate(), _WIDTH[u] + if u not in _WIDTH: + return + width = _WIDTH[u] for cell in [c for c in p.facts if c[0] < off + width and off < c[0] + c[1]]: del p.facts[cell] value = _value(p.env, ins.rt) if isinstance(value, int): p.facts[off, width] = value & ((1 << 8 * width) - 1) + if self._cells is not None and width == 4 and off in self._cells: + if isinstance(value, int): + self._cells[off] = _bits(value) + else: + del self._cells[off] def _eval(self, ins: Instruction, u: rabbitizer.Enum, p: _Path) -> Value | None: env = p.env @@ -674,6 +742,8 @@ def _eval(self, ins: Instruction, u: rabbitizer.Enum, p: _Path) -> Value | None: if base == ENTITY: if u == InstrId.cpu_lw and off == 0: return VTABLE + if u == InstrId.cpu_lwc1 and self._cells is not None and off in self._cells: + return _float_bits(self._cells[off]) width = _WIDTH.get(u, 0) fact = p.facts.get((off, width)) if isinstance(fact, int): diff --git a/packages/mhfu/tests/test_moveset.py b/packages/mhfu/tests/test_moveset.py index cf0f39d6..05ec9924 100644 --- a/packages/mhfu/tests/test_moveset.py +++ b/packages/mhfu/tests/test_moveset.py @@ -220,6 +220,112 @@ def answer(pc, target, args): assert {s.args[0] for s in w.run(BASE)} == both # a decided run leaves nothing behind +def test_decide_answers_a_call_with_a_float(): + getter, thing = 0x0020_0000, a.ACTION_EXECUTOR + c = code( + asm.jal(getter), + asm.NOP, + asm.mul_s(0, 0, 0), + asm.trunc_w_s(0, 0), + 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, decide=lambda pc, t, args: 4.0)] == [16] + assert [s.args[0] for s in w.run(BASE, decide=lambda pc, t, args: 3)] == [None] + + +def test_cells_are_read_and_written(): + thing = a.ACTION_EXECUTOR + c = code( + asm.addu("s0", "a0", "zero"), # a call takes $a0 away + asm.lwc1(1, 0xB0, "s0"), + asm.add_s(1, 1, 1), + asm.swc1(1, 0xB4, "s0"), + asm.lwc1(2, 0xB4, "s0"), + asm.trunc_w_s(0, 2), + asm.mfc1("a1", 0), + asm.jal(thing), + asm.NOP, + asm.lwc1(3, 0xC0, "s0"), # a cell it holds no value of + asm.swc1(3, 0xB4, "s0"), + asm.RET, + asm.NOP, + ) + w = Walker(c, {thing: "anim"}) + cells = {0xB0: 3.0} + assert [s.args[0] for s in w.run(BASE, cells=cells)] == [6] + assert cells == {0xB0: 3.0} # the unknown store took 0xB4 away again + assert [s.args[0] for s in w.run(BASE)] == [None] + + +def float_branch(branch, compare=asm.c_lt_s) -> Code: + """Plays 1 where `$f1 < 10.0` fails and 2 where it holds (`branch` the bc1 flavour).""" + thing = a.ACTION_EXECUTOR + words = [ + asm.lwc1(1, 0x84, "a0"), + asm.lui("v0", asm.hi(BASE + 0x80)), + asm.lwc1(2, asm.lo(BASE + 0x80), "v0"), + compare(1, 2), + asm.NOP, + branch(va(12), va(5)), + asm.NOP, + asm.li("a1", 1), + asm.jal(thing), + asm.NOP, + asm.RET, + asm.NOP, + asm.li("a1", 2), # 12 + asm.jal(thing), + asm.NOP, + asm.RET, + asm.NOP, + ] + pool = struct.unpack(" int: return _i(0x31, base, ft, offset) +def swc1(ft: int, offset: int, base: Reg) -> int: + """`swc1 $f, offset(base)`.""" + return _i(0x39, base, ft, offset) + + def _single(funct: int, fd: int, fs: int, ft: int = 0) -> int: return ( 0x11 << 26 | 0x10 << 21 | (ft & 0x1F) << 16 | (fs & 0x1F) << 11 | (fd & 0x1F) << 6 | funct @@ -153,6 +158,14 @@ def add_s(fd: int, fs: int, ft: int) -> int: return _single(0x00, fd, fs, ft) +def sub_s(fd: int, fs: int, ft: int) -> int: + return _single(0x01, fd, fs, ft) + + +def mov_s(fd: int, fs: int) -> int: + return _single(0x06, fd, fs) + + def mul_s(fd: int, fs: int, ft: int) -> int: return _single(0x02, fd, fs, ft) @@ -175,6 +188,11 @@ def c_le_s(fs: int, ft: int) -> int: return _single(0x3E, 0, fs, ft) +def c_eq_s(fs: int, ft: int) -> int: + """`c.eq.s $f, $f`.""" + return _single(0x32, 0, fs, ft) + + def bc1t(target: int, at: int) -> int: """Branch if the float compare held.""" return 0x11 << 26 | 0x08 << 21 | 1 << 16 | (target - at - 4) >> 2 & 0xFFFF From 5d81577daeb6320b432444cde9b2f57a681b97ad Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:03:18 +0200 Subject: [PATCH 2/5] feat(port): a walk of a donor handler follows its code motion mhfu_port.p3motion keeps the cells a handler moves its monster by (position, velocity, acceleration, the floor) and the effect of each helper on them, with the donor's Hermite curves. Walk hands them to the walker, so the tests of the clip cursor and of the height come out as in the donor: the air roll lasts 22 AI frames, the stamp's hop 18, its wind-up 16 or 39. Each Step carries its travel (cumulative x, z every 2 clip frames), its height over rest, its lunge and its fixed turns. The AI frames that move the monster cannot be skipped over: at most 400 a run. Co-Authored-By: Claude Sonnet 5.5 --- packages/mhfu-port/src/mhfu_port/p3hits.py | 202 ++++++++-- packages/mhfu-port/src/mhfu_port/p3motion.py | 396 +++++++++++++++++++ packages/mhfu-port/tests/test_p3hits.py | 130 +++++- packages/mhfu-port/tests/test_p3motion.py | 221 +++++++++++ 4 files changed, 924 insertions(+), 25 deletions(-) create mode 100644 packages/mhfu-port/src/mhfu_port/p3motion.py create mode 100644 packages/mhfu-port/tests/test_p3motion.py diff --git a/packages/mhfu-port/src/mhfu_port/p3hits.py b/packages/mhfu-port/src/mhfu_port/p3hits.py index b583817b..665b6484 100644 --- a/packages/mhfu-port/src/mhfu_port/p3hits.py +++ b/packages/mhfu-port/src/mhfu_port/p3hits.py @@ -20,10 +20,17 @@ meets, a counter or a gauge a handler keeps), start them at known values (`facts`), and answer the float tests of the monster's HP ratio against a constant (`hp`); `Walk.settle` runs a pair so and returns the cells where it ends. + +It keeps a `p3motion.Motion` too, the cells a handler moves the monster by (position, velocity, +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`. """ from __future__ import annotations +import bisect +import math import struct from collections.abc import Mapping, Sequence from dataclasses import dataclass, field @@ -33,14 +40,16 @@ from mhfu.em.moveset import Cell, Decide, Site, Walker, known from mhfu.files import Extracted from mhfu.memory import Unmapped -from mhfu.mips import Code, Fpr, Gpr, Register +from mhfu.mips import Code, Register from mhfu.overlay import Overlay from rabbitizer import InstrId, Instruction -from . import motion +from . import motion, p3motion from . import p3moveset as p3 from .brain.p3 import Species +from .p3motion import Lunge, Motion, Pose, Spread from .p3moveset import Moveset, Pair, Pairs +from .travel import KEY_STEP DESCRIPTOR_HITS = 0xA4 """A species descriptor's pointer to its hit block.""" @@ -82,20 +91,19 @@ CLIP_ID = 0x09CA699C # noaddr: (entity) the clip playing now CURSOR_STEP = 0x09C9DF10 # noaddr: () $f0 the clip cursor's step this frame TURN = 0x09CA12F4 # noaddr: (entity, rate) turns toward the target by at most rate YAW units -MOTION = ( - 0x09CA20EC, # noaddr: velocity and acceleration - 0x09CA1F34, # noaddr: velocity in world axes - 0x09CA19E0, # noaddr: velocity to zero - 0x0889F098, # noaddr: a curve table read at the clip cursor -) +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.""" STEP_FRAMES = 2.0 """Clip frames the cursor advances in an AI frame at normal speed.""" +MIN_TRAVEL, MIN_LIFT = 0.5, 1.0 +"""World units under which a step's travel, or its height over rest, is not kept.""" MAX_TICKS = 3000 MAX_STEPS = 24 MAX_RUNS = 32 -"""Bounds on one simulation (AI frames, clips played), and on the runs of a pair with their -branches going the other way.""" +MAX_MOVED = 400 +"""Bounds on one simulation (AI frames, clips played), on the runs of a pair with their +branches going the other way, and on the AI frames of a run that move the monster by code, which +cannot be skipped over.""" FALL, TAKEN, LOOPS, EXITS = 0, 1, 2, 3 """How a branch the walk cannot decide goes: falls through, is taken, is taken until the clip ends and then falls through (a wait), or falls through until the clip ends and then is taken.""" @@ -104,8 +112,8 @@ LOOPS: "is taken until the clip ends", EXITS: "is taken once the clip ends", } -ARGS = (Gpr.a1, Gpr.a2, Gpr.a3, Gpr.t0, Fpr.fa0, Fpr.fa0f) -MODE, F12, F13 = 0, 4, 5 +ARGS = p3motion.ARGS +MODE, F12, F13 = p3motion.A1, p3motion.F12, p3motion.F13 """Where a cursor gate's mode and its two frames sit in `ARGS`.""" WINDOWS = frozenset([3, 4, 5, 7]) """The cursor gate modes that test a window of frames; the others test one frame.""" @@ -130,6 +138,10 @@ } """What the walk records, by call target; a kind that needs its target carries it after a colon.""" SPAWNERS = {p3.ATTACK: "spawn", p3.ATTACK0: "spawn0", KILL: "kill"} +HANDLED = frozenset([*p3motion.TARGETS, p3motion.CURSOR, p3motion.CLIP_END, p3motion.SCALE]) +"""The calls whose effect or answer the walk's motion supplies.""" +STARTERS = frozenset(["play", "record", "react"]) +"""The kinds of call that start a clip.""" class Record(NamedTuple): @@ -276,6 +288,15 @@ class Step: stop: float | None = None """The clip frame the cursor test that ended it was crossed at; None when its clip or the pair ended it, or no test of the cursor changed its answer on the tick.""" + travel: tuple[tuple[float, float], ...] = () + """The monster's travel by code, cumulative (x, z) at the clip frames `start`, `start + 2`, + ... in its frame at the first playing frame; the first is (0, 0). Empty: none.""" + lift: tuple[float, ...] = () + """Its height over rest at each whole clip frame from `start` to the step's last, one more + than the frames it covers; empty where it stays within `MIN_LIFT` of rest.""" + lunge: Lunge | None = None + spreads: tuple[Spread, ...] = () + """The turns of a fixed total it makes.""" @dataclass(frozen=True) @@ -343,6 +364,14 @@ class _Step: turns: list[tuple[int | None, float, float, tuple[float, float] | None]] = field( default_factory=list ) + origin: Pose = Pose(0.0, 0.0, 0.0) + """Where the monster was when it began.""" + elapsed: float = 0.0 + """Clip frames it has played, a loop's wraps counted.""" + trace: dict[float, Pose] = field(default_factory=dict) + """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) @dataclass @@ -351,6 +380,8 @@ class _Run: vector: dict[int, int] """How each branch the walk cannot decide goes: FALL, TAKEN, LOOPS or EXITS.""" + kin: Motion + """The kinematic cells of the run.""" trail: list[int] = field(default_factory=list) """Those branches, in the order the run met them.""" steps: list[_Step] = field(default_factory=list) @@ -380,6 +411,16 @@ class _Run: """The tracked entity cells, as the handler's stores left them.""" hp: float | None = None """The HP ratio the float tests of it are answered for.""" + moved: bool = False + moving: int = 0 + """The AI frames so far that were.""" + """A helper acted on, or a read of, the motion this tick: it cannot be skipped.""" + plays: int = 0 + """The clip starts this tick's code has made so far.""" + marks: dict[int, Pose] = field(default_factory=dict) + """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.""" @property def current(self) -> _Step | None: @@ -398,12 +439,14 @@ def __init__( *, track: Sequence[Cell] = (), facts: Mapping[Cell, int] | None = None, + rest: float = p3motion.REST, ) -> None: """`track` are entity cells (offset, width) whose value the walk carries from one AI frame to the next, read off the stores it meets; `facts` the values cells hold when a run starts, - a tracked one's start or a constant.""" + a tracked one's start or a constant; `rest` the height over the floor the monster stands + at, which the landing tests of a jump are answered from.""" self.ms, self.hits, self.lengths, self.step = ms, hits, lengths, step - self.facts = dict(facts or {}) + self.facts, self.rest = dict(facts or {}), rest stores = { i.vram: i.rt for i in ms.code @@ -499,28 +542,35 @@ def settle(self, pair: Pair, hp: float | None = None) -> Settled: def _run(self, pair: Pair, vector: dict[int, int], hp: float | None = None) -> _Run: tick = self.ms.tick_entry assert tick is not None - run = _Run(dict(vector), cells=dict(self.facts), hp=hp) + run = _Run(dict(vector), Motion(self.ms.ovl, self.rest), cells=dict(self.facts), hp=hp) decide = self._decider(run) at = self._dispatched(pair) while run.ticks < MAX_TICKS: - run.ended = run.played = False + run.ended = run.played = run.moved = False run.crossed, run.windows, run.asked = [], [], [] + run.plays, run.marks, run.events = 0, {}, [] + self._begin(run) facts = { **known({PHASE: run.phase, p3.MAIN: at[0], p3.SUB: at[1]}), **run.cells, } - sites = self._walker.run(tick, None, facts, decide) + sites = self._walker.run(tick, None, facts, decide, run.kin.cells) 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 self._apply(run, pair, sites) + self._sample(run) if run.finished: break + run.moving += run.moved + if run.moving > MAX_MOVED: + run.notes.append(Note("limit", f"the handler moved the monster {MAX_MOVED} frames")) + break if run.current is None and not sites: run.notes.append(Note("unresolved", "the handler plays no clip")) break self._advance(run, 1) - if run.phase == phase and not run.played and _neutral(sites): + if run.phase == phase and not run.played and not run.moved and _neutral(sites): self._skip(run, sites) else: run.notes.append(Note("limit", f"the handler ran {MAX_TICKS} frames without ending")) @@ -535,19 +585,49 @@ def _dispatched(self, pair: Pair) -> Pairs: # --- the world the walk answers from --- def _decider(self, run: _Run) -> Decide: - def decide(pc: int, target: int | None, args: tuple[int | None, ...]) -> int | None: + def decide(pc: int, target: int | None, args: tuple[int | None, ...]) -> int | float | None: if target is None: return self._branch(run, pc) + if target not in HANDLED and not self.ms.code.at(pc).doesLink(): + return None # a tail call: only the motion is told of those if target in CURSOR_GATES: return self._gate(run, args) if target == CLIP_ENDED: return self._ask(run, _Query(None)) if target == CLIP_ID: return run.clip if run.clip is not None else 0 + if target in HANDLED: + return self._act(run, target, args) + kind = WATCH.get(target, "").partition(":")[0] + if kind in STARTERS: + run.marks[pc] = run.kin.before_hand_over() + run.plays += 1 + if kind == "play": + run.kin.turn_over() return None return decide + 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 + out = run.kin.call(target, args) + run.events += [(run.plays, e) for e in run.kin.events] + run.kin.events.clear() + return out + + def _begin(self, run: _Run) -> None: + """The motion's view of the clip for the AI frame about to run.""" + length = self.lengths.get(run.clip) if run.clip is not None else None + cursor = run.cursor if length is not None else None + run.kin.begin(cursor, None if length is None else float(length.frames), self.step) + + def _sample(self, run: _Run) -> None: + """Where the AI frame left the monster, for the step it ended in.""" + step = run.current + if step is not None: + step.trace[step.elapsed] = _relative(step, run.kin.now(), self.rest) + 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: return said @@ -661,7 +741,23 @@ def _skip(self, run: _Run, sites: Sequence[Site]) -> None: # --- what the handler did in a tick --- + def _take(self, run: _Run, after: bool) -> None: + """What the helpers asked for, before the tick's first clip start or after it.""" + step = run.current + for plays, event in run.events: + if step is None or (plays > 0) != after: + continue + if isinstance(event, Lunge): + step.lunge = step.lunge or event + elif event not in step.spreads: + step.spreads.append(event) + def _apply(self, run: _Run, pair: Pair, sites: Sequence[Site]) -> None: + self._take(run, False) + self._apply_sites(run, pair, sites) + self._take(run, True) + + def _apply_sites(self, run: _Run, pair: Pair, sites: Sequence[Site]) -> None: for s in sites: if run.finished: return @@ -704,7 +800,7 @@ def _play(self, run: _Run, pair: Pair, s: Site) -> None: run.notes.append(Note("unresolved", f"the clip played at 0x{s.site:08X} is computed")) run.finished = True return - self._start(run, clip, s.args[1], s.args[2] or 0) + self._start(run, clip, s.args[1], s.args[2] or 0, s.site) def _play_first(self, run: _Run, ids: Sequence[int] | None, s: Site) -> None: """A helper that plays one of several clips it is given a record of: the first.""" @@ -716,9 +812,9 @@ def _play_first(self, run: _Run, ids: Sequence[int] | None, s: Site) -> None: return if len(ids) > 1: run.notes.append(Note("record", f"clip {ids[0]} of {', '.join(map(str, ids))}")) - self._start(run, ids[0], None, 0) + self._start(run, ids[0], None, 0, s.site) - def _start(self, run: _Run, clip: int, blend: int | None, start: int) -> None: + def _start(self, run: _Run, clip: int, blend: int | None, start: int, site: int) -> None: if clip not in self.lengths: run.notes.append(Note("unresolved", f"the donor has no clip {clip}")) run.finished = True @@ -727,6 +823,9 @@ def _start(self, run: _Run, clip: int, blend: int | None, start: int) -> None: run.notes.append(Note("limit", f"the handler played {MAX_STEPS} clips without ending")) run.finished = True return + where = run.marks.get(site, run.kin.before_hand_over()) + if run.current is not None: + run.current.trace[run.current.elapsed] = _relative(run.current, where, self.rest) self._close_step(run, "clip" if run.ended else "cursor", self._crossed(run)) carried = {} for slot, node in run.nodes.items(): @@ -736,7 +835,7 @@ def _start(self, run: _Run, clip: int, blend: int | None, start: int) -> None: 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)) + step = _Step(clip, blend, float(start), origin=where) step.hits.extend(n.hit for n in carried.values()) run.steps.append(step) run.clip, run.cursor, run.played = clip, float(start), True @@ -786,6 +885,7 @@ def _advance(self, run: _Run, n: int) -> None: if step is None or run.played: return step.ticks += n + step.elapsed += n * self.step length = self.lengths.get(step.clip) run.cursor += n * self.step for node in run.nodes.values(): @@ -821,16 +921,21 @@ def _close_step(self, run: _Run, how: How, stop: float | None = None) -> None: _ended(h, min(h.end, end)) if end is not None and h.end is not None else h for h in step.hits ] + ticks = step.ticks + 1 run.done.append( Step( step.clip, step.blend, step.start, how, - step.ticks + 1, + ticks, tuple(hits), _turns(step), stop, + _path(step.trace), + _height(step.trace, round(ticks * self.step)), + step.lunge, + tuple(step.spreads), ) ) run.steps.pop() @@ -838,6 +943,8 @@ def _close_step(self, run: _Run, how: How, stop: float | None = None) -> None: def _close(self, run: _Run) -> None: how: How = "clip" if run.ended else "pair" if run.left else "cursor" self._close_step(run, how) + if run.kin.lost: + run.notes.append(Note("motion", f"its motion could not be followed: {run.kin.lost}")) def _spawns(run: _Run) -> int: @@ -870,6 +977,53 @@ def _turns(step: _Step) -> tuple[Turn, ...]: return tuple(out) +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) + + +def _along(keys: Sequence[float], trace: Mapping[float, Pose], frame: float, axis: int) -> float: + """The trace's `axis` at clip frame `frame`: straight lines between its points (`keys`, in + order), held past them.""" + if frame <= keys[0]: + return trace[keys[0]][axis] + if frame >= keys[-1]: + return trace[keys[-1]][axis] + hi = bisect.bisect_right(keys, frame) + a, b = keys[hi - 1], keys[hi] + return trace[a][axis] + (frame - a) / (b - a) * (trace[b][axis] - trace[a][axis]) + + +def _path(trace: Mapping[float, Pose]) -> tuple[tuple[float, float], ...]: + """The trace's (x, z) every KEY_STEP clip frames from the step's start. The first is the + origin, as the move player's first frame moves nothing: what the frames at the start did + shows from the second.""" + if not trace: + return () + keys = sorted(trace) + n = math.ceil(keys[-1] / KEY_STEP - 1e-9) + out = [(0.0, 0.0)] + [ + ( + round(_along(keys, trace, KEY_STEP * k, 0), 2), + round(_along(keys, trace, KEY_STEP * k, 2), 2), + ) + for k in range(1, n + 1) + ] + return tuple(out) if max(max(abs(x), abs(z)) for x, z in out) >= MIN_TRAVEL else () + + +def _height(trace: Mapping[float, Pose], frames: int) -> tuple[float, ...]: + """The trace's height over rest at each whole clip frame from 0 to `frames`; empty where it + stays put (a monster that landed low and lies there has the engine's floor to stand on).""" + if not trace: + return () + heights = [p.y for p in trace.values()] + if max(heights) - min(heights) < MIN_LIFT: + return () + keys = sorted(trace) + return tuple(round(_along(keys, trace, f, 1), 2) for f in range(frames + 1)) + + def _wrappers(code: Code) -> frozenset[int]: """The functions that test the clip cursor and then tail-call an attack spawner: the form a species' clip hook spawns through.""" diff --git a/packages/mhfu-port/src/mhfu_port/p3motion.py b/packages/mhfu-port/src/mhfu_port/p3motion.py new file mode 100644 index 00000000..93a4662e --- /dev/null +++ b/packages/mhfu-port/src/mhfu_port/p3motion.py @@ -0,0 +1,396 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 sp00ktober +"""How an MHP3rd handler moves its monster by code: the Hermite curves it samples and the +kinematic cells its helpers act on. + + motion = Motion(ovl) + motion.begin(cursor=40.0, end=70.0, step=2.0) + motion.call(VEL_ACC, ()) # position += velocity turned by yaw, then velocity += accel + motion.cells[Y] # the height over the floor, as the handler's tests read it + +A walk (`p3hits.Walk`) hands `cells` to the walker, which reads and writes them as the handler's +float loads and stores go, and answers each helper call through `call`. The frame is the monster's +at the first playing frame of a step (x toward its +x, its left; z forward), yaw counted from there; +the cursor is the playing clip's, in clip frames, `step` of them to an AI frame. +""" + +from __future__ import annotations + +import math +import struct +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import NamedTuple + +from mhfu.memory import Memory +from mhfu.mips import Fpr, Gpr + +ARGS = ( + Gpr.a1, + Gpr.a2, + Gpr.a3, + Gpr.t0, + Fpr.fa0, + Fpr.fa0f, + Gpr.t1, + Fpr.fa1, + Fpr.fa1f, + Fpr.ft4, + Fpr.ft4f, + Fpr.ft5, +) +"""The registers a walk reads at a call: a1-a3 and t0, $f12 and $f13 (a cursor gate's two frames), +then t1 and $f14-$f18 (what the lunge helper takes).""" +A1, A2, A3, T0, F12, F13, _T1, F14, F15, F16, F17, F18 = range(len(ARGS)) +"""Where each sits in a call's `args`.""" + +CURSOR = 0x09C9DF3C # noaddr: () $f0 the playing clip's cursor, in clip frames +CLIP_END = 0x09C9DF68 # noaddr: () $f0 the playing clip's last frame +SCALE = 0x09C9E888 # noaddr: () $f0 the monster's size against its species' +VEL_APPLY = 0x09CA1BB0 # noaddr: (entity, angles) position += velocity turned by the yaw +VEL_ACC = 0x09CA20EC # noaddr: (entity, angles) VEL_APPLY, then velocity += acceleration +VEL_WORLD = 0x09CA1F34 # noaddr: position += velocity, unturned +VEL_ZERO = 0x09CA19E0 # noaddr: velocity = 0 +VEL_ACC_ZERO = 0x09CA1B58 # noaddr: velocity and acceleration = 0 +LIFT = 0x09C9E8D4 # noaddr: (entity, $f12) height += $f12 +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 +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 + +X, Y, Z = 0x80, 0x84, 0x88 +"""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.""" +REST = 298.0 +"""The height over the floor a monster stands at: the value the donor's own landing snaps to.""" +TURN = 0x10000 +"""YAW units in a full turn.""" +MAX_KEYS = 256 +"""Keys a curve is read with: the donor's hold a few dozen.""" +_HEADER = struct.Struct(" Curve: + """The curve whose header, `{u16 keys, s16 hint, u32 table}`, is at `at`.""" + count, _, table = mem.unpack(_HEADER, at) + if count > MAX_KEYS: + raise ValueError(f"the curve at 0x{at:08X} claims {count} keys") + keys = tuple(Key(*mem.unpack(_KEY, table + _KEY.size * i)) for i in range(count)) + if not keys: + raise ValueError(f"the curve at 0x{at:08X} has no keys") + return cls(keys) + + def at(self, t: float) -> float: + """The value at clip frame `t`: the first key's before it, the last's after.""" + keys = self.keys + if t <= keys[0].time: + return keys[0].value + for a, b in zip(keys, keys[1:], strict=False): + if t <= b.time and b.time > a.time: + return _hermite(t, a, b) + return keys[-1].value + + def delta(self, t: float, step: float) -> float: + """The change over the `step` frames up to `t` (from the start at the earliest); 0 at the + start.""" + return 0.0 if t == 0 else self.at(t) - self.at(max(t - step, 0.0)) + + +def _hermite(t: float, a: Key, b: Key) -> float: + span = b.time - a.time + u = (t - a.time) / span + return ( + (2 * u**3 - 3 * u**2 + 1) * a.value + + (u**3 - 2 * u**2 + u) * span * a.ease_out + + (3 * u**2 - 2 * u**3) * b.value + + (u**3 - u**2) * span * b.ease_in + ) + + +class Lunge(NamedTuple): + """A handler's lunge: `base + range * min((distance - near) / span, 1)` units toward the + target across the clip frames `window` (lo, hi], none under `near`.""" + + window: tuple[float, float] + near: float + span: float + base: float + range: float + + +class Spread(NamedTuple): + """A turn of `total` YAW units spread over the clip frames `window` (lo, hi].""" + + total: int + window: tuple[float, float] + + @property + def degrees(self) -> float: + return self.total / TURN * 360 + + +class Pose(NamedTuple): + """A position in the frame of the step it moved in.""" + + x: float + y: float + z: float + + +Args = tuple[int | None, ...] +"""What a call's `ARGS` registers held, where known.""" + + +def _float(bits: int | None) -> float | None: + if bits is None: + return None + return float(struct.unpack(" int: + bits &= 0xFFFFFFFF + return bits - (1 << 32) if bits & 0x80000000 else bits + + +def _turned(x: float, z: float, yaw: int) -> tuple[float, float]: + """`x` and `z` turned about y by `yaw` YAW units, as the donor's rotation does.""" + a = yaw / TURN * math.tau + s, c = math.sin(a), math.cos(a) + return x * c + z * s, z * c - x * s + + +@dataclass +class Motion: + """The kinematic cells of one walk and the effect of each helper on them.""" + + mem: Memory + """Where the curves are.""" + rest: float = REST + """The height over the floor the monster starts at, the one its jumps are counted from.""" + cells: dict[int, float] = field(default_factory=dict) + """The float cells by entity offset: what a walker run reads and writes.""" + cursor: float | None = None + end: float | None = None + step: float = 2.0 + yaw: int = 0 + """YAW units turned since the step began.""" + yaw0: int = 0 + """The yaw a curve walk began at.""" + handed: float = 0.0 + """The height `LIFT` added this AI frame: it belongs to the step the frame ends in.""" + lost: str = "" + """What the model could not follow: a value it does not know, a curve it cannot read.""" + events: list[Lunge | Spread] = field(default_factory=list) + """What helpers asked for, for the walk to take.""" + _curves: dict[int, Curve] = field(default_factory=dict) + + def __post_init__(self) -> None: + for base in (X, VELOCITY, ACCELERATION): + self.cells.update({base: 0.0, base + 4: 0.0, base + 8: 0.0}) + self.cells[Y] = self.rest + self.cells[GROUND] = 0.0 + + # --- a step and an AI frame --- + + def begin(self, cursor: float | None, end: float | None, step: float) -> None: + """An AI frame starts: the clip's cursor and last frame (None where none plays).""" + self.cursor, self.end, self.step, self.handed = cursor, end, step, 0.0 + + def turn_over(self) -> None: + """A step starts: the frame is the monster's now.""" + self.yaw = self.yaw0 = 0 + + def now(self) -> Pose: + c = self.cells + return Pose(c.get(X, 0.0), c.get(Y, self.rest), c.get(Z, 0.0)) + + def before_hand_over(self) -> Pose: + """Where it is without the height this frame's `LIFT` added.""" + p = self.now() + return Pose(p.x, p.y - self.handed, p.z) + + # --- the helpers --- + + def call(self, target: int, args: Args) -> float | None: + """The effect of a call to `target`, and its $f0 where it answers one.""" + if target == CURSOR: + return self.cursor + if target == CLIP_END: + return self.end + if target == SCALE: + return 1.0 + helper = _HELPERS.get(target) + return None if helper is None else helper(self, args) + + def _lose(self, why: str) -> None: + self.lost = self.lost or why + + def _axes(self, base: int) -> tuple[float, float, float] | None: + c = self.cells + x, y, z = (c.get(base + 4 * i) for i in range(3)) + if x is None or y is None or z is None: + self._lose(f"the cell 0x{base:02X} is not known") + return None + return x, y, z + + def _move(self, yaw: int | None) -> None: + """Position += velocity, turned by `yaw` YAW units (None: not turned).""" + v, p = self._axes(VELOCITY), self._axes(X) + if v is None or p is None: + return + x, y, z = v + if yaw: + x, z = _turned(x, z, yaw) + self.cells.update({X: p[0] + x, Y: p[1] + y, Z: p[2] + z}) + + def _vel_apply(self, args: Args) -> float | None: + self._move(self.yaw) + return None + + def _vel_acc(self, args: Args) -> float | None: + self._move(self.yaw) + v, a = self._axes(VELOCITY), self._axes(ACCELERATION) + if v is not None and a is not None: + for i in range(3): + self.cells[VELOCITY + 4 * i] = v[i] + a[i] + return None + + def _vel_world(self, args: Args) -> float | None: + self._move(None) + return None + + def _vel_zero(self, args: Args) -> float | None: + self.cells.update({VELOCITY: 0.0, VELOCITY + 4: 0.0, VELOCITY + 8: 0.0}) + return None + + def _vel_acc_zero(self, args: Args) -> float | None: + self._vel_zero(args) + self.cells.update({ACCELERATION: 0.0, ACCELERATION + 4: 0.0, ACCELERATION + 8: 0.0}) + return None + + def _lift(self, args: Args) -> float | None: + height = _float(args[F12]) + if height is None or Y not in self.cells: + self._lose("a height it adds is not known") + else: + self.cells[Y] += height + self.handed += height + return None + + def _rot_y(self, args: Args) -> float | None: + """The handler turns its velocity by the yaw, into world axes.""" + v = self._axes(VELOCITY) + if v is not None: + self.cells[VELOCITY], self.cells[VELOCITY + 8] = _turned(v[0], v[2], self.yaw) + return None + + def _curve(self, at: int | None) -> Curve | None: + if at is None: + self._lose("a curve is not known") + return None + if at not in self._curves: + try: + self._curves[at] = Curve.read(self.mem, at) + except (ValueError, LookupError, struct.error) as err: + self._lose(f"the curve at 0x{at:08X} cannot be read ({err})") + return None + return self._curves[at] + + def _curve_delta(self, args: Args) -> float | None: + curve, t = self._curve(args[A1]), _float(args[F12]) + if curve is None or t is None: + self._lose("a curve's cursor is not known") + return None + return curve.delta(t, self.step) + + def _curve_move_init(self, args: Args) -> float | None: + self._vel_acc_zero(args) + self.yaw0 = self.yaw + return None + + def _curve_move(self, args: Args) -> float | None: + """The x, y and z curves read at the cursor into the velocity (a curve left out leaves its + axis alone), y then zeroed, the move turned by the yaw `CURVE_MOVE_INIT` kept.""" + if self.cursor is None: + self._lose("a curve walk without a clip") + return None + if args[T0]: + self._lose("a curve walk's yaw curve is not followed") + for axis, arg in enumerate((A1, A2, A3)): + if args[arg]: + curve = self._curve(args[arg]) + if curve is None: + return None + self.cells[VELOCITY + 4 * axis] = curve.delta(self.cursor, self.step) + self.cells[VELOCITY + 4] = 0.0 + self._move(self.yaw0) + return None + + def _yaw_spread(self, args: Args) -> float | None: + total, lo, hi = args[A1], _float(args[F12]), _float(args[F13]) + if total is None or lo is None or hi is None or hi <= lo: + self._lose("a turn's total or window is not known") + return None + spread = Spread(_signed(total), (lo, hi)) + if spread not in self.events: + self.events.append(spread) + if self.cursor is None or self.end is None: + return None + if not lo < min(self.cursor + self.step, self.end) <= hi + self.step: + return None + part = self.step + min(self.cursor - lo, 0.0) # the frames of the step inside the window + part -= max(part + self.cursor - hi, 0.0) + self.yaw += int(part * (spread.total / (hi - lo))) + return None + + def _lunge(self, args: Args) -> float | None: + lo, hi, near, far = (_float(args[i]) for i in (F12, F13, F15, F16)) + v0, v1 = _float(args[F17]), _float(args[F18]) + if lo is None or hi is None or near is None or far is None or v0 is None or v1 is None: + self._lose("a lunge's window or lengths are not known") + elif far <= near: + self._lose("a lunge's distances do not grow") + else: + lunge = Lunge((lo, hi), near, far - near, v0, v1 - v0) + if lunge not in self.events: + self.events.append(lunge) + return None + + +_HELPERS: dict[int, Callable[[Motion, Args], float | None]] = { + VEL_APPLY: Motion._vel_apply, + VEL_ACC: Motion._vel_acc, + VEL_WORLD: Motion._vel_world, + VEL_ZERO: Motion._vel_zero, + VEL_ACC_ZERO: Motion._vel_acc_zero, + LIFT: Motion._lift, + ROT_Y: Motion._rot_y, + YAW_SPREAD: Motion._yaw_spread, + CURVE_DELTA: Motion._curve_delta, + LUNGE: Motion._lunge, + CURVE_MOVE: Motion._curve_move, + CURVE_MOVE_INIT: Motion._curve_move_init, +} +"""The effect of each helper on a `Motion`.""" +TARGETS = frozenset(_HELPERS) +"""The helpers the model acts for, which a walk does not descend into.""" diff --git a/packages/mhfu-port/tests/test_p3hits.py b/packages/mhfu-port/tests/test_p3hits.py index ad59598c..d971d0e4 100644 --- a/packages/mhfu-port/tests/test_p3hits.py +++ b/packages/mhfu-port/tests/test_p3hits.py @@ -10,7 +10,7 @@ from mhfu.memory import Image from mhfu.mips import Code from mhfu.overlay import Overlay -from mhfu_port import p3hits, p3moveset +from mhfu_port import p3hits, p3motion, p3moveset from mhfu_port import p3moveset as p3 from mhfu_port.manifest import Source from mhfu_port.p3hits import Hit, Hits, Length, Record, Walk, window @@ -378,6 +378,64 @@ def below(a: Asm) -> None: assert w.settle(pair(), 0.6).cells == {(GAUGE, 2): 10} +def jump(a: Asm) -> None: + """Rises 30 a frame while the cursor is under 4, then falls under a pull of 20 until its height + is 30 or less: the clip 62 plays and the pair ends.""" + a(asm.addu("a0", "s0", "zero")) + a.call(p3motion.CURSOR) + a(asm.mov_s(20, 0)) + a.load_float(1, "four") + a(asm.c_lt_s(20, 1), asm.NOP) + a.float_branch("f", "fall") + a.load_float(2, "up") + a(asm.swc1(2, 0xB4, "s0"), asm.addu("a0", "s0", "zero")) + a.call(p3motion.VEL_APPLY) + a.jump("done") + a.label("fall") + a.load_float(2, "pull") + a(asm.swc1(2, 0xC4, "s0"), asm.addu("a0", "s0", "zero")) + a.call(p3motion.VEL_ACC) + a(asm.lwc1(3, 0x84, "s0")) + a.load_float(4, "land") + a(asm.c_le_s(3, 4), asm.NOP) + a.float_branch("f", "done") + play(a, 62) + a.call(LEAVE) + + +JUMP = {"four": 4.0, "up": 30.0, "pull": -20.0, "land": 30.0} + + +def test_a_jump_lasts_until_the_height_the_motion_model_works_out(): + lengths = {61: Length(100.0), 62: Length(40.0)} + first, second = ( + walk(species(jump, lambda a: play(a, 61), JUMP), lengths, rest=0.0).plan(pair()).steps + ) + heights = [30, 60, 90, 100, 90, 60, 10] # after the AI frame at cursor 0, 2, ... 12 + assert (first.clip, first.ticks, first.ended) == (61, 7, "cursor") + assert first.lift == tuple(pytest.approx(v) for v in jump_lift(heights, 15)) + assert first.travel == () and first.lunge is None and not first.spreads + assert (second.clip, second.lift) == (62, ()) # the height it lands at is the floor's + + +def jump_lift(heights: list[int], frames: int) -> list[float]: + """Straight lines through (0, h0), (2, h1), ... held past the last.""" + out = [] + for f in range(frames): + k = min(f / 2, len(heights) - 1) + lo = int(k) + hi = min(lo + 1, len(heights) - 1) + out.append(heights[lo] + (k - lo) * (heights[hi] - heights[lo])) + return out + + +def test_a_jump_starts_from_the_rest_height_it_is_given(): + lengths = {61: Length(100.0), 62: Length(40.0)} + w = walk(species(jump, lambda a: play(a, 61), JUMP), lengths, rest=500.0) + first, _ = w.plan(pair()).steps + assert first.ticks > 7 # the landing test reads the height over the floor: a longer fall + + BRUTE, BRUTE_MODEL = 23, 5248 ZINOGRE, ZINOGRE_MODEL = 40, 5339 @@ -474,3 +532,73 @@ def test_zinogre_walks_its_attack_pairs(zinogre): assert all(p.complete for p in plans) spawned = sum(any(h for s in p.steps for h in s.hits) for p in plans) assert spawned >= len(plans) * 3 // 4 + + +@pytest.fixture(scope="module") +def plans(zinogre): + """The Zinogre's plans by pair, each walked once.""" + w, ms = zinogre + got: dict[tuple[int, int], p3hits.Plan] = {} + + def of(main: int, sub: int) -> p3hits.Plan: + if (main, sub) not in got: + got[main, sub] = w.plan(ms.pairs[main, sub]) + return got[main, sub] + + return of + + +def step_of(plan: p3hits.Plan, clip: int) -> p3hits.Step: + return next(s for s in plan.steps if s.clip == clip) + + +def test_zinogre_air_roll_lasts_22_frames_and_flies_as_the_donor_does(plans): + for pair_ in [(7, 22), (7, 112), (7, 201)]: + roll = step_of(plans(*pair_), 225) + assert roll.ticks == 22 and roll.travel[0] == (0.0, 0.0) + assert roll.travel[-1] == pytest.approx((120, 852), rel=0.01) + assert max(roll.lift) == pytest.approx(644, rel=0.01) and len(roll.lift) == 45 + assert roll.lunge == p3motion.Lunge((0.0, 32.0), 400.0, 1800.0, -700.0, 1800.0) + assert not roll.spreads + landing = step_of(plans(7, 22), 226) + assert landing.lift == () and landing.travel == () # the floor takes it from there + + +def test_zinogre_stamp_jump_lasts_18_frames_after_a_wind_up_of_16_or_39(plans): + for pair_, wind_up in [((7, 21), 16), ((7, 86), 39), ((7, 100), 39)]: + steps = plans(*pair_).steps + assert steps[0].clip == 213 and steps[0].ticks == wind_up + hop = step_of(plans(*pair_), 209) + assert hop.ticks == 18 and hop.lift[-1] == 0.0 # it lands and snaps to rest + assert hop.travel[-1] == pytest.approx((83, 1401), rel=0.01, abs=1) + assert max(hop.lift) == pytest.approx(300, rel=0.01) + + +def test_zinogre_runs_that_end_in_a_slide_travel_and_turn_half_way_round(plans): + first = step_of(plans(7, 35), 229) + assert first.travel[-1] == pytest.approx((-10, 1046), rel=0.01, abs=0.5) + assert first.spreads == (p3motion.Spread(-0x8000, (0.0, 60.0)),) + second = step_of(plans(7, 67), 230) + assert second.travel[-1] == pytest.approx((71, 1434), rel=0.01, abs=0.5) + assert len(first.travel) == 75 and len(second.travel) == 52 # every two clip frames + + +def test_zinogre_sidesteps_travel_across_their_window_and_turn_with_it(plans): + for sub, x, total in [(16, -257, -0x8000), (17, 192, 0x8000)]: + (step,) = plans(2, sub).steps + assert step.ticks == 103 and step.travel[0] == (0.0, 0.0) + assert step.travel[40 // 2 - 1] == (0.0, 0.0) and step.travel[-1] == (x, -525.0) + assert step.spreads == (p3motion.Spread(total, (40.0, 160.0)),) + + +def test_zinogre_tumbles_turn_by_the_donors_totals(plans): + assert step_of(plans(7, 66), 236).spreads == (p3motion.Spread(0x2666, (60.0, 98.0)),) + assert step_of(plans(7, 66), 236).travel[-1] == (0.0, -116.0) + roll = step_of(plans(7, 87), 248) + assert roll.spreads == (p3motion.Spread(-0x4CCD, (26.0, 64.0)),) + assert roll.travel[-1] == pytest.approx((52, 232), abs=0.5) + + +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, ()) diff --git a/packages/mhfu-port/tests/test_p3motion.py b/packages/mhfu-port/tests/test_p3motion.py new file mode 100644 index 00000000..3b8eab19 --- /dev/null +++ b/packages/mhfu-port/tests/test_p3motion.py @@ -0,0 +1,221 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 sp00ktober +import struct +from pathlib import Path + +import pytest +from mhfu.memory import Image +from mhfu_port import p3motion +from mhfu_port.p3motion import ( + ACCELERATION, + VELOCITY, + Curve, + Lunge, + Motion, + Spread, + X, + Y, + Z, +) + +BASE = 0x0010_0000 +KEYS = BASE + 0x100 + + +def f(x: float) -> int: + return struct.unpack(" Image: + """Curves back to back, each a header and its keys: the first at BASE.""" + blob = bytearray(0x400) + at, table = 0, 0x100 + for keys in curves: + struct.pack_into(" 0 and swing.at(3) > 0 # an arch + + +def test_a_curve_changes_over_the_last_step(): + c = Curve.read(image([(0.0, 0, 1.0, 1.0), (100.0, 100, 1.0, 1.0)]), BASE) + assert c.delta(10, 2) == pytest.approx(2.0) + assert c.delta(1, 2) == pytest.approx(1.0) # from the start at the earliest + assert c.delta(0, 2) == 0.0 + + +def test_a_curve_that_claims_too_many_keys_is_refused(): + blob = bytearray(0x400) + struct.pack_into(" Motion: + return Motion(Image(bytes(8), BASE), rest=0.0) + + +def at(m: Motion, off: int) -> float: + return m.cells[off] + + +def test_velocity_moves_and_then_accelerates(): + m = motion() + m.cells.update({VELOCITY + 4: 10.0, ACCELERATION + 4: -3.0}) + m.call(p3motion.VEL_ACC, ()) + assert (at(m, Y), at(m, VELOCITY + 4)) == (10.0, 7.0) + m.call(p3motion.VEL_ACC, ()) + assert (at(m, Y), at(m, VELOCITY + 4)) == (17.0, 4.0) + m.call(p3motion.VEL_APPLY, ()) + assert at(m, Y) == 21.0 + + +def test_the_yaw_turns_a_move_toward_its_left(): + m = motion() + m.cells[VELOCITY + 8] = 100.0 # forward + m.yaw = 0x4000 # a quarter turn the way YAW grows: toward +x + m.call(p3motion.VEL_APPLY, ()) + assert (at(m, X), at(m, Z)) == pytest.approx((100.0, 0.0)) + m.call(p3motion.VEL_WORLD, ()) # unturned + assert (at(m, X), at(m, Z)) == pytest.approx((100.0, 100.0)) + + +def test_a_handler_turns_its_velocity_into_world_axes(): + m = motion() + m.cells[VELOCITY + 8] = 5.0 + m.yaw = 0x8000 + m.call(p3motion.ROT_Y, ()) + assert (at(m, VELOCITY), at(m, VELOCITY + 8)) == pytest.approx((0.0, -5.0)) + + +def test_a_lift_is_the_next_steps_height(): + m = motion() + m.begin(0.0, 70.0, 2.0) + m.call(p3motion.VEL_APPLY, ()) + m.call(p3motion.LIFT, (None,) * 4 + (f(25.0),)) + assert at(m, Y) == 25.0 and m.before_hand_over().y == 0.0 + m.begin(2.0, 70.0, 2.0) + assert m.before_hand_over().y == 25.0 + + +def test_zeroing_clears_velocity_and_acceleration(): + m = motion() + m.cells.update({VELOCITY: 1.0, ACCELERATION + 8: 2.0}) + m.call(p3motion.VEL_ZERO, ()) + assert (at(m, VELOCITY), at(m, ACCELERATION + 8)) == (0.0, 2.0) + m.call(p3motion.VEL_ACC_ZERO, ()) + assert at(m, ACCELERATION + 8) == 0.0 + + +def spread_args(total: int, lo: float, hi: float) -> tuple[int | None, ...]: + return (total & 0xFFFFFFFF, None, None, None, f(lo), f(hi)) + + +def test_a_spread_turns_by_its_total_across_the_window_and_is_noted_once(): + m = motion() + args = spread_args(-1200, 4.0, 16.0) # 100 units a clip frame + for cursor in range(0, 20, 2): + m.begin(float(cursor), 70.0, 2.0) + m.call(p3motion.YAW_SPREAD, args) + assert m.yaw == -1200 + assert m.events == [Spread(-1200, (4.0, 16.0))] + assert Spread(-16384, (0, 1)).degrees == -90.0 + + +def test_a_spread_outside_its_window_turns_nothing(): + m = motion() + m.begin(30.0, 70.0, 2.0) + m.call(p3motion.YAW_SPREAD, spread_args(1000, 4.0, 16.0)) + assert m.yaw == 0 + + +def test_a_curve_walk_moves_by_the_curve_in_the_yaw_it_began_in(): + img = image( + [(0.0, 0, 1.0, 1.0), (100.0, 100, 1.0, 1.0)], + [(0.0, 0, 3.0, 3.0), (300.0, 100, 3.0, 3.0)], + ) + m = Motion(img, rest=0.0) + m.yaw = 0x4000 + m.call(p3motion.CURVE_MOVE_INIT, ()) + m.yaw = 0 # the curve walk keeps the yaw it began at + m.begin(10.0, 70.0, 2.0) + m.call(p3motion.CURVE_MOVE, (BASE, 0, BASE + 8, 0, None, None, 0)) + assert at(m, VELOCITY + 4) == 0.0 + # x 2 and z 6 turned a quarter toward +x + assert (at(m, X), at(m, Z)) == pytest.approx((6.0, -2.0)) + + +def test_a_curve_delta_call_answers_the_curves_step(): + img = image([(0.0, 0, 1.0, 1.0), (100.0, 100, 1.0, 1.0)]) + m = Motion(img, rest=0.0) + m.begin(10.0, 70.0, 2.0) + assert m.call(p3motion.CURVE_DELTA, (BASE, None, None, None, f(10.0))) == pytest.approx(2.0) + + +def lunge_args(lo: float, hi: float, near: float, far: float, v0: float, v1: float): + return (None, None, None, None, f(lo), f(hi), None, None, f(near), f(far), f(v0), f(v1)) + + +def test_a_lunge_is_taken_down_as_its_constants(): + m = motion() + m.call(p3motion.LUNGE, lunge_args(0, 32, 400, 2200, -700, 1100)) + assert m.events == [Lunge((0.0, 32.0), 400.0, 1800.0, -700.0, 1800.0)] + + +def test_what_the_model_cannot_follow_is_said(): + m = motion() + m.call(p3motion.LUNGE, lunge_args(0, 32, 400, 2200, -700, 1100)[:11] + (None,)) + assert "lunge" in m.lost + n = motion() + del n.cells[VELOCITY] + n.call(p3motion.VEL_APPLY, ()) + assert "0xB0" in n.lost + k = motion() + k.call(p3motion.CURVE_DELTA, (BASE + 0x300, None, None, None, f(1.0))) + assert "curve" in k.lost + + +def test_clip_reads_answer_the_cursor_and_the_clips_end(): + m = motion() + m.begin(12.0, 70.0, 2.0) + assert [m.call(t, ()) for t in (p3motion.CURSOR, p3motion.CLIP_END, p3motion.SCALE)] == [ + 12.0, + 70.0, + 1.0, + ] + m.begin(None, None, 2.0) + assert m.call(p3motion.CURSOR, ()) is None + + +def test_the_zinogres_curves_give_the_numbers_the_donor_walks(mhp3rd_data: Path): + """The somersault's three curves, read from the overlay, at the cursors the research took + them at (units a frame at a step of 2): the first rows of its table.""" + from mhfu.files import Extracted + from mhfu_port.brain.p3 import Species + + ex = Extracted.find(mhp3rd_data) + found = Species.read(ex).slots[40][0] + ovl = ex.overlay(found.file) + x, y, z = (Curve.read(ovl, a) for a in (0x09DD9850, 0x09DD96E8, 0x09DD9580)) # noaddr: curves + got = {t: (x.delta(t, 2), y.delta(t, 2), z.delta(t, 2)) for t in (2.0, 30.0, 38.0)} + assert got[2.0] == pytest.approx((-7.6, 14.7, 27.1), abs=0.1) + assert got[30.0] == pytest.approx((10.9, -31.2, 128.3), abs=0.1) + assert got[38.0] == pytest.approx((16.3, -187.8, 31.8), abs=0.1) From 5d77b0bafab3802d4192c91705256c3a9b3253fd Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:27:02 +0200 Subject: [PATCH 3/5] feat(port): own moves carry the donor's travel, lunge, fixed turns and the height of its jumps A derived move takes its step's travel, its lunge (the window cut to the frames the move plays, its total by the share left) and a fixed turn across its window. A step with height plays a one-shot copy of its clip cut to the frames it plays, Clip.lift, pinned in a free executor entry (layout.spare) beside its source; travel.raised bakes the lift onto joint 0 frame by frame. The CLI builds the manifest as own.expanded leaves it, so the copies are clips of the build. Co-Authored-By: Claude Sonnet 5.5 --- .../src/mhfu_studio/monster/move_game.py | 2 + docs/formats/p3rd_ai_script.md | 20 ++- packages/mhfu-port/src/mhfu_port/brain/own.py | 160 ++++++++++++++++-- packages/mhfu-port/src/mhfu_port/build.py | 8 +- packages/mhfu-port/src/mhfu_port/cli/build.py | 13 +- packages/mhfu-port/src/mhfu_port/cli/own.py | 13 +- packages/mhfu-port/src/mhfu_port/layout.py | 57 ++++++- packages/mhfu-port/src/mhfu_port/manifest.py | 23 ++- packages/mhfu-port/src/mhfu_port/travel.py | 51 +++++- packages/mhfu-port/tests/test_layout.py | 47 +++++ packages/mhfu-port/tests/test_manifest.py | 45 +++++ packages/mhfu-port/tests/test_own.py | 131 +++++++++++++- packages/mhfu-port/tests/test_own_cli.py | 13 ++ packages/mhfu-port/tests/test_travel.py | 34 ++++ .../mhfu-port/tests/test_zinogre_brain.py | 71 +++++++- ports/README.md | 5 +- 16 files changed, 644 insertions(+), 49 deletions(-) diff --git a/apps/studio/src/mhfu_studio/monster/move_game.py b/apps/studio/src/mhfu_studio/monster/move_game.py index 9c58734d..507bae1b 100644 --- a/apps/studio/src/mhfu_studio/monster/move_game.py +++ b/apps/studio/src/mhfu_studio/monster/move_game.py @@ -25,8 +25,10 @@ def deploy(m: Manifest, games: Data, mods: Path) -> tuple[list[Path], int]: """`m` built as it is, its modules written into `mods/lib` where they changed, and `mhfu_port.lua` kept in step: what was written, and the build of the moves module now on the stick, the one the port must run. The game re-runs a changed module.""" + from mhfu_port.brain import own from mhfu_port.cli.build import modules # the writer `mhfu-port inject` uses + m = own.expanded(m, games) # the lifted copies of its jumps are clips of the build out = modules(m, build.build(m, games), mods / layout.LIB, games) lib = runtime.sync_library(mods, runtime.library()) text = (mods / layout.LIB / moves.module_name(m)).read_text(encoding="utf-8") diff --git a/docs/formats/p3rd_ai_script.md b/docs/formats/p3rd_ai_script.md index 818627b6..15f7edb8 100644 --- a/docs/formats/p3rd_ai_script.md +++ b/docs/formats/p3rd_ai_script.md @@ -188,7 +188,22 @@ clip that hands to the next by `after`: "26" = 40` names the host record for donor record 26; the port may define it with `[[attack]]` and `[[hitbox]]`. - **Turn.** A `hunter` steer with the donor's rate and the cursor window it turned in - (`steer.window`). What the donor does by code, not by its clips (a slide, a curve), is not carried. + (`steer.window`); where the handler spreads a total over a window (`yaw_spread`) a `fixed` one + with that total as `angle` and the window, which wins over the other. +- **Motion by code.** The walk keeps the cells a handler moves the monster by (position, velocity, + acceleration, the floor: `mhfu_port.p3motion`), which the handler's float loads and stores and + its helpers (`VEL_APPLY`, `VEL_ACC`, `VEL_WORLD`, `CURVE_DELTA`, `curve_move`, `LIFT`, ...) act + on, so the tests of the clip cursor and of the height come out as in the donor (an air roll + lasts 22 AI frames, the stamp's hop 18, its wind-up 16 or 39). Each step's motion, in the + monster's frame at the step's first frame, becomes `Move.travel` (cumulative x, z every 2 clip + frames, the first (0, 0): what the first frames did shows from the second), `Move.lunge` (the + handler's window and distances), and, for a jump, `Clip.lift`: a one-shot copy of the clip cut to + the frames the step plays, named `p3__air`, in a free executor entry (`layout.spare`), + whose joint 0 `travel.raised` gives the height over rest at each frame. The engine puts the + monster's Y back on the floor every frame, so a height lives only in the pose. Windows count + from the move's first clip frame and are cut to the frames it plays, the total by the share + left. The rest height is `p3motion.REST`, 298, the value the donor's stamp snaps to; the + landing tests of a jump depend on it. | Finding | Level | What | |---|---|---| @@ -196,7 +211,8 @@ clip that hands to the next by `after`: | `P3_OWN_ATTACK` | warning | a donor record with no host record and no spare, or more attacks on a clip than the move player holds: left out | | `P3_OWN_SPARE` | info | a donor record with no host record took a spare one: its id, set, volumes, power, kind, active frames | | `P3_OWN_PARTIAL` | warning | the walk gave up, or a clip it could not resolve | -| `P3_OWN_BRANCH`, `P3_OWN_MOTION` | info | a branch taken one way; code movement | +| `P3_OWN_BRANCH`, `P3_OWN_MOTION` | info | a branch taken one way; code movement the walk could not follow, or a window cut or left out | +| `P3_OWN_AIR` | info, error | a jump's lifted copy of its clip and its entry (an error where no entry is free), or its height left out | | `P3_OWN_NEXT` | info | a hand-over not followed, and why | | `P3_OWN_SPLIT`, `P3_OWN_GROUP`, `P3_OWN_ACTIVE` | info | a node split per clip; records that land one hit between them in the donor; a window longer than the host record's life | diff --git a/packages/mhfu-port/src/mhfu_port/brain/own.py b/packages/mhfu-port/src/mhfu_port/brain/own.py index f9d7c454..1823ebf4 100644 --- a/packages/mhfu-port/src/mhfu_port/brain/own.py +++ b/packages/mhfu-port/src/mhfu_port/brain/own.py @@ -3,7 +3,8 @@ """The own moves a manifest's `[brain.pairs] "7,13" = "own"` stands for, derived at each build from both games' files: the clips the donor's handler plays, from the clip frame it starts them at to the one it ends them at, the attacks it spawns with their frames, matched to the host -species' attack records, and how it turns. +species' attack records, how it turns, and the motion it makes by code (`Move.travel`, +`Move.lunge`, a fixed turn, and a jump's height as a lifted copy of the clip in a free entry). m, findings = own.expand(manifest, data) # the manifest with `p3_7_13` and its clips @@ -33,9 +34,10 @@ from mhfu import hitbox, hitzone from mhfu.em.script.check import Finding from mhfu.files import em_overlay +from mhp_formats import fu from numpy.typing import NDArray -from .. import build, fk, layout, moves, p3hits, p3moveset, sequence +from .. import build, fk, layout, moves, p3hits, p3motion, p3moveset, sequence from ..data import Data from ..manifest import ( MOVE_ATTACKS, @@ -43,6 +45,7 @@ AttackWindow, Brain, Clip, + Lunge, Manifest, ManifestError, Move, @@ -51,6 +54,7 @@ pair_of, ) from ..p3hits import Plan, Step, Walk +from ..travel import KEY_STEP from .spare import HOST_NO_DAMAGE, NO_DAMAGE, POWER_SCALE, Spare, Spares Pair = tuple[int, int] @@ -217,8 +221,9 @@ def __init__( matcher: Callable[[], Matcher], say: Report, pairs: Sequence[Pair] = (), + spare: Callable[[], list[int]] = list, ) -> None: - self.m, self.walk, self.matcher, self.say = m, walk, matcher, say + self.m, self.walk, self.matcher, self.say, self.spare = m, walk, matcher, say, spare self.pairs = list(pairs) """The pairs being derived; the pairs they hand over to are derived too, and with them feed the spare records' power scale, which reads all their matches.""" @@ -407,19 +412,109 @@ def _move(self, step: Step, after: str | None) -> Move: stop = round(step.stop) if step.stop is not None else 0 stop = stop if start < stop <= frames else 0 cut = length.loop or step.ended != "clip" + air = self._air(step, start, frames) + # a lifted copy is the source cut at `start`: the move counts its frames from there + off, size = (start, len(step.lift) - 1) if air else (0, frames) + played = (start - off, stop - off if stop else size) # the clip frames it plays return Move( - clip=self._clip(step.clip), - attacks=self._attacks(step, start, stop or frames), - steer=self._steer(step, walls=length.loop), + clip=air or self._clip(step.clip), + attacks=self._attacks(step, start, stop or frames, off), + steer=self._steer(step, length.loop, off, played), length=max(1, step.ticks) if cut and not stop else None, - start=start, - stop=stop, + start=start - off, + stop=stop - off if stop else 0, + travel=self._travel(step, size, start - off), + lunge=self._lunge(step, off, played), after=after, label=f"MHP3rd {self.pair[0]},{self.pair[1]}", ) - def _attacks(self, step: Step, start: int, until: int) -> list[AttackWindow]: - """The step's attack windows inside `[start, until]`.""" + def _air(self, step: Step, start: int, frames: int) -> str | None: + """The name of the one-shot copy of the step's clip cut to the frames it plays, which + carries its height over rest (`Clip.lift`) in an entry of its own; None where it has no + height, or the copy cannot be made.""" + if not step.lift: + return None + n = len(step.lift) - 1 + if start + n > frames: + self.find( + "info", + "P3_OWN_AIR", + f"clip {step.clip} is {frames} frames and the step plays {n} from {start}: its " + "height is left out", + ) + return None + lift = list(step.lift) + for name, c in self.m.clips.items(): + if (c.source, c.start, c.frames, c.lift) == (step.clip, start, n, lift): + return name + taken = {c.slot for c in self.m.clips.values() if c.lift is not None} + free = [e for e in self.spare() if e not in taken] + if not free: + self.find( + "error", + "P3_OWN_AIR", + f"no free executor entry for the lifted copy of clip {step.clip}", + ) + return None + name = f"p3_{step.clip}_air" + while name in self.m.clips: + name += "_" + self.m.clips[name] = Clip(slot=free[0], source=step.clip, start=start, frames=n, lift=lift) + peak = max(lift, key=abs) + self.find( + "info", + "P3_OWN_AIR", + f"clip {step.clip} frames {start} to {start + n} carry a height of {peak:+.0f} over " + f"rest, as clips.{name} in entry {free[0]}", + ) + return name + + def _travel(self, step: Step, frames: int, start: int) -> list[tuple[float, float]] | None: + """The step's travel by code as samples from the move's `start`, those past the clip's + `frames` and a held tail left off.""" + found = list(step.travel) + room = (frames - start) // KEY_STEP + 1 + if len(found) > room: + self.find( + "info", "P3_OWN_MOTION", f"its travel outruns its clip: kept to {room} samples" + ) + found = found[:room] + while len(found) > 2 and found[-1] == found[-2]: + found.pop() + return found or None + + def _inside( + self, what: str, window: tuple[float, float], off: int, played: tuple[int, int] + ) -> tuple[int, int, float] | None: + """`window` counted from clip frame `off`, cut to the frames `played`, and the share of + it that is left; None, said, where none is.""" + lo, hi = round(window[0]) - off, round(window[1]) - off + a, b = max(lo, played[0]), min(hi, played[1]) + if hi <= lo or b <= a: + self.find( + "info", "P3_OWN_MOTION", f"{what} falls outside the frames it plays: left out" + ) + return None + if (a, b) != (lo, hi): + share = (b - a) / (hi - lo) + self.find( + "info", "P3_OWN_MOTION", f"{what} is cut to the frames it plays ({share:.0%})" + ) + return a, b, (b - a) / (hi - lo) + + def _lunge(self, step: Step, off: int, played: tuple[int, int]) -> Lunge | None: + """The step's lunge, its window counted from the move's first clip frame and cut to the + frames the move plays, its length by the share of it that is left.""" + got = step.lunge + inside = None if got is None else self._inside("a lunge", got.window, off, played) + if got is None or inside is None: + return None + lo, hi, share = inside + return Lunge((lo, hi), got.near, got.span, got.base * share, got.range * share) + + def _attacks(self, step: Step, start: int, until: int, off: int = 0) -> list[AttackWindow]: + """The step's attack windows inside `[start, until]`, counted from clip frame `off`.""" out: list[AttackWindow] = [] for h in sorted(step.hits, key=lambda h: (h.frame, h.slot)): frame = max(start, round(h.frame)) @@ -443,7 +538,7 @@ def _attacks(self, step: Step, start: int, until: int) -> list[AttackWindow]: "P3_OWN_ACTIVE", f"record {h.record} is active {end - frame} frames, host record {host} {life}", ) - out.append(AttackWindow(host, frame, end, label=f"donor {h.record}")) + out.append(AttackWindow(host, frame - off, end - off, label=f"donor {h.record}")) opens = [h for h in step.hits if h.flags & p3hits.FLAG_OPENS] joins = [h for h in step.hits if h.flags & p3hits.FLAG_JOINS] if opens and joins: @@ -501,9 +596,15 @@ def _scale(self) -> float: ratios.append(host.power / donor.power) return statistics.median(ratios) if ratios else POWER_SCALE - def _steer(self, step: Step, walls: bool) -> Steer: - """The donor's turn; a wall ends only a step on a looping clip, a run (the donor ends no - move at a wall: a one-shot stop or blow plays out where it stands).""" + def _steer( + self, step: Step, walls: bool, off: int = 0, played: tuple[int, int] = (0, 0xFFFF) + ) -> Steer: + """The donor's turn, its windows counted from clip frame `off`: a fixed one where the + handler spreads a total over a window, else the longest toward the hunter. A wall ends + only a step on a looping clip, a run (the donor ends no move at a wall: a one-shot stop or + blow plays out where it stands).""" + if step.spreads: + return self._spread(step, walls, off, played) if not step.turns: return Steer(walls=walls) turn = max(step.turns, key=lambda t: t.window[1] - t.window[0]) @@ -517,10 +618,28 @@ def _steer(self, step: Step, walls: bool) -> Steer: if rate is not None and not 1 <= rate <= MAX_RATE: self.find("info", "P3_OWN_MOTION", f"turn rate {rate} is out of range: the host's") rate = None - lo = round(turn.window[0]) - window = (lo, max(lo + 1, round(turn.window[1]))) + lo = max(round(turn.window[0]) - off, 0) + window = (lo, max(lo + 1, round(turn.window[1]) - off)) return Steer(turn="hunter", rate=rate, walls=walls, window=window) + def _spread(self, step: Step, walls: bool, off: int, played: tuple[int, int]) -> Steer: + """The turn of a fixed total across a window: the longest of the step's, if it has more + than one.""" + spread = max(step.spreads, key=lambda s: s.window[1] - s.window[0]) + if len(step.spreads) > 1: + self.find("info", "P3_OWN_MOTION", f"{len(step.spreads)} fixed turns: kept the longest") + if step.turns: + self.find( + "info", "P3_OWN_MOTION", "a fixed turn and a turn toward the hunter: kept the fixed" + ) + inside = self._inside("a fixed turn", spread.window, off, played) + if inside is None or not -p3motion.TURN < spread.total < p3motion.TURN: + if inside is not None: + self.find("info", "P3_OWN_MOTION", f"a turn of {spread.total} units is left out") + return Steer(walls=walls) + lo, hi, share = inside + return Steer(turn="fixed", angle=spread.degrees * share, walls=walls, window=(lo, hi)) + def pairs(b: Brain) -> list[Pair]: """The donor pairs whose own move the build derives, in order.""" @@ -601,7 +720,14 @@ def matcher() -> Matcher: cache.append(Matcher(m, data, walk.hits)) return cache[0] - derive = Derivation(out, walk, matcher, found.append, wanted) + hosted: list[fu.Anim] = [] + + def spare() -> list[int]: + if not hosted: + hosted.append(build.host(m, data).anim) + return layout.spare(out.clips, walk.lengths, hosted[0], m.port.host_species) + + derive = Derivation(out, walk, matcher, found.append, wanted, spare) for pair in wanted: derive.run(pair) derive.finish() diff --git a/packages/mhfu-port/src/mhfu_port/build.py b/packages/mhfu-port/src/mhfu_port/build.py index 31c22725..51a1ef96 100644 --- a/packages/mhfu-port/src/mhfu_port/build.py +++ b/packages/mhfu-port/src/mhfu_port/build.py @@ -233,9 +233,9 @@ def animation( lift: float = 0.0, ) -> fu.Anim: """The donor's moveset on the rig's joints in the entries `placed` gives it, cut where it - cuts, its pelvis raised by `lift` world units, and on its own rig its travel and turns where - the engine takes them (`travel.carry`). Entries it leaves keep the host's clips on the - host's rig.""" + cuts, its pelvis raised by `lift` world units, the entries `placed` lifts raised frame by frame + (`travel.raised`), and on its own rig its travel and turns where the engine takes them + (`travel.carry`). Entries it leaves keep the host's clips on the host's rig.""" clips: Mapping[int, Clip] = d.clips if lift: bone_of_record = {r: bone for bone, r in record_of.items()} @@ -248,6 +248,8 @@ def animation( keep = bind.mode == "retarget" played = layouts.clips(placed, clips) out = motion.build(played, {e: e for e in played}, h.anim, bind.rig.streams, track_of, keep) + if placed.lifts: + out = travel.raised(out, bind.rig.skeleton, placed.lifts) return out if keep else travel.carry(out, bind.rig.skeleton) diff --git a/packages/mhfu-port/src/mhfu_port/cli/build.py b/packages/mhfu-port/src/mhfu_port/cli/build.py index ae44e04d..7c22eb2b 100644 --- a/packages/mhfu-port/src/mhfu_port/cli/build.py +++ b/packages/mhfu-port/src/mhfu_port/cli/build.py @@ -38,8 +38,8 @@ def register(sub: Subparsers) -> None: def run_build(args: argparse.Namespace) -> int: - m = manifest.load(args.manifest) games = data.from_arguments(args) + m = own.expanded(manifest.load(args.manifest), games, say) # its jumps' lifted clips are built built = build(m, games) out: Path = args.out or Path(m.port.pac) written = modules(m, built, out.parent, games, say) @@ -49,8 +49,8 @@ def run_build(args: argparse.Namespace) -> int: def run_inject(args: argparse.Namespace) -> int: - m = manifest.load(args.manifest) games = data.from_arguments(args) + m = own.expanded(manifest.load(args.manifest), games, say) built = build(m, games) lib = args.lib or inject.memstick() / inject.MODS_SUBDIR / layout.LIB written = modules(m, built, lib, games, say) @@ -74,10 +74,11 @@ def modules( report: own.Report | None = None, ) -> list[Path]: """Write the port's clips, turns and moves modules into `where`, each whole and only when it - changed, since the game re-runs a module that changed on disk; those written. The moves - module carries the own moves `[brain.pairs]` derive (`own.expand`) and, with `[brain] fatigue` - and `charge`, the stamina and charge tables (`fatigue.table`, `charge.table`), each finding of - which goes to `report`. Raises + changed, since the game re-runs a module that changed on disk; those written. `built` is of + `m` as `own.expanded` leaves it: a build of the manifest before it lacks the lifted copies of + jumps the moves play. The moves module carries the own moves `[brain.pairs]` derive (a no-op + on an expanded `m`) and, with `[brain] fatigue` and `charge`, the stamina and charge tables + (`fatigue.table`, `charge.table`), each finding of which goes to `report`. Raises `ManifestError` for a move the moves module cannot carry, before any is written.""" m = own.expanded(m, games, report) stamina = fatigue.expanded(m, games, report) diff --git a/packages/mhfu-port/src/mhfu_port/cli/own.py b/packages/mhfu-port/src/mhfu_port/cli/own.py index 0934c8d9..8a634c12 100644 --- a/packages/mhfu-port/src/mhfu_port/cli/own.py +++ b/packages/mhfu-port/src/mhfu_port/cli/own.py @@ -68,8 +68,8 @@ def run(args: argparse.Namespace) -> int: def describe(m: manifest.Manifest, name: str, slot: int | None, shared: str | None = None) -> str: - """One derived move: its slot, clip, frames, length and steer, the pair it comes from, then its - attacks. `shared` is the head of an earlier chain that already holds it.""" + """One derived move: its slot, clip, frames, length, steer and motion by code, the pair it comes + from, then its attacks. `shared` is the head of an earlier chain that already holds it.""" mv = m.moves[name] clip = m.clips[mv.clip] if mv.clip is not None else None head = f" {name} (slot {slot}) clip {clip.id if clip else mv.anim} {mv.clip}" @@ -81,6 +81,15 @@ def describe(m: manifest.Manifest, name: str, slot: int | None, shared: str | No if s.turn == "hunter": window = "" if s.window is None else f" in frames {s.window[0]}-{s.window[1]}" head += f" turns to the hunter at {s.rate or 'the host charge'}{window}" + if s.turn == "fixed": + head += f" turns {s.angle:g} degrees" + (f" in frames {s.window}" if s.window else "") + if mv.travel: + x, z = mv.travel[-1] + head += f" travels {x:g}, {z:g} over {len(mv.travel)} samples" + if mv.lunge is not None: + head += f" lunges {mv.lunge.base:g} to {mv.lunge.base + mv.lunge.range:g}" + if clip is not None and clip.lift is not None: + head += f" rises {max(clip.lift, key=abs):+g}" if mv.label: head += f" ({mv.label})" if shared is not None: diff --git a/packages/mhfu-port/src/mhfu_port/layout.py b/packages/mhfu-port/src/mhfu_port/layout.py index d0e636b4..b2aeb2cd 100644 --- a/packages/mhfu-port/src/mhfu_port/layout.py +++ b/packages/mhfu-port/src/mhfu_port/layout.py @@ -11,8 +11,10 @@ in that entry; the clip the packer had there takes the entry the pin freed, so a pin moves only the clips it touches. A manifest clip without one only names its clip. One with a `start` pins only those frames of its source, so a source can fill several entries; its pins free its packer -entry once, and the other clips they displace go to free entries. What fills the entries left -over is `motion.build`'s. +entry once, and the other clips they displace go to free entries. A clip with a `lift` is a copy +of its source for a jump (`brain.own` makes them): it takes the entry its `slot` names and displaces +nothing, its source keeping its own; `spare` lists the entries that can take one. What fills the +entries left over is `motion.build`'s. """ from __future__ import annotations @@ -58,6 +60,8 @@ class Layout: """Entry -> its clip's frames; the build sets it.""" cuts: dict[int, tuple[int, int]] = field(default_factory=dict) """Entry -> `(start, frames)` of the source it holds; an entry not here holds all of it.""" + lifts: dict[int, tuple[float, ...]] = field(default_factory=dict) + """Entry -> the height over rest its cut's joint 0 gains at each of its clip frames.""" ids: dict[int, int] = field(init=False, repr=False, compare=False) """MHP3rd clip id -> executor entry; a cut source's first cut.""" @@ -116,6 +120,7 @@ def plan( part = partial(host, cap) pins: dict[int, int] = {} cuts: dict[int, tuple[int, int]] = {} + copies: dict[int, tuple[str, manifest.Clip]] = {} for name, c in sorted(clips.items()): where = f"clips.{name}" if c.id not in ids: @@ -126,6 +131,9 @@ def plan( raise LayoutError(f"{where}: entry {c.slot} is past the host's {cap}") if c.slot in part: raise LayoutError(f"{where}: the host plays entry {c.slot} on some body parts only") + if c.lift is not None: + copies[c.slot] = name, c + continue pins[c.slot] = c.id if c.cut is not None: cuts[c.slot] = c.cut @@ -135,17 +143,20 @@ def plan( pinned = set(pins.values()) entries = dict(pins) entries.update((e, cid) for e, cid in packed.items() if e not in pins and cid not in pinned) + for e, (name, _) in copies.items(): + if e in entries: + raise LayoutError(f"clips.{name}: entry {e} holds clip {entries[e]}") displaced = [] for e in sorted(pins): cid = packed.get(e) if cid is None or cid in pinned: continue freed = _freed(e, pins, home) - if freed is None or freed in entries: + if freed is None or freed in entries or freed in copies: displaced.append(cid) else: entries[freed] = cid - free = iter(_free(entries.keys() | part, cap, asked)) + free = iter(_free(entries.keys() | part | copies.keys(), cap, asked)) unplaced: list[int] = [] for cid in [*displaced, *(c for c in left if c not in pinned)]: to = next(free, None) @@ -153,8 +164,37 @@ def plan( unplaced.append(cid) else: entries[to] = cid + lifts = {} + for e, (_, c) in copies.items(): + entries[e] = c.id + if c.cut is not None: + cuts[e] = c.cut + assert c.lift is not None + lifts[e] = tuple(c.lift) placed = dict(sorted(entries.items())) - return Layout(placed, frozenset(pins), cap, part, tuple(unplaced), cuts=cuts) + return Layout( + placed, + frozenset([*pins, *copies]), + cap, + part, + tuple(unplaced), + cuts=cuts, + lifts=lifts, + ) + + +def spare( + clips: Mapping[str, manifest.Clip], ids: Collection[int], host: AnimPack, species: int +) -> list[int]: + """The entries `clips` leave free for a lifted copy to take, those the host does not fill + first (its own brain asks for those): the layout of `clips` without their copies, less every + entry it holds.""" + kept = {n: c for n, c in clips.items() if c.lift is None} + held = plan(kept, ids, host, species) + asked = set(filled(host)) + taken = held.entries.keys() | held.partial + free = [e for e in range(held.capacity) if e not in taken] + return sorted(free, key=lambda e: (e in asked, e)) def _free(taken: Collection[int], cap: int, asked: Collection[int]) -> list[int]: @@ -196,7 +236,8 @@ def pinned(m: manifest.Manifest) -> Layout: """The manifest's pins alone: all that is known of a layout without the donor and host.""" pins = {c.slot: c for c in m.clips.values() if c.slot is not None} cuts = {e: c.cut for e, c in pins.items() if c.cut is not None} - return Layout({e: c.id for e, c in pins.items()}, cuts=cuts) + lifts = {e: tuple(c.lift) for e, c in pins.items() if c.lift is not None} + return Layout({e: c.id for e, c in pins.items()}, cuts=cuts, lifts=lifts) def where(c: manifest.Clip, ids: Mapping[int, int]) -> int | None: @@ -223,8 +264,8 @@ def clips(placed: Layout, donor: Mapping[int, Clip]) -> dict[int, Clip]: def holder(m: manifest.Manifest, cid: int) -> str | None: - """The name of the manifest clip that names donor clip `cid`.""" - return next((n for n, c in m.clips.items() if c.id == cid), None) + """The name of the manifest clip that names donor clip `cid`, a lifted copy of it apart.""" + return next((n for n, c in m.clips.items() if c.id == cid and c.lift is None), None) def name_clip(m: manifest.Manifest, name: str, cid: int) -> None: diff --git a/packages/mhfu-port/src/mhfu_port/manifest.py b/packages/mhfu-port/src/mhfu_port/manifest.py index 72fed6b1..d93a9c00 100644 --- a/packages/mhfu-port/src/mhfu_port/manifest.py +++ b/packages/mhfu-port/src/mhfu_port/manifest.py @@ -61,6 +61,8 @@ """Own moves the framework holds per port.""" MAX_FRAME = 0xFFFF """The clip frame a move's `start` or `stop` can name: the executor stores it in 16 bits.""" +LIFT_RANGE = 2048 +"""World units a clip's lift stays under: a joint's location is a 16-bit count of sixteenths.""" ROWS = range(hitzone.MAX_ROW + 1) BYTE = range(0x100) JOINER, NODE_CAPSULE, NODE_SPHERE = hitzone.MARKER_BONES @@ -754,6 +756,12 @@ def _validate(m: Manifest) -> None: _need(c.slot is not None and c.frames is not None, w, "start needs slot and frames") _need(c.start >= 0, w, "start is 0 or more") _need(c.frames is not None and c.frames >= 1, w, "frames is at least 1") + if c.lift is not None: + _need(c.start is not None, w, "lift goes with start: it is a copy of a cut") + n = None if c.frames is None else c.frames + 1 + _need(len(c.lift) == n, w, "lift has one value per clip frame, 0 to frames") + _need(all(abs(v) < LIFT_RANGE for v in c.lift), w, f"lift is within {LIFT_RANGE} units") + continue # a copy beside its source: it does not place the source held = sources.get(cid) cuts = held is not None and c.start is not None and m.clips[held].start is not None _need(held is None or cuts, w, f"clip {cid} is placed by clips.{held} too") @@ -771,11 +779,22 @@ def _validate(m: Manifest) -> None: _own_move(mv, w) else: plain = Move(mv.main, mv.sub) - fields = ("attacks", "steer", "length", "start", "stop", "carrier", "host_attacks") + fields = ( + "attacks", + "steer", + "length", + "start", + "stop", + "travel", + "lunge", + "carrier", + "host_attacks", + ) _need( all(getattr(mv, k) == getattr(plain, k) for k in fields), w, - "attack, steer, length, start, stop, carrier and host_attacks are for an own move", + "attack, steer, length, start, stop, travel, lunge, carrier and host_attacks are " + "for an own move", ) if mv.claim is not None: _need(bool(mv.claim.mains), w, "claim needs a main") diff --git a/packages/mhfu-port/src/mhfu_port/travel.py b/packages/mhfu-port/src/mhfu_port/travel.py index efe23c7d..1797b121 100644 --- a/packages/mhfu-port/src/mhfu_port/travel.py +++ b/packages/mhfu-port/src/mhfu_port/travel.py @@ -15,14 +15,23 @@ from __future__ import annotations import math -from collections.abc import Callable, Iterable, Mapping +from collections.abc import Callable, Iterable, Mapping, Sequence from dataclasses import dataclass import numpy as np from mhfu import addresses from mhfu.entries import PART_STREAMS from mhp_formats import fu -from mhp_formats.anim import CHANNEL_BITS, Channel, Clip, Keyframe, Kind, Track, dequantize +from mhp_formats.anim import ( + CHANNEL_BITS, + Channel, + Clip, + Keyframe, + Kind, + Track, + dequantize, + quantize, +) from mhp_formats.skeleton import Skeleton from numpy.typing import ArrayLike @@ -163,6 +172,44 @@ def carry(anim: fu.Anim, skeleton: Skeleton) -> fu.Anim: return _each(swapped, streams, lambda c: turned.get(id(c), c)) +def raised(anim: fu.Anim, skeleton: Skeleton, lifts: Mapping[int, Sequence[float]]) -> fu.Anim: + """`anim` with joint 0 of each entry in `lifts` raised by its height over rest, one value per + clip frame from 0: the engine puts the monster's Y back on the floor every frame, and draws + joint 0's height, so a jump lives only there. Keyed on every frame, added to the height + joint 0 already carries; the entry's clip is its own, so one that shared a stored clip stops + sharing it. Raises ValueError for an entry joint 0 has no clip in, or one a lift outruns.""" + part = skeleton.bones[0].stream + t0 = fk.part_joints([b.stream for b in skeleton.bones])[part].index(0) + out = [list(s) for s in anim.streams] + for e, lift in lifts.items(): + si, slot = fk.entry_slot(part, e) + clip = out[si][slot] if si < len(out) and slot < len(out[si]) else None + if clip is None or t0 >= len(clip.tracks): + raise ValueError(f"entry {e}: joint 0 has no clip to raise") + if len(lift) != motion.frames(clip) + 1: + raise ValueError(f"entry {e}: {len(lift)} lift values for {motion.frames(clip)} frames") + base = clip.tracks[t0] + y = _sum(_locs(base).get(Y), _lift_channel(lift)) + tracks = list(clip.tracks) + tracks[t0] = _track(base, {**_locs(base), Y: y}, motion.frames(clip)) + out[si][slot] = Clip(tracks, clip.loop, clip.loop_start) + return fu.Anim(out, anim.tail) + + +def _lift_channel(lift: Sequence[float]) -> Channel: + """A location-Y channel through `lift` at every frame, each key's slope the change across it + per frame.""" + raw = [quantize("loc", v) for v in lift] + n = len(raw) + keys = [] + for f in range(n): + slope = (raw[min(f + 1, n - 1)] - raw[max(f - 1, 0)]) / max( + min(f + 1, n - 1) - max(f - 1, 0), 1 + ) + keys.append(Keyframe(s16(raw[f]), f, s16(slope), s16(slope))) + return Channel(_LOC[Y], keys) + + def _each(anim: fu.Anim, streams: Iterable[int], fn: Callable[[Clip], Clip]) -> fu.Anim: """`anim` with `fn` of each clip in `streams`, once per stored clip, so shared stay shared.""" done: dict[int, Clip] = {} diff --git a/packages/mhfu-port/tests/test_layout.py b/packages/mhfu-port/tests/test_layout.py index a7450de1..33de74ad 100644 --- a/packages/mhfu-port/tests/test_layout.py +++ b/packages/mhfu-port/tests/test_layout.py @@ -173,6 +173,53 @@ def test_refuses(named, why): layout.plan({"x": named}, {1, 2, 4, 123}, host({1}, {4}), TIGREX) +def lifted(slot: int, source: int = 2, frames: int = 10) -> Named: + return Named( + slot, source=source, start=0, frames=frames, lift=[float(v) for v in range(frames + 1)] + ) + + +def test_a_lifted_copy_takes_its_own_entry_and_leaves_its_source_where_it_is(): + ids, h = {1, 2, 100, 205}, host({1, 2, 3}) + base = layout.plan({}, ids, h, TIGREX) + copy = lifted(60) + got = layout.plan({"air": copy}, ids, h, TIGREX) + assert got.entries == {**base.entries, 60: 2} and got.ids[2] == base.ids[2] + assert (got.cuts[60], got.lifts[60], got.placed) == ((0, 10), tuple(copy.lift or ()), {60}) + assert layout.moved(base, got) == {} and layout.pinned(manifest.loads(HEAD + COPY)).lifts == { + 60: tuple(float(v) for v in range(11)) + } + + +COPY = ( + "[clips.air]\nslot = 60\nsource = 2\nstart = 0\nframes = 10\nlift = " + + str([float(v) for v in range(11)]) + + "\n" +) + + +def test_the_entries_a_copy_may_take_are_those_nothing_holds(): + ids, h = {1, 2, 100, 205}, host({1, 2, 3, 50}) + free = layout.spare({}, ids, h, TIGREX) + assert free[:3] == [0, 4, 5] and free[-1] == 50, "the entries the host fills come last" + assert 3 not in free and 1 not in free and 100 not in free # held by the packer + assert layout.spare({"air": lifted(60)}, ids, h, TIGREX) == free + + +@pytest.mark.parametrize( + ("copy", "why"), + [(lifted(1), "entry 1 holds clip 1"), (lifted(4), "entry 4 on some body parts only")], +) +def test_a_copy_refuses_an_entry_that_is_taken(copy, why): + with pytest.raises(layout.LayoutError, match=f"clips.air: .*{why}"): + layout.plan({"air": copy}, {1, 2, 4}, host({1, 2}, {4}), TIGREX) + + +def test_the_holder_of_a_clip_is_never_its_lifted_copy(): + m = manifest.loads(HEAD + COPY.replace("air", "a") + "[clips.whole]\nsource = 2\n") + assert layout.holder(m, 2) == "whole" + + HEAD = '[port]\nname = "t"\nhost_species = 75\npac = "t.bin"\n[source]\nmodel = 5248\n' diff --git a/packages/mhfu-port/tests/test_manifest.py b/packages/mhfu-port/tests/test_manifest.py index 69be4836..4dccb5ce 100644 --- a/packages/mhfu-port/tests/test_manifest.py +++ b/packages/mhfu-port/tests/test_manifest.py @@ -706,3 +706,48 @@ def test_part_events(): else: with pytest.raises(ManifestError, match="unknown param"): M.loads(text) + + +LIFTED = ( + MINIMAL + "[clips.air]\nslot = 60\nsource = 2\nstart = 0\nframes = 2\nlift = [0.0, 1.5, 3.0]\n" +) + + +def test_a_lifted_copy_sits_beside_its_source_and_round_trips(): + m = M.loads(LIFTED + "[clips.whole]\nsource = 2\n") + assert m.clips["air"].lift == [0.0, 1.5, 3.0] and M.loads(M.dumps(m)) == m + with pytest.raises(ManifestError, match="clip 2 is placed by clips.whole too"): + M.loads( + MINIMAL + + "[clips.whole]\nsource = 2\n" + + "[clips.cut]\nslot = 4\nsource = 2\nstart = 0\nframes = 2\n" + ) + + +@pytest.mark.parametrize( + ("extra", "why"), + [ + ("[clips.air]\nslot = 60\nsource = 2\nlift = [0.0]\n", "lift goes with start"), + ( + "[clips.air]\nslot = 60\nsource = 2\nstart = 0\nframes = 2\nlift = [0.0, 1.0]\n", + "one value per clip frame", + ), + ( + "[clips.air]\nslot = 60\nsource = 2\nstart = 0\nframes = 1\nlift = [0.0, 9999.0]\n", + "within 2048", + ), + ], +) +def test_a_lifted_copy_is_checked(extra, why): + with pytest.raises(ManifestError, match=why): + M.loads(MINIMAL + extra) + + +def test_a_host_pair_move_has_no_travel_or_lunge(): + pair = "[moves.m]\nmain = 1\nsub = 2\nanim = 4\n" + for extra in ( + "travel = [[0.0, 0.0], [1.0, 2.0]]\n", + "lunge = { window = [0, 8], near = 1.0, span = 2.0, base = 3.0, range = 4.0 }\n", + ): + with pytest.raises(ManifestError, match="travel, lunge"): + M.loads(MINIMAL + pair + extra) diff --git a/packages/mhfu-port/tests/test_own.py b/packages/mhfu-port/tests/test_own.py index c01d192d..510cecd5 100644 --- a/packages/mhfu-port/tests/test_own.py +++ b/packages/mhfu-port/tests/test_own.py @@ -1,5 +1,6 @@ # SPDX-License-Identifier: GPL-3.0-or-later # SPDX-FileCopyrightText: 2026 sp00ktober +import dataclasses from collections.abc import Sequence from pathlib import Path from types import SimpleNamespace @@ -8,10 +9,10 @@ import pytest from mhfu import hitbox from mhfu.files import em_overlay -from mhfu_port import manifest, moves, p3hits, sequence +from mhfu_port import manifest, moves, p3hits, p3motion, sequence from mhfu_port.behaviour import Block, MoveNode from mhfu_port.brain import own, spare -from mhfu_port.manifest import AttackWindow, Effect, Steer +from mhfu_port.manifest import AttackWindow, Effect, Lunge, Steer from mhfu_port.p3hits import Hit, Length, Note, Plan, Step, Turn PORTS = Path(__file__).parents[3] / "ports" @@ -80,11 +81,11 @@ def attack(self, host: int) -> object: return SimpleNamespace(unknown_01=self.active, power=50) -def derive(plan: Plan, **kw): +def derive(plan: Plan, spare: Sequence[int] = (), **kw): m = manifest.loads(TEXT) found: list = [] fake = Fake(plan, **kw) - d = own.Derivation(m, fake, lambda: fake, found.append) # type: ignore[arg-type] + d = own.Derivation(m, fake, lambda: fake, found.append, spare=lambda: list(spare)) # type: ignore[arg-type] d.run(plan.pair) d.finish() return m, found @@ -183,6 +184,128 @@ def test_the_move_player_holds_four_attacks(): assert len(m.moves["p3_7_13"].attacks) == 4 and "P3_OWN_ATTACK" in codes(found) +def moving(start: float = 0.0, ticks: int = 22, clip: int = 61, **motion) -> Step: + return dataclasses.replace(step(clip, ended="cursor", ticks=ticks, start=start), **motion) + + +JUMP = tuple(float(v) for v in range(45)) + + +def test_a_jump_plays_a_lifted_copy_of_its_clip_in_a_free_entry(): + hits = [Hit(0, 21, 0, 10.0, 30.0)] + m, found = derive( + 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 m.clips["p3_61_air"] == manifest.Clip( + slot=90, source=61, start=0, frames=44, lift=list(JUMP) + ) + assert m.clips["charge"].lift is None, "the whole clip stays as it was" + assert [(a.id, a.frame, a.end) for a in mv.attacks] == [(7, 10, 30)] + (f,) = [f for f in found if f.code == "P3_OWN_AIR"] + assert f.level == "info" and "+44" in f.text and "entry 90" in f.text + manifest.check(m) + + +def test_a_copy_counts_its_frames_from_where_the_step_starts(): + hits = (Hit(0, 21, 0, 110.0, 130.0),) + got = moving( + start=100.0, + lift=JUMP, + hits=hits, + lunge=p3motion.Lunge((104.0, 132.0), 400.0, 1800.0, -700.0, 1800.0), + spreads=(p3motion.Spread(-0x4000, (110.0, 120.0)),), + stop=None, + ) + m, _ = derive(plan(got), spare=[90]) + mv = m.moves["p3_7_13"] + assert (m.clips["p3_61_air"].start, mv.start) == (100, 0) + assert [(a.frame, a.end) for a in mv.attacks] == [(10, 30)] + assert mv.lunge == Lunge((4, 32), 400.0, 1800.0, -700.0, 1800.0) + assert mv.steer == Steer(turn="fixed", angle=-90.0, walls=False, window=(10, 20)) + manifest.check(m) + + +def test_steps_with_the_same_height_share_one_copy(): + p = plan(moving(lift=JUMP)) + other = dataclasses.replace(p, pair=(7, 14)) + m = manifest.loads(TEXT) + fake = Fake([p, other]) + found: list = [] + d = own.Derivation(m, fake, lambda: fake, found.append, spare=lambda: [90, 91, 92]) # type: ignore[arg-type] + d.run((7, 13)) + d.run((7, 14)) + assert [n for n, c in m.clips.items() if c.lift is not None] == ["p3_61_air"] + assert m.moves["p3_7_13"].clip == m.moves["p3_7_14"].clip == "p3_61_air" + + +def test_a_jump_with_no_free_entry_is_an_error_and_plays_the_clip_plain(): + m, found = derive(plan(moving(lift=JUMP)), spare=[]) + assert m.moves["p3_7_13"].clip == "charge" and not any(c.lift for c in m.clips.values()) + (f,) = [f for f in found if f.code == "P3_OWN_AIR"] + assert f.level == "error" and "no free executor entry" in f.text + + +def test_a_jump_that_outruns_its_clip_keeps_no_height(): + m, found = derive(plan(moving(start=60.0, clip=62, lift=JUMP)), spare=[90]) + assert m.moves["p3_7_13"].clip == "p3_62" + (f,) = [f for f in found if f.code == "P3_OWN_AIR"] + assert f.level == "info" and "height is left out" in f.text + + +def test_travel_and_a_lunge_are_the_steps_and_a_held_tail_is_left_off(): + travel = ((0.0, 0.0), (1.0, 2.0), (3.0, 4.0), (3.0, 4.0), (3.0, 4.0)) + lunge = p3motion.Lunge((0.0, 32.0), 400.0, 1800.0, -700.0, 1800.0) + m, _ = derive(plan(moving(travel=travel, lunge=lunge))) + mv = m.moves["p3_7_13"] + assert mv.travel == [(0.0, 0.0), (1.0, 2.0), (3.0, 4.0)] + assert mv.lunge == Lunge((0, 32), 400.0, 1800.0, -700.0, 1800.0) + manifest.check(m) + m, _ = derive(plan(moving())) + assert m.moves["p3_7_13"].travel is None and m.moves["p3_7_13"].lunge is None + + +def test_travel_past_the_clips_end_is_cut_there(): + long = tuple((float(k), 0.0) for k in range(60)) # clip 62 is 100 frames: 51 samples + m, found = derive(plan(moving(clip=62, travel=long))) + assert len(m.moves["p3_7_13"].travel) == 51 + assert any(f.code == "P3_OWN_MOTION" and "outruns" in f.text for f in found) + + +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,)))) + 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 + assert sum("is cut to the frames it plays" in f.text for f in found) == 2 + manifest.check(m) + + +def test_a_window_the_move_never_plays_is_left_out(): + past = p3motion.Lunge((150.0, 160.0), 400.0, 1800.0, -800.0, 1000.0) + m, found = derive( + plan(moving(clip=62, lunge=past, spreads=(p3motion.Spread(100, (150.0, 160.0)),))) + ) + mv = m.moves["p3_7_13"] + assert mv.lunge is None and mv.steer.turn == "clip" + assert sum("falls outside the frames it plays" in f.text for f in found) == 2 + + +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)),)))) + 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,)))) + assert m.moves["p3_7_13"].steer.turn == "clip" and any("left out" in f.text for f in found) + + def test_a_window_past_the_hosts_active_frames(): _, found = derive(plan(step(61, [Hit(0, 21, 0, 100.0, 140.0)])), active=10) assert "P3_OWN_ACTIVE" in codes(found) diff --git a/packages/mhfu-port/tests/test_own_cli.py b/packages/mhfu-port/tests/test_own_cli.py index 6be98320..3014fb07 100644 --- a/packages/mhfu-port/tests/test_own_cli.py +++ b/packages/mhfu-port/tests/test_own_cli.py @@ -51,6 +51,19 @@ def test_the_list_shows_the_frames_a_move_plays(): ) +def test_the_list_shows_the_motion_a_move_makes_by_code(): + m = manifest.loads(TEXT + "[moves.p3_7_13]\nclip = 'charge'\n") + mv = m.moves["p3_7_13"] + mv.travel = [(0.0, 0.0), (2.0, 5.5)] + mv.lunge = manifest.Lunge((0, 8), 1.0, 2.0, -3.0, 5.0) + mv.steer = manifest.Steer(turn="fixed", angle=-90.0, window=(2, 8)) + line = describe(m, "p3_7_13", 3) + assert "turns -90 degrees in frames (2, 8)" in line and "travels 2, 5.5 over 2 samples" in line + assert "lunges -3 to 2" in line and "rises" not in line + m.clips["charge"].lift = [0.0, 3.0, -9.0] + assert "rises -9" in describe(m, "p3_7_13", 3) + + def test_the_list_names_the_pair_and_the_chain_a_step_is_shared_with(): m = manifest.loads(TEXT + "[moves.p3_7_13]\nclip = 'charge'\nlabel = 'MHP3rd 7,13'\n") assert describe(m, "p3_7_13", 3).endswith("clip 61 charge (MHP3rd 7,13)") diff --git a/packages/mhfu-port/tests/test_travel.py b/packages/mhfu-port/tests/test_travel.py index ca6751ae..878b149f 100644 --- a/packages/mhfu-port/tests/test_travel.py +++ b/packages/mhfu-port/tests/test_travel.py @@ -114,6 +114,40 @@ def test_carry_takes_a_facing_off_the_start_out_too(): assert np.abs(facing).max() < math.radians(0.5) +def test_raised_adds_the_lift_to_the_height_joint_0_has_frame_by_frame(): + clip = dash([loc(1, (0, 165), (40, 165))], [HIP]) + lift = [2.0 * f for f in range(41)] + after = travel.raised(pack(clip), rig(), {0: lift}) + c = after.streams[0][0] + assert c is not None + frames = np.array([0.0, 10.0, 25.0, 40.0]) + _, at = fk.Curves(c, fk.Rig.from_skeleton(rig())).at(frames) + assert at[:, 0, 1] == pytest.approx([165, 185, 215, 245], abs=0.1) + + +def test_raised_gives_a_joint_without_height_one(): + clip = dash([loc(2, (0, 0), (40, 100))], [HIP]) + after = travel.raised(pack(clip), rig(), {0: [0.0, 5.0, 0.0] + [0.0] * 38}) + c = after.streams[0][0] + assert c is not None + _, at = fk.Curves(c, fk.Rig.from_skeleton(rig())).at(np.array([0.0, 1.0, 40.0])) + assert at[:, 0, 1] == pytest.approx([0.0, 5.0, 0.0], abs=0.1) and at[-1, 0, 2] == 100.0 + + +def test_raised_leaves_the_other_entries_and_what_they_share_alone(): + clip = dash([loc(1, (0, 165), (40, 165))], [HIP]) + other = pack(clip, clip) + after = travel.raised(other, rig(), {1: [0.0] * 40 + [9.0]}) + assert after.streams[0][0] is clip and after.streams[0][1] is not clip + + +@pytest.mark.parametrize(("entry", "values", "why"), [(5, 41, "no clip"), (0, 3, "3 lift values")]) +def test_raised_refuses_what_it_cannot_place(entry, values, why): + clip = dash([loc(1, (0, 165), (40, 165))], [HIP]) + with pytest.raises(ValueError, match=why): + travel.raised(pack(clip), rig(), {entry: [0.0] * values}) + + def engine_pose(clip: Clip, frames: np.ndarray, s: Skeleton | None = None) -> np.ndarray: """World joint positions as the engine draws them: no root translation, the tail tip on.""" s = s or rig() diff --git a/packages/mhfu-port/tests/test_zinogre_brain.py b/packages/mhfu-port/tests/test_zinogre_brain.py index a97efc98..f14c690b 100644 --- a/packages/mhfu-port/tests/test_zinogre_brain.py +++ b/packages/mhfu-port/tests/test_zinogre_brain.py @@ -14,12 +14,17 @@ from mhfu.em.script import brainfile, build, draw, edit, ir from mhfu.em.script import vars as script_vars from mhfu.em.script.budget import LIMIT -from mhfu_port import behaviour, manifest, moves, sequence +from mhfu_port import behaviour, fk, manifest, moves, sequence +from mhfu_port import build as builds from mhfu_port.brain import charge, fatigue, own, p3, p3ops, splice 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.data import Data +from mhfu_port.model import ANIMATION, SKELETON +from mhp_formats import fu +from mhp_formats.pac import Pac +from mhp_formats.skeleton import Skeleton PORT = Path(__file__).parents[3] / "ports" / "zinogre.toml" OWN = addresses.OWN_MOVE_MAIN @@ -146,6 +151,68 @@ def test_every_derived_window_lies_inside_its_move_s_frames(zinogre, module_text assert mv.start <= a.frame < a.end <= (mv.stop or frames), name +def air_clips(m: manifest.Manifest) -> dict[int, manifest.Clip]: + """The lifted copies of jumps, by the donor clip they copy.""" + return {c.source: c for c in m.clips.values() if c.lift is not None and c.source is not None} + + +def test_the_jumps_are_lifted_copies_in_entries_nothing_else_holds(zinogre, data): + m = zinogre.m + air = air_clips(m) + assert {s: max(c.lift) for s, c in air.items()} == pytest.approx({225: 644, 209: 300}, rel=0.01) + lay = builds.layout(m, builds.donor(m, data), builds.host(m, data)) + assert not lay.unplaced + for source, c in air.items(): + 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 + + +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 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 + + +def test_the_stamp_waits_16_or_39_frames_and_hops_for_18(zinogre): + m = zinogre.m + short, long = m.moves["p3_7_21"], m.moves[manifest.own_name((7, 86))] + assert (short.start, short.stop, long.start, long.stop) == ( + 46, + 78, + 0, + 78, + ) # 16 and 39 AI frames + hop = m.moves[short.after] + assert (m.clips[hop.clip].source, hop.length) == (209, 18) + assert hop.travel[-1] == pytest.approx((83, 1401), rel=0.01, abs=1) + + +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"] + assert left.travel[-1] == (-257.0, -525.0) and right.travel[-1] == (192.0, -525.0) + assert left.steer == manifest.Steer(turn="fixed", angle=-180.0, walls=False, window=(40, 160)) + assert right.steer.angle == 180.0 and left.length is None + + +def test_the_built_clips_carry_the_jumps_height_on_joint_0(zinogre, module_text): + _, built = module_text + pac = Pac.from_bytes(built.pac) + anim = fu.Anim.from_bytes(pac.entries[ANIMATION]) + skeleton = Skeleton.from_bytes(pac.entries[SKELETON]) + for source, peak in {225: 644, 209: 300}.items(): + c = air_clips(zinogre.m)[source] + clip = fk.rig_clip(anim, c.slot, skeleton) + assert clip is not None + (height,) = [ch for ch in clip.tracks[0].channels if ch.bit == 0x80] + assert max(k.value for k in height.keyframes) / 16 == pytest.approx(peak, rel=0.01) + + HANDS = { "p3_7_4": [212, 213, 209, 210], "p3_7_12": [203, 200, 257, 253], @@ -430,7 +497,7 @@ def test_the_fatigue_table(zinogre, data): @pytest.fixture(scope="module") def module_text(zinogre, data, tmp_path_factory): where = tmp_path_factory.mktemp("zinogre") - m = manifest.load(PORT) + m = zinogre.m # built as the CLI does: after its own moves are derived built = build_port(m, data) modules(m, built, where, data) return (where / moves.module_name(m)).read_text(), built diff --git a/ports/README.md b/ports/README.md index 54f419eb..b310c7ee 100644 --- a/ports/README.md +++ b/ports/README.md @@ -218,7 +218,10 @@ move of the pair it leads to; pairs whose derived moves come out identical (twin first one's, and ten of them, which the donor hands over elsewhere, play it with its hand-over (`ELSEWHERE` in `test_zinogre_brain.py`: their own would take 125 own moves of 96). 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. The donor's walks are derived moves on their own walk clips: em75's +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 93 own moves, the 14 authored and the 79 derived, take 4701 of the registry's 5632 turn keys and 93 of its 96 slots. From 61cb96cd03d7d7bc7010db68f8d5bec7cd16acf4 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:27:40 +0200 Subject: [PATCH 4/5] docs(port): the model's frame and rest height, and what is known of them Co-Authored-By: Claude Sonnet 5.5 --- packages/mhfu-port/src/mhfu_port/p3motion.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/mhfu-port/src/mhfu_port/p3motion.py b/packages/mhfu-port/src/mhfu_port/p3motion.py index 93a4662e..22b1295d 100644 --- a/packages/mhfu-port/src/mhfu_port/p3motion.py +++ b/packages/mhfu-port/src/mhfu_port/p3motion.py @@ -11,7 +11,9 @@ A walk (`p3hits.Walk`) hands `cells` to the walker, which reads and writes them as the handler's float loads and stores go, and answers each helper call through `call`. The frame is the monster's at the first playing frame of a step (x toward its +x, its left; z forward), yaw counted from there; -the cursor is the playing clip's, in clip frames, `step` of them to an AI frame. +the cursor is the playing clip's, in clip frames, `step` of them to an AI frame. The donor's x is +MHFU's: a clip that looks to the monster's right (the Zinogre's 40) has its head at -x in the +port's model space and its code slides it toward -x. """ from __future__ import annotations @@ -65,7 +67,10 @@ VELOCITY, ACCELERATION, GROUND = 0xB0, 0xC0, 0xD8 """Velocity (x, y, z), acceleration (x, y, z) and the floor height, in cells 4 bytes apart.""" REST = 298.0 -"""The height over the floor a monster stands at: the value the donor's own landing snaps to.""" +"""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) +sets Y to D8 when the mode changes, which would make it 0, and a jump then comes down in 21 and +10 AI frames, not 22 and 18.""" TURN = 0x10000 """YAW units in a full turn.""" MAX_KEYS = 256 From d761dd0f092d4d0bd7fd3fdc897b86c70f2ad381 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:26:46 +0200 Subject: [PATCH 5/5] feat(ports): the ten twins of the Zinogre derive their own chains (7, 70), (7, 71), (7, 79), (7, 80), (7, 81), (7, 82), (7, 116), (7, 117), (7, 118) and (7, 119) are "own": the donor hands each over to another pair than its twin's, so each now plays its chain to its finisher (7, 118: clips 203 200 257 253 224 225 226). The turn wrappers (7, 72), (7, 73), (7, 83), (7, 84), (7, 120) and (7, 121) lead to pairs (219, 220, 222, 223, 229, 230) that derive the same chains, so they play these moves. 125 own moves of 128 slots; the registry holds 2432 turn keys, 1052 travel samples and 10 lunges of them. Co-Authored-By: Claude Sonnet 5.5 --- packages/mhfu-port/tests/test_own_cli.py | 5 +- .../mhfu-port/tests/test_zinogre_brain.py | 67 +++++++++---------- ports/README.md | 10 +-- ports/zinogre.toml | 29 ++++---- 4 files changed, 57 insertions(+), 54 deletions(-) diff --git a/packages/mhfu-port/tests/test_own_cli.py b/packages/mhfu-port/tests/test_own_cli.py index 3014fb07..2b35fad0 100644 --- a/packages/mhfu-port/tests/test_own_cli.py +++ b/packages/mhfu-port/tests/test_own_cli.py @@ -74,18 +74,19 @@ 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"39 pairs, 93 own moves of {manifest.OWN_MOVES}" in out + assert code == 0 and f"49 pairs, 125 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] assert chain == ["p3_7_12", "p3_7_12_2", "p3_7_12_3", "p3_7_156"] assert any("p3_7_156 (slot" in line and "(MHP3rd 7,156)" in line for line in lines) held = [line for line in lines if line.startswith(" p3_7_0 (slot")] - assert len(held) == 3, "its own chain, then (7, 14)'s and (7, 16)'s hand-overs" + assert len(held) == 5, "its own chain and the hand-overs of four pairs" assert not held[0].endswith("shared with p3_7_0") and held[1].endswith("shared with p3_7_0") again = [line for line in lines if line.startswith(" p3_7_14_2 (slot")] assert len(again) == 2 and again[1].endswith("shared with p3_7_14"), "marked once per chain" assert not held[2].endswith("shared with p3_7_0"), "(7, 16)'s tail was marked at its first step" + assert held[3].endswith("shared with p3_7_0") and not held[4].endswith("shared with p3_7_0") def test_the_cli_lists_the_moves(data, capsys): diff --git a/packages/mhfu-port/tests/test_zinogre_brain.py b/packages/mhfu-port/tests/test_zinogre_brain.py index f14c690b..dd961943 100644 --- a/packages/mhfu-port/tests/test_zinogre_brain.py +++ b/packages/mhfu-port/tests/test_zinogre_brain.py @@ -69,7 +69,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) == 104 and len(b.derived()) == len(own.pairs(b)) == 39 + assert len(b.pairs) == 107 and len(b.derived()) == len(own.pairs(b)) == 49 def test_a_donor_pair_plays_the_authored_move_where_it_is_the_donors(zinogre): @@ -89,7 +89,7 @@ def test_a_wrapper_turns_and_plays_the_move_of_the_pair_it_leads_to(zinogre): 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, 84)) == [TURN, "p3_7_13"] + assert (7, 13) in b.shared() and b.targets((7, 38)) == [TURN, "p3_7_13"] WALKS = { @@ -140,8 +140,8 @@ 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_")} - assert sum(bool(mv.start) for mv in made.values()) == 14 - assert sum(bool(mv.stop) for mv in made.values()) == 13 + 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 for name, mv in made.items(): frames = built.layout.frames[moves.entry(zinogre.m, name, built.layout)] @@ -254,41 +254,40 @@ def test_equal_tails_are_one_move(zinogre): assert all(f.item in m.moves for f in zinogre.derived if f.item and f.item.startswith("p3_")) -ELSEWHERE = {"7,70", "7,71", "7,79", "7,80", "7,81", "7,82", "7,116", "7,117", "7,118", "7,119"} -"""The twins whose handler hands over to another pair than their twin's: they play their twin's -whole chain. Deriving them would take 125 own moves of 96.""" - - -def own_steps(m: manifest.Manifest, name: str) -> list[tuple[object, ...]]: - """The leading steps of `chain_of` that come from the pair `name` heads, up to its hand-over.""" - out = [] - for n, step in zip(sequence.chain(m, name), chain_of(m, name), strict=True): - if m.moves[n].label != m.moves[name].label: - break - out.append(step) - return out +LEADS = { + "7,219": "p3_7_70", + "7,220": "p3_7_71", + "7,222": "p3_7_81", + "7,223": "p3_7_82", + "7,229": "p3_7_118", + "7,230": "p3_7_119", +} +"""The pairs the turn wrappers (7, 72), (7, 73), (7, 83), (7, 84), (7, 120), (7, 121) lead to, with +the move each plays.""" def test_the_pairs_that_share_a_move_derive_the_same_one(zinogre, data): - """A pair that names its twin's move would derive that very move itself, up to where it hands - over. (7, 71), (7, 82) and (7, 119) are not the script's: only wrappers lead to them, and play - (7, 13)'s move.""" + """A pair that names its twin's move would derive that very move itself, and the wrappers play + the moves of twins of the pairs they lead to.""" b = zinogre.m.brain - named = {k: v for k, v in b.pairs.items() if isinstance(v, str)} - twins = {k: v for k, v in named.items() if v in b.derived()} - twins |= {"7,71": "p3_7_13", "7,82": "p3_7_13", "7,119": "p3_7_13"} - assert len(twins) == 21 + twins = {k: v for k, v in b.pairs.items() if isinstance(v, str) and v in b.derived()} + assert len(twins) == 11 + twins |= LEADS apart = manifest.load(PORT) apart.brain.pairs |= {k: manifest.OWN for k in twins} again, _ = own.expand(apart, data) - differ = set() for key, first in twins.items(): name = manifest.own_name(manifest.pair_of(key)) - want = own_steps(again, first) - assert chain_of(again, name)[: len(want)] == want, key - if chain_of(again, name) != chain_of(again, first): - differ.add(key) - assert differ == ELSEWHERE + assert chain_of(again, name) == chain_of(again, first), key + + +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() + } + assert all(b.pairs[f"7,{p}"] == manifest.OWN for p in wrappers.values()) def test_what_fought_the_brain_is_gone(zinogre): @@ -517,12 +516,12 @@ 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 turn keys the registry holds: the keys are what bounds the port.""" + """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] - keys = sum(len(moves.curve(zinogre.m, n, built.layout) or "") // 4 for n in names) - assert len(names) == 93 and keys == 4701 - assert len(names) <= moves.SLOTS and keys <= moves.POOL + 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 not moves.problems(zinogre.m, built.layout, moves.records(Data.find().fu, 75)) diff --git a/ports/README.md b/ports/README.md index b310c7ee..fe6750ab 100644 --- a/ports/README.md +++ b/ports/README.md @@ -215,16 +215,16 @@ 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 ten of them, which the donor hands over elsewhere, play it with its hand-over -(`ELSEWHERE` in `test_zinogre_brain.py`: their own would take 125 own moves of 96). A derived +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 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 93 -own moves, the 14 authored and the 79 derived, take 4701 of the registry's 5632 turn keys and 93 -of its 96 slots. +(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. 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 06bd6088..2b776b08 100644 --- a/ports/zinogre.toml +++ b/ports/zinogre.toml @@ -1271,20 +1271,21 @@ charge = true "7,67" = "own" "7,68" = "own" "7,69" = "own" -"7,70" = "p3_7_12" +"7,70" = "own" +"7,71" = "own" "7,72" = [ [ 1, 2, ], - "p3_7_12", + "p3_7_70", ] "7,73" = [ [ 1, 2, ], - "p3_7_13", + "p3_7_71", ] "7,74" = [ [ @@ -1300,22 +1301,23 @@ charge = true ], "p3_7_67", ] -"7,79" = "p3_7_68" -"7,80" = "p3_7_69" -"7,81" = "p3_7_12" +"7,79" = "own" +"7,80" = "own" +"7,81" = "own" +"7,82" = "own" "7,83" = [ [ 1, 2, ], - "p3_7_12", + "p3_7_81", ] "7,84" = [ [ 1, 2, ], - "p3_7_13", + "p3_7_82", ] "7,85" = "own" "7,86" = "own" @@ -1414,22 +1416,23 @@ charge = true "7,113" = "p3_7_94" "7,114" = "p3_7_94" "7,115" = "p3_7_94" -"7,116" = "p3_7_68" -"7,117" = "p3_7_69" -"7,118" = "p3_7_12" +"7,116" = "own" +"7,117" = "own" +"7,118" = "own" +"7,119" = "own" "7,120" = [ [ 1, 2, ], - "p3_7_12", + "p3_7_118", ] "7,121" = [ [ 1, 2, ], - "p3_7_13", + "p3_7_119", ] [brain.when]