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
42 changes: 42 additions & 0 deletions docs/formats/p3rd_ai_script.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,48 @@ hits cost nothing there.

`mhfu-port splice` prints the table's size and findings with the splice's.

### Charge

MHP3rd's Zinogre fills a gauge by charge-ups, transforms at 100 and stays charged until hits wear
its durability out; its script asks `switch_query` for "charged" and "the cooldown runs". `[brain]
charge = true` has the framework's helper (`mhfu.em_charge`, armed by `mhfu_port.lua` from the
moves module's `charge` table) keep that state and answer the two queries; the splice keeps those
two `switch_query` args (`P3_FOLDED` for any other). The build reads the donor's overlay
(`mhfu_port.p3charge`) and `mhfu_port.brain.charge` maps it onto the port's moves:

- **Charge-ups**: the pairs whose handler calls the gauge's one writer. Each is run to its end
from an empty gauge at an HP ratio in each of the helper's bands (`Walk.settle`: the handler's
counter, the gauge and the float tests of the HP ratio against the species' constants answered
from the code), and the gauge left is the gain, by HP at or above 95 %, 75 %, 40 % and below. For
the Zinogre, (1,15) gives 25, 35, 40, 55 and (1,16) 40, 55, 65, 100: the repeats of one handler
add up, and a charge-up near full tops itself up to 100. The gains are for the normal spawn (the
variant byte clear). The helper adds a gain when the own move starts, the donor over the
handler's keyframes.
- **The transform**: the pair whose handler sets the charged state, (1,4); the lowest of the
pairs sharing that handler.
- **Chips**: a hit takes the row's step off the gauge while the monster is not charged and off the
durability while it is charged, by rank; the lowest rank's row (5, with a cap of 10 per burst of
hits, both tables). The helper chips each hit and has no cap; the donor chips in its
flinch handler.
- **Cooldowns** are the constants the handlers store: 420 frames after a charge-up, 900 after a
discharge.
- **Discharge**: em75's knockout reaction pairs (the class `brain.fatigue` sorts, where a trap's
REACTION_CHECK code enters the pair), as MHP3rd's knockout and trap handler zeroes the charge.

A donor pair becomes the own move `[brain.pairs]` plays for it (the last item of a list). A
charge-up pair the map lacks is a warning, the transform's an error; a pair mapped for the table
that the script never enters is not reported unused. Two charge-ups on one move take the larger
gain in each band. The moves module's `charge = { ups = { name = { g95, g75, g40, glow } },
transform = name, chip_gauge, chip_durability, cooldown_up, cooldown_out, discharge = {{m, s}} }`
is written with the module (`mhfu-port build`, `inject`, the studio's deploy).

| Finding | Level | What |
|---|---|---|
| `P3_CHARGE_DONOR`, `P3_CHARGE_PAIRS`, `P3_CHARGE_UPS` | error | the species has no charge state or one the build cannot read; more discharge pairs or charge-up moves than the helper holds |
| `P3_CHARGE_UNMAPPED`, `P3_CHARGE_MOVE` | error for the transform, warning for a charge-up | no own move for the donor pair: no `[brain.pairs]` entry, a host pair, or a name `[moves]` lacks |
| `P3_CHARGE_REACTIONS` | warning | no reaction table for the host: nothing discharges |
| `P3_CHARGE_SHARED` | info | the larger gain of two charge-ups on one move |

## Checking

`mhfu-port brain --check` lifts every id and slot and compares each routine with its bytes. It
Expand Down
9 changes: 6 additions & 3 deletions packages/mhfu-port/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,16 @@ port.pac, port.summary.text()
A donor pair that `[brain.pairs]` maps to an own move of `[moves]` becomes `enter 16 k 0`, `k` its
slot in `mhfu_port.moves.slots`; a list value (`[[1, 2], "own"]`) enters each item in turn.
`[brain] charge = true` keeps the donor's `switch_query` on the two args the framework's charge
helper answers (charged, cooldown running); any other query is taken as answer 0.
helper answers (charged, cooldown running; any other query is taken as answer 0) and has the
build write the donor's charge-ups, transform, chips and cooldowns into the moves module for it
(`brain.charge`, `p3charge`).
`[brain] fatigue = true` also has the build write the donor's stamina into the moves module for the
framework's helper, and the splice read it as `if_gauge_a_low` (`brain.fatigue`).
`-o FILE` holds code translated from the game: keep it in the workspace, never in a repo. How a
donor routine maps onto the host's brain is in the
[format notes](../../docs/formats/p3rd_ai_script.md#onto-an-mhfu-brain) and
[fatigue](../../docs/formats/p3rd_ai_script.md#fatigue).
[format notes](../../docs/formats/p3rd_ai_script.md#onto-an-mhfu-brain),
[fatigue](../../docs/formats/p3rd_ai_script.md#fatigue) and
[charge](../../docs/formats/p3rd_ai_script.md#charge).

## Status

Expand Down
187 changes: 187 additions & 0 deletions packages/mhfu-port/src/mhfu_port/brain/charge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: 2026 sp00ktober
"""The donor's charge state as the framework's helper takes it: `mhfu.em_charge`'s table, read from
MHP3rd's files (`p3charge`) and mapped onto the port's own moves.

ch, findings = table(manifest, data) # `[brain] charge = true`
ch.module() # the moves module's `charge` table

The helper keeps a gauge, a charged state and a cooldown for the monster and answers the script's
`switch_query` (charged, cooldown running). Each donor charge-up pair becomes the own move
`[brain.pairs]` plays for it, with the gauge a whole charge-up gives by HP band; the transform pair
becomes the own move the helper plays when the gauge is full. Hits wear the gauge or, while charged,
the durability, by the donor's per-hit step. A charged monster discharges at once in em75's
knockout reactions, as MHP3rd's does in a knockout or a trap (`brain.fatigue`'s classes, the one
classifier).
"""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any

from mhfu import addresses
from mhfu.em.script.check import Finding

from .. import p3charge
from ..data import Data
from ..manifest import Brain, Manifest, ManifestError
from . import fatigue
from .own import Report

Pair = tuple[int, int]
Gains = p3charge.Gains

UPS = addresses.CHARGE_CFG.UPS.count or 0
"""Charge-up moves the helper holds."""
PAIRS = addresses.CHARGE_CFG.PAIRS.count or 0
"""Discharging pairs it holds."""
KNOCKOUT = fatigue.CLASSES[2]
"""The reaction class that discharges."""


@dataclass(frozen=True)
class Charge:
"""The helper's table for a port."""

ups: Mapping[str, Gains]
"""Own move name -> the gauge it gives by HP band."""
transform: str
chip_gauge: int
chip_durability: int
cooldown_up: int
cooldown_out: int
discharge: tuple[Pair, ...]
"""Host pairs that discharge a charged monster."""
named: frozenset[Pair] = field(default_factory=frozenset, compare=False)
"""The donor pairs it reads, whether or not the script enters them."""

def module(self) -> dict[str, Any]:
"""The moves module's `charge` table: `mhfu.em_charge`'s, `ups` and `transform` by name."""
return {
"ups": {name: list(g) for name, g in sorted(self.ups.items())},
"transform": self.transform,
"chip_gauge": self.chip_gauge,
"chip_durability": self.chip_durability,
"cooldown_up": self.cooldown_up,
"cooldown_out": self.cooldown_out,
"discharge": [list(p) for p in self.discharge],
}


def _line(pair: Pair) -> str:
return f"{pair[0]},{pair[1]}"


def derive(
donor: p3charge.Donor,
reactions: Mapping[Pair, str],
b: Brain,
moves: Mapping[str, bool] | None = None,
) -> tuple[Charge | None, list[Finding]]:
"""The helper's table from the donor's charge state: each charge-up pair's gain on the own move
`[brain.pairs]` plays for it, the transform on its own move, the host's knockout reactions as
the discharge pairs. Two pairs on one move: the larger gain in each band. `moves` (name -> is
an own move, the expanded manifest's `[moves]`) makes a name the manifest lacks an error; None
does not look."""
found: list[Finding] = []

def say(level: str, code: str, text: str) -> None:
found.append(Finding(level, code, text, None, "charge"))

def own(pair: Pair, what: str) -> str | None:
"""The own move the donor `pair` plays, or None, said: the transform's is an error."""
level = "error" if what == "transform" else "warning"
target = b.target(pair)
if isinstance(target, str):
if moves is None or moves.get(target, False):
return target
say(level, "P3_CHARGE_MOVE", f"{what} {_line(pair)} plays {target}, no own move")
return None
if target is None:
text = f"{what} {_line(pair)} has no entry in `[brain.pairs]`"
else:
text = f"{what} {_line(pair)} plays host pair {_line(target)}, not an own move"
say(level, "P3_CHARGE_UNMAPPED", text)
return None

ups: dict[str, Gains] = {}
sources: dict[str, list[Pair]] = {}
for pair, gains in sorted(donor.ups.items()):
if (name := own(pair, "charge-up")) is None:
continue
sources.setdefault(name, []).append(pair)
old = ups.get(name)
if old is not None and old != gains:
gains = (
max(old[0], gains[0]),
max(old[1], gains[1]),
max(old[2], gains[2]),
max(old[3], gains[3]),
)
ups[name] = gains
for name, pairs in sources.items():
if len({donor.ups[p] for p in pairs}) > 1:
names = ", ".join(f"{_line(p)} {donor.ups[p]}" for p in pairs)
say("info", "P3_CHARGE_SHARED", f"{name} takes {names}: the larger, {ups[name]}")
transform = own(donor.transform, "transform")
if len(ups) > UPS:
say("error", "P3_CHARGE_UPS", f"{len(ups)} charge-up moves, the helper holds {UPS}")
knockouts = tuple(sorted(p for p, k in reactions.items() if k == KNOCKOUT))
if len(knockouts) > PAIRS:
say(
"error",
"P3_CHARGE_PAIRS",
f"{len(knockouts)} discharge pairs, the helper holds {PAIRS}",
)
if any(f.level == "error" for f in found) or transform is None:
return None, found
named = frozenset([donor.transform, *donor.ups])
return (
Charge(
ups=ups,
transform=transform,
chip_gauge=donor.gauge.step,
chip_durability=donor.durability.step,
cooldown_up=donor.cooldown_up,
cooldown_out=donor.cooldown_out,
discharge=knockouts,
named=named,
),
found,
)


def table(m: Manifest, data: Data) -> tuple[Charge | None, list[Finding]]:
"""The table of a port whose `[brain] charge` is set, and the findings; None without. `m` is
the manifest with its derived own moves (`own.expand`)."""
b = m.brain
if b is None or not b.charge:
return None, []
try:
donor = p3charge.read(data.p3rd, data.p3rd.read(m.source.anim), b.donor, b.slot)
except ValueError as e:
return None, [Finding("error", "P3_CHARGE_DONOR", str(e), None, "charge")]
species = m.port.host_species
hosts = fatigue.host_reactions(data.fu, species)
found: list[Finding] = []
if not hosts:
text = (
f"no reaction table is known for host species {species}: a knockout does not discharge"
)
found.append(Finding("warning", "P3_CHARGE_REACTIONS", text, None, "charge"))
ch, more = derive(donor, hosts, b, {n: mv.own for n, mv in m.moves.items()})
return ch, [*found, *more]


def expanded(m: Manifest, data: Data, report: Report | None = None) -> Charge | None:
"""`table`, each finding handed to `report`; `ManifestError` for the first error."""
ch, found = table(m, data)
for f in found:
if report is not None:
report(f)
errors = [f for f in found if f.level == "error"]
if errors:
raise ManifestError(f"{errors[0].code}: {errors[0].text}")
return ch
12 changes: 7 additions & 5 deletions packages/mhfu-port/src/mhfu_port/brain/splice.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from __future__ import annotations

from collections import Counter
from collections.abc import Callable, Mapping, Sequence
from collections.abc import Callable, Collection, Mapping, Sequence
from dataclasses import dataclass, field
from enum import Enum

Expand Down Expand Up @@ -843,6 +843,7 @@ def splice(
survey: Mapping[int, script_vars.Var] | None = None,
slots: Mapping[str, int] | None = None,
overlay: Overlay | None = None,
named: Collection[Pair] = (),
) -> Spliced:
"""The host's `spec.splice` routine with the donor's `spec.entry` in its place, as edits to
`host`; `findings` say what could not carry over, an error that it cannot be built. `survey`
Expand Down Expand Up @@ -877,17 +878,18 @@ def splice(
es.notes[key] = _origin(donor, spec.entry)
_steps(host, es)
own = {n: t.slots[n] for _, n in t.uses if isinstance(n, str)}
found = [*t.findings, *_unused(spec, t), *_gauge_a(host, spec, overlay)]
found = [*t.findings, *_unused(spec, t, named), *_gauge_a(host, spec, overlay)]
return Spliced(es, found, dict(t.sources), dict(t.uses), own)


def _failed(es: EditSet, spec: manifest.Brain, text: str) -> Spliced:
return Spliced(es, [Finding("error", "P3_ROUTINE", text, None, spec.splice)], {})


def _unused(spec: manifest.Brain, t: Translator) -> list[Finding]:
"""Manifest entries that match nothing in the donor's code."""
met = {f"{m},{s}" for m, s in t.entered}
def _unused(spec: manifest.Brain, t: Translator, named: Collection[Pair] = ()) -> list[Finding]:
"""Manifest entries that match nothing in the donor's code and that the charge table does not
name."""
met = {f"{m},{s}" for m, s in [*t.entered, *named]}
out = [
Finding("warning", "P3_PAIR_UNUSED", f"{k} is mapped but the donor code never enters it")
for k in spec.pairs
Expand Down
15 changes: 11 additions & 4 deletions packages/mhfu-port/src/mhfu_port/cli/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from mhfu import inject

from .. import data, layout, manifest, moves
from ..brain import fatigue, own
from ..brain import charge, fatigue, own
from ..build import Built, build
from .own import say

Expand Down Expand Up @@ -75,17 +75,24 @@ def modules(
) -> list[Path]:
"""Write the port's clips, turns and moves modules into `where`, each whole and only when it
changed, since the game re-runs a module that changed on disk; those written. The moves
module carries the own moves `[brain.pairs]` derive (`own.expand`) and, with `[brain] fatigue`,
the stamina table (`fatigue.table`), each finding of which goes to `report`. Raises
module carries the own moves `[brain.pairs]` derive (`own.expand`) and, with `[brain] fatigue`
and `charge`, the stamina and charge tables (`fatigue.table`, `charge.table`), each finding of
which goes to `report`. Raises
`ManifestError` for a move the moves module cannot carry, before any is written."""
m = own.expanded(m, games, report)
stamina = fatigue.expanded(m, games, report)
charging = charge.expanded(m, games, report)
known = moves.records(games.fu, m.port.host_species)
texts = {
layout.module_name(m): layout.lua(m, built.layout),
layout.turns_module_name(m): layout.turns_lua(m, built.layout),
moves.module_name(m): moves.lua(
m, built.layout, known, built.tip, None if stamina is None else stamina.module()
m,
built.layout,
known,
built.tip,
None if stamina is None else stamina.module(),
None if charging is None else charging.module(),
),
}
where.mkdir(parents=True, exist_ok=True)
Expand Down
22 changes: 16 additions & 6 deletions packages/mhfu-port/src/mhfu_port/cli/splice.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from mhfu.em.script.check import Finding

from .. import data, manifest, moves
from ..brain import fatigue, own, p3, splice
from ..brain import charge, fatigue, own, p3, splice
from . import finding_line

if TYPE_CHECKING:
Expand Down Expand Up @@ -57,12 +57,14 @@ def run(args: argparse.Namespace) -> int:
overlay = games.fu.em(row)
survey = script_vars.survey(host, overlay)
donor = p3.lift(games.p3rd, b.donor, b.slot)
spliced = splice.splice(donor, host, b, survey, moves.slots(m), overlay)
built = Built.of(host, spliced.edits)
stamina, helper = fatigue.table(m, games)
findings = [*derived, *helper, *spliced.findings, *built.findings]
charging, powered = charge.table(m, games)
named = charging.named if charging is not None else ()
spliced = splice.splice(donor, host, b, survey, moves.slots(m), overlay, named)
built = Built.of(host, spliced.edits)
findings = [*derived, *helper, *powered, *spliced.findings, *built.findings]
print(f"splice {m.port.name}: MHP3rd {b.donor} {b.entry} into em{row} {b.splice}")
print(summary(spliced, built, findings, stamina))
print(summary(spliced, built, findings, stamina, charging))
for f in findings:
print(finding_line(f))
ok = not any(f.level == "error" for f in findings)
Expand All @@ -83,9 +85,10 @@ def summary(
built: Built,
findings: list[Finding],
stamina: fatigue.Fatigue | None = None,
charging: charge.Charge | None = None,
) -> str:
"""What the splice came to: routines and bytes added, the longest run, the pairs used, the
fatigue table's size."""
fatigue and charge tables' size."""
added = f"{len(spliced.routines)} routines added"
if built.image and (worst := built.budget.worst):
grown = len(built.image.data) - (built.lift.end - built.lift.base)
Expand All @@ -104,6 +107,13 @@ def summary(
f"stamina {stamina.starts[0]} or {stamina.starts[1]} of {stamina.max}, "
f"tired {stamina.tired} frames"
)
if charging is not None:
lines.append(
f" charge: {len(charging.ups)} charge-up moves, transform {charging.transform}; "
f"chips {charging.chip_gauge} gauge, {charging.chip_durability} durability; "
f"cooldowns {charging.cooldown_up} and {charging.cooldown_out}; "
f"{len(charging.discharge)} discharge pairs"
)
codes = Counter(f.code for f in findings)
lines.append(
" findings: " + (", ".join(f"{c} {n}" for c, n in sorted(codes.items())) or "none")
Expand Down
4 changes: 2 additions & 2 deletions packages/mhfu-port/src/mhfu_port/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,8 +423,8 @@ class Brain:
charge: bool = False
"""The donor's charged state runs in the game (the framework's charge helper): the splice keeps
the donor's `switch_query` on the two args the helper answers (`CHARGE_QUERY_CHARGED`,
`CHARGE_QUERY_COOLDOWN`); without it every `switch_query` is taken as answer 0 (never
charged)."""
`CHARGE_QUERY_COOLDOWN`), and the build writes the donor's charge table into the moves module
(`brain.charge`); without it every `switch_query` is taken as answer 0 (never charged)."""

def targets(self, pair: Pair) -> list[Target]:
"""What the donor's `pair` plays, in order: host pairs and own moves' names (`OWN`
Expand Down
Loading
Loading