From 95c2aa40e85b2d8dd0dc91a053483fd6509cfdbe Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:21:16 +0200 Subject: [PATCH 1/2] feat(mhfu): the checker knows a random that reads its parent's draw A random that opens a branch of a random reads the draw its parent read, so it is no CORRELATED_DRAW; its branches the parent's band never reaches are ARM_UNREACHABLE. The CORRELATED_DRAW text says the draw is a counter a step only adds 1 to. `draw` holds the pick's semantics once (take, spans, nested); `catalogue.shares` uses it. Co-Authored-By: Claude Sonnet 5.5 --- .../src/mhfu_studio/monster/workspace.py | 1 + docs/formats/ai_script.md | 3 +- packages/mhfu/src/mhfu/em/script/budget.py | 16 +++-- packages/mhfu/src/mhfu/em/script/catalogue.py | 28 ++------ packages/mhfu/src/mhfu/em/script/check.py | 34 +++++++--- packages/mhfu/src/mhfu/em/script/draw.py | 68 +++++++++++++++++++ packages/mhfu/tests/test_script_budget.py | 39 +++++++++++ packages/mhfu/tests/test_script_draw.py | 56 +++++++++++++++ packages/mhfu/tests/test_script_edit.py | 4 +- 9 files changed, 211 insertions(+), 38 deletions(-) create mode 100644 packages/mhfu/src/mhfu/em/script/draw.py create mode 100644 packages/mhfu/tests/test_script_draw.py diff --git a/apps/studio/src/mhfu_studio/monster/workspace.py b/apps/studio/src/mhfu_studio/monster/workspace.py index 165c42f1..c869ede0 100644 --- a/apps/studio/src/mhfu_studio/monster/workspace.py +++ b/apps/studio/src/mhfu_studio/monster/workspace.py @@ -153,6 +153,7 @@ "WEIGHT_ZERO": "Give the branch a weight, or take it out.", "RANDOM_WEIGHTS": "The weights must add up to 32: Even sets them.", "CORRELATED_DRAW": "Fold the two chances into one pick.", + "ARM_UNREACHABLE": "Give the branch weight inside its outer branch, or fold the two picks.", "PAIR_UNKNOWN": "Pick a pair from the list the bar offers.", "PAIR_RANGE": "main is 0 to 7.", "DOC_ANCHOR": "Its anchor is gone: remove this edit, or Back to the game's.", diff --git a/docs/formats/ai_script.md b/docs/formats/ai_script.md index 758eede3..a7ba9071 100644 --- a/docs/formats/ai_script.md +++ b/docs/formats/ai_script.md @@ -190,7 +190,8 @@ and the resume point behind each `enter`) is an upper bound. `check` turns it in | `END_KIND_CONTEXT` | error | An `end` kind the game acts on in no entry, or one for a list the routine does not run from (an `end return` in a routine only base lists run) | | `OPS_PER_RUN` | warning, error | A path through new code can dispatch more than 900 in a run; more than 999 | | `RESTART_LOOP` | error, warning | `base[0]` can restart with no yield: nothing on the path decides; or only when its conditions hold | -| `CORRELATED_DRAW` | warning | A `random` that follows another draw in the run: a draw steps by 1, so its odds follow the first | +| `CORRELATED_DRAW` | warning | A `random` that follows another draw in the run: the draw is a counter that a step adds 1 to, so its branch follows the earlier draw's even after a step; a `random` that opens a branch of a `random` reads its parent's draw and is not one | +| `ARM_UNREACHABLE` | warning | A branch with a weight of a `random` that opens a branch of a `random`, which no draw of the parent's branch takes (its parent's draw is the same, within the branch's band) | | `DEAD_CODE` | warning | Code after an `end` or `goto` that never runs | | `WEIGHT_ZERO` | warning | A random branch that never runs | | `PAIR_UNKNOWN` | warning | An `enter` of a pair the species' intel has no record of, or measured never entered; unmeasured says nothing | diff --git a/packages/mhfu/src/mhfu/em/script/budget.py b/packages/mhfu/src/mhfu/em/script/budget.py index f5494014..311e4db4 100644 --- a/packages/mhfu/src/mhfu/em/script/budget.py +++ b/packages/mhfu/src/mhfu/em/script/budget.py @@ -44,6 +44,7 @@ from typing import NamedTuple from . import ir +from .draw import nested from .opcodes import END_KINDS, LISTS, SLOTS, Flow, Shape, slot __all__ = ["LIMIT", "WARN", "Budget", "Draw", "Restart", "Run", "contexts", "needs"] @@ -115,6 +116,8 @@ class _Site: node: ir.Insn | None = None arg: int = 0 """A repeat's count; 1 on an opener that draws.""" + nested: bool = False + """A random that is the first node of a branch of a random: it reads that draw.""" class _St(NamedTuple): @@ -161,6 +164,9 @@ def __init__(self, p: ir.Program) -> None: self.p = p self.sites: list[_Site] = [] self.entry: dict[str, int] = {} + self.nested: set[int] = set() + for r in p.routines(): + self.nested |= nested(r.body).keys() for r in p.routines(): fall = self._add(_Site("fall", "", r.va is None)) self.entry[r.name] = self._list(r.body, fall) @@ -197,9 +203,8 @@ def _block(self, b: ir.Block, after: int) -> int: skips = shape is Shape.SWITCH or (shape is Shape.IF and not b.arms) if skips and not any(a.sub == 2 for a in b.arms): to.append(after) - return self._add( - _Site("open", b.id, new, dirty, to=tuple(to), arg=int(shape is Shape.RANDOM)) - ) + draws, inside = int(shape is Shape.RANDOM), id(b) in self.nested + return self._add(_Site("open", b.id, new, dirty, to=tuple(to), arg=draws, nested=inside)) class _Walk: @@ -561,7 +566,8 @@ def restarts(self) -> list[Restart]: @cached_property def draws(self) -> list[Draw]: """The new or edited randoms that can run after another draw in the same run, a restart of - base[0] starting a new one.""" + base[0] starting a new one. A random that is the first node of a branch of a random reads + its parent's draw, not a later one, and is not among them.""" found: dict[str, Draw] = {} sites = self.graph.sites if not any(s.dirty and s.arg for s in sites): @@ -577,7 +583,7 @@ def draws(self) -> list[Draw]: st, drew = key s = sites[st.site] if s.kind == "open" and s.arg: - if drew and s.dirty and s.id not in found: + if drew and s.dirty and not s.nested and s.id not in found: found[s.id] = Draw(s.id, w.h.name) drew = True stp = w.step(st) diff --git a/packages/mhfu/src/mhfu/em/script/catalogue.py b/packages/mhfu/src/mhfu/em/script/catalogue.py index 97e9b6c1..0777a707 100644 --- a/packages/mhfu/src/mhfu/em/script/catalogue.py +++ b/packages/mhfu/src/mhfu/em/script/catalogue.py @@ -27,6 +27,7 @@ from ... import addresses from ...stage import STAGE_NAMES, row_name, stage_id +from . import draw from .ir import Arm, Block, Insn, Node, Routine, Sym, Value, walk from .opcodes import BY_NAME, END_KINDS, OPCODES, SLOTS, Flow, Op, Operand, Shape, slot from .opcodes import operands as layout_operands @@ -78,7 +79,7 @@ "title", ] -DRAW = 32 +DRAW = draw.DRAW """Steps of a pick's draw; a branch's weight is a share of it.""" ANY = 0xFFFF """An area operand that is every area, or the monster's own.""" @@ -1250,32 +1251,15 @@ def gloss(node: Node | Arm, words: Words = NO_WORDS, block: Block | None = None) def shares(weights: Sequence[int]) -> tuple[int, ...]: - """The steps of the draw each branch gets: a 0 is skipped, a 255 takes the rest, the others - take their weight in turn and the last branch gets what is left.""" - out = [0] * len(weights) - for d in range(DRAW): - total, pick = 0, len(weights) - 1 - for k, w in enumerate(weights): - if w == 0: - continue - total += w - if w == 0xFF or d < total: - pick = k - break - out[pick] += 1 - return tuple(out) - - -def _weights(b: Block) -> list[int] | None: - ws = [a.args.get("weight") for a in b.arms] - return [w for w in ws if isinstance(w, int)] if all(isinstance(w, int) for w in ws) else None + """The steps of the draw each branch gets (`draw.take`).""" + return tuple(len(r) for r in draw.spans(weights)) def chance(b: Block) -> Chance | None: """A pick of two arms, one of them empty: the arm with a body and its share.""" if b.op.shape is not Shape.RANDOM or len(b.arms) != 2: return None - ws = _weights(b) + ws = draw.weights(b) full = [i for i, a in enumerate(b.arms) if a.body] if ws is None or len(full) != 1: return None @@ -1357,7 +1341,7 @@ def _lower(text: str) -> str: def ports(b: Block, words: Words = NO_WORDS) -> Ports: """The names of a block's ports: its head, where it has one, and each arm.""" op, e = b.op, entry(b.op) - ws = _weights(b) + ws = draw.weights(b) got = shares(ws) if ws else None c = chance(b) arms = [] diff --git a/packages/mhfu/src/mhfu/em/script/check.py b/packages/mhfu/src/mhfu/em/script/check.py index bb2d8b75..6637d6c0 100644 --- a/packages/mhfu/src/mhfu/em/script/check.py +++ b/packages/mhfu/src/mhfu/em/script/check.py @@ -12,10 +12,10 @@ the way the game does (`walk`) and the way the decoder does. Run checks follow the program's paths the way `budget` does and speak only of a path through new code: OPS_PER_RUN, RESTART_LOOP and CORRELATED_DRAW. Anything that makes a routine run off its end, and a restart that never -yields, is an error; dead code, a random that follows another draw and a branch that never runs -are warnings. Every native routine ends each path with an end or a goto, so one with new code -in it that can run off its end is the edit's doing; a deleted `end` in a routine with nothing new -is left to the decoder (DECODE). +yields, is an error; dead code, a random that follows another draw, a branch that never runs and +a branch no draw reaches (ARM_UNREACHABLE) are warnings. Every native routine ends each path with +an end or a goto, so one with new code in it that can run off its end is the edit's doing; a +deleted `end` in a routine with nothing new is left to the decoder (DECODE). """ from __future__ import annotations @@ -25,7 +25,7 @@ from functools import cached_property from ... import addresses -from . import asm, budget, ir, walk +from . import asm, budget, draw, ir, walk from .disasm import SENTINEL, Reader from .lift import LiftError, routine from .opcodes import END_KINDS, OPCODES, Flow, Shape, slot @@ -87,6 +87,8 @@ def __init__(self, p: ir.Program) -> None: self.p = p self.out: list[Finding] = [] self.touched: set[int] = set() + self.bands: dict[int, range] = {} + """The draws that reach each random that reads its parent's draw (`draw.nested`).""" self.reach = p.reach() self.tables = p.tables() self.headers = {h.name: h for h in p.headers()} @@ -97,6 +99,8 @@ def add(self, level: str, code: str, text: str, node: str | None, item: str | No def run(self) -> list[Finding]: for r in self.p.routines(): _touched(r.body, self.touched) + self.bands |= draw.nested(r.body) + for r in self.p.routines(): self.nodes(r, r.body, []) for t in self.p.items: if isinstance(t, ir.Table) and len(t.slots) > ir.MAX_LIST: @@ -145,8 +149,8 @@ def nest(self, r: ir.Routine, b: ir.Block, stack: list[ir.Block]) -> None: def block(self, r: ir.Routine, b: ir.Block) -> None: op = b.op if op.shape is Shape.RANDOM: - weights = [w for a in b.arms if isinstance(w := a.args.get("weight"), int)] - if len(weights) == len(b.arms) and sum(weights) != _TOTAL: + weights = draw.weights(b) + if weights is not None and sum(weights) != _TOTAL: text = f"{op.name} weights add up to {sum(weights)}, not {_TOTAL}" self.add("error", "RANDOM_WEIGHTS", text, b.id, r.name) for a in b.arms: @@ -154,6 +158,8 @@ def block(self, r: ir.Routine, b: ir.Block) -> None: self.add( "warning", "WEIGHT_ZERO", f"{op.name}: this branch never runs", a.id, r.name ) + if weights is not None and id(b) in self.bands: + self.unreachable(r, b, weights, self.bands[id(b)]) if op.code == walk.SHORT and b.pc is None: text = f"{op.name}: the game steps its opener onto the first case value" self.add("error", "SKIP_SHORT", text, b.id, r.name) @@ -163,6 +169,15 @@ def block(self, r: ir.Routine, b: ir.Block) -> None: if op.ordered: self.ordered(r, b) + def unreachable(self, r: ir.Routine, b: ir.Block, weights: list[int], band: range) -> None: + """ARM_UNREACHABLE: a branch of a random that reads its parent's draw, which only the + draws of `band` reach, and none of them takes.""" + text = f"{b.op.name}: only draws {band.start} to {band.stop - 1} reach it, none this branch" + got = draw.spans(weights, band.start, band.stop) + for a, w, span in zip(b.arms, weights, got, strict=True): + if w and not span: + self.add("warning", "ARM_UNREACHABLE", text, a.id, r.name) + def ordered(self, r: ir.Routine, b: ir.Block) -> None: """Adjacent cases, one of them new, must ascend: the game's scan stops at the first case above the value. A pair of native cases is as the game has it.""" @@ -395,7 +410,10 @@ def _runs(c: _Checks, b: budget.Budget | None) -> None: ) c.add("error" if rs.forced else "warning", "RESTART_LOOP", text, rs.node, None) for d in b.draws: - text = "this random follows another draw in the run, and a draw steps by 1: its odds follow" + text = ( + "this random follows another draw in the run; the draw is a counter that a step adds 1" + " to, so its branch follows the earlier draw's, steps or not" + ) c.add("warning", "CORRELATED_DRAW", text, d.node, index.routine(d.node).name) diff --git a/packages/mhfu/src/mhfu/em/script/draw.py b/packages/mhfu/src/mhfu/em/script/draw.py new file mode 100644 index 00000000..24435548 --- /dev/null +++ b/packages/mhfu/src/mhfu/em/script/draw.py @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 sp00ktober +"""A random's pick: which branch a draw takes, and what a random inside a branch can reach. + +`random` reads the draw (the entity's counter, low five bits) and does not advance it, so a +`random` that is the first node of a branch of a `random` reads the draw its parent read, and only +the values its parent's branch took can reach it. `nested` is the one place that says which +randoms those are and what they can see. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from .ir import Block, Node + +__all__ = ["DRAW", "nested", "spans", "take", "weights"] + +DRAW = 32 +"""Values of a pick's draw; a branch's weight is a share of it.""" + + +def weights(b: Block) -> list[int] | None: + """The weight of each arm, None unless every arm has a number.""" + ws = [a.args.get("weight") for a in b.arms] + return [w for w in ws if isinstance(w, int)] if all(isinstance(w, int) for w in ws) else None + + +def take(ws: Sequence[int], d: int) -> int: + """The branch a draw of `d` takes: a 0 is skipped, a 255 takes the rest, the others take their + weight in turn and the last branch gets what is left.""" + total = 0 + for k, w in enumerate(ws): + if w == 0: + continue + total += w + if w == 0xFF or d < total: + return k + return len(ws) - 1 + + +def spans(ws: Sequence[int], lo: int = 0, hi: int = DRAW) -> list[range]: + """The draws in `lo..hi` that each branch takes; empty for a branch none of them reaches.""" + out = [range(0)] * len(ws) + for d in range(lo, hi): + k = take(ws, d) + out[k] = range(out[k].start if out[k] else d, d + 1) + return out + + +def nested(nodes: Sequence[Node], sees: range | None = None) -> dict[int, range]: + """Each `random` that is the first node of a branch of a `random`, by `id()`, with the draws + that can reach it: its branch's, within its parent's. `sees` is the first node's, when + `nodes` is such a branch. A branch that no draw reaches has none of them in it.""" + out: dict[int, range] = {} + for k, n in enumerate(nodes): + if not isinstance(n, Block): + continue + mine = sees if k == 0 and n.op.name == "random" else None + if mine is not None: + out[id(n)] = mine + ws = weights(n) if n.op.name == "random" else None + within = mine or range(DRAW) + got = spans(ws, within.start, within.stop) if ws else [range(0)] * len(n.arms) + out |= nested(n.head) + for a, span in zip(n.arms, got, strict=True): + out |= nested(a.body, span or None) + return out diff --git a/packages/mhfu/tests/test_script_budget.py b/packages/mhfu/tests/test_script_budget.py index 41477522..b6a8ba62 100644 --- a/packages/mhfu/tests/test_script_budget.py +++ b/packages/mhfu/tests/test_script_budget.py @@ -474,6 +474,45 @@ def test_correlated_draw_in_a_repeat(): assert [f.node for f in findings(p, "CORRELATED_DRAW")] == ["n11"] # the second pass +def pick(weights, inside=None, at=0): + """A pick of `weights`, branch `at` opening with `inside`.""" + return edit.random([(w, [inside] if k == at and inside else []) for k, w in enumerate(weights)]) + + +def nested_pick(outer, inner, at=0): + """A pick of weights `outer` whose branch `at` opens with a pick of weights `inner`.""" + p = world() + edit.put(p, "call_0", [pick(outer, pick(inner), at)]) + return p + + +def test_a_random_that_opens_a_branch_reads_its_parents_draw(): + p = nested_pick([16, 16], [16, 16]) + assert len(findings(p, "CORRELATED_DRAW")) == 1 # the pick, after the hub's; not its inner + q = world() + edit.put(q, "call_0", [pick([16, 16], pick([16, 16]))]) + q.item("call_0").body[0].arms[0].body.insert(0, nop()) + assert len(findings(q, "CORRELATED_DRAW")) == 2 # a nop in, it is a draw of its own + r = world() + edit.put(r, "call_0", [pick([16, 16], pick([16, 16])), pick([16, 16])]) + assert len(findings(r, "CORRELATED_DRAW")) == 2 # a draw after a draw still is + + +@pytest.mark.parametrize( + ("outer", "inner", "at", "unreachable"), + [ + ([16, 16], [16, 16], 0, 1), # the second branch starts past the outer's band + ([5, 27], [3, 29], 0, 0), # laid out over the band 0..5 + ([4, 4, 24], [5, 27], 1, 0), # and over 4..8 + ([4, 4, 24], [1, 31], 1, 1), # not: the first branch is below 4 + ([8, 24], [32, 0], 0, 0), # a branch with no weight is WEIGHT_ZERO's + ], +) +def test_arm_unreachable(outer, inner, at, unreachable): + p = nested_pick(outer, inner, at) + assert [f.level for f in findings(p, "ARM_UNREACHABLE")] == ["warning"] * unreachable + + def test_a_restart_rerolls(): p = world() edit.put(p, "base_0", [edit.random([(16, []), (16, [])])]) diff --git a/packages/mhfu/tests/test_script_draw.py b/packages/mhfu/tests/test_script_draw.py new file mode 100644 index 00000000..0c41d3c0 --- /dev/null +++ b/packages/mhfu/tests/test_script_draw.py @@ -0,0 +1,56 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 sp00ktober +import pytest +from mhfu.em.script import draw, edit + + +def pick(weights, *inside): + """A pick of `weights`, the first branch opening with `inside`.""" + return edit.random([(w, list(inside) * (k == 0)) for k, w in enumerate(weights)]) + + +@pytest.mark.parametrize( + ("weights", "lo", "hi", "want"), + [ + ([5, 27], 0, 32, [range(5), range(5, 32)]), + ([5, 27], 0, 8, [range(5), range(5, 8)]), + ([5, 27], 8, 16, [range(0), range(8, 16)]), + ( + [8, 0, 8], + 0, + 32, + [range(8), range(0), range(8, 32)], + ), # a 0 is skipped, the last takes the rest + ([4, 255, 4], 0, 32, [range(4), range(4, 32), range(0)]), + ], +) +def test_spans(weights, lo, hi, want): + assert draw.spans(weights, lo, hi) == want + assert [draw.take(weights, d) for d in range(lo, hi)] == [ + k for k, r in enumerate(want) for _ in r + ] + + +def test_nested_randoms_see_their_branch(): + leaf = pick([16, 16]) + mid = pick([8, 24], leaf) + top = pick([16, 16], mid) + assert draw.nested([top]) == {id(mid): range(16), id(leaf): range(8)} + + +def test_a_random_not_first_in_its_branch_is_not_nested(): + inner = pick([16, 16]) + top = pick([16, 16], edit.insn("nop"), inner) + assert draw.nested([top]) == {} + assert draw.nested([edit.random([(16, []), (16, [])]), inner]) == {} + + +def test_a_branch_no_draw_reaches_holds_no_nested_random(): + inner = pick([16, 16]) + assert draw.nested([edit.random([(0, [inner]), (32, [])])]) == {} + + +def test_a_shifted_random_is_not_a_parent(): + inner = pick([16, 16]) + shifted = edit.random([(16, [inner]), (16, [])], shift=2) + assert draw.nested([shifted]) == {} diff --git a/packages/mhfu/tests/test_script_edit.py b/packages/mhfu/tests/test_script_edit.py index caee69d0..1c98616c 100644 --- a/packages/mhfu/tests/test_script_edit.py +++ b/packages/mhfu/tests/test_script_edit.py @@ -1168,8 +1168,8 @@ def test_random_nested(): edit.put( ok, "base_0", [edit.random([(16, [edit.random([(8, [])])])]), edit.random([], shift=3)] ) - # a random in a random is skipped whole; each of the three draws follows another - assert codes(ok) == [("warning", "CORRELATED_DRAW")] * 2 + # a random in a random is skipped whole; the one that opens its branch reads its draw + assert codes(ok) == [("warning", "CORRELATED_DRAW")] def test_skip_short(): From 798f59c0ccd71d3fbcf134bc734e502993dbcdea Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:21:18 +0200 Subject: [PATCH 2/2] feat(mhfu-port): a split random keeps its weights A donor random of more than 13 arms is an outer random over groups and an inner per group. MHFU's random reads the entity's draw without advancing it, so the inner read the outer's value (plus 1, after a step) confined to its group's band and 4-6 arms of each of the Zinogre's splits were never taken. The weights are now rounded once over all arms and each inner is laid out over its band of the outer's draw, unstepped: every value takes the arm one random over all of them would. Co-Authored-By: Claude Sonnet 5.5 --- docs/formats/p3rd_ai_script.md | 15 +++- .../mhfu-port/src/mhfu_port/brain/splice.py | 64 +++++++++----- packages/mhfu-port/tests/test_brute_brain.py | 8 ++ packages/mhfu-port/tests/test_splice.py | 84 +++++++++++++------ .../mhfu-port/tests/test_zinogre_brain.py | 34 +++++++- 5 files changed, 156 insertions(+), 49 deletions(-) diff --git a/docs/formats/p3rd_ai_script.md b/docs/formats/p3rd_ai_script.md index 7dc86033..28ba0ae2 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. 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`) | +| `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 and an inner `random` per group, all reading one draw (`P3_SPLIT`, below) | | `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` | @@ -101,7 +101,18 @@ call before it would be the newest (`P3_HISTORY` warns), and two reads in a row, 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`, +own picks do. MHP3rd draws a fresh number per pick; MHFU's `random` reads the entity's counter and +only a step moves it, by 1, so that pick reads the next value, not a fresh one (`CORRELATED_DRAW` +says so; the draws after another draw stay, as in MHFU's own scripts). + +A split `random` is one draw read twice: the inner random is the first node of its outer arm, gets +no step, and reads the outer's draw. The weights are rounded once over all the live arms, arm `k` +owning the slots `c_k` to `c_k + s_k` of the 32. A group's outer weight is its arms' slot sum, so +it owns the band `lo` to `hi`; its inner weights are its arms' `s_k`, the `lo` slots below the band +added to the first arm and the `32 - hi` from its end to the last (they never occur). Every value +then takes the arm one random over all of them would. A group of one arm is its body. + +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` (not for a pair the charge table names or whose move another pair plays), `P3_WHEN`, `P3_WHEN_IGNORED`, a diff --git a/packages/mhfu-port/src/mhfu_port/brain/splice.py b/packages/mhfu-port/src/mhfu_port/brain/splice.py index f5bffa86..57aa441b 100644 --- a/packages/mhfu-port/src/mhfu_port/brain/splice.py +++ b/packages/mhfu-port/src/mhfu_port/brain/splice.py @@ -21,6 +21,11 @@ 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. A list there enters each item in turn. +A donor `random` of more live arms than MHFU's 13 branches is split: an outer `random` over groups +of arms and an inner one per group. MHFU's `random` reads the entity's counter without advancing it +(`rng_step` only adds 1), so the inner reads the outer's draw, and its weights are laid out over the +outer's share of it (`layout`): every arm keeps the share the one rounding gives it. + 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 (`brain.fatigue`); no `[brain.when]` answer is asked for them. The host must leave gauge A alone. @@ -147,6 +152,25 @@ def shares(weights: Sequence[int], whole: int = 100) -> tuple[list[int], list[in return out, raised +def layout(slots: Sequence[Sequence[int]]) -> list[tuple[int, list[int]]]: + """A split random's groups, each an outer branch and an inner random over one draw: `slots` is + the share of each arm of each group, in order, from one `shares` over all the arms. A group + owning the draws `lo..hi` is an outer branch of `hi - lo`, and its inner random reads the same + draw, so its weights are the arms' own, the draws below `lo` added to the first arm and those + from `hi` to `TOTAL` to the last: they never reach it, and every value that does takes the arm + the single-level layout gives. Each pair is the outer weight and the inner weights.""" + out = [] + lo = 0 + for group in slots: + hi = lo + sum(group) + inner = list(group) + inner[0] += lo + inner[-1] += TOTAL - hi + out.append((hi - lo, inner)) + lo = hi + return out + + def _text(n: p3.Node) -> str: if isinstance(n, p3.Leave): return n.op.mnemonic(n.sub) @@ -491,7 +515,7 @@ def _random(self, n: p3.Block, w: Where) -> list[ir.Node]: values = [a.value or 0 for a in live] weights, raised = shares(values) if len(live) + (sum(weights) < TOTAL) > ARMS: - return self._split(live, w) + return self._split(live, weights, raised, w) self._raised(w, values, raised, "random") bodies = [self.sub(a.body, w) for a in live] if not any(bodies): @@ -499,36 +523,35 @@ def _random(self, n: p3.Block, w: Where) -> list[ir.Node]: return [] return [edit.random(list(zip(weights, bodies, strict=True)))] - def _split(self, live: Sequence[p3.Arm], w: Where) -> list[ir.Node]: + def _split( + self, live: Sequence[p3.Arm], weights: Sequence[int], raised: Sequence[int], 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.""" + `GROUP`, each group an inner random, every one reading the same draw. `weights` are the + arms' shares of it (`shares` over all of them); `layout` lays the inner weights out over + the outer's, so no arm falls outside its group's band. A group of one arm is its body.""" 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" - ) + cuts = range(0, len(live), size) + if len(cuts) + (sum(weights) < TOTAL) > ARMS: + text = f"random has {len(live)} live arms, a split over {len(cuts)} groups is too wide" self.add(w, "error", "P3_WIDE", text) return [] - self._raised(w, sums, raised, "random group") + self._raised(w, [a.value or 0 for a in live], raised, "random") + placed = layout([weights[i : i + size] for i in cuts]) 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] + for i, (weight, inner) in zip(cuts, placed, strict=True): + bodies = [self.sub(a.body, w) for a in live[i : i + size]] pick: list[ir.Node] = [] - if any(bodies): + if len(bodies) == 1: + pick = bodies[0] + elif 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" + text = f"random with {len(live)} live arms is split into {len(cuts)} inner randoms" self.add(w, "info", "P3_SPLIT", text) return [edit.random(branches)] @@ -819,7 +842,8 @@ def _home(nodes: list[ir.Node], target: ir.Located) -> list[ir.Node] | None: def _steps(host: ir.Program, es: EditSet) -> None: """A random that can follow another draw in a run is preceded by `rng_step2`, as the host's - own picks are: the draws are found on the program the edits make.""" + own picks are: the draws are found on the program the edits make. One that is the first node + of an arm of a random is not: it reads its parent's draw, which `layout` counts on.""" applied = editset.apply(host, es) if not applied.ok: return diff --git a/packages/mhfu-port/tests/test_brute_brain.py b/packages/mhfu-port/tests/test_brute_brain.py index ec273495..f15ab17d 100644 --- a/packages/mhfu-port/tests/test_brute_brain.py +++ b/packages/mhfu-port/tests/test_brute_brain.py @@ -1,5 +1,6 @@ # SPDX-License-Identifier: GPL-3.0-or-later # SPDX-FileCopyrightText: 2026 sp00ktober +from collections import Counter from pathlib import Path from mhfu import addresses @@ -57,6 +58,13 @@ def test_brain_splices_into_its_host(data): assert names <= set(s.slots) and {n for _, n in s.uses if isinstance(n, str)} == names +def test_brain_has_no_split_and_one_draw_after_a_draw(data): + _, _, host, s = spliced(data) + found = Counter(f.code for f in [*s.findings, *build.Built.of(host, s.edits).findings]) + assert not found["P3_SPLIT"] and not found["ARM_UNREACHABLE"] + assert found["CORRELATED_DRAW"] == 1 + + def test_brain_enters_own_moves_and_reads_tired_from_gauge_a(data): _, _, host, s = spliced(data) p = build.Built.of(host, s.edits).program diff --git a/packages/mhfu-port/tests/test_splice.py b/packages/mhfu-port/tests/test_splice.py index 76550140..90ee5aa6 100644 --- a/packages/mhfu-port/tests/test_splice.py +++ b/packages/mhfu-port/tests/test_splice.py @@ -145,17 +145,26 @@ 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] = {} +def taken(nodes: Sequence[ir.Node], v: int) -> int | None: + """The k of the `enter 1 k` that the draw `v` reaches, through randoms that all read it.""" 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": + return int(n.args["sub"]) + if isinstance(n, ir.Block) and n.op.name == "random": + total = 0 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 + total += arm.args["weight"] + if v < total: + return taken(arm.body, v) + return None + + +def one_layout(weights: Sequence[int]) -> list[int | None]: + """The arm each draw takes in a single random over the arms' shares.""" + out: list[int | None] = [] + for k, share in enumerate(splice.shares(weights)[0]): + out += [k] * share + return out + [None] * (splice.TOTAL - len(out)) def wide(weights: Sequence[int], **kw: object) -> tuple[list[ir.Node], splice.Translator]: @@ -186,16 +195,29 @@ def test_random_with_thirteen_arms_and_padding_is_split(): 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] +@pytest.mark.parametrize( + "weights", + [ + [10, 10, 10, 10, 10, 10, 10, 10, 5, 5, 5, 5, 3, 2], + [7, 3, 12, 5, 9, 4, 6, 10, 2, 8, 1, 5, 6, 3, 4, 5], # adds up to 90 + [13, 2, 9, 6, 4, 8, 5, 11, 3, 7, 1, 6, 5, 4, 10, 6], + [1] * 3 + [29] * 3 + [1] * 8 + [3], + ], +) +def test_a_wide_random_takes_the_arm_of_one_layout(weights): 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 + assert ("info", "P3_SPLIT") in found(t) + got = [taken(nodes, v) for v in range(splice.TOTAL)] + assert got == one_layout(weights) + assert {k for k in got if k is not None} == set(range(len(weights))) + for outer in nodes[0].arms: + for inner in outer.body: + assert sum(a.args["weight"] for a in inner.arms) == splice.TOTAL + assert len(inner.arms) <= splice.ARMS + + +def test_the_layout_gives_each_group_its_band(): + assert splice.layout([[3, 2], [4], [1, 5]]) == [(5, [3, 29]), (4, [32]), (6, [10, 22])] def test_a_wide_random_keeps_the_arms_in_order(): @@ -211,17 +233,16 @@ def test_a_wide_random_keeps_the_arms_in_order(): 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 + nodes, _ = wide([4] * 14) + reached = [taken(nodes, v) for v in range(splice.TOTAL)] + assert reached.count(None) == nodes[0].arms[-1].args["weight"] == 14 + assert nodes[0].arms[-1].body == [] 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) + assert all("random weight 1 is raised" in f.text for f in t.findings if f.code == "P3_WEIGHT") def test_a_wide_random_of_nothing_is_dropped(): @@ -1189,13 +1210,24 @@ 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(): +def test_the_inner_draws_of_a_split_random_are_not_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 + assert inner and all([n.op.name for n in b] == ["random"] for b in inner) + built = build.Built.of(host(), s.edits) + assert built.ok and not [f for f in built.findings if f.code.endswith("_DRAW")] + + +def test_a_random_first_in_an_arm_is_not_stepped_but_a_draw_after_a_draw_is(): + inner = blk("random", arms=[case(50, enter(2, 4)), case(50, enter(2, 3))]) + first = blk("random", arms=[case(50, inner), case(50, "")]) + later = blk("random", arms=[case(50, enter(2, 4)), case(50, enter(2, 3))]) + s = splice.splice(donor(base_0=f"{first} {later}"), host(), spec(pairs=TWO)) + first_, step, later_ = s.edits.inserts["u1"].nodes + assert step.op.name == "rng_step2" and later_.op.name == "random" + assert [n.op.name for n in first_.arms[0].body] == ["random"] def test_a_draw_after_a_yield_is_not_stepped(): diff --git a/packages/mhfu-port/tests/test_zinogre_brain.py b/packages/mhfu-port/tests/test_zinogre_brain.py index 06ed2655..8ed9331c 100644 --- a/packages/mhfu-port/tests/test_zinogre_brain.py +++ b/packages/mhfu-port/tests/test_zinogre_brain.py @@ -11,7 +11,7 @@ import pytest from mhfu import addresses -from mhfu.em.script import brainfile, build, ir +from mhfu.em.script import brainfile, build, draw, edit, ir from mhfu.em.script import vars as script_vars from mhfu.em.script.budget import LIMIT from mhfu_port import behaviour, manifest, moves @@ -154,6 +154,38 @@ def test_zinogre_splices_into_em75(zinogre): assert "switch_query 2 taken as answer 0" in folded and "switch_query 0" not in folded +def inner_of(arm: ir.Arm) -> ir.Block | None: + """The random an arm opens with.""" + first = arm.body[0] if arm.body else None + return first if isinstance(first, ir.Block) and first.op.name == "random" else None + + +def reached(outer: ir.Block) -> int: + """How many leaf arms of a split random some draw of the 32 takes.""" + hit = set() + for v in range(draw.DRAW): + k = draw.take(draw.weights(outer) or [], v) + inner = inner_of(outer.arms[k]) + hit.add((k, None if inner is None else draw.take(draw.weights(inner) or [], v))) + return len(hit) + + +def test_zinogre_splits_reach_every_donor_arm(zinogre): + s, built = splice_of(zinogre, zinogre.m.brain) + nodes = [n for i in s.edits.inserts.values() for n in edit.everything(i.nodes)] + nodes += [n for r in s.edits.routines.values() for n in edit.everything(r)] + outers = [ + n + for n in nodes + if isinstance(n, ir.Block) and n.op.name == "random" and any(map(inner_of, n.arms)) + ] + live = sorted(int(f.text.split()[2]) for f in s.findings if f.code == "P3_SPLIT") + assert len(live) == len(outers) == 10 + assert sorted(reached(o) for o in outers) == live + got = codes(built.findings) + assert built.ok and got["warning CORRELATED_DRAW"] == 15 and not got["warning ARM_UNREACHABLE"] + + def queries(built: build.Built) -> Counter[int]: return Counter( n.args["arg"]