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/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"] 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():