Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/studio/src/mhfu_studio/monster/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
3 changes: 2 additions & 1 deletion docs/formats/ai_script.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
15 changes: 13 additions & 2 deletions docs/formats/p3rd_ai_script.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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
Expand Down
64 changes: 44 additions & 20 deletions packages/mhfu-port/src/mhfu_port/brain/splice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -491,44 +515,43 @@ 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):
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]:
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)]

Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions packages/mhfu-port/tests/test_brute_brain.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
84 changes: 58 additions & 26 deletions packages/mhfu-port/tests/test_splice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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():
Expand All @@ -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():
Expand Down Expand Up @@ -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():
Expand Down
34 changes: 33 additions & 1 deletion packages/mhfu-port/tests/test_zinogre_brain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Expand Down
Loading
Loading