From 4e11a7d901b8ab3071e1d952b3a8ad13880d7741 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:39:23 +0200 Subject: [PATCH 1/9] feat(port): manifest fields for an own move's travel, lunge, windowed fixed turn and lifted clip Co-Authored-By: Claude Opus 5.5 --- packages/mhfu-port/src/mhfu_port/manifest.py | 42 ++++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/mhfu-port/src/mhfu_port/manifest.py b/packages/mhfu-port/src/mhfu_port/manifest.py index 32dd4571..72fed6b1 100644 --- a/packages/mhfu-port/src/mhfu_port/manifest.py +++ b/packages/mhfu-port/src/mhfu_port/manifest.py @@ -160,6 +160,10 @@ class Clip: monster's +x, its left. None: the turn its own body makes (`mhfu_port.travel`).""" label: str = "" labelled_build: str | None = None + lift: list[float] | None = None + """Height over rest, one value per clip frame of the entry, added to joint 0's translation y + in the built entry (the engine puts the monster's Y back on the floor every frame): a derived + one-shot copy of its source, in an entry of its own.""" @property def cut(self) -> tuple[int, int] | None: @@ -210,7 +214,7 @@ class Steer: turn: Turn = "clip" """`clip` follows the clip's turn, `hunter` and `away` turn at `rate`, `fixed` turns - `angle` degrees over `frames` AI frames, `still` not at all.""" + `angle` degrees over `frames` AI frames or across `window` by cursor, `still` not at all.""" rate: int | None = None """YAW units an AI frame for `hunter` and `away`; None: the host charge's.""" angle: float | None = None @@ -219,7 +223,22 @@ class Steer: dir: float = 0.0 """Degrees of the travel against the facing, which walls count as ahead (180: backwards).""" window: tuple[int, int] | None = None - """Clip frames `[from, to)` `hunter` and `away` turn in; None: the whole move.""" + """Clip frames `[from, to)` `hunter` and `away` turn in, `fixed` turns across; None: the + whole move.""" + + +@dataclass +class Lunge: + """Travel toward the hunter across clip frames `window` (lo, hi]: none while the hunter is + nearer than `near` at the move's first playing frame, else `base + range * min((d - near) / + span, 1)` units along the bearing to it then, spread over the window by cursor. In world + units, not scaled by the monster's size.""" + + window: tuple[int, int] + near: float + span: float + base: float + range: float @dataclass @@ -259,6 +278,12 @@ class Move: stop: int = 0 """Clip frame the move ends at, when the cursor reaches it; 0: where `length` or the clip's end says.""" + travel: list[tuple[float, float]] | None = None + """An own move's travel by code: cumulative `(x, z)` every 2 clip frames from `start`, in the + monster's frame at its first playing frame (x its +x, its left; z forward). Each AI frame it + moves by the curve's change since the last cursor, turned by YAW then and scaled by its size; + walls and the floor apply as to root motion.""" + lunge: Lunge | None = None carrier: tuple[int, int] | None = None host_attacks: bool = False label: str = "" @@ -676,12 +701,21 @@ def _own_move(mv: Move, w: str) -> None: s = mv.steer fixed = s.turn == "fixed" _need(fixed == (s.angle is not None), f"{w}.steer", "angle goes with turn = fixed") - _need(fixed == (s.frames is not None), f"{w}.steer", "frames goes with turn = fixed") + _need(fixed or s.frames is None, f"{w}.steer", "frames goes with turn = fixed") + _need( + not fixed or (s.frames is None) != (s.window is None), + f"{w}.steer", + "turn = fixed takes frames or window", + ) _need(s.frames is None or 1 <= s.frames <= 0x7FFF, f"{w}.steer", "frames is 1..32767") _need(s.angle is None or abs(s.angle) < 360, f"{w}.steer", "angle is under one turn") _need(s.rate is None or 1 <= s.rate <= 0xFFFF, f"{w}.steer", "rate is 1..65535") if s.window is not None: - _need(s.turn in ("hunter", "away"), f"{w}.steer", "window goes with turn = hunter or away") + _need( + s.turn in ("hunter", "away", "fixed"), + f"{w}.steer", + "window goes with turn = hunter, away or fixed", + ) _need(0 <= s.window[0] < s.window[1], f"{w}.steer", "window is 0 <= from < to") From 8c9e910119e350ff7eaffd73974bc0f410a1f0bd Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:38:45 +0200 Subject: [PATCH 2/9] addresses: lay structs out with next and auto, size arrays by a const A field at "next" follows the one before as C places it, a struct of size "auto" ends where its last field does, and an array's [n] may name a [const]: OWN_MOVES, the key pools and a move's keys are each one number now, and every offset after them follows. The structs that held 96 or 5632 by hand (EM_MOVES, EM_OWN, FATIGUE_CFG, FATIGUE, EM_BLOCK, MOVE_STATE, STEER_SPEC, STEER_STATE) are laid out from them; sizes and offsets come out as they were, bar EM_OWN's trailing pad. A host build of the C structs' static_asserts against a raised const keeps the C mirrors honest. Co-Authored-By: Claude Sonnet 5.5 --- framework/include/mhfu/em_vhook.h | 6 +- framework/include/mhfu/fatigue.h | 2 +- framework/include/mhfu/steer.h | 5 +- framework/tests/test_own_layout.py | 44 ++++++ packages/mhfu-port/src/mhfu_port/travel.py | 7 +- packages/mhfu-port/tests/test_own_cli.py | 2 +- packages/mhfu/README.md | 5 + packages/mhfu/src/mhfu/addresses.py | 163 +++++++++++++++++---- packages/mhfu/src/mhfu/addresses.toml | 104 ++++++------- packages/mhfu/tests/test_addresses.py | 162 +++++++++++++++++++- 10 files changed, 408 insertions(+), 92 deletions(-) create mode 100644 framework/tests/test_own_layout.py diff --git a/framework/include/mhfu/em_vhook.h b/framework/include/mhfu/em_vhook.h index 65fefaf4..9758b5f0 100644 --- a/framework/include/mhfu/em_vhook.h +++ b/framework/include/mhfu/em_vhook.h @@ -182,9 +182,8 @@ int mhfu_em_tip(const uint8_t (*pairs)[2], int n); * pair: the wake the hub raises inside the run is cleared by the script's tick after it, and the * move player clears the one its own entry of the hub raises. mhfu_em_status_t.own_enters counts * these enters, own_last gives the slot of the last that played. */ -#define MHFU_EM_MOVES MHFU_EM_MOVES_MOVES_COUNT -#define MHFU_EM_KEYS MHFU_EM_MOVES_KEYS_COUNT -#define MHFU_EM_NO_MOVE 0xFFu +#define MHFU_EM_MOVES MHFU_OWN_MOVES +#define MHFU_EM_NO_MOVE 0xFFu /* MHFU_EM_KEYS, the keys' pool, is a const in addresses.toml */ /* struct EM_OWN in addresses.toml */ typedef struct { @@ -194,7 +193,6 @@ typedef struct { uint8_t stuck_main, stuck_sub, stuck_mode; uint8_t after; /* slot played when this one ends; MHFU_EM_NO_MOVE */ uint32_t valid; - uint32_t _pad; } mhfu_em_own_t; /* struct EM_MOVES in addresses.toml */ diff --git a/framework/include/mhfu/fatigue.h b/framework/include/mhfu/fatigue.h index 01c37587..4d3205c9 100644 --- a/framework/include/mhfu/fatigue.h +++ b/framework/include/mhfu/fatigue.h @@ -40,7 +40,7 @@ extern "C" { #endif #define MHFU_FATIGUE_PAIRS MHFU_FATIGUE_CFG_PAIRS_COUNT /* costs by pair */ -#define MHFU_FATIGUE_OWN MHFU_EM_MOVES_MOVES_COUNT /* costs by own-move slot */ +#define MHFU_FATIGUE_OWN MHFU_OWN_MOVES /* costs by own-move slot */ /* struct FATIGUE_PAIR in addresses.toml */ typedef struct { diff --git a/framework/include/mhfu/steer.h b/framework/include/mhfu/steer.h index 9c3d03d2..85efff80 100644 --- a/framework/include/mhfu/steer.h +++ b/framework/include/mhfu/steer.h @@ -9,6 +9,7 @@ #include +#include "addresses.gen.h" #include "mhfu/types.h" #ifdef __cplusplus @@ -65,8 +66,8 @@ int mhfu_steer_frame(uint32_t entity, const mhfu_steer_t *p, uint16_t frame); /* --- a move's steering, as the move player runs it (move.cpp) ------------------------------- */ -#define MHFU_STEER_KEY_STEP 2 /* clip frames between turn keys: mhfu_port.travel.KEY_STEP */ -#define MHFU_STEER_KEYS 256 +/* MHFU_STEER_KEY_STEP, clip frames between turn keys, and MHFU_STEER_KEYS are consts in + * addresses.toml */ /* struct STEER_SPEC in addresses.toml */ typedef struct { diff --git a/framework/tests/test_own_layout.py b/framework/tests/test_own_layout.py new file mode 100644 index 00000000..0eaf29c0 --- /dev/null +++ b/framework/tests/test_own_layout.py @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 sp00ktober +"""A capacity in addresses.toml (OWN_MOVES, a pool, a move's keys) is raised by changing its +const: the C structs that mirror the generated layout still agree with it, or the host build of +their static_asserts fails.""" + +import shutil +import subprocess +import tomllib +from importlib.resources import files +from pathlib import Path + +import pytest +from mhfu import addresses + +FRAMEWORK = Path(__file__).parents[1] +SOURCES = ("em_vhook.cpp", "move.cpp", "steer.cpp") + + +@pytest.mark.parametrize( + ("const", "value"), + [ + ("OWN_MOVES", 160), + ("OWN_MOVES", 97), + ("EM_KEYS", 8000), + ("STEER_KEYS", 301), + ], +) +def test_a_raised_capacity_agrees_with_the_c_structs( + const: str, value: int, tmp_path: Path +) -> None: + cc = shutil.which("c++") + if cc is None: + pytest.skip("no host C++ compiler") + data = tomllib.loads(files("mhfu").joinpath("addresses.toml").read_text(encoding="utf-8")) + data["const"][const]["value"] = value + (tmp_path / "addresses.gen.h").write_text(addresses.render_c(addresses.parse(data))) + for src in SOURCES: + subprocess.run( + [cc, "-std=gnu++17", "-fsyntax-only", "-Wall", "-Werror", "-DMHFU_HOST"] + + [f"-I{FRAMEWORK / 'include'}", f"-I{FRAMEWORK / 'src' / 'core'}", f"-I{tmp_path}"] + + [str(FRAMEWORK / "src" / "core" / src)], + check=True, + ) diff --git a/packages/mhfu-port/src/mhfu_port/travel.py b/packages/mhfu-port/src/mhfu_port/travel.py index dfb7b433..efe23c7d 100644 --- a/packages/mhfu-port/src/mhfu_port/travel.py +++ b/packages/mhfu-port/src/mhfu_port/travel.py @@ -19,6 +19,7 @@ from dataclasses import dataclass import numpy as np +from mhfu import addresses from mhfu.entries import PART_STREAMS from mhp_formats import fu from mhp_formats.anim import CHANNEL_BITS, Channel, Clip, Keyframe, Kind, Track, dequantize @@ -44,9 +45,9 @@ FAST_TURN = math.radians(10.0) """A turn between two keys past which joint 0's sway is baked on every frame: turned back fast, it bends between keys.""" -KEY_STEP = 2 -"""Clip frames between the keys `carry` bakes and `Turn` holds: the engine's clip speed, so -its cursor lands on them.""" +KEY_STEP = int(addresses.STEER_KEY_STEP) +"""Clip frames between the keys `carry` bakes and `Turn` holds, and between travel samples: the +engine's clip speed, so its cursor lands on them; the framework's constant.""" _ROT_Y = next(bit for bit, kind in CHANNEL_BITS.items() if kind == ("rot", Y)) Vec2 = tuple[float, float] diff --git a/packages/mhfu-port/tests/test_own_cli.py b/packages/mhfu-port/tests/test_own_cli.py index c6542ce4..6be98320 100644 --- a/packages/mhfu-port/tests/test_own_cli.py +++ b/packages/mhfu-port/tests/test_own_cli.py @@ -61,7 +61,7 @@ def test_the_cli_lists_the_chains_across_hand_overs(data, capsys): path = PORTS / "zinogre.toml" code = main(["own", str(path), "--data", str(data.fu.root), "--p3rd-data", str(data.p3rd.root)]) out = capsys.readouterr().out - assert code == 0 and "39 pairs, 93 own moves of 96" in out + assert code == 0 and f"39 pairs, 93 own moves of {manifest.OWN_MOVES}" in out lines = out.splitlines() head = lines.index("7,12 p3_7_12 + 3 more") chain = [line.split()[0] for line in lines[head + 1 :] if line.startswith(" p3_")][:4] diff --git a/packages/mhfu/README.md b/packages/mhfu/README.md index 1bec363e..a0c445fc 100644 --- a/packages/mhfu/README.md +++ b/packages/mhfu/README.md @@ -37,6 +37,11 @@ mhfu addresses c -o addresses.gen.h # MHFU_SCREEN_STATE, MHFU_ENTITY_HP, .. mhfu addresses lua -o addresses.gen.lua # mhfu.addr.SCREEN_STATE, mhfu.addr.ENTITY.HP, ... ``` +A struct's `size` may be `"auto"` and a field's `offset` `"next"`: C's layout (each field aligned to +its type, the size rounded to the largest alignment) is then worked out, and an array's `[n]` may +name a `[const]`, so a capacity (`OWN_MOVES`, a pool) is one number and the offsets after it +follow. The C structs that mirror such a layout `static_assert` against it. + Product code in this repository never writes an address as a number: a pre-commit check rejects one. For a value that only looks like an address, such as a MIPS jump opcode, end the line with a `noaddr` comment. diff --git a/packages/mhfu/src/mhfu/addresses.py b/packages/mhfu/src/mhfu/addresses.py index c6ce3f9a..b6807550 100644 --- a/packages/mhfu/src/mhfu/addresses.py +++ b/packages/mhfu/src/mhfu/addresses.py @@ -10,6 +10,10 @@ addresses.FREE_ROAM_SCENES # a set of addresses, a frozenset addresses.OWN_MOVE_MAIN # a constant: a number C, Lua and Python agree on, no address +A struct's `size` may be "auto" and a field's `offset` "next": the file then says the layout the +way C lays it out (each field aligned to its type, the size rounded to the largest alignment), and +an array's `[n]` may name a `[const]`, so a table's capacity is one number. + The C header, the Lua table and the C data lua_host builds mhfu.addr from are generated from the same file: @@ -36,8 +40,12 @@ _NAME = re.compile(r"[A-Z][A-Z0-9_]*\Z") _LOWER = re.compile(r"[a-z][a-z0-9_]*\Z") _BASE_TYPES = frozenset("u8 u16 u32 s8 s16 s32 f32 vec3 ptr vtable fn code bytes".split()) -# a base type or a struct name, optionally [n] of them -_TYPE = re.compile(r"(\w+)(?:\[([1-9][0-9]*)\])?\Z") +# a base type or a struct name, optionally [n] of them, n a number or a [const]'s name +_TYPE = re.compile(r"(\w+)(?:\[([1-9][0-9]*|[A-Z][A-Z0-9_]*)\])?\Z") +# the bytes of a base type that a field can have; its alignment is its size, a vec3's 4 +_SIZES = {"u8": 1, "s8": 1, "u16": 2, "s16": 2, "u32": 4, "s32": 4, "f32": 4, "vec3": 12} +_SIZES |= {"ptr": 4, "vtable": 4, "fn": 4, "code": 4} +AUTO, NEXT = "auto", "next" class _Named(int): @@ -56,7 +64,7 @@ def __new__(cls, value: int, name: str, type: str, doc: str) -> _Named: def count(self) -> int | None: """The n of a `type[n]` entry; None for a single value.""" m = _TYPE.match(self.type) - return int(m[2]) if m and m[2] else None + return int(m[2]) if m and m[2] and m[2].isdigit() else None class Address(_Named): @@ -161,6 +169,20 @@ def parse(data: dict[str, Any]) -> Table: if unknown: problems.append(f"unknown top-level tables: {sorted(unknown)}") + consts: dict[str, Const] = {} + for name, entry in data.get("const", {}).items(): + where = f"const.{name}" + if not _NAME.match(name): + problems.append(f"{where}: names are UPPER_SNAKE_CASE") + if not isinstance(entry, dict) or set(entry) != {"value", "doc"}: + problems.append(f"{where}: expects exactly value and doc") + continue + value = entry["value"] + if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value <= 0xFFFF_FFFF: + problems.append(f"{where}: value is an integer from 0 to 0xFFFFFFFF") + elif _doc_ok(where, entry["doc"], problems): + consts[name] = Const(value, name, "u32", entry["doc"]) + addresses: dict[str, Address] = {} by_value: dict[int, str] = {} for name, entry in data.get("address", {}).items(): @@ -172,9 +194,11 @@ def parse(data: dict[str, Any]) -> Table: elif value in by_value: problems.append(f"{where}: 0x{value:08X} is already {by_value[value]}") by_value.setdefault(value, name) - addresses[name] = Address(value, name, entry["type"], entry["doc"]) + type_ = _counted(where, entry["type"], consts, problems) + addresses[name] = Address(value, name, type_, entry["doc"]) - structs: dict[str, Struct] = {} + raw: dict[str, dict[str, Any]] = {} + raw_fields: dict[str, dict[str, dict[str, Any]]] = {} for name, entry in data.get("struct", {}).items(): where = f"struct.{name}" if not _NAME.match(name): @@ -183,20 +207,33 @@ def parse(data: dict[str, Any]) -> Table: problems.append(f"{where}: expects doc, fields and an optional size or stride") continue size, stride = entry.get("size"), entry.get("stride") - if size is not None and (not isinstance(size, int) or size <= 0): - problems.append(f"{where}: size must be a positive integer") - size = None + if size is not None and size != AUTO and (not isinstance(size, int) or size <= 0): + problems.append(f'{where}: size must be a positive integer or "{AUTO}"') + entry = {**entry, "size": None} if stride is not None and (not isinstance(stride, int) or stride <= 0): problems.append(f"{where}: stride must be a positive integer") - stride = None + entry = {**entry, "stride": None} _doc_ok(where, entry.get("doc"), problems) + raw[name] = entry + raw_fields[name] = {} + for fname, fentry in entry.get("fields", {}).items(): + fwhere = f"{where}.{fname}" + if _entry_ok(fwhere, fname, fentry, "offset", problems, NEXT): + type_ = _counted(fwhere, fentry["type"], consts, problems) + raw_fields[name][fname] = {**fentry, "type": type_} + + layout = _Layout(raw, raw_fields, problems) + structs: dict[str, Struct] = {} + for name, entry in raw.items(): + where = f"struct.{name}" + size, _, offsets = layout.of(name) fields: dict[str, Field] = {} by_offset: dict[int, str] = {} - for fname, fentry in entry.get("fields", {}).items(): + for fname, fentry in raw_fields[name].items(): fwhere = f"{where}.{fname}" - if not _entry_ok(fwhere, fname, fentry, "offset", problems): + offset = offsets.get(fname) + if offset is None: continue - offset = fentry["offset"] if offset < 0 or (size is not None and offset >= size): problems.append(f"{fwhere}: offset 0x{offset:X} is outside the struct") elif offset in by_offset: @@ -205,7 +242,7 @@ def parse(data: dict[str, Any]) -> Table: fields[fname] = Field(offset, fname, fentry["type"], fentry["doc"]) if not fields: problems.append(f"{where}: has no fields") - structs[name] = Struct(name, entry.get("doc", ""), fields, size, stride) + structs[name] = Struct(name, entry.get("doc", ""), fields, size, entry.get("stride")) typed: list[tuple[str, _Named]] = [(f"address.{n}", a) for n, a in addresses.items()] typed += [(f"struct.{s.name}.{n}", f) for s in structs.values() for n, f in s.fields.items()] @@ -257,20 +294,6 @@ def parse(data: dict[str, Any]) -> Table: elif _doc_ok(where, entry["doc"], problems): sets[name] = AddressSet(tuple(addresses[n] for n in of), name, entry["doc"]) - consts: dict[str, Const] = {} - for name, entry in data.get("const", {}).items(): - where = f"const.{name}" - if not _NAME.match(name): - problems.append(f"{where}: names are UPPER_SNAKE_CASE") - if not isinstance(entry, dict) or set(entry) != {"value", "doc"}: - problems.append(f"{where}: expects exactly value and doc") - continue - value = entry["value"] - if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value <= 0xFFFF_FFFF: - problems.append(f"{where}: value is an integer from 0 to 0xFFFFFFFF") - elif _doc_ok(where, entry["doc"], problems): - consts[name] = Const(value, name, "u32", entry["doc"]) - # every generated name (Python and Lua namespace, C macro) must be unique for name in addresses.keys() & structs.keys(): problems.append(f"{name}: is both an address and a struct") @@ -302,14 +325,16 @@ def parse(data: dict[str, Any]) -> Table: return Table(addresses, structs, enums, sets, consts) -def _entry_ok(where: str, name: str, entry: Any, key: str, problems: list[str]) -> bool: +def _entry_ok( + where: str, name: str, entry: Any, key: str, problems: list[str], word: str | None = None +) -> bool: if not _NAME.match(name): problems.append(f"{where}: names are UPPER_SNAKE_CASE") if not isinstance(entry, dict) or set(entry) != {key, "type", "doc"}: problems.append(f"{where}: expects exactly {key}, type and doc") return False - if not isinstance(entry[key], int): - problems.append(f"{where}: {key} must be an integer") + if not isinstance(entry[key], int) and entry[key] != word: + problems.append(f"{where}: {key} must be an integer" + (f' or "{word}"' if word else "")) return False t = entry["type"] if ( @@ -322,6 +347,84 @@ def _entry_ok(where: str, name: str, entry: Any, key: str, problems: list[str]) return _doc_ok(where, entry["doc"], problems) +def _counted(where: str, type_: str, consts: dict[str, Const], problems: list[str]) -> str: + """`type_` with a const's name in its `[n]` replaced by the number.""" + m = _TYPE.match(type_) + if not m or not m[2] or m[2].isdigit(): + return type_ + if not consts.get(m[2]): + problems.append(f"{where}: [{m[2]}] is not a const above zero") + return type_ + return f"{m[1]}[{int(consts[m[2]])}]" + + +class _Layout: + """The size, alignment and field offsets of the file's structs, worked out on demand: a + field at "next" follows the one before it as C places it, a struct of size "auto" ends where + its last field does, rounded to its largest alignment.""" + + def __init__( + self, + entries: dict[str, dict[str, Any]], + fields: dict[str, dict[str, dict[str, Any]]], + problems: list[str], + ) -> None: + self.entries, self.fields, self.problems = entries, fields, problems + self.done: dict[str, tuple[int | None, int, dict[str, int]]] = {} + self.busy: set[str] = set() + + def of(self, name: str) -> tuple[int | None, int, dict[str, int]]: + """(size or None, alignment, offset by field) of struct `name`.""" + if name in self.done: + return self.done[name] + if name in self.busy: + self.problems.append(f"struct.{name}: contains itself") + return None, 1, {} + self.busy.add(name) + end, align = 0, 1 + offsets: dict[str, int] = {} + unsized: list[str] = [] + for fname, fentry in self.fields[name].items(): + where = f"struct.{name}.{fname}" + have = self.sized(fentry["type"]) + offset = fentry["offset"] + if offset == NEXT: + if have is None or unsized: + why = "a type with a size" if have is None else f"{unsized[-1]} to have one" + self.problems.append(f'{where}: offset "{NEXT}" needs {why}') + continue + offset = -(-end // have[1]) * have[1] + offsets[fname] = offset + if have is None: + unsized.append(fname) + else: + end, align = max(end, offset + have[0]), max(align, have[1]) + size = self.entries[name].get("size") + if size == AUTO: + if unsized: + self.problems.append(f'struct.{name}: size "{AUTO}" needs a size for {unsized}') + size = -(-end // align) * align + self.busy.discard(name) + self.done[name] = size, align, offsets + return self.done[name] + + def sized(self, type_: str) -> tuple[int, int] | None: + """(bytes, alignment) of a field's type, None where it has no size.""" + m = _TYPE.match(type_) + if not m: + return None + if m[1] in _SIZES: + size, align = _SIZES[m[1]], 4 if m[1] == "vec3" else _SIZES[m[1]] + elif m[1] in self.entries: + size_, align = self.of(m[1])[:2] + if size_ is None: + return None + size = size_ + else: + return None + return size * (int(m[2]) if m[2] and m[2].isdigit() else 1), align + + def _doc_ok(where: str, doc: Any, problems: list[str]) -> bool: if not isinstance(doc, str) or not doc.strip(): problems.append(f"{where}: doc is missing") diff --git a/packages/mhfu/src/mhfu/addresses.toml b/packages/mhfu/src/mhfu/addresses.toml index f18207af..a39a847b 100644 --- a/packages/mhfu/src/mhfu/addresses.toml +++ b/packages/mhfu/src/mhfu/addresses.toml @@ -852,7 +852,7 @@ STOP = { offset = 0x36, type = "u16", doc = "the move ends when PART's clip curs [struct.MOVE_STATE] doc = "the move player's block in partition memory (move.cpp): the move it runs and what happened, read by mhfu.live.moves" -size = 0x7B8 +size = "auto" [struct.MOVE_STATE.fields] MAGIC = { offset = 0x00, type = "u32", doc = "0x4D4F5650 (MOVP)" } STARTED = { offset = 0x04, type = "u32", doc = "moves started since boot" } @@ -878,17 +878,17 @@ NODE_VTABLE = { offset = 0xEC, type = "ptr[4]", doc = "each spawned node's vtabl ENDED_FRAME = { offset = 0xFC, type = "u32[4]", doc = "FRAMES when the move ended each attack's node; 0xFFFFFFFF = not ended by the move" } ENDED_STATE = { offset = 0x10C, type = "u8[4]", doc = "the node's ATTACK_NODE.STATE then: 1 or 2 ended by the move, 0 it had ended itself, 0xFF no longer the move's (left alone)" } STEER = { offset = 0x110, type = "STEER_STATE", doc = "the move's steering: its turn curve, its mode and walls (mhfu/steer.h)" } -REACT = { offset = 0x54C, type = "MOVE", doc = "the move that replaces REACT_KIND on REACT_ENTITY (mhfu_move_react)" } -REACT_STEER = { offset = 0x584, type = "STEER_SPEC", doc = "REACT's steering" } -REACT_ENTITY = { offset = 0x79C, type = "ptr", doc = "the monster whose reaction is replaced; 0 = none" } -REACT_KIND = { offset = 0x7A0, type = "u8", doc = "0 the flinch" } -REACT_PARTS = { offset = 0x7A1, type = "u8", doc = "ENTITY.FLINCH_MASK of the last replaced reaction" } -REACT_PART = { offset = 0x7A2, type = "u8", doc = "ENTITY.MOST_DAMAGED_PART then" } -REACTIONS = { offset = 0x7A4, type = "u32", doc = "reactions replaced since boot" } -REACT_SEEN = { offset = 0x7A8, type = "u32", doc = "em_vhook's replaced enter-actions the step has taken" } -REACT_PAIR = { offset = 0x7AC, type = "u32", doc = "the replaced enter-action: (mode << 16) | (main << 8) | sub" } -WAITED = { offset = 0x7B0, type = "u32", doc = "AI frames NEXT waited for the monster's notice" } -EVENTS = { offset = 0x7B4, type = "ptr", doc = "the monster-event block (struct MONSTER_EVENTS)" } +REACT = { offset = "next", type = "MOVE", doc = "the move that replaces REACT_KIND on REACT_ENTITY (mhfu_move_react)" } +REACT_STEER = { offset = "next", type = "STEER_SPEC", doc = "REACT's steering" } +REACT_ENTITY = { offset = "next", type = "ptr", doc = "the monster whose reaction is replaced; 0 = none" } +REACT_KIND = { offset = "next", type = "u8", doc = "0 the flinch" } +REACT_PARTS = { offset = "next", type = "u8", doc = "ENTITY.FLINCH_MASK of the last replaced reaction" } +REACT_PART = { offset = "next", type = "u8", doc = "ENTITY.MOST_DAMAGED_PART then" } +REACTIONS = { offset = "next", type = "u32", doc = "reactions replaced since boot" } +REACT_SEEN = { offset = "next", type = "u32", doc = "em_vhook's replaced enter-actions the step has taken" } +REACT_PAIR = { offset = "next", type = "u32", doc = "the replaced enter-action: (mode << 16) | (main << 8) | sub" } +WAITED = { offset = "next", type = "u32", doc = "AI frames NEXT waited for the monster's notice" } +EVENTS = { offset = "next", type = "ptr", doc = "the monster-event block (struct MONSTER_EVENTS)" } [struct.STEER_PARAMS] doc = "how a move turns and stops (steer.h mhfu_steer_t)" @@ -905,44 +905,44 @@ WIN_TO = { offset = 0xE, type = "u16", doc = "with WIN_FROM" } [struct.STEER_SPEC] doc = "a move's steering as the move player takes it (steer.h mhfu_steer_spec_t)" -size = 0x218 +size = "auto" [struct.STEER_SPEC.fields] STEER = { offset = 0x0, type = "STEER_PARAMS", doc = "turn mode and walls" } KEY_COUNT = { offset = 0x10, type = "u16", doc = "KEYS in use; 0: the clip does not turn YAW" } STUCK_MAIN = { offset = 0x12, type = "u8", doc = "pair entered on a class-2 wall ahead (ENTITY.STUCK_WALL), em75's (0,6)" } STUCK_SUB = { offset = 0x13, type = "u8", doc = "with STUCK_MAIN" } STUCK_MODE = { offset = 0x14, type = "u8", doc = "ENTER_ACTION mode for it, 1 as the Tigrex charge enters it" } -KEYS = { offset = 0x18, type = "u16[256]", doc = "YAW every 2 clip frames, the last at the clip's end: a port's turns module (mhfu_port.travel.Turn)" } +KEYS = { offset = 0x18, type = "u16[STEER_KEYS]", doc = "YAW every STEER_KEY_STEP clip frames, the last at the clip's end: a port's turns module (mhfu_port.travel.Turn)" } [struct.STEER_STATE] doc = "the move player's steering (steer.h mhfu_steer_state_t), in MOVE_STATE" -size = 0x43C +size = "auto" [struct.STEER_STATE.fields] NEXT = { offset = 0x0, type = "STEER_SPEC", doc = "the steering the next move takes on its first playing frame" } -NOW = { offset = 0x218, type = "STEER_SPEC", doc = "the steering of the move running" } -BASE = { offset = 0x430, type = "u16", doc = "YAW the turn curve adds to: YAW on the first playing frame, turned by the mode and each loop" } -WALL = { offset = 0x432, type = "u8", doc = "the last frame's wall result: 0 none, 1 a wall ahead, 2 a class-2 one" } -CURSOR = { offset = 0x434, type = "f32", doc = "part 0's clip cursor the step saw last" } -YAW0 = { offset = 0x438, type = "u16", doc = "YAW on the move's first playing frame" } -YAW = { offset = 0x43A, type = "u16", doc = "YAW as the step left it" } +NOW = { offset = "next", type = "STEER_SPEC", doc = "the steering of the move running" } +BASE = { offset = "next", type = "u16", doc = "YAW the turn curve adds to: YAW on the first playing frame, turned by the mode and each loop" } +WALL = { offset = "next", type = "u8", doc = "the last frame's wall result: 0 none, 1 a wall ahead, 2 a class-2 one" } +CURSOR = { offset = "next", type = "f32", doc = "part 0's clip cursor the step saw last" } +YAW0 = { offset = "next", type = "u16", doc = "YAW on the move's first playing frame" } +YAW = { offset = "next", type = "u16", doc = "YAW as the step left it" } [struct.EM_OWN] doc = "one own move in em_vhook's registry (em_vhook.h mhfu_em_own_t), played by its slot when Lua, a rule or the move before it asks" -size = 0x58 +size = "auto" [struct.EM_OWN.fields] MOVE = { offset = 0x00, type = "MOVE", doc = "the move; its back pair is its carrier when AFTER names a slot, so the brain does not cut in" } -STEER = { offset = 0x38, type = "STEER_PARAMS", doc = "turn mode and walls" } -KEY_AT = { offset = 0x48, type = "u16", doc = "its first turn key in EM_MOVES.KEYS" } -KEY_COUNT = { offset = 0x4A, type = "u16", doc = "its turn keys; 0: the clip does not turn YAW" } -STUCK_MAIN = { offset = 0x4C, type = "u8", doc = "STEER_SPEC.STUCK_MAIN" } -STUCK_SUB = { offset = 0x4D, type = "u8", doc = "STEER_SPEC.STUCK_SUB" } -STUCK_MODE = { offset = 0x4E, type = "u8", doc = "STEER_SPEC.STUCK_MODE" } -AFTER = { offset = 0x4F, type = "u8", doc = "slot played when this move ends on its clip, its length, its stop or a wall; 0xFF none" } -VALID = { offset = 0x50, type = "u32", doc = "1 once written; 0 while it is rewritten" } +STEER = { offset = "next", type = "STEER_PARAMS", doc = "turn mode and walls" } +KEY_AT = { offset = "next", type = "u16", doc = "its first turn key in EM_MOVES.KEYS" } +KEY_COUNT = { offset = "next", type = "u16", doc = "its turn keys; 0: the clip does not turn YAW" } +STUCK_MAIN = { offset = "next", type = "u8", doc = "STEER_SPEC.STUCK_MAIN" } +STUCK_SUB = { offset = "next", type = "u8", doc = "STEER_SPEC.STUCK_SUB" } +STUCK_MODE = { offset = "next", type = "u8", doc = "STEER_SPEC.STUCK_MODE" } +AFTER = { offset = "next", type = "u8", doc = "slot played when this move ends on its clip, its length, its stop or a wall; 0xFF none" } +VALID = { offset = "next", type = "u32", doc = "1 once written; 0 while it is rewritten" } [struct.EM_MOVES] doc = "em_vhook's own-move registry in partition memory (em_vhook.cpp): a port's own moves by slot, played by the move player on the AI step that asks" -size = 0x4F3C +size = "auto" [struct.EM_MOVES.fields] MAGIC = { offset = 0x00, type = "u32", doc = "0x564F4D45 (EMOV)" } REQ_ENT = { offset = 0x04, type = "ptr", doc = "a play asked for (mhfu_em_play): the monster, taken at its next AI step; 0 none" } @@ -952,10 +952,10 @@ TAG_STARTED = { offset = 0x10, type = "u32", doc = "MOVE_STATE.STARTED of that m PLAYS = { offset = 0x14, type = "u32", doc = "moves started from here: asked, by a rule or by AFTER" } CHAINED = { offset = 0x18, type = "u32", doc = "of them by AFTER" } KEY_TOP = { offset = 0x1C, type = "u32", doc = "KEYS in use" } -MOVES = { offset = 0x20, type = "EM_OWN[96]", doc = "the moves by slot" } -KEYS = { offset = 0x2120, type = "u16[5632]", doc = "every move's turn keys, YAW every 2 clip frames" } -SCRATCH = { offset = 0x4D20, type = "STEER_SPEC", doc = "the steering handed to the move player for the move it plays next" } -REQ_FORCE = { offset = 0x4F38, type = "u32", doc = "1: the play asked for starts while the monster's notice runs (MOVE.FORCE)" } +MOVES = { offset = 0x20, type = "EM_OWN[OWN_MOVES]", doc = "the moves by slot" } +KEYS = { offset = "next", type = "u16[EM_KEYS]", doc = "every move's turn keys, YAW every STEER_KEY_STEP clip frames" } +SCRATCH = { offset = "next", type = "STEER_SPEC", doc = "the steering handed to the move player for the move it plays next" } +REQ_FORCE = { offset = "next", type = "u32", doc = "1: the play asked for starts while the monster's notice runs (MOVE.FORCE)" } [struct.FLINCH_PART] doc = "one part's flinch state (ENTITY.FLINCH_PARTS)" @@ -1068,7 +1068,7 @@ COST = { offset = 0x2, type = "s16", doc = "stamina taken; halved at low HP; neg [struct.FATIGUE_CFG] doc = "what a fatigue helper is set to (fatigue.h mhfu_fatigue_cfg_t), written by mhfu_em_fatigue" -size = 0x1D8 +size = "auto" [struct.FATIGUE_CFG.fields] MAX = { offset = 0x00, type = "s32", doc = "stamina maximum (MHP3rd's Brute 1000)" } STARTS = { offset = 0x04, type = "s32[2]", doc = "stamina at the monster's first AI step, one of the two by the board's generator (Brute 400, 600)" } @@ -1078,23 +1078,23 @@ LOW_HP_PCT = { offset = 0x14, type = "u8", doc = "costs halve at or below this p CALM = { offset = 0x15, type = "u8", doc = "1: the species' own anger starts no rage while tired" } PAIR_COUNT = { offset = 0x16, type = "u16", doc = "PAIRS in use" } PAIRS = { offset = 0x18, type = "FATIGUE_PAIR[64]", doc = "the cost of entering a pair" } -OWN = { offset = 0x118, type = "s16[96]", doc = "the cost of starting an own move, by EM_MOVES slot; one per slot" } +OWN = { offset = "next", type = "s16[OWN_MOVES]", doc = "the cost of starting an own move, by EM_MOVES slot; one per slot" } [struct.FATIGUE] doc = "em_vhook's fatigue block (fatigue.h mhfu_fatigue_t): one per wrapped species, for the board's monster; a new monster resets everything after CFG" -size = 0x200 +size = "auto" [struct.FATIGUE.fields] ENABLED = { offset = 0x00, type = "u32", doc = "1 once mhfu_em_fatigue armed it; the quest's end and mhfu_em_clear turn it off" } CFG = { offset = 0x04, type = "FATIGUE_CFG", doc = "the settings" } -STAMINA = { offset = 0x1DC, type = "s32", doc = "0..CFG.MAX; set at the monster's first AI step after arming" } -TIRED = { offset = 0x1E0, type = "u32", doc = "1 during a tired spell: gauge A reads low" } -LEFT = { offset = 0x1E4, type = "u32", doc = "AI frames of the tired spell left" } -SEEN = { offset = 0x1E8, type = "u32", doc = "1 once the first AI step chose the start" } -PAIR = { offset = 0x1EC, type = "u32", doc = "(main << 8) | sub the last AI frame saw; a different one is charged" } -HELD = { offset = 0x1F0, type = "u32", doc = "1 while this block keeps the species' anger off (CFG.CALM, tired)" } -CHARGES = { offset = 0x1F4, type = "u32", doc = "costs charged" } -SPELLS = { offset = 0x1F8, type = "u32", doc = "tired spells begun" } -GAUGE = { offset = 0x1FC, type = "s32", doc = "ENTITY.GAUGE_A as this block last wanted it" } +STAMINA = { offset = "next", type = "s32", doc = "0..CFG.MAX; set at the monster's first AI step after arming" } +TIRED = { offset = "next", type = "u32", doc = "1 during a tired spell: gauge A reads low" } +LEFT = { offset = "next", type = "u32", doc = "AI frames of the tired spell left" } +SEEN = { offset = "next", type = "u32", doc = "1 once the first AI step chose the start" } +PAIR = { offset = "next", type = "u32", doc = "(main << 8) | sub the last AI frame saw; a different one is charged" } +HELD = { offset = "next", type = "u32", doc = "1 while this block keeps the species' anger off (CFG.CALM, tired)" } +CHARGES = { offset = "next", type = "u32", doc = "costs charged" } +SPELLS = { offset = "next", type = "u32", doc = "tired spells begun" } +GAUGE = { offset = "next", type = "s32", doc = "ENTITY.GAUGE_A as this block last wanted it" } [struct.CHARGE_UP] doc = "one charge-up own move of the charge helper (charge.h mhfu_charge_up_t): starting it raises the gauge" @@ -1147,12 +1147,12 @@ HITS = { offset = 0xC0, type = "u32", doc = "hits counted" } [struct.EM_BLOCK] doc = "em_vhook's partition block from its config block on (em_vhook.cpp place_block): the config block, the tail's map and the cut's wait, then the board, the fatigue block and the charge block; the block's address moves with the PRX build, a debugger finds it by EM_CFG.CANARY" -size = 0x100C +size = "auto" [struct.EM_BLOCK.fields] CFG = { offset = 0x00, type = "EM_CFG", doc = "the config block" } BOARD = { offset = 0xD08, type = "EM_BOARD", doc = "the board, after the tail: sizeof(tail_t) is 0x28" } -FATIGUE = { offset = 0xD48, type = "FATIGUE", doc = "the fatigue block, after the board" } -CHARGE = { offset = 0xF48, type = "CHARGE", doc = "the charge block, after the fatigue block" } +FATIGUE = { offset = "next", type = "FATIGUE", doc = "the fatigue block, after the board" } +CHARGE = { offset = "next", type = "CHARGE", doc = "the charge block, after the fatigue block" } [enum.EM_COND] doc = "a brain rule's condition, EM_OP.OP - 1: hp_at_least/hp_below HP percent of MAX_HP against VALUE; broken/not_broken part ARG in EM_BOARD.BROKEN; enraged/calm ENTITY.FLAGS 0x20; side the hunter in a sector of the monster's facing in mask ARG (90 degrees each, left the monster's own); chance VALUE percent; var_* EM_BOARD.VARS[ARG] against VALUE" @@ -1182,3 +1182,7 @@ names = ["clip", "back", "pair", "stopped", "replaced", "refused", "lost", "wall OWN_MOVE_MAIN = { value = 16, doc = "an AI script `enter` main no species has (they use 0..7; 16 is past the 8-bit main mask of em_vhook's substitutions): em_vhook's enter-action stub enters the carrier of own move (an EM_MOVES slot; em75's hub (0,2) when it is empty or out of range) instead and plays that move at the next AI step" } CHARGE_QUERY_CHARGED = { value = 0, doc = "the AI script `switch_query` arg that asks whether a port with `[brain] charge` is charged (MHP3rd's own numbering of the query): the framework answers 1 or 0" } CHARGE_QUERY_COOLDOWN = { value = 6, doc = "the `switch_query` arg that asks whether the charge cooldown runs (MHP3rd's numbering): the framework answers 1 or 0" } +OWN_MOVES = { value = 96, doc = "own moves the framework's registry holds, EM_MOVES slots 0..OWN_MOVES-1: every table keyed by slot, and the offsets after one, follow it" } +EM_KEYS = { value = 5632, doc = "turn keys all own moves share in EM_MOVES.KEYS" } +STEER_KEYS = { value = 256, doc = "turn keys one move holds (STEER_SPEC.KEYS)" } +STEER_KEY_STEP = { value = 2, doc = "clip frames between a move's turn keys: the cursor's step at speed 2, so it lands on them" } diff --git a/packages/mhfu/tests/test_addresses.py b/packages/mhfu/tests/test_addresses.py index 76cbaefb..970a894a 100644 --- a/packages/mhfu/tests/test_addresses.py +++ b/packages/mhfu/tests/test_addresses.py @@ -123,7 +123,8 @@ def test_python_matches_the_file(): assert getattr(addresses, name).type == entry["type"] for sname, struct in RAW.get("struct", {}).items(): for fname, entry in struct["fields"].items(): - assert getattr(getattr(addresses, sname), fname) == entry["offset"] + if entry["offset"] != "next": + assert getattr(getattr(addresses, sname), fname) == entry["offset"] def test_c_header_matches_python(tmp_path): @@ -314,3 +315,162 @@ def test_parse_rejects(data, problem): def test_parse_end_bound(): assert addresses.parse({"address": {"END": entry(addresses.RAM.stop)}}).addresses["END"] + + +def field(type, offset="next"): + return {"offset": offset, "type": type, "doc": "d"} + + +LAID_OUT = { + "const": {"N": {"value": 3, "doc": "d"}}, + "struct": { + "ROW": {"doc": "d", "size": "auto", "fields": {"A": field("u16", 0), "B": field("u8")}}, + "HOLDER": { + "doc": "d", + "size": "auto", + "fields": { + "FLAG": field("u8", 0), + "WORD": field("u32"), + "HALVES": field("u16[N]"), + "ROW": field("ROW"), + "ROWS": field("ROW[N]"), + "AT": field("vec3"), + "LAST": field("u8"), + }, + }, + }, +} + +C_LAYOUT = """ +#include +#include +#include "addresses.gen.h" +typedef struct { uint16_t a; uint8_t b; } row_t; +typedef struct { + uint8_t flag; uint32_t word; uint16_t halves[3]; row_t row; row_t rows[3]; + float at[3]; uint8_t last; +} holder_t; +#define SAME(t, f, name) _Static_assert(offsetof(t, f) == MHFU_##name, #name) +SAME(row_t, b, ROW_B); +_Static_assert(sizeof(row_t) == MHFU_ROW_SIZE, "ROW_SIZE"); +SAME(holder_t, word, HOLDER_WORD); +SAME(holder_t, halves, HOLDER_HALVES); +SAME(holder_t, row, HOLDER_ROW); +SAME(holder_t, rows, HOLDER_ROWS); +SAME(holder_t, at, HOLDER_AT); +SAME(holder_t, last, HOLDER_LAST); +_Static_assert(sizeof(holder_t) == MHFU_HOLDER_SIZE, "HOLDER_SIZE"); +int main(void) { return 0; } +""" + + +def test_next_and_auto_lay_a_struct_out_as_c_does(tmp_path): + cc = shutil.which("cc") + if cc is None: + pytest.skip("no C compiler") + t = addresses.parse(LAID_OUT) + h = t.structs["HOLDER"] + assert (h.WORD, h.HALVES, h.ROW, h.ROWS, h.AT, h.LAST, h.size) == (4, 8, 14, 18, 32, 44, 48) + assert h.HALVES.count == 3 and h.ROWS.type == "ROW[3]" and t.structs["ROW"].size == 4 + (tmp_path / "addresses.gen.h").write_text(addresses.render_c(t)) + (tmp_path / "main.c").write_text(C_LAYOUT) + subprocess.run([cc, "-std=c11", "-Wall", "-Werror", "-I", tmp_path, "-o", tmp_path / "m", + tmp_path / "main.c"], check=True) # fmt: skip + + +def test_a_const_sizes_an_array_in_every_language(tmp_path): + t = addresses.parse(LAID_OUT) + assert t.structs["HOLDER"].HALVES.count == 3 + for found in (in_c(t, tmp_path), in_c_table(t, tmp_path), in_lua(t)): + assert found["HOLDER_HALVES_COUNT"] == 3 and found["HOLDER_ROWS_COUNT"] == 3 + + +def test_a_const_changes_every_offset_after_it(): + data = tomllib.loads(files("mhfu").joinpath("addresses.toml").read_text(encoding="utf-8")) + old = addresses.table() + data["const"]["OWN_MOVES"]["value"] = 160 + t = addresses.parse(data) + more = 160 - addresses.OWN_MOVES + assert t.structs["EM_MOVES"].MOVES.count == t.structs["FATIGUE_CFG"].OWN.count == 160 + assert ( + t.structs["EM_MOVES"].size - old.structs["EM_MOVES"].size == more * t.structs["EM_OWN"].size + ) + assert t.structs["FATIGUE_CFG"].size == old.structs["FATIGUE_CFG"].size + 2 * more + assert t.structs["EM_BLOCK"].size == old.structs["EM_BLOCK"].size + 2 * more + assert t.structs["EM_BLOCK"].CHARGE - old.structs["EM_BLOCK"].CHARGE == 2 * more + assert ( + t.structs["EM_MOVES"].SCRATCH - old.structs["EM_MOVES"].SCRATCH + == more * t.structs["EM_OWN"].size + ) + + +@pytest.mark.parametrize( + ("data", "problem"), + [ + ( + {"struct": {"S": {"doc": "d", "fields": {"F": field("u8", "later")}}}}, + "must be an integer", + ), + ( + {"struct": {"S": {"doc": "d", "size": 0, "fields": {"F": field("u8", 0)}}}}, + "positive integer", + ), + ( + {"struct": {"S": {"doc": "d", "size": "big", "fields": {"F": field("u8", 0)}}}}, + 'or "auto"', + ), + ( + {"struct": {"S": {"doc": "d", "fields": {"F": field("u8[N]", 0)}}}}, + "not a const above zero", + ), + ( + { + "const": {"N": {"value": 0, "doc": "d"}}, + "struct": {"S": {"doc": "d", "fields": {"F": field("u8[N]", 0)}}}, + }, + "not a const above zero", + ), + ( + { + "struct": { + "S": { + "doc": "d", + "size": "auto", + "fields": {"F": field("u8", 0), "G": field("S")}, + } + } + }, + "contains itself", + ), + ( + { + "struct": { + "U": {"doc": "d", "fields": {"F": field("u8", 0)}}, + "S": {"doc": "d", "fields": {"G": field("U", 0), "H": field("u8")}}, + } + }, + "needs G to have one", + ), + ( + { + "struct": { + "U": {"doc": "d", "fields": {"F": field("u8", 0)}}, + "S": {"doc": "d", "fields": {"G": field("U")}}, + } + }, + "needs a type with a size", + ), + ( + { + "struct": { + "U": {"doc": "d", "fields": {"F": field("u8", 0)}}, + "S": {"doc": "d", "size": "auto", "fields": {"G": field("U", 0)}}, + } + }, + "needs a size for", + ), + ], +) +def test_layout_rejects(data, problem): + with pytest.raises(ValueError, match=problem): + addresses.parse(data) From 92810008cbe3d9ddfbc75998f5ef010c6305644f Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:39:46 +0200 Subject: [PATCH 3/9] framework: an own move's travel, lunge and windowed fixed turn The move player's steer() moves POSITION each AI frame, before ROOT_MOTION, WALL_COLLIDE and GROUND_QUERY, so a wall undoes it as it undoes root motion and the floor keeps the height: - travel: the monster's own (x, z) every STEER_KEY_STEP clip frames from the move's start, 16-bit samples of 1/STEER_TRAVEL_SCALE units; the change of the curve between the cursor's last frame and this one, turned by YAW at the first playing frame and scaled by ENTITY.SIZE_SCALE (any cursor rate, the blend's hold and a loop come out right). - lunge: window (lo, hi], near, span, base, range; the length from the hunter's XZ distance on the first playing frame, along the bearing to it, in world axes, unscaled. - a fixed turn with a window turns `total` across it by the cursor. Travel samples are a pool of their own (EM_MOVES.SAMPLES, 512 x 4 B), a move's inline in its STEER_SPEC (128 x 4 B), lunges a table (EM_LUNGES, 16 x 20 B); EM_MOVES grows from 0x4DBC to 0x5C1C bytes and MOVE_STATE from 0x7B8 to 0xE08. A move without them writes every entity byte as before (checked against the previous build over 400 random moves). Lua: steer.travel (8 hex digits a sample) and steer.lunge, beside steer.window. Co-Authored-By: Claude Sonnet 5.5 --- framework/README.md | 23 +- framework/include/mhfu/em_vhook.h | 18 +- framework/include/mhfu/steer.h | 46 ++- framework/lua/lib/mhfu_port.lua | 6 +- framework/lua/meta/mhfu.d.lua | 19 +- framework/mods/lua_host/bind_em.cpp | 2 + framework/mods/lua_host/bind_move.cpp | 60 +++- framework/src/core/em_vhook.cpp | 52 +++- framework/src/core/move.cpp | 22 +- framework/src/core/steer.cpp | 110 ++++++- framework/tests/move_host.cpp | 4 + framework/tests/test_em_brain.py | 72 ++++- framework/tests/test_mhfu_port.py | 32 ++ framework/tests/test_move.py | 284 +++++++++++++++++- framework/tests/test_own_layout.py | 3 + framework/tests/test_steer.py | 11 + packages/mhfu/src/mhfu/addresses.toml | 48 ++- packages/mhfu/src/mhfu/steering.py | 78 +++++ .../mhfu/tests/live/test_monster_events.py | 2 +- packages/mhfu/tests/test_steering.py | 43 +++ 20 files changed, 875 insertions(+), 60 deletions(-) create mode 100644 packages/mhfu/src/mhfu/steering.py create mode 100644 packages/mhfu/tests/test_steering.py diff --git a/framework/README.md b/framework/README.md index 15506ee2..9585b387 100644 --- a/framework/README.md +++ b/framework/README.md @@ -136,9 +136,10 @@ the engine counts the flinch and applies the damage, then enters the move's carr ## Own moves from a manifest -A port's own moves sit in a registry by slot (`mhfu.em_move`): `mhfu.addr.EM_MOVES.MOVES_COUNT` -(96) of them, and the turn keys they share (`EM_MOVES.KEYS_COUNT`, 5632), in one 20.3 KB -partition block. The seam's +A port's own moves sit in a registry by slot (`mhfu.em_move`): `mhfu.addr.OWN_MOVES` (96) of +them, and the turn keys (`EM_KEYS`, 5632), travel samples (`EM_SAMPLES`, 512) and lunges +(`EM_LUNGES`, 16) they share, in one 23.6 KB partition block. These are consts in +`addresses.toml`: raising one is that one line, every offset after it is laid out from it. The seam's brain, in C on the AI step, plays one through the move player when asked (`mhfu.em_play`), when the move before it ends on its clip, its length, its stop or a wall and names it as its `after`, or when a rule says (`play_move`, and `from_move` to fire while one plays). `mhfu_port.lua` does all of it from the @@ -152,6 +153,22 @@ hunter and away turns apply on every frame of the move unless the steer has a `w to }`: then only while the clip frame (part 0's cursor) is in `[from, to)`, as the donor turns in a cursor window. +A move can travel by code, as MHP3rd's airborne steps do, where the clip carries nothing. +`steer.travel` is the monster's own (x, z) every `mhfu.addr.STEER_KEY_STEP` clip frames from the +move's `start`: cumulative, x toward its left and z forward in its frame when the move began, as +16-bit samples of 1/`STEER_TRAVEL_SCALE` units (8 hex digits a sample in the moves module, +`mhfu.steering`), at most `STEER_SAMPLES` a move. Each AI frame the move player adds the curve's +change since the clip cursor's last frame (straight lines between samples, so any cursor rate +and the blend's hold are right) to POSITION, turned by that YAW and scaled by the monster's size. +It does so before the engine's root motion, wall push-out and floor in the same frame: walls +undo it as they undo root motion, and the floor keeps the height. `steer.lunge = { window = { +lo, hi }, near, span, base, range }` travels toward the hunter across the clip frames (lo, hi]: by +the hunter's XZ distance `d` when the move began, nothing under `near`, else `base + range * +min((d - near) / span, 1)` units along the bearing to the hunter then, in world axes and not +scaled. A fixed turn with a `window` turns `total` across it by the cursor instead of over +`frames` AI frames. All of it is data in the registry and in `mhfu.move_play`'s steer; a move +without it writes what it always did. + An AI script plays own move k with `enter 16 k 0` (`mhfu.addr.OWN_MOVE_MAIN`): the enter-action stub enters the slot's carrier (the hub `(0,2)` for an empty or out-of-range slot, the game never sees main 16), files a forced play, and the brain starts the move at the next AI step; the script diff --git a/framework/include/mhfu/em_vhook.h b/framework/include/mhfu/em_vhook.h index 9758b5f0..41740d8d 100644 --- a/framework/include/mhfu/em_vhook.h +++ b/framework/include/mhfu/em_vhook.h @@ -183,15 +183,19 @@ int mhfu_em_tip(const uint8_t (*pairs)[2], int n); * move player clears the one its own entry of the hub raises. mhfu_em_status_t.own_enters counts * these enters, own_last gives the slot of the last that played. */ #define MHFU_EM_MOVES MHFU_OWN_MOVES -#define MHFU_EM_NO_MOVE 0xFFu /* MHFU_EM_KEYS, the keys' pool, is a const in addresses.toml */ +#define MHFU_EM_NO_MOVE 0xFFu +#define MHFU_EM_NO_LUNGE 0xFFu /* mhfu_em_own_t.lunge_at: no lunge */ +/* The pools' sizes, MHFU_EM_KEYS, _SAMPLES and _LUNGES, are consts in addresses.toml. */ /* struct EM_OWN in addresses.toml */ typedef struct { mhfu_move_t move; mhfu_steer_t steer; uint16_t key_at, key_count; /* its turn keys in mhfu_em_moves_t.keys */ + uint16_t sample_at, sample_count; /* its travel samples in mhfu_em_moves_t.samples */ uint8_t stuck_main, stuck_sub, stuck_mode; uint8_t after; /* slot played when this one ends; MHFU_EM_NO_MOVE */ + uint8_t lunge_at; /* its lunge in mhfu_em_moves_t.lunges, or NO_LUNGE */ uint32_t valid; } mhfu_em_own_t; @@ -201,17 +205,19 @@ typedef struct { uint32_t req_ent, req_slot; /* a play asked for, taken at req_ent's AI step */ uint32_t tag_slot, tag_started; /* the slot of the move player's move, if ours */ uint32_t plays, chained; - uint32_t key_top; + uint32_t key_top, sample_top, lunge_top; /* pool entries in use */ + uint32_t req_force; /* REQ_SLOT starts while the notice runs */ mhfu_em_own_t moves[MHFU_EM_MOVES]; uint16_t keys[MHFU_EM_KEYS]; + mhfu_travel_sample_t samples[MHFU_EM_SAMPLES]; + mhfu_steer_lunge_t lunges[MHFU_EM_LUNGES]; mhfu_steer_spec_t scratch; /* the steering handed to the move player */ - uint32_t req_force; /* REQ_SLOT starts while the notice runs */ } mhfu_em_moves_t; -/* Empties every slot and the turn keys. */ +/* Empties every slot and the pools: turn keys, travel samples, lunges. */ void mhfu_em_moves_clear(void); -/* Slot `slot` plays mv, steered by s (its keys copied); `after` is a slot or MHFU_EM_NO_MOVE. - * 0 for a bad slot or no room left for the keys. */ +/* Slot `slot` plays mv, steered by s (its keys, travel samples and lunge copied); `after` is a + * slot or MHFU_EM_NO_MOVE. 0 for a bad slot or no room left in a pool. */ int mhfu_em_move(int slot, const mhfu_move_t *mv, const mhfu_steer_spec_t *s, uint8_t after); /* Plays slot on entity at its next AI step, forced past the notice wait (MOVE.FORCE) or not; 0 * while nothing is wrapped or the slot is empty. */ diff --git a/framework/include/mhfu/steer.h b/framework/include/mhfu/steer.h index 85efff80..b0ce0808 100644 --- a/framework/include/mhfu/steer.h +++ b/framework/include/mhfu/steer.h @@ -1,9 +1,10 @@ /* SPDX-License-Identifier: MIT */ /* SPDX-FileCopyrightText: 2026 sp00ktober */ -/* A big monster's own move, steered one AI frame at a time: its turn, and whether a wall ends it. - * The engine carries the travel itself (the clip's root motion) and pushes the monster out of - * walls (WALL_COLLIDE, after the AI step); this reads that frame's wall result and turns YAW. - * Leaf functions: no engine or library call. */ +/* A big monster's own move, steered one AI frame at a time: its turn, whether a wall ends it, and + * travel the clip does not carry. The engine carries the clip's root motion itself and pushes the + * monster out of walls (WALL_COLLIDE, after the AI step); this reads that frame's wall result, + * turns YAW and moves POSITION, which that push-out then corrects as it does root motion. + * Leaf functions: no engine call. */ #ifndef MHFU_STEER_H #define MHFU_STEER_H @@ -23,7 +24,7 @@ enum { MHFU_STEER_STILL = 0, /* no turn */ MHFU_STEER_HUNTER = 1, /* toward the hunter, at most `rate` a frame (TURN_TOWARD's step) */ MHFU_STEER_AWAY = 2, /* away from the hunter, the same way */ - MHFU_STEER_FIXED = 3, /* `total` spread evenly over `frames` frames */ + MHFU_STEER_FIXED = 3, /* `total` spread evenly over `frames` frames, or across the window */ }; enum { @@ -40,7 +41,9 @@ typedef struct { uint16_t frames; /* FIXED: frames to spread `total` over, 1..0x7FFF */ uint16_t dir; /* direction of travel against YAW, YAW units: 0 forward, 0x8000 back */ uint16_t win_from, win_to; /* HUNTER, AWAY: turn only while the clip frame is in - * [win_from, win_to); both 0: the whole move */ + * [win_from, win_to); both 0: the whole move. FIXED, in + * mhfu_steer_move: `total` is spread across the window by the + * clip cursor and `frames` is not read; win_to 0: over `frames` */ } mhfu_steer_t; /* atan2(dx, dz) in YAW units, within one unit. */ @@ -66,8 +69,18 @@ int mhfu_steer_frame(uint32_t entity, const mhfu_steer_t *p, uint16_t frame); /* --- a move's steering, as the move player runs it (move.cpp) ------------------------------- */ -/* MHFU_STEER_KEY_STEP, clip frames between turn keys, and MHFU_STEER_KEYS are consts in - * addresses.toml */ +/* MHFU_STEER_KEY_STEP, _KEYS, _SAMPLES and _TRAVEL_SCALE are consts in addresses.toml */ + +/* struct TRAVEL_SAMPLE in addresses.toml: x, z in 1/MHFU_STEER_TRAVEL_SCALE world units */ +typedef struct { + int16_t x, z; +} mhfu_travel_sample_t; + +/* struct STEER_LUNGE in addresses.toml */ +typedef struct { + uint16_t lo, hi; /* clip frames, the window (lo, hi]; hi 0: no lunge */ + float near, span, base, range; +} mhfu_steer_lunge_t; /* struct STEER_SPEC in addresses.toml */ typedef struct { @@ -77,6 +90,11 @@ typedef struct { uint8_t _pad[3]; uint16_t keys[MHFU_STEER_KEYS]; /* YAW every KEY_STEP clip frames, the last at the clip's end: * the port's turns module (mhfu_port.travel.Turn) */ + uint16_t sample_count; /* 0: no travel by code */ + mhfu_steer_lunge_t lunge; + mhfu_travel_sample_t samples[MHFU_STEER_SAMPLES]; /* the monster's own (x, z) at clip frame + * start + k * KEY_STEP, cumulative, in its frame at the first + * playing frame (x its left, z forward); held past the last */ } mhfu_steer_spec_t; /* struct STEER_STATE in addresses.toml */ @@ -85,9 +103,9 @@ typedef struct { mhfu_steer_spec_t now; uint16_t base; /* YAW the curve adds to */ uint8_t wall; /* the last frame's MHFU_STEER_* */ - uint8_t _pad; float cursor; /* the cursor the step saw last */ uint16_t yaw0, yaw; /* YAW on the first playing frame, and as the step left it */ + float lunge_x, lunge_z; /* the lunge's whole length, world axes, set on the first frame */ } mhfu_steer_state_t; /* No turn, no walls; a class-2 wall ends into (0,6) mode 1, as the Tigrex charge does. */ @@ -95,12 +113,18 @@ void mhfu_steer_init_spec(mhfu_steer_spec_t *s); /* The curve's YAW at clip frame `cursor`, the keys joined by straight lines and held past the * last; 0 without keys. Exact on the keys, where the engine's cursor sits at speed 2. */ uint16_t mhfu_steer_curve(const volatile mhfu_steer_spec_t *s, float cursor); +/* sin and cos of YAW `yaw`, within 1e-6. */ +void mhfu_steer_sincos(uint16_t yaw, float *s, float *c); /* One AI frame of a move (move.cpp's seam), `frame` its playing frames from 1: frame 1 takes * NEXT; a cursor that went back (a loop) carries the curve's turn over; YAW = base + curve, the * base turned by the spec's mode (HUNTER and AWAY only while `cursor`, the clip frame, is in - * the window). Returns the wall result of the frame before, from frame 2. */ + * the window; FIXED with a window across it by the cursor). Then POSITION moves by the change + * of the travel curve and of the lunge between the cursor of the frame before (frame 1: `start`, + * the clip frame the move began at) and this one: the curve's turned by YAW on frame 1 and + * scaled by ENTITY.SIZE_SCALE, the lunge along the bearing to `hunter` on frame 1, unscaled. + * Returns the wall result of the frame before, from frame 2, and then moves nothing. */ int mhfu_steer_move(void *entity, const mhfu_vec3_t *hunter, volatile mhfu_steer_state_t *st, - uint32_t frame, float cursor, float loop_start, float end); + uint32_t frame, float start, float cursor, float loop_start, float end); #ifdef __cplusplus } /* extern "C" */ diff --git a/framework/lua/lib/mhfu_port.lua b/framework/lua/lib/mhfu_port.lua index 00cc61e2..f0b38ade 100644 --- a/framework/lua/lib/mhfu_port.lua +++ b/framework/lua/lib/mhfu_port.lua @@ -242,7 +242,8 @@ P.is_own = is_own -- an own move (port:move) and takes the registry slot of its `slot` (the manifest's -- module numbers its own moves, which the brain splice's `enter` names), else the -- first free one by name. Its `steer` goes to the move player as it is (mhfu.MoveSteer: --- turn, rate, `window = { from, to }` the clip frames a hunter or away turn keeps to). +-- turn, rate, `window = { from, to }` the clip frames a turn keeps to or a fixed turn +-- spreads across, `travel` the move's own travel by code, `lunge` toward the hunter). -- Default: the manifest's (P.behaviour) -- rules port:rule{} specs installed with the port; default: the manifest's -- vars, signals name -> index of the board's counters and signals (port:var, port:fire); default: @@ -747,7 +748,8 @@ function Port:_arm_native() if n ~= nil then made = made + 1 if not mhfu.em_move(k, self:_seam_move(n)) then - log("[port:%s] own move '%s' not registered: no room for its turn keys", self.name, n) + log("[port:%s] own move '%s' not registered: no room for its turn keys, travel or lunge", + self.name, n) end end end diff --git a/framework/lua/meta/mhfu.d.lua b/framework/lua/meta/mhfu.d.lua index 81bf06dd..340ae971 100644 --- a/framework/lua/meta/mhfu.d.lua +++ b/framework/lua/meta/mhfu.d.lua @@ -669,12 +669,22 @@ function mhfu.move_block() end ---@field curve? string YAW while the clip plays: the entry's string in `_turns.lua` ---@field turn? "still"|"hunter"|"away"|"fixed" also: toward or away from the hunter at `rate`, or `total` over `frames` ---@field rate? integer YAW units an AI frame toward or away, default 64 (the Tigrex charge's) ----@field window? integer[] { from, to }: clip frames, 0 <= from < to, in which "hunter" and "away" turn (the donor turns inside a window); default the whole move +---@field window? integer[] { from, to }: clip frames, 0 <= from < to, in which "hunter" and "away" turn (the donor turns inside a window), or across which "fixed" spreads `total` by the clip cursor; default the whole move ---@field total? number degrees for "fixed", positive the way YAW grows ----@field frames? integer AI frames "fixed" spreads `total` over +---@field frames? integer AI frames "fixed" spreads `total` over, without a `window` ---@field walls? boolean a wall ahead ends the move; a class-2 one enters `stuck` ---@field dir? number degrees of the travel against YAW, which sectors count as ahead; default 0 ---@field stuck? integer[] { main, sub, mode } entered on a class-2 wall, default { 0, 6, 1 } +---@field travel? string the move's own travel by code, as the port's moves module writes it: 8 hex digits a sample, x then z as 16 bits each in 1/mhfu.addr.STEER_TRAVEL_SCALE world units, cumulative, every mhfu.addr.STEER_KEY_STEP clip frames from the move's `start`, at most mhfu.addr.STEER_SAMPLES; each AI frame moves the monster by the curve's change since the cursor's last frame, in its frame at the first playing frame (x its left, z forward), scaled by its size, held past the last sample +---@field lunge? mhfu.MoveLunge travel toward the hunter, by the hunter's distance when the move begins + +---A move's lunge (mhfu.MoveSteer.lunge): over the clip frames `window` (lo, hi] the monster moves 0 units while the hunter's XZ distance `d` is under `near`, else `base + range * min((d - near) / span, 1)` along the bearing to the hunter at the first playing frame, in world axes, spread by the clip cursor and not scaled by the size. +---@class mhfu.MoveLunge +---@field window integer[] { lo, hi }: clip frames, 0 <= lo < hi +---@field near? number default 0 +---@field span? number default 0, which counts as the whole range +---@field base? number default 0, negative away from the hunter +---@field range? number default 0 ---What `mhfu.move_status()` returns. ---@class mhfu.MoveStatus @@ -713,7 +723,8 @@ function mhfu.move_block() end -- it ends and names it (`after`), or when a rule says (EmRule.play_move). ---Own move slot 0..mhfu.addr.EM_MOVES.MOVES_COUNT-1 plays `spec`; `spec.after` is the slot played when it ends on its clip, its ----length, its stop or a wall. False for a bad slot or no room left for its turn keys. +---length, its stop or a wall. False for a bad slot or no room left for its turn keys, travel +---samples or lunge. ---@param slot integer ---@param spec mhfu.OwnMove ---@return boolean @@ -751,6 +762,8 @@ function mhfu.em_moves_status() end ---@field plays integer own moves started ---@field chained integer of them by `after` ---@field keys integer turn keys in use +---@field samples integer travel samples in use +---@field lunges integer lunges in use -- fatigue (bind_em.cpp, include/mhfu/fatigue.h) --------------------------------------------------- -- MHFU has no fatigue and its AI script cannot count. A stamina for the wrapped species' monster is diff --git a/framework/mods/lua_host/bind_em.cpp b/framework/mods/lua_host/bind_em.cpp index 0fe42256..0e033722 100644 --- a/framework/mods/lua_host/bind_em.cpp +++ b/framework/mods/lua_host/bind_em.cpp @@ -275,6 +275,8 @@ int lb_em_moves_status(lua_State *L) SF_INT("plays", r->plays); SF_INT("chained", r->chained); SF_INT("keys", r->key_top); + SF_INT("samples", r->sample_top); + SF_INT("lunges", r->lunge_top); #undef SF_INT return 1; } diff --git a/framework/mods/lua_host/bind_move.cpp b/framework/mods/lua_host/bind_move.cpp index 5b8c3c6a..342f8707 100644 --- a/framework/mods/lua_host/bind_move.cpp +++ b/framework/mods/lua_host/bind_move.cpp @@ -77,10 +77,24 @@ static int hex(int c) return c >= '0' && c <= '9' ? c - '0' : c >= 'a' && c <= 'f' ? c - 'a' + 10 : -1; } +/* the 16 bits in the 4 hex digits at c */ +static int hex16(lua_State *L, const char *c) +{ + int v = 0; + for (int j = 0; j < 4; j++) { + int d = hex(c[j]); + luaL_argcheck(L, d >= 0, 2, "steer.curve and steer.travel are hex digits"); + v = v * 16 + d; + } + return v; +} + /* t.steer (mhfu/steer.h): turn = "still" | "hunter" | "away" | "fixed", rate (YAW units a - * frame), window = { from, to } (clip frames the hunter and away turns apply in), total - * (degrees) over frames, dir (degrees), walls, stuck = { main, sub, mode }, curve = the entry's - * string in the port's turns module */ + * frame), window = { from, to } (clip frames the hunter and away turns apply in, a fixed turn + * spreads across), total (degrees) over frames, dir (degrees), walls, stuck = { main, sub, mode }, + * curve = the entry's string in the port's turns module, travel = the move's own travel as the + * port's moves module writes it (8 hex digits a sample: x then z, 16 bits each, in + * 1/STEER_TRAVEL_SCALE units), lunge = { window = { lo, hi }, near, span, base, range } */ static void steer_of(lua_State *L, int t, mhfu_steer_spec_t *sp) { mhfu_steer_init_spec(sp); @@ -122,14 +136,38 @@ static void steer_of(lua_State *L, int t, mhfu_steer_spec_t *sp) size_t n = 0; lua_getfield(L, s, "curve"); const char *c = lua_isstring(L, -1) ? lua_tolstring(L, -1, &n) : NULL; - for (size_t i = 0; c && i + 4 <= n && sp->key_count < MHFU_STEER_KEYS; i += 4) { - int v = 0; - for (size_t j = 0; j < 4; j++) { - int d = hex(c[i + j]); - luaL_argcheck(L, d >= 0, 2, "steer.curve is hex digits, 4 a key"); - v = v * 16 + d; - } - sp->keys[sp->key_count++] = (uint16_t)v; + for (size_t i = 0; c && i + 4 <= n && sp->key_count < MHFU_STEER_KEYS; i += 4) + sp->keys[sp->key_count++] = (uint16_t)hex16(L, c + i); + lua_pop(L, 1); + lua_getfield(L, s, "travel"); + c = lua_isstring(L, -1) ? lua_tolstring(L, -1, &n) : NULL; + luaL_argcheck(L, !c || (n % 8 == 0 && n / 8 <= MHFU_STEER_SAMPLES), 2, + "steer.travel is up to STEER_SAMPLES samples of 8 hex digits"); + for (size_t i = 0; c && i < n; i += 8, sp->sample_count++) { + sp->samples[sp->sample_count].x = (int16_t)hex16(L, c + i); + sp->samples[sp->sample_count].z = (int16_t)hex16(L, c + i + 4); + } + lua_pop(L, 1); + if (lua_getfield(L, s, "lunge") == LUA_TTABLE) { + int l = lua_gettop(L); + luaL_argcheck(L, lua_getfield(L, l, "window") == LUA_TTABLE, 2, + "steer.lunge needs a window = { lo, hi }"); + int w = lua_gettop(L); + int lo = list_at(L, w, 1, 0), hi = list_at(L, w, 2, 0); + luaL_argcheck(L, lo >= 0 && lo < hi && hi <= 0xFFFF, 2, + "steer.lunge.window is { lo, hi } with 0 <= lo < hi"); + sp->lunge.lo = (uint16_t)lo; + sp->lunge.hi = (uint16_t)hi; + lua_pop(L, 1); + lua_getfield(L, l, "near"); + sp->lunge.near = (float)luaL_optnumber(L, -1, 0); + lua_getfield(L, l, "span"); + sp->lunge.span = (float)luaL_optnumber(L, -1, 0); + lua_getfield(L, l, "base"); + sp->lunge.base = (float)luaL_optnumber(L, -1, 0); + lua_getfield(L, l, "range"); + sp->lunge.range = (float)luaL_optnumber(L, -1, 0); + lua_pop(L, 4); } lua_pop(L, 2); } diff --git a/framework/src/core/em_vhook.cpp b/framework/src/core/em_vhook.cpp index 64a5dd24..4f4424a5 100644 --- a/framework/src/core/em_vhook.cpp +++ b/framework/src/core/em_vhook.cpp @@ -307,20 +307,31 @@ static_assert(sizeof(mhfu_em_own_t) == MHFU_EM_OWN_SIZE, "EM_OWN layout"); static_assert(offsetof(mhfu_em_own_t, steer) == MHFU_EM_OWN_STEER, "EM_OWN layout"); static_assert(offsetof(mhfu_em_own_t, key_at) == MHFU_EM_OWN_KEY_AT, "EM_OWN layout"); static_assert(offsetof(mhfu_em_own_t, key_count) == MHFU_EM_OWN_KEY_COUNT, "EM_OWN layout"); +static_assert(offsetof(mhfu_em_own_t, sample_at) == MHFU_EM_OWN_SAMPLE_AT, "EM_OWN layout"); +static_assert(offsetof(mhfu_em_own_t, sample_count) == MHFU_EM_OWN_SAMPLE_COUNT, "EM_OWN layout"); static_assert(offsetof(mhfu_em_own_t, stuck_main) == MHFU_EM_OWN_STUCK_MAIN, "EM_OWN layout"); static_assert(offsetof(mhfu_em_own_t, after) == MHFU_EM_OWN_AFTER, "EM_OWN layout"); +static_assert(offsetof(mhfu_em_own_t, lunge_at) == MHFU_EM_OWN_LUNGE_AT, "EM_OWN layout"); static_assert(offsetof(mhfu_em_own_t, valid) == MHFU_EM_OWN_VALID, "EM_OWN layout"); static_assert(sizeof(mhfu_em_moves_t) == MHFU_EM_MOVES_SIZE, "EM_MOVES layout"); static_assert(offsetof(mhfu_em_moves_t, req_ent) == MHFU_EM_MOVES_REQ_ENT, "EM_MOVES layout"); static_assert(offsetof(mhfu_em_moves_t, tag_slot) == MHFU_EM_MOVES_TAG_SLOT, "EM_MOVES layout"); static_assert(offsetof(mhfu_em_moves_t, key_top) == MHFU_EM_MOVES_KEY_TOP, "EM_MOVES layout"); +static_assert(offsetof(mhfu_em_moves_t, sample_top) == MHFU_EM_MOVES_SAMPLE_TOP, "EM_MOVES layout"); +static_assert(offsetof(mhfu_em_moves_t, lunge_top) == MHFU_EM_MOVES_LUNGE_TOP, "EM_MOVES layout"); +static_assert(offsetof(mhfu_em_moves_t, req_force) == MHFU_EM_MOVES_REQ_FORCE, "EM_MOVES layout"); static_assert(offsetof(mhfu_em_moves_t, moves) == MHFU_EM_MOVES_MOVES, "EM_MOVES layout"); static_assert(offsetof(mhfu_em_moves_t, keys) == MHFU_EM_MOVES_KEYS, "EM_MOVES layout"); +static_assert(offsetof(mhfu_em_moves_t, samples) == MHFU_EM_MOVES_SAMPLES, "EM_MOVES layout"); +static_assert(offsetof(mhfu_em_moves_t, lunges) == MHFU_EM_MOVES_LUNGES, "EM_MOVES layout"); static_assert(offsetof(mhfu_em_moves_t, scratch) == MHFU_EM_MOVES_SCRATCH, "EM_MOVES layout"); -static_assert(offsetof(mhfu_em_moves_t, req_force) == MHFU_EM_MOVES_REQ_FORCE, "EM_MOVES layout"); /* the act stub reaches them by 16-bit offsets from the registry and from a slot */ static_assert(MHFU_EM_MOVES_REQ_FORCE < 0x8000 && MHFU_EM_MOVES_MOVES + MHFU_EM_OWN_VALID < 0x8000, "EM_MOVES offsets past a 16-bit immediate"); +/* a slot's pool indices are 16 bits, its lunge's 8 */ +static_assert(MHFU_EM_KEYS <= 0xFFFF && MHFU_EM_SAMPLES <= 0xFFFF && MHFU_EM_LUNGES < 0xFF, + "EM_MOVES pools past an index"); +static_assert(MHFU_OWN_MOVES < 0xFF, "EM_MOVES slots past a byte, 0xFF being none"); static void cfg_reset_live(void) { @@ -481,7 +492,7 @@ static int ours(const volatile mhfu_move_state_t *m) return (int)R->tag_slot; } -/* slot's steering in the registry's scratch, keys and all */ +/* slot's steering in the registry's scratch, keys, travel samples and lunge and all */ static const volatile mhfu_steer_spec_t *steer_of(const volatile mhfu_em_own_t *o) { volatile mhfu_steer_spec_t *sp = &R->scratch; @@ -492,6 +503,12 @@ static const volatile mhfu_steer_spec_t *steer_of(const volatile mhfu_em_own_t * sp->stuck_mode = o->stuck_mode; for (unsigned k = 0; k < o->key_count && k < MHFU_STEER_KEYS; k++) sp->keys[k] = R->keys[o->key_at + k]; + unsigned n = o->sample_count < MHFU_STEER_SAMPLES ? o->sample_count : MHFU_STEER_SAMPLES; + sp->sample_count = (uint16_t)n; + if (n) copy_words(sp->samples, &R->samples[o->sample_at], n * sizeof(mhfu_travel_sample_t)); + sp->lunge.hi = 0; + if (o->lunge_at != MHFU_EM_NO_LUNGE) + copy_words(&sp->lunge, &R->lunges[o->lunge_at], sizeof(mhfu_steer_lunge_t)); return sp; } @@ -1518,22 +1535,36 @@ extern "C" void mhfu_em_moves_clear(void) if (!R) return; R->req_ent = 0; for (int i = 0; i < MHFU_EM_MOVES; i++) R->moves[i].valid = 0; - R->key_top = 0; + R->key_top = R->sample_top = R->lunge_top = 0; } -/* A slot is rewritten off (VALID 0) and its keys appended: a brain that reads it meanwhile sees - * it empty, and keys a rewrite leaves behind wait for mhfu_em_moves_clear. */ +/* A slot is rewritten off (VALID 0) and its keys and samples appended: a brain that reads it + * meanwhile sees it empty, and those a rewrite leaves behind wait for mhfu_em_moves_clear. A + * lunge keeps its place when the slot had one. */ extern "C" int mhfu_em_move(int slot, const mhfu_move_t *mv, const mhfu_steer_spec_t *s, uint8_t after) { if (!R || !mv || !s || slot < 0 || slot >= MHFU_EM_MOVES) return 0; uint32_t n = s->key_count > MHFU_STEER_KEYS ? MHFU_STEER_KEYS : s->key_count; + uint32_t m = s->sample_count > MHFU_STEER_SAMPLES ? MHFU_STEER_SAMPLES : s->sample_count; + volatile mhfu_em_own_t *o = &R->moves[slot]; + const bool lunge = s->lunge.hi != 0; + const bool held = o->valid && o->lunge_at < R->lunge_top; + const uint8_t at = held ? o->lunge_at : (uint8_t)R->lunge_top; if (R->key_top + n > MHFU_EM_KEYS) { mhfu_log("[%s] own move %d: %u turn keys, %u left", OWNER, slot, (unsigned)n, (unsigned)(MHFU_EM_KEYS - R->key_top)); return 0; } - volatile mhfu_em_own_t *o = &R->moves[slot]; + if (R->sample_top + m > MHFU_EM_SAMPLES) { + mhfu_log("[%s] own move %d: %u travel samples, %u left", OWNER, slot, (unsigned)m, + (unsigned)(MHFU_EM_SAMPLES - R->sample_top)); + return 0; + } + if (lunge && !held && R->lunge_top >= MHFU_EM_LUNGES) { + mhfu_log("[%s] own move %d: no room for a lunge (%d)", OWNER, slot, MHFU_EM_LUNGES); + return 0; + } o->valid = 0; copy_words(&o->move, mv, sizeof(*mv)); /* the carrier again at the end, so AFTER follows before the host brain picks; the hub it @@ -1549,6 +1580,15 @@ extern "C" int mhfu_em_move(int slot, const mhfu_move_t *mv, const mhfu_steer_sp o->key_count = (uint16_t)n; for (uint32_t k = 0; k < n; k++) R->keys[R->key_top + k] = s->keys[k]; R->key_top += n; + o->sample_at = (uint16_t)R->sample_top; + o->sample_count = (uint16_t)m; + if (m) copy_words(&R->samples[R->sample_top], s->samples, m * sizeof(mhfu_travel_sample_t)); + R->sample_top += m; + if (lunge) { + copy_words(&R->lunges[at], &s->lunge, sizeof(s->lunge)); + if (!held) R->lunge_top++; + } + o->lunge_at = lunge ? at : MHFU_EM_NO_LUNGE; o->stuck_main = s->stuck_main; o->stuck_sub = s->stuck_sub; o->stuck_mode = s->stuck_mode; diff --git a/framework/src/core/move.cpp b/framework/src/core/move.cpp index 8d8f82c4..c8d34815 100644 --- a/framework/src/core/move.cpp +++ b/framework/src/core/move.cpp @@ -89,10 +89,22 @@ static_assert(sizeof(mhfu_steer_t) == MHFU_STEER_PARAMS_SIZE, "STEER_PARAMS layo static_assert(offsetof(mhfu_steer_t, dir) == MHFU_STEER_PARAMS_DIR, "STEER_PARAMS layout"); static_assert(offsetof(mhfu_steer_t, win_from) == MHFU_STEER_PARAMS_WIN_FROM, "STEER_PARAMS layout"); static_assert(offsetof(mhfu_steer_t, win_to) == MHFU_STEER_PARAMS_WIN_TO, "STEER_PARAMS layout"); +static_assert(sizeof(mhfu_travel_sample_t) == MHFU_TRAVEL_SAMPLE_SIZE, "TRAVEL_SAMPLE layout"); +static_assert(offsetof(mhfu_travel_sample_t, z) == MHFU_TRAVEL_SAMPLE_Z, "TRAVEL_SAMPLE layout"); +static_assert(sizeof(mhfu_steer_lunge_t) == MHFU_STEER_LUNGE_SIZE, "STEER_LUNGE layout"); +static_assert(offsetof(mhfu_steer_lunge_t, hi) == MHFU_STEER_LUNGE_HI, "STEER_LUNGE layout"); +static_assert(offsetof(mhfu_steer_lunge_t, near) == MHFU_STEER_LUNGE_NEAR, "STEER_LUNGE layout"); +static_assert(offsetof(mhfu_steer_lunge_t, span) == MHFU_STEER_LUNGE_SPAN, "STEER_LUNGE layout"); +static_assert(offsetof(mhfu_steer_lunge_t, base) == MHFU_STEER_LUNGE_BASE, "STEER_LUNGE layout"); +static_assert(offsetof(mhfu_steer_lunge_t, range) == MHFU_STEER_LUNGE_RANGE, "STEER_LUNGE layout"); static_assert(sizeof(mhfu_steer_spec_t) == MHFU_STEER_SPEC_SIZE, "STEER_SPEC layout"); static_assert(offsetof(mhfu_steer_spec_t, key_count) == MHFU_STEER_SPEC_KEY_COUNT, "STEER_SPEC layout"); static_assert(offsetof(mhfu_steer_spec_t, stuck_main) == MHFU_STEER_SPEC_STUCK_MAIN, "STEER_SPEC layout"); static_assert(offsetof(mhfu_steer_spec_t, keys) == MHFU_STEER_SPEC_KEYS, "STEER_SPEC layout"); +static_assert(offsetof(mhfu_steer_spec_t, sample_count) == MHFU_STEER_SPEC_SAMPLE_COUNT, + "STEER_SPEC layout"); +static_assert(offsetof(mhfu_steer_spec_t, lunge) == MHFU_STEER_SPEC_LUNGE, "STEER_SPEC layout"); +static_assert(offsetof(mhfu_steer_spec_t, samples) == MHFU_STEER_SPEC_SAMPLES, "STEER_SPEC layout"); static_assert(sizeof(mhfu_steer_state_t) == MHFU_STEER_STATE_SIZE, "STEER_STATE layout"); static_assert(offsetof(mhfu_steer_state_t, now) == MHFU_STEER_STATE_NOW, "STEER_STATE layout"); static_assert(offsetof(mhfu_steer_state_t, base) == MHFU_STEER_STATE_BASE, "STEER_STATE layout"); @@ -100,6 +112,8 @@ static_assert(offsetof(mhfu_steer_state_t, yaw0) == MHFU_STEER_STATE_YAW0, "STEE static_assert(offsetof(mhfu_steer_state_t, wall) == MHFU_STEER_STATE_WALL, "STEER_STATE layout"); static_assert(offsetof(mhfu_steer_state_t, cursor) == MHFU_STEER_STATE_CURSOR, "STEER_STATE layout"); static_assert(offsetof(mhfu_steer_state_t, yaw) == MHFU_STEER_STATE_YAW, "STEER_STATE layout"); +static_assert(offsetof(mhfu_steer_state_t, lunge_x) == MHFU_STEER_STATE_LUNGE_X, "STEER_STATE layout"); +static_assert(offsetof(mhfu_steer_state_t, lunge_z) == MHFU_STEER_STATE_LUNGE_Z, "STEER_STATE layout"); /* --- the engine ------------------------------------------------------------------------- */ @@ -160,8 +174,10 @@ static uint32_t carrier(void) return ((uint32_t)S->move.carrier_main << 8) | S->move.carrier_sub; } -/* YAW along the clip's turn and the spec's mode; the wall result (mhfu/steer.h). Part 0 holds - * the root, whose motion the turn's keys go with. */ +/* YAW along the clip's turn and the spec's mode, POSITION along its travel; the wall result + * (mhfu/steer.h). Part 0 holds the root, whose motion the turn's keys go with. This runs before + * ROOT_MOTION, WALL_COLLIDE and GROUND_QUERY in the same AI frame, so a position written here is + * pushed out of walls and set on the floor as the root motion is. */ static int steer(uint32_t ent) { uint32_t b = clip_block(ent, 0); @@ -169,7 +185,7 @@ static int steer(uint32_t ent) if (!e) return MHFU_STEER_GO; const mhfu_vec3_t *hunter = (const mhfu_vec3_t *)direct(MHFU_PLAYER_ENTITY + MHFU_ENTITY_POSITION); - return mhfu_steer_move(e, hunter, &S->steer, S->frames, + return mhfu_steer_move(e, hunter, &S->steer, S->frames, (float)S->move.start, mhfu_mem_read_f32(b + MHFU_CLIP_BLOCK_PHASE), mhfu_mem_read_f32(b + MHFU_CLIP_BLOCK_LOOP_START), mhfu_mem_read_f32(b + MHFU_CLIP_BLOCK_END)); diff --git a/framework/src/core/steer.cpp b/framework/src/core/steer.cpp index 15b8179e..9b5f9645 100644 --- a/framework/src/core/steer.cpp +++ b/framework/src/core/steer.cpp @@ -82,6 +82,53 @@ INLINE uint16_t curve(const volatile mhfu_steer_spec_t *s, float cursor, float e return (uint16_t)(s->keys[i] + (int32_t)(d < 0 ? d - 0.5f : d + 0.5f)); } +/* sin and cos of YAW `yaw`: the quadrant by integer steps, the rest by series on [-pi/4, pi/4) */ +INLINE void yaw_sincos(uint16_t yaw, float *s, float *c) +{ + const float RAD = 6.28318531f / MHFU_STEER_TURN; + uint32_t q = (((uint32_t)yaw + 0x2000u) >> 14) & 3u; + float r = (float)(int16_t)(uint16_t)(yaw - (q << 14)) * RAD, r2 = r * r; + float sn = r * (1 + r2 * (-1.0f / 6 + r2 * (1.0f / 120 + r2 * (-1.0f / 5040 + r2 / 362880)))); + float cs = 1 + r2 * (-0.5f + r2 * (1.0f / 24 + r2 * (-1.0f / 720 + r2 / 40320))); + *s = q == 0 ? sn : q == 1 ? cs : q == 2 ? -sn : -cs; + *c = q == 0 ? cs : q == 1 ? -sn : q == 2 ? -cs : sn; +} + +/* the travel curve at clip frame `at`, `start` the frame of its first sample: the samples joined + * by straight lines, held outside them; (0, 0) without any */ +INLINE void path(const volatile mhfu_steer_spec_t *s, float start, float at, float *x, float *z) +{ + const float UNIT = 1.0f / MHFU_STEER_TRAVEL_SCALE; + uint32_t n = s->sample_count < MHFU_STEER_SAMPLES ? s->sample_count : MHFU_STEER_SAMPLES; + *x = *z = 0; + if (!n) return; + float u = (at - start) / MHFU_STEER_KEY_STEP; + uint32_t i = 0; + float t = 0; + if (u >= (float)(n - 1)) { + i = n - 1; + } else if (u > 0) { + i = (uint32_t)u; + t = u - (float)i; + } + float x0 = s->samples[i].x, z0 = s->samples[i].z; + if (t > 0) { + x0 += ((float)s->samples[i + 1].x - x0) * t; + z0 += ((float)s->samples[i + 1].z - z0) * t; + } + *x = x0 * UNIT; + *z = z0 * UNIT; +} + +/* how much of a spread across the clip frames (lo, hi] the cursor has covered at `at`: 0 to 1 */ +INLINE float ramp(uint16_t lo, uint16_t hi, float at) +{ + if (hi <= lo || at >= (float)hi) return 1; + return at <= (float)lo ? 0 : (at - (float)lo) / (float)(hi - lo); +} + +INLINE int32_t round_to(float v) { return (int32_t)(v < 0 ? v - 0.5f : v + 0.5f); } + extern "C" { uint16_t mhfu_steer_bearing(float dx, float dz) { return bearing(dx, dz); } @@ -98,6 +145,8 @@ int32_t mhfu_steer_share(int32_t total, uint16_t frames, uint16_t frame) uint32_t mhfu_steer_ahead(uint16_t dir) { return ahead(dir); } +void mhfu_steer_sincos(uint16_t yaw, float *s, float *c) { yaw_sincos(yaw, s, c); } + int mhfu_steer_wall(uint32_t sectors, uint8_t stuck, uint16_t dir) { return wall(sectors, stuck, dir); @@ -140,11 +189,14 @@ uint16_t mhfu_steer_curve(const volatile mhfu_steer_spec_t *s, float cursor) } int mhfu_steer_move(void *entity, const mhfu_vec3_t *hunter, volatile mhfu_steer_state_t *st, - uint32_t frame, float cursor, float loop_start, float end) + uint32_t frame, float start, float cursor, float loop_start, float end) { volatile uint8_t *e = (volatile uint8_t *)entity; volatile uint16_t *yaw = (volatile uint16_t *)(e + MHFU_ENTITY_YAW); + volatile float *pos = (volatile float *)(e + MHFU_ENTITY_POSITION); volatile mhfu_steer_spec_t *sp = &st->now; + float before = st->cursor; + bool loop = false; if (frame <= 1) { volatile uint32_t *to = (volatile uint32_t *)&st->now; volatile uint32_t *from = (volatile uint32_t *)&st->next; @@ -152,6 +204,18 @@ int mhfu_steer_move(void *entity, const mhfu_vec3_t *hunter, volatile mhfu_steer st->yaw0 = *yaw; st->base = (uint16_t)(*yaw - curve(sp, cursor, end)); st->wall = MHFU_STEER_GO; + before = start; + st->lunge_x = st->lunge_z = 0; + if (sp->lunge.hi && hunter) { + float dx = hunter->x - pos[0], dz = hunter->z - pos[2]; + float d = __builtin_sqrtf(dx * dx + dz * dz); + if (d > 0 && d >= sp->lunge.near) { + float t = sp->lunge.span > 0 ? (d - sp->lunge.near) / sp->lunge.span : 1; + float length = sp->lunge.base + sp->lunge.range * (t < 1 ? t : 1); + st->lunge_x = length * dx / d; + st->lunge_z = length * dz / d; + } + } } else { if (sp->steer.walls) { int w = wall(*(volatile uint32_t *)(e + MHFU_ENTITY_WALL_SECTORS), @@ -161,24 +225,64 @@ int mhfu_steer_move(void *entity, const mhfu_vec3_t *hunter, volatile mhfu_steer return w; } } - if (cursor < st->cursor) /* a loop: the turn of the frames it skipped back over stays */ + loop = cursor < before; + if (loop) /* the turn of the frames it skipped back over stays */ st->base = (uint16_t)(st->base + curve(sp, end, end) - curve(sp, loop_start, end)); } st->cursor = cursor; uint8_t mode = sp->steer.turn; if ((mode == MHFU_STEER_HUNTER || mode == MHFU_STEER_AWAY) && hunter && in_window(sp->steer.win_from, sp->steer.win_to, cursor)) { - volatile float *pos = (volatile float *)(e + MHFU_ENTITY_POSITION); uint16_t want = bearing(hunter->x - pos[0], hunter->z - pos[2]); if (mode == MHFU_STEER_AWAY) want = (uint16_t)(want + MHFU_STEER_TURN / 2); uint16_t now = *yaw; st->base = (uint16_t)(st->base + (uint16_t)(toward(now, want, sp->steer.rate) - now)); + } else if (mode == MHFU_STEER_FIXED && sp->steer.win_to) { + uint16_t lo = sp->steer.win_from, hi = sp->steer.win_to; + float total = (float)sp->steer.total; + if (hi > lo && sp->steer.total < MHFU_STEER_TURN && sp->steer.total > -MHFU_STEER_TURN) { + int32_t turned = round_to(total * ramp(lo, hi, cursor)) + - round_to(total * ramp(lo, hi, before)); + if (loop) + turned += round_to(total * ramp(lo, hi, end)) + - round_to(total * ramp(lo, hi, loop_start)); + st->base = (uint16_t)(st->base + turned); + } } else if (mode == MHFU_STEER_FIXED && frame >= 1) { st->base = (uint16_t)(st->base + share(sp->steer.total, sp->steer.frames, (uint16_t)(frame - 1))); } *yaw = (uint16_t)(st->base + curve(sp, cursor, end)); st->yaw = *yaw; + + float mx = 0, mz = 0; + if (sp->sample_count) { + float x0, z0, x1, z1, s, c; + path(sp, start, before, &x0, &z0); + path(sp, start, cursor, &x1, &z1); + float lx = x1 - x0, lz = z1 - z0; + if (loop) { + path(sp, start, loop_start, &x0, &z0); + path(sp, start, end, &x1, &z1); + lx += x1 - x0; + lz += z1 - z0; + } + yaw_sincos(st->yaw0, &s, &c); + float size = *(volatile float *)(e + MHFU_ENTITY_SIZE_SCALE); + mx = (lx * c + lz * s) * size; + mz = (lz * c - lx * s) * size; + } + if (sp->lunge.hi) { + uint16_t lo = sp->lunge.lo, hi = sp->lunge.hi; + float f = ramp(lo, hi, cursor) - ramp(lo, hi, before); + if (loop) f += ramp(lo, hi, end) - ramp(lo, hi, loop_start); + mx += st->lunge_x * f; + mz += st->lunge_z * f; + } + if (mx != 0 || mz != 0) { + pos[0] += mx; + pos[2] += mz; + } return MHFU_STEER_GO; } diff --git a/framework/tests/move_host.cpp b/framework/tests/move_host.cpp index c034624e..87c87d28 100644 --- a/framework/tests/move_host.cpp +++ b/framework/tests/move_host.cpp @@ -14,6 +14,7 @@ #define SIZE 0x4000u static uint8_t g_mem[SIZE]; +static uint8_t g_player[0x300]; /* the hunter, at MHFU_PLAYER_ENTITY */ static mhfu_em_step_fn g_step; static uint32_t g_calls[64][5]; static int g_n; @@ -28,6 +29,8 @@ static uint8_t *at(uint32_t a, uint32_t n) { if (a >= MHFU_BREAK_TABLE && a + n <= MHFU_BREAK_TABLE + sizeof(g_breaks)) return g_breaks + (a - MHFU_BREAK_TABLE); + if (a >= MHFU_PLAYER_ENTITY && a + n <= MHFU_PLAYER_ENTITY + sizeof(g_player)) + return g_player + (a - MHFU_PLAYER_ENTITY); return (a >= BASE && a + n <= BASE + SIZE) ? g_mem + (a - BASE) : 0; } @@ -150,6 +153,7 @@ void host_break_row(int i, uint8_t species, uint8_t part, uint8_t count, uint16_ memcpy(r + MHFU_BREAK_ROW_BREAK_ID, &id, 2); } uint8_t *host_mem(void) { return g_mem; } +uint8_t *host_hunter(void) { return g_player; } void host_set(int lands, float clip_end) { memset(g_breaks, 0, sizeof(g_breaks)); diff --git a/framework/tests/test_em_brain.py b/framework/tests/test_em_brain.py index dd784e13..957abb19 100644 --- a/framework/tests/test_em_brain.py +++ b/framework/tests/test_em_brain.py @@ -9,10 +9,11 @@ import ctypes import math import struct -from collections.abc import Callable +from collections.abc import Callable, Sequence import pytest from mhfu import addresses as a +from mhfu import steering BASE = 0x09000000 # em_host.cpp's memory; noaddr ENT = BASE + 0x1000 @@ -224,9 +225,12 @@ def own( *, turn: int = 0, window: tuple[int, int] = (0, 0), + samples: Sequence[tuple[int, int]] = (), + lunge: tuple[int, int, float, float, float, float] | None = None, **kw: int, ) -> int: - """Own move `slot`; `turn` and `window` are its steer's (a hunter turn at rate 64).""" + """Own move `slot`; `turn` and `window` are its steer's (a hunter turn at rate 64), + `samples` its travel in sample units and `lunge` (lo, hi, near, span, base, range).""" mv = Move(entry=entry, carrier_main=0, carrier_sub=2, back_main=NO) for k, v in kw.items(): setattr(mv, k, v) @@ -236,8 +240,23 @@ def own( p = a.STEER_SPEC.STEER struct.pack_into(" int: + """A field of the registry's own move `slot`.""" + base = int(self.lib.mhfu_em_moves()) + a.EM_MOVES.MOVES + slot * int(a.EM_OWN.size or 0) + size = struct.calcsize("<" + fmt) + return int( + struct.unpack_from("<" + fmt, ctypes.string_at(base + getattr(a.EM_OWN, field), size))[ + 0 + ] + ) + def flinch(self, parts: int, sub: int = 1) -> None: """A host step whose REACTION_CHECK flinched `parts` and entered (4, sub); the next AI frame, after which the mask is cleared again.""" @@ -586,6 +605,55 @@ def test_keys_have_room(g: Game) -> None: assert g.own(full, 46, keys=256) and g.moves("KEY_TOP") == 256 +def played_to_the_end(g: Game, slot: int) -> tuple[float, float]: + """Where the monster stands after own move `slot` ran its clip out, from the origin.""" + g.poke(a.ENTITY.POSITION, "fff", 0.0, 0.0, 0.0) + g.poke(a.ENTITY.SIZE_SCALE, "f", 1.0) + assert g.lib.mhfu_em_play(ENT, slot, 1) + g.frames(2 + int(CLIP_END / SPEED) + 2) + x, _, z = g.peek(a.ENTITY.POSITION, "fff") + return x, z + + +def test_the_registry_hands_a_slots_travel_and_lunge_to_the_move_player(g: Game) -> None: + """Each slot plays its own samples out of the shared pool, and the lunge only the slot that + has one: the sizes and offsets come from the table both sides read.""" + g.hunter_at(0.0, 0.0) + struct.pack_into(" None: + full = a.EM_MOVES.SAMPLES.count // a.STEER_SPEC.SAMPLES.count + for slot in range(full): + assert g.own(slot, 46, samples=[(k, k) for k in range(a.STEER_SPEC.SAMPLES.count)]) + assert not g.own(full, 46, samples=[(0, 0)]) and g.own(full, 46) + g.lib.mhfu_em_moves_clear() + assert g.moves("SAMPLE_TOP") == 0 + assert g.own(full, 46, samples=[(0, 0)] * 3) and g.moves("SAMPLE_TOP") == 3 + + +def test_lunges_have_room_and_a_rewrite_keeps_its_place(g: Game) -> None: + lunge = (0, 10, 400.0, 1000.0, 0.0, 500.0) + for slot in range(a.EM_LUNGES): + assert g.own(slot, 46, lunge=lunge) + assert not g.own(a.EM_LUNGES, 46, lunge=lunge) and g.moves("LUNGE_TOP") == a.EM_LUNGES + assert g.own(3, 46, lunge=(0, 20, 1.0, 2.0, 3.0, 4.0)) # in place + assert g.moves("LUNGE_TOP") == a.EM_LUNGES and g.slot(3, "LUNGE_AT") == 3 + assert g.own(3, 46) and g.slot(3, "LUNGE_AT") == NO + g.lib.mhfu_em_moves_clear() + assert g.moves("LUNGE_TOP") == 0 and g.own(a.EM_LUNGES, 46, lunge=lunge) + + def test_a_rule_on_an_event_fires_in_the_frame_it_is_seen(g: Game) -> None: g.own(0, 46) g.rule(0, on=NOTICED, play_move=1, force=1) diff --git a/framework/tests/test_mhfu_port.py b/framework/tests/test_mhfu_port.py index e72ce246..8f6d073e 100644 --- a/framework/tests/test_mhfu_port.py +++ b/framework/tests/test_mhfu_port.py @@ -618,6 +618,38 @@ def test_steer_window_reaches_the_registry(lua: Any) -> None: lua.execute(STEER_WINDOW) +STEER_TRAVEL = """ +local SEAM = { moves = {} } +function mhfu.em_installed() return true end +function mhfu.em_move(slot, t) SEAM.moves[slot] = t; return true end +function mhfu.em_moves_clear() SEAM.moves = {}; return true end +local P = require("mhfu_port") + +-- a moves module's travel, lunge and fixed turn window reach the registry as they are +package.loaded.z_moves = { + moves = { + jump = { entry = 20, slot = 0, steer = { + turn = "fixed", total = 90, window = { 10, 40 }, travel = "0000000000040010", + lunge = { window = { 0, 32 }, near = 400, span = 1800, base = -700, range = 1800 }, + } }, + }, + rules = {}, +} +local z = P.define{ name = "z", species = 75 } +z.ent = ENT +ticks(1) +local s = SEAM.moves[0].steer +assert(s.turn == "fixed" and s.total == 90 and s.window[1] == 10 and s.window[2] == 40) +assert(s.travel == "0000000000040010") +assert(s.lunge.window[2] == 32 and s.lunge.near == 400 and s.lunge.base == -700) +""" + + +def test_steer_travel_reaches_the_registry(lua: Any) -> None: + memory(lua) + lua.execute(STEER_TRAVEL) + + FATIGUE = """ local SEAM = { moves = {}, fatigue = "unset", calls = 0 } function mhfu.em_installed() return true end diff --git a/framework/tests/test_move.py b/framework/tests/test_move.py index 25dc000b..9a6beb09 100644 --- a/framework/tests/test_move.py +++ b/framework/tests/test_move.py @@ -11,6 +11,7 @@ import pytest from mhfu import addresses as a +from mhfu import steering BASE = 0x09000000 # move_host.cpp's memory; noaddr ENT = BASE + 0x1000 @@ -54,6 +55,7 @@ class Game: def __init__(self, lib: ctypes.CDLL) -> None: self.lib = lib self.mem = (ctypes.c_uint8 * 0x4000).from_address(lib.host_mem()) + self.hunter = (ctypes.c_uint8 * 0x300).from_address(lib.host_hunter()) self.speed = SPEED def poke(self, off: int, fmt: str, *values: float) -> None: @@ -106,6 +108,12 @@ def state(self, field: str, fmt: str = "I") -> tuple[float, ...]: "<" + fmt, ctypes.string_at(base + getattr(a.MOVE_STATE, field), 32) ) + def steer_state(self, field: str, fmt: str = "f") -> tuple[float, ...]: + base = int(self.lib.mhfu_move_state()) + a.MOVE_STATE.STEER + return struct.unpack_from( + "<" + fmt, ctypes.string_at(base + getattr(a.STEER_STATE, field), 8) + ) + def play(self, entry: int = 46, **kw: int) -> None: mv = Move(entry=entry, carrier_main=0, carrier_sub=1, back_main=NO_PAIR) for k, v in kw.items(): @@ -132,6 +140,7 @@ def lib(host_lib: Callable[..., ctypes.CDLL]) -> ctypes.CDLL: "tests/move_host.cpp", ) lib.host_mem.restype = ctypes.c_void_p + lib.host_hunter.restype = ctypes.c_void_p lib.host_frame.restype = ctypes.c_uint32 lib.host_frame.argtypes = [ctypes.c_uint32] lib.host_set.argtypes = [ctypes.c_int, ctypes.c_float] @@ -151,6 +160,7 @@ def g(lib: ctypes.CDLL) -> Game: lib.host_set(1, ctypes.c_float(CLIP_END)) game = Game(lib) ctypes.memset(ctypes.addressof(game.mem), 0, 0x4000) + ctypes.memset(ctypes.addressof(game.hunter), 0, 0x300) game.pair = (1, 3) return game @@ -368,6 +378,18 @@ def test_host_attacks_keeps_them(g: Game) -> None: WALL, STUCK = 8, 9 +class Lunge(ctypes.Structure): + _fields_ = [ + ("lo", ctypes.c_uint16), + ("hi", ctypes.c_uint16), + *[(k, ctypes.c_float) for k in ("near", "span", "base", "range")], + ] + + +class Sample(ctypes.Structure): + _fields_ = [("x", ctypes.c_int16), ("z", ctypes.c_int16)] + + class Spec(ctypes.Structure): _fields_ = [ ("turn", ctypes.c_uint8), @@ -383,11 +405,25 @@ class Spec(ctypes.Structure): ("stuck_sub", ctypes.c_uint8), ("stuck_mode", ctypes.c_uint8), ("_pad", ctypes.c_uint8 * 3), - ("keys", ctypes.c_uint16 * 256), + ("keys", ctypes.c_uint16 * a.STEER_KEYS), + ("sample_count", ctypes.c_uint16), + ("lunge", Lunge), + ("samples", Sample * a.STEER_SAMPLES), ] -def steer(g: Game, keys: tuple[int, ...] = (), **kw: int) -> None: +Point = steering.Point + + +def steer( + g: Game, + keys: tuple[int, ...] = (), + samples: tuple[Point, ...] = (), + lunge: tuple[int, int, float, float, float, float] | None = None, + **kw: int, +) -> None: + """The spec the next move takes: turn `keys`, travel `samples` (world units), a `lunge` as + (lo, hi, near, span, base, range), and steer fields by name.""" spec = Spec() g.lib.mhfu_steer_init_spec(ctypes.byref(spec)) for k, v in kw.items(): @@ -395,6 +431,11 @@ def steer(g: Game, keys: tuple[int, ...] = (), **kw: int) -> None: spec.key_count = len(keys) for i, v in enumerate(keys): spec.keys[i] = v & 0xFFFF + spec.sample_count = len(samples) + for i, (x, z) in enumerate(samples): + spec.samples[i].x, spec.samples[i].z = steering.sample(x), steering.sample(z) + if lunge: + spec.lunge = Lunge(*lunge) g.lib.mhfu_move_steer(ctypes.byref(spec)) @@ -407,6 +448,10 @@ def test_steer_spec_layout() -> None: assert Spec.keys.offset == a.STEER_SPEC.KEYS and Spec.key_count.offset == a.STEER_SPEC.KEY_COUNT assert Spec.win_from.offset == a.STEER_SPEC.STEER + a.STEER_PARAMS.WIN_FROM assert Spec.stuck_main.offset == a.STEER_SPEC.STUCK_MAIN + assert Spec.sample_count.offset == a.STEER_SPEC.SAMPLE_COUNT + assert Spec.lunge.offset == a.STEER_SPEC.LUNGE and Spec.samples.offset == a.STEER_SPEC.SAMPLES + assert ctypes.sizeof(Lunge) == a.STEER_LUNGE.size and Lunge.range.offset == a.STEER_LUNGE.RANGE + assert ctypes.sizeof(Sample) == a.TRAVEL_SAMPLE.size and Sample.z.offset == a.TRAVEL_SAMPLE.Z def test_yaw_follows_the_clip_turn(g: Game) -> None: @@ -588,3 +633,238 @@ def test_react_off(g: Game) -> None: assert g.lib.mhfu_move_react(0, ENT, None, None) == 1 flinch(g) assert g.pair == (4, 1) + + +CURVE: tuple[Point, ...] = ((0, 0), (-2, 10), (-3.25, 30), (0, 60), (4, 100.5), (6, 140)) +"""x, z in the monster's frame at its first playing frame, every STEER_KEY_STEP clip frames.""" + + +def world(yaw0: int, x: float, z: float) -> Point: + """A vector in the frame of YAW `yaw0` as world axes: z forward, x its left.""" + th = yaw0 / 0x10000 * math.tau + return x * math.cos(th) + z * math.sin(th), -x * math.sin(th) + z * math.cos(th) + + +def at(g: Game) -> Point: + x, _, z = g.peek(a.ENTITY.POSITION, "fff") + return x, z + + +def settle(g: Game, frames: int) -> list[tuple[float, Point]]: + """(the cursor the step saw, the position it left) of each of `frames` AI frames from the + first playing one; the frames before it leave the position alone.""" + out = [] + before = at(g) + for _ in range(frames): + g.frame() + if int(g.state("FRAMES")[0]) == 0 or g.state("STATE", "B")[0] == 1: + assert at(g) == before + continue + (cursor,) = g.steer_state("CURSOR") + out.append((cursor, at(g))) + return out + + +TRAVELS = [ + (2.0, 0, 1.0, 0, 0), + (2.8, 0, 1.0, 0, 0), + (2.0, 3, 1.5, 0x4000, 0), + (2.8, 4, 0.75, 0xC123, 0), + (2.0, 2, 1.25, 0x2000, 24), + (2.8, 5, 1.0, 0x8000, 42), +] + + +@pytest.mark.parametrize(("speed", "blend", "size", "yaw0", "start"), TRAVELS) +def test_travel_is_the_curves_change_turned_and_scaled( + g: Game, speed: float, blend: int, size: float, yaw0: int, start: int +) -> None: + """After every frame the monster is where the curve's change since the move began, turned by + YAW as it was then and scaled by its size, puts it; the blend's held cursor moves it not.""" + g.lib.host_blend(blend) + g.speed = speed + g.poke(a.ENTITY.YAW, "H", yaw0) + g.poke(a.ENTITY.SIZE_SCALE, "f", size) + g.poke(a.ENTITY.POSITION, "fff", 100.0, 7.0, -50.0) + steer(g, samples=CURVE) + g.play(46, start=start) + seen = settle(g, 2 + blend + int((CLIP_END - start) / speed)) + assert len(seen) > 8 + for cursor, got in seen: + x, z = steering.at(CURVE, cursor, start) + ox, oz = steering.at(CURVE, start, start) + dx, dz = world(yaw0, x - ox, z - oz) + assert got == pytest.approx((100.0 + dx * size, -50.0 + dz * size), abs=0.02) + held = [p for c, p in seen if c == start] + assert (len(held) > 0) == (blend > 0) and all(p == (100.0, -50.0) for p in held) + assert seen[-1][1] != (100.0, -50.0) + assert g.peek(a.ENTITY.POSITION + 4, "f") == (7.0,) # y is the floor's + + +def test_travel_turns_by_the_first_frames_yaw_not_the_live_one(g: Game) -> None: + """The move's own turn changes YAW as it plays; the curve stays in the first frame's.""" + g.poke(a.ENTITY.SIZE_SCALE, "f", 1.0) + steer(g, samples=((0, 0), (0, 100)), turn=3, total=0x4000, frames=10) + g.play(46) + seen = settle(g, 12) + assert g.peek(a.ENTITY.YAW, "H")[0] == 0x4000 + assert seen[-1][1] == pytest.approx((0.0, 100.0), abs=0.02) # along world +z, not the turn + + +def test_travel_keeps_the_resolution(g: Game) -> None: + g.poke(a.ENTITY.SIZE_SCALE, "f", 1.0) + steer(g, samples=((0, 0), (0.25, -0.75))) + g.play(46) + settle(g, 8) + assert at(g) == pytest.approx((0.25, -0.75), abs=1e-6) + + +def test_a_wall_ahead_stops_the_travel_that_frame(g: Game) -> None: + g.poke(a.ENTITY.SIZE_SCALE, "f", 1.0) + steer(g, samples=CURVE, walls=1) + g.play(46) + seen = settle(g, 8) + g.poke(a.ENTITY.WALL_SECTORS, "I", 0x40000000) + g.frame() + assert at(g) == seen[-1][1] and g.state("END", "B") == (WALL,) + + +def test_a_loop_carries_the_curve_on(g: Game) -> None: + """A clip that loops back to its loop start does not undo the travel: the rest of the curve + is travelled, then the part from the loop start again.""" + g.poke(a.ENTITY.SIZE_SCALE, "f", 1.0) + steer(g, samples=((0, 0), (0, 10), (0, 20), (0, 30))) + g.play(46) + settle(g, 6) + b = g.block(0) + g.poke(b + a.CLIP_BLOCK.PHASE, "f", 0.0) # the engine wrapped the cursor + g.poke(b + a.CLIP_BLOCK.LOOP_START, "f", 0.0) + g.frame() + assert at(g) == pytest.approx((0.0, 30.0), abs=0.02) + g.frame() + assert at(g)[1] == pytest.approx(30.0 + 10.0, abs=0.02) # the cursor is at 2 again + + +LUNGE = (10, 40, 400.0, 1800.0, -700.0, 1800.0) +"""window (lo, hi], near, span, base, range: MHP3rd's stamp jump.""" + + +def lunge_total(d: float) -> float: + lo, hi, near, span, base, rng = LUNGE + return 0.0 if d < near else base + rng * min((d - near) / span, 1) + + +@pytest.mark.parametrize("d", [300.0, 399.0, 400.0, 1300.0, 2200.0, 3500.0]) +@pytest.mark.parametrize(("bearing", "yaw0", "size"), [(0.4, 0, 1.0), (-2.5, 0x6000, 2.0)]) +def test_lunge_is_the_distances_length_along_the_bearing( + g: Game, d: float, bearing: float, yaw0: int, size: float +) -> None: + """Spread over its window by the cursor, along the bearing to the hunter at the first playing + frame in world axes: unscaled, whatever YAW and size are.""" + ex, ez = 10.0, -20.0 + ux, uz = math.sin(bearing), math.cos(bearing) + g.poke(a.ENTITY.POSITION, "fff", ex, 0.0, ez) + g.poke(a.ENTITY.YAW, "H", yaw0) + g.poke(a.ENTITY.SIZE_SCALE, "f", size) + struct.pack_into(" hi + + +def test_lunge_takes_the_hunter_where_the_move_began(g: Game) -> None: + g.poke(a.ENTITY.POSITION, "fff", 0.0, 0.0, 0.0) + struct.pack_into(" None: + g.speed = speed + g.poke(a.ENTITY.SIZE_SCALE, "f", 2.0) + struct.pack_into(" None: + """None before the window, the share of it the cursor has covered inside, all of it after: + the cumulative rounding makes the sum exact whatever the cursor's step.""" + lo, hi = FIXED_WINDOW + g.speed = speed + g.poke(a.ENTITY.YAW, "H", 0x1000) + steer(g, turn=3, total=total, win_from=lo, win_to=hi) + g.play(46) + for _ in range(2 + int(CLIP_END / speed)): + g.frame() + if int(g.state("FRAMES")[0]) == 0: + continue + (cursor,) = g.steer_state("CURSOR") + share = min(max((cursor - lo) / (hi - lo), 0.0), 1.0) + turned = (int(g.peek(a.ENTITY.YAW, "H")[0]) - 0x1000 + 0x8000) % 0x10000 - 0x8000 + assert abs(turned - total * share) <= 1 + assert yaw(g) == (0x1000 + total) % 0x10000 + + +def test_fixed_turn_without_a_window_still_runs_over_its_frames(g: Game) -> None: + g.poke(a.ENTITY.YAW, "H", 0) + steer(g, turn=3, total=0x1000, frames=8) + g.play(46) + for _ in range(2 + 6): + g.frame() + assert 0 < yaw(g) < 0x1000 + for _ in range(10): + g.frame() + assert yaw(g) == 0x1000 + + +def test_a_move_without_travel_leaves_the_entity_as_it_was(g: Game) -> None: + """Samples or a lunge past their counts are never read: the entity's bytes are what a spec + without them leaves.""" + + def run(junk: bool) -> bytes: + g.lib.host_set(1, ctypes.c_float(CLIP_END)) + ctypes.memset(ctypes.addressof(g.mem), 0, 0x4000) + g.pair = (1, 3) + g.poke(a.ENTITY.SIZE_SCALE, "f", 1.5) + g.poke(a.ENTITY.POSITION, "fff", 5.0, 6.0, 7.0) + struct.pack_into(" None: assert lib.mhfu_steer_bearing(0, 0) == 0 +def test_sincos(lib: ctypes.CDLL) -> None: + sn, cs = ctypes.c_float(), ctypes.c_float() + lib.mhfu_steer_sincos.argtypes = [ctypes.c_uint16, ctypes.c_void_p, ctypes.c_void_p] + worst = 0.0 + for yaw in range(TURN): + lib.mhfu_steer_sincos(yaw, ctypes.byref(sn), ctypes.byref(cs)) + th = yaw / TURN * math.tau + worst = max(worst, abs(sn.value - math.sin(th)), abs(cs.value - math.cos(th))) + assert worst < 1e-6 + + def test_toward(lib: ctypes.CDLL) -> None: assert lib.mhfu_steer_toward(0x8000, 0x9000, 64) == 0x8040 assert lib.mhfu_steer_toward(0x8000, 0x7000, 64) == 0x7FC0 diff --git a/packages/mhfu/src/mhfu/addresses.toml b/packages/mhfu/src/mhfu/addresses.toml index a39a847b..7d47ee68 100644 --- a/packages/mhfu/src/mhfu/addresses.toml +++ b/packages/mhfu/src/mhfu/addresses.toml @@ -897,12 +897,30 @@ size = 0x10 TURN = { offset = 0x0, type = "u8", doc = "0 still, 1 toward the hunter, 2 away, 3 TOTAL over FRAMES" } WALLS = { offset = 0x1, type = "u8", doc = "1: a wall ahead ends the move" } RATE = { offset = 0x2, type = "u16", doc = "toward or away: most YAW units an AI frame (TURN_TOWARD's step; the charge steers 64)" } -TOTAL = { offset = 0x4, type = "s32", doc = "fixed: YAW units over the move, under one turn either way" } -FRAMES = { offset = 0x8, type = "u16", doc = "fixed: AI frames TOTAL is spread over" } +TOTAL = { offset = 0x4, type = "s32", doc = "fixed: YAW units over the move or the window, under one turn either way" } +FRAMES = { offset = 0x8, type = "u16", doc = "fixed without a window: AI frames TOTAL is spread over" } DIR = { offset = 0xA, type = "u16", doc = "direction of travel against YAW, YAW units: 0 forward; picks the wall sectors that count as ahead" } -WIN_FROM = { offset = 0xC, type = "u16", doc = "toward or away: turn only while the clip frame (part 0's cursor) is in [WIN_FROM, WIN_TO); both 0 = the whole move" } +WIN_FROM = { offset = 0xC, type = "u16", doc = "toward or away: turn only while the clip frame (part 0's cursor) is in [WIN_FROM, WIN_TO); both 0 = the whole move. fixed: TOTAL is spread across the window by the cursor, WIN_TO 0 spreads it over FRAMES AI frames" } WIN_TO = { offset = 0xE, type = "u16", doc = "with WIN_FROM" } +[struct.TRAVEL_SAMPLE] +doc = "a point of a move's travel curve (steer.h mhfu_travel_sample_t): the monster's own x and z at a clip frame, cumulative from the move's START" +size = "auto" +[struct.TRAVEL_SAMPLE.fields] +X = { offset = 0x0, type = "s16", doc = "toward the monster's +x, its left, at the move's first playing frame; in 1/STEER_TRAVEL_SCALE world units, before the monster's size" } +Z = { offset = "next", type = "s16", doc = "forward, the same way" } + +[struct.STEER_LUNGE] +doc = "a move's lunge (steer.h mhfu_steer_lunge_t): travel toward the hunter whose length the hunter's distance at the move's first playing frame sets" +size = "auto" +[struct.STEER_LUNGE.fields] +LO = { offset = 0x0, type = "u16", doc = "clip frame the window (LO, HI] opens after" } +HI = { offset = "next", type = "u16", doc = "clip frame it closes at; 0: no lunge" } +NEAR = { offset = "next", type = "f32", doc = "no lunge while the hunter's XZ distance is under this" } +SPAN = { offset = "next", type = "f32", doc = "distance past NEAR over which the length grows to BASE + RANGE" } +BASE = { offset = "next", type = "f32", doc = "world units along the bearing to the hunter at NEAR, negative away from it" } +RANGE = { offset = "next", type = "f32", doc = "units added by the time the distance is NEAR + SPAN" } + [struct.STEER_SPEC] doc = "a move's steering as the move player takes it (steer.h mhfu_steer_spec_t)" size = "auto" @@ -913,6 +931,9 @@ STUCK_MAIN = { offset = 0x12, type = "u8", doc = "pair entered on a class-2 wall STUCK_SUB = { offset = 0x13, type = "u8", doc = "with STUCK_MAIN" } STUCK_MODE = { offset = 0x14, type = "u8", doc = "ENTER_ACTION mode for it, 1 as the Tigrex charge enters it" } KEYS = { offset = 0x18, type = "u16[STEER_KEYS]", doc = "YAW every STEER_KEY_STEP clip frames, the last at the clip's end: a port's turns module (mhfu_port.travel.Turn)" } +SAMPLE_COUNT = { offset = "next", type = "u16", doc = "SAMPLES in use; 0: the move does not travel by code" } +LUNGE = { offset = "next", type = "STEER_LUNGE", doc = "the lunge; its HI 0: none" } +SAMPLES = { offset = "next", type = "TRAVEL_SAMPLE[STEER_SAMPLES]", doc = "the move's own travel by code: sample k is at clip frame START + k * STEER_KEY_STEP; each AI frame moves the monster by the curve's change since the cursor's last frame, held past the last" } [struct.STEER_STATE] doc = "the move player's steering (steer.h mhfu_steer_state_t), in MOVE_STATE" @@ -924,7 +945,9 @@ BASE = { offset = "next", type = "u16", doc = "YAW the turn curve adds to: YAW o WALL = { offset = "next", type = "u8", doc = "the last frame's wall result: 0 none, 1 a wall ahead, 2 a class-2 one" } CURSOR = { offset = "next", type = "f32", doc = "part 0's clip cursor the step saw last" } YAW0 = { offset = "next", type = "u16", doc = "YAW on the move's first playing frame" } -YAW = { offset = "next", type = "u16", doc = "YAW as the step left it" } +YAW = { offset = "next", type = "u16", doc = "YAW as the move's last step left it" } +LUNGE_X = { offset = "next", type = "f32", doc = "the lunge's whole length in world x, taken on the first playing frame; 0 without a lunge" } +LUNGE_Z = { offset = "next", type = "f32", doc = "and in world z" } [struct.EM_OWN] doc = "one own move in em_vhook's registry (em_vhook.h mhfu_em_own_t), played by its slot when Lua, a rule or the move before it asks" @@ -934,10 +957,13 @@ MOVE = { offset = 0x00, type = "MOVE", doc = "the move; its back pair is its car STEER = { offset = "next", type = "STEER_PARAMS", doc = "turn mode and walls" } KEY_AT = { offset = "next", type = "u16", doc = "its first turn key in EM_MOVES.KEYS" } KEY_COUNT = { offset = "next", type = "u16", doc = "its turn keys; 0: the clip does not turn YAW" } +SAMPLE_AT = { offset = "next", type = "u16", doc = "its first travel sample in EM_MOVES.SAMPLES" } +SAMPLE_COUNT = { offset = "next", type = "u16", doc = "its travel samples; 0: it does not travel by code" } STUCK_MAIN = { offset = "next", type = "u8", doc = "STEER_SPEC.STUCK_MAIN" } STUCK_SUB = { offset = "next", type = "u8", doc = "STEER_SPEC.STUCK_SUB" } STUCK_MODE = { offset = "next", type = "u8", doc = "STEER_SPEC.STUCK_MODE" } AFTER = { offset = "next", type = "u8", doc = "slot played when this move ends on its clip, its length, its stop or a wall; 0xFF none" } +LUNGE_AT = { offset = "next", type = "u8", doc = "its lunge in EM_MOVES.LUNGES; 0xFF none" } VALID = { offset = "next", type = "u32", doc = "1 once written; 0 while it is rewritten" } [struct.EM_MOVES] @@ -952,10 +978,14 @@ TAG_STARTED = { offset = 0x10, type = "u32", doc = "MOVE_STATE.STARTED of that m PLAYS = { offset = 0x14, type = "u32", doc = "moves started from here: asked, by a rule or by AFTER" } CHAINED = { offset = 0x18, type = "u32", doc = "of them by AFTER" } KEY_TOP = { offset = 0x1C, type = "u32", doc = "KEYS in use" } -MOVES = { offset = 0x20, type = "EM_OWN[OWN_MOVES]", doc = "the moves by slot" } +SAMPLE_TOP = { offset = "next", type = "u32", doc = "SAMPLES in use" } +LUNGE_TOP = { offset = "next", type = "u32", doc = "LUNGES in use" } +REQ_FORCE = { offset = "next", type = "u32", doc = "1: the play asked for starts while the monster's notice runs (MOVE.FORCE)" } +MOVES = { offset = "next", type = "EM_OWN[OWN_MOVES]", doc = "the moves by slot" } KEYS = { offset = "next", type = "u16[EM_KEYS]", doc = "every move's turn keys, YAW every STEER_KEY_STEP clip frames" } +SAMPLES = { offset = "next", type = "TRAVEL_SAMPLE[EM_SAMPLES]", doc = "every move's travel samples" } +LUNGES = { offset = "next", type = "STEER_LUNGE[EM_LUNGES]", doc = "the moves' lunges" } SCRATCH = { offset = "next", type = "STEER_SPEC", doc = "the steering handed to the move player for the move it plays next" } -REQ_FORCE = { offset = "next", type = "u32", doc = "1: the play asked for starts while the monster's notice runs (MOVE.FORCE)" } [struct.FLINCH_PART] doc = "one part's flinch state (ENTITY.FLINCH_PARTS)" @@ -1184,5 +1214,9 @@ CHARGE_QUERY_CHARGED = { value = 0, doc = "the AI script `switch_query` arg that CHARGE_QUERY_COOLDOWN = { value = 6, doc = "the `switch_query` arg that asks whether the charge cooldown runs (MHP3rd's numbering): the framework answers 1 or 0" } OWN_MOVES = { value = 96, doc = "own moves the framework's registry holds, EM_MOVES slots 0..OWN_MOVES-1: every table keyed by slot, and the offsets after one, follow it" } EM_KEYS = { value = 5632, doc = "turn keys all own moves share in EM_MOVES.KEYS" } +EM_SAMPLES = { value = 512, doc = "travel samples all own moves share in EM_MOVES.SAMPLES" } +EM_LUNGES = { value = 16, doc = "lunges the registry holds, one per own move that has one" } STEER_KEYS = { value = 256, doc = "turn keys one move holds (STEER_SPEC.KEYS)" } -STEER_KEY_STEP = { value = 2, doc = "clip frames between a move's turn keys: the cursor's step at speed 2, so it lands on them" } +STEER_KEY_STEP = { value = 2, doc = "clip frames between a move's turn keys and between its travel samples: the cursor's step at speed 2, so it lands on them" } +STEER_SAMPLES = { value = 128, doc = "travel samples one move holds (STEER_SPEC.SAMPLES)" } +STEER_TRAVEL_SCALE = { value = 4, doc = "a travel sample's units to a world unit: its x and z are 16-bit multiples of 1/STEER_TRAVEL_SCALE" } diff --git a/packages/mhfu/src/mhfu/steering.py b/packages/mhfu/src/mhfu/steering.py new file mode 100644 index 00000000..73f0dc1c --- /dev/null +++ b/packages/mhfu/src/mhfu/steering.py @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 sp00ktober +"""A move's travel curve as the game holds it, so that what a port's moves module writes and what +`mhfu.live.moves` puts in the game agree: cumulative (x, z) every `STEP` clip frames in 16-bit +samples of `1 / SCALE` world units (struct TRAVEL_SAMPLE), and as `steer.travel` in that module, +8 hex digits a sample (x, then z). + + hex_of([(0, 0), (-2.5, 40)]) # "00000000fff60000..." for the module + samples([(0, 0), (-2.5, 40)]) # [(0, 0), (-10, 160)] for the game +""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence + +from . import addresses + +SCALE = int(addresses.STEER_TRAVEL_SCALE) +"""Sample units to a world unit.""" +STEP = int(addresses.STEER_KEY_STEP) +"""Clip frames between two samples.""" +MOST = int(addresses.STEER_SAMPLES) +"""Samples one move holds.""" +REACH = 0x8000 / SCALE +"""A sample's x and z are within -REACH and REACH (exclusive) world units.""" + +Point = tuple[float, float] + + +def sample(value: float) -> int: + """`value` world units as a sample's 16 bits; ValueError past `REACH`.""" + q = round(value * SCALE) + if not -0x8000 <= q < 0x8000: + raise ValueError(f"{value} is past the +-{REACH} a travel sample holds") + return q + + +def samples(path: Iterable[Point]) -> list[tuple[int, int]]: + """Each (x, z) of `path` as the game's sample; ValueError past `MOST` or `REACH`.""" + out = [(sample(x), sample(z)) for x, z in path] + if len(out) > MOST: + raise ValueError(f"{len(out)} travel samples, a move holds {MOST}") + return out + + +def path_of(held: Iterable[tuple[int, int]]) -> tuple[Point, ...]: + """World units from the game's samples.""" + return tuple((x / SCALE, z / SCALE) for x, z in held) + + +def hex_of(path: Iterable[Point]) -> str: + """`path` as a moves module's `steer.travel`.""" + return "".join(f"{x & 0xFFFF:04x}{z & 0xFFFF:04x}" for x, z in samples(path)) + + +def parse(text: str) -> tuple[Point, ...]: + """A `steer.travel` string's path.""" + if len(text) % 8 or not all(c in "0123456789abcdefABCDEF" for c in text): + raise ValueError("travel is 8 hex digits a sample") + words = [int(text[i : i + 4], 16) for i in range(0, len(text), 4)] + held = [w - 0x10000 if w >= 0x8000 else w for w in words] + return path_of(zip(held[0::2], held[1::2], strict=True)) + + +def at(path: Sequence[Point], clip_frame: float, start: float = 0) -> Point: + """The curve at `clip_frame`: straight lines between samples `STEP` frames apart from + `start`, held before the first and after the last; (0, 0) for an empty path.""" + if not path: + return 0.0, 0.0 + u = (clip_frame - start) / STEP + if u <= 0: + return path[0] + if u >= len(path) - 1: + return path[-1] + i = int(u) + t = u - i + (x0, z0), (x1, z1) = path[i], path[i + 1] + return x0 + (x1 - x0) * t, z0 + (z1 - z0) * t diff --git a/packages/mhfu/tests/live/test_monster_events.py b/packages/mhfu/tests/live/test_monster_events.py index 625e79fe..ff605b2d 100644 --- a/packages/mhfu/tests/live/test_monster_events.py +++ b/packages/mhfu/tests/live/test_monster_events.py @@ -10,7 +10,7 @@ from mhfu.memory import Image, Space MOVES = 0x09DE5900 # where the move player's block sits; noaddr -EVENTS = 0x09DE6100 # noaddr +EVENTS = MOVES + 0x2000 ENT = 0x090C1B00 # noaddr diff --git a/packages/mhfu/tests/test_steering.py b/packages/mhfu/tests/test_steering.py new file mode 100644 index 00000000..5af79150 --- /dev/null +++ b/packages/mhfu/tests/test_steering.py @@ -0,0 +1,43 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 sp00ktober +import pytest +from mhfu import steering + +PATH = ((0.0, 0.0), (-2.5, 40.25), (3.0, 100.0), (0.0, -8191.75)) + + +def test_samples_are_quarter_units() -> None: + assert steering.SCALE == 4 and steering.STEP == 2 + assert steering.samples(PATH) == [(0, 0), (-10, 161), (12, 400), (0, -32767)] + assert steering.path_of(steering.samples(PATH)) == PATH + + +def test_hex_round_trips() -> None: + text = steering.hex_of(PATH) + assert text == "00000000" + "fff600a1" + "00000190"[:0] + "000c0190" + "00008001" + assert steering.parse(text) == PATH + assert steering.parse("") == () + + +@pytest.mark.parametrize("bad", ["0", "0000000", "0000000g", "00000000000000"]) +def test_parse_refuses(bad: str) -> None: + with pytest.raises(ValueError, match="8 hex digits"): + steering.parse(bad) + + +def test_the_most_and_the_reach_are_refused() -> None: + assert steering.hex_of([(0, 0)] * steering.MOST) + with pytest.raises(ValueError, match=f"a move holds {steering.MOST}"): + steering.hex_of([(0, 0)] * (steering.MOST + 1)) + assert steering.sample(-steering.REACH) == -0x8000 + with pytest.raises(ValueError, match="a travel sample holds"): + steering.sample(steering.REACH) + + +def test_the_curve_is_straight_lines_held_outside() -> None: + p = ((0.0, 0.0), (10.0, 20.0), (10.0, 60.0)) + assert steering.at(p, 0) == (0, 0) and steering.at(p, -5) == (0, 0) + assert steering.at(p, 1) == (5, 10) and steering.at(p, 2) == (10, 20) + assert steering.at(p, 3) == (10, 40) and steering.at(p, 99) == (10, 60) + assert steering.at(p, 25, start=24) == (5, 10) + assert steering.at((), 5) == (0, 0) From 1a6fdeb53d51c9f7ab6a3bc44c3ee9c14cd5f008 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:39:58 +0200 Subject: [PATCH 4/9] mhfu, mhfu-port: an own move's travel and lunge in the moves module and the live steer mhfu_port.moves writes a move's `travel` (steer.travel, the samples as 8 hex digits each), its `lunge` and a fixed turn's `window` into the Lua module, and `problems` refuses what the move player cannot carry: travel past its capacity, reach or the clip, more samples or lunges than the registry holds, a lunge or fixed window outside [start, stop], a lunge distance that is not a length. mhfu.steering is the one place that quantizes and hexes the samples; mhfu.live.moves.Steer packs and unpacks travel and lunge with it, and SteerState reads the lunge's length. Co-Authored-By: Claude Sonnet 5.5 --- packages/mhfu-port/src/mhfu_port/moves.py | 84 +++++++++++++-- packages/mhfu-port/tests/test_moves.py | 120 ++++++++++++++++++++++ packages/mhfu/src/mhfu/live/moves.py | 84 +++++++++++++-- packages/mhfu/tests/test_moves.py | 46 +++++++++ 4 files changed, 320 insertions(+), 14 deletions(-) diff --git a/packages/mhfu-port/src/mhfu_port/moves.py b/packages/mhfu-port/src/mhfu_port/moves.py index f0e9538d..369c99cb 100644 --- a/packages/mhfu-port/src/mhfu_port/moves.py +++ b/packages/mhfu-port/src/mhfu_port/moves.py @@ -11,8 +11,10 @@ takes it (`mhfu.move_play`): its `slot` in the framework's registry (`slots`), its clip's executor entry and, for `turn = "clip"`, its turn keys, both from the build's layout; the attack windows, the carrier (`hub` when it names none), the steer and `after`, and `start` and `stop`, the clip -frames it plays from and to, when it sets them (`length` counts AI frames instead). `tip` is the -tail tip's `{ joint, carrier }` pairs (`rig.Tip`), which `mhfu.em_tip` takes. +frames it plays from and to, when it sets them (`length` counts AI frames instead). The steer +carries the move's own `travel` (`mhfu.steering.hex_of`) and `lunge` when the manifest has them, +and a fixed turn's `window`. `tip` is the tail tip's `{ joint, carrier }` pairs (`rig.Tip`), +which `mhfu.em_tip` takes. `fatigue` is the stamina table `mhfu.em_fatigue` takes (`brain.fatigue`), given when the manifest's `[brain] fatigue` is set; `mhfu_port.lua` arms it with the port, an own move keyed by name. @@ -26,12 +28,13 @@ from __future__ import annotations +import math import re import zlib from collections.abc import Collection, Mapping from typing import Any -from mhfu import addresses, hitbox, inject +from mhfu import addresses, hitbox, inject, steering from mhfu.files import Extracted, em_overlay from . import behaviour @@ -51,6 +54,12 @@ """Turn keys the framework holds for all of a port's own moves.""" SLOTS: int = OWN_MOVES """Own moves the framework's registry holds.""" +SAMPLES: int = steering.MOST +"""Travel samples the move player holds for one move.""" +SAMPLE_POOL: int = addresses.EM_MOVES.SAMPLES.count or 0 +"""Travel samples the framework holds for all of a port's own moves.""" +LUNGES: int = addresses.EM_MOVES.LUNGES.count or 0 +"""Lunges the framework holds for a port's own moves.""" def slots(m: Manifest) -> dict[str, int]: @@ -124,14 +133,51 @@ def records(game: Extracted, species: int) -> frozenset[int]: return frozenset(r.index - off for r in ([] if t is None else t.attacks) if any(r.raw)) +def motion_problems(mv: Move, frames: int | None) -> list[tuple[str, str]]: + """What the move player cannot carry of an own move's travel, lunge and fixed turn window, as + (where under the move, text): travel samples past its capacity, its reach or the clip's + `frames`; a lunge or window outside `[start, stop]` (the clip's end for no stop) or with a + distance that is not a length.""" + out: list[tuple[str, str]] = [] + end = mv.stop or frames + try: + steering.samples(mv.travel or ()) + except ValueError as err: + out.append((".travel", str(err))) + last = mv.start + steering.STEP * (len(mv.travel or ()) - 1) + if frames is not None and mv.travel and last > frames: + out.append( + ( + ".travel", + f"its {len(mv.travel)} samples reach frame {last}, past the clip's {frames}", + ) + ) + if (lunge := mv.lunge) is not None: + lo, hi = lunge.window + if not 0 <= lo < hi <= 0xFFFF: + out.append((".lunge", f"window {lunge.window} is not 0 <= lo < hi")) + elif lo < mv.start or (end is not None and hi > end): + out.append((".lunge", f"window {lunge.window} is outside [{mv.start}, {end}]")) + values = (lunge.near, lunge.span, lunge.base, lunge.range) + if not all(math.isfinite(v) for v in values) or lunge.near < 0 or lunge.span <= 0: + out.append((".lunge", "near is 0 or more, span above 0, all of them finite")) + s = mv.steer + if s.turn == "fixed" and s.window is not None: + lo, hi = s.window + if lo < mv.start or (end is not None and hi > end): + out.append((".steer", f"fixed window {s.window} is outside [{mv.start}, {end}]")) + return out + + def problems(m: Manifest, layout: Layout, known: Collection[int] | None = None) -> list[str]: """Every own move or rule the module cannot carry: more own moves than slots, a clip with no entry, a start or stop past its clip or an attack outside them (`manifest.span_problems`), an - attack past its clip or with no record in `known` (the host's, when given), more turn keys - than the framework holds, no carrier; rules on the flinch whose moves ride different - carriers.""" + attack past its clip or with no record in `known` (the host's, when given), more turn keys, + travel samples or lunges than the framework holds, travel past the clip, a lunge or fixed + turn window outside `[start, stop]`, no carrier; rules on the flinch whose moves ride + different carriers.""" out: list[str] = [] - pool = 0 + pool = samples = lunges = 0 if (n := sum(mv.own for mv in m.moves.values())) > SLOTS: out.append(f"moves: {n} own moves, the framework holds {SLOTS}") for name, mv in m.moves.items(): @@ -146,6 +192,9 @@ def problems(m: Manifest, layout: Layout, known: Collection[int] | None = None) continue frames = layout.frames.get(e) out += [f"{w}{sub}: {text}" for sub, text in span_problems(mv, frames)] + out += [f"{w}{sub}: {text}" for sub, text in motion_problems(mv, frames)] + samples += len(mv.travel or ()) + lunges += mv.lunge is not None for i, a in enumerate(mv.attacks): if known is not None and a.id not in known: out.append( @@ -160,6 +209,13 @@ def problems(m: Manifest, layout: Layout, known: Collection[int] | None = None) pool += keys if pool > POOL: out.append(f"moves: the own moves' turns need {pool} keys, the framework holds {POOL}") + if samples > SAMPLE_POOL: + out.append( + f"moves: the own moves' travel needs {samples} samples, the framework holds " + f"{SAMPLE_POOL}" + ) + if lunges > LUNGES: + out.append(f"moves: {lunges} lunges, the framework holds {LUNGES}") flinch = set() try: rules = behaviour.compile(m) @@ -222,9 +278,21 @@ def _steer(m: Manifest, name: str, layout: Layout) -> dict[str, Any]: if s.window is not None: out["window"] = list(s.window) elif s.turn == "fixed": - out |= {"turn": "fixed", "total": s.angle, "frames": s.frames} + out |= {"turn": "fixed", "total": s.angle} + out |= {"window": list(s.window)} if s.window is not None else {"frames": s.frames} elif (keys := curve(m, name, layout)) is not None: out["curve"] = keys + mv = m.moves[name] + if mv.travel: + out["travel"] = steering.hex_of(mv.travel) + if (lunge := mv.lunge) is not None: + out["lunge"] = { + "window": list(lunge.window), + "near": lunge.near, + "span": lunge.span, + "base": lunge.base, + "range": lunge.range, + } return out diff --git a/packages/mhfu-port/tests/test_moves.py b/packages/mhfu-port/tests/test_moves.py index f28da27a..bf76e98a 100644 --- a/packages/mhfu-port/tests/test_moves.py +++ b/packages/mhfu-port/tests/test_moves.py @@ -150,6 +150,126 @@ def test_steer_window(): assert "window" not in moves.lua(_m(), LAYOUT, {6}) +JUMP = ( + OWN + + """ +[moves.jump] +anim = 9 +carrier = [0, 1] +travel = [[0, 0], [-2.5, 40.25], [3, 100]] +[moves.jump.lunge] +window = [0, 32] +near = 400 +span = 1800 +base = -700 +range = 1800 +[moves.jump.steer] +turn = "fixed" +angle = 90.0 +window = [10, 40] +""" +) + + +def test_travel_lunge_and_fixed_window(): + text = moves.lua(_m(JUMP), LAYOUT, {6}) + assert ( + 'steer = { walls = true, turn = "fixed", total = 90.0, window = { 10, 40 }, ' + 'travel = "00000000fff600a1000c0190", lunge = { window = { 0, 32 }, near = 400.0, ' + "span = 1800.0, base = -700.0, range = 1800.0 } } }," in text + ) + plain = moves.lua(_m(), LAYOUT, {6}) + assert " travel = " not in plain and "lunge = { window" not in plain + assert "total = 90.0, frames = 50 }" in plain # a fixed turn without a window: its frames + + +def test_travel_the_move_player_can_carry_is_not_refused(): + m = _m(JUMP) + assert moves.problems(m, LAYOUT, {6}) == [] + m.moves["jump"].travel = [(0.0, 0.0)] * 51 # frames 0, 2 .. 100: the clip's last + assert moves.problems(m, LAYOUT, {6}) == [] + + +def _refused(**fields: object) -> list[str]: + m = _m(JUMP) + for k, v in fields.items(): + setattr(m.moves["jump"], k, v) + return moves.problems(m, LAYOUT, {6}) + + +def test_travel_past_the_clip_is_refused(): + found = _refused(travel=[(0.0, 0.0)] * 52) + assert found == ["moves.jump.travel: its 52 samples reach frame 102, past the clip's 100"] + found = _refused(travel=[(0.0, 0.0)] * 40, start=24) + assert "moves.jump.travel: its 40 samples reach frame 102, past the clip's 100" in found + + +def test_travel_the_move_player_cannot_hold_is_refused(): + n = moves.SAMPLES + 1 + big = _refused(travel=[(0.0, 0.0)] * n) + assert f"moves.jump.travel: {n} travel samples, a move holds {moves.SAMPLES}" in big + assert "a travel sample holds" in _refused(travel=[(0.0, 9000.0)])[0] + + +def test_a_lunge_outside_its_moves_span_is_refused(): + lunge = _m(JUMP).moves["jump"].lunge + assert lunge is not None + for window, why in [((0, 101), "(0, 101) is outside [0, 100]"), ((5, 5), "not 0 <= lo < hi")]: + lunge.window = window + assert why in _refused(lunge=lunge)[0] + lunge.window = (0, 32) + assert "(0, 32) is outside [0, 30]" in _refused(lunge=lunge, stop=30)[0] + assert "(0, 32) is outside [24, 100]" in _refused(lunge=lunge, start=24)[0] + for field, value in (("span", 0.0), ("near", -1.0), ("base", float("inf"))): + lunge = _m(JUMP).moves["jump"].lunge + assert lunge is not None + setattr(lunge, field, value) + assert "span above 0" in _refused(lunge=lunge)[0] + + +def test_a_fixed_window_outside_its_moves_span_is_refused(): + m = _m(JUMP) + mv = m.moves["jump"] + mv.stop = 30 + assert moves.problems(m, LAYOUT, {6}) == [ + "moves.jump.lunge: window (0, 32) is outside [0, 30]", + "moves.jump.steer: fixed window (10, 40) is outside [0, 30]", + ] + mv.stop, mv.start = 0, 12 + assert "fixed window (10, 40) is outside [12, 100]" in moves.problems(m, LAYOUT, {6})[1] + mv.start = 0 + mv.steer.window = (10, 101) + assert "(10, 101) is outside [0, 100]" in moves.problems(m, LAYOUT, {6})[0] + + +def test_hunter_windows_are_not_held_to_the_span(): + """Only a fixed turn's window must lie inside [start, stop]: the hunter turn's is as before.""" + m = _m(JUMP) + mv = m.moves["jump"] + mv.steer.turn, mv.steer.window, mv.steer.angle, mv.stop = "hunter", (10, 400), None, 30 + assert moves.problems(m, LAYOUT, {6}) == ["moves.jump.lunge: window (0, 32) is outside [0, 30]"] + + +def test_pools_of_samples_and_lunges(): + lay = layout.Layout({}, frames={9: 400}) + k = moves.SAMPLE_POOL // moves.SAMPLES + 1 + path = [[0, 0]] * moves.SAMPLES + many = "".join(f"[moves.t{i}]\nanim = 9\ncarrier = [0, 1]\ntravel = {path}\n" for i in range(k)) + assert moves.problems(_m(many), lay, {6}) == [ + f"moves: the own moves' travel needs {moves.SAMPLES * k} samples, the framework holds " + f"{moves.SAMPLE_POOL}" + ] + n = moves.LUNGES + 1 + lunging = "".join( + f"[moves.l{i}]\nanim = 9\ncarrier = [0, 1]\n[moves.l{i}.lunge]\nwindow = [0, 9]\n" + "near = 1\nspan = 1\nbase = 0\nrange = 1\n" + for i in range(n) + ) + assert moves.problems(_m(lunging), lay, {6}) == [ + f"moves: {n} lunges, the framework holds {moves.LUNGES}" + ] + + def test_tip(): tip = Tip(((46, 42), (47, 42), (48, 43)), (0.0, 0.0, -705.0)) body = _body(moves.lua(_m(), LAYOUT, {6}, tip)) diff --git a/packages/mhfu/src/mhfu/live/moves.py b/packages/mhfu/src/mhfu/live/moves.py index b07f34d6..6a707b89 100644 --- a/packages/mhfu/src/mhfu/live/moves.py +++ b/packages/mhfu/src/mhfu/live/moves.py @@ -23,8 +23,9 @@ from dataclasses import dataclass, replace from .. import addresses as a +from .. import steering from ..entries import entry_clip -from ..views import View, f32s, ptr, ptrs, u8, u8s, u16, u32, u32s +from ..views import View, f32, f32s, ptr, ptrs, u8, u8s, u16, u32, u32s from .clips import Pack, bridge, monster from .session import Session from .shell_anim import STALLED, Bridge, Op @@ -150,25 +151,59 @@ def pack(self) -> bytes: return bytes(out) +@dataclass(frozen=True) +class Lunge: + """`mhfu_steer_lunge_t`: over the clip frames `window` (lo, hi] the monster travels + `base + range * min((d - near) / span, 1)` units along the bearing to the hunter, nothing + while the hunter's distance `d` at the move's first playing frame is under `near`.""" + + window: tuple[int, int] + near: float + span: float + base: float + range: float + + def pack(self) -> bytes: + """struct STEER_LUNGE.""" + f = a.STEER_LUNGE + assert f.size + out = bytearray(f.size) + struct.pack_into(" Lunge | None: + """struct STEER_LUNGE; None where it has no window (its HI is 0).""" + f = a.STEER_LUNGE + lo, hi = struct.unpack_from(" bytes: """struct STEER_SPEC.""" @@ -176,7 +211,7 @@ def pack(self) -> bytes: most = a.STEER_SPEC.KEYS.count or 0 if len(self.curve) > most: raise ValueError(f"at most {most} curve keys") - assert f.size + assert f.size and a.TRAVEL_SAMPLE.size out = bytearray(f.size) total = round(self.total * FULL_TURN / 360) struct.pack_into( @@ -186,8 +221,43 @@ def pack(self) -> bytes: struct.pack_into(" Steer: + """struct STEER_SPEC; `dir` comes back in [0, 360).""" + f, p = a.STEER_SPEC, a.STEER_PARAMS + assert a.TRAVEL_SAMPLE.size + turn, walls, rate = struct.unpack_from(" int: return round(degrees * FULL_TURN / 360) & 0xFFFF @@ -213,6 +283,8 @@ class SteerState(View): wall = u8(a.STEER_STATE.WALL) yaw0 = u16(a.STEER_STATE.YAW0) yaw = u16(a.STEER_STATE.YAW) + lunge_x = f32(a.STEER_STATE.LUNGE_X) + lunge_z = f32(a.STEER_STATE.LUNGE_Z) class MoveState(View): diff --git a/packages/mhfu/tests/test_moves.py b/packages/mhfu/tests/test_moves.py index 2ac9075f..14f961fd 100644 --- a/packages/mhfu/tests/test_moves.py +++ b/packages/mhfu/tests/test_moves.py @@ -4,6 +4,7 @@ import struct +import pytest from mhfu import addresses as a from mhfu.cli import main from mhfu.cli import moves as cli @@ -72,6 +73,51 @@ def test_steer_packs_as_the_spec(): assert struct.unpack_from("<3H", b, a.STEER_SPEC.KEYS) == (0, 0x2000, 0xFFFF) +def test_travel_and_lunge_pack_as_the_spec(): + lunge = moves.Lunge((0, 32), 400.0, 1800.0, -700.0, 1800.0) + b = moves.Steer(travel=((0, 0), (-2.5, 40.25), (3, 100)), lunge=lunge).pack() + assert len(b) == a.STEER_SPEC.size + assert struct.unpack_from(" Date: Fri, 23 Oct 2026 09:01:20 +0200 Subject: [PATCH 5/9] framework: 128 own-move slots OWN_MOVES 96 -> 128, the one const: EM_MOVES grows from 0x5C1C to 0x679C bytes (23580 to 26524), FATIGUE and EM_BLOCK by 0x40 each; the C structs, Lua tables, Python limits and tests follow it. Co-Authored-By: Claude Sonnet 5.5 --- framework/README.md | 4 ++-- packages/mhfu/src/mhfu/addresses.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/framework/README.md b/framework/README.md index 9585b387..1b342f72 100644 --- a/framework/README.md +++ b/framework/README.md @@ -136,9 +136,9 @@ the engine counts the flinch and applies the damage, then enters the move's carr ## Own moves from a manifest -A port's own moves sit in a registry by slot (`mhfu.em_move`): `mhfu.addr.OWN_MOVES` (96) of +A port's own moves sit in a registry by slot (`mhfu.em_move`): `mhfu.addr.OWN_MOVES` (128) of them, and the turn keys (`EM_KEYS`, 5632), travel samples (`EM_SAMPLES`, 512) and lunges -(`EM_LUNGES`, 16) they share, in one 23.6 KB partition block. These are consts in +(`EM_LUNGES`, 16) they share, in one 26.5 KB partition block. These are consts in `addresses.toml`: raising one is that one line, every offset after it is laid out from it. The seam's brain, in C on the AI step, plays one through the move player when asked (`mhfu.em_play`), when the move before it ends on its clip, its length, its stop or a wall and names it as its `after`, or when a rule diff --git a/packages/mhfu/src/mhfu/addresses.toml b/packages/mhfu/src/mhfu/addresses.toml index 7d47ee68..09749c7d 100644 --- a/packages/mhfu/src/mhfu/addresses.toml +++ b/packages/mhfu/src/mhfu/addresses.toml @@ -1212,7 +1212,7 @@ names = ["clip", "back", "pair", "stopped", "replaced", "refused", "lost", "wall OWN_MOVE_MAIN = { value = 16, doc = "an AI script `enter` main no species has (they use 0..7; 16 is past the 8-bit main mask of em_vhook's substitutions): em_vhook's enter-action stub enters the carrier of own move (an EM_MOVES slot; em75's hub (0,2) when it is empty or out of range) instead and plays that move at the next AI step" } CHARGE_QUERY_CHARGED = { value = 0, doc = "the AI script `switch_query` arg that asks whether a port with `[brain] charge` is charged (MHP3rd's own numbering of the query): the framework answers 1 or 0" } CHARGE_QUERY_COOLDOWN = { value = 6, doc = "the `switch_query` arg that asks whether the charge cooldown runs (MHP3rd's numbering): the framework answers 1 or 0" } -OWN_MOVES = { value = 96, doc = "own moves the framework's registry holds, EM_MOVES slots 0..OWN_MOVES-1: every table keyed by slot, and the offsets after one, follow it" } +OWN_MOVES = { value = 128, doc = "own moves the framework's registry holds, EM_MOVES slots 0..OWN_MOVES-1: every table keyed by slot, and the offsets after one, follow it" } EM_KEYS = { value = 5632, doc = "turn keys all own moves share in EM_MOVES.KEYS" } EM_SAMPLES = { value = 512, doc = "travel samples all own moves share in EM_MOVES.SAMPLES" } EM_LUNGES = { value = 16, doc = "lunges the registry holds, one per own move that has one" } From 426486901f4b4c53f4ea4b2e43c0cac770ff1068 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:43:04 +0200 Subject: [PATCH 6/9] framework, mhfu-port: a run a registry pool holds is shared, and the check counts that mhfu_em_move reuses an identical run already in the pool, anywhere in it, instead of appending: turn keys and travel samples word for word, a lunge byte for byte. A slot that is rewritten with what it had adds nothing. mhfu_port.moves.Pools is the same rule in Python; `problems` counts the pools through it, in slot order, and a test runs equal, nested and distinct runs through the host registry and compares its tops and each slot's offsets with Pools'. Co-Authored-By: Claude Sonnet 5.5 --- framework/README.md | 4 +- framework/include/mhfu/em_vhook.h | 6 +- framework/src/core/em_vhook.cpp | 63 ++++++++---- framework/tests/test_em_brain.py | 78 +++++++++++---- packages/mhfu-port/src/mhfu_port/moves.py | 111 ++++++++++++++++++---- packages/mhfu-port/tests/test_moves.py | 44 ++++++++- packages/mhfu/src/mhfu/addresses.toml | 6 +- 7 files changed, 244 insertions(+), 68 deletions(-) diff --git a/framework/README.md b/framework/README.md index 1b342f72..062fed53 100644 --- a/framework/README.md +++ b/framework/README.md @@ -139,7 +139,9 @@ the engine counts the flinch and applies the damage, then enters the move's carr A port's own moves sit in a registry by slot (`mhfu.em_move`): `mhfu.addr.OWN_MOVES` (128) of them, and the turn keys (`EM_KEYS`, 5632), travel samples (`EM_SAMPLES`, 512) and lunges (`EM_LUNGES`, 16) they share, in one 26.5 KB partition block. These are consts in -`addresses.toml`: raising one is that one line, every offset after it is laid out from it. The seam's +`addresses.toml`: raising one is that one line, every offset after it is laid out from it. A run +a pool holds already, anywhere in it, word for word (a lunge byte for byte), is shared, not +appended: `mhfu_port.moves.Pools` counts the same way, so a module its check passes fits. The seam's brain, in C on the AI step, plays one through the move player when asked (`mhfu.em_play`), when the move before it ends on its clip, its length, its stop or a wall and names it as its `after`, or when a rule says (`play_move`, and `from_move` to fire while one plays). `mhfu_port.lua` does all of it from the diff --git a/framework/include/mhfu/em_vhook.h b/framework/include/mhfu/em_vhook.h index 41740d8d..fda82f5d 100644 --- a/framework/include/mhfu/em_vhook.h +++ b/framework/include/mhfu/em_vhook.h @@ -216,8 +216,10 @@ typedef struct { /* Empties every slot and the pools: turn keys, travel samples, lunges. */ void mhfu_em_moves_clear(void); -/* Slot `slot` plays mv, steered by s (its keys, travel samples and lunge copied); `after` is a - * slot or MHFU_EM_NO_MOVE. 0 for a bad slot or no room left in a pool. */ +/* Slot `slot` plays mv, steered by s; `after` is a slot or MHFU_EM_NO_MOVE. Its keys, travel + * samples and lunge are copied into the pools unless a pool holds that run already, anywhere, + * word for word (a lunge byte for byte): then the slot points there. 0 for a bad slot or no room + * left in a pool for what is new. */ int mhfu_em_move(int slot, const mhfu_move_t *mv, const mhfu_steer_spec_t *s, uint8_t after); /* Plays slot on entity at its next AI step, forced past the notice wait (MOVE.FORCE) or not; 0 * while nothing is wrapped or the slot is empty. */ diff --git a/framework/src/core/em_vhook.cpp b/framework/src/core/em_vhook.cpp index 4f4424a5..99373e81 100644 --- a/framework/src/core/em_vhook.cpp +++ b/framework/src/core/em_vhook.cpp @@ -1538,9 +1538,26 @@ extern "C" void mhfu_em_moves_clear(void) R->key_top = R->sample_top = R->lunge_top = 0; } -/* A slot is rewritten off (VALID 0) and its keys and samples appended: a brain that reads it - * meanwhile sees it empty, and those a rewrite leaves behind wait for mhfu_em_moves_clear. A - * lunge keeps its place when the slot had one. */ +/* Where a pool of `top` units of `size` bytes holds the `n` units of `run` byte for byte, the + * first such place in the whole pool; `top` where it does not (a run of none is anywhere: 0). + * mhfu_port.moves.Pools is this rule on the Python side. */ +static uint32_t find_run(const volatile void *pool, uint32_t top, const void *run, uint32_t n, + unsigned size) +{ + if (!n) return 0; + const volatile uint8_t *p = (const volatile uint8_t *)pool; + const uint8_t *r = (const uint8_t *)run; + for (uint32_t i = 0; i + n <= top; i++) { + uint32_t k = 0; + while (k < n * size && p[i * size + k] == r[k]) k++; + if (k == n * size) return i; + } + return top; +} + +/* A slot is rewritten off (VALID 0) and what its keys, samples and lunge need appended, unless + * the pool holds that run already (a run is shared, never moved): a brain that reads the slot + * meanwhile sees it empty, and the runs a rewrite leaves behind wait for mhfu_em_moves_clear. */ extern "C" int mhfu_em_move(int slot, const mhfu_move_t *mv, const mhfu_steer_spec_t *s, uint8_t after) { @@ -1549,19 +1566,25 @@ extern "C" int mhfu_em_move(int slot, const mhfu_move_t *mv, const mhfu_steer_sp uint32_t m = s->sample_count > MHFU_STEER_SAMPLES ? MHFU_STEER_SAMPLES : s->sample_count; volatile mhfu_em_own_t *o = &R->moves[slot]; const bool lunge = s->lunge.hi != 0; - const bool held = o->valid && o->lunge_at < R->lunge_top; - const uint8_t at = held ? o->lunge_at : (uint8_t)R->lunge_top; - if (R->key_top + n > MHFU_EM_KEYS) { + const uint32_t key_at = find_run(R->keys, R->key_top, s->keys, n, sizeof(uint16_t)); + const uint32_t sample_at = + find_run(R->samples, R->sample_top, s->samples, m, sizeof(mhfu_travel_sample_t)); + const uint32_t lunge_at = + lunge ? find_run(R->lunges, R->lunge_top, &s->lunge, 1, sizeof(mhfu_steer_lunge_t)) : 0; + const bool key_new = key_at == R->key_top && n; + const bool sample_new = sample_at == R->sample_top && m; + const bool lunge_new = lunge && lunge_at == R->lunge_top; + if (key_new && R->key_top + n > MHFU_EM_KEYS) { mhfu_log("[%s] own move %d: %u turn keys, %u left", OWNER, slot, (unsigned)n, (unsigned)(MHFU_EM_KEYS - R->key_top)); return 0; } - if (R->sample_top + m > MHFU_EM_SAMPLES) { + if (sample_new && R->sample_top + m > MHFU_EM_SAMPLES) { mhfu_log("[%s] own move %d: %u travel samples, %u left", OWNER, slot, (unsigned)m, (unsigned)(MHFU_EM_SAMPLES - R->sample_top)); return 0; } - if (lunge && !held && R->lunge_top >= MHFU_EM_LUNGES) { + if (lunge_new && R->lunge_top >= MHFU_EM_LUNGES) { mhfu_log("[%s] own move %d: no room for a lunge (%d)", OWNER, slot, MHFU_EM_LUNGES); return 0; } @@ -1576,19 +1599,23 @@ extern "C" int mhfu_em_move(int slot, const mhfu_move_t *mv, const mhfu_steer_sp o->move.back_mode = 0; } copy_words(&o->steer, &s->steer, sizeof(s->steer)); - o->key_at = (uint16_t)R->key_top; + if (key_new) { + for (uint32_t k = 0; k < n; k++) R->keys[key_at + k] = s->keys[k]; + R->key_top += n; + } + o->key_at = (uint16_t)key_at; o->key_count = (uint16_t)n; - for (uint32_t k = 0; k < n; k++) R->keys[R->key_top + k] = s->keys[k]; - R->key_top += n; - o->sample_at = (uint16_t)R->sample_top; + if (sample_new) { + copy_words(&R->samples[sample_at], s->samples, m * sizeof(mhfu_travel_sample_t)); + R->sample_top += m; + } + o->sample_at = (uint16_t)sample_at; o->sample_count = (uint16_t)m; - if (m) copy_words(&R->samples[R->sample_top], s->samples, m * sizeof(mhfu_travel_sample_t)); - R->sample_top += m; - if (lunge) { - copy_words(&R->lunges[at], &s->lunge, sizeof(s->lunge)); - if (!held) R->lunge_top++; + if (lunge_new) { + copy_words(&R->lunges[lunge_at], &s->lunge, sizeof(s->lunge)); + R->lunge_top++; } - o->lunge_at = lunge ? at : MHFU_EM_NO_LUNGE; + o->lunge_at = lunge ? (uint8_t)lunge_at : MHFU_EM_NO_LUNGE; o->stuck_main = s->stuck_main; o->stuck_sub = s->stuck_sub; o->stuck_mode = s->stuck_mode; diff --git a/framework/tests/test_em_brain.py b/framework/tests/test_em_brain.py index 957abb19..a71c37cc 100644 --- a/framework/tests/test_em_brain.py +++ b/framework/tests/test_em_brain.py @@ -14,6 +14,8 @@ import pytest from mhfu import addresses as a from mhfu import steering +from mhfu.live.moves import Lunge +from mhfu_port.moves import Pools BASE = 0x09000000 # em_host.cpp's memory; noaddr ENT = BASE + 0x1000 @@ -221,7 +223,7 @@ def own( slot: int, entry: int, after: int = NO, - keys: int = 0, + keys: Sequence[int] = (), *, turn: int = 0, window: tuple[int, int] = (0, 0), @@ -229,14 +231,16 @@ def own( lunge: tuple[int, int, float, float, float, float] | None = None, **kw: int, ) -> int: - """Own move `slot`; `turn` and `window` are its steer's (a hunter turn at rate 64), - `samples` its travel in sample units and `lunge` (lo, hi, near, span, base, range).""" + """Own move `slot`; `keys` are its turn keys, `turn` and `window` its steer's (a hunter + turn at rate 64), `samples` its travel in sample units and `lunge` (lo, hi, near, span, + base, range).""" mv = Move(entry=entry, carrier_main=0, carrier_sub=2, back_main=NO) for k, v in kw.items(): setattr(mv, k, v) spec = ctypes.create_string_buffer(a.STEER_SPEC.size) self.lib.mhfu_steer_init_spec(spec) - struct.pack_into(" None: full = a.EM_MOVES.KEYS.count // 256 for slot in range(full): - assert g.own(slot, 46, keys=256) - assert not g.own(full, 46, keys=1) + assert g.own(slot, 46, keys=[slot] * 256) + assert not g.own(full, 46, keys=[full]) and g.moves("KEY_TOP") == full * 256 + assert g.own(full, 46, keys=[0] * 7) # held already: no room needed g.lib.mhfu_em_moves_clear() - assert g.own(full, 46, keys=256) and g.moves("KEY_TOP") == 256 + assert g.own(full, 46, keys=[full] * 256) and g.moves("KEY_TOP") == 256 def played_to_the_end(g: Game, slot: int) -> tuple[float, float]: @@ -621,37 +626,72 @@ def test_the_registry_hands_a_slots_travel_and_lunge_to_the_move_player(g: Game) g.hunter_at(0.0, 0.0) struct.pack_into(" None: full = a.EM_MOVES.SAMPLES.count // a.STEER_SPEC.SAMPLES.count for slot in range(full): - assert g.own(slot, 46, samples=[(k, k) for k in range(a.STEER_SPEC.SAMPLES.count)]) - assert not g.own(full, 46, samples=[(0, 0)]) and g.own(full, 46) + assert g.own(slot, 46, samples=[(slot, k) for k in range(a.STEER_SPEC.SAMPLES.count)]) + assert not g.own(full, 46, samples=[(full, 0)]) and g.own(full, 46) + assert g.own(full, 46, samples=[(0, 0), (0, 1)]) # a run held already g.lib.mhfu_em_moves_clear() assert g.moves("SAMPLE_TOP") == 0 assert g.own(full, 46, samples=[(0, 0)] * 3) and g.moves("SAMPLE_TOP") == 3 -def test_lunges_have_room_and_a_rewrite_keeps_its_place(g: Game) -> None: - lunge = (0, 10, 400.0, 1000.0, 0.0, 500.0) +def test_lunges_have_room_and_a_rewrite_adds_only_what_is_new(g: Game) -> None: + def lunge(k: int) -> tuple[int, int, float, float, float, float]: + return 0, 10, 400.0 + k, 1000.0, 0.0, 500.0 + for slot in range(a.EM_LUNGES): - assert g.own(slot, 46, lunge=lunge) - assert not g.own(a.EM_LUNGES, 46, lunge=lunge) and g.moves("LUNGE_TOP") == a.EM_LUNGES - assert g.own(3, 46, lunge=(0, 20, 1.0, 2.0, 3.0, 4.0)) # in place - assert g.moves("LUNGE_TOP") == a.EM_LUNGES and g.slot(3, "LUNGE_AT") == 3 + assert g.own(slot, 46, lunge=lunge(slot)) + assert not g.own(a.EM_LUNGES, 46, lunge=lunge(99)) and g.moves("LUNGE_TOP") == a.EM_LUNGES + assert g.own(a.EM_LUNGES, 46, lunge=lunge(5)) and g.slot(a.EM_LUNGES, "LUNGE_AT") == 5 + assert g.own(3, 46, lunge=lunge(3)) and g.slot(3, "LUNGE_AT") == 3 # again: nothing new + assert not g.own(3, 46, lunge=(0, 20, 1.0, 2.0, 3.0, 4.0)) # a changed one is new assert g.own(3, 46) and g.slot(3, "LUNGE_AT") == NO g.lib.mhfu_em_moves_clear() - assert g.moves("LUNGE_TOP") == 0 and g.own(a.EM_LUNGES, 46, lunge=lunge) + assert g.moves("LUNGE_TOP") == 0 and g.own(a.EM_LUNGES, 46, lunge=lunge(0)) + + +def test_the_registry_holds_what_the_capacity_check_counts(g: Game) -> None: + """Slots with equal runs, a run inside another and equal lunges: the registry's tops and each + slot's offsets are `moves.Pools`' for the same moves in the same order.""" + run = [(k, -k) for k in range(1, 9)] + lunge = (0, 20, 100.0, 400.0, -300.0, 900.0) + spec = [ + ([1, 2, 3, 4, 5, 6], run[:5], None), + ([1, 2, 3, 4, 5, 6], run[:5], lunge), + ([3, 4, 5], run[2:5], lunge), + ([7, 7], [], (0, 20, 100.0, 400.0, -300.0, 901.0)), + ([6, 7], run, lunge), + ([], [], None), + ([1, 2, 3, 4, 5, 6, 7], run[:5], (0, 20, 100.0, 400.0, -300.0, 901.0)), + ] + pools = Pools() + for slot, (keys, samples, lunge_) in enumerate(spec): + record = None if lunge_ is None else Lunge((lunge_[0], lunge_[1]), *lunge_[2:]).pack() + want = pools.add(keys, samples, record) + assert g.own(slot, 46, keys=keys, samples=samples, lunge=lunge_) + got = ( + g.slot(slot, "KEY_AT", "H"), + g.slot(slot, "SAMPLE_AT", "H"), + g.slot(slot, "LUNGE_AT"), + ) + assert got == (want[0], want[1], NO if want[2] is None else want[2]) + tops = (g.moves("KEY_TOP"), g.moves("SAMPLE_TOP"), g.moves("LUNGE_TOP")) + assert tops == (len(pools.keys), len(pools.samples), len(pools.lunges)) + assert tops == (8, 13, 2) # a run holds what is inside it; a longer one is not extended def test_a_rule_on_an_event_fires_in_the_frame_it_is_seen(g: Game) -> None: diff --git a/packages/mhfu-port/src/mhfu_port/moves.py b/packages/mhfu-port/src/mhfu_port/moves.py index 369c99cb..b2573224 100644 --- a/packages/mhfu-port/src/mhfu_port/moves.py +++ b/packages/mhfu-port/src/mhfu_port/moves.py @@ -28,22 +28,26 @@ from __future__ import annotations -import math import re +import struct import zlib -from collections.abc import Collection, Mapping -from typing import Any +from collections.abc import Collection, Mapping, Sequence +from dataclasses import dataclass, field +from typing import Any, TypeVar from mhfu import addresses, hitbox, inject, steering from mhfu.files import Extracted, em_overlay +from mhfu.live import moves as live from . import behaviour from .behaviour import Rule from .layout import Layout -from .manifest import OWN_MOVES, Manifest, ManifestError, Move, span_problems +from .manifest import OWN_MOVES, Lunge, Manifest, ManifestError, Move, span_problems from .rig import Tip Pair = tuple[int, int] +T = TypeVar("T") +FLOAT_MAX = 3.4e38 HUBS: dict[int, Pair] = {75: (0, 2)} """Host species -> the pair an own move rides when it names no carrier: em75's alert hub, which @@ -62,6 +66,47 @@ """Lunges the framework holds for a port's own moves.""" +def _place(pool: list[T], run: Sequence[T]) -> int: + """Where `pool` holds `run` whole (the first such place), else where `run` was appended; an + empty run is at 0.""" + n = len(run) + if not n: + return 0 + i = -1 + while True: + try: + i = pool.index(run[0], i + 1) + except ValueError: + break + if pool[i : i + n] == list(run): + return i + at = len(pool) + pool.extend(run) + return at + + +@dataclass +class Pools: + """What the framework's registry holds of a port's own moves when `mhfu_em_move` fills it + slot by slot (em_vhook.cpp `find_run` is this rule): a move's turn keys, travel samples and + lunge record go where the pool already holds that run, anywhere in it, else at its end. The + capacity check counts these, so a module it passes fits.""" + + keys: list[int] = field(default_factory=list) + samples: list[tuple[int, int]] = field(default_factory=list) + lunges: list[bytes] = field(default_factory=list) + + def add( + self, + keys: Sequence[int], + samples: Sequence[tuple[int, int]], + lunge: bytes | None = None, + ) -> tuple[int, int, int | None]: + """Register one move's runs; where each starts (the lunge's index, None for no lunge).""" + at = (_place(self.keys, keys), _place(self.samples, samples)) + return *at, None if lunge is None else _place(self.lunges, [lunge]) + + def slots(m: Manifest) -> dict[str, int]: """Each own move's registry slot: sorted by name, numbered from 0, at most `SLOTS` (`problems` names the rest).""" @@ -133,6 +178,33 @@ def records(game: Extracted, species: int) -> frozenset[int]: return frozenset(r.index - off for r in ([] if t is None else t.attacks) if any(r.raw)) +def lunge_record(lunge: Lunge) -> bytes: + """A lunge as the registry holds it (struct STEER_LUNGE).""" + return live.Lunge(lunge.window, lunge.near, lunge.span, lunge.base, lunge.range).pack() + + +def keys_of(text: str | None) -> list[int]: + """The turn keys of a clips module's hex `text` (`curve`).""" + text = text or "" + return [int(text[i : i + 4], 16) for i in range(0, len(text), 4)] + + +def pools(m: Manifest, layout: Layout) -> Pools: + """The registry's pools once the own moves are registered in slot order, as `mhfu_port.lua` + does; a move `problems` refuses adds nothing.""" + out = Pools() + for name, mv in ((n, m.moves[n]) for n in slots(m)): + try: + entry(m, name, layout) + keys = keys_of(curve(m, name, layout)) + samples = steering.samples(mv.travel or ()) + record = None if mv.lunge is None else lunge_record(mv.lunge) + except (ManifestError, ValueError, OverflowError, struct.error): + continue + out.add(keys, samples, record) + return out + + def motion_problems(mv: Move, frames: int | None) -> list[tuple[str, str]]: """What the move player cannot carry of an own move's travel, lunge and fixed turn window, as (where under the move, text): travel samples past its capacity, its reach or the clip's @@ -159,7 +231,7 @@ def motion_problems(mv: Move, frames: int | None) -> list[tuple[str, str]]: elif lo < mv.start or (end is not None and hi > end): out.append((".lunge", f"window {lunge.window} is outside [{mv.start}, {end}]")) values = (lunge.near, lunge.span, lunge.base, lunge.range) - if not all(math.isfinite(v) for v in values) or lunge.near < 0 or lunge.span <= 0: + if not all(abs(v) <= FLOAT_MAX for v in values) or lunge.near < 0 or lunge.span <= 0: out.append((".lunge", "near is 0 or more, span above 0, all of them finite")) s = mv.steer if s.turn == "fixed" and s.window is not None: @@ -173,11 +245,10 @@ def problems(m: Manifest, layout: Layout, known: Collection[int] | None = None) """Every own move or rule the module cannot carry: more own moves than slots, a clip with no entry, a start or stop past its clip or an attack outside them (`manifest.span_problems`), an attack past its clip or with no record in `known` (the host's, when given), more turn keys, - travel samples or lunges than the framework holds, travel past the clip, a lunge or fixed - turn window outside `[start, stop]`, no carrier; rules on the flinch whose moves ride - different carriers.""" + travel samples or lunges than the framework holds (`Pools`: a run it holds twice is held + once), travel past the clip, a lunge or fixed turn window outside `[start, stop]`, no + carrier; rules on the flinch whose moves ride different carriers.""" out: list[str] = [] - pool = samples = lunges = 0 if (n := sum(mv.own for mv in m.moves.values())) > SLOTS: out.append(f"moves: {n} own moves, the framework holds {SLOTS}") for name, mv in m.moves.items(): @@ -193,8 +264,6 @@ def problems(m: Manifest, layout: Layout, known: Collection[int] | None = None) frames = layout.frames.get(e) out += [f"{w}{sub}: {text}" for sub, text in span_problems(mv, frames)] out += [f"{w}{sub}: {text}" for sub, text in motion_problems(mv, frames)] - samples += len(mv.travel or ()) - lunges += mv.lunge is not None for i, a in enumerate(mv.attacks): if known is not None and a.id not in known: out.append( @@ -203,19 +272,21 @@ def problems(m: Manifest, layout: Layout, known: Collection[int] | None = None) ) if frames is not None and a.frame >= frames: out.append(f"{w}.attack[{i}]: frame {a.frame} is past the clip's {frames}") - keys = len(curve(m, name, layout) or "") // 4 + keys = len(keys_of(curve(m, name, layout))) if keys > KEYS: out.append(f"{w}: the clip's turn has {keys} keys, a move holds {KEYS}") - pool += keys - if pool > POOL: - out.append(f"moves: the own moves' turns need {pool} keys, the framework holds {POOL}") - if samples > SAMPLE_POOL: + held = pools(m, layout) + if len(held.keys) > POOL: + out.append( + f"moves: the own moves' turns need {len(held.keys)} keys, the framework holds {POOL}" + ) + if len(held.samples) > SAMPLE_POOL: out.append( - f"moves: the own moves' travel needs {samples} samples, the framework holds " - f"{SAMPLE_POOL}" + f"moves: the own moves' travel needs {len(held.samples)} samples, the framework " + f"holds {SAMPLE_POOL}" ) - if lunges > LUNGES: - out.append(f"moves: {lunges} lunges, the framework holds {LUNGES}") + if len(held.lunges) > LUNGES: + out.append(f"moves: {len(held.lunges)} lunges, the framework holds {LUNGES}") flinch = set() try: rules = behaviour.compile(m) diff --git a/packages/mhfu-port/tests/test_moves.py b/packages/mhfu-port/tests/test_moves.py index bf76e98a..133be3a0 100644 --- a/packages/mhfu-port/tests/test_moves.py +++ b/packages/mhfu-port/tests/test_moves.py @@ -253,8 +253,10 @@ def test_hunter_windows_are_not_held_to_the_span(): def test_pools_of_samples_and_lunges(): lay = layout.Layout({}, frames={9: 400}) k = moves.SAMPLE_POOL // moves.SAMPLES + 1 - path = [[0, 0]] * moves.SAMPLES - many = "".join(f"[moves.t{i}]\nanim = 9\ncarrier = [0, 1]\ntravel = {path}\n" for i in range(k)) + many = "".join( + f"[moves.t{i}]\nanim = 9\ncarrier = [0, 1]\ntravel = {[[i + 1, 0]] * moves.SAMPLES}\n" + for i in range(k) + ) assert moves.problems(_m(many), lay, {6}) == [ f"moves: the own moves' travel needs {moves.SAMPLES * k} samples, the framework holds " f"{moves.SAMPLE_POOL}" @@ -262,7 +264,7 @@ def test_pools_of_samples_and_lunges(): n = moves.LUNGES + 1 lunging = "".join( f"[moves.l{i}]\nanim = 9\ncarrier = [0, 1]\n[moves.l{i}.lunge]\nwindow = [0, 9]\n" - "near = 1\nspan = 1\nbase = 0\nrange = 1\n" + f"near = {i + 1}\nspan = 1\nbase = 0\nrange = 1\n" for i in range(n) ) assert moves.problems(_m(lunging), lay, {6}) == [ @@ -270,6 +272,34 @@ def test_pools_of_samples_and_lunges(): ] +def test_runs_the_registry_holds_twice_count_once(): + lay = layout.Layout({}, frames={9: 400}) + k = max(moves.SAMPLE_POOL // moves.SAMPLES, moves.LUNGES) + 1 + same = "".join( + f"[moves.t{i}]\nanim = 9\ncarrier = [0, 1]\ntravel = {[[1, 2]] * moves.SAMPLES}\n" + f"[moves.t{i}.lunge]\nwindow = [0, 9]\nnear = 1\nspan = 1\nbase = 0\nrange = 1\n" + for i in range(k) + ) + assert moves.problems(_m(same), lay, {6}) == [] + held = moves.pools(_m(same), lay) + assert (len(held.samples), len(held.lunges)) == (moves.SAMPLES, 1) + + +def test_pools_place_a_run_where_the_pool_holds_it(): + p = moves.Pools() + assert p.add([1, 2, 3], [(0, 0), (1, 1)], b"a") == (0, 0, 0) + assert p.add([1, 2, 3], [(0, 0), (1, 1)], b"a") == (0, 0, 0) + assert p.add([2, 3], [(1, 1)], b"b") == (1, 1, 1) + assert p.add([3, 4], [(0, 0), (2, 2)]) == (3, 2, None) + assert p.add([3], [(2, 2)], b"a") == (2, 3, 0) + assert p.add([], []) == (0, 0, None) + assert (p.keys, p.samples, p.lunges) == ( + [1, 2, 3, 3, 4], + [(0, 0), (1, 1), (0, 0), (2, 2)], + [b"a", b"b"], + ) + + def test_tip(): tip = Tip(((46, 42), (47, 42), (48, 43)), (0.0, 0.0, -705.0)) body = _body(moves.lua(_m(), LAYOUT, {6}, tip)) @@ -421,13 +451,17 @@ def test_check(extra, lay, known, why): def test_pool(): - lay = layout.Layout(LAYOUT.entries, turns={46: travel.Turn(500, (0,) * 251, 0, None)}) n = moves.POOL // 251 + 1 - many = "".join(f"[moves.s{i}]\nclip = 'stamp'\n" for i in range(n)) + lay = layout.Layout( + {}, turns={100 + i: travel.Turn(500, (i,) * 251, 0, None) for i in range(n)} + ) + many = "".join(f"[moves.s{i}]\nanim = {100 + i}\ncarrier = [0, 1]\n" for i in range(n)) with pytest.raises( ManifestError, match=f"need {251 * n} keys, the framework holds {moves.POOL}" ): moves.check(_m(many), lay) + twice = "".join(f"[moves.s{i}]\nanim = 100\ncarrier = [0, 1]\n" for i in range(n)) + moves.check(_m(twice), lay) # one turn run, held once def test_manifest_limits(): diff --git a/packages/mhfu/src/mhfu/addresses.toml b/packages/mhfu/src/mhfu/addresses.toml index 09749c7d..6fa12eed 100644 --- a/packages/mhfu/src/mhfu/addresses.toml +++ b/packages/mhfu/src/mhfu/addresses.toml @@ -1213,9 +1213,9 @@ OWN_MOVE_MAIN = { value = 16, doc = "an AI script `enter` main no species has (t CHARGE_QUERY_CHARGED = { value = 0, doc = "the AI script `switch_query` arg that asks whether a port with `[brain] charge` is charged (MHP3rd's own numbering of the query): the framework answers 1 or 0" } CHARGE_QUERY_COOLDOWN = { value = 6, doc = "the `switch_query` arg that asks whether the charge cooldown runs (MHP3rd's numbering): the framework answers 1 or 0" } OWN_MOVES = { value = 128, doc = "own moves the framework's registry holds, EM_MOVES slots 0..OWN_MOVES-1: every table keyed by slot, and the offsets after one, follow it" } -EM_KEYS = { value = 5632, doc = "turn keys all own moves share in EM_MOVES.KEYS" } -EM_SAMPLES = { value = 512, doc = "travel samples all own moves share in EM_MOVES.SAMPLES" } -EM_LUNGES = { value = 16, doc = "lunges the registry holds, one per own move that has one" } +EM_KEYS = { value = 5632, doc = "turn keys all own moves share in EM_MOVES.KEYS, a run held twice once" } +EM_SAMPLES = { value = 512, doc = "travel samples all own moves share in EM_MOVES.SAMPLES, a run held twice once" } +EM_LUNGES = { value = 16, doc = "lunges the registry holds, an equal one of two moves once" } STEER_KEYS = { value = 256, doc = "turn keys one move holds (STEER_SPEC.KEYS)" } STEER_KEY_STEP = { value = 2, doc = "clip frames between a move's turn keys and between its travel samples: the cursor's step at speed 2, so it lands on them" } STEER_SAMPLES = { value = 128, doc = "travel samples one move holds (STEER_SPEC.SAMPLES)" } From fa6027c36716833c22f994132d962ef20fdc3efc Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:21:46 +0200 Subject: [PATCH 7/9] framework: size the registry's pools to the Zinogre's need after sharing EM_KEYS 5632 -> 2944, EM_SAMPLES 512 -> 1280, EM_LUNGES 16 -> 12: the need of the Zinogre's 125 own moves (2432 keys, 1052 samples, 10 lunges once equal runs are shared) and about 20 %, in multiples of 64 (lunges of 4). EM_MOVES goes from 0x679C to 0x5E4C bytes (26524 to 24140). Co-Authored-By: Claude Sonnet 5.5 --- framework/README.md | 4 ++-- framework/tests/test_em_brain.py | 20 +++++++++++--------- packages/mhfu/src/mhfu/addresses.toml | 6 +++--- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/framework/README.md b/framework/README.md index 062fed53..4d4871a1 100644 --- a/framework/README.md +++ b/framework/README.md @@ -137,8 +137,8 @@ the engine counts the flinch and applies the damage, then enters the move's carr ## Own moves from a manifest A port's own moves sit in a registry by slot (`mhfu.em_move`): `mhfu.addr.OWN_MOVES` (128) of -them, and the turn keys (`EM_KEYS`, 5632), travel samples (`EM_SAMPLES`, 512) and lunges -(`EM_LUNGES`, 16) they share, in one 26.5 KB partition block. These are consts in +them, and the turn keys (`EM_KEYS`, 2944), travel samples (`EM_SAMPLES`, 1280) and lunges +(`EM_LUNGES`, 12) they share, in one 24 KB partition block. These are consts in `addresses.toml`: raising one is that one line, every offset after it is laid out from it. A run a pool holds already, anywhere in it, word for word (a lunge byte for byte), is shared, not appended: `mhfu_port.moves.Pools` counts the same way, so a module its check passes fits. The seam's diff --git a/framework/tests/test_em_brain.py b/framework/tests/test_em_brain.py index a71c37cc..f6543042 100644 --- a/framework/tests/test_em_brain.py +++ b/framework/tests/test_em_brain.py @@ -601,10 +601,11 @@ def test_an_own_moves_hunter_turn_keeps_to_its_steer_window( def test_keys_have_room(g: Game) -> None: - full = a.EM_MOVES.KEYS.count // 256 - for slot in range(full): - assert g.own(slot, 46, keys=[slot] * 256) - assert not g.own(full, 46, keys=[full]) and g.moves("KEY_TOP") == full * 256 + cap = a.EM_MOVES.KEYS.count + for slot, start in enumerate(range(0, cap, 256)): + assert g.own(slot, 46, keys=[slot] * min(256, cap - start)) + full = slot + 1 + assert g.moves("KEY_TOP") == cap and not g.own(full, 46, keys=[full]) assert g.own(full, 46, keys=[0] * 7) # held already: no room needed g.lib.mhfu_em_moves_clear() assert g.own(full, 46, keys=[full] * 256) and g.moves("KEY_TOP") == 256 @@ -639,11 +640,12 @@ def test_the_registry_hands_a_slots_travel_and_lunge_to_the_move_player(g: Game) def test_samples_have_room(g: Game) -> None: - full = a.EM_MOVES.SAMPLES.count // a.STEER_SPEC.SAMPLES.count - for slot in range(full): - assert g.own(slot, 46, samples=[(slot, k) for k in range(a.STEER_SPEC.SAMPLES.count)]) - assert not g.own(full, 46, samples=[(full, 0)]) and g.own(full, 46) - assert g.own(full, 46, samples=[(0, 0), (0, 1)]) # a run held already + cap, most = a.EM_MOVES.SAMPLES.count, a.STEER_SPEC.SAMPLES.count + for slot, start in enumerate(range(0, cap, most)): + assert g.own(slot, 46, samples=[(slot, k) for k in range(min(most, cap - start))]) + full = slot + 1 + assert g.moves("SAMPLE_TOP") == cap and not g.own(full, 46, samples=[(full, 0)]) + assert g.own(full, 46) and g.own(full, 46, samples=[(0, 0), (0, 1)]) # held already g.lib.mhfu_em_moves_clear() assert g.moves("SAMPLE_TOP") == 0 assert g.own(full, 46, samples=[(0, 0)] * 3) and g.moves("SAMPLE_TOP") == 3 diff --git a/packages/mhfu/src/mhfu/addresses.toml b/packages/mhfu/src/mhfu/addresses.toml index 6fa12eed..be710b36 100644 --- a/packages/mhfu/src/mhfu/addresses.toml +++ b/packages/mhfu/src/mhfu/addresses.toml @@ -1213,9 +1213,9 @@ OWN_MOVE_MAIN = { value = 16, doc = "an AI script `enter` main no species has (t CHARGE_QUERY_CHARGED = { value = 0, doc = "the AI script `switch_query` arg that asks whether a port with `[brain] charge` is charged (MHP3rd's own numbering of the query): the framework answers 1 or 0" } CHARGE_QUERY_COOLDOWN = { value = 6, doc = "the `switch_query` arg that asks whether the charge cooldown runs (MHP3rd's numbering): the framework answers 1 or 0" } OWN_MOVES = { value = 128, doc = "own moves the framework's registry holds, EM_MOVES slots 0..OWN_MOVES-1: every table keyed by slot, and the offsets after one, follow it" } -EM_KEYS = { value = 5632, doc = "turn keys all own moves share in EM_MOVES.KEYS, a run held twice once" } -EM_SAMPLES = { value = 512, doc = "travel samples all own moves share in EM_MOVES.SAMPLES, a run held twice once" } -EM_LUNGES = { value = 16, doc = "lunges the registry holds, an equal one of two moves once" } +EM_KEYS = { value = 2944, doc = "turn keys all own moves share in EM_MOVES.KEYS, a run held twice once: the Zinogre's 125 moves hold 2432" } +EM_SAMPLES = { value = 1280, doc = "travel samples all own moves share in EM_MOVES.SAMPLES, a run held twice once: the Zinogre's hold 1052" } +EM_LUNGES = { value = 12, doc = "lunges the registry holds, an equal one of two moves once: the Zinogre's hold 10" } STEER_KEYS = { value = 256, doc = "turn keys one move holds (STEER_SPEC.KEYS)" } STEER_KEY_STEP = { value = 2, doc = "clip frames between a move's turn keys and between its travel samples: the cursor's step at speed 2, so it lands on them" } STEER_SAMPLES = { value = 128, doc = "travel samples one move holds (STEER_SPEC.SAMPLES)" } From 2925ef77f7cae47d4461e938456a55bc3f4b09e3 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:50:17 +0200 Subject: [PATCH 8/9] framework: a move holds 132 travel samples The Brute's longest derived walk travels over 130 samples (a 260-frame clip), which the move player's 128 refused once the check ran on it. STEER_SAMPLES is 132; EM_MOVES grows by 16 bytes, to 0x5E5C (24156). Co-Authored-By: Claude Sonnet 5.5 --- packages/mhfu/src/mhfu/addresses.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mhfu/src/mhfu/addresses.toml b/packages/mhfu/src/mhfu/addresses.toml index be710b36..b33e2484 100644 --- a/packages/mhfu/src/mhfu/addresses.toml +++ b/packages/mhfu/src/mhfu/addresses.toml @@ -1218,5 +1218,5 @@ EM_SAMPLES = { value = 1280, doc = "travel samples all own moves share in EM_MOV EM_LUNGES = { value = 12, doc = "lunges the registry holds, an equal one of two moves once: the Zinogre's hold 10" } STEER_KEYS = { value = 256, doc = "turn keys one move holds (STEER_SPEC.KEYS)" } STEER_KEY_STEP = { value = 2, doc = "clip frames between a move's turn keys and between its travel samples: the cursor's step at speed 2, so it lands on them" } -STEER_SAMPLES = { value = 128, doc = "travel samples one move holds (STEER_SPEC.SAMPLES)" } +STEER_SAMPLES = { value = 132, doc = "travel samples one move holds (STEER_SPEC.SAMPLES): 2 clip frames each, so a clip of 262 frames; the Brute's longest walk has 130" } STEER_TRAVEL_SCALE = { value = 4, doc = "a travel sample's units to a world unit: its x and z are 16-bit multiples of 1/STEER_TRAVEL_SCALE" } From 1c2139a9bb9af506083bdb46ff6221f5f2bcd979 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:36:10 +0200 Subject: [PATCH 9/9] framework: a test holds a port's capacity check to the registry A manifest whose own moves repeat turn keys, travel and lunges is registered slot by slot through the host registry; its tops are those of mhfu_port.moves.pools. Co-Authored-By: Claude Sonnet 5.5 --- framework/tests/test_em_brain.py | 52 ++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/framework/tests/test_em_brain.py b/framework/tests/test_em_brain.py index f6543042..9edd1836 100644 --- a/framework/tests/test_em_brain.py +++ b/framework/tests/test_em_brain.py @@ -15,6 +15,7 @@ from mhfu import addresses as a from mhfu import steering from mhfu.live.moves import Lunge +from mhfu_port import layout, manifest, moves, travel from mhfu_port.moves import Pools BASE = 0x09000000 # em_host.cpp's memory; noaddr @@ -666,9 +667,9 @@ def lunge(k: int) -> tuple[int, int, float, float, float, float]: assert g.moves("LUNGE_TOP") == 0 and g.own(a.EM_LUNGES, 46, lunge=lunge(0)) -def test_the_registry_holds_what_the_capacity_check_counts(g: Game) -> None: +def test_the_registry_places_runs_where_pools_does(g: Game) -> None: """Slots with equal runs, a run inside another and equal lunges: the registry's tops and each - slot's offsets are `moves.Pools`' for the same moves in the same order.""" + slot's offsets are `Pools`' for the same moves in the same order.""" run = [(k, -k) for k in range(1, 9)] lunge = (0, 20, 100.0, 400.0, -300.0, 900.0) spec = [ @@ -696,6 +697,53 @@ def test_the_registry_holds_what_the_capacity_check_counts(g: Game) -> None: assert tops == (8, 13, 2) # a run holds what is inside it; a longer one is not extended +PORT = '[port]\nname = "z"\nhost_species = 75\npac = "z.bin"\n[source]\nmodel = 5339\n' + + +def test_the_registry_holds_what_a_ports_capacity_check_counts(g: Game) -> None: + """A port whose own moves repeat turn keys, travel and lunges, registered slot by slot as + `mhfu_port.lua` does: the registry's tops are those of `moves.pools`, the check's count.""" + keys = (1, 2, 3, 4, 5, 6) + lay = layout.Layout( + {}, + turns={ + 9: travel.Turn(12, keys, 0, None), + 10: travel.Turn(6, keys[2:5], 0, None), + 11: travel.Turn(12, keys[::-1], 0, None), + }, + ) + path = [[0, 0], [1, 2], [3, 4]] + rows = [ # entry, travel, the lunge's range + (9, path, 900), + (9, path, 900), + (10, path[1:], 900), + (11, path + [[5, 6]], 901), + (10, [], None), + ] + text = PORT + for i, (entry, route, reach) in enumerate(rows): + text += f"[moves.m{i}]\nanim = {entry}\ncarrier = [0, 1]\ntravel = {route}\n" + if reach is not None: + text += f"[moves.m{i}.lunge]\nwindow = [0, 20]\nnear = 100\nspan = 400\nbase = -300\n" + text += f"range = {reach}\n" + m = manifest.loads(text) + assert moves.problems(m, lay) == [] + held = moves.pools(m, lay) + for name, slot in moves.slots(m).items(): + mv = m.moves[name] + lng = mv.lunge + assert g.own( + slot, + 46, + keys=moves.keys_of(moves.curve(m, name, lay)), + samples=steering.samples(mv.travel or ()), + lunge=None if lng is None else (*lng.window, lng.near, lng.span, lng.base, lng.range), + ) + tops = (g.moves("KEY_TOP"), g.moves("SAMPLE_TOP"), g.moves("LUNGE_TOP")) + assert tops == (len(held.keys), len(held.samples), len(held.lunges)) + assert tops == (12, 7, 2) # of 24 keys, 12 samples and 4 lunges, each run held once + + def test_a_rule_on_an_event_fires_in_the_frame_it_is_seen(g: Game) -> None: g.own(0, 46) g.rule(0, on=NOTICED, play_move=1, force=1)