From b7d5a5d4d7b2b9bc056fbf6212e1e90c540ad91a Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:46:27 +0200 Subject: [PATCH 1/2] feat(framework): a move starts and stops on clip frames MOVE.START is the executor's a3 on the dispatch: every part's cursor starts there, held for the blend as for any clip, and an attack before it never spawns (one at it spawns on the first playing frame). MOVE.STOP ends the move through the timed branch when PART's cursor reaches it, whatever the blend or the cursor's rate; LENGTH stays AI frames. With both 0 the move player writes what it did. MOVE grows to 0x38, so MOVE_STATE, EM_OWN, EM_MOVES and CLI_BRIDGE shift with it. ENTITY.ANIM_MODE and ANIM_SPEED are a slot's start and blend frames, not mode and speed: their docs are corrected and the names kept, as C, Lua, the HUD and the shell read them under those names. CLIP_BLOCK gains BLEND, and the cursor tests' docs say they answer 0 while a blend runs. Co-Authored-By: Claude Sonnet 5.5 --- framework/README.md | 10 +++- framework/include/mhfu/em_vhook.h | 4 +- framework/include/mhfu/move.h | 16 +++++- framework/lua/meta/mhfu.d.lua | 8 ++- framework/mods/lua_host/bind_move.cpp | 5 ++ framework/src/core/em_vhook.cpp | 4 +- framework/src/core/move.cpp | 22 ++++--- framework/tests/em_host.cpp | 8 +-- framework/tests/move_host.cpp | 14 +++-- framework/tests/test_em_brain.py | 13 +++++ framework/tests/test_move.py | 66 ++++++++++++++++++++- packages/mhfu/src/mhfu/addresses.toml | 83 ++++++++++++++------------- packages/mhfu/src/mhfu/cli/moves.py | 4 ++ packages/mhfu/src/mhfu/live/moves.py | 9 +++ packages/mhfu/tests/test_moves.py | 8 +++ 15 files changed, 205 insertions(+), 69 deletions(-) diff --git a/framework/README.md b/framework/README.md index 2a300378..15506ee2 100644 --- a/framework/README.md +++ b/framework/README.md @@ -116,6 +116,12 @@ for while the monster's notice runs (it knows the player, not yet in combat) wai and the combat entry; the call's `force` starts it at once (`mhfu.move_play(ent, spec, true)`). `mhfu.monster_state(ent)` says whether it noticed the player, is in combat, runs its notice. +`start` and `stop` are clip frames: the clip starts at `start` (the executor's a3, so every part +agrees and an attack before it never spawns) and the move ends when the timing part's cursor +reaches `stop`, as the clip's end or `length` would end it. Neither depends on the blend (2 to 5 +AI frames held at `start`) or on the cursor's rate (2.0 a frame, 2.8 enraged); `length` counts +AI frames from the dispatch and does. + ## Monster events The same AI step finds what a mod may answer, one AI frame after the engine's change at most, @@ -131,10 +137,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 19.9 KB +(96) of them, and the turn keys they share (`EM_MOVES.KEYS_COUNT`, 5632), in one 20.3 KB partition block. 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 or a wall and names it as its `after`, or when a rule +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 port's generated `_moves.lua`: `port:move("stamp")` (`ports/README.md` "Moves and behaviour"). Its `tip` goes to `mhfu.em_tip`: each tip joint takes its carrier's pose after the diff --git a/framework/include/mhfu/em_vhook.h b/framework/include/mhfu/em_vhook.h index e7d727f3..65fefaf4 100644 --- a/framework/include/mhfu/em_vhook.h +++ b/framework/include/mhfu/em_vhook.h @@ -170,8 +170,8 @@ int mhfu_em_tip(const uint8_t (*pairs)[2], int n); /* --- own moves ------------------------------------------------------------------------------ * A port's own moves, by slot, in partition memory. The brain, in C on the wrapped AI step, plays * one through the move player that same AI frame: one asked for (mhfu_em_play), else the AFTER of - * one of ours that ended on its clip, its length or a wall (forced as that one was), else a - * rule's. A slot with an AFTER ends into its carrier (its back pair) with the AI script's wake + * one of ours that ended on its clip, its length, its stop or a wall (forced as that one was), + * else a rule's. A slot with an AFTER ends into its carrier (its back pair) with the AI script's wake * cleared (MOVE.HOLD_SCRIPT), so the host brain does not cut in before the next. A slot's FORCE is * the call's, never the slot's. * diff --git a/framework/include/mhfu/move.h b/framework/include/mhfu/move.h index b389014f..c98657d0 100644 --- a/framework/include/mhfu/move.h +++ b/framework/include/mhfu/move.h @@ -8,8 +8,16 @@ * crosses its frame, and an attack with an end frame is ended there (or when the move ends * first) through the node's own end state; without one it lives as long as its record says. The * move ends when its clip does (the carrier hands off to the brain, or - * the move enters its back pair), after LENGTH frames, or when anything else changes the pair, a - * reaction first of all. With SKIP the host AI step does not run while the clip plays, except + * the move enters its back pair), when the timing part's cursor reaches STOP, after LENGTH frames, + * or when anything else changes the pair, a reaction first of all. + * + * START and STOP are clip frames, so they hold whatever the blend and the cursor's rate are: the + * clip starts at START (the executor's a3) and an attack before it never spawns, one at it spawns + * on the first playing frame. LENGTH counts AI frames from the dispatch instead, the blend's + * (2 to 5, the cursor held at START) among them, at the cursor's SPEED (2.0 a frame calm, 2.8 + * enraged). + * + * With SKIP the host AI step does not run while the clip plays, except * on a frame with a hit pending, so the brain cannot cut in; the host still takes the damage. * While a move plays, the host's animation events for the entry its clip sits in (attacks and * effects at the host clip's frames) are skipped, unless HOST_ATTACKS keeps them. @@ -72,6 +80,10 @@ typedef struct { * AI script does not pick between this move and the one that * follows (em_vhook sets it for a move with an AFTER) */ uint8_t _pad; + uint16_t start; /* clip frame the clip starts at (the executor's a3); 0 = the + * row's own, frame 0 on em75 */ + uint16_t stop; /* the move ends when part's cursor reaches this clip + * frame; 0 = none */ } mhfu_move_t; /* MOVE_STATE.STATE */ diff --git a/framework/lua/meta/mhfu.d.lua b/framework/lua/meta/mhfu.d.lua index 62dee722..81bf06dd 100644 --- a/framework/lua/meta/mhfu.d.lua +++ b/framework/lua/meta/mhfu.d.lua @@ -652,7 +652,9 @@ function mhfu.move_block() end ---A move for `mhfu.move_play`; every field but `entry` is optional. ---@class mhfu.Move ---@field entry integer executor entry: the clip ----@field length? integer AI frames from the clip's dispatch, default 0 = until the clip ends +---@field length? integer AI frames from the clip's dispatch (the blend's among them), default 0 = until the clip ends or `stop` +---@field start? integer clip frame the clip starts at, default 0 = its first; an attack before it never spawns +---@field stop? integer clip frame the move ends at, when `part`'s cursor reaches it, whatever the blend and the cursor's rate; default 0 = none ---@field carrier? integer[] { main, sub } the native pair the move rides, default { 0, 2 } ---@field back? integer[] { main, sub, mode } entered at the end, default none ---@field skip? boolean the host AI step skips while the clip plays, except on a hit @@ -711,7 +713,7 @@ 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 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. ---@param slot integer ---@param spec mhfu.OwnMove ---@return boolean @@ -739,7 +741,7 @@ function mhfu.em_moves_status() end ---An own move for `mhfu.em_move`: a `mhfu.Move` and the slot after it. ---@class mhfu.OwnMove: mhfu.Move ----@field after? integer own move slot played when this one ends on its clip, its length or a wall +---@field after? integer own move slot played when this one ends on its clip, its length, its stop or a wall ---What `mhfu.em_moves_status()` returns. ---@class mhfu.OwnMovesStatus diff --git a/framework/mods/lua_host/bind_move.cpp b/framework/mods/lua_host/bind_move.cpp index e57fcc17..5b8c3c6a 100644 --- a/framework/mods/lua_host/bind_move.cpp +++ b/framework/mods/lua_host/bind_move.cpp @@ -27,6 +27,11 @@ static void spec_of(lua_State *L, int t, mhfu_move_t *mv) { mhfu_move_init_spec(mv, (uint16_t)opt_field(L, t, "entry", 0)); mv->length = (uint16_t)opt_field(L, t, "length", 0); + int start = opt_field(L, t, "start", 0), stop = opt_field(L, t, "stop", 0); + luaL_argcheck(L, start >= 0 && start <= 0xFFFF && stop >= 0 && stop <= 0xFFFF, t, + "start and stop are clip frames, 0..65535"); + mv->start = (uint16_t)start; + mv->stop = (uint16_t)stop; mv->part = (uint8_t)opt_field(L, t, "part", 0); mv->spawner = (uint32_t)opt_field(L, t, "spawner", 0); lua_getfield(L, t, "skip"); diff --git a/framework/src/core/em_vhook.cpp b/framework/src/core/em_vhook.cpp index 9ffa07e1..36642d6e 100644 --- a/framework/src/core/em_vhook.cpp +++ b/framework/src/core/em_vhook.cpp @@ -426,8 +426,8 @@ static int g_qry_wrapped; /* MONSTER_VTABLE.QUERY carries the charge * It enters the pair mhfu_em_request asked for, keeps the pair's dwell, the hunter's distance and * the monster events, then plays or enters at most one thing: an own move asked for * (mhfu_em_play), else the move of the rule on the flinch whose reaction the last host step - * replaced, else the AFTER of the registry's move that just ended on its clip, its length or a - * wall, else the first rule that holds. Last, the rules on the + * replaced, else the AFTER of the registry's move that just ended on its clip, its length, its + * stop or a wall, else the first rule that holds. Last, the rules on the * flinch arm the reaction replacement for the host step that follows. An own move goes to the * move player here, and its step, called right after, enters the carrier the same AI frame. While * the tail cut waits for its drop it enters and plays nothing (cut_waits). Runs inside the diff --git a/framework/src/core/move.cpp b/framework/src/core/move.cpp index 27c75208..8d8f82c4 100644 --- a/framework/src/core/move.cpp +++ b/framework/src/core/move.cpp @@ -59,6 +59,8 @@ static_assert(offsetof(mhfu_move_t, spawner) == MHFU_MOVE_SPAWNER, "MOVE layout" static_assert(offsetof(mhfu_move_t, host_attacks) == MHFU_MOVE_HOST_ATTACKS, "MOVE layout"); static_assert(offsetof(mhfu_move_t, force) == MHFU_MOVE_FORCE, "MOVE layout"); static_assert(offsetof(mhfu_move_t, hold_script) == MHFU_MOVE_HOLD_SCRIPT, "MOVE layout"); +static_assert(offsetof(mhfu_move_t, start) == MHFU_MOVE_START, "MOVE layout"); +static_assert(offsetof(mhfu_move_t, stop) == MHFU_MOVE_STOP, "MOVE layout"); static_assert(MHFU_MOVE_ATTACKS_COUNT == MHFU_MOVE_MAX_ATTACKS, "MOVE layout"); static_assert(sizeof(mhfu_move_state_t) == MHFU_MOVE_STATE_SIZE, "MOVE_STATE layout"); static_assert(offsetof(mhfu_move_state_t, state) == MHFU_MOVE_STATE_STATE, "MOVE_STATE layout"); @@ -106,10 +108,10 @@ static void enter(uint32_t ent, uint32_t main_state, uint32_t sub_state, uint32_ { mhfu_call(MHFU_ENTER_ACTION, ent, main_state, sub_state, mode); } -/* a2 a3 as the hub handlers pass them */ -static void execute(uint32_t ent, uint32_t entry) +/* a2 as the hub handlers pass it (the row's blend); a3 the clip's start frame, 0 the row's */ +static void execute(uint32_t ent, uint32_t entry, uint32_t start) { - mhfu_call(MHFU_ACTION_EXECUTOR, ent, entry, 0, 0); + mhfu_call(MHFU_ACTION_EXECUTOR, ent, entry, 0, start); } /* natives pass *COLLISION_WORLD_PTR as the unused first argument */ static uint32_t spawn(uint32_t spawner, uint32_t ent, uint32_t id) @@ -122,11 +124,11 @@ static void end_node(uint32_t node, uint32_t vtable) } #else extern "C" void mhfu_host_enter(uint32_t ent, uint32_t m, uint32_t s, uint32_t mode); -extern "C" void mhfu_host_execute(uint32_t ent, uint32_t entry); +extern "C" void mhfu_host_execute(uint32_t ent, uint32_t entry, uint32_t start); extern "C" uint32_t mhfu_host_spawn(uint32_t spawner, uint32_t ent, uint32_t id); extern "C" void mhfu_host_end(uint32_t node, uint32_t vtable); static void enter(uint32_t e, uint32_t m, uint32_t s, uint32_t mode) { mhfu_host_enter(e, m, s, mode); } -static void execute(uint32_t e, uint32_t entry) { mhfu_host_execute(e, entry); } +static void execute(uint32_t e, uint32_t entry, uint32_t start) { mhfu_host_execute(e, entry, start); } static uint32_t spawn(uint32_t sp, uint32_t e, uint32_t id) { return mhfu_host_spawn(sp, e, id); } static void end_node(uint32_t node, uint32_t vtable) { mhfu_host_end(node, vtable); } extern "C" void *mhfu_host_at(uint32_t addr); @@ -293,7 +295,7 @@ static void dispatch(uint32_t ent) { uint32_t now = pair_of(ent); if (now != carrier()) { finish(MHFU_MOVE_END_PAIR, now); return; } - execute(ent, S->move.entry); + execute(ent, S->move.entry, S->move.start); for (int k = 0; k < MHFU_ENTITY_CLIP_BLOCKS_COUNT; k++) { S->node[k] = mhfu_mem_read_u32(clip_block(ent, k) + MHFU_CLIP_BLOCK_NODE); S->clip_end[k] = mhfu_mem_read_f32(clip_block(ent, k) + MHFU_CLIP_BLOCK_END); @@ -332,6 +334,8 @@ static uint32_t play(uint32_t ent) if (now != carrier()) { finish(MHFU_MOVE_END_PAIR, now); return 0; } int p = S->move.part; float prev = S->peak[p]; + const float before = (float)S->move.start - 1.0f; /* an attack before START never spawns */ + if (prev < before) prev = before; for (int k = 0; k < MHFU_ENTITY_CLIP_BLOCKS_COUNT; k++) { uint32_t b = clip_block(ent, k); float cur = mhfu_mem_read_f32(b + MHFU_CLIP_BLOCK_PHASE); @@ -343,7 +347,8 @@ static uint32_t play(uint32_t ent) finish(MHFU_MOVE_END_LOST, now); return 0; } - attacks(ent, prev, mhfu_mem_read_f32(b + MHFU_CLIP_BLOCK_PHASE)); + const float cursor = mhfu_mem_read_f32(b + MHFU_CLIP_BLOCK_PHASE); + attacks(ent, prev, cursor); int wall = steer(ent); if (wall == MHFU_STEER_STUCK) { /* as the Tigrex charge ends on such a wall; the AI script waits for the hub after it */ @@ -354,7 +359,8 @@ static uint32_t play(uint32_t ent) return 0; } int done = !(mhfu_mem_read_u16(b + MHFU_CLIP_BLOCK_FLAGS) & 1) || wall == MHFU_STEER_WALL; - int timed = S->move.length && S->frames >= S->move.length; + int timed = (S->move.length && S->frames >= S->move.length) + || (S->move.stop && cursor >= (float)S->move.stop); if (done || timed) { if (S->move.back_main != MHFU_MOVE_NO_PAIR || timed) { int back = S->move.back_main != MHFU_MOVE_NO_PAIR; diff --git a/framework/tests/em_host.cpp b/framework/tests/em_host.cpp index d8d881f4..ee43c330 100644 --- a/framework/tests/em_host.cpp +++ b/framework/tests/em_host.cpp @@ -59,17 +59,17 @@ void mhfu_host_enter(uint32_t e, uint32_t m, uint32_t s, uint32_t mode) if (cell) { cell[0] = (uint8_t)m; cell[1] = (uint8_t)s; } if (m == 0 && s == 2) mhfu_mem_write_u8(e + MHFU_ENTITY_SCRIPT_WAKE, 1); /* the hub wakes the AI script */ } -void mhfu_host_execute(uint32_t e, uint32_t entry) +void mhfu_host_execute(uint32_t e, uint32_t entry, uint32_t start) { - record('X', e, entry, 0, 0); + record('X', e, entry, 0, start); for (uint32_t k = 0; k < MHFU_ENTITY_CLIP_BLOCKS_COUNT; k++) { uint32_t b = e + MHFU_ENTITY_CLIP_BLOCKS + k * MHFU_CLIP_BLOCK_SIZE; uint32_t node = 0x5000u + entry; - float zero = 0; + float phase = (float)start; uint16_t playing = 1; memcpy(at(b + MHFU_CLIP_BLOCK_NODE, 4), &node, 4); memcpy(at(b + MHFU_CLIP_BLOCK_END, 4), &g_clip_end, 4); - memcpy(at(b + MHFU_CLIP_BLOCK_PHASE, 4), &zero, 4); + memcpy(at(b + MHFU_CLIP_BLOCK_PHASE, 4), &phase, 4); memcpy(at(b + MHFU_CLIP_BLOCK_FLAGS, 2), &playing, 2); } } diff --git a/framework/tests/move_host.cpp b/framework/tests/move_host.cpp index 712829f1..c034624e 100644 --- a/framework/tests/move_host.cpp +++ b/framework/tests/move_host.cpp @@ -19,6 +19,7 @@ static uint32_t g_calls[64][5]; static int g_n; static int g_lands = 1; /* enter-action writes the pair cells */ static float g_clip_end = 228; /* what the executor installs on every part */ +static int g_blend; /* blend frames the executor installs (CLIP_BLOCK.BLEND) */ /* BREAK_TABLE, where the game has it */ static uint8_t g_breaks[MHFU_BREAK_TABLE_COUNT * MHFU_BREAK_ROW_SIZE]; @@ -82,17 +83,20 @@ void mhfu_host_enter(uint32_t e, uint32_t m, uint32_t s, uint32_t mode) uint8_t *cell = at(e + MHFU_ENTITY_MAIN_STATE, 2); if (g_lands && cell) { cell[0] = (uint8_t)m; cell[1] = (uint8_t)s; } } -void mhfu_host_execute(uint32_t e, uint32_t entry) +/* the executor: every part's clip at cursor `start`, held for the blend as CLIP_SET does */ +void mhfu_host_execute(uint32_t e, uint32_t entry, uint32_t start) { - record('X', e, entry, 0, 0); + record('X', e, entry, 0, start); for (uint32_t k = 0; k < MHFU_ENTITY_CLIP_BLOCKS_COUNT; k++) { uint32_t b = e + MHFU_ENTITY_CLIP_BLOCKS + k * MHFU_CLIP_BLOCK_SIZE; uint32_t node = 0x5000u + entry; - float zero = 0; + float phase = (float)start; + int32_t blend = g_blend ? g_blend + 1 : 0; uint16_t playing = 1; memcpy(at(b + MHFU_CLIP_BLOCK_NODE, 4), &node, 4); memcpy(at(b + MHFU_CLIP_BLOCK_END, 4), &g_clip_end, 4); - memcpy(at(b + MHFU_CLIP_BLOCK_PHASE, 4), &zero, 4); + memcpy(at(b + MHFU_CLIP_BLOCK_PHASE, 4), &phase, 4); + memcpy(at(b + MHFU_CLIP_BLOCK_BLEND, 4), &blend, 4); memcpy(at(b + MHFU_CLIP_BLOCK_FLAGS, 2), &playing, 2); } } @@ -150,8 +154,10 @@ void host_set(int lands, float clip_end) { memset(g_breaks, 0, sizeof(g_breaks)); g_lands = lands; g_clip_end = clip_end; g_n = 0; g_step = 0; g_nraised = 0; g_cut = 0; + g_blend = 0; memset(g_react, 0, sizeof(g_react)); } +void host_blend(int frames) { g_blend = frames; } int host_has_step(void) { return g_step != 0; } int host_calls(uint32_t *out) { memcpy(out, g_calls, sizeof(g_calls)); int n = g_n; g_n = 0; return n; } uint32_t host_frame(uint32_t ent) { return g_step ? g_step(ent) : 0xFFFFFFFFu; } diff --git a/framework/tests/test_em_brain.py b/framework/tests/test_em_brain.py index ea265026..a08fea05 100644 --- a/framework/tests/test_em_brain.py +++ b/framework/tests/test_em_brain.py @@ -56,9 +56,14 @@ class Move(ctypes.Structure): ("force", ctypes.c_uint8), ("hold_script", ctypes.c_uint8), ("_pad", ctypes.c_uint8), + ("start", ctypes.c_uint16), + ("stop", ctypes.c_uint16), ] +assert ctypes.sizeof(Move) == a.MOVE.size + + class Op(ctypes.Structure): _fields_ = [("op", ctypes.c_uint8), ("arg", ctypes.c_uint8), ("value", ctypes.c_int16)] @@ -433,6 +438,14 @@ def test_play_asked_starts_that_frame(g: Game) -> None: assert g.lib.mhfu_em_play(ENT, 1, 0) == 0 # an empty slot +def test_a_slots_start_and_stop_reach_the_move(g: Game) -> None: + assert g.own(0, 46, start=6, stop=14) and g.lib.mhfu_em_play(ENT, 0, 0) + g.frames(2) + assert ("X", ENT, 46, 0, 6) in g.calls() + g.until(lambda: g.move("STATE", "B") == DONE) + assert g.move("END", "B") == BACK and g.move("END_FRAME") == 4 # (14 - 6) / SPEED + + def test_after_follows_through_the_carrier(g: Game) -> None: """A slot with an AFTER ends into its carrier, and the next plays on the following frame.""" g.own(0, 46, after=1) diff --git a/framework/tests/test_move.py b/framework/tests/test_move.py index d2f425c3..25dc000b 100644 --- a/framework/tests/test_move.py +++ b/framework/tests/test_move.py @@ -5,6 +5,7 @@ frame then advances the clip.""" import ctypes +import math import struct from collections.abc import Callable @@ -41,13 +42,19 @@ class Move(ctypes.Structure): ("force", ctypes.c_uint8), ("hold_script", ctypes.c_uint8), ("_pad", ctypes.c_uint8), + ("start", ctypes.c_uint16), + ("stop", ctypes.c_uint16), ] +assert ctypes.sizeof(Move) == a.MOVE.size + + class Game: def __init__(self, lib: ctypes.CDLL) -> None: self.lib = lib self.mem = (ctypes.c_uint8 * 0x4000).from_address(lib.host_mem()) + self.speed = SPEED def poke(self, off: int, fmt: str, *values: float) -> None: struct.pack_into("<" + fmt, self.mem, ENT - BASE + off, *values) @@ -69,14 +76,20 @@ def block(self, part: int) -> int: return int(a.ENTITY.CLIP_BLOCKS) + part * a.CLIP_BLOCK.size def frame(self) -> int: - """One AI frame: the step, then the clip advances and stops at its end.""" + """One AI frame: the step, then the clip advances at `speed`, held while the blend + counts down, and stops at its end.""" skip = int(self.lib.host_frame(ENT)) for k in range(3): b = self.block(k) (phase,) = self.peek(b + a.CLIP_BLOCK.PHASE, "f") (end,) = self.peek(b + a.CLIP_BLOCK.END, "f") if self.peek(b + a.CLIP_BLOCK.FLAGS, "H")[0] & 1: - phase = min(phase + SPEED, end) + (left,) = self.peek(b + a.CLIP_BLOCK.BLEND, "i") + if left: + self.poke(b + a.CLIP_BLOCK.BLEND, "i", left - 1) + if left > 1: + continue + phase = min(phase + self.speed, end) self.poke(b + a.CLIP_BLOCK.PHASE, "f", phase) if phase >= end: self.poke(b + a.CLIP_BLOCK.FLAGS, "H", 0) @@ -122,6 +135,7 @@ def lib(host_lib: Callable[..., ctypes.CDLL]) -> ctypes.CDLL: lib.host_frame.restype = ctypes.c_uint32 lib.host_frame.argtypes = [ctypes.c_uint32] lib.host_set.argtypes = [ctypes.c_int, ctypes.c_float] + lib.host_blend.argtypes = [ctypes.c_int] lib.mhfu_move_state.restype = ctypes.c_void_p lib.mhfu_move_play.argtypes = [ctypes.c_uint32, ctypes.c_void_p] lib.mhfu_move_steer.argtypes = [ctypes.c_void_p] @@ -217,6 +231,54 @@ def test_length_enters_the_back_pair(g: Game) -> None: assert g.state("END", "B") == (BACK,) and g.state("END_FRAME") == (10,) +def test_start_is_the_executors_a3(g: Game) -> None: + g.play(46, start=24) + g.frame() + g.frame() + assert g.calls() == [("E", ENT, 0, 1, 0), ("M", ENT, 0, 0, 0), ("X", ENT, 46, 0, 24)] + cursors = {g.peek(g.block(k) + a.CLIP_BLOCK.PHASE, "f") for k in range(3)} + assert cursors == {(24.0 + SPEED,)} # every part on the dispatch frame's step + + +SPANS = [(0, 60, 0, 2.0), (24, 86, 4, 2.0), (24, 86, 4, 2.8), (42, 102, 3, 2.8)] + + +@pytest.mark.parametrize(("start", "stop", "blend", "speed"), SPANS) +def test_stop_ends_the_move_at_a_clip_frame( + g: Game, start: int, stop: int, blend: int, speed: float +) -> None: + g.lib.host_blend(blend) + g.speed = speed + g.play(46, start=start, stop=stop) + while g.state("STATE", "B")[0] != DONE: + g.frame() + held = blend + math.ceil((stop - start) / speed) # the AI frame the cursor reaches stop + assert g.state("END", "B") == (BACK,) and g.state("END_FRAME") == (held,) + assert g.pair == (0, 1) # the carrier again: its handler hands back + + +def test_stop_comes_before_a_longer_length_and_the_clips_end(g: Game) -> None: + g.play(46, start=0, stop=100, length=200) + while g.state("STATE", "B")[0] != DONE: + g.frame() + assert g.state("END_FRAME") == (50,) + g.play(46, stop=300) # past the clip's end + while g.state("STATE", "B")[0] != 3: + g.frame() + assert g.state("END", "B") == (CLIP,) + + +def test_an_attack_before_start_never_spawns(g: Game) -> None: + g.lib.host_blend(2) + g.play(46, start=24, attacks=[(10, 3), (24, 5), (56, 6)]) # type: ignore[arg-type] + for _ in range(30): + g.frame() + assert [c[3] for c in g.calls() if c[0] == "S"] == [5, 6] + # the one at START on the first playing frame, with the cursor still held there + assert g.state("SPAWN_FRAME", "III") == (NEVER, 1, 18) + assert g.state("SPAWN_CURSOR", "fff") == (-1.0, 24.0, 56.0) + + def test_carrier_that_does_not_land_is_refused(g: Game) -> None: g.lib.host_set(0, ctypes.c_float(CLIP_END)) g.play(46) diff --git a/packages/mhfu/src/mhfu/addresses.toml b/packages/mhfu/src/mhfu/addresses.toml index f525349f..f18207af 100644 --- a/packages/mhfu/src/mhfu/addresses.toml +++ b/packages/mhfu/src/mhfu/addresses.toml @@ -175,8 +175,8 @@ STAGE_SCRATCH = { eu = 0x09E00000, type = "bytes[65536]", doc = "free RAM the co USER_RAM_END = { eu = 0x0A000000, type = "bytes", doc = "first address past the user partition, and the start of the emulator's extra-RAM window" } # --- big-monster behaviour: moves, phases, chains --- -CLIP_CURSOR_CROSSES = { eu = 0x08864348, type = "fn", doc = "(clip block, slot, frame in $f12): 1 on the tick the slot's clip cursor steps over frame" } -CLIP_CURSOR_REACHED = { eu = 0x08864408, type = "fn", doc = "(clip block, slot, frame in $f12): 1 once the slot's clip cursor is at or past frame" } +CLIP_CURSOR_CROSSES = { eu = 0x08864348, type = "fn", doc = "(clip block, slot, frame in $f12): 1 on the tick the slot's clip cursor steps over frame (PHASE <= frame < PHASE + SPEED, read before the advance); 0 for every slot while block 0 blends, so a clip started at S never answers for S to S + SPEED" } +CLIP_CURSOR_REACHED = { eu = 0x08864408, type = "fn", doc = "(clip block, slot, frame in $f12): 1 once the slot's clip cursor is at or past frame; 0 for every slot while block 0 blends" } ACT_SET = { eu = 0x09AC8690, type = "fn", doc = "act_set(entity, main, sub, mode): enters a behaviour pair through SET_AI_STATE; species translators end here" } SET_AI_STATE_ALERT = { eu = 0x09AC87E8, type = "fn", doc = "SET_AI_STATE after clearing entity+0x769, then sets entity+0x4B7 = 1" } SET_AI_STATE = { eu = 0x09AC8818, type = "fn", doc = "(entity, main, sub): writes ENTITY.MAIN_STATE and SUB_STATE and zeroes the three PHASE bytes" } @@ -370,8 +370,8 @@ TALKABLE = { offset = 0x2FE, type = "u8", doc = "village NPCs: 1 while this NPC MESH_MASK = { offset = 0x2FF, type = "bytes", doc = "big monsters: a u8 per PMO mesh, the draw skips mesh k while +k is 0; TAIL_DROP zeroes mesh 1's" } STIMULUS_TAG = { offset = 0x322, type = "u16", doc = "stimulus type written by the damage handler; picks alert or flee responses" } ANIM_INPUT = { offset = 0x324, type = "u16[3]", doc = "per-body-part animation input = executor action id + 0x3E8 + slot*0xC8" } -ANIM_MODE = { offset = 0x32C, type = "u16[3]", doc = "per-body-part animation mode" } -ANIM_SPEED = { offset = 0x334, type = "u16[3]", doc = "per-body-part playback speed each action installs with its clip; it tracks the action but is not an AI state" } +ANIM_MODE = { offset = 0x32C, type = "u16[3]", doc = "per body part, the clip start frame of its last executor dispatch (a3, CLIP_SET's start; 0 = the row's), not a mode" } +ANIM_SPEED = { offset = 0x334, type = "u16[3]", doc = "per body part, the blend frames of its last executor dispatch (a2 >> 1, CLIP_BLOCK.BLEND), not a speed; it tracks the action but is not an AI state" } ATTACK_GATE = { offset = 0x33C, type = "u8", doc = "ATTACK_RESOLVER runs the full attack resolution only while this is 0" } COMBAT_ENGAGED = { offset = 0x33D, type = "u8", doc = "set to 1 by the combat registrar together with COMBAT_NODE" } COMBATANT_IDS = { offset = 0x33E, type = "u16[16]", doc = "player only: combatant ids registered against the player" } @@ -523,7 +523,8 @@ doc = "one body part's clip player (ENTITY.CLIP_BLOCKS): which clip the part pla size = 0x40 [struct.CLIP_BLOCK.fields] PHASE = { offset = 0x10, type = "f32", doc = "playback cursor in clip frames; CLIP_CURSOR_REACHED tests it" } -SPEED = { offset = 0x14, type = "f32", doc = "frames the cursor advances per game frame, set by the action dispatch, not the clip" } +SPEED = { offset = 0x14, type = "f32", doc = "clip frames the cursor advances per AI frame: 2.0 * ENTITY+0x4B0 * (100 + u8 ENTITY+0x769) / 100, written every frame by MONSTER_STEP, not the clip's or the dispatch's (2.0 calm, em75 enraged 2.8)" } +BLEND = { offset = 0x30, type = "s32", doc = "after a dispatch with a blend (ENTITY.ANIM_SPEED), blend + 1; the cursor holds at its start while it counts down once an AI frame, so the clip moves from AI frame blend + 1" } LOOP_START = { offset = 0x18, type = "f32", doc = "frame a looping clip restarts at" } END = { offset = 0x1C, type = "f32", doc = "the clip's last keyframe: its length in frames" } NODE = { offset = 0x38, type = "ptr", doc = "the clip: ENTITY.ACTION_TABLE (the animation pack) plus the slot table's offset" } @@ -699,8 +700,8 @@ STATUS = { offset = 0x1C, type = "u32", doc = "action id currently held" } PROBE = { offset = 0x20, type = "u32", doc = "CLI scratch word used to test that the block is mapped" } MOVE_STATE = { offset = 0x24, type = "ptr", doc = "the move player's MOVE_STATE block, which cli_bridge.lua publishes when it loads" } MOVE = { offset = 0x40, type = "MOVE", doc = "the move CMD 4 plays on SLOT's monster" } -NAME = { offset = 0x74, type = "bytes[32]", doc = "CMD 4 with ARG bit 0: the own move the port riding SLOT's monster plays instead of MOVE, by name, NUL-terminated; ARG bit 1 plays it past the notice (MOVE.FORCE)" } -RESULT = { offset = 0x94, type = "u32", doc = "CMD 4 with ARG bit 0: 1 the port took NAME, 0 no port rides the monster or it has no such own move, 2 its moves module is not build ARG2 (yet); written before ACK" } +NAME = { offset = 0x78, type = "bytes[32]", doc = "CMD 4 with ARG bit 0: the own move the port riding SLOT's monster plays instead of MOVE, by name, NUL-terminated; ARG bit 1 plays it past the notice (MOVE.FORCE)" } +RESULT = { offset = 0x98, type = "u32", doc = "CMD 4 with ARG bit 0: 1 the port took NAME, 0 no port rides the monster or it has no such own move, 2 its moves module is not build ARG2 (yet); written before ACK" } [struct.MAP_ROW] doc = "one row of MAP_TABLE: the stages of one map" @@ -829,10 +830,10 @@ END = { offset = 0x4, type = "u16", doc = "clip frame the attack node is ended a [struct.MOVE] doc = "a move for the framework's move player (move.h mhfu_move_t): one executor entry played on a native carrier pair" -size = 0x34 +size = 0x38 [struct.MOVE.fields] ENTRY = { offset = 0x00, type = "u16", doc = "executor entry: the clip" } -LENGTH = { offset = 0x02, type = "u16", doc = "AI frames from the clip's dispatch to the end; 0 = until the clip ends" } +LENGTH = { offset = 0x02, type = "u16", doc = "AI frames from the clip's dispatch to the end, the blend's among them; 0 = until the clip ends or STOP" } CARRIER_MAIN = { offset = 0x04, type = "u8", doc = "the native pair the move rides, entered through ENTER_ACTION" } CARRIER_SUB = { offset = 0x05, type = "u8", doc = "with CARRIER_MAIN" } BACK_MAIN = { offset = 0x06, type = "u8", doc = "pair entered at the end; 0xFF = none, the carrier hands off itself" } @@ -846,10 +847,12 @@ SPAWNER = { offset = 0x2C, type = "ptr", doc = "the species' attack spawner; 0 = HOST_ATTACKS = { offset = 0x30, type = "u8", doc = "1 keeps the host entry's animation events (MONSTER_VTABLE.ANIM_EVENTS) while the move plays; 0 skips them" } FORCE = { offset = 0x31, type = "u8", doc = "1 starts while the monster's notice runs (the player's ENTITY.AWARE bit, COMBAT_MODE 0); 0 waits up to 450 AI frames for its roar and combat entry; per call, not per move" } HOLD_SCRIPT = { offset = 0x32, type = "u8", doc = "1: entering the back pair clears ENTITY.SCRIPT_WAKE, which the hub raises there, so the AI script does not pick a pair between this move and the one that follows (an own move with an AFTER)" } +START = { offset = 0x34, type = "u16", doc = "clip frame the clip starts at: the executor's a3 on the dispatch (ENTITY.ANIM_MODE); 0 = the row's own, frame 0 on em75. An attack before it never spawns" } +STOP = { offset = 0x36, type = "u16", doc = "the move ends when PART's clip cursor reaches this clip frame, whatever the blend and the cursor's rate; 0 = none" } [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 = 0x7AC +size = 0x7B8 [struct.MOVE_STATE.fields] MAGIC = { offset = 0x00, type = "u32", doc = "0x4D4F5650 (MOVP)" } STARTED = { offset = 0x04, type = "u32", doc = "moves started since boot" } @@ -869,23 +872,23 @@ SPAWN_FRAME = { offset = 0x48, type = "u32[4]", doc = "FRAMES at each attack's s SPAWN_CURSOR = { offset = 0x58, type = "f32[4]", doc = "the timing part's cursor at each spawn" } SPAWN_NODE = { offset = 0x68, type = "ptr[4]", doc = "what the spawner returned: the ATTACK_NODE, 0 out of section" } MOVE = { offset = 0x78, type = "MOVE", doc = "the move running" } -NEXT = { offset = 0xAC, type = "MOVE", doc = "the move asked for, taken at the next AI step" } -NEXT_ENTITY = { offset = 0xE0, type = "ptr", doc = "NEXT's monster" } -NODE_VTABLE = { offset = 0xE4, type = "ptr[4]", doc = "each spawned node's vtable right after the spawn, which ending it checks" } -ENDED_FRAME = { offset = 0xF4, type = "u32[4]", doc = "FRAMES when the move ended each attack's node; 0xFFFFFFFF = not ended by the move" } -ENDED_STATE = { offset = 0x104, 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 = 0x108, type = "STEER_STATE", doc = "the move's steering: its turn curve, its mode and walls (mhfu/steer.h)" } -REACT = { offset = 0x544, type = "MOVE", doc = "the move that replaces REACT_KIND on REACT_ENTITY (mhfu_move_react)" } -REACT_STEER = { offset = 0x578, type = "STEER_SPEC", doc = "REACT's steering" } -REACT_ENTITY = { offset = 0x790, type = "ptr", doc = "the monster whose reaction is replaced; 0 = none" } -REACT_KIND = { offset = 0x794, type = "u8", doc = "0 the flinch" } -REACT_PARTS = { offset = 0x795, type = "u8", doc = "ENTITY.FLINCH_MASK of the last replaced reaction" } -REACT_PART = { offset = 0x796, type = "u8", doc = "ENTITY.MOST_DAMAGED_PART then" } -REACTIONS = { offset = 0x798, type = "u32", doc = "reactions replaced since boot" } -REACT_SEEN = { offset = 0x79C, type = "u32", doc = "em_vhook's replaced enter-actions the step has taken" } -REACT_PAIR = { offset = 0x7A0, type = "u32", doc = "the replaced enter-action: (mode << 16) | (main << 8) | sub" } -WAITED = { offset = 0x7A4, type = "u32", doc = "AI frames NEXT waited for the monster's notice" } -EVENTS = { offset = 0x7A8, type = "ptr", doc = "the monster-event block (struct MONSTER_EVENTS)" } +NEXT = { offset = 0xB0, type = "MOVE", doc = "the move asked for, taken at the next AI step" } +NEXT_ENTITY = { offset = 0xE8, type = "ptr", doc = "NEXT's monster" } +NODE_VTABLE = { offset = 0xEC, type = "ptr[4]", doc = "each spawned node's vtable right after the spawn, which ending it checks" } +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)" } [struct.STEER_PARAMS] doc = "how a move turns and stops (steer.h mhfu_steer_t)" @@ -925,21 +928,21 @@ YAW = { offset = 0x43A, 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 = 0x54 +size = 0x58 [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 = 0x34, type = "STEER_PARAMS", doc = "turn mode and walls" } -KEY_AT = { offset = 0x44, type = "u16", doc = "its first turn key in EM_MOVES.KEYS" } -KEY_COUNT = { offset = 0x46, type = "u16", doc = "its turn keys; 0: the clip does not turn YAW" } -STUCK_MAIN = { offset = 0x48, type = "u8", doc = "STEER_SPEC.STUCK_MAIN" } -STUCK_SUB = { offset = 0x49, type = "u8", doc = "STEER_SPEC.STUCK_SUB" } -STUCK_MODE = { offset = 0x4A, type = "u8", doc = "STEER_SPEC.STUCK_MODE" } -AFTER = { offset = 0x4B, type = "u8", doc = "slot played when this move ends on its clip, its length or a wall; 0xFF none" } -VALID = { offset = 0x4C, type = "u32", doc = "1 once written; 0 while it is rewritten" } +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" } [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 = 0x4DBC +size = 0x4F3C [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" } @@ -950,9 +953,9 @@ PLAYS = { offset = 0x14, type = "u32", doc = "moves started from here: asked, by 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 = 0x1FA0, type = "u16[5632]", doc = "every move's turn keys, YAW every 2 clip frames" } -SCRATCH = { offset = 0x4BA0, type = "STEER_SPEC", doc = "the steering handed to the move player for the move it plays next" } -REQ_FORCE = { offset = 0x4DB8, type = "u32", doc = "1: the play asked for starts while the monster's notice runs (MOVE.FORCE)" } +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)" } [struct.FLINCH_PART] doc = "one part's flinch state (ENTITY.FLINCH_PARTS)" diff --git a/packages/mhfu/src/mhfu/cli/moves.py b/packages/mhfu/src/mhfu/cli/moves.py index 9ac2b4ef..17319652 100644 --- a/packages/mhfu/src/mhfu/cli/moves.py +++ b/packages/mhfu/src/mhfu/cli/moves.py @@ -64,6 +64,8 @@ def register(sub: Subparsers) -> None: c.add_argument("--carrier", type=_ints, default=(0, 2), metavar="MAIN,SUB") c.add_argument("--back", type=_ints, metavar="MAIN,SUB[,MODE]", help="entered at the end") c.add_argument("--length", type=int, default=0, help="AI frames; default the clip's") + c.add_argument("--start", type=int, default=0, help="clip frame the clip starts at") + c.add_argument("--stop", type=int, default=0, help="clip frame the move ends at") c.add_argument("--skip", action="store_true", help="no host AI step while the clip plays") c.add_argument("--part", type=int, default=0, help="body part whose cursor times attacks") c.add_argument( @@ -226,6 +228,8 @@ def play(args: argparse.Namespace) -> int: args.part, host_attacks=args.host_attacks, force=args.force, + start=args.start, + stop=args.stop, ) curve: tuple[int, ...] = () if args.curve: diff --git a/packages/mhfu/src/mhfu/live/moves.py b/packages/mhfu/src/mhfu/live/moves.py index b14bb660..b07f34d6 100644 --- a/packages/mhfu/src/mhfu/live/moves.py +++ b/packages/mhfu/src/mhfu/live/moves.py @@ -91,6 +91,11 @@ class Move: """Keep the host entry's own attacks and effects (MONSTER_VTABLE.ANIM_EVENTS).""" force: bool = False """Start while the monster's notice runs instead of waiting for combat (MOVE.FORCE).""" + start: int = 0 + """Clip frame the clip starts at, every part (MOVE.START); 0: the row's own, frame 0 on em75.""" + stop: int = 0 + """Clip frame `part`'s cursor ends the move at, whatever the blend and the rate (MOVE.STOP); + 0: none.""" @classmethod def unpack(cls, raw: bytes) -> Move: @@ -107,6 +112,7 @@ def unpack(cls, raw: bytes) -> Move: (end,) = struct.unpack_from(" Move: spawner, bool(raw[f.HOST_ATTACKS]), bool(raw[f.FORCE]), + start, + stop, ) def pack(self) -> bytes: @@ -138,6 +146,7 @@ def pack(self) -> bytes: struct.pack_into(" None: assert raw[a.MOVE.FORCE] == 1 and moves.Move.unpack(raw).force +def test_start_and_stop_round_trip() -> None: + mv = moves.Move(53, (moves.Attack(36, 56, 66),), start=24, stop=86, length=9) + raw = mv.pack() + assert struct.unpack_from(" None: assert cli._attack("6@56") == moves.Attack(6, 56) assert cli._attack("6@56-90") == moves.Attack(6, 56, 90) From 164e3056932d2a004e2ff11de27666e106ee3505 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:14:16 +0200 Subject: [PATCH 2/2] feat(mhfu-port): own moves carry start and stop, taken from the donor's clip frames A derived step starts its clip at the frame the donor's handler played it from and ends at the frame its handler tested when it ended the step (Step.stop: the last cursor test of the closing tick whose answer changed, its lo, or its hi for a window the cursor left). `length`, in AI frames, stays only for a step that ended on no frame of its clip (a pair, a loop, a test the cursor never changed); it no longer sets the attack windows' end, which clamp to [start, stop]. P3_OWN_START goes: a start is carried now. Manifest Move.start and Move.stop, written to the moves module; span_problems is the one place that refuses a start or stop outside the clip, a stop not after the start, or an attack window outside [start, stop], for the manifest's load and for moves.problems (derived moves skip the load). Co-Authored-By: Claude Sonnet 5.5 --- docs/formats/p3rd_ai_script.md | 9 ++-- packages/mhfu-port/src/mhfu_port/brain/own.py | 25 +++++---- packages/mhfu-port/src/mhfu_port/cli/own.py | 4 +- packages/mhfu-port/src/mhfu_port/manifest.py | 40 ++++++++++++-- packages/mhfu-port/src/mhfu_port/moves.py | 19 ++++--- packages/mhfu-port/src/mhfu_port/p3hits.py | 42 ++++++++++++--- packages/mhfu-port/tests/test_manifest.py | 20 ++++++- packages/mhfu-port/tests/test_moves.py | 45 ++++++++++++++++ packages/mhfu-port/tests/test_own.py | 54 +++++++++++++++++-- packages/mhfu-port/tests/test_own_cli.py | 11 ++++ packages/mhfu-port/tests/test_p3hits.py | 47 ++++++++++++++++ .../mhfu-port/tests/test_zinogre_brain.py | 31 ++++++++++- ports/README.md | 7 +++ 13 files changed, 314 insertions(+), 40 deletions(-) diff --git a/docs/formats/p3rd_ai_script.md b/docs/formats/p3rd_ai_script.md index 5e1e8ab7..b6b8b6a3 100644 --- a/docs/formats/p3rd_ai_script.md +++ b/docs/formats/p3rd_ai_script.md @@ -163,8 +163,11 @@ clip that hands to the next by `after`: - **Clips.** The default path. A branch on something the build cannot know (a bearing, a counter, a wait) is tried each way and the path that spawns most attacks and plays fewest clips is taken; - the others are named in `P3_OWN_BRANCH`. A looping clip, or one the handler leaves at a frame it - tests, gets the `length` the donor played it for. + the others are named in `P3_OWN_BRANCH`. A clip the donor starts past its first frame gets that + `start`; one the handler leaves at a frame it tests gets that frame as `stop`. Both are clip + frames, so they hold at any blend and cursor rate (a blend holds the cursor 2 to 5 AI frames, + enraged it runs 2.8 a frame). Only a step that ended on no frame of its clip (a pair, a loop, + a test the cursor never changed) gets the `length`, in AI frames, the donor played it for. - **Attacks.** A record's START and LEN against the cursor at the spawn give the frames; a node that outlives a clip change is split per clip. The record's volume set is matched to the host record whose volumes overlap it most on the port's rig (the same kind first, then power); below @@ -180,7 +183,7 @@ clip that hands to the next by `after`: | `P3_OWN_ATTACK` | warning | a donor record with no host record and no spare, or more attacks on a clip than the move player holds: left out | | `P3_OWN_SPARE` | info | a donor record with no host record took a spare one: its id, set, volumes, power, kind, active frames | | `P3_OWN_PARTIAL` | warning | the walk gave up, or a clip it could not resolve | -| `P3_OWN_BRANCH`, `P3_OWN_MOTION`, `P3_OWN_START`, `P3_OWN_NEXT` | info | a branch taken one way; code movement; a clip the donor starts past its first frame; a pair the handler hands over to | +| `P3_OWN_BRANCH`, `P3_OWN_MOTION`, `P3_OWN_NEXT` | info | a branch taken one way; code movement; a pair the handler hands over to | | `P3_OWN_SPLIT`, `P3_OWN_GROUP`, `P3_OWN_ACTIVE` | info | a node split per clip; records that land one hit between them in the donor; a window longer than the host record's life | ### Spare attack records diff --git a/packages/mhfu-port/src/mhfu_port/brain/own.py b/packages/mhfu-port/src/mhfu_port/brain/own.py index e89fe1ca..64e4e483 100644 --- a/packages/mhfu-port/src/mhfu_port/brain/own.py +++ b/packages/mhfu-port/src/mhfu_port/brain/own.py @@ -1,8 +1,9 @@ # SPDX-License-Identifier: GPL-3.0-or-later # SPDX-FileCopyrightText: 2026 sp00ktober """The own moves a manifest's `[brain.pairs] "7,13" = "own"` stands for, derived at each build -from both games' files: the clips the donor's handler plays, the attacks it spawns with their -frames, matched to the host species' attack records, and how it turns. +from both games' files: the clips the donor's handler plays, from the clip frame it starts them +at to the one it ends them at, the attacks it spawns with their frames, matched to the host +species' attack records, and how it turns. m, findings = own.expand(manifest, data) # the manifest with `p3_7_13` and its clips @@ -60,7 +61,6 @@ SAMPLES = 100_000 """Points the overlap of two volume sets is sampled at.""" MAX_RATE = 0xFFFF -STEP = p3hits.STEP_FRAMES Report = Callable[[Finding], None] @@ -266,7 +266,6 @@ def _notes(self, plan: Plan) -> None: "record": ("info", "P3_OWN_BRANCH"), "limit": ("info", "P3_OWN_BRANCH"), "motion": ("info", "P3_OWN_MOTION"), - "start": ("info", "P3_OWN_START"), "unresolved": ("warning", "P3_OWN_PARTIAL"), } for n in plan.notes: @@ -295,22 +294,30 @@ def _clip(self, cid: int) -> str: return name def _move(self, step: Step, after: str | None) -> Move: + """The step as a move, from the clip frame the donor started it at to the one its handler + tested when it ended it (`Step.stop`); `length`, in AI frames, only for a step that ended + on no frame of its clip: a pair, a loop, a test the cursor never changed.""" length = self.walk.lengths[step.clip] + frames, start = round(length.frames), round(step.start) + stop = round(step.stop) if step.stop is not None else 0 + stop = stop if start < stop <= frames else 0 cut = length.loop or step.ended != "clip" - until = step.start + step.ticks * STEP if cut else length.frames return Move( clip=self._clip(step.clip), - attacks=self._attacks(step, until if length.loop else min(until, length.frames)), + attacks=self._attacks(step, start, stop or frames), steer=self._steer(step, walls=length.loop), - length=max(1, step.ticks) if cut else None, + length=max(1, step.ticks) if cut and not stop else None, + start=start, + stop=stop, after=after, label=f"MHP3rd {self.pair[0]},{self.pair[1]}", ) - def _attacks(self, step: Step, until: float) -> list[AttackWindow]: + def _attacks(self, step: Step, start: int, until: int) -> list[AttackWindow]: + """The step's attack windows inside `[start, until]`.""" out: list[AttackWindow] = [] for h in sorted(step.hits, key=lambda h: (h.frame, h.slot)): - frame = round(h.frame) + frame = max(start, round(h.frame)) end = round(until if h.end is None else min(h.end, until)) if end <= frame: continue diff --git a/packages/mhfu-port/src/mhfu_port/cli/own.py b/packages/mhfu-port/src/mhfu_port/cli/own.py index c0a8f8ae..7ff27645 100644 --- a/packages/mhfu-port/src/mhfu_port/cli/own.py +++ b/packages/mhfu-port/src/mhfu_port/cli/own.py @@ -59,10 +59,12 @@ def run(args: argparse.Namespace) -> int: def describe(m: manifest.Manifest, name: str, slot: int | None) -> str: - """One derived move: its slot, clip, length and steer, then its attacks.""" + """One derived move: its slot, clip, frames, length and steer, then its attacks.""" mv = m.moves[name] clip = m.clips[mv.clip] if mv.clip is not None else None head = f" {name} (slot {slot}) clip {clip.id if clip else mv.anim} {mv.clip}" + if mv.start or mv.stop: + head += f" frames {mv.start}-{mv.stop or 'end'}" if mv.length is not None: head += f" {mv.length} AI frames" s = mv.steer diff --git a/packages/mhfu-port/src/mhfu_port/manifest.py b/packages/mhfu-port/src/mhfu_port/manifest.py index 2e7c2edf..32dd4571 100644 --- a/packages/mhfu-port/src/mhfu_port/manifest.py +++ b/packages/mhfu-port/src/mhfu_port/manifest.py @@ -59,6 +59,8 @@ """Attacks the move player holds per move.""" OWN_MOVES: int = addresses.EM_MOVES.MOVES.count or 0 """Own moves the framework holds per port.""" +MAX_FRAME = 0xFFFF +"""The clip frame a move's `start` or `stop` can name: the executor stores it in 16 bits.""" ROWS = range(hitzone.MAX_ROW + 1) BYTE = range(0x100) JOINER, NODE_CAPSULE, NODE_SPHERE = hitzone.MARKER_BONES @@ -231,7 +233,8 @@ class Move: An own move plays its clip on every body part, riding `carrier` (None: the host's hub), spawns `attacks` at its clip frames and turns as `steer` says; the host's attacks for the - clip's entry are muted unless `host_attacks`. + clip's entry are muted unless `host_attacks`. It plays from clip frame `start` to `stop` (or + the clip's end), both clip frames, so they hold at any blend and cursor rate. """ main: int | None = None @@ -250,7 +253,12 @@ class Move: attacks: list[AttackWindow] = field(default_factory=list, metadata=_toml("attack")) steer: Steer = field(default_factory=Steer) length: int | None = None - """AI frames from the clip's dispatch; None: until the clip ends.""" + """AI frames from the clip's dispatch, the blend's among them; None: until the clip ends.""" + start: int = 0 + """Clip frame the clip starts at; 0: its first. An attack before it never spawns.""" + stop: int = 0 + """Clip frame the move ends at, when the cursor reaches it; 0: where `length` or the clip's + end says.""" carrier: tuple[int, int] | None = None host_attacks: bool = False label: str = "" @@ -635,6 +643,26 @@ def _vec3(v: list[float] | None, where: str) -> None: _need(v is None or len(v) == 3, where, "needs x, y, z") +def span_problems(mv: Move, frames: int | None = None) -> list[tuple[str, str]]: + """What is wrong with an own move's `start`, `stop` and attack windows, as (where under the + move, text); with the clip's `frames`, also what lies past it.""" + if not 0 <= mv.start <= MAX_FRAME or not 0 <= mv.stop <= MAX_FRAME: + return [("", f"start and stop are clip frames, 0 to {MAX_FRAME}")] + out: list[tuple[str, str]] = [] + if mv.stop and mv.stop <= mv.start: + out.append(("", f"stop {mv.stop} is not after start {mv.start}")) + if frames is not None and mv.start >= frames: + out.append(("", f"start {mv.start} is past the clip's {frames} frames")) + if frames is not None and mv.stop > frames: + out.append(("", f"stop {mv.stop} is past the clip's {frames} frames")) + for i, a in enumerate(mv.attacks): + if a.frame < mv.start: + out.append((f".attack[{i}]", f"frame {a.frame} is before start {mv.start}")) + elif mv.stop and (a.end or a.frame) > mv.stop: + out.append((f".attack[{i}]", f"it lasts to {a.end or a.frame}, past stop {mv.stop}")) + return out + + def _own_move(mv: Move, w: str) -> None: _need(mv.claim is None and mv.hold_max is None, w, "an own move has no claim or hold_max") _need(len(mv.attacks) <= MOVE_ATTACKS, w, f"the move player holds {MOVE_ATTACKS} attacks") @@ -642,6 +670,8 @@ def _own_move(mv: Move, w: str) -> None: _need(a.id >= 0 and a.frame >= 0, f"{w}.attack[{i}]", "id and frame are 0 or more") _need(a.end is None or a.end > a.frame, f"{w}.attack[{i}]", "end is after frame") _need(mv.length is None or mv.length >= 1, w, "length is at least 1 AI frame") + for sub, text in span_problems(mv): + _need(False, w + sub, text) _need(mv.carrier is None or mv.carrier[0] in MAIN_STATES, w, "carrier main is a main state") s = mv.steer fixed = s.turn == "fixed" @@ -707,11 +737,11 @@ def _validate(m: Manifest) -> None: _own_move(mv, w) else: plain = Move(mv.main, mv.sub) + fields = ("attacks", "steer", "length", "start", "stop", "carrier", "host_attacks") _need( - (mv.attacks, mv.steer, mv.length, mv.carrier, mv.host_attacks) - == (plain.attacks, plain.steer, plain.length, plain.carrier, plain.host_attacks), + all(getattr(mv, k) == getattr(plain, k) for k in fields), w, - "attack, steer, length, carrier and host_attacks are for an own move", + "attack, steer, length, start, stop, carrier and host_attacks are for an own move", ) if mv.claim is not None: _need(bool(mv.claim.mains), w, "claim needs a main") diff --git a/packages/mhfu-port/src/mhfu_port/moves.py b/packages/mhfu-port/src/mhfu_port/moves.py index 455b88bf..f0e9538d 100644 --- a/packages/mhfu-port/src/mhfu_port/moves.py +++ b/packages/mhfu-port/src/mhfu_port/moves.py @@ -10,8 +10,9 @@ A pair move goes as the runtime paints it on its host pair. An own move goes as the move player 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`. `tip` is the tail tip's -`{ joint, carrier }` pairs (`rig.Tip`), which `mhfu.em_tip` takes. +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. `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. @@ -36,7 +37,7 @@ from . import behaviour from .behaviour import Rule from .layout import Layout -from .manifest import OWN_MOVES, Manifest, ManifestError, Move +from .manifest import OWN_MOVES, Manifest, ManifestError, Move, span_problems from .rig import Tip Pair = tuple[int, int] @@ -125,9 +126,10 @@ def records(game: Extracted, species: int) -> frozenset[int]: 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, 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.""" + 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.""" out: list[str] = [] pool = 0 if (n := sum(mv.own for mv in m.moves.values())) > SLOTS: @@ -143,6 +145,7 @@ def problems(m: Manifest, layout: Layout, known: Collection[int] | None = None) out.append(str(err)) continue frames = layout.frames.get(e) + out += [f"{w}{sub}: {text}" for sub, text in span_problems(mv, frames)] for i, a in enumerate(mv.attacks): if known is not None and a.id not in known: out.append( @@ -237,6 +240,10 @@ def _own_move(m: Manifest, name: str, layout: Layout, slot: int) -> dict[str, An out["carrier"] = list(carrier(m, name)) if mv.length is not None: out["length"] = mv.length + if mv.start: + out["start"] = mv.start + if mv.stop: + out["stop"] = mv.stop if mv.host_attacks: out["host_attacks"] = True out["steer"] = _steer(m, name, layout) diff --git a/packages/mhfu-port/src/mhfu_port/p3hits.py b/packages/mhfu-port/src/mhfu_port/p3hits.py index 0f68b9ce..b583817b 100644 --- a/packages/mhfu-port/src/mhfu_port/p3hits.py +++ b/packages/mhfu-port/src/mhfu_port/p3hits.py @@ -273,13 +273,16 @@ class Step: """AI frames it played, from the first after the play to the one that ended it.""" hits: tuple[Hit, ...] turns: tuple[Turn, ...] + stop: float | None = None + """The clip frame the cursor test that ended it was crossed at; None when its clip or the + pair ended it, or no test of the cursor changed its answer on the tick.""" @dataclass(frozen=True) class Note: """What a plan cannot say.""" - code: Literal["branch", "motion", "start", "unresolved", "limit", "record"] + code: Literal["branch", "motion", "unresolved", "limit", "record"] text: str @@ -701,10 +704,7 @@ def _play(self, run: _Run, pair: Pair, s: Site) -> None: run.notes.append(Note("unresolved", f"the clip played at 0x{s.site:08X} is computed")) run.finished = True return - start = s.args[2] or 0 - if start: - run.notes.append(Note("start", f"clip {clip} starts at frame {start}")) - self._start(run, clip, s.args[1], start) + self._start(run, clip, s.args[1], s.args[2] or 0) def _play_first(self, run: _Run, ids: Sequence[int] | None, s: Site) -> None: """A helper that plays one of several clips it is given a record of: the first.""" @@ -727,7 +727,7 @@ def _start(self, run: _Run, clip: int, blend: int | None, start: int) -> None: run.notes.append(Note("limit", f"the handler played {MAX_STEPS} clips without ending")) run.finished = True return - self._close_step(run, "clip" if run.ended else "cursor") + self._close_step(run, "clip" if run.ended else "cursor", self._crossed(run)) carried = {} for slot, node in run.nodes.items(): if not node.hit.flags & FLAG_OUTLIVES or (node.left is not None and node.left <= 0): @@ -794,7 +794,24 @@ def _advance(self, run: _Run, n: int) -> None: if length is not None and length.loop and run.cursor >= length.frames: run.cursor %= length.frames - def _close_step(self, run: _Run, how: How) -> None: + def _crossed(self, run: _Run) -> float | None: + """The clip frame the step ends at: of the last cursor test this tick whose answer changed + since the tick before, its `lo`, or its `hi` for a window the cursor left. None on a + step's first tick, which has no tick before.""" + step, clip = run.current, run.clip + length = self.lengths.get(clip) if clip is not None else None + if step is None or step.ticks < 1 or run.ended or clip is None or length is None: + return None + before = run.cursor - self.step + if length.loop: + before %= length.frames + for q, yes in reversed(run.asked): + was = None if q.mode is None else self._answer(q, clip, before) + if was is not None and was != yes: + return q.hi if was and q.mode in WINDOWS else q.lo + return None + + def _close_step(self, run: _Run, how: How, stop: float | None = None) -> None: step = run.current if step is None: return @@ -805,7 +822,16 @@ def _close_step(self, run: _Run, how: How) -> None: for h in step.hits ] run.done.append( - Step(step.clip, step.blend, step.start, how, step.ticks + 1, tuple(hits), _turns(step)) + Step( + step.clip, + step.blend, + step.start, + how, + step.ticks + 1, + tuple(hits), + _turns(step), + stop, + ) ) run.steps.pop() diff --git a/packages/mhfu-port/tests/test_manifest.py b/packages/mhfu-port/tests/test_manifest.py index f72ecc17..69be4836 100644 --- a/packages/mhfu-port/tests/test_manifest.py +++ b/packages/mhfu-port/tests/test_manifest.py @@ -190,6 +190,8 @@ def _v1(extra: str) -> str: OWN + """carrier = [0, 2] length = 90 +start = 24 +stop = 90 host_attacks = true [[moves.o.attack]] id = 6 @@ -474,8 +476,10 @@ def test_own_move(): o = m.moves["o"] assert o.own and o.pair is None and m.moves == M.loads(M.dumps(m)).moves assert o.attacks == [M.AttackWindow(6, 56, 80)] and o.steer.angle == -90.0 + assert (o.length, o.start, o.stop) == (90, 24, 90) assert not M.loads(_with(MOVE)).moves["m"].own - assert "steer" not in M.dumps(M.loads(_with(OWN))) + text = M.dumps(M.loads(_with(OWN))) + assert "steer" not in text and "start" not in text and "stop" not in text CUT = ( @@ -529,11 +533,25 @@ def test_cuts_share_a_source(): ("[[effect]]\nmove = 'm'\nframe = 1\nid = 1\nbone = 1\n", "not in moves"), ("[moves.m]\nmain = 1\nanim = 1\n", "both main and sub"), (MOVE + "length = 9\n", "for an own move"), + (MOVE + "start = 9\n", "for an own move"), + (MOVE + "stop = 9\n", "for an own move"), (MOVE + "[[moves.m.attack]]\nid = 6\nframe = 1\n", "for an own move"), (OWN + "hold_max = 9\n", "no claim or hold_max"), (OWN + "[[moves.o.attack]]\nid = 6\nframe = 1\n" * 5, "holds 4 attacks"), (OWN + "[[moves.o.attack]]\nid = 6\nframe = 9\nend = 9\n", "after frame"), (OWN + "length = 0\n", "length"), + (OWN + "start = 70000\n", "start and stop are clip frames, 0 to 65535"), + (OWN + "stop = -1\n", "start and stop are clip frames"), + (OWN + "start = 90\nstop = 80\n", "moves.o: stop 80 is not after start 90"), + ( + OWN + "start = 60\n[[moves.o.attack]]\nid = 6\nframe = 56\n", + r"o.attack\[0\]: frame 56 is before", + ), + ( + OWN + "stop = 70\n[[moves.o.attack]]\nid = 6\nframe = 56\nend = 80\n", + "lasts to 80, past stop 70", + ), + (OWN + "stop = 50\n[[moves.o.attack]]\nid = 6\nframe = 56\n", "lasts to 56, past stop 50"), (OWN + "carrier = [9, 0]\n", "carrier main"), (OWN + "[moves.o.steer]\nturn = 'fixed'\n", "angle goes with"), (OWN + "[moves.o.steer]\nangle = 90.0\n", "angle goes with"), diff --git a/packages/mhfu-port/tests/test_moves.py b/packages/mhfu-port/tests/test_moves.py index 59514403..f28da27a 100644 --- a/packages/mhfu-port/tests/test_moves.py +++ b/packages/mhfu-port/tests/test_moves.py @@ -111,6 +111,36 @@ def test_lua(): ] +SPAN = OWN.replace('after = "dash"\n', 'after = "dash"\nstart = 24\nstop = 86\n', 1) +"""The stamp playing its clip from frame 24 to 86, its attack at 56 to 80 inside.""" + + +def test_start_and_stop_reach_the_module(): + text = moves.lua(_m(SPAN), LAYOUT, {6}) + assert "carrier = { 0, 2 }, start = 24, stop = 86, steer = { walls = false," in text + assert " start = " not in moves.lua(_m(), LAYOUT, {6}) + + +def test_a_derived_moves_span_is_checked_here(): + """Derived moves never go through the manifest's load: `problems` is what refuses them.""" + m = _m(SPAN) + mv = m.moves["stamp"] + assert moves.problems(m, LAYOUT, {6}) == [] + mv.start, mv.stop = 90, 80 + found = moves.problems(m, LAYOUT, {6}) + assert found == [ + "moves.stamp: stop 80 is not after start 90", + "moves.stamp.attack[0]: frame 56 is before start 90", + ] + mv.start, mv.stop = 300, 0 + assert moves.problems(m, LAYOUT, {6}) == [ + "moves.stamp: start 300 is past the clip's 228 frames", + "moves.stamp.attack[0]: frame 56 is before start 300", + ] + with pytest.raises(ManifestError, match="start 300 is past"): + moves.check(m, LAYOUT, {6}) + + def test_steer_window(): hunter = OWN.replace( 'turn = "hunter"\ndir = 180.0', 'turn = "hunter"\nrate = 546\nwindow = [10, 40]' @@ -235,6 +265,21 @@ def test_hub(): None, "frame 228 is past the clip's 228", ), + ( + OWN.replace("anim = 9\n", "anim = 9\nstart = 100\n"), + LAYOUT, + None, + "spin: start 100 is past", + ), + (SPAN.replace("stop = 86", "stop = 229"), LAYOUT, None, "stop 229 is past the clip's 228"), + (SPAN.replace("stop = 86", "stop = 24"), LAYOUT, None, "stop 24 is not after start 24"), + ( + SPAN.replace("start = 24", "start = 60"), + LAYOUT, + None, + r"attack\[0\]: frame 56 is before", + ), + (SPAN.replace("stop = 86", "stop = 70"), LAYOUT, None, "lasts to 80, past stop 70"), ( OWN, layout.Layout(LAYOUT.entries, turns={46: travel.Turn(600, (0,) * 301, 0, None)}), diff --git a/packages/mhfu-port/tests/test_own.py b/packages/mhfu-port/tests/test_own.py index eef7791e..7da448d7 100644 --- a/packages/mhfu-port/tests/test_own.py +++ b/packages/mhfu-port/tests/test_own.py @@ -79,8 +79,8 @@ def derive(plan: Plan, **kw): return m, found -def step(clip: int, hits=(), turns=(), ended="clip", ticks=10, start=0.0) -> Step: - return Step(clip, 4, start, ended, ticks, tuple(hits), tuple(turns)) +def step(clip: int, hits=(), turns=(), ended="clip", ticks=10, start=0.0, stop=None) -> Step: + return Step(clip, 4, start, ended, ticks, tuple(hits), tuple(turns), stop) def plan(*steps: Step, **kw) -> Plan: @@ -123,9 +123,38 @@ def test_a_chain_hands_on_and_a_loop_is_cut(): assert set(m.clips) == {"charge", "p3_59", "p3_60"} -def test_a_crossing_ends_a_clip_early(): - m, _ = derive(plan(step(61, ended="cursor", ticks=55))) - assert m.moves["p3_7_13"].length == 55 +def spans(m: manifest.Manifest, name: str = "p3_7_13") -> tuple[int, int, int | None]: + mv = m.moves[name] + return mv.start, mv.stop, mv.length + + +def test_the_frame_a_test_ended_a_step_at_is_its_stop(): + hits = [Hit(1, 21, 16, 56.0, 200.0), Hit(0, 28, 8, 10.0, 20.0)] + m, _ = derive(plan(step(61, hits, ended="cursor", ticks=31, start=24.0, stop=86.0))) + assert spans(m) == (24, 86, None) + # clamped to [start, stop]; the one over before the start is dropped + assert [(a.id, a.frame, a.end) for a in m.moves["p3_7_13"].attacks] == [(7, 56, 86)] + manifest.check(m) + + +def test_a_loop_stops_where_the_donor_left_it(): + p = plan(step(59, [Hit(0, 19, 3, 68.0, None)], ended="cursor", ticks=57, stop=114.0)) + m, _ = derive(p) + assert spans(m) == (0, 114, None) and m.moves["p3_7_13"].attacks[0].end == 114 + + +def test_a_start_is_carried_and_the_clip_plays_on_to_its_end(): + m, found = derive(plan(step(61, ended="clip", ticks=100, start=18.0))) + assert spans(m) == (18, 0, None) and "P3_OWN_START" not in codes(found) + + +@pytest.mark.parametrize( + ("ended", "stop"), [("cursor", None), ("pair", None), ("cursor", 500.0), ("cursor", 24.0)] +) +def test_a_step_with_no_frame_to_stop_on_keeps_its_length(ended, stop): + """No frame, or one outside the clip or not after the start.""" + m, _ = derive(plan(step(61, ended=ended, ticks=55, start=24.0, stop=stop))) + assert spans(m) == (24, 0, 55) def test_an_attack_with_no_host_record_is_left_out(): @@ -229,6 +258,21 @@ def test_brute_derives_its_seven_pairs(derived): assert sequence.chain(m, "p3_7_14") == ["p3_7_14", "p3_7_14_2"] +def test_brute_windows_lie_inside_their_spans(derived, data): + m, _ = derived + made = {n: mv for n, mv in m.moves.items() if n.startswith("p3_")} + assert {n: (mv.start, mv.stop, mv.length) for n, mv in made.items() if mv.start or mv.stop} == { + "p3_2_19_3": (6, 0, None) + } + assert {n: mv.length for n, mv in made.items() if mv.length is not None} == { + "p3_2_19_2": 39, + "p3_7_14": 72, + } + for name, mv in made.items(): + for a in mv.attacks: + assert mv.start <= a.frame < a.end <= (mv.stop or 10_000), name + + def test_brute_records_match_by_volume(data): m = brute() hits = p3hits.Walk.of(data.p3rd, 23, data.p3rd.read(m.source.anim)).hits diff --git a/packages/mhfu-port/tests/test_own_cli.py b/packages/mhfu-port/tests/test_own_cli.py index 96ae9302..bb2197d1 100644 --- a/packages/mhfu-port/tests/test_own_cli.py +++ b/packages/mhfu-port/tests/test_own_cli.py @@ -7,6 +7,7 @@ from mhfu_port.brain.splice import Spliced from mhfu_port.cli import main from mhfu_port.cli.build import modules +from mhfu_port.cli.own import describe from mhfu_port.cli.splice import summary PORTS = Path(__file__).parents[3] / "ports" @@ -40,6 +41,16 @@ def test_the_cli_needs_an_own_pair(tmp_path, capsys): assert "no [brain.pairs] entry" in capsys.readouterr().err +def test_the_list_shows_the_frames_a_move_plays(): + m = manifest.loads(TEXT + "[moves.p3_7_13]\nclip = 'charge'\nstart = 24\nstop = 86\n") + assert describe(m, "p3_7_13", 3).startswith(" p3_7_13 (slot 3) clip 61 charge frames 24-86") + plain = manifest.loads(TEXT + "[moves.p3_7_13]\nclip = 'charge'\nstart = 24\n") + assert "frames 24-end" in describe(plain, "p3_7_13", 3) + assert "frames" not in describe( + manifest.loads(TEXT + "[moves.p3_7_13]\nclip = 'charge'\n"), "p3_7_13", 3 + ) + + def test_the_cli_lists_the_moves(data, capsys): path = PORTS / "brute_tigrex.toml" code = main(["own", str(path), "--data", str(data.fu.root), "--p3rd-data", str(data.p3rd.root)]) diff --git a/packages/mhfu-port/tests/test_p3hits.py b/packages/mhfu-port/tests/test_p3hits.py index daea2313..ad59598c 100644 --- a/packages/mhfu-port/tests/test_p3hits.py +++ b/packages/mhfu-port/tests/test_p3hits.py @@ -205,6 +205,53 @@ def test_straight_pair(): assert (step.clip, step.start, step.ended, step.ticks) == (61, 0, "clip", 50) assert step.hits == (Hit(0, 5, 0, 20, 50), Hit(1, 6, 0, 60, 70)) assert [(t.rate, t.window) for t in step.turns] == [(100, (0, 40))] + assert step.stop is None # the clip ended it + + +def test_a_step_stops_at_the_frame_its_gate_crossed(): + def second(a: Asm) -> None: + gate(a, 0, F60) + a.branch("beq", "v0", "zero", "done") + play(a, 62) + a.call(LEAVE) + + plan = walk(species(second, lambda a: play(a, 61)), {61: Length(100.0), 62: Length(40.0)}).plan( + pair() + ) + first, last = plan.steps + assert (first.clip, first.ended, first.ticks, first.stop) == (61, "cursor", 30, 60.0) + assert (last.clip, last.ended, last.stop) == (62, "pair", None) + + +def test_a_step_stops_at_the_end_of_the_window_it_left(): + def second(a: Asm) -> None: + gate(a, 3, F0, F40) + a.branch("bne", "v0", "zero", "done") + play(a, 62) + a.call(LEAVE) + + plan = walk(species(second, lambda a: play(a, 61)), {61: Length(100.0), 62: Length(40.0)}).plan( + pair() + ) + assert [(s.ended, s.ticks, s.stop) for s in plan.steps] == [ + ("cursor", 21, 40.0), + ("pair", 1, None), + ] + + +def test_a_step_ended_on_its_first_tick_has_no_frame_to_stop_on(): + def second(a: Asm) -> None: + gate(a, 1, F0) # already true on the first tick: nothing changed since a tick before + play(a, 62) + a.call(LEAVE) + + plan = walk(species(second, lambda a: play(a, 61)), {61: Length(100.0), 62: Length(40.0)}).plan( + pair() + ) + assert [(s.ended, s.ticks, s.stop) for s in plan.steps] == [ + ("cursor", 1, None), + ("pair", 1, None), + ] def test_a_branch_the_walk_cannot_decide_is_taken_for_the_attack(): diff --git a/packages/mhfu-port/tests/test_zinogre_brain.py b/packages/mhfu-port/tests/test_zinogre_brain.py index 22633755..04410188 100644 --- a/packages/mhfu-port/tests/test_zinogre_brain.py +++ b/packages/mhfu-port/tests/test_zinogre_brain.py @@ -108,17 +108,44 @@ def test_the_walks_play_own_moves_not_the_host_pair_1_0(zinogre): def chain_of(m: manifest.Manifest, name: str) -> list[tuple[object, ...]]: - """What a derived move plays, step by step: clip, attacks, steer, length.""" + """What a derived move plays, step by step: clip, attacks, steer, length, start, stop.""" out = [] mv: manifest.Move | None = m.moves[name] while mv is not None: clip = m.clips[mv.clip].source if mv.clip else mv.anim attacks = tuple((a.id, a.frame, a.end) for a in mv.attacks) - out.append((clip, attacks, mv.steer, mv.length)) + out.append((clip, attacks, mv.steer, mv.length, mv.start, mv.stop)) mv = m.moves[mv.after] if mv.after else None return out +D2 = {"p3_7_17_3": 42, "p3_7_17_5": 24, "p3_7_19_3": 42, "p3_7_19_5": 24} +"""The steps that ended on a frame past their attack, live D2: `length` in AI frames stopped +them at cursor 54 of the 56 the attack needs.""" + + +def test_the_d2_steps_start_where_the_donor_did_and_stop_past_their_attack(zinogre): + for name, start in D2.items(): + mv = zinogre.m.moves[name] + (attack,) = mv.attacks + assert (mv.start, mv.length) == (start, None), name + assert attack.frame <= attack.end <= mv.stop and mv.stop > attack.frame, name + + +def test_every_derived_window_lies_inside_its_move_s_frames(zinogre, module_text): + _, built = module_text + made = {n: mv for n, mv in zinogre.m.moves.items() if n.startswith("p3_")} + assert sum(bool(mv.start) for mv in made.values()) == 10 + assert sum(bool(mv.stop) for mv in made.values()) == 13 + assert sum(mv.length is not None for mv in made.values()) == 14 + for name, mv in made.items(): + frames = built.layout.frames[moves.entry(zinogre.m, name, built.layout)] + assert mv.start < frames and mv.stop <= frames, name + assert not (mv.stop and mv.length is not None), name + for a in mv.attacks: + assert mv.start <= a.frame < a.end <= (mv.stop or frames), name + + def test_the_pairs_that_share_a_move_derive_the_same_one(zinogre, data): """A pair that names its twin's move would derive that very move itself. (7, 71), (7, 82) and (7, 119) are not the script's: only wrappers lead to them, and play (7, 13)'s move.""" diff --git a/ports/README.md b/ports/README.md index b4b8a881..8bdc41f4 100644 --- a/ports/README.md +++ b/ports/README.md @@ -81,6 +81,8 @@ player on its carrier (none given: the host's hub, `mhfu_port.moves.HUBS`, em75' [moves.stamp] clip = "stamp_right_claw" attack = [{ id = 6, frame = 56, end = 80 }] # an attack record, spawned and ended at clip frames +start = 24 # the clip starts at this frame ... +stop = 86 # ... and the move ends at this one after = "dash" # played when this one ends; own or a pair [moves.stamp.steer] @@ -88,6 +90,11 @@ turn = "clip" # or still, hunter, away, fixed (angle over frames) walls = true # a wall ahead ends it ``` +`start` and `stop` are clip frames, so they hold at any blend and cursor rate; `length` is AI +frames from the dispatch, which the blend and the rate change. An attack must lie inside +`[start, stop]`. A move derived from a donor pair gets its `start` and `stop` from the donor's own +clip frames (`mhfu-port own` lists them). + `[behaviour]` decides when a move plays, as a graph. A block is `[behaviour.blocks.]`: its `kind`, the kind's params flat beside it, `at` (canvas x, y), `next` (the blocks it feeds), `play` (the moves a path that gets here plays) and `label`. A move on the canvas is