diff --git a/apps/studio/README.md b/apps/studio/README.md index dc9742d9..1b5c6d2a 100644 --- a/apps/studio/README.md +++ b/apps/studio/README.md @@ -5,8 +5,9 @@ One Qt window for authoring MHFU mods offline, with the commands behind it. Two - **Monster**: a ported monster's clips, labels, parts, hitboxes and moves, kept in its manifest in [`ports/`](../../ports), checked against the base monster's data from the game. Its hit tables are one set of writes over the base monster's, exported as a Lua module the framework - loads (`_hit.lua`) or pushed into the running game through PPSSPP's debugger. Its Brain - tab reads the base monster's AI script. + loads (`_hit.lua`) or pushed into the running game through PPSSPP's debugger; a port + with a `[brain]` also ships the spare attack records its own moves play, derived from both games + each time and never saved. Its Brain tab reads the base monster's AI script. - **Map**: edits to a stage's mesh, collision and textures, kept as edit lists in a `map.toml` document and pushed into the running game through PPSSPP's debugger; and named points (the Point tool, the Points panel), which `mhfu rig goto|walk` takes the hunter to. diff --git a/apps/studio/src/mhfu_studio/monster/commands.py b/apps/studio/src/mhfu_studio/monster/commands.py index 1fe6790b..944ac548 100644 --- a/apps/studio/src/mhfu_studio/monster/commands.py +++ b/apps/studio/src/mhfu_studio/monster/commands.py @@ -6,6 +6,7 @@ import argparse import dataclasses +import sys from pathlib import Path from typing import TYPE_CHECKING @@ -18,6 +19,7 @@ if TYPE_CHECKING: from mhfu.em.intel import SpeciesIntel + from mhfu.em.script.check import Finding from mhfu_port.data import Data from mhfu_port.manifest import Manifest @@ -377,24 +379,42 @@ def _host(args: argparse.Namespace, m: Manifest) -> Host | None: return host +def _expanded(args: argparse.Namespace, m: Manifest) -> tuple[Manifest, str]: + """`runtime.expanded`: a port with a `[brain]` also ships the spare attack records its own + moves play; what the derivation says about them goes to stderr.""" + from mhfu_port.cli import finding_line + + from mhfu_studio.monster import runtime + + def say(f: Finding) -> None: + if f.level != "info" or f.code == "P3_OWN_SPARE": + print(finding_line(f), file=sys.stderr) + + return runtime.expanded(m, lambda: _games(args), report=say) + + def run_hit(args: argparse.Namespace) -> int: from mhfu_studio.monster import runtime - m = _manifest(args.manifest) + m, source = _expanded(args, _manifest(args.manifest)) host = _host(args, m) if args.print: - print(runtime.lua_hit_module(m, host), end="") + print(runtime.lua_hit_module(m, host, source), end="") return 0 path = args.out or Path(runtime.module_name(m)) - dep = runtime.ship(m, path, host, library_path=args.library) if args.deploy else None + dep = ( + runtime.ship(m, path, host, library_path=args.library, source=source) + if args.deploy + else None + ) if dep is None: - runtime.export(m, path, host) + runtime.export(m, path, host, source) print( f"wrote {path} ({len(m.hurtboxes)} volume(s), {len(m.hitzones)} state(s), " f"{len(runtime.sets_of(m))} attack set(s), {len(m.attacks)} attack record(s), " f"id {runtime.content_id(m)})" ) - for note in runtime.plan(m, host).notes: + for note in runtime.plan(m, host, source).notes: print(note) if dep is not None: print(f"deployed {dep.describe()}") @@ -404,14 +424,14 @@ def run_hit(args: argparse.Namespace) -> int: def run_push(args: argparse.Namespace) -> int: from mhfu_studio.monster import push, runtime - m = _manifest(args.manifest) + m, source = _expanded(args, _manifest(args.manifest)) host = _host(args, m) if args.dry: - for w in runtime.plan(m, host).writes: + for w in runtime.plan(m, host, source).writes: print(f"0x{w.at:08X} {len(w.data):5d} B {w.what} ({len(w.guards)} guard(s))") return 0 try: - print(push.to_game(m, host).describe()) + print(push.to_game(m, host, source).describe()) except Refused as e: print(f"refused: {e}") return 1 diff --git a/apps/studio/src/mhfu_studio/monster/runtime.py b/apps/studio/src/mhfu_studio/monster/runtime.py index da37deeb..4f61c584 100644 --- a/apps/studio/src/mhfu_studio/monster/runtime.py +++ b/apps/studio/src/mhfu_studio/monster/runtime.py @@ -10,6 +10,9 @@ hold (the pointer that leads to the table, the host set's own sentinel) that no write changes: another or a relocated overlay fails them, and then nothing is written. +A port with a `[brain]` is planned from `expanded`: the manifest with the spare attack records +its own moves play (`mhfu_port.brain.own`), which are never saved. + The routes: `_hit.lua` on the memory stick, which the framework's `mhfu_port.lua` applies once the port is live in-area and re-applies after a reload (`export`, `ship`), and `push`, which writes through the debugger and reads back. The module is regenerated, never edited, and @@ -21,7 +24,7 @@ import hashlib import shutil import struct -from collections.abc import Mapping +from collections.abc import Callable, Mapping from dataclasses import dataclass, field from pathlib import Path @@ -29,6 +32,9 @@ from mhfu import hitzone from mhfu.em.intel import SpeciesIntel from mhfu.writes import Plan, Write +from mhfu_port import manifest +from mhfu_port.brain import own +from mhfu_port.data import Data from mhfu_port.manifest import Hitbox, Hurtbox, Manifest, ManifestError from mhfu_studio.shell import places @@ -43,8 +49,11 @@ "power": a.ATTACK_RECORD.POWER, "element": a.ATTACK_RECORD.ELEMENT, "volume": a.ATTACK_RECORD.VOLUME_SET, + "kind": a.ATTACK_RECORD.KIND, + "active": a.ATTACK_RECORD.UNKNOWN_01, } -"""The attack record bytes a manifest may set.""" +"""The attack record bytes a manifest may set; `active` is the byte the struct leaves unnamed at ++0x01, which counts the frames the node is active (measured).""" WORD = 4 """Bytes per group in the module's hex.""" @@ -162,7 +171,8 @@ def content_id(m: Manifest) -> str: for hb in vols: h.update(attack_volume(hb)) for at in m.attacks: - h.update(f"atk{at.id}:{at.power},{at.element},{at.volume};".encode()) + more = "" if at.kind is None and at.active is None else f",{at.kind},{at.active}" + h.update(f"atk{at.id}:{at.power},{at.element},{at.volume}{more};".encode()) return h.hexdigest()[:8] @@ -171,6 +181,30 @@ def has_tables(m: Manifest) -> bool: return bool(m.hurtboxes or m.hitzones or m.hitboxes or m.attacks) +def derives(m: Manifest) -> bool: + """`m`'s `[brain]` has own moves to derive (one the manifest defines itself is not), whose + spare attack records `expanded` adds.""" + return m.brain is not None and any( + manifest.own_name(p) not in m.moves for p in own.pairs(m.brain) + ) + + +def expanded( + m: Manifest, + games: Callable[[], Data], + source: str = "", + report: own.Report | None = None, +) -> tuple[Manifest, str]: + """`m` as the plan takes it, and the source line for the module's header. A port with a + `[brain]` gets the spare attack records and volumes its own moves play, derived from `games()` + (read only then); `ManifestError` where the derivation fails. Any other `m` is returned as + it is.""" + if not derives(m): + return m, source + out = own.expanded(m, games(), report) + return out, (source or source_of(m)) + " + the donor's spare attack records" + + def module_name(m: Manifest) -> str: return f"{m.port.name}_hit.lua" diff --git a/apps/studio/src/mhfu_studio/monster/workspace.py b/apps/studio/src/mhfu_studio/monster/workspace.py index 03a676d6..165c42f1 100644 --- a/apps/studio/src/mhfu_studio/monster/workspace.py +++ b/apps/studio/src/mhfu_studio/monster/workspace.py @@ -1031,19 +1031,25 @@ def revert(self) -> None: # the runtime module: the manifest as it is now, saved or not (its header says which) def exportable(self) -> bool: - """The manifest has tables the runtime module carries.""" + """The manifest has tables the runtime module carries, or its `[brain]` derives some.""" + return self.manifest is not None and self._tables(self.manifest) + + @staticmethod + def _tables(m: Manifest) -> bool: from mhfu_studio.monster import runtime - return self.manifest is not None and runtime.has_tables(self.manifest) + return runtime.has_tables(m) or runtime.derives(m) def _module(self) -> tuple[Manifest, Host | None, str]: - """What `runtime.export` takes for the document as it is now.""" + """What `runtime.export` takes for the document as it is now: with a `[brain]`, the + spare attack records its own moves play are derived from the games (a few seconds).""" from mhfu_studio.monster import runtime if self.doc is None: raise ValueError("no port manifest open") m, intel = self.doc.manifest, self.host_intel() source = f"{runtime.source_of(m)}, unsaved edits" if self.doc.dirty else "" + m, source = runtime.expanded(m, self.games, source) return m, runtime.host(intel), source def export_hit(self) -> None: @@ -1062,7 +1068,7 @@ def deploy_hit(self) -> None: m, host, source = self._module() out = runtime.cache_dir() / runtime.module_name(m) dep = runtime.ship(m, out, host, mods_dir=self.mods_dir(), source=source) - self._sent = m + self._sent = self.manifest # not `m`: with a `[brain]` that is a derived copy lib = "" if dep.library is None else f" and {runtime.LIB_SUBDIR}/{runtime.LIBRARY}" self.message = ( f"sent {dep.module.name}{lib} to {_home(dep.module.parent)}; it applies when a mod" @@ -1085,13 +1091,14 @@ def send_blocker(self) -> str | None: if self.scene is None: return "no port open: open a port manifest (ports/.toml)" return "a bare PAC has no hit tables: open its port manifest (.toml) instead" - if not runtime.has_tables(m): + if not self._tables(m): return ( "nothing to send yet: your port has no hitboxes, hurtboxes or damage grid. Copy" " the base monster's in Hitboxes or Parts first." ) try: - runtime.check(m, runtime.host(self.host_intel())) + if runtime.has_tables(m): # a derived table is checked when it is made + runtime.check(m, runtime.host(self.host_intel())) except ManifestError as e: return self._cannot("send", m, e) try: @@ -1125,10 +1132,11 @@ def push_blocker(self) -> str | None: from mhfu_studio.monster import runtime m = self.manifest - if m is None or not runtime.has_tables(m): + if m is None or not self._tables(m): return self.send_blocker() try: - runtime.check(m, runtime.host(self.host_intel())) + if runtime.has_tables(m): + runtime.check(m, runtime.host(self.host_intel())) except ManifestError as e: return self._cannot("push", m, e) return None diff --git a/apps/studio/tests/monster/test_commands.py b/apps/studio/tests/monster/test_commands.py index a8a4e565..6e123c24 100644 --- a/apps/studio/tests/monster/test_commands.py +++ b/apps/studio/tests/monster/test_commands.py @@ -87,6 +87,19 @@ def test_hit(offline, games, tmp_path, capsys, monkeypatch): assert capsys.readouterr().out.endswith("80 B hurtboxes (2 guard(s))\n") +def test_hit_of_a_port_with_a_brain(games, ports, capsys): + data = ["--data", str(games.fu.root), "--p3rd-data", str(games.p3rd.root)] + path = str(ports / "brute_tigrex.toml") + assert main(["port", "hit", path, "--print", *data]) == 0 + got = capsys.readouterr() + assert 'P.hit("brute_tigrex", {' in got.out and "-- donor 26" in got.out + assert "+ the donor's spare attack records" in got.out + assert "P3_OWN_SPARE" in got.err and "P3_OWN_BRANCH" not in got.err, "warnings and spares" + assert main(["port", "push", path, "--dry", *data]) == 0 + whats = capsys.readouterr().out + assert " attack set " in whats and " kind " in whats and " active " in whats + + def test_clips(games, ports, tmp_path, capsys): data = ["--data", str(games.fu.root), "--p3rd-data", str(games.p3rd.root)] assert main(["port", "clips", str(ports / "zinogre.toml"), *data]) == 0 diff --git a/apps/studio/tests/monster/test_runtime.py b/apps/studio/tests/monster/test_runtime.py index 5193ad2b..55d39ddd 100644 --- a/apps/studio/tests/monster/test_runtime.py +++ b/apps/studio/tests/monster/test_runtime.py @@ -1,5 +1,6 @@ # SPDX-License-Identifier: GPL-3.0-or-later # SPDX-FileCopyrightText: 2026 sp00ktober +import copy import importlib.util import struct from pathlib import Path @@ -11,6 +12,7 @@ from mhfu.hitzone import HitVolume from mhfu.memory import Image, Space from mhfu.writes import Refused, Write, apply +from mhfu_port import manifest from mhfu_port.manifest import Attack, Hitbox, HitzoneState, Hurtbox, ManifestError from mhfu_studio.monster import runtime as RT @@ -82,6 +84,29 @@ def test_plan(make): assert p.notes == ("grid: 1 state(s), host em75 has 2: 1 written",) +def test_plan_carries_a_spare_record(make): + m = make() + m.hitboxes = [Hitbox(9, 130.0, 19, offset=[0.0, 1.0, 2.0], label="donor 26")] + m.attacks = [Attack(34, power=18, volume=19, kind=2, active=59, label="donor 26")] + p = RT.plan(m, HOST) + assert [w.what for w in p.writes] == [ + "attack set 19", + "attack 34 power", + "attack 34 volume", + "attack 34 kind", + "attack 34 active", + ] + rec = a.ATTACK_RECORD + base = 0x6000 + 34 * rec.step + got = {w.what: (w.at - base, w.data) for w in p.writes[1:]} + assert got == { + "attack 34 power": (rec.POWER, bytes([18])), + "attack 34 volume": (rec.VOLUME_SET, bytes([19])), + "attack 34 kind": (rec.KIND, bytes([2])), + "attack 34 active": (rec.UNKNOWN_01, bytes([59])), + } + + def test_plan_leaves_out(make): m = hurt(make()) m.hurtboxes *= 2 @@ -134,6 +159,10 @@ def test_content_id(make): m.hitboxes[0].label = "renamed" ids.append(RT.content_id(m)) assert len(set(ids)) == 4 and all(len(i) == 8 for i in ids) + m.attacks[0] = Attack(6, power=41, kind=2) + m2 = RT.content_id(m) + m.attacks[0] = Attack(6, power=41, active=9) + assert len({ids[2], m2, RT.content_id(m)}) == 3 def test_export_and_deploy(make, tmp_path, monkeypatch): @@ -156,6 +185,22 @@ def test_export_and_deploy(make, tmp_path, monkeypatch): assert RT.deploy(path, mods, lib).library is None +def boom() -> None: + raise AssertionError("no game data is read") + + +def test_only_a_port_with_own_moves_to_derive_is_expanded(make): + m = hits(make()) + assert not RT.derives(m) and RT.expanded(m, boom, "ports/t.toml") == (m, "ports/t.toml") # type: ignore[arg-type] + brain = '[brain]\ndonor = 23\nsplice = "base_3"\nentry = "base_12"\n' + plain = make(brain) + assert not RT.derives(plain) and RT.expanded(plain, boom)[0] is plain # type: ignore[arg-type] + pairs = brain + '[brain.pairs]\n"7,13" = "own"\n' + assert RT.derives(make(pairs)) + own = make('[clips.c]\nsource = 61\n[moves.p3_7_13]\nclip = "c"\n' + pairs) + assert not RT.derives(own) and RT.expanded(own, boom)[0] is own # type: ignore[arg-type] + + def test_library(): assert RT.library() == FRAMEWORK / "lua" / "lib" / RT.LIBRARY @@ -336,3 +381,66 @@ def test_notes_reach_the_log(lua, make): logs = run(lua, RT.lua_hit_module(m, HOST)) assert any("hit tables: hurtboxes: 4 volume(s), the host's set holds 3" in x for x in logs) assert any("NOT written: hurtboxes: 0x" in x for x in logs), "the empty game fails a guard" + + +# a port with a [brain]: the spare record its own moves play ships with the tables + + +@pytest.fixture(scope="module") +def brute(games: Any, ports: Path) -> tuple[Any, str]: + m = manifest.load(ports / "brute_tigrex.toml") + assert not RT.has_tables(m), "every table of the plan comes from the derivation" + return RT.expanded(m, lambda: games) + + +def feet(m: Any) -> Attack: + (spare,) = m.attacks + return spare + + +def test_the_hit_plan_of_a_brain_port_carries_the_spare_record(brute, em75): + m, source = brute + assert source == "ports/brute_tigrex.toml + the donor's spare attack records" + host = RT.host(em75) + spare = feet(m) + p = RT.plan(m, host, source) + assert [w.what for w in p.writes] == [ + f"attack set {spare.volume}", + *(f"attack {spare.id} {lever}" for lever in ("power", "volume", "kind", "active")), + ] + assert not p.notes and "donor 26" in RT.lua_hit_module(m, host, source) + + +def test_the_spare_record_lands_in_the_game(brute, games, em75): + m, _ = brute + host = RT.host(em75) + assert host is not None and host.attacks is not None + spare = feet(m) + space = game(games) + done = apply(space, RT.plan(m, host)) + assert not done.mismatched + rec = a.ATTACK_RECORD + at = host.attacks.records_va + spare.id * rec.step + assert space.read(at + rec.POWER, 1) == bytes([spare.power]) + assert space.read(at + rec.KIND, 1) == bytes([spare.kind]) + assert space.read(at + rec.UNKNOWN_01, 1) == bytes([spare.active]) + assert space.read(at + rec.VOLUME_SET, 1) == bytes([spare.volume]) + va, cap = host.attacks.sets[spare.volume] + volumes = [HitVolume(space, va + i * REC) for i in range(3)] + assert [(v.bone, v.shape, v.radius) for v in volumes[:2]] == [(24, 0, 130.0), (29, 0, 130.0)] + assert cap >= 2 and volumes[2].bone == hitzone.SENTINEL_BONE + + +def test_the_stick_writes_the_spare_record_as_the_push_does(lua, brute, games, em75): + m, source = copy.deepcopy(brute[0]), brute[1] + m.port.name = "t" # the port `run` defines + host = RT.host(em75) + p = RT.plan(m, host, source) + space = game(games) + mem = Mem(lua) + seed(mem, space, p.writes) + assert not apply(space, p).mismatched + (line,) = applied(run(lua, RT.lua_hit_module(m, host, source))) + assert f"{len(p.writes)} write(s), {p.size} byte(s)" in line + for w in p.writes: + assert mem.read(w.at, len(w.data)) == space.read(w.at, len(w.data)) == w.data, w.what diff --git a/apps/studio/tests/monster/test_send.py b/apps/studio/tests/monster/test_send.py index 6d4f6e0c..1cc8bd29 100644 --- a/apps/studio/tests/monster/test_send.py +++ b/apps/studio/tests/monster/test_send.py @@ -2,12 +2,14 @@ # SPDX-FileCopyrightText: 2026 sp00ktober """Send to game: the hit tables as they are now, onto a memory stick in tmp_path.""" +import shutil import struct from pathlib import Path import pytest from mhfu import inject from mhfu.em.intel import SpeciesIntel +from mhfu_port.data import Data from mhfu_studio.monster.core.scene import Scene from mhfu_studio.monster.document import PortDocument from mhfu_studio.monster.workspace import MonsterWorkspace @@ -106,3 +108,21 @@ def test_send_failure_is_a_message(ws: MonsterWorkspace, mods: Path) -> None: mods.rmdir() ws.send() assert ws.message.startswith("send failed: ") + + +def test_send_derives_the_spare_records_of_a_brain_port( + mods: Path, games: Data, em75: SpeciesIntel, ports: Path, tmp_path: Path +) -> None: + path = tmp_path / "brute_tigrex.toml" + shutil.copyfile(ports / "brute_tigrex.toml", path) + w = MonsterWorkspace(games) + w.intel_cache[75] = em75 + w.doc = PortDocument.open(path) + assert w.exportable() and w.send_blocker() is None and w.push_blocker() is None + w.send() + text = (mods / "brute_tigrex_hit.lua").read_text() + assert w.message.startswith("sent brute_tigrex_hit.lua and lib/mhfu_port.lua to ") + assert "ports/brute_tigrex.toml + the donor's spare attack records" in text + assert 'what = "attack set ' in text and "-- donor 26" in text + assert w._sent is w.manifest, "the document, not the derived copy, counts as sent" + assert w.manifest is not None and not w.manifest.attacks, "nothing derived is saved" diff --git a/docs/formats/p3rd_ai_script.md b/docs/formats/p3rd_ai_script.md index 5bd2a020..61491ca8 100644 --- a/docs/formats/p3rd_ai_script.md +++ b/docs/formats/p3rd_ai_script.md @@ -143,19 +143,53 @@ clip that hands to the next by `after`: - **Attacks.** A record's START and LEN against the cursor at the spawn give the frames; a node that outlives a clip change is split per clip. The record's volume set is matched to the host record whose volumes overlap it most on the port's rig (the same kind first, then power); below - 0.25 there is no match and the attack is left out. `[brain.attacks] "26" = 40` names the host - record for donor record 26; the port may define it with `[[attack]]` and `[[hitbox]]`. + 0.25 there is no match and the record takes a spare host record (below). `[brain.attacks] + "26" = 40` names the host record for donor record 26; the port may define it with `[[attack]]` + and `[[hitbox]]`. - **Turn.** A `hunter` steer with the donor's rate and the cursor window it turned in (`steer.window`). What the donor does by code, not by its clips (a slide, a curve), is not carried. | Finding | Level | What | |---|---|---| | `P3_OWN_NOCLIPS`, `P3_OWN_CARRIER`, `P3_OWN_SLOTS` | error | no clip for the pair; no carrier for the host; more own moves than the framework holds | -| `P3_OWN_ATTACK` | warning | a donor record with no host record, or more attacks on a clip than the move player holds: left out | +| `P3_OWN_ATTACK` | warning | a donor record with no host record and no spare, or more attacks on a clip than the move player holds: left out | +| `P3_OWN_SPARE` | info | a donor record with no host record took a spare one: its id, set, volumes, power, kind, active frames | | `P3_OWN_PARTIAL` | warning | the walk gave up, or a clip it could not resolve | | `P3_OWN_BRANCH`, `P3_OWN_MOTION`, `P3_OWN_START`, `P3_OWN_NEXT` | info | a branch taken one way; code movement; a clip the donor starts past its first frame; a pair the handler hands over to | | `P3_OWN_SPLIT`, `P3_OWN_GROUP`, `P3_OWN_ACTIVE` | info | a node split per clip; records that land one hit between them in the donor; a window longer than the host record's life | +### Spare attack records + +A donor record that no host record matches gets a spare one in `own.expand` +(`mhfu_port.brain.spare`), so its attack is played instead of dropped. em75's record table holds +two more species' records (`hitbox.ID_OFFSETS`: 34-69 and 71-106, on sets 18-55), which a species-75 +port never spawns. The build points one of them at one of those sets and adds both to the +*expanded* manifest as `[[attack]]` and `[[hitbox]]`; the saved manifest never has them, and the +own move's attack plays the spare's id. + +- **Record.** Of the free ids, the one whose own kind is the donor's family (hit 2, weak 1, a node + that hurts nobody 29: donor kind 0x34 or power 0), then the plainest (`+0x0C` and the angle + zero, the family byte the host's records of that kind carry), then the lowest. Not an id the node + code treats by id (5, 6, 21, 22, 24, 28, 31, 39, 40, 99 and 100, read from the overlay), not 70 + (blank), not one the manifest defines (`[[attack]]`, `[brain.attacks]`). The levers set are + `power`, `kind`, `active` and `volume`; the other bytes (family, angle, tag, element) stay the + game's own record's. +- **Power** is the donor's times the median of host power over donor power across this port's + matched records (0.78, em75 against em058, where none matched); 0 for a node that hurts nobody. +- **Active frames** are the donor's LEN, grown to the longest window a move holds the node + (a node the donor refreshes lives to the clip's end), at most 255. +- **Set.** The free set (not one of the species' own, not in `[[hitbox]]`) with the fewest records + that holds the donor's volumes; records on one donor set share one host set. A set holds as many + volumes as the host's does: a donor set that no free set holds gets no spare. +- **Volumes** are the donor's on the port's joints (`binding.joint_of`, the match's rig); a capsule + across two bones becomes a capsule on the first with its far end in bind pose. Markers, bones off + the rig and empty volumes are left out. + +The tables reach the game through the studio's write plan, not the moves module: a port with a +`[brain]` gets them from `studio port hit` (`--deploy` for `_hit.lua`, `port push`, Send and +Push to game), which expand the manifest first (`mhfu_studio.monster.runtime.expanded`). A move +that plays a spare record without its tables plays the species-76 or 88 record of that id. + ### Fatigue MHP3rd keeps a stamina per monster and its script asks `if_tired`; MHFU has none and its script diff --git a/packages/mhfu-port/README.md b/packages/mhfu-port/README.md index abf30496..ad981bf3 100644 --- a/packages/mhfu-port/README.md +++ b/packages/mhfu-port/README.md @@ -63,7 +63,7 @@ port.pac, port.summary.text() | `mhfu-port brain ID` | An MHP3rd monster's AI script as text (`mhfu_port.brain`; [format](../../docs/formats/p3rd_ai_script.md)) | | `mhfu-port brain --check` | Every monster's script lifted and encoded back to the game's bytes | | `mhfu-port p3moveset ID` | The monster's (main, sub) pairs, the clips each plays and the pairs it enters, as CSV (`p3moveset`) | -| `mhfu-port own M` | The own moves a manifest's `[brain.pairs] ... = "own"` derives from the donor: each chain, its clips, its attacks as donor record to host record, its turn, and the findings (`brain.own`) | +| `mhfu-port own M` | The own moves a manifest's `[brain.pairs] ... = "own"` derives from the donor: each chain, its clips, its attacks as donor record to host record (a spare one where no host record matches, `brain.spare`), its turn, and the findings (`brain.own`) | | `mhfu-port splice M` | A manifest's `[brain]`: the donor's routine translated into the host's script, the findings on what did not carry over, the size and the longest run (`brain.splice`; `--source`, `-o FILE`) | `splice` writes an edit set for the host's row, which `mhfu brain push|deploy --doc FILE` sends on. diff --git a/packages/mhfu-port/src/mhfu_port/brain/own.py b/packages/mhfu-port/src/mhfu_port/brain/own.py index a8ad04ea..893cca17 100644 --- a/packages/mhfu-port/src/mhfu_port/brain/own.py +++ b/packages/mhfu-port/src/mhfu_port/brain/own.py @@ -14,12 +14,15 @@ A pair with several clips is a chain, `p3_7_14`, `p3_7_14_2`, ..., each step handing to the next through `after`; a step carries the donor's attacks that are active on its clip. A donor record becomes the host record whose volumes overlap it most on the port's rig (`[brain.attacks]` names -one instead), and the node plays with the host record's power and life. +one instead), and the node plays with the host record's power and life. A record nothing matches +gets a spare host record (`brain.spare`), added to the returned manifest as `[[attack]]` and +`[[hitbox]]`; the studio's hit tables write it into the game. """ from __future__ import annotations import copy +import statistics from collections.abc import Callable, Sequence from typing import NamedTuple @@ -46,6 +49,7 @@ targets_of, ) from ..p3hits import Plan, Step, Walk +from .spare import HOST_NO_DAMAGE, NO_DAMAGE, POWER_SCALE, Spare, Spares Pair = tuple[int, int] Floats = NDArray[np.float64] @@ -57,10 +61,6 @@ 0.30 or more, measured (27 on 8 is 0.297); the best that does not is 0.12.""" SAMPLES = 100_000 """Points the overlap of two volume sets is sampled at.""" -NO_DAMAGE = 0x34 -"""The donor record kind of the roar's and the slams' node, which hurts nobody.""" -HOST_NO_DAMAGE = 0x1D -"""The host record kind it pairs with.""" MAX_RATE = 0xFFFF STEP = p3hits.STEP_FRAMES @@ -197,24 +197,42 @@ class Derivation: """One pair's plan turned into moves and findings.""" def __init__( - self, m: Manifest, walk: Walk, matcher: Callable[[], Matcher], say: Report + self, + m: Manifest, + walk: Walk, + matcher: Callable[[], Matcher], + say: Report, + pairs: Sequence[Pair] = (), ) -> None: self.m, self.walk, self.matcher, self.say = m, walk, matcher, say + self.pairs = list(pairs) + """Every pair being derived: the spare records' power scale reads all their matches.""" self.pair: Pair = (0, 0) self.item = "" """The move findings are about.""" + self._plans: dict[Pair, Plan] = {} + self._spares: Spares | None = None + self._near: dict[int, tuple[str, str]] = {} + """Spare donor record -> what it came closest to, and the move that first needed it.""" def find(self, level: str, code: str, text: str) -> None: self.say(Finding(level, code, text, None, self.item)) + def _plan(self, pair: Pair) -> Plan | None: + donor = self.walk.ms.pairs.get(pair) + if donor is None: + return None + if pair not in self._plans: + self._plans[pair] = self.walk.plan(donor) + return self._plans[pair] + def run(self, pair: Pair) -> None: head = own_name(pair) self.pair, self.item = pair, head - donor = self.walk.ms.pairs.get(pair) - if donor is None: + plan = self._plan(pair) + if plan is None: self.find("error", "P3_OWN_NOCLIPS", "the donor has no such pair") return - plan = self.walk.plan(donor) self._notes(plan) if not plan.steps: self.find("error", "P3_OWN_NOCLIPS", "its handler plays no clip") @@ -227,6 +245,23 @@ def run(self, pair: Pair) -> None: after = names[k + 1] if k + 1 < len(names) else None self.m.moves[names[k]] = self._move(step, after) + def finish(self) -> None: + """One `P3_OWN_SPARE` per spare record taken, once every pair has had its say: the + record's final active frames are known only then.""" + for record, (near, item) in self._near.items(): + got = self._spares.take(record) if self._spares is not None else None + if isinstance(got, Spare): + a = got.attack + joints = ", ".join(map(str, sorted(set(got.joints)))) + self.item = item + self.find( + "info", + "P3_OWN_SPARE", + f"donor record {record} has no host record{near}: spare record {got.id} on " + f"set {got.set} ({got.volumes} volume(s) on joint(s) {joints}), power " + f"{a.power}, {got.family}, active {a.active} frames", + ) + def _notes(self, plan: Plan) -> None: codes = { "branch": ("info", "P3_OWN_BRANCH"), @@ -284,27 +319,21 @@ def _attacks(self, step: Step, until: float) -> list[AttackWindow]: if h.carried: self.find("info", "P3_OWN_SPLIT", f"record {h.record} spans clips: split per clip") match = self.matcher().match(h.record) - if match.host is None: - near = ( - "" - if match.best is None - else f" (closest {match.best}, overlap {match.overlap:.2f})" - ) - self.find( - "warning", - "P3_OWN_ATTACK", - f"donor record {h.record} has no host record{near}: left out", - ) - continue - active = self.matcher().attack(match.host) - if active is not None and end - frame > active.unknown_01 > 0: + host, life = match.host, None + if host is None: + spare = self._spare(h.record, match, end - frame) + if spare is None: + continue + host, life = spare.id, spare.attack.active + elif (active := self.matcher().attack(host)) is not None: + life = active.unknown_01 + if life is not None and end - frame > life > 0: self.find( "info", "P3_OWN_ACTIVE", - f"record {h.record} is active {end - frame} frames, host record {match.host} " - f"{active.unknown_01}", + f"record {h.record} is active {end - frame} frames, host record {host} {life}", ) - out.append(AttackWindow(match.host, frame, end, label=f"donor {h.record}")) + out.append(AttackWindow(host, frame, end, 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: @@ -322,6 +351,46 @@ def _attacks(self, step: Step, until: float) -> list[AttackWindow]: out = out[:MOVE_ATTACKS] return out + def _spare(self, record: int, match: Match, frames: int) -> Spare | None: + """The spare host record for a donor record nothing matches; None, with the warning, + when there is none.""" + got = self._pool().take(record, frames) + near = "" + if match.best is not None: + near = f" (closest {match.best}, overlap {match.overlap:.2f})" + if isinstance(got, str): + self.find( + "warning", + "P3_OWN_ATTACK", + f"donor record {record} has no host record{near} and no spare ({got}): left out", + ) + return None + self._near.setdefault(record, (near, self.item)) + return got + + def _pool(self) -> Spares: + if self._spares is None: + mt = self.matcher() + self._spares = Spares( + self.m, mt.hits, mt.table, mt.binding.joint_of, mt.bind, self._scale() + ) + return self._spares + + def _scale(self) -> float: + """Host power over donor power for a spare record: the median over the records of this + port's pairs that matched (the ones `[brain.attacks]` chose left out).""" + mt = self.matcher() + plans = (self._plan(p) for p in dict.fromkeys([*self.pairs, self.pair])) + used = {h.record for plan in plans if plan for s in plan.steps for h in s.hits} + ratios = [] + for r in sorted(used): + got = mt.match(r) + host = None if got.host is None or got.given else mt.attack(got.host) + donor = self.walk.hits.records[r] if 0 <= r < len(self.walk.hits.records) else None + if host is not None and donor is not None and host.power and donor.power: + 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).""" @@ -370,9 +439,10 @@ def matcher() -> Matcher: cache.append(Matcher(m, data, walk.hits)) return cache[0] - derive = Derivation(out, walk, matcher, found.append) + derive = Derivation(out, walk, matcher, found.append, wanted) for pair in wanted: derive.run(pair) + derive.finish() species = m.port.host_species if ( any(mv.own and mv.carrier is None for mv in out.moves.values()) diff --git a/packages/mhfu-port/src/mhfu_port/brain/spare.py b/packages/mhfu-port/src/mhfu_port/brain/spare.py new file mode 100644 index 00000000..106bc1a8 --- /dev/null +++ b/packages/mhfu-port/src/mhfu_port/brain/spare.py @@ -0,0 +1,198 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 sp00ktober +"""Spare host attack records for the donor records no host record matches. + +em75's record table holds two more species' records (`hitbox.ID_OFFSETS`), on volume sets of +their own, which a species-75 port never spawns. A donor record with no match takes one of them: +`[[attack]]` sets its power, kind and active frames from the donor's and points it at one of +those sets, and `[[hitbox]]` makes that set the donor's volumes on the port's joints. `Spares` +allocates and appends both to the manifest it is given (`brain.own.expand` hands it the copy it +returns); nothing is saved. + +A spare record keeps the bytes the game's own record has that no lever reaches (family, angle, +tag, element, +0x0C): the allocator takes the free record whose kind is wanted and those bytes +are the plainest, so a spare is not a knockdown by accident. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Literal, NamedTuple + +import numpy as np +from mhfu import hitbox, hitzone +from numpy.typing import NDArray + +from ..manifest import Attack, Hitbox, Manifest +from ..p3hits import Hits, Record, Volume + +Floats = NDArray[np.float64] +Family = Literal["hit", "weak", "roar"] + +NO_DAMAGE = 0x34 +"""The donor record kind of the roar's and the slams' node, which hurts nobody.""" +HOST_NO_DAMAGE = 0x1D +"""The host record kind it pairs with.""" +WEAK = 1 +"""The donor kind of the small hits (power 5 to 35).""" +HOST_KIND: dict[Family, int] = {"hit": 2, "weak": 1, "roar": HOST_NO_DAMAGE} +"""The host kind byte of each family.""" +HOST_FAMILY_BYTE = {"hit": 0, "weak": 0, "roar": 0x10} +"""The family byte (+0x05) the host's own records of each family carry.""" +RULED: dict[int, frozenset[int]] = { + 75: frozenset({5, 6, 21, 22, 24, 28, 31, 39, 40, 99, 100}), +} +"""By overlay species, the ids its attack-node code treats by id, read from the overlay: the +initialiser (24 starts at bone 43) and the two per-frame steps (5, 6, 21, 22, 28, 31 in the one, +39, 40, 99, 100 in the other; 6 is refreshed). A spare never takes one.""" +POWER_SCALE = 0.78 +"""Host power over donor power where no record of the port matched: em75's against em058's, the +median over the records the two share.""" +MAX_ACTIVE = 255 + + +class Spare(NamedTuple): + """One donor record's host record.""" + + id: int + """The host record.""" + set: int + """The volume set it is pointed at.""" + family: Family + volumes: int + """How many volumes the set carries.""" + attack: Attack + """Its `[[attack]]` entry, in the manifest.""" + joints: tuple[int, ...] + """The port joints the volumes sit on.""" + + +def family(r: Record) -> Family: + """What kind of node a donor record is.""" + if r.power == 0 or r.kind == NO_DAMAGE: + return "roar" + return "weak" if r.kind == WEAK else "hit" + + +class Row(NamedTuple): + """One donor volume on the port's rig.""" + + joint: int + shape: Literal["sphere", "capsule"] + radius: float + offset: list[float] + to: list[float] | None + + +class Spares: + """The free records and volume sets of the host's table, and what has been taken.""" + + def __init__( + self, + m: Manifest, + hits: Hits | None, + table: hitbox.AttackTable | None, + joint_of: Mapping[int, int], + bind: Floats, + scale: float = POWER_SCALE, + ) -> None: + self.m, self.hits, self.table = m, hits, table + self.joint_of, self.bind, self.scale = joint_of, bind, scale + self._ids: list[int] = [] + self._sets: dict[int, int] = {} + """Free set -> its record count.""" + self._taken: dict[int, Spare | str] = {} + self._shared: dict[int, int] = {} + """Donor volume set -> the host set that carries it.""" + if table is not None: + self._free(m, table) + + def _free(self, m: Manifest, table: hitbox.AttackTable) -> None: + species = m.port.host_species + starts = sorted(o for o in hitbox.ID_OFFSETS.get(species, {}).values() if o > 0) + if not starts: + return + asked = set() if m.brain is None else set(m.brain.attacks.values()) + used = {a.id for a in m.attacks} | asked | RULED.get(species, frozenset()) + n = len(table.attacks) + self._ids = [i for i in range(starts[0] + 1, n) if i not in starts and i not in used] + own = {table.attacks[i].volume_set for i in range(min(starts[0], n))} + held = {h.set for h in m.hitboxes} | {a.volume for a in m.attacks if a.volume is not None} + self._sets = {i: len(s.volumes) for i, s in enumerate(table.volumes) if i not in own | held} + + def take(self, record: int, frames: int = 0) -> Spare | str: + """The spare for donor `record`, allocated at the first ask; `frames` is how long a move + keeps the node, which its active frames grow to cover. A str says why there is none.""" + got = self._taken.get(record) + if got is None: + got = self._taken[record] = self._allocate(record) + if isinstance(got, Spare) and got.attack.active is not None: + got.attack.active = max(got.attack.active, min(frames, MAX_ACTIVE)) + return got + + def _allocate(self, record: int) -> Spare | str: + if self.table is None or self.hits is None: + return "the host has no attack table" + if not self._ids: + return "the host's table has no spare record" + donor = self.hits.records[record] if 0 <= record < len(self.hits.records) else None + if donor is None: + return "the donor has no such record" + rows = self._rows(self.hits.volumes(record)) + if not rows: + return "none of its volumes lands on the port's rig" + host_set = self._shared.get(donor.volumes) + if host_set is None: + fits = [(n, i) for i, n in self._sets.items() if n >= len(rows)] + if not fits: + return ( + f"{len(rows)} volume(s), the largest free set holds " + f"{max(self._sets.values(), default=0)}" + ) + host_set = min(fits)[1] + self._shared[donor.volumes] = host_set + del self._sets[host_set] + label = f"donor {record}" + self.m.hitboxes += [ + Hitbox(r.joint, r.radius, host_set, r.shape, r.offset, r.to, 0, label) for r in rows + ] + fam = family(donor) + i = min(self._ids, key=lambda i: self._rank(i, fam)) + self._ids.remove(i) + power = 0 if fam == "roar" else max(1, min(255, round(donor.power * self.scale))) + active = max(1, min(MAX_ACTIVE, donor.length)) + attack = Attack(i, power, None, host_set, HOST_KIND[fam], active, f"donor {record}") + self.m.attacks.append(attack) + return Spare(i, host_set, fam, len(rows), attack, tuple(r.joint for r in rows)) + + def _rank(self, i: int, fam: Family) -> tuple[bool, bool, bool, bool, int]: + """Best first: the kind wanted, then the bytes no lever sets plain (`+0x0C` and the angle + zero, the family byte the host's own records of the kind carry), then the lowest id.""" + assert self.table is not None + a = self.table.attacks[i] + return ( + a.kind != HOST_KIND[fam], + a.half_0c != 0, + a.angle != 0, + a.family != HOST_FAMILY_BYTE[fam], + i, + ) + + def _rows(self, volumes: Sequence[Volume]) -> list[Row]: + """The donor's volumes on the port's joints: markers, bones off the rig and empty + volumes are left out. A capsule across two bones keeps its far end as an offset from the + first, in bind pose (the match is made there too).""" + out = [] + for v in volumes: + j = self.joint_of.get(v.bone) + if v.bone in hitzone.MARKER_BONES or v.radius <= 0 or j is None: + continue + offset = [float(x) for x in v.offset] + to = None + j2 = self.joint_of.get(v.bone2) + if v.shape == "capsule": + to = [float(x) for x in v.to] + elif v.shape == "capsule2" and j2 is not None: + to = [float(x) for x in self.bind[j2] + np.asarray(v.to) - self.bind[j]] + out.append(Row(j, "sphere" if to is None else "capsule", float(v.radius), offset, to)) + return out diff --git a/packages/mhfu-port/src/mhfu_port/cli/own.py b/packages/mhfu-port/src/mhfu_port/cli/own.py index ae16acbe..c0a8f8ae 100644 --- a/packages/mhfu-port/src/mhfu_port/cli/own.py +++ b/packages/mhfu-port/src/mhfu_port/cli/own.py @@ -70,6 +70,8 @@ def describe(m: manifest.Manifest, name: str, slot: int | None) -> str: 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}" lines = [head] + spare = {a.id for a in m.attacks} for a in mv.attacks: - lines.append(f" {a.label} -> host record {a.id}, frames {a.frame}-{a.end}") + kind = " (spare)" if a.id in spare else "" + lines.append(f" {a.label} -> host record {a.id}{kind}, frames {a.frame}-{a.end}") return "\n".join(lines) diff --git a/packages/mhfu-port/src/mhfu_port/manifest.py b/packages/mhfu-port/src/mhfu_port/manifest.py index c4c8f7c0..288e2d02 100644 --- a/packages/mhfu-port/src/mhfu_port/manifest.py +++ b/packages/mhfu-port/src/mhfu_port/manifest.py @@ -355,11 +355,16 @@ class Attack: element: int | None = None volume: int | None = None """The volume set the record's node walks.""" + kind: int | None = None + """The record's kind byte: 1 weak, 2 hit, 29 a node that hurts nobody (em75's).""" + active: int | None = None + """The frames its node stays active.""" label: str = "" @property def is_empty(self) -> bool: - return self.power is None and self.element is None and self.volume is None + levers = (self.power, self.element, self.volume, self.kind, self.active) + return all(v is None for v in levers) @dataclass @@ -408,7 +413,8 @@ class Brain: attacks: dict[str, int] = field(default_factory=dict) """The host attack record an own move derived from the donor plays for a donor record, keyed by the donor's record id; it overrides the match by volumes. The port may define the host - record with `[[attack]]` and `[[hitbox]]`.""" + record with `[[attack]]` and `[[hitbox]]`; a donor record with neither a match nor an entry here + gets a spare host record (`brain.spare`).""" fatigue: bool = False """The donor's stamina runs in the game (`mhfu.em_fatigue`): the build derives the helper's table from the donor into the moves module (`brain.fatigue`), and the splice turns the @@ -721,7 +727,7 @@ def _validate(m: Manifest) -> None: _need(a.id >= 0, w, "id is 0 or more") _need(a.id not in ids, w, f"record {a.id} is declared twice") ids.add(a.id) - for lever in (a.power, a.element, a.volume): + for lever in (a.power, a.element, a.volume, a.kind, a.active): _need(lever in (None, *BYTE), w, f"{lever} is not a byte") for i, e in enumerate(m.effects): _need(e.move in m.moves, f"effect[{i}]", f"move {e.move!r} is not in moves") diff --git a/packages/mhfu-port/tests/test_manifest.py b/packages/mhfu-port/tests/test_manifest.py index 35d90c67..9910bd92 100644 --- a/packages/mhfu-port/tests/test_manifest.py +++ b/packages/mhfu-port/tests/test_manifest.py @@ -413,6 +413,14 @@ def test_steer_window(): M.loads(_with(OWN + "[moves.o.steer]\nwindow = [1, 2]\n")) +def test_attack_levers(): + m = M.loads(_with("[[attack]]\nid = 34\npower = 18\nkind = 2\nactive = 59\nvolume = 19\n")) + (a,) = m.attacks + assert (a.kind, a.active, a.is_empty) == (2, 59, False) + assert M.loads(M.dumps(m)) == m and M.Attack(1).is_empty + assert not M.Attack(1, active=0).is_empty and not M.Attack(1, kind=1).is_empty + + def test_brain_attacks(): text = BRAIN + "[brain.pairs]\n'7,14' = 'own'\n[brain.attacks]\n'26' = 40\n" m = M.loads(_with(text)) @@ -486,6 +494,8 @@ def test_cuts_share_a_source(): ("[[hitbox]]\nbone = 1\nradius = 1.0\nset = -1\n", "set"), ("[[hitzone]]\nstate = 's'\nrows = [[0]]\n", "rows"), ("[[attack]]\nid = 1\npower = 256\n", "byte"), + ("[[attack]]\nid = 1\nkind = 256\n", "byte"), + ("[[attack]]\nid = 1\nactive = -1\n", "byte"), ("[[attack]]\nid = 1\n[[attack]]\nid = 1\n", "twice"), ("[[effect]]\nmove = 'm'\nframe = 1\nid = 1\nbone = 1\n", "not in moves"), ("[moves.m]\nmain = 1\nanim = 1\n", "both main and sub"), diff --git a/packages/mhfu-port/tests/test_own.py b/packages/mhfu-port/tests/test_own.py index 7ed5a166..b992c6ec 100644 --- a/packages/mhfu-port/tests/test_own.py +++ b/packages/mhfu-port/tests/test_own.py @@ -5,8 +5,10 @@ import numpy as np import pytest +from mhfu import hitbox +from mhfu.files import em_overlay from mhfu_port import manifest, moves, p3hits, sequence -from mhfu_port.brain import own +from mhfu_port.brain import own, spare from mhfu_port.manifest import AttackWindow, Steer from mhfu_port.p3hits import Hit, Length, Note, Plan, Step, Turn @@ -51,6 +53,10 @@ def __init__(self, plan: Plan, hosts=HOSTS, active=10) -> None: self.lengths = LENGTHS self.ms = SimpleNamespace(pairs={plan.pair: object()}) self.hosts, self.active, self._plan = hosts, active, plan + self.hits = SimpleNamespace(records=[]) + self.table = None # no spare records to hand out + self.binding = SimpleNamespace(joint_of={}) + self.bind = np.zeros((0, 3)) def plan(self, pair: object) -> Plan: return self._plan @@ -60,7 +66,7 @@ def match(self, record: int) -> own.Match: return own.Match(record, host, 0.5 if host is not None else 0.1, False, 4) def attack(self, host: int) -> object: - return SimpleNamespace(unknown_01=self.active) + return SimpleNamespace(unknown_01=self.active, power=50) def derive(plan: Plan, **kw): @@ -69,6 +75,7 @@ def derive(plan: Plan, **kw): fake = Fake(plan, **kw) d = own.Derivation(m, fake, lambda: fake, found.append) # type: ignore[arg-type] d.run(plan.pair) + d.finish() return m, found @@ -127,6 +134,7 @@ def test_an_attack_with_no_host_record_is_left_out(): assert [a.id for a in m.moves["p3_7_13"].attacks] == [7] (f,) = [f for f in found if f.code == "P3_OWN_ATTACK"] assert f.level == "warning" and "26" in f.text and f.item == "p3_7_13" + assert "no spare (the host has no attack table)" in f.text def test_the_move_player_holds_four_attacks(): @@ -248,18 +256,39 @@ def test_brute_charge(derived): def test_brute_rush_and_leap(derived): m, found = derived + feet = m.attacks[0].id # donor record 26 plays its spare record assert [m.moves[n].clip for n in ("p3_7_14", "p3_7_14_2")] == ["p3_59", "p3_60"] - assert windows(m, "p3_7_14") == [(15, 85, 144)] - assert windows(m, "p3_7_14_2") == [(15, 0, 14), (7, 94, 124), (8, 96, 106)] + assert windows(m, "p3_7_14") == [(15, 85, 144), (feet, 85, 144)] + assert windows(m, "p3_7_14_2") == [(15, 0, 14), (feet, 0, 14), (7, 94, 124), (8, 96, 106)] assert m.moves["p3_7_14"].steer.rate == 364 and m.moves["p3_7_14"].length == 72 assert m.moves["p3_7_14"].steer.walls and not m.moves["p3_7_14_2"].steer.walls assert windows(m, "p3_7_15") == [(4, 75, 100)] assert m.moves["p3_7_15"].steer == Steer(turn="hunter", rate=273, walls=False, window=(0, 20)) - left_out = [f for f in found if f.code == "P3_OWN_ATTACK"] - assert left_out and all("record 26" in f.text for f in left_out) + assert not [f for f in found if f.code == "P3_OWN_ATTACK"] assert {f.item for f in found if f.code == "P3_OWN_BRANCH"} >= {"p3_7_14"} +def host_table(data): + return hitbox.primary_table(hitbox.tables(data.fu.overlay(em_overlay(75)))) + + +def test_brute_hind_feet_get_a_spare_record(derived, data): + m, found = derived + (a,) = m.attacks + assert a.id in range(34, 107) and a.id != 70 and a.id not in spare.RULED[75] + assert (a.kind, a.label) == (2, "donor 26") and a.volume is not None and a.volume >= 18 + feet = [h for h in m.hitboxes if h.set == a.volume] + assert sorted(h.bone for h in feet) == [24, 29], "the donor's two volumes, on the port's joints" + assert {(h.radius, h.shape) for h in feet} == {(130.0, "sphere")} + # power 25 scaled by the median of 1.0, 0.8, 0.67 and 0.42, the matched records' ratios + assert a.power == 18 and a.active == 59, "its node is held from frame 85 to the clip's end" + (note,) = [f for f in found if f.code == "P3_OWN_SPARE"] + assert "donor record 26" in note.text and f"spare record {a.id}" in note.text + t = host_table(data) + assert len(t.volumes[a.volume].volumes) >= len(feet) and any(t.attacks[a.id].raw) + manifest.check(m) + + def test_brute_pairs_without_attacks(derived): m, _ = derived for name in ("p3_1_15", "p3_2_20", "p3_2_23", "p3_2_19", "p3_2_19_2", "p3_2_19_3"): @@ -277,7 +306,8 @@ def test_an_attacks_override_beats_the_match(data): m.brain.attacks["26"] = 25 out, found = own.expand(m, data) assert [a.id for a in out.moves["p3_7_14"].attacks] == [15, 25] - assert not [f for f in found if f.code == "P3_OWN_ATTACK"] + assert not [f for f in found if f.code in ("P3_OWN_ATTACK", "P3_OWN_SPARE")] + assert out.attacks == [] and out.hitboxes == [], "a chosen record needs no spare" def test_slots_follow_the_expanded_manifest(derived): @@ -287,19 +317,48 @@ def test_slots_follow_the_expanded_manifest(derived): assert slots["p3_7_13"] == sorted(slots).index("p3_7_13") -def test_zinogre_derives_without_an_error(data): +def zinogre(pairs: list[tuple[int, int]]) -> manifest.Manifest: text = (PORTS / "zinogre.toml").read_text() + body = "".join(f"'{a},{b}' = 'own'\n" for a, b in pairs) + brain = f"\n[brain]\ndonor = 40\nsplice = 'base_3'\nentry = 'base_12'\n[brain.pairs]\n{body}" + return manifest.loads(text + brain) + + +def test_zinogre_derives_without_an_error(data): walk = p3hits.Walk.of(data.p3rd, 40, data.p3rd.read(manifest.Source(model=5339).anim)) keys = [k for k, p in walk.ms.pairs.items() if p.attacks][:8] - pairs = "".join(f"'{a},{b}' = 'own'\n" for a, b in keys) - brain = f"\n[brain]\ndonor = 40\nsplice = 'base_3'\nentry = 'base_12'\n[brain.pairs]\n{pairs}" - m, found = own.expand(manifest.loads(text + brain), data) + m, found = own.expand(zinogre(keys), data) assert not [f for f in found if f.level == "error"] manifest.check(m) made = [mv for n, mv in m.moves.items() if n.startswith("p3_")] assert len(made) >= len(keys) and sum(len(mv.attacks) for mv in made) >= len(keys) +UNMATCHED = [(7, 2), (7, 3), (7, 8), (7, 9), (7, 15), (7, 40), (7, 162), (7, 163)] +"""Zinogre's pairs that play the nine donor records no Tigrex record matches (4, 7, 18, 34, 35, +36, 37, 43, 44); all its 111 pairs with attacks play 27 records, 154 hits.""" + + +def test_zinogre_unmatched_records_get_spares(data): + m, found = own.expand(zinogre(UNMATCHED), data) + assert not [f for f in found if f.level == "error" or f.code == "P3_OWN_ATTACK"] + made = [a for a in m.attacks if a.label.startswith("donor ")] + assert sorted(int(a.label.split()[1]) for a in made) == [4, 7, 18, 34, 35, 36, 37, 43, 44] + assert len([f for f in found if f.code == "P3_OWN_SPARE"]) == 9 + ids = [a.id for a in made] + authored = {6, 31} # its own [[attack]] levers + assert len(set(ids)) == 9 and not set(ids) & {*spare.RULED[75], 70, *authored} + t = host_table(data) + for a in made: + assert ( + a.volume is not None and a.volume >= 18 and a.volume != 2 and any(t.attacks[a.id].raw) + ) + boxes = [h for h in m.hitboxes if h.set == a.volume] + assert 1 <= len(boxes) <= len(t.volumes[a.volume].volumes), f"set {a.volume}" + assert {a.kind for a in made} == {1, 2}, "record 18 is the one weak hit" + manifest.check(m) + + def test_a_pair_the_donor_lacks_stops_the_build(data): m = brute() assert m.brain is not None diff --git a/packages/mhfu-port/tests/test_own_cli.py b/packages/mhfu-port/tests/test_own_cli.py index 8a313e72..ffc340f2 100644 --- a/packages/mhfu-port/tests/test_own_cli.py +++ b/packages/mhfu-port/tests/test_own_cli.py @@ -44,6 +44,7 @@ def test_the_cli_lists_the_moves(data, capsys): out = capsys.readouterr().out assert code == 0 and "p3_7_13 (slot 6) clip 61 charge" in out assert "donor 21 -> host record 7, frames 135-165" in out + assert "donor 26 -> host record 34 (spare), frames 85-144" in out def test_the_moves_module_carries_the_derived_moves(data, tmp_path): @@ -57,7 +58,7 @@ def test_the_moves_module_carries_the_derived_moves(data, tmp_path): assert f'p3_7_14 = {{ entry = {entry}, slot = 7, clip = "p3_59"' in text assert "attacks = { { 135, 7, 165 }, { 138, 8, 148 } }" in text assert 'turn = "hunter", rate = 546, window = { 10, 40 }' in text - assert 'after = "p3_7_14_2"' in text and any(f.code == "P3_OWN_ATTACK" for f in seen) + assert 'after = "p3_7_14_2"' in text and any(f.code == "P3_OWN_SPARE" for f in seen) def test_the_splice_enters_the_derived_moves(data, capsys): diff --git a/packages/mhfu-port/tests/test_spare.py b/packages/mhfu-port/tests/test_spare.py new file mode 100644 index 00000000..236d1a22 --- /dev/null +++ b/packages/mhfu-port/tests/test_spare.py @@ -0,0 +1,309 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 sp00ktober +from types import SimpleNamespace + +import numpy as np +from mhfu_port import manifest +from mhfu_port.brain import own, spare +from mhfu_port.manifest import AttackWindow +from mhfu_port.p3hits import Hit, Hits, Length, Plan, Record, Step, Volume + +TEXT = """ +schema = 2 +[port] +name = "z" +host_species = 75 +pac = "z.bin" +[source] +model = 5248 +[brain] +donor = 23 +splice = "base_3" +entry = "base_12" +[brain.pairs] +"7,13" = "own" +""" +RECORDS = 107 +ROAR_KIND, WEAK_KIND = 29, 1 + + +def host_attack(i: int, kinds: dict[int, int], sets: int) -> SimpleNamespace: + """em75's record `i`: the species' own 1..32 on sets 0..17, the others on 18..55.""" + own_set = i % 18 if i < 33 else 18 + (i - 34) % (sets - 18) + return SimpleNamespace( + kind=kinds.get(i, 2), + family=0x10 if kinds.get(i) == ROAR_KIND else 0, + angle=0, + half_0c=4096 if i == 38 else 0, + volume_set=own_set, + power=50, + unknown_01=10, + ) + + +def table(sizes: dict[int, int] | None = None, records: int = RECORDS, **kinds: int): + """A host table shaped like em75's: `sizes` the record count of a set (default 2), `kinds` + the kind of records named `r`.""" + sets = 56 + sized = {i: 2 for i in range(sets)} | (sizes or {}) + by_id = {int(k[1:]): v for k, v in kinds.items()} + return SimpleNamespace( + attacks=[host_attack(i, by_id, sets) for i in range(records)], + volumes=[SimpleNamespace(volumes=[object()] * sized[i]) for i in range(sets)], + ) + + +def vol(bone: int, shape: str = "sphere", **kw) -> Volume: + base = Volume(bone, 0, shape, 0, 130.0, (0.0, 1.0, 2.0), (0.0, 0.0, 0.0)) + return base._replace(**kw) + + +def donor(*records: Record, sets: tuple[tuple[Volume, ...], ...] = ((vol(5),),)) -> Hits: + return Hits((Record(0, 0, 0, 0, 0, 0, 0), *records), sets) + + +def rec(i: int, power: int = 50, kind: int = 2, length: int = 10, volumes: int = 0) -> Record: + return Record(i, 0, length, power, kind, 40, volumes) + + +def manifest_of(extra: str = "") -> manifest.Manifest: + return manifest.loads(TEXT + extra) + + +BIND = np.array( + [[0.0, 0.0, 0.0]] * 8 + [[0.0, 0.0, 0.0], [5.0, 6.0, 7.0]] + [[0.0, 100.0, 0.0]] * 4 +) + + +def spares(m, hits, tbl=None, joint_of=None, scale=0.5) -> spare.Spares: + return spare.Spares(m, hits, tbl or table(), joint_of or {5: 9, 6: 12}, BIND, scale) + + +def test_a_spare_takes_the_first_plain_record_and_a_fitting_set(): + m = manifest_of() + s = spares(m, donor(rec(1, power=50, length=24))) + got = s.take(1) + assert isinstance(got, spare.Spare) and got.id == 34 + assert got.set == 18 and got.family == "hit" and got.joints == (9,) + (a,) = m.attacks + assert (a.id, a.power, a.kind, a.active, a.volume) == (34, 25, 2, 24, 18) + (h,) = m.hitboxes + assert (h.bone, h.radius, h.set, h.shape, h.offset, h.to) == ( + 9, + 130.0, + 18, + "sphere", + [0.0, 1.0, 2.0], + None, + ) + manifest.check(m) + assert s.take(1) is got and len(m.attacks) == 1, "one record, one spare" + + +def test_the_set_is_the_smallest_that_holds_the_volumes(): + m = manifest_of() + three = (vol(5), vol(6), vol(5, offset=(9.0, 9.0, 9.0))) + hits = donor(rec(1), rec(2, volumes=1), sets=((vol(5),), three)) + s = spares(m, hits, table({19: 3, 20: 3, 21: 7, 24: 10})) + one, two = s.take(1), s.take(2) + assert isinstance(one, spare.Spare) and isinstance(two, spare.Spare) + assert (one.set, two.set) == (18, 19), "2 holds one volume; the first of size 3 holds three" + assert [h.set for h in m.hitboxes] == [18, 19, 19, 19] + + +def test_records_on_one_donor_set_share_the_host_set(): + m = manifest_of() + s = spares(m, donor(rec(1), rec(2))) + one, two = s.take(1), s.take(2) + assert isinstance(one, spare.Spare) and isinstance(two, spare.Spare) + assert one.set == two.set and one.id != two.id and len(m.hitboxes) == 1 + + +def test_a_donor_set_too_big_for_any_free_set(): + hits = donor(rec(1), sets=(tuple(vol(5) for _ in range(4)),)) + got = spares(manifest_of(), hits, table(dict.fromkeys(range(18, 56), 3))).take(1) + assert got == "4 volume(s), the largest free set holds 3" + + +def test_authored_and_chosen_ids_and_sets_are_skipped(): + m = manifest_of( + "[[attack]]\nid = 34\npower = 1\n[[hitbox]]\nbone = 1\nradius = 1.0\nset = 18\n" + "[brain.attacks]\n'7' = 35\n" + ) + got = spares(m, donor(rec(1))).take(1) + assert isinstance(got, spare.Spare) and (got.id, got.set) == (36, 19) + + +def test_the_ruled_ids_are_never_handed_out(): + recs = [rec(i) for i in range(1, 70)] + s = spares(manifest_of(), donor(*recs)) + got = [s.take(r.id) for r in recs] + ids = [g.id for g in got if isinstance(g, spare.Spare)] + assert len(ids) == len(set(ids)) == 68, "34..106 but 70 and the four ruled ids" + assert not set(ids) & {*spare.RULED[75], 33, 70} + assert (min(ids), max(ids)) == (34, 106) + assert got[-1] == "the host's table has no spare record" + + +def test_none_left(): + s = spares(manifest_of(), donor(rec(1), rec(2)), table(records=36)) + assert isinstance(s.take(1), spare.Spare) and isinstance(s.take(2), spare.Spare) + none = spares(manifest_of(), donor(rec(1)), table(records=34)) + assert none.take(1) == "the host's table has no spare record" + + +def test_kind_by_family_and_the_record_that_has_it(): + m = manifest_of() + hits = donor( + rec(1, power=0, kind=spare.NO_DAMAGE, length=10), + rec(2, power=15, kind=spare.WEAK), + rec(3, power=60, kind=31), + sets=((vol(5),),), + ) + s = spares(m, hits, table(r44=ROAR_KIND, r45=WEAK_KIND, r46=WEAK_KIND)) + roar, weak, hit = s.take(1), s.take(2), s.take(3) + assert isinstance(roar, spare.Spare) and isinstance(weak, spare.Spare) + assert isinstance(hit, spare.Spare) + assert (roar.family, roar.id) == ("roar", 44) + assert (weak.family, weak.id) == ("weak", 45) + assert (hit.family, hit.id) == ("hit", 34) + kinds = {a.id: (a.kind, a.power) for a in m.attacks} + assert kinds == {44: (spare.HOST_NO_DAMAGE, 0), 45: (1, 8), 34: (2, 30)} + + +def test_the_plainest_record_of_the_kind_wins_over_a_lower_id(): + s = spares(manifest_of(), donor(rec(1)), table(r34=3, r35=3, r36=3, r37=3)) + got = s.take(1) + # 38 is a hit but carries +0x0C, 39 and 40 are ruled: 41 is the first plain one + assert isinstance(got, spare.Spare) and got.id == 41 + + +def test_active_frames_grow_to_cover_a_move_and_stop_at_a_byte(): + m = manifest_of() + s = spares(m, donor(rec(1, length=10))) + s.take(1, 6) + assert m.attacks[0].active == 10 + s.take(1, 59) + assert m.attacks[0].active == 59 + s.take(1, 600) + assert m.attacks[0].active == spare.MAX_ACTIVE + + +def test_volumes_land_on_the_ports_joints(): + m = manifest_of() + vols = ( + vol(5), + vol(6, "capsule", to=(0.0, 0.0, 50.0)), + vol(5, "capsule2", bone2=6, to=(10.0, 0.0, 0.0), radius=77.0), + vol(5, "capsule2", bone2=99, to=(10.0, 0.0, 0.0)), + vol(99), + vol(5, radius=0.0), + vol(125), + ) + s = spares(m, donor(rec(1), sets=(vols,)), table({18: 6}), joint_of={5: 9, 6: 12, 125: 3}) + got = s.take(1) + assert isinstance(got, spare.Spare) and got.volumes == 4 + cap, across, short = m.hitboxes[1:] + assert (m.hitboxes[0].bone, m.hitboxes[0].shape) == (9, "sphere") + assert (cap.bone, cap.shape, cap.to) == (12, "capsule", [0.0, 0.0, 50.0]) + assert (across.bone, across.shape, across.radius) == (9, "capsule", 77.0) + assert across.to == [5.0, 94.0, -7.0] # bone 12 at (0, 100, 0) + to, from bone 9 at (5, 6, 7) + assert short.shape == "sphere" and short.to is None, "a second bone off the rig: a sphere" + + +def test_no_volume_on_the_rig_or_no_such_record(): + m = manifest_of() + s = spares(m, donor(rec(1), sets=((vol(99),),))) + assert s.take(1) == "none of its volumes lands on the port's rig" + assert s.take(40) == "the donor has no such record" + assert not m.attacks and not m.hitboxes, "nothing is kept for a refusal" + assert spare.Spares(m, None, None, {}, BIND).take(1) == "the host has no attack table" + + +def test_a_species_without_spare_records(): + m = manifest_of() + m.port.host_species = 7 + assert spares(m, donor(rec(1))).take(1) == "the host's table has no spare record" + + +def test_family(): + assert spare.family(rec(1, power=0, kind=2)) == "roar" + assert spare.family(rec(1, power=9, kind=spare.NO_DAMAGE)) == "roar" + assert spare.family(rec(1, power=9, kind=1)) == "weak" + assert spare.family(rec(1, power=9, kind=31)) == "hit" + + +# --- derived through a pair --- + + +class Stub: + """A walk and a matcher in one: every donor record in `hosts` matches, the rest do not.""" + + def __init__(self, plan: Plan, hits: Hits, hosts: dict[int, int], tbl=None) -> None: + self.lengths = {61: Length(382.0)} + self.ms = SimpleNamespace(pairs={plan.pair: object()}) + self._plan, self.hits, self.hosts = plan, hits, hosts + self.table = tbl or table() + self.binding = SimpleNamespace(joint_of={5: 9, 6: 12}) + self.bind = BIND + + def plan(self, pair: object) -> Plan: + return self._plan + + def match(self, record: int) -> own.Match: + host = self.hosts.get(record) + return own.Match(record, host, 0.5 if host is not None else 0.12, False, 25) + + def attack(self, host: int) -> object: + return self.table.attacks[host] + + +def derive(hits: Hits, hosts: dict[int, int], spawns: list[Hit], tbl=None): + m = manifest_of("[clips.charge]\nsource = 61\n") + plan = Plan((7, 13), (Step(61, 4, 0.0, "clip", 191, tuple(spawns), ()),), ((0, 0),), True) + found: list = [] + stub = Stub(plan, hits, hosts, tbl) + d = own.Derivation(m, stub, lambda: stub, found.append, [(7, 13)]) # type: ignore[arg-type] + d.run((7, 13)) + d.finish() + return m, found + + +def test_a_donor_record_without_a_match_plays_its_spare(): + hits = donor(rec(1, power=40), rec(2, power=100), rec(3, power=60, length=40)) + spawns = [Hit(0, 1, 0, 135.0, 165.0), Hit(1, 2, 0, 135.0, 165.0), Hit(0, 3, 0, 140.0, 170.0)] + m, found = derive(hits, {1: 7, 2: 8}, spawns) + attacks = m.moves["p3_7_13"].attacks + assert attacks == [ + AttackWindow(7, 135, 165, label="donor 1"), + AttackWindow(8, 135, 165, label="donor 2"), + AttackWindow(34, 140, 170, label="donor 3"), + ] + # host power 50 against donor 40 and 100: ratios 1.25 and 0.5, median 0.875 + (a,) = m.attacks + assert (a.id, a.power, a.kind, a.active) == (34, round(60 * 0.875), 2, 40) + (note,) = [f for f in found if f.code == "P3_OWN_SPARE"] + assert note.level == "info" and note.item == "p3_7_13" + assert "donor record 3" in note.text and "spare record 34 on set 18" in note.text + assert "P3_OWN_ATTACK" not in [f.code for f in found] + manifest.check(m) + + +def test_the_scale_falls_back_where_nothing_matched(): + m, _ = derive(donor(rec(1, power=100)), {}, [Hit(0, 1, 0, 10.0, 20.0)]) + assert m.attacks[0].power == round(100 * spare.POWER_SCALE) + + +def test_a_refused_spare_is_a_warning_and_the_attack_is_left_out(): + spawns = [Hit(0, 1, 0, 10.0, 20.0), Hit(1, 9, 0, 10.0, 20.0)] + m, found = derive(donor(rec(1)), {}, spawns, table(records=34)) + assert m.moves["p3_7_13"].attacks == [] + warnings = [f for f in found if f.code == "P3_OWN_ATTACK"] + assert len(warnings) == 2 and "no spare" in warnings[0].text and "record 9" in warnings[1].text + assert not [f for f in found if f.code == "P3_OWN_SPARE"] + + +def test_a_node_held_past_its_record_widens_it_and_the_window_is_not_cut(): + m, found = derive(donor(rec(1, length=10)), {}, [Hit(0, 1, 0, 85.0, 144.0)]) + assert m.attacks[0].active == 59 + assert "P3_OWN_ACTIVE" not in [f.code for f in found]