From 42ff55928ed58c3f7f7a7953b68d0cbeeff2acc0 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:33:39 +0200 Subject: [PATCH 1/8] feat(port): a [brain.pairs] value may be a list of targets played in order Co-Authored-By: Claude Sonnet 5.5 --- docs/formats/p3rd_ai_script.md | 6 +- .../mhfu-port/src/mhfu_port/brain/fatigue.py | 2 +- packages/mhfu-port/src/mhfu_port/brain/own.py | 3 +- .../mhfu-port/src/mhfu_port/brain/splice.py | 32 +++++---- packages/mhfu-port/src/mhfu_port/manifest.py | 70 +++++++++++++------ packages/mhfu-port/tests/test_fatigue.py | 7 ++ packages/mhfu-port/tests/test_manifest.py | 31 ++++++++ packages/mhfu-port/tests/test_own.py | 5 ++ packages/mhfu-port/tests/test_splice.py | 30 ++++++++ 9 files changed, 147 insertions(+), 39 deletions(-) diff --git a/docs/formats/p3rd_ai_script.md b/docs/formats/p3rd_ai_script.md index 347db1ec..815caa8a 100644 --- a/docs/formats/p3rd_ai_script.md +++ b/docs/formats/p3rd_ai_script.md @@ -75,7 +75,7 @@ keeps everything else: its roaming, sections, target pick and interrupts. | Donor | Host | |---|---| -| `enter m s` | `enter M S 0`, the pair from `[brain.pairs]`; an own move there: `enter 16 k 0` | +| `enter m s` | `enter M S 0`, the pair from `[brain.pairs]`; an own move there: `enter 16 k 0`; a list there: one `enter` per item | | `random` | `random`: each weight `w` becomes `w * 32 / 100` by largest remainder, at least 1; a zero arm goes | | `switch_range` | `switch_range`: case `k` is bucket `k + 1`, the default the last, a missing case copies the default | | `switch_mode` | `if_mode2` for mode 1, the default as its else | @@ -102,6 +102,10 @@ donor's `enter 7 13` becomes `enter 16 k 0`: main 16 is `OWN_MOVE_MAIN`, which no species uses, and `k` the move's registry slot. em_vhook plays move `k` in place of the pair and the script goes on after the `enter` when it ends, as for a host pair. +A value may be a list, played in order, each item one `enter` that yields: `"7,23" = [[1, 2], "own"]` +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. + 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/src/mhfu_port/brain/fatigue.py b/packages/mhfu-port/src/mhfu_port/brain/fatigue.py index 1891a5b4..cdf74383 100644 --- a/packages/mhfu-port/src/mhfu_port/brain/fatigue.py +++ b/packages/mhfu-port/src/mhfu_port/brain/fatigue.py @@ -393,7 +393,7 @@ def carriers(m: Manifest) -> set[Pair]: hub = moves.hub(species) uses = any(mv.own and mv.carrier is None for mv in m.moves.values()) or ( m.brain is not None - and any(isinstance(m.brain.target(_pair(k)), str) for k in m.brain.pairs) + and any(isinstance(t, str) for k in m.brain.pairs for t in m.brain.targets(_pair(k))) ) if uses and hub is not None: out.add(hub) diff --git a/packages/mhfu-port/src/mhfu_port/brain/own.py b/packages/mhfu-port/src/mhfu_port/brain/own.py index 0274c8ae..a8ad04ea 100644 --- a/packages/mhfu-port/src/mhfu_port/brain/own.py +++ b/packages/mhfu-port/src/mhfu_port/brain/own.py @@ -43,6 +43,7 @@ Move, Steer, own_name, + targets_of, ) from ..p3hits import Plan, Step, Walk @@ -347,7 +348,7 @@ def pairs(b: Brain) -> list[Pair]: found = [] for key, target in b.pairs.items(): main, _, sub = key.partition(",") - if target == OWN: + if OWN in targets_of(target): found.append((int(main), int(sub))) return sorted(found) diff --git a/packages/mhfu-port/src/mhfu_port/brain/splice.py b/packages/mhfu-port/src/mhfu_port/brain/splice.py index 82630138..d322af9f 100644 --- a/packages/mhfu-port/src/mhfu_port/brain/splice.py +++ b/packages/mhfu-port/src/mhfu_port/brain/splice.py @@ -19,7 +19,7 @@ A donor pair that `[brain.pairs]` maps to an own move becomes `enter OWN_MOVE_MAIN k 0`, `k` the move's slot in `slots` (`moves.slots`, the one numbering the moves module and `mhfu_port.lua` -use); em_vhook plays that move in place of a host pair. +use); em_vhook plays that move in place of a host pair. A list there enters each item in turn. With `[brain] fatigue` the donor's `if_tired`, and `if_stamina_low` at 0 (stamina 0 only precedes tired), become the host's `if_gauge_a_low`, which the framework's fatigue helper drives @@ -366,22 +366,24 @@ def _yields(self, n: ir.Node) -> Yields: # ---- ops def _enter(self, n: p3.Insn, w: Where) -> list[ir.Node]: + """One `enter` per target of the pair, in order.""" pair = (n.args["main"], n.args["sub"]) - target = self.spec.target(pair) - if target is None: + targets = self.spec.targets(pair) + if not targets: self.add(w, "error", "P3_PAIR_UNMAPPED", f"enter {pair[0]},{pair[1]} has no host pair") - return [] - if isinstance(target, str): - slot = self.slots.get(target) - if slot is None: - text = f"enter {pair[0]},{pair[1]} plays own move {target}, which has no slot" - self.add(w, "error", "P3_OWN_MISSING", text) - return [] - enter = edit.insn("enter", addresses.OWN_MOVE_MAIN, slot, 0) - else: - enter = edit.insn("enter", *target, 0) - self.uses[pair, target] += 1 - return [enter] + out: list[ir.Node] = [] + for target in targets: + if isinstance(target, str): + slot = self.slots.get(target) + if slot is None: + text = f"enter {pair[0]},{pair[1]} plays own move {target}, which has no slot" + self.add(w, "error", "P3_OWN_MISSING", text) + continue + out.append(edit.insn("enter", addresses.OWN_MOVE_MAIN, slot, 0)) + else: + out.append(edit.insn("enter", *target, 0)) + self.uses[pair, target] += 1 + return out def _end(self, n: p3.Insn, w: Where) -> list[ir.Node]: if not w.tail: diff --git a/packages/mhfu-port/src/mhfu_port/manifest.py b/packages/mhfu-port/src/mhfu_port/manifest.py index ccdbe47c..d69c17f6 100644 --- a/packages/mhfu-port/src/mhfu_port/manifest.py +++ b/packages/mhfu-port/src/mhfu_port/manifest.py @@ -374,6 +374,10 @@ class Effect: Pair = tuple[int, int] +Target = Pair | str +"""What a donor pair plays: a host pair, or an own move's name (`OWN` for the derived one).""" +Plays = Target | list[Target] +"""A `[brain.pairs]` value: one target, or several played in order.""" _PAIR = re.compile(r"(\d+),(\d+)") @@ -394,9 +398,10 @@ class Brain: history: int | None = None """The script variable that keeps the last call, for `switch_prev_call`. None: the host's first free variable (`mhfu.em.script.vars`).""" - pairs: dict[str, Pair | str] = field(default_factory=dict) + 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`. The splice enters an own move as `enter OWN_MOVE_MAIN k 0`, `k` its + `[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.""" when: dict[str, bool] = field(default_factory=dict) """The arm a donor test with no host counterpart takes, by its MHP3rd opcode name.""" @@ -410,11 +415,17 @@ class Brain: donor's `if_tired` (and `if_stamina_low` 0) into the host's `if_gauge_a_low` instead of asking `[brain.when]`. The host must leave gauge A alone (`P3_FATIGUE_HOST`).""" - def target(self, pair: Pair) -> Pair | str | None: - """What the donor's `pair` plays: a host pair or an own move's name (`OWN` resolved to - `own_name(pair)`); None when the map has nothing.""" + def targets(self, pair: Pair) -> list[Target]: + """What the donor's `pair` plays, in order: host pairs and own moves' names (`OWN` + resolved to `own_name(pair)`); empty when the map has nothing.""" t = self.pairs.get(f"{pair[0]},{pair[1]}") - return own_name(pair) if t == OWN else t + return [own_name(pair) if x == OWN else x for x in targets_of(t)] + + def target(self, pair: Pair) -> Target | None: + """What the donor's `pair` comes to: its one target, or the last of a list (the move the + turn before it leads to); None when the map has nothing.""" + found = self.targets(pair) + return found[-1] if found else None def host_pair(self, pair: Pair) -> Pair | None: """The host pair the donor's `pair` enters; None when the map has none or an own move.""" @@ -422,6 +433,13 @@ def host_pair(self, pair: Pair) -> Pair | None: return t if isinstance(t, tuple) else None +def targets_of(plays: Plays | None) -> list[Target]: + """The targets of a `[brain.pairs]` value, as written.""" + if plays is None: + return [] + return list(plays) if isinstance(plays, list) else [plays] + + OWN = "own" """A `[brain.pairs]` value: the build derives the donor pair's own move from both games' files, named `own_name(pair)`; an own move of that name in `[moves]` is used instead.""" @@ -499,9 +517,14 @@ def _value(tp: Any, v: object, where: str) -> Any: args = typing.get_args(tp) if origin in (typing.Union, types.UnionType): opts = [a for a in args if a is not type(None)] - if len(opts) > 1: # str or one other type: a string reads as str - return _value(str if isinstance(v, str) else opts[0], v, where) - return _value(opts[0], v, where) + if len(opts) > 1 and str in opts: + opts = [str] if isinstance(v, str) else [a for a in opts if a is not str] + for a in opts[:-1]: # the first type that reads it, a tuple before a list + try: + return _value(a, v, where) + except ManifestError: + pass + return _value(opts[-1], v, where) if tp is Claim: v = _claim_shorthand(v) if isinstance(tp, type) and dataclasses.is_dataclass(tp): @@ -589,13 +612,17 @@ def _own_move(mv: Move, w: str) -> None: def _brain(b: Brain) -> None: _need(b.donor > 0 and b.slot >= 0, "brain", "donor is a monster id and slot 0 or more") _need(b.history in (None, *script_vars.BYTE), "brain.history", "is a script variable, 1 to 4") - for key, t in b.pairs.items(): + for key, plays in b.pairs.items(): m = _PAIR.fullmatch(key) w = f"brain.pairs.{key}" _need(m is not None and all(int(x) in BYTE for x in m.groups()), w, "is main,sub") - if isinstance(t, tuple): - main, sub = t - _need(main in MAIN_STATES and sub in BYTE, w, "a host pair is a main state and a sub") + _need(not isinstance(plays, list) or bool(plays), w, "a list needs a target") + for t in targets_of(plays): + if isinstance(t, tuple): + main, sub = t + _need( + main in MAIN_STATES and sub in BYTE, w, "a host pair is a main state and a sub" + ) for key, record in b.attacks.items(): _need(key.isdecimal(), f"brain.attacks.{key}", "is a donor record id") _need(record >= 0, f"brain.attacks.{key}", "a host record id is 0 or more") @@ -648,14 +675,15 @@ 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: - for donor, t in m.brain.pairs.items(): - if isinstance(t, str) and t != OWN: - target = m.moves.get(t) - _need( - target is not None and target.own, - f"brain.pairs.{donor}", - f"{t!r} is no own move", - ) + for donor, plays in m.brain.pairs.items(): + for t in targets_of(plays): + if isinstance(t, str) and t != OWN: + target = m.moves.get(t) + _need( + target is not None and target.own, + f"brain.pairs.{donor}", + f"{t!r} is no own move", + ) for name, p in m.parts.items(): _need(p.index in PARTS, f"parts.{name}", f"index {p.index} is not a part") _need(p.hitzone_row in (None, *ROWS), f"parts.{name}", "hitzone_row is not a row") diff --git a/packages/mhfu-port/tests/test_fatigue.py b/packages/mhfu-port/tests/test_fatigue.py index bfded70f..ca41f92f 100644 --- a/packages/mhfu-port/tests/test_fatigue.py +++ b/packages/mhfu-port/tests/test_fatigue.py @@ -233,6 +233,13 @@ def test_carriers_of_a_manifest(): assert fatigue.carriers(plain) == set() +def test_a_list_with_an_own_move_rides_the_hub(): + m = manifest.loads(TEXT.replace('"7,13" = "own"', '"7,13" = [[1, 2], "own"]')) + assert fatigue.carriers(m) == {(0, 2)} + m = manifest.loads(TEXT.replace('"7,13" = "own"', '"7,13" = [[1, 2], [1, 3]]')) + assert fatigue.carriers(m) == set() + + def test_a_port_without_the_flag_has_no_table(): m = manifest.loads(TEXT) assert fatigue.table(m, None) == (None, []) # type: ignore[arg-type] # nothing read diff --git a/packages/mhfu-port/tests/test_manifest.py b/packages/mhfu-port/tests/test_manifest.py index 4c93d45b..1bad6630 100644 --- a/packages/mhfu-port/tests/test_manifest.py +++ b/packages/mhfu-port/tests/test_manifest.py @@ -230,6 +230,37 @@ def test_brain_own_moves(): M.loads(_with(own + "[brain.pairs]\n'7,14' = 'bite'\n")) +def test_brain_pairs_may_be_lists(): + own = "[moves.slam]\nanim = 40\n" + BRAIN + text = ( + own + + "[brain.pairs]\n'7,13' = [[1, 2], 'own']\n'7,14' = [[1, 2], 'slam']\n'2,3' = [[1, 2]]\n" + ) + m = M.loads(_with(text)) + b = m.brain + assert b is not None + assert b.pairs["7,13"] == [(1, 2), "own"] and b.pairs["2,3"] == [(1, 2)] + assert b.targets((7, 13)) == [(1, 2), "p3_7_13"] and b.targets((7, 14)) == [(1, 2), "slam"] + assert b.target((7, 13)) == "p3_7_13" and b.target((2, 3)) == (1, 2) + assert b.host_pair((2, 3)) == (1, 2) and b.host_pair((7, 14)) is None + assert b.targets((9, 9)) == [] and b.target((9, 9)) is None + assert M.loads(M.dumps(m)) == m + + +@pytest.mark.parametrize( + ("pairs", "why"), + [ + ("[]", "needs a target"), + ("[[1, 2], 'bite']", "no own move"), + ("[[8, 2], 'own']", "a host pair"), + ("[[1, 2], [3]]", "2 values"), + ], +) +def test_brain_pair_lists_are_checked(pairs, why): + with pytest.raises(ManifestError, match=why): + M.loads(_with(BRAIN + f"[brain.pairs]\n'7,13' = {pairs}\n")) + + def test_minimal(): m = M.loads(MINIMAL) assert m.build == M.Build() diff --git a/packages/mhfu-port/tests/test_own.py b/packages/mhfu-port/tests/test_own.py index bbfda846..7ed5a166 100644 --- a/packages/mhfu-port/tests/test_own.py +++ b/packages/mhfu-port/tests/test_own.py @@ -176,6 +176,11 @@ def test_pairs(): assert m.brain is not None and own.pairs(m.brain) == [(7, 13)] +def test_pairs_sees_own_in_a_list(): + m = manifest.loads(TEXT + '"7,14" = [[1, 2], "own"]\n"7,15" = [[1, 2], [1, 3]]\n') + assert m.brain is not None and own.pairs(m.brain) == [(7, 13), (7, 14)] + + # --- with the games --- diff --git a/packages/mhfu-port/tests/test_splice.py b/packages/mhfu-port/tests/test_splice.py index e3792f0f..62d6bd31 100644 --- a/packages/mhfu-port/tests/test_splice.py +++ b/packages/mhfu-port/tests/test_splice.py @@ -431,6 +431,36 @@ def test_the_splice_without_slots_fails_an_own_pair(): assert [f.code for f in s.findings] == ["P3_OWN_MISSING"] and s.slots == {} +def test_a_list_enters_its_targets_in_order(): + sp = spec(pairs={"7,13": [(1, 2), "slam"], "2,3": [(1, 2), (1, 3)]}) + got, t = translate(sp, slots={"slam": 5}, base_0=f"{enter(7, 13)} {enter(2, 3)}") + assert got == ["enter 1 2 0", f"enter {OWN} 5 0", "enter 1 2 0", "enter 1 3 0"] + assert not t.findings + assert dict(t.uses) == { + ((7, 13), (1, 2)): 1, + ((7, 13), "slam"): 1, + ((2, 3), (1, 2)): 1, + ((2, 3), (1, 3)): 1, + } + + +def test_a_list_names_a_derived_move(): + sp = spec(pairs={"7,13": [(1, 2), manifest.OWN]}) + got, _ = translate(sp, slots={"p3_7_13": 2}, base_0=enter(7, 13)) + assert got == ["enter 1 2 0", f"enter {OWN} 2 0"] + + +def test_a_list_with_a_move_that_has_no_slot_keeps_the_rest(): + got, t = translate(spec(pairs={"7,13": [(1, 2), "slam"]}), base_0=enter(7, 13)) + assert got == ["enter 1 2 0"] and found(t) == [("error", "P3_OWN_MISSING")] + + +def test_every_enter_of_a_list_yields_for_the_latch(): + sp = spec(pairs=TWO | {"7,13": [(1, 2), "slam"]}) + got, t = translate(sp, slots={"slam": 5}, base_0=latched(enter(7, 13))) + assert got == ["enter 1 2 0", f"enter {OWN} 5 0", "enter 1 2 0"] and not t.findings + + # ---- the call history From fec8dff78cfc9755d075bababf0ff69b89052b5a Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:35:17 +0200 Subject: [PATCH 2/8] feat(port): the splice translates switch_dist, switch_hp, if_target_side and switch_global_540 Co-Authored-By: Claude Sonnet 5.5 --- docs/formats/p3rd_ai_script.md | 4 + .../mhfu-port/src/mhfu_port/brain/splice.py | 78 +++++++++++++++++++ packages/mhfu-port/tests/test_splice.py | 75 ++++++++++++++++++ packages/mhfu/src/mhfu/addresses.toml | 2 + 4 files changed, 159 insertions(+) diff --git a/docs/formats/p3rd_ai_script.md b/docs/formats/p3rd_ai_script.md index 815caa8a..d81eec0f 100644 --- a/docs/formats/p3rd_ai_script.md +++ b/docs/formats/p3rd_ai_script.md @@ -81,6 +81,10 @@ keeps everything else: its roaming, sections, target pick and interrupts. | `switch_mode` | `if_mode2` for mode 1, the default as its else | | `if_enraged` | `if_enraged` | | `if_facing ang` | `if_pursuit_off_facing` at `ang * 360 / 256` degrees with the arms swapped; past 127, `if_off_facing` | +| `switch_dist` | `switch_dist`: the cases are hundreds in both; a case at or under an earlier one, which no distance reaches, goes | +| `switch_hp` | `switch_hp` with each case one higher: MHP3rd's matches the integer HP percent at or below it, MHFU's below it | +| `if_target_side` | `if_target_in_arc 0 127`: the target's bearing minus the facing in the first half turn (bit 15 clear), arms as they are | +| `switch_global_540` | its case 2, the only value the scripts test (the default without one): the host has no such word | | `if_target_state`, `if_tired`, `if_stamina_low`, `if_target_set` | no host state: `[brain.when]` picks the arm; with `[brain] fatigue`, `if_tired` and `if_stamina_low 0` are `if_gauge_a_low` ([Fatigue](#fatigue)) | | `if_object_near` | false | | `set_target 0 0 0` | dropped: the host picked its target before | diff --git a/packages/mhfu-port/src/mhfu_port/brain/splice.py b/packages/mhfu-port/src/mhfu_port/brain/splice.py index d322af9f..30ca8753 100644 --- a/packages/mhfu-port/src/mhfu_port/brain/splice.py +++ b/packages/mhfu-port/src/mhfu_port/brain/splice.py @@ -55,6 +55,14 @@ """MHP3rd's mode byte to the MHFU test that holds in that mode.""" WHEN = ("if_target_state", "if_tired", "if_stamina_low", "if_target_set") """The tests the manifest answers, in `[brain.when]`: MHFU has no state for them.""" +SIDE = (0, 127) +"""The arc `if_target_in_arc` tests for `if_target_side`: the target's bearing minus the facing in +0 to 127/256 of a turn, MHP3rd's bit 15 clear (its upper bound is the byte below the half).""" +GLOBAL_540 = 2 +"""The one value of the word `switch_global_540` reads that the donors' scripts test for.""" +HP_SHIFT = 1 +"""MHP3rd's `switch_hp` case matches an HP percent at or below it, MHFU's below it (both compare +the truncated integer percent), so each case moves up by one.""" GAUGE = "if_gauge_a_low" """The host test the fatigue helper answers.""" GAUGE_A_OPS = (GAUGE, "refill_gauge_a") @@ -467,6 +475,72 @@ def _range(self, n: p3.Block, w: Where) -> list[ir.Node]: return [] return [edit.switch("switch_range", cases=[(b,) for b in buckets])] + def _cases( + self, n: p3.Block, w: Where, bound: bool, shift: int = 0 + ) -> tuple[list[tuple[int, list[ir.Node]]], list[ir.Node]]: + """A switch's cases, translated and ascending, and its default. A case no value reaches is + dropped before it is translated: with `bound` (the first limit above the value wins) one + at or under an earlier case, else one that repeats a value. `shift` moves each value up.""" + cases: dict[int, list[ir.Node]] = {} + default: list[ir.Node] = [] + top = -1 + for a in n.arms: + if a.sub != p3ops.CASE or a.value is None: + default = self.sub(a.body, w) + continue + value = min(a.value + shift, 0xFF) + if value <= top if bound else value in cases: + self.add(w, "info", "P3_DROPPED", f"{n.op.name} case {a.value} is never reached") + continue + cases[value] = self.sub(a.body, w) + top = max(top, value) + return sorted(cases.items()), default + + def _switch( + self, n: p3.Block, w: Where, *args: int, bound: bool = True, shift: int = 0 + ) -> list[ir.Node]: + """A switch of the same name on the host, its cases kept in step.""" + cases, default = self._cases(n, w, bound, shift) + if not default and not any(body for _, body in cases): + self.add(w, "info", "P3_DROPPED", f"{n.op.name}: every arm is empty") + return [] + return [edit.switch(n.op.name, *args, cases=cases, default=default or None)] + + def _answer( + self, n: p3.Block, w: Where, value: int, why: str, say: bool = False + ) -> list[ir.Node]: + """A switch whose value is taken as `value`: the case with it, else the default, in its + place; the arms with code that go are named (a note even when none does, with `say`).""" + arms = [a for a in n.arms if a.sub == p3ops.CASE and a.value == value] + keep = next(iter(arms or [a for a in n.arms if a.sub != p3ops.CASE]), None) + gone = [a for a in n.arms if a is not keep and a.body] + if gone: + names = ", ".join( + f"{f'case {a.value}' if a.sub == p3ops.CASE else 'default'} " + f"({p3.count(a.body)} instructions, first {_text(a.body[0])})" + for a in gone + ) + text = f"{_text(n)} taken as answer {value} ({why}): arms dropped: {names}" + self.add(w, "warning", "P3_FOLDED", text) + elif say: + self.add(w, "info", "P3_FOLDED", f"{_text(n)} taken as answer {value} ({why})") + return self.sub(keep.body, w) if keep else [] + + def _dist(self, n: p3.Block, w: Where) -> list[ir.Node]: + return self._switch(n, w) + + def _hp(self, n: p3.Block, w: Where) -> list[ir.Node]: + return self._switch(n, w, shift=HP_SHIFT) + + def _side(self, n: p3.Block, w: Where) -> list[ir.Node]: + then, other = _branches(n) + return self.if_( + w, n.op.name, "if_target_in_arc", SIDE, self.sub(then, w), self.sub(other, w) + ) + + def _global_540(self, n: p3.Block, w: Where) -> list[ir.Node]: + return self._answer(n, w, GLOBAL_540, "the host has no such word", say=True) + def _mode(self, n: p3.Block, w: Where) -> list[ir.Node]: cases: list[tuple[int, list[ir.Node]]] = [] out: list[ir.Node] = [] @@ -596,6 +670,10 @@ def _host(self, n: p3.Insn | p3.Block, w: Where) -> list[ir.Node]: "switch_mode": Translator._mode, "if_enraged": Translator._enraged, "if_facing": Translator._facing, + "if_target_side": Translator._side, + "switch_dist": Translator._dist, + "switch_hp": Translator._hp, + "switch_global_540": Translator._global_540, "if_object_near": Translator._near, "set_target": Translator._target, "set_latch": Translator._set_latch, diff --git a/packages/mhfu-port/tests/test_splice.py b/packages/mhfu-port/tests/test_splice.py index 62d6bd31..b2a69c20 100644 --- a/packages/mhfu-port/tests/test_splice.py +++ b/packages/mhfu-port/tests/test_splice.py @@ -200,6 +200,81 @@ def test_facing_swaps_arms_and_scales(ang, name, deg): assert found(t) == ([("warning", "P3_APPROX")] if name == "if_off_facing" else []) +# ---- distance, side, HP, the global word + + +def test_dist_cases_keep_their_hundreds_and_the_default(): + arms = [case(5, enter(2, 3)), case(10, ""), other(enter(2, 4))] + got, t = translate(base_0=blk("switch_dist", arms=arms)) + assert got == ["switch_dist", "case 5", " enter 1 2 0", "case 10", "default", " enter 1 3 0"] + assert not t.findings + + +def test_dist_without_a_default_adds_none(): + got, _ = translate(base_0=blk("switch_dist", arms=[case(5, enter(2, 3))])) + assert got == ["switch_dist", "case 5", " enter 1 2 0"] + + +def test_a_case_nothing_reaches_goes_before_it_is_translated(): + arms = [case(10, enter(2, 3)), case(10, op("call", 1)), case(4, enter(2, 4))] + got, t = translate(base_0=blk("switch_dist", arms=arms), call_1=enter(2, 4)) + assert got == ["switch_dist", "case 10", " enter 1 2 0"] + assert found(t) == [("info", "P3_DROPPED")] * 2 and not t.routines + + +def test_dist_with_nothing_to_do(): + arms = [case(5, ""), other("")] + got, t = translate(base_0=blk("switch_dist", arms=arms)) + assert got == [] and found(t) == [("info", "P3_DROPPED")] + + +def test_hp_cases_are_one_percent_up(): + arms = [case(25, enter(2, 3)), case(70, enter(2, 4)), other(enter(7, 5))] + got, t = translate(base_0=blk("switch_hp", arms=arms)) + assert got == [ + "switch_hp", + "case 26", + " enter 1 2 0", + "case 71", + " enter 1 3 0", + "default", + " enter 1 4 0", + ] + assert not t.findings + + +def test_hp_cases_stop_at_a_byte(): + arms = [case(254, enter(2, 3)), case(255, enter(2, 4))] + got, t = translate(base_0=blk("switch_hp", arms=arms)) + assert got == ["switch_hp", "case 255", " enter 1 2 0"] and found(t) == [ + ("info", "P3_DROPPED") + ] + + +def test_side_is_an_arc_with_the_arms_as_they_are(): + got, t = translate(base_0=blk("if_target_side", head=enter(2, 3), arms=[other(enter(2, 4))])) + assert got == ["if_target_in_arc 0 127", " enter 1 2 0", "else", " enter 1 3 0"] + assert not t.findings + + +def test_global_540_is_case_2(): + got, t = translate(base_0=blk("switch_global_540", arms=[case(2, enter(2, 3))])) + assert got == ["enter 1 2 0"] and found(t) == [("info", "P3_FOLDED")] + + +def test_global_540_names_the_arms_it_drops(): + arms = [case(1, enter(2, 4)), case(2, enter(2, 3)), other(enter(7, 5))] + got, t = translate(base_0=blk("switch_global_540", arms=arms)) + assert got == ["enter 1 2 0"] and found(t) == [("warning", "P3_FOLDED")] + assert "case 1 (1 instructions" in t.findings[0].text and "default" in t.findings[0].text + + +def test_global_540_without_case_2_takes_the_default(): + arms = [case(1, enter(2, 4)), other(enter(2, 3))] + got, t = translate(base_0=blk("switch_global_540", arms=arms)) + assert got == ["enter 1 2 0"] and found(t) == [("warning", "P3_FOLDED")] + + # ---- tests without a host state diff --git a/packages/mhfu/src/mhfu/addresses.toml b/packages/mhfu/src/mhfu/addresses.toml index 5cbc15bc..1f88e12a 100644 --- a/packages/mhfu/src/mhfu/addresses.toml +++ b/packages/mhfu/src/mhfu/addresses.toml @@ -1126,3 +1126,5 @@ names = ["clip", "back", "pair", "stopped", "replaced", "refused", "lost", "wall [const] OWN_MOVE_MAIN = { value = 16, doc = "an AI script `enter` main no species has (they use 0..7; 16 is past the 8-bit main mask of em_vhook's substitutions): em_vhook's enter-action stub enters the carrier of own move (an EM_MOVES slot; em75's hub (0,2) when it is empty or out of range) instead and plays that move at the next AI step" } +CHARGE_QUERY_CHARGED = { value = 0, doc = "the AI script `switch_query` arg that asks whether a port with `[brain] charge` is charged (MHP3rd's own numbering of the query): the framework answers 1 or 0" } +CHARGE_QUERY_COOLDOWN = { value = 6, doc = "the `switch_query` arg that asks whether the charge cooldown runs (MHP3rd's numbering): the framework answers 1 or 0" } From e9fc1154d36d8fb3879e8d4489fd694ed8cf57be Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:36:07 +0200 Subject: [PATCH 3/8] feat(port): the splice keeps switch_query on the args the charge helper answers `[brain] charge` and the CHARGE_QUERY_* constants; any other query is taken as answer 0. Co-Authored-By: Claude Sonnet 5.5 --- docs/formats/p3rd_ai_script.md | 1 + .../mhfu-port/src/mhfu_port/brain/splice.py | 12 +++ packages/mhfu-port/src/mhfu_port/manifest.py | 5 ++ packages/mhfu-port/tests/test_manifest.py | 7 ++ packages/mhfu-port/tests/test_splice.py | 80 +++++++++++++++++++ 5 files changed, 105 insertions(+) diff --git a/docs/formats/p3rd_ai_script.md b/docs/formats/p3rd_ai_script.md index d81eec0f..f816f27e 100644 --- a/docs/formats/p3rd_ai_script.md +++ b/docs/formats/p3rd_ai_script.md @@ -81,6 +81,7 @@ keeps everything else: its roaming, sections, target pick and interrupts. | `switch_mode` | `if_mode2` for mode 1, the default as its else | | `if_enraged` | `if_enraged` | | `if_facing ang` | `if_pursuit_off_facing` at `ang * 360 / 256` degrees with the arms swapped; past 127, `if_off_facing` | +| `switch_query arg` | `[brain] charge`: args 0 (charged) and 6 (cooldown running) stay a `switch_query`, which the framework answers, cases ascending and repeats dropped; any other arg, or no `charge`, is answer 0: the case 0 arm or else the default, the rest dropped (`P3_FOLDED`, naming them) | | `switch_dist` | `switch_dist`: the cases are hundreds in both; a case at or under an earlier one, which no distance reaches, goes | | `switch_hp` | `switch_hp` with each case one higher: MHP3rd's matches the integer HP percent at or below it, MHFU's below it | | `if_target_side` | `if_target_in_arc 0 127`: the target's bearing minus the facing in the first half turn (bit 15 clear), arms as they are | diff --git a/packages/mhfu-port/src/mhfu_port/brain/splice.py b/packages/mhfu-port/src/mhfu_port/brain/splice.py index 30ca8753..11d11df1 100644 --- a/packages/mhfu-port/src/mhfu_port/brain/splice.py +++ b/packages/mhfu-port/src/mhfu_port/brain/splice.py @@ -63,6 +63,8 @@ HP_SHIFT = 1 """MHP3rd's `switch_hp` case matches an HP percent at or below it, MHFU's below it (both compare the truncated integer percent), so each case moves up by one.""" +QUERIES = (int(addresses.CHARGE_QUERY_CHARGED), int(addresses.CHARGE_QUERY_COOLDOWN)) +"""The `switch_query` args the framework answers for a port with `[brain] charge`.""" GAUGE = "if_gauge_a_low" """The host test the fatigue helper answers.""" GAUGE_A_OPS = (GAUGE, "refill_gauge_a") @@ -526,6 +528,15 @@ def _answer( self.add(w, "info", "P3_FOLDED", f"{_text(n)} taken as answer {value} ({why})") return self.sub(keep.body, w) if keep else [] + def _query(self, n: p3.Block, w: Where) -> list[ir.Node]: + """The host's `switch_query` on an arg the charge helper answers; any other is taken as + answer 0, as is every one without `[brain] charge`.""" + arg = n.args["arg"] + if self.spec.charge and arg in QUERIES: + return self._switch(n, w, arg, bound=False) + why = "no charge helper" if not self.spec.charge else "the helper does not answer it" + return self._answer(n, w, 0, why) + def _dist(self, n: p3.Block, w: Where) -> list[ir.Node]: return self._switch(n, w) @@ -671,6 +682,7 @@ def _host(self, n: p3.Insn | p3.Block, w: Where) -> list[ir.Node]: "if_enraged": Translator._enraged, "if_facing": Translator._facing, "if_target_side": Translator._side, + "switch_query": Translator._query, "switch_dist": Translator._dist, "switch_hp": Translator._hp, "switch_global_540": Translator._global_540, diff --git a/packages/mhfu-port/src/mhfu_port/manifest.py b/packages/mhfu-port/src/mhfu_port/manifest.py index d69c17f6..c4c8f7c0 100644 --- a/packages/mhfu-port/src/mhfu_port/manifest.py +++ b/packages/mhfu-port/src/mhfu_port/manifest.py @@ -414,6 +414,11 @@ class Brain: table from the donor into the moves module (`brain.fatigue`), and the splice turns the donor's `if_tired` (and `if_stamina_low` 0) into the host's `if_gauge_a_low` instead of asking `[brain.when]`. The host must leave gauge A alone (`P3_FATIGUE_HOST`).""" + charge: bool = False + """The donor's charged state runs in the game (the framework's charge helper): the splice keeps + the donor's `switch_query` on the two args the helper answers (`CHARGE_QUERY_CHARGED`, + `CHARGE_QUERY_COOLDOWN`); without it every `switch_query` is taken as answer 0 (never + charged).""" def targets(self, pair: Pair) -> list[Target]: """What the donor's `pair` plays, in order: host pairs and own moves' names (`OWN` diff --git a/packages/mhfu-port/tests/test_manifest.py b/packages/mhfu-port/tests/test_manifest.py index 1bad6630..35d90c67 100644 --- a/packages/mhfu-port/tests/test_manifest.py +++ b/packages/mhfu-port/tests/test_manifest.py @@ -219,6 +219,13 @@ def test_brain(): assert M.loads(M.dumps(m)) == m and "slot" not in M.dumps(m) +def test_brain_charge(): + m = M.loads(_with(BRAIN + "charge = true\n")) + assert m.brain is not None and m.brain.charge and "charge = true" in M.dumps(m) + assert not M.loads(_with(BRAIN)).brain.charge # type: ignore[union-attr] + assert M.loads(M.dumps(m)) == m + + def test_brain_own_moves(): own = "[moves.slam]\nanim = 40\n" + BRAIN text = own + "[brain.pairs]\n'7,13' = 'own'\n'7,14' = 'slam'\n'2,3' = [1, 2]\n" diff --git a/packages/mhfu-port/tests/test_splice.py b/packages/mhfu-port/tests/test_splice.py index b2a69c20..653b47a3 100644 --- a/packages/mhfu-port/tests/test_splice.py +++ b/packages/mhfu-port/tests/test_splice.py @@ -275,6 +275,86 @@ def test_global_540_without_case_2_takes_the_default(): assert got == ["enter 1 2 0"] and found(t) == [("warning", "P3_FOLDED")] +# ---- the species query + +CHARGED = [case(1, enter(2, 3)), other(enter(2, 4))] + + +def test_the_queries_are_the_charge_helpers(): + assert splice.QUERIES == (addresses.CHARGE_QUERY_CHARGED, addresses.CHARGE_QUERY_COOLDOWN) + assert splice.QUERIES == (0, 6) + + +@pytest.mark.parametrize("arg", splice.QUERIES) +def test_an_answered_query_stays_a_switch(arg): + got, t = translate(spec(charge=True), base_0=blk("switch_query", arg, arms=CHARGED)) + want = [f"switch_query {arg}", "case 1", " enter 1 2 0", "default", " enter 1 3 0"] + assert got == want and not t.findings + + +def test_query_cases_ascend_and_repeats_go(): + arms = [case(3, enter(2, 3)), case(1, enter(2, 4)), case(3, enter(7, 5)), other("")] + got, t = translate(spec(charge=True), base_0=blk("switch_query", 0, arms=arms)) + assert got == ["switch_query 0", "case 1", " enter 1 3 0", "case 3", " enter 1 2 0"] + assert found(t) == [("info", "P3_DROPPED")] + + +def test_an_empty_case_still_shields_the_default(): + arms = [case(1, ""), other(enter(2, 3))] + got, _ = translate(spec(charge=True), base_0=blk("switch_query", 0, arms=arms)) + assert got == ["switch_query 0", "case 1", "default", " enter 1 2 0"] + + +def test_a_query_with_nothing_to_do(): + got, t = translate(spec(charge=True), base_0=blk("switch_query", 0, arms=[case(1, "")])) + assert got == [] and found(t) == [("info", "P3_DROPPED")] + + +@pytest.mark.parametrize( + ("arg", "charge"), [(2, True), (3, True), (7, True), (0, False), (6, False)] +) +def test_any_other_query_is_answer_0(arg, charge): + arms = [case(0, enter(2, 3)), case(1, enter(2, 4)), other(enter(7, 5))] + got, t = translate(spec(charge=charge), base_0=blk("switch_query", arg, arms=arms)) + assert got == ["enter 1 2 0"] and found(t) == [("warning", "P3_FOLDED")] + text = t.findings[0].text + assert f"switch_query {arg} taken as answer 0" in text + assert "case 1 (1 instructions, first enter 2 4), default (1 instructions" in text + assert "case 0" not in text + + +def test_answer_0_without_a_case_0_is_the_default(): + got, t = translate( + base_0=blk("switch_query", 2, arms=[case(1, enter(2, 4)), other(enter(2, 3))]) + ) + assert got == ["enter 1 2 0"] and found(t) == [("warning", "P3_FOLDED")] + got, t = translate(base_0=blk("switch_query", 2, arms=[case(1, enter(2, 4))])) + assert got == [] and found(t) == [("warning", "P3_FOLDED")] + + +def test_a_fold_that_drops_no_code_is_quiet(): + arms = [case(0, enter(2, 3)), case(1, ""), other("")] + got, t = translate(base_0=blk("switch_query", 2, arms=arms)) + assert got == ["enter 1 2 0"] and not t.findings + + +def test_a_folded_arm_makes_no_routine(): + arms = [case(0, enter(2, 3)), case(1, op("call", 1))] + _, t = translate(base_0=blk("switch_query", 2, arms=arms), call_1=enter(2, 4)) + assert not t.routines + + +def test_the_splice_builds_a_query(): + sp = spec(charge=True, pairs=TWO) + h = host() + s = splice.splice(donor(base_0=blk("switch_query", 0, arms=CHARGED)), h, sp) + assert not s.findings + built = build.Built.of(h, s.edits) + assert built.ok, built.findings + base_3 = next(r for r in built.program.routines() if r.name == "base_3") + assert lines(base_3.body)[1:3] == ["switch_query 0", "case 1"] + + # ---- tests without a host state From f96cc0285091e1d4a37c00ec93d296bd1cfc2a34 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:37:51 +0200 Subject: [PATCH 4/8] feat(port): a random with too many live arms splits into an outer and inner randoms Co-Authored-By: Claude Sonnet 5.5 --- docs/formats/p3rd_ai_script.md | 6 +- .../mhfu-port/src/mhfu_port/brain/splice.py | 64 ++++++++--- packages/mhfu-port/tests/test_splice.py | 108 +++++++++++++++++- 3 files changed, 161 insertions(+), 17 deletions(-) diff --git a/docs/formats/p3rd_ai_script.md b/docs/formats/p3rd_ai_script.md index f816f27e..8e510b49 100644 --- a/docs/formats/p3rd_ai_script.md +++ b/docs/formats/p3rd_ai_script.md @@ -76,7 +76,7 @@ keeps everything else: its roaming, sections, target pick and interrupts. | Donor | Host | |---|---| | `enter m s` | `enter M S 0`, the pair from `[brain.pairs]`; an own move there: `enter 16 k 0`; a list there: one `enter` per item | -| `random` | `random`: each weight `w` becomes `w * 32 / 100` by largest remainder, at least 1; a zero arm goes | +| `random` | `random`: each weight `w` becomes `w * 32 / 100` by largest remainder, at least 1; a zero arm goes. Too many live arms for MHFU's 13 branches (the padding one counts): an outer `random` over groups of at most 12, weighted by their sums, each group an inner `random` weighted within it (`P3_SPLIT`) | | `switch_range` | `switch_range`: case `k` is bucket `k + 1`, the default the last, a missing case copies the default | | `switch_mode` | `if_mode2` for mode 1, the default as its else | | `if_enraged` | `if_enraged` | @@ -94,10 +94,10 @@ keeps everything else: its roaming, sections, target pick and interrupts. | map and area ops | none: the host's roaming owns them | A random that can follow another draw in the same run gets an `rng_step2` first, as the host's -own picks do. Findings are errors (`P3_PAIR_UNMAPPED`, `P3_OWN_MISSING`, `P3_UNSUPPORTED`, +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`) and notes -(`P3_DROPPED`, `P3_WEIGHT`). +(`P3_DROPPED`, `P3_WEIGHT`, `P3_SPLIT`, an `info` `P3_FOLDED`). ### Own moves diff --git a/packages/mhfu-port/src/mhfu_port/brain/splice.py b/packages/mhfu-port/src/mhfu_port/brain/splice.py index 11d11df1..710f0215 100644 --- a/packages/mhfu-port/src/mhfu_port/brain/splice.py +++ b/packages/mhfu-port/src/mhfu_port/brain/splice.py @@ -49,6 +49,8 @@ """What an MHFU random's weights add up to; MHP3rd's add up to 100.""" ARMS = 13 """Branches of an MHFU random, the padding one included.""" +GROUP = ARMS - 1 +"""Live arms of an inner random of a split one: the padding branch makes it ARMS.""" BUCKETS = 5 """Buckets of an MHFU switch_range.""" MODES: Mapping[int, str] = {1: "if_mode2"} @@ -128,13 +130,14 @@ class Where: body: Body -def shares(weights: Sequence[int]) -> tuple[list[int], list[int]]: - """MHFU weights for MHP3rd's: each `w * 32 / 100` by largest remainder, a non-zero one at least - 1, the total at most 32; and the positions that were raised to 1.""" +def shares(weights: Sequence[int], whole: int = 100) -> tuple[list[int], list[int]]: + """MHFU weights for MHP3rd's, which add up to `whole` or less: each `w * 32 / whole` by + largest remainder, a non-zero one at least 1, the total at most 32; and the positions that + were raised to 1.""" exact = [w * TOTAL for w in weights] - out = [e // 100 for e in exact] - spare = max(0, min(TOTAL, (sum(exact) + 50) // 100) - sum(out)) - for i in sorted(range(len(out)), key=lambda j: (-(exact[j] % 100), j))[:spare]: + out = [e // whole for e in exact] + spare = max(0, min(TOTAL, (sum(exact) + whole // 2) // whole) - sum(out)) + for i in sorted(range(len(out)), key=lambda j: (-(exact[j] % whole), j))[:spare]: out[i] += 1 raised = [i for i, (w, s) in enumerate(zip(weights, out, strict=True)) if w and not s] for i in raised: @@ -427,22 +430,57 @@ def _call_base(self, n: p3.Insn, w: Where) -> list[ir.Node]: return [] return self.pasted(name, w.item) + def _raised(self, w: Where, values: Sequence[int], raised: Sequence[int], what: str) -> None: + for i in raised: + text = f"{what} weight {values[i]} is raised to 1 of {TOTAL}" + self.add(w, "info", "P3_WEIGHT", text) + def _random(self, n: p3.Block, w: Where) -> list[ir.Node]: live = [a for a in n.arms if a.value] - weights, raised = shares([a.value or 0 for a in live]) + values = [a.value or 0 for a in live] + weights, raised = shares(values) if len(live) + (sum(weights) < TOTAL) > ARMS: - self.add(w, "error", "P3_WIDE", f"random has {len(live)} live arms, MHFU takes {ARMS}") - return [] - for i in raised: - self.add( - w, "info", "P3_WEIGHT", f"random weight {live[i].value} is raised to 1 of {TOTAL}" - ) + return self._split(live, w) + self._raised(w, values, raised, "random") bodies = [self.sub(a.body, w) for a in live] if not any(bodies): self.add(w, "info", "P3_DROPPED", "random: every arm is empty") return [] return [edit.random(list(zip(weights, bodies, strict=True)))] + def _split(self, live: Sequence[p3.Arm], w: Where) -> list[ir.Node]: + """A random with more live arms than MHFU's takes: an outer random over groups of at most + `GROUP`, weighted by their shares, each group an inner random weighted within it. Each + level rounds its own weights, so the sums the donor draws survive.""" + count = -(-len(live) // GROUP) + size = -(-len(live) // count) + groups = [live[i : i + size] for i in range(0, len(live), size)] + sums = [sum(a.value or 0 for a in g) for g in groups] + outer, raised = shares(sums) + if len(groups) + (sum(outer) < TOTAL) > ARMS: + text = ( + f"random has {len(live)} live arms, a split over {len(groups)} groups is too wide" + ) + self.add(w, "error", "P3_WIDE", text) + return [] + self._raised(w, sums, raised, "random group") + branches: list[tuple[int, list[ir.Node]]] = [] + for group, whole, weight in zip(groups, sums, outer, strict=True): + values = [a.value or 0 for a in group] + inner, raised = shares(values, whole) + self._raised(w, values, raised, "random") + bodies = [self.sub(a.body, w) for a in group] + pick: list[ir.Node] = [] + if any(bodies): + pick.append(edit.random(list(zip(inner, bodies, strict=True)))) + branches.append((weight, pick)) + if not any(pick for _, pick in branches): + self.add(w, "info", "P3_DROPPED", "random: every arm is empty") + return [] + text = f"random with {len(live)} live arms is split into {len(groups)} inner randoms" + self.add(w, "info", "P3_SPLIT", text) + return [edit.random(branches)] + def _range(self, n: p3.Block, w: Where) -> list[ir.Node]: cases: dict[int, list[ir.Node]] = {} default: list[ir.Node] = [] diff --git a/packages/mhfu-port/tests/test_splice.py b/packages/mhfu-port/tests/test_splice.py index 653b47a3..a6c6f24e 100644 --- a/packages/mhfu-port/tests/test_splice.py +++ b/packages/mhfu-port/tests/test_splice.py @@ -121,6 +121,14 @@ def test_shares(weights, want): assert splice.shares(weights)[0] == want +@pytest.mark.parametrize( + ("weights", "whole", "want"), + [([1, 2, 3, 4], 10, [3, 6, 10, 13]), ([5, 5], 10, [16, 16]), ([1, 29], 30, [1, 31])], +) +def test_shares_within_a_whole(weights, whole, want): + assert splice.shares(weights, whole)[0] == want + + def test_shares_name_what_is_raised(): assert splice.shares([1, 99])[1] == [0] @@ -137,12 +145,101 @@ def test_random_pads_when_weights_fall_short(): assert got == ["random", "branch 16", " enter 1 2 0", "branch 16"] +def picks(nodes: Sequence[ir.Node], p: float = 1.0) -> dict[int, float]: + """The chance of each `enter 1 k` the nodes make, by k, through nested randoms.""" + out: dict[int, float] = {} + for n in nodes: + if isinstance(n, ir.Insn) and n.op.name == "enter": + out[n.args["sub"]] = out.get(n.args["sub"], 0) + p + elif isinstance(n, ir.Block) and n.op.name == "random": + for arm in n.arms: + for k, q in picks(arm.body, p * arm.args["weight"] / splice.TOTAL).items(): + out[k] = out.get(k, 0) + q + return out + + +def wide(weights: Sequence[int], **kw: object) -> tuple[list[ir.Node], splice.Translator]: + arms = [case(w, enter(2, k)) for k, w in enumerate(weights)] + sp = spec(pairs={f"2,{k}": (1, k) for k in range(len(weights))}, **kw) + t = splice.Translator(donor(base_0=blk("random", arms=arms)), sp) + return t.paste("base_0", "base_3"), t + + def test_random_too_wide(): - arms = [case(1, enter(2, 3)) for _ in range(splice.ARMS + 1)] + arms = [case(1, enter(2, 3)) for _ in range(splice.ARMS * splice.GROUP + 1)] got, t = translate(base_0=blk("random", arms=arms)) assert got == [] and found(t) == [("error", "P3_WIDE")] +def test_random_with_thirteen_arms_and_no_padding_fits(): + nodes, t = wide([8, 8, 8, 8, 8, 8, 8, 8, 7, 7, 7, 7, 8]) + assert [n.op.name for n in nodes] == ["random"] and len(nodes[0].arms) == splice.ARMS + assert not t.findings + + +def test_random_with_thirteen_arms_and_padding_is_split(): + nodes, t = wide([6] * 13) + assert found(t) == [("info", "P3_SPLIT")] + (outer,) = nodes + assert [a.body[0].op.name for a in outer.arms[:2]] == ["random", "random"] + assert [sum(a.args["weight"] for a in outer.arms)] == [splice.TOTAL] + assert [len(a.body[0].arms) for a in outer.arms[:2]] == [7, 6] + + +def test_a_wide_random_keeps_what_it_draws(): + weights = [10, 10, 10, 10, 10, 10, 10, 10, 5, 5, 5, 5, 3, 2] + nodes, t = wide(weights) + assert found(t) == [("info", "P3_SPLIT")] and "14 live arms" in t.findings[0].text + got = picks(nodes) + assert sorted(got) == list(range(14)) + assert all(abs(got[k] - w / 100) < 0.02 for k, w in enumerate(weights)) + assert abs(sum(got.values()) - 1) < 0.01 + for n in nodes[0].arms: + assert len(n.body[0].arms) <= splice.ARMS + + +def test_a_wide_random_keeps_the_arms_in_order(): + nodes, _ = wide([4] * 16 + [9] * 4) + order = [ + b.body[0].args["sub"] + for outer in nodes[0].arms + if outer.body + for b in outer.body[0].arms + if b.body + ] + assert order == list(range(20)) + + +def test_a_wide_random_that_falls_short_keeps_its_pad(): + weights = [4] * 14 + nodes, _ = wide(weights) + got = picks(nodes) + assert abs(sum(got.values()) - 0.56) < 0.02 + assert nodes[0].arms[-1].body == [] and nodes[0].arms[-1].args["weight"] >= 13 + + +def test_a_wide_random_names_what_it_raises(): + nodes, t = wide([1] * 3 + [29] * 3 + [1] * 8 + [3]) + assert ("info", "P3_WEIGHT") in found(t) and ("info", "P3_SPLIT") in found(t) + assert picks(nodes) + + +def test_a_wide_random_of_nothing_is_dropped(): + nothing = op("set_target", 0, 0, 0) + got, t = translate(base_0=blk("random", arms=[case(7, nothing) for _ in range(14)])) + assert got == [] and ("info", "P3_SPLIT") not in found(t) + assert found(t).count(("info", "P3_DROPPED")) == 15 + + +def test_a_group_with_nothing_to_do_stays_a_wait(): + nothing = op("set_target", 0, 0, 0) + arms = [case(5, nothing) for _ in range(7)] + [case(5, enter(2, k)) for k in range(7)] + sp = spec(pairs={f"2,{k}": (1, k) for k in range(7)}) + t = splice.Translator(donor(base_0=blk("random", arms=arms)), sp) + (outer,) = t.paste("base_0", "base_3") + assert [bool(a.body) for a in outer.arms[:2]] == [False, True] + + # ---- buckets, modes, facing @@ -895,6 +992,15 @@ def test_a_draw_after_a_draw_is_stepped(): assert [n.op.name for n in s.edits.inserts["u1"].nodes] == ["random", "rng_step2", "random"] +def test_the_inner_draws_of_a_split_random_are_stepped(): + arms = [case(7, enter(2, 3)) for _ in range(14)] + s = splice.splice(donor(base_0=blk("random", arms=arms)), host(), spec(pairs=TWO)) + (outer,) = s.edits.inserts["u1"].nodes + inner = [a.body for a in outer.arms if a.body] + assert inner and all([n.op.name for n in b] == ["rng_step2", "random"] for b in inner) + assert build.Built.of(host(), s.edits).ok + + def test_a_draw_after_a_yield_is_not_stepped(): pick = blk("random", arms=[case(50, enter(2, 3)), case(50, enter(2, 3))]) s = splice.splice(donor(base_0=f"{pick} {pick}"), host(), spec(pairs=TWO)) From d32776a1501554574d354c2c7f95c7d4502b3fcd Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:41:42 +0200 Subject: [PATCH 5/8] feat(port): the history note sinks to a late switch_prev_call Co-Authored-By: Claude Sonnet 5.5 --- docs/formats/p3rd_ai_script.md | 9 +- .../mhfu-port/src/mhfu_port/brain/splice.py | 95 +++++++--- packages/mhfu-port/tests/test_splice.py | 164 +++++++++++++++++- 3 files changed, 238 insertions(+), 30 deletions(-) diff --git a/docs/formats/p3rd_ai_script.md b/docs/formats/p3rd_ai_script.md index 8e510b49..38e9b600 100644 --- a/docs/formats/p3rd_ai_script.md +++ b/docs/formats/p3rd_ai_script.md @@ -90,9 +90,16 @@ keeps everything else: its roaming, sections, target pick and interrupts. | `if_object_near` | false | | `set_target 0 0 0` | dropped: the host picked its target before | | `set_latch`, `switch_latch` | folded: case 0 when something between them yields, else the default | -| `switch_prev_call` | `if_var` on `[brain] history` (unset: the host's first free variable, `mhfu script N --vars`), which each called routine sets first | +| `switch_prev_call` | `if_var` on `[brain] history` (unset: the host's first free variable, `mhfu script N --vars`), which each called routine sets to its own index on every path, after the read where it reads | | map and area ops | none: the host's roaming owns them | +The history is one variable, MHP3rd's two ids: the variable holds the newest call, and a read before +the routine's own note sees the call before it. The note goes first on each path, but the read's +arms follow the read, wherever it is (after code, in a branch, in its own default). That is right +while no call precedes the read in the routine, as at every site of the Brute's and the +Zinogre's scripts: a call before it would be the newest (`P3_HISTORY` warns), and two reads in a +row, the second seeing 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 diff --git a/packages/mhfu-port/src/mhfu_port/brain/splice.py b/packages/mhfu-port/src/mhfu_port/brain/splice.py index 710f0215..0d44e1ea 100644 --- a/packages/mhfu-port/src/mhfu_port/brain/splice.py +++ b/packages/mhfu-port/src/mhfu_port/brain/splice.py @@ -125,8 +125,8 @@ class Where: """The routine it lands in, for findings.""" tail: bool """Nothing runs after the node in its routine.""" - entered: int | None - """The call index, when the node opens the body of a called routine.""" + call: int | None + """The call index of the routine the node is in; None while pasting.""" body: Body @@ -162,10 +162,6 @@ def _branches(n: p3.Block) -> tuple[list[p3.Node], list[p3.Node]]: return n.head, next((a.body for a in n.arms if a.sub == p3ops.ELSE), []) -def _first(nodes: Sequence[p3.Node], name: str) -> bool: - return bool(nodes) and isinstance(nodes[0], p3.Block) and nodes[0].op.name == name - - class Translator: """MHP3rd nodes as MHFU nodes: `paste` the entry, and `routines` holds the new routines.""" @@ -194,6 +190,9 @@ def __init__( self.var: int | None = None """The history variable, once the region is known to read the call before.""" self._stack: list[str] = [] + self._placed: set[int] = set() + """The history reads whose routine has noted its call: a routine that inlines one sees + a call there, no read of its own.""" def add(self, w: Where | str, level: str, code: str, text: str) -> None: item = w if isinstance(w, str) else w.item @@ -289,11 +288,63 @@ def routine(self, name: str, index: int, item: str) -> str | None: return label def callee(self, name: str, index: int, level: int, item: str) -> list[ir.Node]: - """A called routine's body, which first notes the call in the history variable.""" - nodes = self.donor.routines[name].body - out = self.run(nodes, level, item, True, index) - if self.var is not None and not _first(nodes, "switch_prev_call"): - out.insert(0, edit.insn("set_var", self.var, index)) + """A called routine's body, which notes the call in the history variable.""" + out = self.run(self.donor.routines[name].body, level, item, True, index) + return out if self.var is None else self.noted(out, index, item) + + def noted(self, nodes: list[ir.Node], index: int, item: str) -> list[ir.Node]: + """`nodes`, a routine's body, with `set_var H index` first on every path, but after a read + of the history (an `if_var` on H, from `_prev`), whose arms follow it: the variable holds + the call before the newest until then. Right while no call precedes the read in the + routine, which a call would make the newest (`P3_HISTORY` warns); two reads in a row + break it too, the second seeing the first's note (an error). A routine inlined into this + one is a call, whatever it reads.""" + var = self.var + assert var is not None + + def note() -> ir.Node: + return edit.insn("set_var", var, index) + + def is_read(x: ir.Located) -> bool: + return isinstance(x, ir.Block) and x.op.name == "if_var" and x.args.get("var") == var + + def reads(ns: Sequence[ir.Node]) -> bool: + return any(is_read(x) and id(x) not in self._placed for x in ir.walk(ns)) + + def calls(ns: Sequence[ir.Node]) -> bool: + return any( + isinstance(x, ir.Insn) + and (x.op.name == "call" or (x.op.name == "set_var" and x.args.get("var") == var)) + for x in ir.walk(ns) + ) + + def place(ns: list[ir.Node]) -> list[ir.Node]: + at = next((i for i, x in enumerate(ns) if reads([x])), None) + if at is None: + return [note(), *ns] + if calls(ns[:at]): + text = "a call runs before switch_prev_call: it reads that call, MHP3rd this one" + self.add(item, "warning", "P3_HISTORY", text) + if reads(ns[at + 1 :]): + text = "switch_prev_call twice in a row: the second would read the first's note" + self.add(item, "error", "P3_HISTORY", text) + return [*ns[:at], sink(ns[at]), *ns[at + 1 :]] + + def sink(n: ir.Node) -> ir.Node: + """The note on each path through the block that does not read.""" + assert isinstance(n, ir.Block) + if n.op.shape is Shape.IF: + n.head = place(n.head) + if not n.arms: + n.arms.append(ir.Arm(1, {}, [])) + for a in n.arms: + a.body = place(a.body) + if n.op.shape is Shape.SWITCH and not any(a.sub == 2 for a in n.arms): + n.arms.append(ir.Arm(2, {}, [note()])) + return n + + out = place(nodes) + self._placed |= {id(x) for x in ir.walk(out) if is_read(x)} return out # ---- nodes @@ -304,17 +355,17 @@ def run( level: int, item: str, tail: bool, - entered: int | None = None, + call: int | None = None, ) -> list[ir.Node]: b = Body() for i, n in enumerate(nodes): last = tail and all(_is_end(x) for x in nodes[i + 1 :]) - b.out += self.node(n, Where(level, item, last, entered if i == 0 else None, b)) + b.out += self.node(n, Where(level, item, last, call, b)) return b.out def sub(self, nodes: Sequence[p3.Node], w: Where) -> list[ir.Node]: """A block's body, where its own node was.""" - return self.run(nodes, w.level, w.item, w.tail) + return self.run(nodes, w.level, w.item, w.tail, w.call) def node(self, n: p3.Node, w: Where) -> list[ir.Node]: if isinstance(n, p3.Leave): @@ -677,19 +728,12 @@ def _latch(self, n: p3.Block, w: Where) -> list[ir.Node]: return self.sub(arms[0].body if arms else [], w) def _prev(self, n: p3.Block, w: Where) -> list[ir.Node]: - """`if_var` on the history, each arm noting the call after it has read it.""" - if w.entered is None: - self.add( - w, "error", "P3_HISTORY", "switch_prev_call is only read at the start of a call" - ) + """`if_var` on the history, case by case; `noted` puts each arm's note first.""" + if w.call is None: + self.add(w, "error", "P3_HISTORY", "switch_prev_call is only read in a called routine") return [] if self.var is None: return [] - var, index = self.var, w.entered - - def note() -> ir.Node: - return edit.insn("set_var", var, index) - cases: list[tuple[int, list[ir.Node]]] = [] out: list[ir.Node] = [] for a in n.arms: @@ -698,9 +742,8 @@ def note() -> ir.Node: cases.append((a.value, body)) else: out = body - out = [note(), *out] for value, then in reversed(cases): - out = [edit.if_("if_var", var, value, then=[note(), *then], else_=out)] + out = [edit.if_("if_var", self.var, value, then=then, else_=out)] return out def _host(self, n: p3.Insn | p3.Block, w: Where) -> list[ir.Node]: diff --git a/packages/mhfu-port/tests/test_splice.py b/packages/mhfu-port/tests/test_splice.py index a6c6f24e..b1c962aa 100644 --- a/packages/mhfu-port/tests/test_splice.py +++ b/packages/mhfu-port/tests/test_splice.py @@ -754,9 +754,167 @@ def test_history_unused_notes_nothing(): assert lines(t.routines["p3_call_1"]) == ["enter 1 2 0", "end return"] -def test_history_read_late(): - _, t = translate(spec(history=2), base_0=op("call", 1), call_1=f"{enter(2, 3)} {prev('', '')}") - assert ("error", "P3_HISTORY") in found(t) +def routine(**routines: str) -> list[str]: + """The lines of `p3_call_1`, translated with the history in variable 2.""" + _, t = translate(spec(history=2), base_0=op("call", 1), **routines) + return lines(t.routines["p3_call_1"]) + + +def history_found(**routines: str) -> list[tuple[str, str]]: + return found(translate(spec(history=2), base_0=op("call", 1), **routines)[1]) + + +READ = prev(enter(2, 3), enter(2, 4)) +IF_READ = [ + "if_var 2 1", + " set_var 2 1", + " enter 1 2 0", + "else", + " set_var 2 1", + " enter 1 3 0", +] + + +def test_history_read_late_notes_in_the_arms_after_it(): + got = routine(call_1=f"{enter(2, 3)} {prev(enter(2, 3), '')}") + assert got == [ + "enter 1 2 0", + "if_var 2 1", + " set_var 2 1", + " enter 1 2 0", + "else", + " set_var 2 1", + "end return", + ] + assert not history_found(call_1=f"{enter(2, 3)} {prev(enter(2, 3), '')}") + + +def test_history_read_in_a_branch_notes_the_other_path_first(): + code = blk("if_enraged", head=f"{enter(2, 3)} {READ}", arms=[other(enter(2, 4))]) + want = ["if_enraged", " enter 1 2 0", *(f" {x}" for x in IF_READ)] + assert routine(call_1=code) == [*want, "else", " set_var 2 1", " enter 1 3 0", "end return"] + assert not history_found(call_1=code) + + +def test_history_read_in_a_branch_with_no_else_adds_one(): + got = routine(call_1=blk("if_enraged", head=READ)) + assert got == [ + "if_enraged", + *(f" {x}" for x in IF_READ), + "else", + " set_var 2 1", + "end return", + ] + + +def indented(nodes: list[str]) -> list[str]: + return [f" {x}" for x in nodes] + + +def test_history_read_in_a_pick_notes_the_other_arms(): + random = blk("random", arms=[case(50, READ), case(50, enter(2, 3))]) + assert routine(call_1=random) == [ + "random", + "branch 16", + *indented(IF_READ), + "branch 16", + " set_var 2 1", + " enter 1 2 0", + "end return", + ] + ranges = blk("switch_range", arms=[case(0, READ), other(enter(2, 3))]) + assert routine(call_1=ranges) == [ + "switch_range", + "bucket", + *indented(IF_READ), + "bucket", + " set_var 2 1", + " enter 1 2 0", + "end return", + ] + assert not history_found(call_1=random) and not history_found(call_1=ranges) + + +def test_history_read_in_a_switch_without_a_default_adds_one(): + code = blk("switch_dist", arms=[case(5, READ)]) + want = ["switch_dist", "case 5", *indented(IF_READ), "default", " set_var 2 1", "end return"] + assert routine(call_1=code) == want and not history_found(call_1=code) + + +def test_history_read_nested_in_its_own_default(): + again = blk("switch_prev_call", arms=[case(2, enter(2, 4)), other(enter(7, 5))]) + code = blk("switch_prev_call", arms=[case(1, enter(2, 3)), other(again)]) + assert routine(call_1=code) == [ + "if_var 2 1", + " set_var 2 1", + " enter 1 2 0", + "else", + " if_var 2 2", + " set_var 2 1", + " enter 1 3 0", + " else", + " set_var 2 1", + " enter 1 4 0", + "end return", + ] + assert not history_found(call_1=code) + + +def test_history_read_after_code_in_its_own_default(): + again = blk("switch_prev_call", arms=[case(2, enter(2, 4)), other("")]) + code = blk("switch_prev_call", arms=[case(1, enter(2, 3)), other(f"{enter(7, 5)} {again}")]) + assert routine(call_1=code) == [ + "if_var 2 1", + " set_var 2 1", + " enter 1 2 0", + "else", + " enter 1 4 0", + " if_var 2 2", + " set_var 2 1", + " enter 1 3 0", + " else", + " set_var 2 1", + "end return", + ] + assert not history_found(call_1=code) + + +def test_history_read_after_a_call_warns(): + call = f"{op('call', 2)} {READ}" + got = routine(call_1=call, call_2=enter(2, 3)) + assert got[:2] == ["set_var 2 2", "enter 1 2 0"] + assert history_found(call_1=call, call_2=enter(2, 3)) == [("warning", "P3_HISTORY")] + + +def test_history_read_twice_in_a_row_is_an_error(): + assert history_found(call_1=f"{READ} {READ}") == [("error", "P3_HISTORY")] + + +def test_history_of_an_inlined_read_is_the_calls_not_the_callers(): + call = f"{op('call', 2)} {enter(2, 3)}" + assert routine(call_1=call, call_2=READ) == [ + "set_var 2 1", + "if_var 2 1", + " set_var 2 2", + " enter 1 2 0", + "else", + " set_var 2 2", + " enter 1 3 0", + "enter 1 2 0", + "end return", + ] + assert not history_found(call_1=call, call_2=READ) + + +def test_a_late_read_builds(): + h = host() + code = donor(base_0=op("call", 1), call_1=f"{enter(2, 3)} {READ}") + s = splice.splice(code, h, spec(pairs=TWO, history=2)) + assert not s.findings + built = build.Built.of(h, s.edits) + assert built.ok, built.findings + mine = next(r for r in built.program.routines() if r.name == "p3_call_1") + assert lines(mine.body)[:2] == ["enter 1 2 0", "if_var 2 1"] def test_history_read_in_a_pasted_routine(): From e978bf9fd396e0b3f8afa3d58c8febe9cd538a18 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:46:01 +0200 Subject: [PATCH 6/8] test(port): Zinogre splices into em75 from a scratch [brain] Co-Authored-By: Claude Sonnet 5.5 --- packages/mhfu-port/README.md | 4 +- .../mhfu-port/tests/test_zinogre_brain.py | 223 ++++++++++++++++++ 2 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 packages/mhfu-port/tests/test_zinogre_brain.py diff --git a/packages/mhfu-port/README.md b/packages/mhfu-port/README.md index 57d8bff3..abf30496 100644 --- a/packages/mhfu-port/README.md +++ b/packages/mhfu-port/README.md @@ -68,7 +68,9 @@ port.pac, port.summary.text() `splice` writes an edit set for the host's row, which `mhfu brain push|deploy --doc FILE` sends on. A donor pair that `[brain.pairs]` maps to an own move of `[moves]` becomes `enter 16 k 0`, `k` its -slot in `mhfu_port.moves.slots`. +slot in `mhfu_port.moves.slots`; a list value (`[[1, 2], "own"]`) enters each item in turn. +`[brain] charge = true` keeps the donor's `switch_query` on the two args the framework's charge +helper answers (charged, cooldown running); any other query is taken as answer 0. `[brain] fatigue = true` also has the build write the donor's stamina into the moves module for the framework's helper, and the splice read it as `if_gauge_a_low` (`brain.fatigue`). `-o FILE` holds code translated from the game: keep it in the workspace, never in a repo. How a diff --git a/packages/mhfu-port/tests/test_zinogre_brain.py b/packages/mhfu-port/tests/test_zinogre_brain.py new file mode 100644 index 00000000..6136b762 --- /dev/null +++ b/packages/mhfu-port/tests/test_zinogre_brain.py @@ -0,0 +1,223 @@ +# 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.""" + +import copy +from collections import Counter +from collections.abc import Sequence +from pathlib import Path +from types import SimpleNamespace + +import pytest +from mhfu import addresses +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.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] + ) + + +@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) + + +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) + 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 every_slot(b: manifest.Brain) -> dict[str, int]: + return {name: k for k, name in enumerate(owned(b))} + + +def codes(found: Sequence[object]) -> Counter[str]: + return Counter(f"{f.level} {f.code}" for f in found) # type: ignore[attr-defined] + + +def test_the_scratch_brain_is_a_manifest(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 + + +def test_zinogre_splices_into_em75(zinogre): + b = zinogre.m.brain + s, built = splice_of(zinogre, b, every_slot(b)) + 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) + grown = len(built.image.data) - (built.lift.end - built.lift.base) + assert 8000 < grown < 14000 and 50 <= len(s.routines) <= 65 + got = codes(s.findings) + assert got["info P3_SPLIT"] == 10 and got["warning P3_FOLDED"] == 3 + assert got["info P3_HISTORY"] == 1 and got["warning P3_APPROX"] == 1 + assert {k for k in got if k.startswith(("error", "warning"))} == { + "warning P3_FOLDED", + "warning P3_APPROX", + } + folded = " ".join(f.text for f in s.findings if f.code == "P3_FOLDED") + assert "switch_global_540 taken as answer 2" in folded + assert "switch_query 2 taken as answer 0" in folded and "switch_query 0" not in folded + + +def queries(built: build.Built) -> Counter[int]: + return Counter( + n.args["arg"] + for r in built.program.routines() + for n in ir.walk(r.body) + if isinstance(n, ir.Block) and n.op.name == "switch_query" + ) + + +def test_zinogre_keeps_the_charge_queries(zinogre): + b = zinogre.m.brain + _, built = splice_of(zinogre, b, every_slot(b)) + got = queries(built) + assert set(got) == {0, 6} and got[0] > 100 and got[6] > 70 + + +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)) + 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") + assert folded == { + "if_target_set": 1, + "switch_query 0": 4, + "switch_query 2": 1, + "switch_query 6": 2, + } + 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]] = [] + for n in p3.walk(nodes): + if isinstance(n, p3.Insn) and n.op.name == "enter": + out.append((n.args["main"], n.args["sub"])) + elif isinstance(n, p3.Insn) and n.op.name == "call": + name = brain_.entry(p3ops.CALL, n.args["routine"]) + assert name is not None + out += expanded(brain_, brain_.routines[name].body) + 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.""" + 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 + + +def test_zinogre_brain_file_round_trip(zinogre, data, tmp_path): + b = zinogre.m.brain + s, built = splice_of(zinogre, b, every_slot(b)) + 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 From 70a693760f37c19f9021d27114f51e4e7a5bcfb2 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:48:15 +0200 Subject: [PATCH 7/8] fix(port): history reads in copied buckets stay noted Co-Authored-By: Claude Sonnet 5.5 --- docs/formats/p3rd_ai_script.md | 6 ++--- .../mhfu-port/src/mhfu_port/brain/splice.py | 10 +++++++- packages/mhfu-port/tests/test_splice.py | 23 +++++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/docs/formats/p3rd_ai_script.md b/docs/formats/p3rd_ai_script.md index 38e9b600..f8d9f0f0 100644 --- a/docs/formats/p3rd_ai_script.md +++ b/docs/formats/p3rd_ai_script.md @@ -96,9 +96,9 @@ keeps everything else: its roaming, sections, target pick and interrupts. The history is one variable, MHP3rd's two ids: the variable holds the newest call, and a read before the routine's own note sees the call before it. The note goes first on each path, but the read's arms follow the read, wherever it is (after code, in a branch, in its own default). That is right -while no call precedes the read in the routine, as at every site of the Brute's and the -Zinogre's scripts: a call before it would be the newest (`P3_HISTORY` warns), and two reads in a -row, the second seeing the first's note, are an error. +while no call precedes the read in the routine, as at every site of the Zinogre's script: a +call before it would be the newest (`P3_HISTORY` warns), and two reads in a row, the second seeing +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`, diff --git a/packages/mhfu-port/src/mhfu_port/brain/splice.py b/packages/mhfu-port/src/mhfu_port/brain/splice.py index 0d44e1ea..79274cfb 100644 --- a/packages/mhfu-port/src/mhfu_port/brain/splice.py +++ b/packages/mhfu-port/src/mhfu_port/brain/splice.py @@ -532,6 +532,14 @@ def _split(self, live: Sequence[p3.Arm], w: Where) -> list[ir.Node]: self.add(w, "info", "P3_SPLIT", text) return [edit.random(branches)] + def _clone(self, nodes: list[ir.Node]) -> list[ir.Node]: + """Copies of translated `nodes`, the history reads among them still noted.""" + out = edit.clone(nodes) + for a, b in zip(ir.walk(nodes), ir.walk(out), strict=True): + if id(a) in self._placed: + self._placed.add(id(b)) + return out + def _range(self, n: p3.Block, w: Where) -> list[ir.Node]: cases: dict[int, list[ir.Node]] = {} default: list[ir.Node] = [] @@ -559,7 +567,7 @@ def _range(self, n: p3.Block, w: Where) -> list[ir.Node]: "P3_DROPPED", f"switch_range has no case {k}: it takes the default's body", ) - buckets.append(cases[k] if k in cases else edit.clone(default)) + buckets.append(cases[k] if k in cases else self._clone(default)) buckets.append(default) if not any(buckets): self.add(w, "info", "P3_DROPPED", "switch_range: every arm is empty") diff --git a/packages/mhfu-port/tests/test_splice.py b/packages/mhfu-port/tests/test_splice.py index b1c962aa..3b447d4f 100644 --- a/packages/mhfu-port/tests/test_splice.py +++ b/packages/mhfu-port/tests/test_splice.py @@ -906,6 +906,29 @@ def test_history_of_an_inlined_read_is_the_calls_not_the_callers(): assert not history_found(call_1=call, call_2=READ) +def test_history_reads_copied_into_buckets_stay_noted(): + ranges = blk("switch_range", arms=[case(1, enter(2, 3)), other(op("call", 2))]) + inlined = [ + "if_var 2 1", + " set_var 2 2", + " enter 1 2 0", + "else", + " set_var 2 2", + " enter 1 3 0", + ] + assert routine(call_1=ranges, call_2=READ) == [ + "set_var 2 1", + "switch_range", + "bucket", + *indented(inlined), + "bucket", + " enter 1 2 0", + "bucket", + *indented(inlined), + "end return", + ] + + def test_a_late_read_builds(): h = host() code = donor(base_0=op("call", 1), call_1=f"{enter(2, 3)} {READ}") From dd35d6dd2f1b40b06d498741e9c9ac2a53f5180a Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:49:01 +0200 Subject: [PATCH 8/8] fix(port): the splice holds the history reads it has noted Co-Authored-By: Claude Sonnet 5.5 --- docs/formats/p3rd_ai_script.md | 5 +++-- packages/mhfu-port/src/mhfu_port/brain/splice.py | 10 +++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/formats/p3rd_ai_script.md b/docs/formats/p3rd_ai_script.md index f8d9f0f0..5bd2a020 100644 --- a/docs/formats/p3rd_ai_script.md +++ b/docs/formats/p3rd_ai_script.md @@ -103,8 +103,9 @@ 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`) and notes -(`P3_DROPPED`, `P3_WEIGHT`, `P3_SPLIT`, an `info` `P3_FOLDED`). +build, warnings (`P3_FOLDED`, `P3_APPROX`, `P3_PAIR_UNUSED`, `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`). ### Own moves diff --git a/packages/mhfu-port/src/mhfu_port/brain/splice.py b/packages/mhfu-port/src/mhfu_port/brain/splice.py index 79274cfb..2f763acf 100644 --- a/packages/mhfu-port/src/mhfu_port/brain/splice.py +++ b/packages/mhfu-port/src/mhfu_port/brain/splice.py @@ -190,9 +190,9 @@ def __init__( self.var: int | None = None """The history variable, once the region is known to read the call before.""" self._stack: list[str] = [] - self._placed: set[int] = set() - """The history reads whose routine has noted its call: a routine that inlines one sees - a call there, no read of its own.""" + self._placed: dict[int, ir.Located] = {} + """The history reads whose routine has noted its call, by `id` (held, so no other node + takes it): a routine that inlines one sees a call there, no read of its own.""" def add(self, w: Where | str, level: str, code: str, text: str) -> None: item = w if isinstance(w, str) else w.item @@ -344,7 +344,7 @@ def sink(n: ir.Node) -> ir.Node: return n out = place(nodes) - self._placed |= {id(x) for x in ir.walk(out) if is_read(x)} + self._placed |= {id(x): x for x in ir.walk(out) if is_read(x)} return out # ---- nodes @@ -537,7 +537,7 @@ def _clone(self, nodes: list[ir.Node]) -> list[ir.Node]: out = edit.clone(nodes) for a, b in zip(ir.walk(nodes), ir.walk(out), strict=True): if id(a) in self._placed: - self._placed.add(id(b)) + self._placed[id(b)] = b return out def _range(self, n: p3.Block, w: Where) -> list[ir.Node]: