diff --git a/docs/formats/p3rd_ai_script.md b/docs/formats/p3rd_ai_script.md index 347db1ec..5bd2a020 100644 --- a/docs/formats/p3rd_ai_script.md +++ b/docs/formats/p3rd_ai_script.md @@ -75,24 +75,37 @@ 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` | -| `random` | `random`: each weight `w` becomes `w * 32 / 100` by largest remainder, at least 1; a zero arm goes | +| `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. 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` | | `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 | +| `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 | | `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 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. 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`). +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 @@ -102,6 +115,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/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/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..2f763acf 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 @@ -49,12 +49,24 @@ """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"} """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.""" +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") @@ -113,18 +125,19 @@ 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 -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: @@ -149,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.""" @@ -181,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: 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 @@ -276,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): x for x in ir.walk(out) if is_read(x)} return out # ---- nodes @@ -291,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): @@ -366,22 +430,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: @@ -415,22 +481,65 @@ 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 _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[id(b)] = b + return out + def _range(self, n: p3.Block, w: Where) -> list[ir.Node]: cases: dict[int, list[ir.Node]] = {} default: list[ir.Node] = [] @@ -458,13 +567,88 @@ 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") 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 _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) + + 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] = [] @@ -552,19 +736,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: @@ -573,9 +750,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]: @@ -594,6 +770,11 @@ 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_query": Translator._query, + "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/src/mhfu_port/manifest.py b/packages/mhfu-port/src/mhfu_port/manifest.py index ccdbe47c..c4c8f7c0 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.""" @@ -409,12 +414,23 @@ 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`).""" - - 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.""" + 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` + 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 +438,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 +522,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 +617,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 +680,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..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" @@ -230,6 +237,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..3b447d4f 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 @@ -200,6 +297,161 @@ 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")] + + +# ---- 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 @@ -431,6 +683,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 @@ -472,9 +754,190 @@ 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_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}") + 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(): @@ -710,6 +1173,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)) 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 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" }