From d2663273520ed663f8188a1acd76bc94ae1f59f1 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:58:19 +0200 Subject: [PATCH 1/7] feat(port): a [brain.pairs] entry may play the move another pair derives A turn wrapper or a twin pair names the own move OWN derives for the pair it leads to ("7,23" = [[1, 2], "p3_7_0"]) instead of deriving a duplicate; Brain.derived and Brain.shared say which, and the splice does not report a pair whose move another pair plays as unused. Co-Authored-By: Claude Sonnet 5.5 --- packages/mhfu-port/src/mhfu_port/brain/own.py | 9 +---- .../mhfu-port/src/mhfu_port/brain/splice.py | 6 ++-- packages/mhfu-port/src/mhfu_port/manifest.py | 36 +++++++++++++++---- packages/mhfu-port/tests/test_manifest.py | 19 ++++++++++ packages/mhfu-port/tests/test_splice.py | 7 ++++ 5 files changed, 60 insertions(+), 17 deletions(-) diff --git a/packages/mhfu-port/src/mhfu_port/brain/own.py b/packages/mhfu-port/src/mhfu_port/brain/own.py index 893cca17..e89fe1ca 100644 --- a/packages/mhfu-port/src/mhfu_port/brain/own.py +++ b/packages/mhfu-port/src/mhfu_port/brain/own.py @@ -36,7 +36,6 @@ from ..data import Data from ..manifest import ( MOVE_ATTACKS, - OWN, OWN_MOVES, AttackWindow, Brain, @@ -46,7 +45,6 @@ Move, Steer, own_name, - targets_of, ) from ..p3hits import Plan, Step, Walk from .spare import HOST_NO_DAMAGE, NO_DAMAGE, POWER_SCALE, Spare, Spares @@ -414,12 +412,7 @@ def _steer(self, step: Step, walls: bool) -> Steer: def pairs(b: Brain) -> list[Pair]: """The donor pairs whose own move the build derives, in order.""" - found = [] - for key, target in b.pairs.items(): - main, _, sub = key.partition(",") - if OWN in targets_of(target): - found.append((int(main), int(sub))) - return sorted(found) + return sorted(b.derived().values()) def expand(m: Manifest, data: Data) -> tuple[Manifest, list[Finding]]: diff --git a/packages/mhfu-port/src/mhfu_port/brain/splice.py b/packages/mhfu-port/src/mhfu_port/brain/splice.py index 02ba0038..f5bffa86 100644 --- a/packages/mhfu-port/src/mhfu_port/brain/splice.py +++ b/packages/mhfu-port/src/mhfu_port/brain/splice.py @@ -887,9 +887,9 @@ def _failed(es: EditSet, spec: manifest.Brain, text: str) -> Spliced: def _unused(spec: manifest.Brain, t: Translator, named: Collection[Pair] = ()) -> list[Finding]: - """Manifest entries that match nothing in the donor's code and that the charge table does not - name.""" - met = {f"{m},{s}" for m, s in [*t.entered, *named]} + """Manifest entries that match nothing in the donor's code, that the charge table does not name + and whose move no other entry plays.""" + met = {f"{m},{s}" for m, s in [*t.entered, *named, *spec.shared()]} out = [ Finding("warning", "P3_PAIR_UNUSED", f"{k} is mapped but the donor code never enters it") for k in spec.pairs diff --git a/packages/mhfu-port/src/mhfu_port/manifest.py b/packages/mhfu-port/src/mhfu_port/manifest.py index 1144a4ef..18c0ec05 100644 --- a/packages/mhfu-port/src/mhfu_port/manifest.py +++ b/packages/mhfu-port/src/mhfu_port/manifest.py @@ -405,9 +405,9 @@ class Brain: first free variable (`mhfu.em.script.vars`).""" pairs: dict[str, Plays] = field(default_factory=dict) """What each donor pair, keyed `"main,sub"`, plays: a host pair, the name of an own move in - `[moves]`, or `OWN`; or a list of those, each entered in turn (`[[1, 2], "own"]`: the host's - turn, then the move). The splice enters an own move as `enter OWN_MOVE_MAIN k 0`, `k` its - `moves.slots` slot.""" + `[moves]`, or `OWN`, or the name `OWN` gives another pair (`derived`); or a list of those, each + entered in turn (`[[1, 2], "own"]`: the host's turn, then the move). The splice enters an own + move as `enter OWN_MOVE_MAIN k 0`, `k` its `moves.slots` slot.""" when: dict[str, bool] = field(default_factory=dict) """The arm a donor test with no host counterpart takes, by its MHP3rd opcode name.""" attacks: dict[str, int] = field(default_factory=dict) @@ -443,6 +443,30 @@ def host_pair(self, pair: Pair) -> Pair | None: t = self.target(pair) return t if isinstance(t, tuple) else None + def derived(self) -> dict[str, Pair]: + """The own moves `OWN` stands for, by name, each with the donor pair it comes from. Another + pair plays one by naming it: `"7,23" = [[1, 2], "p3_7_0"]` turns, then plays pair (7, 0)'s + move.""" + pairs = {pair_of(key): plays for key, plays in self.pairs.items()} + return {own_name(p): p for p, plays in pairs.items() if OWN in targets_of(plays)} + + def shared(self) -> set[Pair]: + """The pairs mapped to `OWN` whose move another pair plays, so the script need not enter + them.""" + names = self.derived() + return { + names[t] + for key in self.pairs + for t in self.targets(pair_of(key)) + if isinstance(t, str) and t in names and names[t] != pair_of(key) + } + + +def pair_of(key: str) -> Pair: + """The donor pair a `[brain.pairs]` key names.""" + main, _, sub = key.partition(",") + return int(main), int(sub) + def targets_of(plays: Plays | None) -> list[Target]: """The targets of a `[brain.pairs]` value, as written.""" @@ -686,9 +710,11 @@ def _validate(m: Manifest) -> None: _need(key not in claimed, w, f"claims main {k} as moves.{claimed.get(key)} does") claimed[key] = name if m.brain is not None: + _brain(m.brain) + derived = m.brain.derived() for donor, plays in m.brain.pairs.items(): for t in targets_of(plays): - if isinstance(t, str) and t != OWN: + if isinstance(t, str) and t != OWN and t not in derived: target = m.moves.get(t) _need( target is not None and target.own, @@ -731,8 +757,6 @@ def _validate(m: Manifest) -> None: _need(lever in (None, *BYTE), w, f"{lever} is not a byte") for i, e in enumerate(m.effects): _need(e.move in m.moves, f"effect[{i}]", f"move {e.move!r} is not in moves") - if m.brain is not None: - _brain(m.brain) own = sum(mv.own for mv in m.moves.values()) _need(own <= OWN_MOVES, "moves", f"{own} own moves, the framework holds {OWN_MOVES}") validate(m) diff --git a/packages/mhfu-port/tests/test_manifest.py b/packages/mhfu-port/tests/test_manifest.py index 9910bd92..4741d9a4 100644 --- a/packages/mhfu-port/tests/test_manifest.py +++ b/packages/mhfu-port/tests/test_manifest.py @@ -254,6 +254,25 @@ def test_brain_pairs_may_be_lists(): assert M.loads(M.dumps(m)) == m +def test_a_pair_plays_the_move_another_pair_derives(): + text = BRAIN + "[brain.pairs]\n'7,0' = 'own'\n'7,23' = [[1, 2], 'p3_7_0']\n'7,24' = 'p3_7_0'\n" + b = M.loads(_with(text)).brain + assert b is not None + assert b.derived() == {"p3_7_0": (7, 0)} and b.shared() == {(7, 0)} + assert b.targets((7, 23)) == [(1, 2), "p3_7_0"] and b.target((7, 24)) == "p3_7_0" + assert M.loads(M.dumps(M.loads(_with(text)))) == M.loads(_with(text)) + alone = M.loads(_with(BRAIN + "[brain.pairs]\n'7,0' = 'own'\n'7,1' = [[1, 2], 'own']\n")).brain + assert alone is not None and alone.shared() == set() + assert alone.derived() == {"p3_7_0": (7, 0), "p3_7_1": (7, 1)} + + +def test_a_name_no_pair_derives_is_no_own_move(): + with pytest.raises(M.ManifestError, match="'p3_7_0' is no own move"): + M.loads(_with(BRAIN + "[brain.pairs]\n'7,23' = [[1, 2], 'p3_7_0']\n")) + with pytest.raises(M.ManifestError, match="'p3_7_0' is no own move"): + M.loads(_with(BRAIN + "[brain.pairs]\n'7,0' = [1, 2]\n'7,23' = 'p3_7_0'\n")) + + @pytest.mark.parametrize( ("pairs", "why"), [ diff --git a/packages/mhfu-port/tests/test_splice.py b/packages/mhfu-port/tests/test_splice.py index 241ccabe..76550140 100644 --- a/packages/mhfu-port/tests/test_splice.py +++ b/packages/mhfu-port/tests/test_splice.py @@ -1175,6 +1175,13 @@ def test_a_pair_the_charge_table_names_is_not_unused(): assert "1,4" in [f.text.split()[0] for f in plain.findings if f.code == "P3_PAIR_UNUSED"] +def test_a_pair_whose_move_another_pair_plays_is_not_unused(): + sp = spec(pairs=PAIRS | {"5,5": "own", "5,6": [(1, 2), "p3_5_5"]}) + s = splice.splice(donor(base_0=enter(2, 3)), host(), sp, slots={"p3_5_5": 0}) + unused = [f.text.split()[0] for f in s.findings if f.code == "P3_PAIR_UNUSED"] + assert "5,5" not in unused and "5,6" in unused + + def test_a_draw_after_a_draw_is_stepped(): pick = blk("random", arms=[case(50, ""), case(50, enter(2, 3))]) both = f"{pick} {blk('random', arms=[case(50, enter(2, 4)), case(50, enter(2, 3))])}" From 3dc0167187b7fa87a7c9c9c0371f8e75b2bae16e Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:24:05 +0200 Subject: [PATCH 2/7] fix(port): splice prints a pair that turns, then plays a move The summary sorted its uses by donor pair and target, which a pair that plays a host pair and an own move (a list value) made compare a tuple with a name. Co-Authored-By: Claude Sonnet 5.5 --- .../mhfu-port/src/mhfu_port/cli/splice.py | 4 +++- packages/mhfu-port/tests/test_own_cli.py | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/mhfu-port/src/mhfu_port/cli/splice.py b/packages/mhfu-port/src/mhfu_port/cli/splice.py index 3311f5a6..d31acbeb 100644 --- a/packages/mhfu-port/src/mhfu_port/cli/splice.py +++ b/packages/mhfu-port/src/mhfu_port/cli/splice.py @@ -94,7 +94,9 @@ def summary( grown = len(built.image.data) - (built.lift.end - built.lift.base) added += f", {grown:+} bytes, worst run {worst.count} of {LIMIT}" lines = [f" {added}"] - for (donor, target), uses in sorted(spliced.uses.items(), key=lambda use: use[0]): + for (donor, target), uses in sorted( + spliced.uses.items(), key=lambda use: (use[0][0], str(use[0][1])) + ): to = ( f"own {target} (slot {spliced.slots[target]})" if isinstance(target, str) diff --git a/packages/mhfu-port/tests/test_own_cli.py b/packages/mhfu-port/tests/test_own_cli.py index ffc340f2..96ae9302 100644 --- a/packages/mhfu-port/tests/test_own_cli.py +++ b/packages/mhfu-port/tests/test_own_cli.py @@ -4,8 +4,10 @@ from types import SimpleNamespace from mhfu_port import layout, manifest, motion, moves, slots +from mhfu_port.brain.splice import Spliced from mhfu_port.cli import main from mhfu_port.cli.build import modules +from mhfu_port.cli.splice import summary PORTS = Path(__file__).parents[3] / "ports" TEXT = """ @@ -66,3 +68,21 @@ def test_the_splice_enters_the_derived_moves(data, capsys): assert main(["splice", str(PORTS / "brute_tigrex.toml"), *args]) == 0 out = capsys.readouterr().out assert "pair 7,13 -> own p3_7_13 (slot 6)" in out and "pair 7,14 -> own p3_7_14 (slot 7)" in out + + +def test_the_summary_sorts_a_pair_that_turns_then_plays_a_move(): + + uses = { + ((7, 23), (1, 2)): 3, + ((7, 23), "p3_7_0"): 3, + ((7, 0), "p3_7_0"): 1, + ((2, 5), (1, 2)): 4, + } + spliced = Spliced(None, [], {}, uses, {"p3_7_0": 5}) # type: ignore[arg-type] + lines = summary(spliced, SimpleNamespace(image=None), []).splitlines() # type: ignore[arg-type] + assert [line for line in lines if "pair" in line] == [ + " pair 2,5 -> 1,2 x4", + " pair 7,0 -> own p3_7_0 (slot 5) x1", + " pair 7,23 -> 1,2 x3", + " pair 7,23 -> own p3_7_0 (slot 5) x3", + ] From d89afb6709cfd562a0d81b1fe1021afcdfb0d851 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:43:55 +0200 Subject: [PATCH 3/7] feat(studio): renaming or deleting an own move follows [brain.pairs] Brain.users names the pairs that play a move and Brain.rename_move renames it in each item; the studio's rename carries them over and a move a pair plays is not deleted. Co-Authored-By: Claude Sonnet 5.5 --- apps/studio/src/mhfu_studio/monster/clips.py | 8 ++++++-- packages/mhfu-port/src/mhfu_port/manifest.py | 11 +++++++++++ packages/mhfu-port/tests/test_manifest.py | 10 ++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/studio/src/mhfu_studio/monster/clips.py b/apps/studio/src/mhfu_studio/monster/clips.py index 0af1debf..86c0a271 100644 --- a/apps/studio/src/mhfu_studio/monster/clips.py +++ b/apps/studio/src/mhfu_studio/monster/clips.py @@ -479,7 +479,7 @@ def drop(m: Manifest) -> None: def rename_move(doc: PortDocument, old: str, new: str) -> str: - """Renames a move and every `after`, block and effect that names it.""" + """Renames a move and every `after`, block, `[brain.pairs]` item and effect that names it.""" new = check_name(new) if old not in doc.manifest.moves: raise ManifestError(f"no move {old!r}") @@ -493,6 +493,8 @@ def rename(m: Manifest) -> None: for mv in m.moves.values(): mv.after = new if mv.after == old else mv.after behaviour.rename_move(m, old, new) + if m.brain is not None: + m.brain.rename_move(old, new) for e in m.effects: e.move = new if e.move == old else e.move @@ -501,9 +503,11 @@ def rename(m: Manifest) -> None: def move_users(m: Manifest, name: str) -> list[str]: - """What names move `name`: other moves' `after`, the behaviour graph and effects.""" + """What names move `name`: other moves' `after`, the behaviour graph, `[brain.pairs]` and + effects.""" out = [f"moves.{k} (after)" for k, mv in m.moves.items() if mv.after == name] out += behaviour.uses(m, name) + out += [f"brain.pairs.{k}" for k in ([] if m.brain is None else m.brain.users(name))] out += [f"effect {e.id}" for e in m.effects if e.move == name] return out diff --git a/packages/mhfu-port/src/mhfu_port/manifest.py b/packages/mhfu-port/src/mhfu_port/manifest.py index 18c0ec05..2e7c2edf 100644 --- a/packages/mhfu-port/src/mhfu_port/manifest.py +++ b/packages/mhfu-port/src/mhfu_port/manifest.py @@ -443,6 +443,17 @@ def host_pair(self, pair: Pair) -> Pair | None: t = self.target(pair) return t if isinstance(t, tuple) else None + def users(self, move: str) -> list[str]: + """The `[brain.pairs]` keys that play the own move `move`.""" + return [k for k in self.pairs if move in self.targets(pair_of(k))] + + def rename_move(self, old: str, new: str) -> None: + """Every `[brain.pairs]` item that plays the own move `old` plays `new`.""" + for key, plays in self.pairs.items(): + if old in targets_of(plays): + renamed = [new if t == old else t for t in targets_of(plays)] + self.pairs[key] = renamed if isinstance(plays, list) else renamed[0] + def derived(self) -> dict[str, Pair]: """The own moves `OWN` stands for, by name, each with the donor pair it comes from. Another pair plays one by naming it: `"7,23" = [[1, 2], "p3_7_0"]` turns, then plays pair (7, 0)'s diff --git a/packages/mhfu-port/tests/test_manifest.py b/packages/mhfu-port/tests/test_manifest.py index 4741d9a4..f72ecc17 100644 --- a/packages/mhfu-port/tests/test_manifest.py +++ b/packages/mhfu-port/tests/test_manifest.py @@ -266,6 +266,16 @@ def test_a_pair_plays_the_move_another_pair_derives(): assert alone.derived() == {"p3_7_0": (7, 0), "p3_7_1": (7, 1)} +def test_the_pairs_that_play_a_move_follow_its_name(): + own = "[moves.slam]\nanim = 40\n" + BRAIN + text = own + "[brain.pairs]\n'7,13' = 'slam'\n'7,14' = [[1, 2], 'slam']\n'7,15' = 'own'\n" + b = M.loads(_with(text)).brain + assert b is not None and b.users("slam") == ["7,13", "7,14"] and b.users("p3_7_15") == ["7,15"] + b.rename_move("slam", "bash") + assert b.pairs["7,13"] == "bash" and b.pairs["7,14"] == [(1, 2), "bash"] + assert b.pairs["7,15"] == "own" and b.users("slam") == [] + + def test_a_name_no_pair_derives_is_no_own_move(): with pytest.raises(M.ManifestError, match="'p3_7_0' is no own move"): M.loads(_with(BRAIN + "[brain.pairs]\n'7,23' = [[1, 2], 'p3_7_0']\n")) From 68b9ad5f60a60e09c208b6cbfd99db24d9f9b503 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:43:56 +0200 Subject: [PATCH 4/7] feat(port): splice prints each charge-up's gains Co-Authored-By: Claude Sonnet 5.5 --- framework/README.md | 3 ++- packages/mhfu-port/src/mhfu_port/cli/splice.py | 3 ++- packages/mhfu-port/src/mhfu_port/p3charge.py | 2 +- packages/mhfu-port/tests/test_charge.py | 14 ++++++++++++++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/framework/README.md b/framework/README.md index cb1ccff2..9b6d0ef1 100644 --- a/framework/README.md +++ b/framework/README.md @@ -223,7 +223,8 @@ start sets charged, durability 100, gauge 100. Durability 0 or a `discharge` pai traps) discharges it: gauge 0, `cooldown_out` frames of cooldown. The state stays across areas; a new monster, the quest's end, `mhfu.em_clear()` and `mhfu.em_charge(nil)` reset or drop it, and `mhfu.em_charge_status()` reads it. A moves module's `charge` table does all of it with the port, -its `ups` keys and `transform` by move name or slot. +its `ups` keys and `transform` by move name or slot; `mhfu-port` writes it from the donor's code +with `[brain] charge` (docs/formats/p3rd_ai_script.md). ## AI scripts from the stick diff --git a/packages/mhfu-port/src/mhfu_port/cli/splice.py b/packages/mhfu-port/src/mhfu_port/cli/splice.py index d31acbeb..4058d822 100644 --- a/packages/mhfu-port/src/mhfu_port/cli/splice.py +++ b/packages/mhfu-port/src/mhfu_port/cli/splice.py @@ -110,8 +110,9 @@ def summary( f"tired {stamina.tired} frames" ) if charging is not None: + ups = ", ".join(f"{n} {g[0]}/{g[1]}/{g[2]}/{g[3]}" for n, g in sorted(charging.ups.items())) lines.append( - f" charge: {len(charging.ups)} charge-up moves, transform {charging.transform}; " + f" charge: {ups or 'no charge-up'}; transform {charging.transform}; " f"chips {charging.chip_gauge} gauge, {charging.chip_durability} durability; " f"cooldowns {charging.cooldown_up} and {charging.cooldown_out}; " f"{len(charging.discharge)} discharge pairs" diff --git a/packages/mhfu-port/src/mhfu_port/p3charge.py b/packages/mhfu-port/src/mhfu_port/p3charge.py index 12a6e4fa..60dfc670 100644 --- a/packages/mhfu-port/src/mhfu_port/p3charge.py +++ b/packages/mhfu-port/src/mhfu_port/p3charge.py @@ -54,7 +54,7 @@ BANDS = (1.0, 0.85, 0.6, 0.2) """An HP ratio inside each of a gain's bands (at least 95 %, 75 %, 40 %, below).""" ROW = struct.Struct(" Date: Thu, 22 Oct 2026 01:46:55 +0200 Subject: [PATCH 5/7] feat(ports): the Zinogre's [brain], and the hand-built rules that fought it removed [brain] splices the donor's base_3 into em75's with fatigue and charge. A donor pair plays a host pair, the authored move that is the donor's (charge_up, dash, dash_stop, break_howl) or an own move derived from it; a turn wrapper turns, then plays the move of the pair it leads to. The registry's 2048 turn keys hold the authored moves' 991 and the derived ones that fit: the groups that did not play the nearest that did. Removed as they fought the brain: the lunge's claim, the stamp rules after the skid and the dash, the flinch counter and charge-up from idle, the far-dash and low-HP howl, the enrage after the charge, and the dash's stop. The tests and the README follow. Co-Authored-By: Claude Sonnet 5.5 --- apps/studio/tests/monster/test_behaviour.py | 55 +- .../studio/tests/monster/ui/test_behaviour.py | 13 +- docs/formats/p3rd_ai_script.md | 10 +- packages/mhfu-port/tests/test_behaviour.py | 88 +-- packages/mhfu-port/tests/test_moves.py | 23 +- packages/mhfu-port/tests/test_own.py | 8 +- .../mhfu-port/tests/test_zinogre_brain.py | 290 ++++---- ports/README.md | 31 +- ports/zinogre.toml | 653 ++++++++++-------- 9 files changed, 654 insertions(+), 517 deletions(-) diff --git a/apps/studio/tests/monster/test_behaviour.py b/apps/studio/tests/monster/test_behaviour.py index 616f5e32..68eaf90e 100644 --- a/apps/studio/tests/monster/test_behaviour.py +++ b/apps/studio/tests/monster/test_behaviour.py @@ -366,17 +366,19 @@ def test_the_palette_offers_every_kind(monkeypatch: pytest.MonkeyPatch) -> None: def test_the_zinogre_reads(ports: Path) -> None: m = manifest.load(ports / "zinogre.toml") r = B.read(m) - assert len(m.behaviour.blocks) == 39 and len(r.paths) == 17 - assert sorted(n for ns in r.priority.values() for n in ns) == list(range(1, 18)) + assert len(m.behaviour.blocks) == 17 and len(r.paths) == 10 + assert sorted(n for ns in r.priority.values() for n in ns) == list(range(1, 11)) assert r.loose == [] and r.refused == {} and r.capped is None - assert [p.blocks[-1] for p in r.paths if p.play is None] == ["b15", "b17", "b23", "b39"] - assert [p.play is None for p in r.paths] == [True] * 4 + [False] * 13, "bookkeeping runs first" - assert model.names(m, "var") == ["flinches", "in_combat"] - enrage = r.paths[3] # while charge_up plays: 120 frames, wait 250, enrage - assert (enrage.blocks, enrage.during) == (("b37", "b38", "b39"), "charge_up") - assert m.behaviour.blocks["b39"].kind == "enrage" and B.natural_rage(m) - charge = r.paths[13] # the 2nd flinch, from idle: charge up - assert (charge.blocks[-1], charge.play) == ("b27", "charge_up") + assert [p.blocks[-1] for p in r.paths if p.play is None] == ["b15", "b17"] + assert [p.play is None for p in r.paths] == [True] * 2 + [False] * 8, "bookkeeping runs first" + assert model.names(m, "var") == ["in_combat"] + skid = r.paths[2] # while the lunge plays: past the hunter, a skid + assert (skid.blocks, skid.during, skid.play) == ( + ("b1", "b2", "b3", "b4"), + "lunge", + "lunge_stop", + ) + assert B.natural_rage(m) and not [b for b in m.behaviour.blocks.values() if b.kind == "enrage"] assert min(b.at[0] for b in m.behaviour.blocks.values()) == model.LEFT assert {w.dst for w in B.wires(m) if w.dst_port == "play"} >= {"move:flinch_head"} assert len(B.spots(m)) == len(m.moves) @@ -385,10 +387,10 @@ def test_the_zinogre_reads(ports: Path) -> None: def test_renaming_a_move_keeps_its_paths(ports: Path) -> None: z = PortDocument(manifest.load(ports / "zinogre.toml")) before = model.compile(z.manifest) - clips.rename_move(z, "stamp", "stomp") + clips.rename_move(z, "notice_howl", "greet") clips.rename_move(z, "lunge", "lunge_fwd") - assert {"stomp", "lunge_fwd"} <= set(z.manifest.behaviour.moves) - names = {"stomp": "stamp", "lunge_fwd": "lunge"} + assert {"greet", "lunge_fwd"} <= set(z.manifest.behaviour.moves) + names = {"greet": "notice_howl", "lunge_fwd": "lunge"} back = [ dataclasses.replace( r, play=names.get(r.play, r.play), from_move=names.get(r.from_move or "", r.from_move) @@ -401,6 +403,33 @@ def test_renaming_a_move_keeps_its_paths(ports: Path) -> None: assert model.compile(z.manifest) == before +def test_renaming_a_move_the_brain_plays_follows_its_pairs(ports: Path) -> None: + z = PortDocument(manifest.load(ports / "zinogre.toml")) + b = z.manifest.brain + assert b is not None and b.users("dash") == ["2,4", "7,29", "7,30"] + clips.rename_move(z, "dash", "rush") + assert z.manifest.brain is not None + assert z.manifest.brain.targets((2, 4)) == ["rush"] + assert z.manifest.brain.targets((7, 29)) == [(1, 2), "rush"] + assert clips.move_users(z.manifest, "rush") == [ + "brain.pairs.2,4", + "brain.pairs.7,29", + "brain.pairs.7,30", + ] + z.undo() + assert z.manifest.brain is not None and z.manifest.brain.users("dash") == [ + "2,4", + "7,29", + "7,30", + ] + + +def test_a_move_the_brain_plays_is_not_deleted(ports: Path) -> None: + z = PortDocument(manifest.load(ports / "zinogre.toml")) + with pytest.raises(ManifestError, match="brain.pairs.2,4"): + clips.drop_move(z, "dash") + + def test_a_move_in_use_is_not_deleted(ports: Path) -> None: z = PortDocument(manifest.load(ports / "zinogre.toml")) with pytest.raises(ManifestError, match="block b18 plays it"): diff --git a/apps/studio/tests/monster/ui/test_behaviour.py b/apps/studio/tests/monster/ui/test_behaviour.py index 55137b9e..1b8e17a8 100644 --- a/apps/studio/tests/monster/ui/test_behaviour.py +++ b/apps/studio/tests/monster/ui/test_behaviour.py @@ -338,16 +338,15 @@ def test_the_zinogre_opens( ws.all_view = B.CANVAS # a loaded document starts in order p = make(qtbot, ws) m = zin.manifest - assert len(m.behaviour.blocks) == 39 and len(p.view._shown) == 39 + len(m.moves) + assert len(m.behaviour.blocks) == 17 and len(p.view._shown) == 17 + len(m.moves) plays = sorted(badge(p, i) for i in m.behaviour.blocks if badge(p, i)) - assert plays == sorted(f"#{k}" for k in range(1, 18)) + assert plays == sorted(f"#{k}" for k in range(1, 11)) for k, path in enumerate(B.read(m).paths, 1): # a path that plays nothing has its place too assert badge(p, path.blocks[-1]) == rank(k) - assert [badge(p, i) for i in ("b15", "b17", "b23", "b39")] == [rank(k) for k in (1, 2, 3, 4)] - assert badge(p, "b4") == rank(5) and badge(p, "b27") == rank(14) - assert badge(p, "b36") == rank(17) and p.rage.isChecked() - assert bar_on(p, "b23").controls["counter"].get_value() == "flinches" - assert options(p, "b15", "flag") == ["flinches", "in_combat"] + assert [badge(p, i) for i in ("b15", "b17")] == [rank(k) for k in (1, 2)] + assert badge(p, "b4") == rank(3) and badge(p, "b6") == rank(4) + assert badge(p, "b36") == rank(10) and p.rage.isChecked() + assert options(p, "b15", "flag") == ["in_combat"] assert all(p.view._shown[i].view.opacity() == 1.0 for i in m.behaviour.blocks) assert in_step(p, ws) wrote = {i: p.view._shown[i].view.xy_pos for i in m.behaviour.blocks} diff --git a/docs/formats/p3rd_ai_script.md b/docs/formats/p3rd_ai_script.md index f79aa40a..7dc86033 100644 --- a/docs/formats/p3rd_ai_script.md +++ b/docs/formats/p3rd_ai_script.md @@ -103,7 +103,8 @@ the first's note, are an error. A random that can follow another draw in the same run gets an `rng_step2` first, as the host's own picks do (a split one's inner randoms do). Findings are errors (`P3_PAIR_UNMAPPED`, `P3_OWN_MISSING`, `P3_UNSUPPORTED`, `P3_HOST_OP`, `P3_UNDECIDED`, `P3_LATCH`, `P3_HISTORY`, `P3_WIDE`, `P3_ROUTINE`, `P3_FATIGUE_HOST`), which stop a -build, warnings (`P3_FOLDED`, `P3_APPROX`, `P3_PAIR_UNUSED`, `P3_WHEN`, `P3_WHEN_IGNORED`, a +build, warnings (`P3_FOLDED`, `P3_APPROX`, `P3_PAIR_UNUSED` (not for a pair the charge table names or whose +move another pair plays), `P3_WHEN`, `P3_WHEN_IGNORED`, a `P3_HISTORY` for a call before the read) and notes (`P3_DROPPED`, `P3_WEIGHT`, `P3_SPLIT`, an `info` `P3_FOLDED`). @@ -119,6 +120,13 @@ A value may be a list, played in order, each item one `enter` that yields: `"7,2 is the host's turn (1, 2), then the move. An item is a host pair, an own move's name or `"own"`; the build charges the donor pair's stamina to the last item. +A pair can also name the move another pair derives, `"7,23" = [[1, 2], "p3_7_0"]` where `"7,0" = +"own"`: a turn wrapper (a donor pair that turns toward the target, then enters another pair) +plays the move of the pair it leads to rather than a duplicate of its own handler, and twin pairs +(same clips, attacks and steer) share one move, which saves slots and turn keys. The name must be +`own_name` of a pair mapped to `"own"` (`Brain.derived`), and that pair is not reported unused +while another pair plays its move (`Brain.shared`). + The slots are `mhfu_port.moves.slots`: the own moves by sorted name, numbered from 0, at most `EM_MOVES.MOVES.count`. The moves module `mhfu-port` writes carries each one as `slot = k` and `mhfu_port.lua` registers by it, so the number in the script, the module and the registry is one. diff --git a/packages/mhfu-port/tests/test_behaviour.py b/packages/mhfu-port/tests/test_behaviour.py index 63bacb9b..1b305970 100644 --- a/packages/mhfu-port/tests/test_behaviour.py +++ b/packages/mhfu-port/tests/test_behaviour.py @@ -230,24 +230,14 @@ def test_triggers_list_every_kind_and_group_the_zinogre_by_what_starts_it(): assert held == { "on_combat_entered": (1,), "on_combat_left": (2,), - "on_flinch": (3,), - "during:charge_up": (4,), - "during:lunge": (5, 6), - "during:lunge_stop": (7,), - "during:dash": (8,), - "on_noticed": (9,), - "on_part_broken": (10, 11, 12, 13), - "idle": (14, 15, 16), - "on_signal:roar": (17,), + "during:lunge": (3, 4), + "on_noticed": (5,), + "on_part_broken": (6, 7, 8, 9), + "on_signal:roar": (10,), } - assert [t.key for t in ts if t.section == "move"] == [ - "during:charge_up", - "during:lunge", - "during:lunge_stop", - "during:dash", - ] + assert [t.key for t in ts if t.section == "move"] == ["during:lunge"] by_rank = sorted((r, p) for t in ts for r, p in t.rules) - assert [p for _, p in by_rank] == B.paths(m) and [r for r, _ in by_rank] == list(range(1, 18)) + assert [p for _, p in by_rank] == B.paths(m) and [r for r, _ in by_rank] == list(range(1, 11)) def test_a_flinch_of_any_part_is_one_trigger(): @@ -1067,20 +1057,11 @@ def test_migrate_nothing(): Rule( None, on="combat_entered", - effects=[("var_set", 1, 1)], + effects=[("var_set", 0, 1)], label="combat start -> set in_combat", ), Rule( - None, on="combat_left", effects=[("var_set", 1, 0)], label="combat end -> clear in_combat" - ), - Rule(None, on="flinch", effects=[("var_add", 0, 1)], label="count the flinches"), - Rule( - None, - from_move="charge_up", - min_frames=120, - cooldown=250, - effects=[("enrage", 0, 0)], - label="after the charge -> enrage", + None, on="combat_left", effects=[("var_set", 0, 0)], label="combat end -> clear in_combat" ), Rule( "lunge_stop", @@ -1092,21 +1073,6 @@ def test_migrate_nothing(): label="just past you -> skid", ), Rule("lunge_stop", from_move="lunge", min_frames=75, cooldown=30, label="run budget by time"), - Rule( - "stamp", - from_move="lunge_stop", - min_frames=20, - dist=(0.0, 1200.0), - cooldown=150, - label="the hunter close once the skid has stood -> stamp", - ), - Rule( - "stamp", - from_move="dash", - min_frames=10, - dist=(0.0, 800.0), - label="the hunter close during the dash -> stamp", - ), Rule( "notice_howl", on="noticed", @@ -1117,47 +1083,23 @@ def test_migrate_nothing(): Rule("topple_left", on="part_broken", part=4, label="left foreleg breaks -> topple left"), Rule("topple_right", on="part_broken", part=6, label="right foreleg breaks -> topple right"), Rule("break_howl", on="part_broken", label="a part breaks"), - Rule( - "charge_up", - from_main=[0], - conds=[("var_at_least", 1, 1), ("var_at_least", 0, 2)], - effects=[("var_set", 0, 0)], - label="2nd flinch -> charge up", - ), - Rule( - "dash", - from_main=[0], - dist=(1500.0, B.UNLIMITED_DIST), - cooldown=300, - conds=[("var_at_least", 1, 1)], - label="hunter far -> dash", - ), - Rule( - "notice_howl", - from_main=[0], - conds=[("var_at_least", 1, 1), ("hp_below", 0, 30)], - count=1, - label="below 30% HP -> howl", - ), Rule("notice_howl", signal=0, label="a mod's roar -> howl"), ] def test_zinogre_compiles_to_its_rules(): + """What stays of the hand-built rules: the brain picks the rest.""" m = M.load(PORTS / "zinogre.toml") assert B.compile(m) == ZINOGRE assert B.loose(m) == [] and B.refused(m) == {} - assert B.vars(m) == {"flinches": 0, "in_combat": 1} and B.signals(m) == {"roar": 0} - old = [r for r in ZINOGRE if r.from_move and r.play] - assert len(old) == 4 - assert all(not r.conds and not r.effects and r.signal is None for r in old) + assert B.vars(m) == {"in_combat": 0} and B.signals(m) == {"roar": 0} + skids = [r for r in ZINOGRE if r.from_move and r.play] + assert len(skids) == 2 + assert all(not r.conds and not r.effects and r.signal is None for r in skids) assert len(ZINOGRE) <= B.SEAM_RULES - idle = [r for r in ZINOGRE if r.from_main == [0]] - assert len(idle) == 3 and all(("var_at_least", 1, 1) in r.conds for r in idle) - notice = next(i for i, r in enumerate(ZINOGRE) if r.on == "noticed") - assert notice < ZINOGRE.index(idle[0]), "the notice scans before the idle paths" + assert not [r for r in ZINOGRE if r.from_main == [0]], "nothing picks from idle but the brain" quiet = [i for i, r in enumerate(ZINOGRE) if r.play is None] - assert quiet == [0, 1, 2, 3], "the rules that play nothing run first" + assert quiet == [0, 1], "the rules that play nothing run first" def test_zinogre_topples_end_in_the_stamp(): diff --git a/packages/mhfu-port/tests/test_moves.py b/packages/mhfu-port/tests/test_moves.py index 82a0c3d9..cd399568 100644 --- a/packages/mhfu-port/tests/test_moves.py +++ b/packages/mhfu-port/tests/test_moves.py @@ -304,26 +304,15 @@ def test_zinogre(data): assert sorted(at.values()) == list(range(len(own))) and at["stamp"] == 6 stamp = 'stamp = { entry = 46, slot = 6, clip = "stamp_right_claw", attacks = { { 56, 6, 80 } }' assert stamp + ", carrier = { 0, 2 }" in text - assert 'after = "dash_stop"' in text and 'turn = "fixed", total = 90.0, frames = 50' in text - assert '{ play = "stamp", from = "dash", min_frames = 10' in text + assert 'turn = "fixed", total = 90.0, frames = 50' in text and "claim" not in text + dash = next(line for line in text.splitlines() if line.strip().startswith("dash = {")) + assert "after" not in dash, "the brain's pairs end the dash themselves" assert '{ play = "flinch_head", on = "part_broken", part = 0' in text assert '{ play = "topple_left", on = "part_broken", part = 4' in text - assert '{ no_play = true, on = "flinch", effects = { { "var_add", 0, 1 } }' in text - assert 'conds = { { "var_at_least", 1, 1 }, { "var_at_least", 0, 2 } }, ' in text - assert 'effects = { { "var_set", 0, 0 } }' in text and "signal = 0" in text - enrage = '{ no_play = true, from = "charge_up", min_frames = 120, cooldown = 250, ' - assert enrage + 'effects = { { "enrage", 0, 0 } }' in text - # it fires in the last frames of the charge (2 clip frames an AI frame), not in its first - rule = next(r for r in behaviour.compile(m) if r.from_move == "charge_up") - ai_frames = b.layout.frames[own["charge_up"]] // 2 - assert ai_frames - 5 <= rule.min_frames < ai_frames and rule.cooldown >= 200 + assert "signal = 0" in text and "enrage" not in text and "var_add" not in text assert " sever_below = 50,\n tip = " in text and "natural_rage" not in text - assert '{ no_play = true, on = "combat_entered", effects = { { "var_set", 1, 1 } }' in text - assert 'conds = { { "var_at_least", 1, 1 }, { "hp_below", 0, 30 } }' in text - assert ( - " vars = { flinches = 0, in_combat = 1 },\n signals = { roar = 0 },\n sever_below" - in text - ) + assert '{ no_play = true, on = "combat_entered", effects = { { "var_set", 0, 1 } }' in text + assert " vars = { in_combat = 0 },\n signals = { roar = 0 },\n sever_below" in text assert '{ play = "notice_howl", on = "noticed", force = true' in text assert "length = 78, steer = { walls = false" in text and 'after = "topple_left_2"' in text diff --git a/packages/mhfu-port/tests/test_own.py b/packages/mhfu-port/tests/test_own.py index b992c6ec..eef7791e 100644 --- a/packages/mhfu-port/tests/test_own.py +++ b/packages/mhfu-port/tests/test_own.py @@ -318,10 +318,10 @@ def test_slots_follow_the_expanded_manifest(derived): 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) + """The Zinogre port with a `[brain]` of its own, every pair `own`.""" + m = manifest.load(PORTS / "zinogre.toml") + m.brain = manifest.Brain(40, "base_3", "base_12", pairs={f"{a},{b}": "own" for a, b in pairs}) + return m def test_zinogre_derives_without_an_error(data): diff --git a/packages/mhfu-port/tests/test_zinogre_brain.py b/packages/mhfu-port/tests/test_zinogre_brain.py index 6136b762..82b46b64 100644 --- a/packages/mhfu-port/tests/test_zinogre_brain.py +++ b/packages/mhfu-port/tests/test_zinogre_brain.py @@ -1,8 +1,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later # SPDX-FileCopyrightText: 2026 sp00ktober -"""Zinogre's MHP3rd script spliced into em75, from a scratch [brain] on ports/zinogre.toml (the -port's own manifest is not touched): every own pair is `"own"`, the attacks the donor turns before -(1, 2) are a list. Needs both games' files.""" +"""The Zinogre's MHP3rd script spliced into em75 by ports/zinogre.toml's `[brain]`, and the tables +and moves module that go with it. Needs both games' files.""" import copy from collections import Counter @@ -15,107 +14,102 @@ from mhfu.em.script import brainfile, build, ir from mhfu.em.script import vars as script_vars from mhfu.em.script.budget import LIMIT -from mhfu_port import manifest, moves -from mhfu_port.brain import p3, p3ops, splice +from mhfu_port import behaviour, manifest, moves +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 PORT = Path(__file__).parents[3] / "ports" / "zinogre.toml" OWN = addresses.OWN_MOVE_MAIN TURN = (1, 2) -HOST = { - "0,255": (0, 1), - "1,1": (1, 2), - "2,5": (1, 2), - "2,6": (1, 2), - "2,12": (1, 0), - "2,13": (1, 0), - "2,14": (1, 0), - "2,16": (1, 0), - "2,17": (1, 0), - "2,21": (1, 0), - "2,23": (1, 0), - "2,24": (1, 0), - "2,25": (1, 0), - "2,26": (1, 2), -} -"""The pairs that play a host pair.""" -TURNED = ( - "7,23 7,24 7,27 7,28 7,29 7,30 7,38 7,39 7,43 7,44 7,45 7,46 7,55 7,56 7,57 7,58 7,59 7,60 " - "7,72 7,73 7,74 7,75 7,83 7,84 7,92 7,93 7,95 7,96 7,97 7,98 7,101 7,102 7,103 7,104 " - "7,110 7,111 7,120 7,121" -).split() -"""The pairs that turn on the host's (1, 2) before their own move.""" - - -def pair_of(key: str) -> tuple[int, int]: - main, _, sub = key.partition(",") - return int(main), int(sub) - - -def brain(donor: p3.Brain, **kw: object) -> manifest.Brain: - """Every pair the entry reaches: a host pair, a turn then an own move, or an own move.""" - scan = splice.Translator(donor, manifest.Brain(40, "base_3", "base_3")) - scan.scan("base_3", "base_3") - pairs: dict[str, manifest.Plays] = {} - for m, s in sorted(scan.entered): - key = f"{m},{s}" - pairs[key] = HOST[key] if key in HOST else [TURN, manifest.OWN] if key in TURNED else "own" - when = {"if_target_set": True, "if_target_state": True} - return manifest.Brain( - 40, - "base_3", - "base_3", - pairs=pairs, - when=when, - fatigue=True, - **kw, # type: ignore[arg-type] - ) +KEPT = {f"b{i}" for i in [*range(1, 7), *range(12, 22), 36]} +"""The hand-built rules that stay: the lunge's skids, the notice, the combat flag, the part breaks +and the roar signal.""" @pytest.fixture(scope="module") def zinogre(data: Data) -> SimpleNamespace: - """The donor, the host row, and `m`, whose `[brain]` maps the 98 pairs the splice enters (the - roaming code `if_target_set` folds away enters nine more).""" - donor = p3.lift(data.p3rd, 40, 0) - host = build.lift_row(data.fu, 75, 75) - overlay = data.fu.em(75) - survey = script_vars.survey(host, overlay) - m = manifest.load(PORT) - b = m.brain = brain(donor, charge=True) - names = {t: k for k, t in enumerate(owned(b))} - used = splice.splice(donor, host, b, survey, names, overlay).uses - b.pairs = {k: v for k, v in b.pairs.items() if pair_of(k) in {p for p, _ in used}} - return SimpleNamespace(donor=donor, host=host, overlay=overlay, m=m, survey=survey) + """The donor, the host row, the manifest with its derived moves and the helpers' tables.""" + m, derived = own.expand(manifest.load(PORT), data) + assert m.brain is not None + ch, said = charge.table(m, data) + return SimpleNamespace( + donor=p3.lift(data.p3rd, m.brain.donor, m.brain.slot), + host=build.lift_row(data.fu, 75, 75), + overlay=data.fu.em(75), + survey=script_vars.survey(build.lift_row(data.fu, 75, 75), data.fu.em(75)), + m=m, + derived=derived, + slots=moves.slots(m), + charge=ch, + said=said, + ) -def splice_of(z: SimpleNamespace, b: manifest.Brain, slots: dict[str, int]): - s = splice.splice(z.donor, z.host, b, z.survey, slots, z.overlay) +def splice_of(z: SimpleNamespace, b: manifest.Brain): + named = z.charge.named if z.charge is not None else () + s = splice.splice(z.donor, z.host, b, z.survey, z.slots, z.overlay, named) return s, build.Built.of(z.host, s.edits) -def owned(b: manifest.Brain) -> list[str]: - return sorted({t for k in b.pairs for t in b.targets(pair_of(k)) if isinstance(t, str)}) +def codes(found: Sequence[object]) -> Counter[str]: + return Counter(f"{f.level} {f.code}" for f in found) # type: ignore[attr-defined] -def every_slot(b: manifest.Brain) -> dict[str, int]: - return {name: k for k, name in enumerate(owned(b))} +def test_the_manifest_carries_its_brain(zinogre): + m = zinogre.m + b = m.brain + 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) == 99 and len(b.derived()) == len(own.pairs(b)) == 18 -def codes(found: Sequence[object]) -> Counter[str]: - return Counter(f"{f.level} {f.code}" for f in found) # type: ignore[attr-defined] +def test_a_donor_pair_plays_the_authored_move_where_it_is_the_donors(zinogre): + b = zinogre.m.brain + assert [b.targets((1, k)) for k in (15, 16, 2)] == [ + ["charge_up"], + ["charge_up"], + ["break_howl"], + ] + assert [b.targets(p) for p in [(2, 3), (2, 4)]] == [["dash_stop"], ["dash"]] + assert b.targets((7, 29)) == b.targets((7, 30)) == [TURN, "dash"] + assert zinogre.m.moves["dash"].after is None -def test_the_scratch_brain_is_a_manifest(zinogre): +def test_a_wrapper_turns_and_plays_the_move_of_the_pair_it_leads_to(zinogre): b = zinogre.m.brain - assert len(b.pairs) == 98 and len(owned(b)) == 84 - assert sum(isinstance(v, list) for v in b.pairs.values()) == len(TURNED) == 38 - assert b.targets((7, 23)) == [TURN, "p3_7_23"] and b.targets((2, 5)) == [(1, 2)] - assert manifest.loads(manifest.dumps(zinogre.m)) == zinogre.m + assert b.targets((7, 23)) == [TURN, "p3_7_0"] and b.targets((7, 0)) == ["p3_7_0"] + assert b.targets((7, 110)) == [TURN, "p3_7_109"] and b.targets((7, 109)) == ["p3_7_109"] + twins = [b.targets((2, 11)), b.targets((7, 5))] + assert twins == [["p3_2_10"], ["p3_7_4"]] + assert {p for p in b.derived().values()} >= {(7, 0), (7, 109), (2, 10), (7, 4), (1, 4)} + assert b.shared() <= set(b.derived().values()) and (7, 0) in b.shared() + + +def test_what_fought_the_brain_is_gone(zinogre): + m = zinogre.m + assert not [n for n, mv in m.moves.items() if mv.claim is not None] + assert set(m.behaviour.blocks) == KEPT + assert not {"flinches", "in_combat"} - set( + behaviour.vars(m) + ) or "flinches" not in behaviour.vars(m) + plays = {p for blk in m.behaviour.blocks.values() for p in blk.play} + assert plays == { + "lunge_stop", + "notice_howl", + "flinch_head", + "topple_left", + "topple_right", + "break_howl", + } + assert m.moves["topple_left_3"].after == "stamp" and "stamp" in m.moves def test_zinogre_splices_into_em75(zinogre): - b = zinogre.m.brain - s, built = splice_of(zinogre, b, every_slot(b)) + s, built = splice_of(zinogre, zinogre.m.brain) assert not [f for f in s.findings if f.level == "error"], s.findings assert built.ok, built.findings assert built.budget.worst is not None and built.budget.worst.count <= min(LIMIT, 150) @@ -124,6 +118,7 @@ def test_zinogre_splices_into_em75(zinogre): got = codes(s.findings) assert got["info P3_SPLIT"] == 10 and got["warning P3_FOLDED"] == 3 assert got["info P3_HISTORY"] == 1 and got["warning P3_APPROX"] == 1 + assert not got["warning P3_PAIR_UNUSED"] and not got["warning P3_WHEN_IGNORED"] assert {k for k in got if k.startswith(("error", "warning"))} == { "warning P3_FOLDED", "warning P3_APPROX", @@ -143,16 +138,36 @@ def queries(built: build.Built) -> Counter[int]: def test_zinogre_keeps_the_charge_queries(zinogre): - b = zinogre.m.brain - _, built = splice_of(zinogre, b, every_slot(b)) + _, built = splice_of(zinogre, zinogre.m.brain) got = queries(built) assert set(got) == {0, 6} and got[0] > 100 and got[6] > 70 +def test_zinogre_reads_tired_from_gauge_a(zinogre): + _, built = splice_of(zinogre, zinogre.m.brain) + tests = [ + n for r in built.program.routines() for n in ir.walk(r.body) if isinstance(n, ir.Block) + ] + assert any(n.op.name == "if_gauge_a_low" for n in tests) + assert not any(n.op.name in ("if_tired", "if_stamina_low") for n in tests) + + +def test_zinogre_enters_its_own_moves(zinogre): + s, built = splice_of(zinogre, zinogre.m.brain) + own_enters = { + n.args["sub"] + for r in built.program.routines() + for n in ir.walk(r.body) + if isinstance(n, ir.Insn) and n.op.name == "enter" and n.args["main"] == OWN + } + assert own_enters == {zinogre.slots[n] for _, n in s.uses if isinstance(n, str)} + assert {"p3_7_0", "p3_7_12", "p3_1_19", "charge_up", "dash", "break_howl"} <= set(s.slots) + + def test_zinogre_without_the_charge_helper_is_never_charged(zinogre): plain = copy.deepcopy(zinogre.m.brain) plain.charge = False - s, built = splice_of(zinogre, plain, every_slot(plain)) + s, built = splice_of(zinogre, plain) assert built.ok and not queries(built) assert not [f for f in s.findings if f.level == "error"] folded = Counter(f.text.split(" taken")[0] for f in s.findings if f.code == "P3_FOLDED") @@ -165,27 +180,6 @@ def test_zinogre_without_the_charge_helper_is_never_charged(zinogre): assert len(s.routines) < 40 # the charged picker and what only it calls are gone -def test_zinogre_turns_then_moves(zinogre): - b = zinogre.m.brain - slots = every_slot(b) - s, _ = splice_of(zinogre, b, slots) - for key in TURNED: - pair = pair_of(key) - own = b.targets(pair)[-1] - assert s.uses[pair, TURN] > 0 and s.uses[pair, own] == s.uses[pair, TURN] - for label, name in s.routines.items(): - got = Counter( - (n.args["main"], n.args["sub"]) - for n in ir.walk(s.edits.routines[label]) - if isinstance(n, ir.Insn) and n.op.name == "enter" - ) - want: Counter[tuple[int, int]] = Counter() - for pair in expanded(zinogre.donor, zinogre.donor.routines[name].body): - for t in b.targets(pair): - want[(OWN, slots[t]) if isinstance(t, str) else t] += 1 - assert got == want, label - - def expanded(brain_: p3.Brain, nodes: Sequence[p3.Node]) -> list[tuple[int, int]]: """The pairs a routine enters, with each call replaced by its routine's.""" out: list[tuple[int, int]] = [] @@ -199,25 +193,87 @@ def expanded(brain_: p3.Brain, nodes: Sequence[p3.Node]) -> list[tuple[int, int] return out -def test_zinogre_with_the_registry_as_it_is(zinogre): - """The slots `moves.slots` gives while the registry is smaller than the donor's own moves: - the moves past it are the only error.""" +def test_zinogre_turns_then_moves(zinogre): b = zinogre.m.brain - m = copy.deepcopy(zinogre.m) - m.moves |= {name: manifest.Move(anim=1) for name in owned(b)} - s, built = splice_of(zinogre, b, moves.slots(m)) - errors = {f.code for f in s.findings if f.level == "error"} - if moves.SLOTS < len(owned(b)): - assert errors == {"P3_OWN_MISSING"} - else: - assert not errors and built.ok + s, _ = splice_of(zinogre, b) + turned = [manifest.pair_of(k) for k in b.pairs if len(b.targets(manifest.pair_of(k))) == 2] + assert len(turned) == 38 + for pair in turned: + move = b.targets(pair)[-1] + assert s.uses[pair, TURN] > 0 and s.uses[pair, move] == s.uses[pair, TURN] + for label, name in s.routines.items(): + got = Counter( + (n.args["main"], n.args["sub"]) + for n in ir.walk(s.edits.routines[label]) + if isinstance(n, ir.Insn) and n.op.name == "enter" + ) + want: Counter[tuple[int, int]] = Counter() + for pair in expanded(zinogre.donor, zinogre.donor.routines[name].body): + for t in b.targets(pair): + want[(OWN, zinogre.slots[t]) if isinstance(t, str) else t] += 1 + assert got == want, label def test_zinogre_brain_file_round_trip(zinogre, data, tmp_path): - b = zinogre.m.brain - s, built = splice_of(zinogre, b, every_slot(b)) + s, built = splice_of(zinogre, zinogre.m.brain) bf = brainfile.new(75) bf.put(s.edits) path = brainfile.save(bf, tmp_path / "em75.toml") again = build.compile(brainfile.load(path), data.fu, 75, lift=zinogre.host) assert again.id == built.id + + +def test_the_charge_table(zinogre): + ch = zinogre.charge + assert ch is not None + assert ch.ups == {"charge_up": (40, 55, 65, 100)} and ch.transform == "p3_1_4" + assert (ch.chip_gauge, ch.chip_durability, ch.cooldown_up, ch.cooldown_out) == (5, 5, 420, 900) + assert ch.discharge == ((4, 16), (4, 29), (4, 30), (4, 31)) + got = [(f.level, f.code) for f in zinogre.said] + assert got.count(("warning", "P3_CHARGE_UNMAPPED")) == 4 and ("info", "P3_CHARGE_SHARED") in got + never = {f.text.split()[1] for f in zinogre.said if f.code == "P3_CHARGE_UNMAPPED"} + assert never == {"1,3", "1,6", "1,17", "1,20"} and len(zinogre.said) == 5 + + +def test_the_fatigue_table(zinogre, data): + fat, found = fatigue.table(zinogre.m, data) + assert fat is not None and not [f for f in found if f.level == "error"] + assert fat.pairs[4, 16] == 200 and {"p3_7_12", "p3_7_94", "stamp"} <= set(fat.own) + + +@pytest.fixture(scope="module") +def module_text(zinogre, data, tmp_path_factory): + where = tmp_path_factory.mktemp("zinogre") + m = manifest.load(PORT) + built = build_port(m, data) + modules(m, built, where, data) + return (where / moves.module_name(m)).read_text(), built + + +def test_the_moves_module_carries_fatigue_and_charge(module_text): + text, _ = module_text + lines = {line.split(" = ")[0].strip(): line for line in text.splitlines()} + assert "fatigue" in lines and "calm = true" in lines["fatigue"] + assert lines["charge"].strip() == ( + 'charge = { ups = { charge_up = { 40, 55, 65, 100 } }, transform = "p3_1_4", ' + "chip_gauge = 5, chip_durability = 5, cooldown_up = 420, cooldown_out = 900, " + "discharge = { { 4, 16 }, { 4, 29 }, { 4, 30 }, { 4, 31 } } }," + ) + + +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.""" + _, 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) <= moves.SLOTS and keys <= moves.POOL + assert not moves.problems(zinogre.m, built.layout, moves.records(Data.find().fu, 75)) + + +def test_the_cli_splices_the_port(data, capsys): + args = ["--data", str(data.fu.root), "--p3rd-data", str(data.p3rd.root)] + assert main(["splice", str(PORT), *args]) == 0 + out = capsys.readouterr().out + assert "routines added" in out and "worst run" in out + assert " charge: charge_up 40/55/65/100; transform p3_1_4;" in out + assert " pair 7,23 -> 1,2 x" in out and " pair 7,23 -> own p3_7_0 (slot " in out diff --git a/ports/README.md b/ports/README.md index 6ae4d07f..f592d0ca 100644 --- a/ports/README.md +++ b/ports/README.md @@ -165,8 +165,8 @@ A signal fires its rules in the AI frame the brain takes it, once. An unknown na A flinch rule that plays a move is armed ahead of the flinch: its conditions are judged in the AI frame before the host step that flinches, so a counter it tests misses the flinch about to be -counted, in any order. The Zinogre counts flinches on a no-play rule and charges up from idle -once `flinches` is 2, not on the flinch itself. +counted, in any order: a rule that counts flinches and one that acts on the count go on the +following frames, not on the flinch itself. `enrage` starts the engine's own rage, roar included, unless the monster is enraged; `calm` ends it, if it is. In a mod, `port:enrage()` and `port:calm()` ask the same, applied in the brain's @@ -201,22 +201,29 @@ port with them; `mhfu move play --own NAME [--force]` plays one of its own moves `framework/lua/examples/ported_brute.lua` loads the Brute Tigrex in place of a Giadrome; `ported_zinogre.lua` the Zinogre with its manifest's moves and behaviour. -The Zinogre's paths, after the lunge and dash chains (higher is checked first; 2, 4 and 7 play -nothing and so run first): +The Zinogre's moves are the donor's: `[brain]` splices MHP3rd's own script (`base_3`, its charged +picker `base_6` and its normal one `base_5`) into em75's brain, so what he picks, when he charges +up (the helper's gauge, `[brain] charge`) and when he is tired (`[brain] fatigue`) is the donor's. +A donor pair plays a host pair (a turn, a walk), 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. The registry holds 2048 turn keys for all own moves, which the +authored moves take 991 of, so the derived moves are the ones that fit; a donor move that did not +plays the nearest move that did: the other horn charges the right-horn one, the long claw combos +their opening, the tumbles and the straight flip the backflip, the second smackdown the first, +the claw into a backflip the `stamp`. + +What stays of the hand-built rules, after the brain's own picks (2 plays nothing and so runs +first): 1. The notice plays `notice_howl` at once, before anything that idles. 2. Combat start sets the flag `in_combat`, combat end clears it; both play nothing. 3. A break of the head plays `flinch_head`; of the left foreleg `topple_left`; of the right `topple_right`; of any part `break_howl`. A topple is a sequence: fall, lie helpless, get up, then `stamp` (`after`). -4. A flinch adds 1 to `flinches` and plays nothing. -5. Idle, in combat: with `flinches` at least 2, set it to 0 and play `charge_up`; with the - hunter 1500 away, play `dash`, then wait 300; below 30% HP, play `notice_howl` once. -6. The signal `roar` plays `notice_howl`. -7. Once `charge_up` has played 120 AI frames, just short of its end (121 by its clip, 125 - measured), he enrages, then waits 250 frames. The roar starts one AI frame after the enrage, - so it comes after the charge; on the charge path itself it would cut the charge after one - frame. +4. The signal `roar` plays `notice_howl`. +5. While the lunge (1, 4) plays, em75's own interrupts skid it by distance and time + (`lunge_stop`). His tail cuts only below 50% HP (`sever_below`); natural rage stays on. diff --git a/ports/zinogre.toml b/ports/zinogre.toml index 56fa84b7..608862a5 100644 --- a/ports/zinogre.toml +++ b/ports/zinogre.toml @@ -552,11 +552,6 @@ clip = "lunge_forward" after = "lunge_stop" hold_max = 12 -[moves.lunge.claim] -main = [ - 1, -] - [moves.lunge_stop] main = 0 sub = 3 @@ -575,8 +570,7 @@ walls = false [moves.dash] clip = "dash_forward" -after = "dash_stop" -label = "the long dash, ended by a wall ahead, then the stop" +label = "the long dash, ended by a wall ahead" [moves.dash_stop] clip = "dash_forward_stop" @@ -917,63 +911,6 @@ play = [ "lunge_stop", ] -[behaviour.blocks.b7] -kind = "played_for" -at = [ - 400.0, - 400.0, -] -frames = 20 -next = [ - "b8", -] -label = "the hunter close once the skid has stood -> stamp" - -[behaviour.blocks.b8] -kind = "distance" -at = [ - 680.0, - 400.0, -] -hi = 1200.0 -next = [ - "b9", -] - -[behaviour.blocks.b9] -kind = "cooldown" -at = [ - 960.0, - 400.0, -] -frames = 150 -play = [ - "stamp", -] - -[behaviour.blocks.b10] -kind = "played_for" -at = [ - 400.0, - 600.0, -] -frames = 10 -next = [ - "b11", -] -label = "the hunter close during the dash -> stamp" - -[behaviour.blocks.b11] -kind = "distance" -at = [ - 680.0, - 600.0, -] -hi = 800.0 -play = [ - "stamp", -] - [behaviour.blocks.b12] kind = "on_noticed" at = [ @@ -1081,161 +1018,6 @@ play = [ ] label = "a part breaks" -[behaviour.blocks.b22] -kind = "on_flinch" -at = [ - 400.0, - 2200.0, -] -next = [ - "b23", -] -label = "count the flinches" - -[behaviour.blocks.b23] -kind = "counter_add" -at = [ - 680.0, - 2200.0, -] -counter = "flinches" -by = 1 - -[behaviour.blocks.b24] -kind = "idle" -at = [ - 400.0, - 2400.0, -] -next = [ - "b25", -] -label = "2nd flinch -> charge up" - -[behaviour.blocks.b25] -kind = "flag_is" -at = [ - 680.0, - 2400.0, -] -flag = "in_combat" -next = [ - "b26", -] - -[behaviour.blocks.b26] -kind = "counter_is" -at = [ - 960.0, - 2400.0, -] -counter = "flinches" -test = "at least" -value = 2 -next = [ - "b27", -] - -[behaviour.blocks.b27] -kind = "counter_set" -at = [ - 1240.0, - 2400.0, -] -counter = "flinches" -to = 0 -play = [ - "charge_up", -] - -[behaviour.blocks.b28] -kind = "idle" -at = [ - 400.0, - 2640.0, -] -next = [ - "b29", -] -label = "hunter far -> dash" - -[behaviour.blocks.b29] -kind = "flag_is" -at = [ - 680.0, - 2640.0, -] -flag = "in_combat" -next = [ - "b30", -] - -[behaviour.blocks.b30] -kind = "distance" -at = [ - 960.0, - 2640.0, -] -lo = 1500.0 -next = [ - "b31", -] - -[behaviour.blocks.b31] -kind = "cooldown" -at = [ - 1240.0, - 2640.0, -] -frames = 300 -play = [ - "dash", -] - -[behaviour.blocks.b32] -kind = "idle" -at = [ - 400.0, - 2840.0, -] -next = [ - "b33", -] -label = "below 30% HP -> howl" - -[behaviour.blocks.b33] -kind = "flag_is" -at = [ - 680.0, - 2840.0, -] -flag = "in_combat" -next = [ - "b34", -] - -[behaviour.blocks.b34] -kind = "monster_hp" -at = [ - 960.0, - 2840.0, -] -hi = 30 -next = [ - "b35", -] - -[behaviour.blocks.b35] -kind = "limit" -at = [ - 1240.0, - 2840.0, -] -times = 1 -play = [ - "notice_howl", -] - [behaviour.blocks.b36] kind = "on_signal" at = [ @@ -1248,36 +1030,6 @@ play = [ ] label = "a mod's roar -> howl" -[behaviour.blocks.b37] -kind = "played_for" -at = [ - 400.0, - 3200.0, -] -frames = 120 -next = [ - "b38", -] -label = "after the charge -> enrage" - -[behaviour.blocks.b38] -kind = "cooldown" -at = [ - 680.0, - 3200.0, -] -frames = 250 -next = [ - "b39", -] - -[behaviour.blocks.b39] -kind = "enrage" -at = [ - 960.0, - 3200.0, -] - [behaviour.moves.lunge] at = [ 0.0, @@ -1293,24 +1045,6 @@ at = [ 0.0, 400.0, ] -during = [ - "b7", -] - -[behaviour.moves.stamp] -at = [ - 1520.0, - 400.0, -] - -[behaviour.moves.dash] -at = [ - 0.0, - 600.0, -] -during = [ - "b10", -] [behaviour.moves.notice_howl] at = [ @@ -1342,11 +1076,384 @@ at = [ 2000.0, ] -[behaviour.moves.charge_up] -at = [ - 0.0, - 3200.0, +[brain] +donor = 40 +splice = "base_3" +entry = "base_3" +fatigue = true +charge = true + +[brain.pairs] +"0,255" = [ + 0, + 1, ] -during = [ - "b37", +"1,1" = [ + 1, + 2, +] +"1,2" = "break_howl" +"1,4" = "own" +"1,15" = "charge_up" +"1,16" = "charge_up" +"1,19" = "own" +"2,3" = "dash_stop" +"2,4" = "dash" +"2,5" = [ + 1, + 2, +] +"2,6" = [ + 1, + 2, +] +"2,10" = "own" +"2,11" = "p3_2_10" +"2,12" = [ + 1, + 0, +] +"2,13" = [ + 1, + 0, +] +"2,14" = [ + 1, + 0, +] +"2,16" = [ + 1, + 0, +] +"2,17" = [ + 1, + 0, +] +"2,21" = [ + 1, + 0, +] +"2,22" = "own" +"2,23" = [ + 1, + 0, +] +"2,24" = [ + 1, + 0, +] +"2,25" = [ + 1, + 0, +] +"2,26" = [ + 1, + 2, +] +"7,0" = "own" +"7,4" = "own" +"7,5" = "p3_7_4" +"7,12" = "own" +"7,14" = "own" +"7,16" = "own" +"7,17" = "p3_7_16" +"7,19" = "p3_7_16" +"7,22" = "p3_7_33" +"7,23" = [ + [ + 1, + 2, + ], + "p3_7_0", +] +"7,24" = [ + [ + 1, + 2, + ], + "p3_7_0", +] +"7,27" = [ + [ + 1, + 2, + ], + "p3_7_86", +] +"7,28" = [ + [ + 1, + 2, + ], + "p3_7_86", +] +"7,29" = [ + [ + 1, + 2, + ], + "dash", +] +"7,30" = [ + [ + 1, + 2, + ], + "dash", ] +"7,33" = "own" +"7,35" = "own" +"7,38" = [ + [ + 1, + 2, + ], + "p3_7_12", +] +"7,39" = [ + [ + 1, + 2, + ], + "p3_7_12", +] +"7,40" = "own" +"7,43" = [ + [ + 1, + 2, + ], + "p3_7_40", +] +"7,44" = [ + [ + 1, + 2, + ], + "p3_7_40", +] +"7,45" = [ + [ + 1, + 2, + ], + "p3_7_35", +] +"7,46" = [ + [ + 1, + 2, + ], + "p3_7_35", +] +"7,55" = [ + [ + 1, + 2, + ], + "p3_7_33", +] +"7,56" = [ + [ + 1, + 2, + ], + "p3_7_33", +] +"7,57" = [ + [ + 1, + 2, + ], + "p3_2_10", +] +"7,58" = [ + [ + 1, + 2, + ], + "p3_2_10", +] +"7,59" = [ + [ + 1, + 2, + ], + "p3_2_10", +] +"7,60" = [ + [ + 1, + 2, + ], + "p3_2_10", +] +"7,65" = "own" +"7,66" = "p3_7_33" +"7,67" = "own" +"7,68" = "p3_7_12" +"7,69" = "p3_7_12" +"7,70" = "p3_7_12" +"7,72" = [ + [ + 1, + 2, + ], + "p3_7_12", +] +"7,73" = [ + [ + 1, + 2, + ], + "p3_7_12", +] +"7,74" = [ + [ + 1, + 2, + ], + "p3_7_67", +] +"7,75" = [ + [ + 1, + 2, + ], + "p3_7_67", +] +"7,79" = "p3_7_12" +"7,80" = "p3_7_12" +"7,81" = "p3_7_12" +"7,83" = [ + [ + 1, + 2, + ], + "p3_7_12", +] +"7,84" = [ + [ + 1, + 2, + ], + "p3_7_12", +] +"7,85" = "stamp" +"7,86" = "own" +"7,87" = "p3_7_33" +"7,89" = "own" +"7,92" = [ + [ + 1, + 2, + ], + "p3_1_19", +] +"7,93" = [ + [ + 1, + 2, + ], + "p3_1_19", +] +"7,94" = "own" +"7,95" = [ + [ + 1, + 2, + ], + "p3_2_22", +] +"7,96" = [ + [ + 1, + 2, + ], + "p3_2_22", +] +"7,97" = [ + [ + 1, + 2, + ], + "p3_7_89", +] +"7,98" = [ + [ + 1, + 2, + ], + "p3_7_89", +] +"7,99" = "p3_7_40" +"7,100" = "p3_7_86" +"7,101" = [ + [ + 1, + 2, + ], + "p3_7_40", +] +"7,102" = [ + [ + 1, + 2, + ], + "p3_7_40", +] +"7,103" = [ + [ + 1, + 2, + ], + "p3_7_86", +] +"7,104" = [ + [ + 1, + 2, + ], + "p3_7_86", +] +"7,107" = "p3_7_33" +"7,108" = "p3_7_33" +"7,109" = "own" +"7,110" = [ + [ + 1, + 2, + ], + "p3_7_109", +] +"7,111" = [ + [ + 1, + 2, + ], + "p3_7_109", +] +"7,113" = "p3_7_94" +"7,114" = "p3_7_94" +"7,115" = "p3_7_94" +"7,116" = "p3_7_12" +"7,117" = "p3_7_12" +"7,118" = "p3_7_12" +"7,120" = [ + [ + 1, + 2, + ], + "p3_7_12", +] +"7,121" = [ + [ + 1, + 2, + ], + "p3_7_12", +] + +[brain.when] +if_target_set = true +if_target_state = true From 701d9b19392e895360dddd0e07e0f2c13b9a5450 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:59:13 +0200 Subject: [PATCH 6/7] feat(framework): the own-move registry holds 5632 turn keys EM_MOVES.KEYS 2048 -> 5632 (the moves block 12732 -> 19900 bytes, 0x4DBC, +7168): the Zinogre's own moves, each donor move kept, need 4367 keys (the authored ones 991); 5632 is that plus a quarter, up to a multiple of 512. The slots stay 96. The C arrays and asserts read the generated KEYS_COUNT and offsets, the Python pool derives from it, and the tests that spelled 2048 follow it. Co-Authored-By: Claude Sonnet 5.5 --- framework/README.md | 3 ++- framework/tests/test_em_brain.py | 8 ++++---- packages/mhfu-port/tests/test_moves.py | 7 +++++-- packages/mhfu/src/mhfu/addresses.toml | 8 ++++---- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/framework/README.md b/framework/README.md index 9b6d0ef1..2a300378 100644 --- a/framework/README.md +++ b/framework/README.md @@ -131,7 +131,8 @@ the engine counts the flinch and applies the damage, then enters the move's carr ## Own moves from a manifest A port's own moves sit in a registry by slot (`mhfu.em_move`): `mhfu.addr.EM_MOVES.MOVES_COUNT` -(96) of them, and the turn keys they share (2048), in one 12.8 KB partition block. The seam's +(96) of them, and the turn keys they share (`EM_MOVES.KEYS_COUNT`, 5632), in one 19.9 KB +partition block. The seam's brain, in C on the AI step, plays one through the move player when asked (`mhfu.em_play`), when the move before it ends on its clip, its length or a wall and names it as its `after`, or when a rule says (`play_move`, and `from_move` to fire while one plays). `mhfu_port.lua` does all of it from the diff --git a/framework/tests/test_em_brain.py b/framework/tests/test_em_brain.py index e0912bab..ea265026 100644 --- a/framework/tests/test_em_brain.py +++ b/framework/tests/test_em_brain.py @@ -561,12 +561,12 @@ def test_an_own_moves_hunter_turn_keeps_to_its_steer_window( def test_keys_have_room(g: Game) -> None: - assert g.own(0, 46, keys=256) - for slot in range(1, 8): + full = a.EM_MOVES.KEYS.count // 256 + for slot in range(full): assert g.own(slot, 46, keys=256) - assert not g.own(8, 46, keys=1) + assert not g.own(full, 46, keys=1) g.lib.mhfu_em_moves_clear() - assert g.own(8, 46, keys=256) and g.moves("KEY_TOP") == 256 + assert g.own(full, 46, keys=256) and g.moves("KEY_TOP") == 256 def test_a_rule_on_an_event_fires_in_the_frame_it_is_seen(g: Game) -> None: diff --git a/packages/mhfu-port/tests/test_moves.py b/packages/mhfu-port/tests/test_moves.py index cd399568..59514403 100644 --- a/packages/mhfu-port/tests/test_moves.py +++ b/packages/mhfu-port/tests/test_moves.py @@ -257,8 +257,11 @@ def test_check(extra, lay, known, why): def test_pool(): lay = layout.Layout(LAYOUT.entries, turns={46: travel.Turn(500, (0,) * 251, 0, None)}) - many = "".join(f"[moves.s{i}]\nclip = 'stamp'\n" for i in range(9)) - with pytest.raises(ManifestError, match="need 2259 keys, the framework holds 2048"): + n = moves.POOL // 251 + 1 + many = "".join(f"[moves.s{i}]\nclip = 'stamp'\n" for i in range(n)) + with pytest.raises( + ManifestError, match=f"need {251 * n} keys, the framework holds {moves.POOL}" + ): moves.check(_m(many), lay) diff --git a/packages/mhfu/src/mhfu/addresses.toml b/packages/mhfu/src/mhfu/addresses.toml index 74890d50..f525349f 100644 --- a/packages/mhfu/src/mhfu/addresses.toml +++ b/packages/mhfu/src/mhfu/addresses.toml @@ -939,7 +939,7 @@ VALID = { offset = 0x4C, type = "u32", doc = "1 once written; 0 while it is rewr [struct.EM_MOVES] doc = "em_vhook's own-move registry in partition memory (em_vhook.cpp): a port's own moves by slot, played by the move player on the AI step that asks" -size = 0x31BC +size = 0x4DBC [struct.EM_MOVES.fields] MAGIC = { offset = 0x00, type = "u32", doc = "0x564F4D45 (EMOV)" } REQ_ENT = { offset = 0x04, type = "ptr", doc = "a play asked for (mhfu_em_play): the monster, taken at its next AI step; 0 none" } @@ -950,9 +950,9 @@ PLAYS = { offset = 0x14, type = "u32", doc = "moves started from here: asked, by CHAINED = { offset = 0x18, type = "u32", doc = "of them by AFTER" } KEY_TOP = { offset = 0x1C, type = "u32", doc = "KEYS in use" } MOVES = { offset = 0x20, type = "EM_OWN[96]", doc = "the moves by slot" } -KEYS = { offset = 0x1FA0, type = "u16[2048]", doc = "every move's turn keys, YAW every 2 clip frames" } -SCRATCH = { offset = 0x2FA0, type = "STEER_SPEC", doc = "the steering handed to the move player for the move it plays next" } -REQ_FORCE = { offset = 0x31B8, type = "u32", doc = "1: the play asked for starts while the monster's notice runs (MOVE.FORCE)" } +KEYS = { offset = 0x1FA0, type = "u16[5632]", doc = "every move's turn keys, YAW every 2 clip frames" } +SCRATCH = { offset = 0x4BA0, type = "STEER_SPEC", doc = "the steering handed to the move player for the move it plays next" } +REQ_FORCE = { offset = 0x4DB8, type = "u32", doc = "1: the play asked for starts while the monster's notice runs (MOVE.FORCE)" } [struct.FLINCH_PART] doc = "one part's flinch state (ENTITY.FLINCH_PARTS)" From 87825208420846b9eaaaead6a661e7891e68afe7 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:21:50 +0200 Subject: [PATCH 7/7] feat(ports): each donor pair of the Zinogre plays its own derived move again The stand-ins the 2048-key pool forced are gone: the horn charges, the claw combos, the tumbles and flips, the second smackdown and the claw into a backflip each play their own derived move. Only pairs whose derived chains are identical share one (17 pairs, checked by a test), (1, 15) derives p3_1_15 and (1, 16) keeps charge_up so each charge-up has its own gains, and the unentered charge-ups are derived for the charge table. 83 own moves of 96 slots take 4367 of the 5632 turn keys. Co-Authored-By: Claude Sonnet 5.5 --- .../mhfu-port/tests/test_zinogre_brain.py | 70 ++++++++++++++----- ports/README.md | 8 +-- ports/zinogre.toml | 59 +++++++++------- 3 files changed, 87 insertions(+), 50 deletions(-) diff --git a/packages/mhfu-port/tests/test_zinogre_brain.py b/packages/mhfu-port/tests/test_zinogre_brain.py index 82b46b64..06ed2655 100644 --- a/packages/mhfu-port/tests/test_zinogre_brain.py +++ b/packages/mhfu-port/tests/test_zinogre_brain.py @@ -64,16 +64,13 @@ 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) == 99 and len(b.derived()) == len(own.pairs(b)) == 18 + assert len(b.pairs) == 104 and len(b.derived()) == len(own.pairs(b)) == 34 def test_a_donor_pair_plays_the_authored_move_where_it_is_the_donors(zinogre): b = zinogre.m.brain - assert [b.targets((1, k)) for k in (15, 16, 2)] == [ - ["charge_up"], - ["charge_up"], - ["break_howl"], - ] + assert b.targets((1, 16)) == ["charge_up"] and b.targets((1, 15)) == ["p3_1_15"] + assert b.targets((1, 2)) == ["break_howl"] assert [b.targets(p) for p in [(2, 3), (2, 4)]] == [["dash_stop"], ["dash"]] assert b.targets((7, 29)) == b.targets((7, 30)) == [TURN, "dash"] assert zinogre.m.moves["dash"].after is None @@ -85,8 +82,37 @@ def test_a_wrapper_turns_and_plays_the_move_of_the_pair_it_leads_to(zinogre): assert b.targets((7, 110)) == [TURN, "p3_7_109"] and b.targets((7, 109)) == ["p3_7_109"] twins = [b.targets((2, 11)), b.targets((7, 5))] assert twins == [["p3_2_10"], ["p3_7_4"]] - assert {p for p in b.derived().values()} >= {(7, 0), (7, 109), (2, 10), (7, 4), (1, 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"] + + +def chain_of(m: manifest.Manifest, name: str) -> list[tuple[object, ...]]: + """What a derived move plays, step by step: clip, attacks, steer, length.""" + out = [] + mv: manifest.Move | None = m.moves[name] + while mv is not None: + clip = m.clips[mv.clip].source if mv.clip else mv.anim + attacks = tuple((a.id, a.frame, a.end) for a in mv.attacks) + out.append((clip, attacks, mv.steer, mv.length)) + mv = m.moves[mv.after] if mv.after else None + return out + + +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. (7, 71), (7, 82) and + (7, 119) are not the script's: only wrappers lead to them, and play (7, 13)'s move.""" + 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) == 17 + apart = manifest.load(PORT) + apart.brain.pairs |= {k: manifest.OWN for k in twins} + again, _ = own.expand(apart, data) + for key, first in twins.items(): + name = manifest.own_name(manifest.pair_of(key)) + assert chain_of(again, name) == chain_of(again, first), key def test_what_fought_the_brain_is_gone(zinogre): @@ -226,19 +252,24 @@ def test_zinogre_brain_file_round_trip(zinogre, data, tmp_path): def test_the_charge_table(zinogre): ch = zinogre.charge assert ch is not None - assert ch.ups == {"charge_up": (40, 55, 65, 100)} and ch.transform == "p3_1_4" - assert (ch.chip_gauge, ch.chip_durability, ch.cooldown_up, ch.cooldown_out) == (5, 5, 420, 900) + assert ch.ups == { + "p3_1_6": (10, 15, 15, 20), + "p3_1_15": (25, 35, 40, 55), + "charge_up": (40, 55, 65, 100), + "p3_1_17": (55, 75, 100, 100), + "p3_1_3": (50, 50, 50, 50), + "p3_1_20": (50, 50, 50, 50), + } + assert ch.transform == "p3_1_4" and (ch.chip_gauge, ch.chip_durability) == (5, 5) + assert (ch.cooldown_up, ch.cooldown_out) == (420, 900) assert ch.discharge == ((4, 16), (4, 29), (4, 30), (4, 31)) - got = [(f.level, f.code) for f in zinogre.said] - assert got.count(("warning", "P3_CHARGE_UNMAPPED")) == 4 and ("info", "P3_CHARGE_SHARED") in got - never = {f.text.split()[1] for f in zinogre.said if f.code == "P3_CHARGE_UNMAPPED"} - assert never == {"1,3", "1,6", "1,17", "1,20"} and len(zinogre.said) == 5 + assert zinogre.said == [], "every charge-up pair has its own move" def test_the_fatigue_table(zinogre, data): fat, found = fatigue.table(zinogre.m, data) assert fat is not None and not [f for f in found if f.level == "error"] - assert fat.pairs[4, 16] == 200 and {"p3_7_12", "p3_7_94", "stamp"} <= set(fat.own) + assert fat.pairs[4, 16] == 200 and {"p3_7_12", "p3_7_94", "p3_7_85"} <= set(fat.own) @pytest.fixture(scope="module") @@ -255,9 +286,11 @@ def test_the_moves_module_carries_fatigue_and_charge(module_text): lines = {line.split(" = ")[0].strip(): line for line in text.splitlines()} assert "fatigue" in lines and "calm = true" in lines["fatigue"] assert lines["charge"].strip() == ( - 'charge = { ups = { charge_up = { 40, 55, 65, 100 } }, transform = "p3_1_4", ' - "chip_gauge = 5, chip_durability = 5, cooldown_up = 420, cooldown_out = 900, " - "discharge = { { 4, 16 }, { 4, 29 }, { 4, 30 }, { 4, 31 } } }," + "charge = { ups = { charge_up = { 40, 55, 65, 100 }, p3_1_15 = { 25, 35, 40, 55 }, " + "p3_1_17 = { 55, 75, 100, 100 }, p3_1_20 = { 50, 50, 50, 50 }, " + "p3_1_3 = { 50, 50, 50, 50 }, p3_1_6 = { 10, 15, 15, 20 } }, " + 'transform = "p3_1_4", chip_gauge = 5, chip_durability = 5, cooldown_up = 420, ' + "cooldown_out = 900, discharge = { { 4, 16 }, { 4, 29 }, { 4, 30 }, { 4, 31 } } }," ) @@ -266,6 +299,7 @@ def test_the_own_moves_fit_the_framework(zinogre, module_text): _, 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) == 83 and keys == 4367 assert len(names) <= moves.SLOTS and keys <= moves.POOL assert not moves.problems(zinogre.m, built.layout, moves.records(Data.find().fu, 75)) @@ -275,5 +309,5 @@ def test_the_cli_splices_the_port(data, capsys): assert main(["splice", str(PORT), *args]) == 0 out = capsys.readouterr().out assert "routines added" in out and "worst run" in out - assert " charge: charge_up 40/55/65/100; transform p3_1_4;" in out + assert " charge: charge_up 40/55/65/100, p3_1_15 25/35/40/55, " in out assert " pair 7,23 -> 1,2 x" in out and " pair 7,23 -> own p3_7_0 (slot " in out diff --git a/ports/README.md b/ports/README.md index f592d0ca..9fb6e4e2 100644 --- a/ports/README.md +++ b/ports/README.md @@ -207,11 +207,9 @@ up (the helper's gauge, `[brain] charge`) and when he is tired (`[brain] fatigue A donor pair plays a host pair (a turn, a walk), 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. The registry holds 2048 turn keys for all own moves, which the -authored moves take 991 of, so the derived moves are the ones that fit; a donor move that did not -plays the nearest move that did: the other horn charges the right-horn one, the long claw combos -their opening, the tumbles and the straight flip the backflip, the second smackdown the first, -the claw into a backflip the `stamp`. +move of the pair it leads to; pairs whose derived moves come out identical (twins) share the +first one's. His 83 own moves, the 14 authored and the 69 derived, take 4367 of the registry's +5632 turn keys and 83 of its 96 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 608862a5..cced5303 100644 --- a/ports/zinogre.toml +++ b/ports/zinogre.toml @@ -1093,10 +1093,14 @@ charge = true 2, ] "1,2" = "break_howl" +"1,3" = "own" "1,4" = "own" -"1,15" = "charge_up" +"1,6" = "own" +"1,15" = "own" "1,16" = "charge_up" +"1,17" = "own" "1,19" = "own" +"1,20" = "own" "2,3" = "dash_stop" "2,4" = "dash" "2,5" = [ @@ -1154,11 +1158,12 @@ charge = true "7,4" = "own" "7,5" = "p3_7_4" "7,12" = "own" +"7,13" = "own" "7,14" = "own" "7,16" = "own" -"7,17" = "p3_7_16" -"7,19" = "p3_7_16" -"7,22" = "p3_7_33" +"7,17" = "own" +"7,19" = "own" +"7,22" = "own" "7,23" = [ [ 1, @@ -1208,7 +1213,7 @@ charge = true 1, 2, ], - "p3_7_12", + "p3_7_13", ] "7,39" = [ [ @@ -1251,14 +1256,14 @@ charge = true 1, 2, ], - "p3_7_33", + "p3_7_22", ] "7,56" = [ [ 1, 2, ], - "p3_7_33", + "p3_7_22", ] "7,57" = [ [ @@ -1289,10 +1294,10 @@ charge = true "p3_2_10", ] "7,65" = "own" -"7,66" = "p3_7_33" +"7,66" = "own" "7,67" = "own" -"7,68" = "p3_7_12" -"7,69" = "p3_7_12" +"7,68" = "own" +"7,69" = "own" "7,70" = "p3_7_12" "7,72" = [ [ @@ -1306,7 +1311,7 @@ charge = true 1, 2, ], - "p3_7_12", + "p3_7_13", ] "7,74" = [ [ @@ -1322,8 +1327,8 @@ charge = true ], "p3_7_67", ] -"7,79" = "p3_7_12" -"7,80" = "p3_7_12" +"7,79" = "p3_7_68" +"7,80" = "p3_7_69" "7,81" = "p3_7_12" "7,83" = [ [ @@ -1337,11 +1342,11 @@ charge = true 1, 2, ], - "p3_7_12", + "p3_7_13", ] -"7,85" = "stamp" +"7,85" = "own" "7,86" = "own" -"7,87" = "p3_7_33" +"7,87" = "own" "7,89" = "own" "7,92" = [ [ @@ -1386,38 +1391,38 @@ charge = true ], "p3_7_89", ] -"7,99" = "p3_7_40" -"7,100" = "p3_7_86" +"7,99" = "own" +"7,100" = "own" "7,101" = [ [ 1, 2, ], - "p3_7_40", + "p3_7_99", ] "7,102" = [ [ 1, 2, ], - "p3_7_40", + "p3_7_99", ] "7,103" = [ [ 1, 2, ], - "p3_7_86", + "p3_7_100", ] "7,104" = [ [ 1, 2, ], - "p3_7_86", + "p3_7_100", ] -"7,107" = "p3_7_33" -"7,108" = "p3_7_33" +"7,107" = "p3_7_87" +"7,108" = "p3_7_87" "7,109" = "own" "7,110" = [ [ @@ -1436,8 +1441,8 @@ charge = true "7,113" = "p3_7_94" "7,114" = "p3_7_94" "7,115" = "p3_7_94" -"7,116" = "p3_7_12" -"7,117" = "p3_7_12" +"7,116" = "p3_7_68" +"7,117" = "p3_7_69" "7,118" = "p3_7_12" "7,120" = [ [ @@ -1451,7 +1456,7 @@ charge = true 1, 2, ], - "p3_7_12", + "p3_7_13", ] [brain.when]