From 3898d301d185c87c6b3f60a0672b70f39421c9ec Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:03:40 +0100 Subject: [PATCH 1/6] framework: a move aims at the monster's target; turns go by clip speed, lunges by size The move player's turn and lunge and the brain's distance and side gates read one function, mhfu_move_target_pos: ENTITY.TARGET's object, the player when it is 0 or not RAM. The hunter and away turn turns the entity's own YAW (base), at the donor's rate times CLIP_BLOCK.SPEED / 2; the lunge's near, span, base and range are times the size. Co-Authored-By: Claude Sonnet 5.5 --- framework/README.md | 11 +- framework/include/mhfu/em_vhook.h | 6 +- framework/include/mhfu/move.h | 5 + framework/include/mhfu/steer.h | 30 ++-- framework/lua/lib/mhfu_port.lua | 4 +- framework/lua/meta/mhfu.d.lua | 10 +- framework/src/core/em_vhook.cpp | 24 ++- framework/src/core/move.cpp | 21 ++- framework/src/core/steer.cpp | 35 ++-- framework/tests/em_host.cpp | 1 + framework/tests/move_host.cpp | 1 + framework/tests/test_em_brain.py | 38 ++++- framework/tests/test_move.py | 159 +++++++++++++++++-- packages/mhfu-port/src/mhfu_port/manifest.py | 12 +- packages/mhfu/src/mhfu/addresses.toml | 20 +-- packages/mhfu/src/mhfu/live/moves.py | 12 +- 16 files changed, 300 insertions(+), 89 deletions(-) diff --git a/framework/README.md b/framework/README.md index 4d4871a1..766d629c 100644 --- a/framework/README.md +++ b/framework/README.md @@ -164,10 +164,13 @@ change since the clip cursor's last frame (straight lines between samples, so an 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 +lo, hi }, near, span, base, range }` travels toward the target across the clip frames (lo, hi]: by +the target'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 target then, in world axes, with `near`, +`span`, `base` and `range` times the monster's size. The target is what `ENTITY.TARGET` points at, +the player when it is 0 or not RAM: the turn reads it every frame, the lunge at the move's first, +the brain's distance and side gates too. A turn's `rate` is YAW units a frame at clip speed 1 (times +the clip's speed / 2 in play) and turns the entity's own YAW, which the clip's turn rides on. 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. diff --git a/framework/include/mhfu/em_vhook.h b/framework/include/mhfu/em_vhook.h index fda82f5d..58953bda 100644 --- a/framework/include/mhfu/em_vhook.h +++ b/framework/include/mhfu/em_vhook.h @@ -9,7 +9,7 @@ * request a pair entered on the game thread in the next AI frame, through * MHFU_ENTER_ACTION; * rules a 30 Hz brain in C: "in pair P (or own move M) for >= N frames, or on a - * monster event or a Lua signal, player distance in [lo, hi), receding or + * monster event or a Lua signal, the target's distance in [lo, hi), receding or * closing, every condition holding -> enter (main, sub, mode) or play own move * M', and change counters", with cooldown and budget; a rule on the flinch plays * its move in place of the host's reaction; @@ -81,7 +81,7 @@ typedef struct { * 0: from the pair, which waits while any move plays */ uint8_t play_move; /* own move slot + 1 played instead of entering (to_main, to_sub) */ uint32_t min_frames; /* the pair (or own move) must have stood this long */ - float dist_lo, dist_hi; /* player XZ distance window [lo, hi) */ + float dist_lo, dist_hi; /* the target's XZ distance window [lo, hi) (mhfu_move_target_pos) */ uint32_t cooldown; /* frames between two fires */ uint32_t count; /* fires allowed; MHFU_EM_UNLIMITED for a standing rule */ uint8_t on; /* MHFU_MONSTER_*: fires in the AI frame the event is seen, from_* and @@ -97,7 +97,7 @@ typedef struct { typedef struct { uint32_t installed; uint32_t ai_ticks, act_enters, last_pair, frames; - float dist; /* player XZ distance at the last measurement */ + float dist; /* the target's XZ distance at the last measurement */ uint32_t sub_hits, sub_landed, sub_last_in; uint32_t brain_fires; uint32_t req_pending, req_done, req_result; diff --git a/framework/include/mhfu/move.h b/framework/include/mhfu/move.h index c98657d0..f1ce6932 100644 --- a/framework/include/mhfu/move.h +++ b/framework/include/mhfu/move.h @@ -147,6 +147,11 @@ void mhfu_move_steer(const mhfu_steer_spec_t *s); /* The block, or 0 before the framework's init. */ const volatile mhfu_move_state_t *mhfu_move_state(void); +/* Where the monster aims: the address of ENTITY.POSITION of the object ENTITY.TARGET points at + * (the player or a comrade), the player's when that is 0 or not in RAM. The one place the target + * is resolved: the move's turn and lunge, and the brain's distance and side gates, read it. */ +uint32_t mhfu_move_target_pos(uint32_t entity); + /* Replaces reaction `kind` of entity with mv (and steer, NULL for none) from the next one on, until * called again or a brain rule on the flinch is installed; mv NULL stops it. 0 while nothing is * wrapped. */ diff --git a/framework/include/mhfu/steer.h b/framework/include/mhfu/steer.h index b0ce0808..66333a96 100644 --- a/framework/include/mhfu/steer.h +++ b/framework/include/mhfu/steer.h @@ -76,7 +76,7 @@ typedef struct { int16_t x, z; } mhfu_travel_sample_t; -/* struct STEER_LUNGE in addresses.toml */ +/* struct STEER_LUNGE in addresses.toml; near, span, base and range are times ENTITY.SIZE_SCALE */ typedef struct { uint16_t lo, hi; /* clip frames, the window (lo, hi]; hi 0: no lunge */ float near, span, base, range; @@ -101,13 +101,22 @@ typedef struct { typedef struct { mhfu_steer_spec_t next; /* taken on the move's first playing frame */ mhfu_steer_spec_t now; - uint16_t base; /* YAW the curve adds to */ + uint16_t base; /* the entity's own YAW: the curve adds to it */ uint8_t wall; /* the last frame's MHFU_STEER_* */ 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; +/* Part 0's clip block as the move player reads it each AI frame. */ +typedef struct { + float start; /* the clip frame the move began at */ + float cursor; + float loop_start; + float end; + float speed; /* CLIP_BLOCK.SPEED: clip frames an AI frame, 2.0 calm */ +} mhfu_steer_clip_t; + /* No turn, no walls; a class-2 wall ends into (0,6) mode 1, as the Tigrex charge does. */ 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 @@ -117,14 +126,15 @@ uint16_t mhfu_steer_curve(const volatile mhfu_steer_spec_t *s, float cursor); 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; 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 start, float cursor, float loop_start, float end); + * base turned by the spec's mode (HUNTER and AWAY toward `target`, at the rate times + * clip->speed / 2, only while the cursor, the clip frame, is in 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: clip->start) and this one: the curve + * turned by YAW on frame 1, the lunge along the bearing to `target` on frame 1, both scaled by + * ENTITY.SIZE_SCALE. 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 *target, volatile mhfu_steer_state_t *st, + uint32_t frame, const mhfu_steer_clip_t *clip); #ifdef __cplusplus } /* extern "C" */ diff --git a/framework/lua/lib/mhfu_port.lua b/framework/lua/lib/mhfu_port.lua index f0b38ade..64f7d2e2 100644 --- a/framework/lua/lib/mhfu_port.lua +++ b/framework/lua/lib/mhfu_port.lua @@ -243,7 +243,7 @@ P.is_own = is_own -- 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 turn keeps to or a fixed turn --- spreads across, `travel` the move's own travel by code, `lunge` toward the hunter). +-- spreads across, `travel` the move's own travel by code, `lunge` toward the target). -- 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: @@ -511,7 +511,7 @@ end --- port:rule{ from = "lunge", -- a move name, or { main = 1, sub = 4 }, --- from_main = { 0, 1 }, -- ...or a set of main states (any sub) --- min_frames = 15, -- the pair (or own move) must have stood this long ---- dist = { 250, 1e9 }, -- player XZ distance window [lo, hi) +--- dist = { 250, 1e9 }, -- the target's XZ distance window [lo, hi) --- receding = true, -- only while the gap is GROWING --- closing = false, -- only while it is SHRINKING --- play = "lunge_stop", -- the move: its pair entered, or an own move played diff --git a/framework/lua/meta/mhfu.d.lua b/framework/lua/meta/mhfu.d.lua index 340ae971..5fcc71d2 100644 --- a/framework/lua/meta/mhfu.d.lua +++ b/framework/lua/meta/mhfu.d.lua @@ -667,8 +667,8 @@ function mhfu.move_block() end ---How a move turns and stops (mhfu/steer.h); every field is optional. ---@class mhfu.MoveSteer ---@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 turn? "still"|"hunter"|"away"|"fixed" also: toward or away from the target (ENTITY.TARGET, the player without one) at `rate`, or `total` over `frames` +---@field rate? integer YAW units an AI frame toward or away at clip speed 1, times the clip's speed / 2 in play; 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), 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, without a `window` @@ -676,14 +676,14 @@ function mhfu.move_block() end ---@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 +---@field lunge? mhfu.MoveLunge travel toward the target, by the target'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. +---A move's lunge (mhfu.MoveSteer.lunge): over the clip frames `window` (lo, hi] the monster moves 0 units while the target's XZ distance `d` is under `near`, else `base + range * min((d - near) / span, 1)` along the bearing to the target at the first playing frame, in world axes, spread by the clip cursor; `near`, `span`, `base` and `range` are times the monster's 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 base? number default 0, negative away from the target ---@field range? number default 0 ---What `mhfu.move_status()` returns. diff --git a/framework/src/core/em_vhook.cpp b/framework/src/core/em_vhook.cpp index 99373e81..03225307 100644 --- a/framework/src/core/em_vhook.cpp +++ b/framework/src/core/em_vhook.cpp @@ -40,13 +40,13 @@ * with them; the events seen meanwhile reach the rules after the drop. * TIP OBJ_VTABLE.BRAIN post (wrap.h): the port's tip joints posed from * their carriers, after ATTACH and before the draw, until the drop. - * BRAIN slot-29 pre, in C (brain()): the pair's dwell, the hunter's + * BRAIN slot-29 pre, in C (brain()): the pair's dwell, the target's * distance and the monster events (monster_events.cpp), then at most * one of: an own move asked for, the move of a rule on the flinch (or on * the break it made) whose reaction was replaced, the AFTER of one of * ours that ended, a rule * ("in pair P or own move M for N frames, or on event E or signal S, - * player at [lo,hi), receding, every condition holding -> enter Q or play + * target at [lo,hi), receding, every condition holding -> enter Q or play * own move M', change counters", cooldown, budget); the rules that play * nothing run first, all of them, in every AI frame; * last, the rules on the flinch arm the reaction replacement for the @@ -435,7 +435,7 @@ static int g_qry_wrapped; /* MONSTER_VTABLE.QUERY carries the charge /* --- the brain: slot 29 calls it every AI frame, before the C step. ---- * - * It enters the pair mhfu_em_request asked for, keeps the pair's dwell, the hunter's distance and + * It enters the pair mhfu_em_request asked for, keeps the pair's dwell, the target'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, its @@ -849,16 +849,15 @@ static void take_rage(uint32_t ent) #define ROW_FORWARD 8 #define ROW_LEFT 0 -/* The 90 degree sector of the monster's frame the hunter stands in, as a SIDE_*: the hunter's - * offset against the two rows with dot products, no trig. The rows may carry the monster's size, +/* The 90 degree sector of the monster's frame the target stands in, as a SIDE_*: its offset + * against the two rows with dot products, no trig. The rows may carry the monster's size, * so each dot is taken over its row's length (squared, to avoid the root). */ static unsigned side_of(uint32_t ent) { const uint32_t rot = ent + MHFU_ENTITY_ROTATION; - const float dx = mhfu_mem_read_f32(MHFU_PLAYER_ENTITY + MHFU_ENTITY_TRANSLATION) - - mhfu_mem_read_f32(ent + MHFU_ENTITY_POSITION); - const float dz = mhfu_mem_read_f32(MHFU_PLAYER_ENTITY + MHFU_ENTITY_TRANSLATION + 8) - - mhfu_mem_read_f32(ent + MHFU_ENTITY_POSITION + 8); + const uint32_t at = mhfu_move_target_pos(ent); + const float dx = mhfu_mem_read_f32(at) - mhfu_mem_read_f32(ent + MHFU_ENTITY_POSITION); + const float dz = mhfu_mem_read_f32(at + 8) - mhfu_mem_read_f32(ent + MHFU_ENTITY_POSITION + 8); const float fx = mhfu_mem_read_f32(rot + 4 * ROW_FORWARD); const float fz = mhfu_mem_read_f32(rot + 4 * (ROW_FORWARD + 2)); const float lx = mhfu_mem_read_f32(rot + 4 * ROW_LEFT); @@ -1187,10 +1186,9 @@ static uint32_t brain(uint32_t ent) else request(ent); uint32_t pair = pair_of(ent); c->frames = pair == c->prev_pair ? c->frames + 1 : 0; - float dx = mhfu_mem_read_f32(ent + MHFU_ENTITY_POSITION) - - mhfu_mem_read_f32(MHFU_PLAYER_ENTITY + MHFU_ENTITY_TRANSLATION); - float dz = mhfu_mem_read_f32(ent + MHFU_ENTITY_POSITION + 8) - - mhfu_mem_read_f32(MHFU_PLAYER_ENTITY + MHFU_ENTITY_TRANSLATION + 8); + const uint32_t at = mhfu_move_target_pos(ent); + float dx = mhfu_mem_read_f32(ent + MHFU_ENTITY_POSITION) - mhfu_mem_read_f32(at); + float dz = mhfu_mem_read_f32(ent + MHFU_ENTITY_POSITION + 8) - mhfu_mem_read_f32(at + 8); c->d2_prev = c->d2; c->d2 = f32_bits(dx * dx + dz * dz); fat_frame(ent, pair); diff --git a/framework/src/core/move.cpp b/framework/src/core/move.cpp index c8d34815..bf254778 100644 --- a/framework/src/core/move.cpp +++ b/framework/src/core/move.cpp @@ -174,6 +174,13 @@ static uint32_t carrier(void) return ((uint32_t)S->move.carrier_main << 8) | S->move.carrier_sub; } +extern "C" uint32_t mhfu_move_target_pos(uint32_t entity) +{ + uint32_t t = mhfu_mem_read_u32(entity + MHFU_ENTITY_TARGET); + if (!t || !mhfu_mem_valid(t)) t = MHFU_PLAYER_ENTITY; + return t + MHFU_ENTITY_POSITION; +} + /* 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 @@ -183,12 +190,14 @@ static int steer(uint32_t ent) uint32_t b = clip_block(ent, 0); void *e = direct(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, (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)); + const mhfu_vec3_t *target = (const mhfu_vec3_t *)direct(mhfu_move_target_pos(ent)); + mhfu_steer_clip_t clip; + clip.start = (float)S->move.start; + clip.cursor = mhfu_mem_read_f32(b + MHFU_CLIP_BLOCK_PHASE); + clip.loop_start = mhfu_mem_read_f32(b + MHFU_CLIP_BLOCK_LOOP_START); + clip.end = mhfu_mem_read_f32(b + MHFU_CLIP_BLOCK_END); + clip.speed = mhfu_mem_read_f32(b + MHFU_CLIP_BLOCK_SPEED); + return mhfu_steer_move(e, target, &S->steer, S->frames, &clip); } static uint32_t step(uint32_t ent); diff --git a/framework/src/core/steer.cpp b/framework/src/core/steer.cpp index 9b5f9645..c70c28e5 100644 --- a/framework/src/core/steer.cpp +++ b/framework/src/core/steer.cpp @@ -129,6 +129,15 @@ INLINE float ramp(uint16_t lo, uint16_t hi, float at) INLINE int32_t round_to(float v) { return (int32_t)(v < 0 ? v - 0.5f : v + 0.5f); } +/* A turn rate at clip speed 1 as it is at `speed` (the donor's ent[0xAC4]): CLIP_BLOCK.SPEED is 2 + * a frame calm, so the rate is times speed / 2. */ +INLINE uint16_t paced(uint16_t rate, float speed) +{ + if (!(speed > 0)) return 0; + float r = (float)rate * speed * 0.5f + 0.5f; + return r >= 65535.0f ? 0xFFFF : (uint16_t)r; +} + extern "C" { uint16_t mhfu_steer_bearing(float dx, float dz) { return bearing(dx, dz); } @@ -188,13 +197,16 @@ uint16_t mhfu_steer_curve(const volatile mhfu_steer_spec_t *s, float cursor) return curve(s, cursor, (float)(MHFU_STEER_KEYS * MHFU_STEER_KEY_STEP)); } -int mhfu_steer_move(void *entity, const mhfu_vec3_t *hunter, volatile mhfu_steer_state_t *st, - uint32_t frame, float start, float cursor, float loop_start, float end) +int mhfu_steer_move(void *entity, const mhfu_vec3_t *target, volatile mhfu_steer_state_t *st, + uint32_t frame, const mhfu_steer_clip_t *clip) { 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; + const float start = clip->start, cursor = clip->cursor; + const float loop_start = clip->loop_start, end = clip->end; + const float size = *(volatile float *)(e + MHFU_ENTITY_SIZE_SCALE); float before = st->cursor; bool loop = false; if (frame <= 1) { @@ -206,12 +218,13 @@ int mhfu_steer_move(void *entity, const mhfu_vec3_t *hunter, volatile mhfu_steer 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]; + if (sp->lunge.hi && target) { + float dx = target->x - pos[0], dz = target->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); + float near = sp->lunge.near * size, span = sp->lunge.span * size; + if (d > 0 && d >= near) { + float t = span > 0 ? (d - near) / span : 1; + float length = (sp->lunge.base + sp->lunge.range * (t < 1 ? t : 1)) * size; st->lunge_x = length * dx / d; st->lunge_z = length * dz / d; } @@ -231,12 +244,11 @@ int mhfu_steer_move(void *entity, const mhfu_vec3_t *hunter, volatile mhfu_steer } st->cursor = cursor; uint8_t mode = sp->steer.turn; - if ((mode == MHFU_STEER_HUNTER || mode == MHFU_STEER_AWAY) && hunter + if ((mode == MHFU_STEER_HUNTER || mode == MHFU_STEER_AWAY) && target && in_window(sp->steer.win_from, sp->steer.win_to, cursor)) { - uint16_t want = bearing(hunter->x - pos[0], hunter->z - pos[2]); + uint16_t want = bearing(target->x - pos[0], target->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)); + st->base = toward(st->base, want, paced(sp->steer.rate, clip->speed)); } 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; @@ -268,7 +280,6 @@ int mhfu_steer_move(void *entity, const mhfu_vec3_t *hunter, volatile mhfu_steer 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; } diff --git a/framework/tests/em_host.cpp b/framework/tests/em_host.cpp index 1cb967e6..569aea05 100644 --- a/framework/tests/em_host.cpp +++ b/framework/tests/em_host.cpp @@ -52,6 +52,7 @@ void mhfu_mem_write_u8(uint32_t a, uint8_t v) { uint8_t *p = at(a, 1); if (p void mhfu_mem_write_u16(uint32_t a, uint16_t v) { uint8_t *p = at(a, 2); if (p) memcpy(p, &v, 2); } void mhfu_mem_write_u32(uint32_t a, uint32_t v) { uint8_t *p = at(a, 4); if (p) memcpy(p, &v, 4); } void *mhfu_host_at(uint32_t a) { return at(a, 4); } +int mhfu_mem_valid(uint32_t a) { return at(a, 4) != 0; } /* here: mapped */ void mhfu_log(const char *, ...) {} uint32_t mhfu_host_usec(void) { return 0; } void mhfu_event_fire(mhfu_event_id_t, const void *) {} diff --git a/framework/tests/move_host.cpp b/framework/tests/move_host.cpp index 87c87d28..734393bb 100644 --- a/framework/tests/move_host.cpp +++ b/framework/tests/move_host.cpp @@ -49,6 +49,7 @@ uint32_t mhfu_mem_read_u32(uint32_t a) { uint32_t v = 0; uint8_t *p = at(a, 4); float mhfu_mem_read_f32(uint32_t a) { float v = 0; uint8_t *p = at(a, 4); if (p) memcpy(&v, p, 4); return v; } void mhfu_mem_write_u8(uint32_t a, uint8_t v) { uint8_t *p = at(a, 1); if (p) *p = v; } void *mhfu_host_at(uint32_t a) { return at(a, 4); } +int mhfu_mem_valid(uint32_t a) { return at(a, 4) != 0; } /* here: mapped */ void mhfu_log(const char *, ...) {} int mhfu_em_installed(void) { return 1; } static int g_cut; diff --git a/framework/tests/test_em_brain.py b/framework/tests/test_em_brain.py index 9edd1836..fb31d637 100644 --- a/framework/tests/test_em_brain.py +++ b/framework/tests/test_em_brain.py @@ -177,7 +177,7 @@ def pair(self, ms: tuple[int, int]) -> None: self.poke(a.ENTITY.MAIN_STATE, "BB", *ms) def hunter_at(self, x: float, z: float) -> None: - struct.pack_into(" None: """One AI frame: the brain and the step, then each playing clip advances.""" @@ -185,6 +185,7 @@ def frame(self) -> None: assert a.CLIP_BLOCK.size for k in range(3): b = int(a.ENTITY.CLIP_BLOCKS) + k * a.CLIP_BLOCK.size + self.poke(b + a.CLIP_BLOCK.SPEED, "f", SPEED) # MONSTER_STEP writes it every frame (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: @@ -601,6 +602,41 @@ def test_an_own_moves_hunter_turn_keeps_to_its_steer_window( assert any(d for _, d in seen) +TARGET = BASE + 0x2000 # a second entity in em_host.cpp's memory: what the monster aims at + + +def aim_at(g: Game, x: float, z: float) -> None: + """ENTITY.TARGET points at an entity standing at (x, z).""" + struct.pack_into(" None: + """The hunter is 500 away; the target, a comrade, 300: the rules' window reads the target's.""" + aim_at(g, 0.0, 300.0) + g.frame() + assert g.status().dist == pytest.approx(300.0) + g.rule(0, from_mask=1 << 1, dist_lo=250.0, dist_hi=350.0, to_main=3, to_sub=6) + assert g.go() == [(3, 6)] + g.poke(a.ENTITY.TARGET, "I", 0) # without one it is the hunter's + g.frame() + assert g.status().dist == pytest.approx(500.0) + g.rule(0, from_mask=1 << 1, dist_lo=250.0, dist_hi=350.0, to_main=3, to_sub=6) + assert g.go() == [] + + +def test_the_side_is_the_targets(g: Game) -> None: + """The hunter stands to the monster's left, the target ahead.""" + face(g, 0) + g.hunter_at(500.0, 0.0) + aim_at(g, 0.0, 500.0) + g.rule(0, from_mask=1 << 1, to_main=3, to_sub=6, conds=[cond("side", 1)]) + assert g.go() == [(3, 6)] + g.poke(a.ENTITY.TARGET, "I", 0) + g.rule(0, from_mask=1 << 1, to_main=3, to_sub=6, conds=[cond("side", 1)]) + assert g.go() == [] + + def test_keys_have_room(g: Game) -> None: cap = a.EM_MOVES.KEYS.count for slot, start in enumerate(range(0, cap, 256)): diff --git a/framework/tests/test_move.py b/framework/tests/test_move.py index 9a6beb09..4454c48e 100644 --- a/framework/tests/test_move.py +++ b/framework/tests/test_move.py @@ -83,6 +83,7 @@ def frame(self) -> int: skip = int(self.lib.host_frame(ENT)) for k in range(3): b = self.block(k) + self.poke(b + a.CLIP_BLOCK.SPEED, "f", self.speed) # MONSTER_STEP writes it every frame (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: @@ -151,6 +152,8 @@ def lib(host_lib: Callable[..., ctypes.CDLL]) -> ctypes.CDLL: lib.mhfu_steer_init_spec.argtypes = [ctypes.c_void_p] lib.mhfu_move_react.argtypes = [ctypes.c_int, ctypes.c_uint32, ctypes.c_void_p, ctypes.c_void_p] lib.host_react_enter.argtypes = [ctypes.c_uint32] * 3 + lib.mhfu_move_target_pos.restype = ctypes.c_uint32 + lib.mhfu_move_target_pos.argtypes = [ctypes.c_uint32] return lib @@ -415,15 +418,15 @@ class Spec(ctypes.Structure): Point = steering.Point -def steer( +def spec_of( 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: + """A spec: 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(): @@ -436,7 +439,18 @@ def steer( 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)) + return spec + + +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 (spec_of's arguments).""" + g.lib.mhfu_move_steer(ctypes.byref(spec_of(g, keys, samples, lunge, **kw))) def yaw(g: Game) -> int: @@ -457,7 +471,7 @@ def test_steer_spec_layout() -> None: def test_yaw_follows_the_clip_turn(g: Game) -> None: """YAW reads its first-frame value plus the curve at the cursor, a loop's turn kept.""" g.poke(a.ENTITY.YAW, "H", 0x1000) - steer(g, tuple(100 * k for k in range(int(CLIP_END) // 2 + 1))) + steer(g, keys=tuple(100 * k for k in range(int(CLIP_END) // 2 + 1))) g.play(46) g.frame() g.frame() # the dispatch @@ -749,18 +763,22 @@ def test_a_loop_carries_the_curve_on(g: Game) -> None: """window (lo, hi], near, span, base, range: MHP3rd's stamp jump.""" -def lunge_total(d: float) -> float: +def lunge_total(d: float, size: float = 1.0) -> float: + """The lunge's length at distance d: its four numbers are times the size.""" lo, hi, near, span, base, rng = LUNGE - return 0.0 if d < near else base + rng * min((d - near) / span, 1) + near, span = near * size, span * size + return 0.0 if d < near else (base + rng * min((d - near) / span, 1)) * size -@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)]) +@pytest.mark.parametrize("d", [150.0, 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), (1.1, 0x2000, 0.5)] +) 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.""" + frame in world axes, whatever YAW is; near, span, base and range times the size.""" ex, ez = 10.0, -20.0 ux, uz = math.sin(bearing), math.cos(bearing) g.poke(a.ENTITY.POSITION, "fff", ex, 0.0, ez) @@ -770,7 +788,7 @@ def test_lunge_is_the_distances_length_along_the_bearing( steer(g, lunge=LUNGE) g.play(46) seen = settle(g, 2 + int(CLIP_END / SPEED)) - total, (lo, hi, *_) = lunge_total(d), LUNGE + total, (lo, hi, *_) = lunge_total(d, size), LUNGE for cursor, (x, z) in seen: share = min(max((cursor - lo) / (hi - lo), 0.0), 1.0) assert (x, z) == pytest.approx((ex + ux * total * share, ez + uz * total * share), abs=0.02) @@ -779,6 +797,7 @@ def test_lunge_is_the_distances_length_along_the_bearing( def test_lunge_takes_the_hunter_where_the_move_began(g: Game) -> None: + g.poke(a.ENTITY.SIZE_SCALE, "f", 1.0) g.poke(a.ENTITY.POSITION, "fff", 0.0, 0.0, 0.0) struct.pack_into(" None: g.play(46) settle(g, 2 + int(CLIP_END / speed)) x, z = world(0, *CURVE[-1]) - assert at(g) == pytest.approx((x * 2.0, z * 2.0 + lunge_total(1300.0)), abs=0.05) + assert at(g) == pytest.approx((x * 2.0, z * 2.0 + lunge_total(1300.0, 2.0)), abs=0.05) FIXED_WINDOW = (10, 40) @@ -868,3 +887,117 @@ def run(junk: bool) -> bytes: return bytes(g.mem[ENT - BASE : ENT - BASE + 0x800]) assert run(False) == run(True) + + +def test_the_lunge_halves_at_half_the_size(g: Game) -> None: + """Far past its span the length is base + range, in world units times the size.""" + + def lunged(size: float) -> float: + 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", size) + struct.pack_into(" None: + """ENTITY.TARGET points at an entity standing at (x, z).""" + struct.pack_into(" None: + struct.pack_into(" None: + aim_at(g, 5.0, 6.0) + assert g.lib.mhfu_move_target_pos(ENT) == TARGET_POS + + +@pytest.mark.parametrize("pointer", [0, 0x1234, int(a.USER_RAM_END)]) +def test_the_aim_falls_back_to_the_hunter(g: Game, pointer: int) -> None: + """No target, or one that is not game memory.""" + g.poke(a.ENTITY.TARGET, "I", pointer) + assert g.lib.mhfu_move_target_pos(ENT) == HUNTER_POS + + +def test_the_turn_follows_the_target_every_frame(g: Game) -> None: + """The hunter is ahead; the target to the left, then to the right: it turns after whoever + ENTITY.TARGET holds that frame, the way the donor's turn refreshes its target.""" + hunter_at(g, 0.0, 1000.0) + steer(g, turn=1, rate=0x400) + g.play(46) + aim_at(g, 1000.0, 0.0) + for _ in range(2 + 0x4000 // 0x400): + g.frame() + assert yaw(g) == 0x4000 + aim_at(g, -1000.0, 0.0) + for _ in range(0x8000 // 0x400): + g.frame() + assert yaw(g) == 0xC000 + g.poke(a.ENTITY.TARGET, "I", 0) # the target is gone: the hunter, ahead + for _ in range(0x4000 // 0x400): + g.frame() + assert yaw(g) == 0 + + +def test_the_lunge_goes_to_the_target(g: Game) -> None: + g.poke(a.ENTITY.SIZE_SCALE, "f", 1.0) + hunter_at(g, 0.0, 3000.0) + aim_at(g, 3000.0, 0.0) + steer(g, lunge=LUNGE) + g.play(46) + settle(g, 3) + aim_at(g, 0.0, -3000.0) # read at the first frame only + settle(g, 40) + assert at(g) == pytest.approx((lunge_total(3000.0), 0.0), abs=0.02) + + +# --- the turn and its rate + + +@pytest.mark.parametrize(("speed", "rate"), [(2.0, 1000), (2.8, 1400), (1.0, 500)]) +def test_the_turn_rate_is_the_donors_at_clip_speed_1(g: Game, speed: float, rate: int) -> None: + """A move's rate is its units a frame at speed 1; the move player times it by the clip's + speed / 2 (1.0 calm, 1.4 enraged).""" + g.speed = speed + hunter_at(g, 1000.0, 0.0) # 0x4000 to the left + steer(g, turn=1, rate=1000) + g.play(46) + steps = [] + last = 0 + for _ in range(2 + 0x4000 // rate + 3): + g.frame() + steps.append(yaw(g) - last) + last = yaw(g) + turning = [d for d in steps if d] + assert turning[:-1] == [rate] * (len(turning) - 1) and 0 < turning[-1] <= rate + assert yaw(g) == 0x4000 + + +def test_the_hunter_turn_turns_base_and_the_curve_rides_on_top(g: Game) -> None: + """The entity's own YAW is what turns toward the target; the clip's facing is added to it, so + the body ends facing the target plus the clip's last key.""" + keys = tuple(0x80 * k for k in range(int(CLIP_END) // 2 + 1)) + hunter_at(g, 1000.0, 1000.0) # 0x2000 + steer(g, keys=keys, turn=1, rate=0x400) + g.play(46) + for _ in range(2 + int(CLIP_END / SPEED)): + g.frame() + assert g.steer_state("BASE", "H") == (0x2000,) + assert yaw(g) == 0x2000 + keys[-1] diff --git a/packages/mhfu-port/src/mhfu_port/manifest.py b/packages/mhfu-port/src/mhfu_port/manifest.py index d93a9c00..9ee3ec28 100644 --- a/packages/mhfu-port/src/mhfu_port/manifest.py +++ b/packages/mhfu-port/src/mhfu_port/manifest.py @@ -215,10 +215,12 @@ class Steer: """How an own move turns, and whether a wall ends it.""" turn: Turn = "clip" - """`clip` follows the clip's turn, `hunter` and `away` turn at `rate`, `fixed` turns + """`clip` follows the clip's turn, `hunter` and `away` turn toward or away from the target + (what the monster aims at: its AI target, the player without one) at `rate`, `fixed` turns `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.""" + """YAW units an AI frame at clip speed 1 for `hunter` and `away`, times the speed / 2 in play; + None: the host charge's.""" angle: float | None = None frames: int | None = None walls: bool = True @@ -231,10 +233,10 @@ class Steer: @dataclass class Lunge: - """Travel toward the hunter across clip frames `window` (lo, hi]: none while the hunter is + """Travel toward the target across clip frames `window` (lo, hi]: none while the target 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.""" + span, 1)` units along the bearing to it then, spread over the window by cursor. `near`, + `span`, `base` and `range` are scaled by the monster's size.""" window: tuple[int, int] near: float diff --git a/packages/mhfu/src/mhfu/addresses.toml b/packages/mhfu/src/mhfu/addresses.toml index b33e2484..7bef1fc2 100644 --- a/packages/mhfu/src/mhfu/addresses.toml +++ b/packages/mhfu/src/mhfu/addresses.toml @@ -894,9 +894,9 @@ EVENTS = { offset = "next", type = "ptr", doc = "the monster-event block (struct doc = "how a move turns and stops (steer.h mhfu_steer_t)" size = 0x10 [struct.STEER_PARAMS.fields] -TURN = { offset = 0x0, type = "u8", doc = "0 still, 1 toward the hunter, 2 away, 3 TOTAL over FRAMES" } +TURN = { offset = 0x0, type = "u8", doc = "0 still, 1 toward the target, 2 away from it, 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)" } +RATE = { offset = 0x2, type = "u16", doc = "toward or away: most YAW units an AI frame at clip speed 1 (TURN_TOWARD's step; the charge steers 64); the move player multiplies it by part 0's CLIP_BLOCK.SPEED / 2" } 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" } @@ -911,15 +911,15 @@ X = { offset = 0x0, type = "s16", doc = "toward the monster's +x, its left, at t 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" +doc = "a move's lunge (steer.h mhfu_steer_lunge_t): travel toward the target whose length the target's distance at the move's first playing frame sets; NEAR, SPAN, BASE and RANGE are scaled by ENTITY.SIZE_SCALE then" 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" } +NEAR = { offset = "next", type = "f32", doc = "no lunge while the target's XZ distance is under this, times the size" } +SPAN = { offset = "next", type = "f32", doc = "distance past NEAR over which the length grows to BASE + RANGE, times the size" } +BASE = { offset = "next", type = "f32", doc = "world units along the bearing to the target at NEAR, negative away from it, times the size" } +RANGE = { offset = "next", type = "f32", doc = "units added by the time the distance is NEAR + SPAN, times the size" } [struct.STEER_SPEC] doc = "a move's steering as the move player takes it (steer.h mhfu_steer_spec_t)" @@ -941,7 +941,7 @@ 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 = "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" } +BASE = { offset = "next", type = "u16", doc = "the entity's own YAW, without the clip's turn (YAW = BASE + the curve): YAW less the curve on the first playing frame, turned toward the target by the mode and by 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" } @@ -1052,11 +1052,11 @@ FROM_SUB = { offset = 0x01, type = "u8", doc = "with FROM_MASK: the exact sub, o TO_MAIN = { offset = 0x02, type = "u8", doc = "the pair entered when PLAY_MOVE is 0" } TO_SUB = { offset = 0x03, type = "u8", doc = "with TO_MAIN" } MODE = { offset = 0x04, type = "u8", doc = "ENTER_ACTION mode for TO_MAIN, TO_SUB" } -FLAGS = { offset = 0x05, type = "u8", doc = "1 only while the hunter recedes, 2 only while they close, 4 plays nothing: applies its EFFECTS and the scan goes on" } +FLAGS = { offset = 0x05, type = "u8", doc = "1 only while the target recedes, 2 only while it closes, 4 plays nothing: applies its EFFECTS and the scan goes on" } FROM_MOVE = { offset = 0x06, type = "u8", doc = "own move slot + 1 that must be playing (its AI frames the dwell); 0 none" } PLAY_MOVE = { offset = 0x07, type = "u8", doc = "own move slot + 1 played instead of entering TO_MAIN, TO_SUB; 0 none" } MIN_FRAMES = { offset = 0x08, type = "u32", doc = "AI frames the pair (or FROM_MOVE) must have stood" } -D2_LO = { offset = 0x0C, type = "u32", doc = "the hunter's XZ distance squared, f32 bits: at least this" } +D2_LO = { offset = 0x0C, type = "u32", doc = "the target's XZ distance squared (the player's without a target), f32 bits: at least this" } D2_HI = { offset = 0x10, type = "u32", doc = "...and under this" } LEFT = { offset = 0x14, type = "u32", doc = "fires left; 0 off, 0xFFFFFFFF standing" } FIRED = { offset = 0x18, type = "u32", doc = "fires so far" } diff --git a/packages/mhfu/src/mhfu/live/moves.py b/packages/mhfu/src/mhfu/live/moves.py index 6a707b89..349a9bda 100644 --- a/packages/mhfu/src/mhfu/live/moves.py +++ b/packages/mhfu/src/mhfu/live/moves.py @@ -154,8 +154,9 @@ def pack(self) -> bytes: @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`.""" + `base + range * min((d - near) / span, 1)` units along the bearing to its target, nothing + while the target's distance `d` at the move's first playing frame is under `near`; `near`, + `span`, `base` and `range` are scaled by the monster's size.""" window: tuple[int, int] near: float @@ -186,12 +187,13 @@ class Steer: """What `mhfu_steer_spec_t` holds: the clip's turn curve (`curve`, YAW every 2 clip frames: a port's turns module, `turns_of`), a turn mode on top, whether a wall ends the move, a class-2 one into `stuck`, and the move's own travel: `travel`, the monster's (x, z) every - `steering.STEP` clip frames from the move's start, and a `lunge` toward the hunter.""" + `steering.STEP` clip frames from the move's start, and a `lunge` toward the target.""" curve: tuple[int, ...] = () turn: str = "still" rate: int = 64 - """YAW units an AI frame toward or away from the hunter.""" + """YAW units an AI frame at clip speed 1 toward or away from the target (the move player + multiplies it by the clip's speed / 2).""" total: float = 0.0 """Degrees "fixed" turns over `frames`, or across `window`, the way YAW grows.""" frames: int = 1 @@ -200,7 +202,7 @@ class Steer: """Degrees of the travel against YAW: which wall sectors count as ahead.""" stuck: tuple[int, int, int] = (0, 6, 1) window: tuple[int, int] = (0, 0) - """Clip frames [from, to) the hunter and away turns apply in, a fixed turn spreads across; + """Clip frames [from, to) the target and away turns apply in, a fixed turn spreads across; 0, 0 the whole move.""" travel: tuple[steering.Point, ...] = () lunge: Lunge | None = None From 418d8bafb34a8a40ecbeaece2dbdc6feb6ebf8a2 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:04:30 +0100 Subject: [PATCH 2/6] framework: a move hands over to its AFTER in place, and the facing carries over When a step ends (clip, stop, length or wall) and the registry names a successor on the same carrier, the move player ends the step's windowed attacks and dispatches the successor's entry in that AI frame, before the host step: the hub's clip and its blend freeze are no longer played between two steps. em_vhook's mhfu_em_chain is the one place a successor is chosen, filled and tagged as play_slot does it. The successor keeps the step's base (the entity's own YAW); any other start sets base to YAW. YAW is base plus the clip's curve plus a ramp that starts where YAW is continuous and falls to 0 as part 0's BLEND counts down. Co-Authored-By: Claude Sonnet 5.5 --- framework/README.md | 4 +- framework/include/mhfu/em_vhook.h | 13 +- framework/include/mhfu/move.h | 6 + framework/include/mhfu/steer.h | 22 +-- framework/src/core/em_vhook.cpp | 47 ++++++- framework/src/core/move.cpp | 29 ++++ framework/src/core/steer.cpp | 10 +- framework/tests/move_host.cpp | 21 ++- framework/tests/test_em_brain.py | 45 +++++- framework/tests/test_move.py | 193 +++++++++++++++++++++++++- packages/mhfu/src/mhfu/addresses.toml | 7 +- packages/mhfu/src/mhfu/live/moves.py | 5 +- 12 files changed, 368 insertions(+), 34 deletions(-) diff --git a/framework/README.md b/framework/README.md index 766d629c..1ff07555 100644 --- a/framework/README.md +++ b/framework/README.md @@ -170,7 +170,9 @@ min((d - near) / span, 1)` units along the bearing to the target then, in world `span`, `base` and `range` times the monster's size. The target is what `ENTITY.TARGET` points at, the player when it is 0 or not RAM: the turn reads it every frame, the lunge at the move's first, the brain's distance and side gates too. A turn's `rate` is YAW units a frame at clip speed 1 (times -the clip's speed / 2 in play) and turns the entity's own YAW, which the clip's turn rides on. A fixed turn with a `window` turns `total` across it by the cursor instead of over +the clip's speed / 2 in play) and turns the entity's own YAW, which the clip's turn rides on. An +`after` on the same carrier plays in the AI frame its move ends, the carrier not entered again; it +keeps the entity's own YAW, and the jump in the clips' facing fades over its blend. 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. diff --git a/framework/include/mhfu/em_vhook.h b/framework/include/mhfu/em_vhook.h index 58953bda..ece96d31 100644 --- a/framework/include/mhfu/em_vhook.h +++ b/framework/include/mhfu/em_vhook.h @@ -171,9 +171,10 @@ int mhfu_em_tip(const uint8_t (*pairs)[2], int n); * 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, 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. + * else a rule's. An AFTER on the same carrier plays in the AI frame its move ends, before the host + * step, the carrier not entered again (mhfu_em_chain). Otherwise 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. * * An AI script plays a slot too: `enter MHFU_OWN_MOVE_MAIN k 0`. The slot-32 stub enters the slot's * carrier (em75's hub (0,2) for an empty or out-of-range slot) in place of the main the game @@ -226,6 +227,12 @@ int mhfu_em_move(int slot, const mhfu_move_t *mv, const mhfu_steer_spec_t *s, u int mhfu_em_play(uint32_t entity, int slot, int force); /* The slot of the move the move player runs (entering or playing), if it came from here; -1. */ int mhfu_em_playing(void); +/* The move player's step ended and asks for the successor in place: the playing slot's AFTER into + * *mv and *steer (the registry's scratch), taken and counted as a play, when it rides the same + * carrier and the move has no back pair of its own; else 0, and the old way follows (the carrier + * is entered, and the brain plays the AFTER once the move ends into it). The one place a + * successor is chosen. */ +int mhfu_em_chain(uint32_t entity, mhfu_move_t *mv, const mhfu_steer_spec_t **steer); /* The registry, or 0 before the framework's init. */ const volatile mhfu_em_moves_t *mhfu_em_moves(void); diff --git a/framework/include/mhfu/move.h b/framework/include/mhfu/move.h index f1ce6932..1a8f889b 100644 --- a/framework/include/mhfu/move.h +++ b/framework/include/mhfu/move.h @@ -27,6 +27,12 @@ * clears the wake for a move on the hub or during the notice, and a move with HOLD_SCRIPT clears * it where it enters its back pair, so the script resumes only when the carrier hands back. * + * A move that ends with a successor on its carrier (em_vhook's AFTER, mhfu_em_chain) hands over in + * place: the end's attacks are ended and the successor's entry goes to the executor in the same AI + * frame, before the host step, so the carrier's handler (the hub's clip) never runs between the two. + * The steering carries over (steer.h): the successor keeps the entity's own YAW, and the turn the + * clips' facings make is blended out over the successor's blend. + * * A move asked for while the monster's notice runs (the player's ENTITY.AWARE bit set, * COMBAT_MODE still 0: the roar, the hub, the AI script's combat entry) waits for it, up to * MHFU_MOVE_WAIT AI frames, unless the call FORCEs it. One asked for while the tail cut waits for diff --git a/framework/include/mhfu/steer.h b/framework/include/mhfu/steer.h index 66333a96..1b502bc3 100644 --- a/framework/include/mhfu/steer.h +++ b/framework/include/mhfu/steer.h @@ -101,11 +101,14 @@ typedef struct { typedef struct { mhfu_steer_spec_t next; /* taken on the move's first playing frame */ mhfu_steer_spec_t now; - uint16_t base; /* the entity's own YAW: the curve adds to it */ + uint16_t base; /* the entity's own YAW: the curve and the ramp add to it */ uint8_t wall; /* the last frame's MHFU_STEER_* */ 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 */ + uint8_t keep; /* the step started in place: its first frame keeps `base` */ + uint16_t blend; /* part 0's CLIP_BLOCK.BLEND right after the dispatch */ + int16_t ramp; /* the YAW jump at the switch, falling to 0 as the blend counts down */ } mhfu_steer_state_t; /* Part 0's clip block as the move player reads it each AI frame. */ @@ -115,6 +118,7 @@ typedef struct { float loop_start; float end; float speed; /* CLIP_BLOCK.SPEED: clip frames an AI frame, 2.0 calm */ + int32_t blend; /* CLIP_BLOCK.BLEND now */ } mhfu_steer_clip_t; /* No turn, no walls; a class-2 wall ends into (0,6) mode 1, as the Tigrex charge does. */ @@ -125,14 +129,16 @@ 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 toward `target`, at the rate times + * NEXT; a cursor that went back (a loop) carries the curve's turn over; YAW = base + curve + ramp, + * the base turned by the spec's mode (HUNTER and AWAY toward `target`, at the rate times * clip->speed / 2, only while the cursor, the clip frame, is in 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: clip->start) and this one: the curve - * turned by YAW on frame 1, the lunge along the bearing to `target` on frame 1, both scaled by - * ENTITY.SIZE_SCALE. Returns the wall result of the frame before, from frame 2, and then moves - * nothing. */ + * across it by the cursor). Frame 1 keeps `base` when the step started in place (st->keep), else + * sets it to YAW, and the ramp starts at the jump that keeps YAW continuous, falling to 0 as + * clip->blend counts down from st->blend. Then POSITION moves by the change of the travel curve + * and of the lunge between the cursor of the frame before (frame 1: clip->start) and this one: + * the curve turned by YAW on frame 1, the lunge along the bearing to `target` on frame 1, both + * scaled by ENTITY.SIZE_SCALE. 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 *target, volatile mhfu_steer_state_t *st, uint32_t frame, const mhfu_steer_clip_t *clip); diff --git a/framework/src/core/em_vhook.cpp b/framework/src/core/em_vhook.cpp index 03225307..d8059e71 100644 --- a/framework/src/core/em_vhook.cpp +++ b/framework/src/core/em_vhook.cpp @@ -525,31 +525,64 @@ static void tag(uint32_t ent, uint32_t slot, const volatile mhfu_move_state_t *m chg_own(ent, slot); } -static int play_slot(uint32_t ent, uint32_t slot, const volatile mhfu_move_state_t *m, int force) +/* slot's move into mv and its steering (in the registry's scratch) into *steer, forced or not: how + * a slot goes to the move player, asked (play_slot) or told (mhfu_em_chain). 0 for an empty slot. */ +static int fill(uint32_t slot, int force, mhfu_move_t *mv, const mhfu_steer_spec_t **steer) { if (slot >= MHFU_EM_MOVES || !R->moves[slot].valid) return 0; const volatile mhfu_em_own_t *o = &R->moves[slot]; + copy_words(mv, &o->move, sizeof(*mv)); + mv->force = (uint8_t)(force != 0); + *steer = (const mhfu_steer_spec_t *)steer_of(o); + return 1; +} + +static int play_slot(uint32_t ent, uint32_t slot, const volatile mhfu_move_state_t *m, int force) +{ mhfu_move_t mv; - copy_words(&mv, &o->move, sizeof(mv)); - mv.force = (uint8_t)(force != 0); - mhfu_move_steer((const mhfu_steer_spec_t *)steer_of(o)); + const mhfu_steer_spec_t *sp; + if (!fill(slot, force, &mv, &sp)) return 0; + mhfu_move_steer(sp); if (!mhfu_move_play(ent, &mv)) return 0; tag(ent, slot, m); return 1; } -/* The move that just ended on its clip's end into its carrier or on a wall names an AFTER, which +/* The slot that plays when slot k's move ends: its AFTER, MHFU_EM_NO_MOVE for none. */ +static uint32_t successor(int k) { return R->moves[k].after; } + +/* The move that just ended on its clip's end into its carrier or on a wall names a successor, which * plays this frame: that move's slot, else -1. */ static int chain_due(uint32_t ent, const volatile mhfu_move_state_t *m) { const int k = ours(m); if (k < 0 || m->state != MHFU_MOVE_DONE || m->entity != ent || !(m->end == MHFU_MOVE_END_BACK || m->end == MHFU_MOVE_END_WALL) - || R->moves[k].after == MHFU_EM_NO_MOVE) + || successor(k) == MHFU_EM_NO_MOVE) return -1; return k; } +/* The move player's step ended and asks for the successor in place, on its carrier: the playing + * slot's, taken and tagged as a play, or 0 (the old way: the carrier, then chain_due). A successor + * on another carrier, or a move that enters a back pair of its own, is left to that way. */ +extern "C" int mhfu_em_chain(uint32_t ent, mhfu_move_t *mv, const mhfu_steer_spec_t **steer) +{ + const volatile mhfu_move_state_t *m = mhfu_move_state(); + const int k = R && m ? ours(m) : -1; + if (k < 0 || m->state != MHFU_MOVE_PLAYING || m->entity != ent) return 0; + const uint32_t slot = successor(k); + if (slot >= MHFU_EM_MOVES || !R->moves[slot].valid) return 0; + const volatile mhfu_move_t *to = &R->moves[slot].move, *was = &m->move; + if (to->carrier_main != was->carrier_main || to->carrier_sub != was->carrier_sub + || was->back_main != was->carrier_main || was->back_sub != was->carrier_sub) + return 0; + if (!fill(slot, was->force, mv, steer)) return 0; + tag(ent, slot, m); + R->chained++; + return 1; +} + /* rule r's pair or own move, dwell, distance and cooldown hold on ent this frame */ static int gates(const cfg_rule_t *r, uint32_t ent, const volatile mhfu_move_state_t *m) { @@ -1109,7 +1142,7 @@ static int act(uint32_t ent, const volatile mhfu_move_state_t *m, const seen_t * } else if (react_take(ent, m, s)) { took = 1; } else if (due >= 0) { - if (play_slot(ent, R->moves[due].after, m, m->move.force)) R->chained++; + if (play_slot(ent, successor(due), m, m->move.force)) R->chained++; else R->tag_slot = MHFU_EM_NO_MOVE; /* an empty AFTER: not again */ } else { plays = 1; diff --git a/framework/src/core/move.cpp b/framework/src/core/move.cpp index bf254778..7f5c38a4 100644 --- a/framework/src/core/move.cpp +++ b/framework/src/core/move.cpp @@ -9,6 +9,8 @@ * frame 2..: attacks at their frames, the end checks, SKIP's choice * end: windowed attacks still live are ended; the step comes out once nothing is * pending + * chain: an end with a successor on the same carrier (mhfu_em_chain) ends the attacks and + * dispatches the successor's entry in that AI frame, the carrier not entered * * The step is in from the wrapped species' spawn: each AI frame it takes a reaction em_vhook * replaced (mhfu_em_react) by starting the registered move on the carrier the replacement @@ -114,6 +116,9 @@ static_assert(offsetof(mhfu_steer_state_t, cursor) == MHFU_STEER_STATE_CURSOR, " 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"); +static_assert(offsetof(mhfu_steer_state_t, keep) == MHFU_STEER_STATE_KEEP, "STEER_STATE layout"); +static_assert(offsetof(mhfu_steer_state_t, blend) == MHFU_STEER_STATE_BLEND, "STEER_STATE layout"); +static_assert(offsetof(mhfu_steer_state_t, ramp) == MHFU_STEER_STATE_RAMP, "STEER_STATE layout"); /* --- the engine ------------------------------------------------------------------------- */ @@ -197,6 +202,7 @@ static int steer(uint32_t ent) clip.loop_start = mhfu_mem_read_f32(b + MHFU_CLIP_BLOCK_LOOP_START); clip.end = mhfu_mem_read_f32(b + MHFU_CLIP_BLOCK_END); clip.speed = mhfu_mem_read_f32(b + MHFU_CLIP_BLOCK_SPEED); + clip.blend = (int32_t)mhfu_mem_read_u32(b + MHFU_CLIP_BLOCK_BLEND); return mhfu_steer_move(e, target, &S->steer, S->frames, &clip); } @@ -262,6 +268,7 @@ static int begin(uint32_t ent) S->frames = S->skipped = S->end_frame = 0; S->end = S->skipping = 0; S->end_pair = 0; + S->steer.keep = 0; for (int k = 0; k < MHFU_ENTITY_CLIP_BLOCKS_COUNT; k++) { S->node[k] = 0; S->clip_end[k] = S->peak[k] = -1.0f; @@ -325,6 +332,8 @@ static void dispatch(uint32_t ent) 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); } + int32_t blend = (int32_t)mhfu_mem_read_u32(clip_block(ent, 0) + MHFU_CLIP_BLOCK_BLEND); + S->steer.blend = (uint16_t)(blend < 0 ? 0 : blend > 0xFFFF ? 0xFFFF : blend); S->state = MHFU_MOVE_PLAYING; } @@ -351,6 +360,25 @@ static void attacks(uint32_t ent, float prev, float cursor) } } +/* The step ended and em_vhook names a successor on the same carrier: it takes this AI frame, before + * the host step, so the carrier's handler never sees the clip end (the hub would dispatch its own + * clip) and is not entered again. The pair stays; the step's base is kept (steer.h). */ +static int chain(uint32_t ent) +{ + mhfu_move_t mv; + const mhfu_steer_spec_t *sp; + if (!mhfu_em_chain(ent, &mv, &sp)) return 0; + end_attacks(ent); + mhfu_em_mute_events(0); + copy_words(&S->move, &mv, sizeof(mv)); + copy_words(&S->steer.next, sp, sizeof(*sp)); + if (begin(ent)) { + S->steer.keep = 1; + dispatch(ent); + } + return 1; +} + /* 1 when the host step skips this frame */ static uint32_t play(uint32_t ent) { @@ -387,6 +415,7 @@ static uint32_t play(uint32_t ent) int timed = (S->move.length && S->frames >= S->move.length) || (S->move.stop && cursor >= (float)S->move.stop); if (done || timed) { + if (chain(ent)) return 0; if (S->move.back_main != MHFU_MOVE_NO_PAIR || timed) { int back = S->move.back_main != MHFU_MOVE_NO_PAIR; enter(ent, back ? S->move.back_main : S->move.carrier_main, diff --git a/framework/src/core/steer.cpp b/framework/src/core/steer.cpp index c70c28e5..dc5cad99 100644 --- a/framework/src/core/steer.cpp +++ b/framework/src/core/steer.cpp @@ -214,7 +214,9 @@ int mhfu_steer_move(void *entity, const mhfu_vec3_t *target, volatile mhfu_steer volatile uint32_t *from = (volatile uint32_t *)&st->next; for (unsigned k = 0; k < sizeof(mhfu_steer_spec_t) / 4; k++) to[k] = from[k]; st->yaw0 = *yaw; - st->base = (uint16_t)(*yaw - curve(sp, cursor, end)); + if (!st->keep) st->base = *yaw; + st->keep = 0; + st->ramp = (int16_t)(uint16_t)(*yaw - st->base - curve(sp, start, end)); st->wall = MHFU_STEER_GO; before = start; st->lunge_x = st->lunge_z = 0; @@ -264,7 +266,11 @@ int mhfu_steer_move(void *entity, const mhfu_vec3_t *target, volatile mhfu_steer 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)); + int32_t jump = 0; + if (st->blend && clip->blend > 0) + jump = round_to((float)st->ramp * (float)(clip->blend < st->blend ? clip->blend : st->blend) + / (float)st->blend); + *yaw = (uint16_t)(st->base + curve(sp, cursor, end) + jump); st->yaw = *yaw; float mx = 0, mz = 0; diff --git a/framework/tests/move_host.cpp b/framework/tests/move_host.cpp index 734393bb..616a1360 100644 --- a/framework/tests/move_host.cpp +++ b/framework/tests/move_host.cpp @@ -56,6 +56,25 @@ static int g_cut; int mhfu_em_cut_waits(uint32_t) { return g_cut; } void host_cut(int waits) { g_cut = waits; } void mhfu_em_step(mhfu_em_step_fn fn) { g_step = fn; } + +/* em_vhook's successor choke point: the test hands the move player `g_chains` successors */ +static mhfu_move_t g_chain_move; +static mhfu_steer_spec_t g_chain_spec; +static int g_chains; +int mhfu_em_chain(uint32_t, mhfu_move_t *mv, const mhfu_steer_spec_t **steer) +{ + if (g_chains <= 0) return 0; + g_chains--; + *mv = g_chain_move; + *steer = &g_chain_spec; + return 1; +} +void host_chain(const mhfu_move_t *mv, const mhfu_steer_spec_t *spec, int times) +{ + g_chain_move = *mv; + g_chain_spec = *spec; + g_chains = times; +} void mhfu_em_mute_events(uint32_t entity) { record('M', entity, 0, 0, 0); } /* em_vhook's reaction entry, as its slot-32 stub applies it (tests/test_em_vhook_stubs.py runs @@ -159,7 +178,7 @@ 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; + g_blend = 0; g_chains = 0; memset(g_react, 0, sizeof(g_react)); } void host_blend(int frames) { g_blend = frames; } diff --git a/framework/tests/test_em_brain.py b/framework/tests/test_em_brain.py index fb31d637..6457098c 100644 --- a/framework/tests/test_em_brain.py +++ b/framework/tests/test_em_brain.py @@ -475,16 +475,39 @@ def test_a_slots_start_and_stop_reach_the_move(g: Game) -> None: 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.""" +def played(g: Game, frames: int) -> list[list[tuple[str, int, int, int, int]]]: + """The calls of each of `frames` AI frames.""" + out = [] + for _ in range(frames): + g.frame() + out.append(g.calls()) + return out + + +def test_after_on_the_same_carrier_plays_in_the_frame_the_move_ends(g: Game) -> None: + """The carrier is not entered again: the next entry goes to the executor in the AI frame the + clip ends, before the host step.""" g.own(0, 46, after=1) g.own(1, 53) g.lib.mhfu_em_play(ENT, 0, 0) + frames = played(g, 2 + int(CLIP_END / SPEED)) + flat = [c for f in frames for c in f] + assert [(c[2], c[3]) for c in flat if c[0] == "E"] == [(0, 2)] + assert [c[2] for c in flat if c[0] == "X"] == [46, 53] and ("X", ENT, 53, 0, 0) in frames[-1] + assert (g.moves("CHAINED"), g.moves("PLAYS"), g.lib.mhfu_em_playing()) == (1, 2, 1) + assert g.move("STATE", "B") == PLAYING and g.move("FRAMES") == 0 + + +def test_after_on_another_carrier_goes_through_the_carrier(g: Game) -> None: + """A successor on another carrier takes the old way: the first ends into its carrier, and the + next is entered on its own, a frame on.""" + g.own(0, 46, after=1) + g.own(1, 53, carrier_sub=3) + g.lib.mhfu_em_play(ENT, 0, 0) g.frames(2 + int(CLIP_END / SPEED)) assert g.move("END", "B") == BACK and g.entered() == [(0, 2), (0, 2)] - g.frame() - g.frame() - assert ("X", ENT, 53, 0, 0) in g.calls() + flat = [c for f in played(g, 2) for c in f] + assert [(c[2], c[3]) for c in flat if c[0] == "E"] == [(0, 3)] and ("X", ENT, 53, 0, 0) in flat assert (g.moves("CHAINED"), g.lib.mhfu_em_playing()) == (1, 1) @@ -506,6 +529,18 @@ def test_a_back_pair_given_is_kept(g: Game) -> None: assert g.entered()[-1] == (0, 3) +def test_an_after_chain_runs_through_in_place(g: Game) -> None: + """Three moves, each handing over in place: the entries in order, the carrier entered once.""" + g.own(0, 46, after=1) + g.own(1, 53, after=2) + g.own(2, 59) + g.lib.mhfu_em_play(ENT, 0, 0) + flat = [c for f in played(g, 3 * (int(CLIP_END / SPEED) + 1) + 2) for c in f] + assert [c[2] for c in flat if c[0] == "X"] == [46, 53, 59] + assert [(c[2], c[3]) for c in flat if c[0] == "E"] == [(0, 2)] + assert g.moves("CHAINED") == 2 + + def test_rule_from_a_pair_plays_an_own_move(g: Game) -> None: g.own(2, 46) g.rule(0, from_mask=1 << 1, min_frames=3, dist_hi=600.0, play_move=3) diff --git a/framework/tests/test_move.py b/framework/tests/test_move.py index 4454c48e..ca4c6ec6 100644 --- a/framework/tests/test_move.py +++ b/framework/tests/test_move.py @@ -152,6 +152,7 @@ def lib(host_lib: Callable[..., ctypes.CDLL]) -> ctypes.CDLL: lib.mhfu_steer_init_spec.argtypes = [ctypes.c_void_p] lib.mhfu_move_react.argtypes = [ctypes.c_int, ctypes.c_uint32, ctypes.c_void_p, ctypes.c_void_p] lib.host_react_enter.argtypes = [ctypes.c_uint32] * 3 + lib.host_chain.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int] lib.mhfu_move_target_pos.restype = ctypes.c_uint32 lib.mhfu_move_target_pos.argtypes = [ctypes.c_uint32] return lib @@ -469,17 +470,18 @@ def test_steer_spec_layout() -> None: def test_yaw_follows_the_clip_turn(g: Game) -> None: - """YAW reads its first-frame value plus the curve at the cursor, a loop's turn kept.""" + """A move that starts on a host clip sets base to YAW; YAW is base plus the curve at the + cursor (no blend: no ramp), a loop's turn kept.""" g.poke(a.ENTITY.YAW, "H", 0x1000) steer(g, keys=tuple(100 * k for k in range(int(CLIP_END) // 2 + 1))) g.play(46) g.frame() g.frame() # the dispatch - g.frame() # first playing frame: the cursor reads 2, YAW stays - assert yaw(g) == 0x1000 + g.frame() # first playing frame: the cursor reads 2 + assert yaw(g) == 0x1000 + 100 and g.steer_state("BASE", "H") == (0x1000,) for _ in range(10): g.frame() - assert yaw(g) == 0x1000 + 10 * 100 + assert yaw(g) == 0x1000 + 11 * 100 st = int(g.lib.mhfu_move_state()) + a.MOVE_STATE.STEER assert struct.unpack_from(" None: g.frame() assert g.steer_state("BASE", "H") == (0x2000,) assert yaw(g) == 0x2000 + keys[-1] + + +# --- a chain in place + + +def chained(g: Game, entry: int = 53, times: int = 1, **kw: int) -> None: + """The next `times` ends are handed over in place to a move on `entry` (em_vhook's choke + point), the move player's other arguments steer's.""" + mv = Move(entry=entry, carrier_main=0, carrier_sub=1, back_main=NO_PAIR) + for k, v in kw.items(): + if k in ("keys", "samples", "lunge"): + continue + setattr(mv, k, v) + spec = spec_of(g, kw.get("keys", ()), (), None) # type: ignore[arg-type] + g.lib.host_chain(ctypes.byref(mv), ctypes.byref(spec), times) + + +def run(g: Game, frames: int) -> list[list[tuple[str, int, int, int, int]]]: + """The calls of each of `frames` AI frames.""" + out = [] + for _ in range(frames): + g.frame() + out.append(g.calls()) + return out + + +ENDS = [{"length": 10}, {"stop": 30}, {}] +"""How the first step ends: its AI frames, a clip frame, its clip.""" + + +@pytest.mark.parametrize("end", ENDS) +def test_a_chain_dispatches_the_successor_in_the_frame_the_first_ends( + g: Game, end: dict[str, int] +) -> None: + """No carrier is entered between the two: the executor goes to the second entry in the frame + the first step ends, and the second's frames count from there.""" + g.play(46, **end) + chained(g, 53) + frames = run(g, 2 + int(CLIP_END / SPEED) + 1) + flat = [c for f in frames for c in f] + assert [c for c in flat if c[0] == "E"] == [("E", ENT, 0, 1, 0)] + assert [c[2] for c in flat if c[0] == "X"] == [46, 53] + when = next(k for k, f in enumerate(frames) if ("X", ENT, 53, 0, 0) in f) + assert [c[:2] for c in frames[when] if c[0] == "M"] == [("M", 0), ("M", ENT)] + assert g.state("STARTED") == (2,) and g.state("END", "B") == (0,) + assert g.state("STATE", "B") == (2,) + + +def test_a_chain_restarts_the_frame_count_and_the_attacks(g: Game) -> None: + g.play(46, length=6, attacks=[(2, 6, 4)]) # type: ignore[arg-type] + chained(g, 53) + run(g, 2 + 6) + assert g.state("FRAMES") == (0,) and g.state("SPAWN_FRAME", "I") == (NEVER,) + run(g, 1) + assert g.state("FRAMES") == (1,) + + +def test_a_chain_ends_the_windowed_attacks_of_the_step_before(g: Game) -> None: + g.play(46, length=8, attacks=[(4, 6, 200)]) # type: ignore[arg-type] + chained(g, 53) + flat = [c for f in run(g, 2 + 8) for c in f] + assert [c[0] for c in flat].count("S") == 1 and [c[0] for c in flat].count("K") == 1 + + +def test_a_class_2_wall_and_a_reaction_do_not_chain(g: Game) -> None: + steer(g, walls=1) + g.play(46) + chained(g, 53, times=2) + run(g, 6) + g.poke(a.ENTITY.WALL_SECTORS, "I", 0x00000001) + g.poke(a.ENTITY.STUCK_WALL, "B", 1) + flat = [c for f in run(g, 2) for c in f] + assert g.state("END", "B") == (STUCK,) and ("X", ENT, 53, 0, 0) not in flat + g.poke(a.ENTITY.WALL_SECTORS, "I", 0) + g.pair = (1, 3) + g.play(46) + run(g, 6) + g.pair = (4, 1) + flat = [c for f in run(g, 2) for c in f] + assert g.state("END", "B") == (PAIR,) and ("X", ENT, 53, 0, 0) not in flat + + +def test_a_wall_ends_into_the_successor_in_place(g: Game) -> None: + steer(g, walls=1) + g.play(46) + chained(g, 53) + run(g, 6) + g.poke(a.ENTITY.WALL_SECTORS, "I", 0x40000000) + flat = [c for f in run(g, 2) for c in f] + assert ("X", ENT, 53, 0, 0) in flat and g.state("STARTED") == (2,) + + +K1 = tuple(100 * k for k in range(int(CLIP_END) // 2 + 1)) +K2 = tuple(0x2000 + 30 * k for k in range(int(CLIP_END) // 2 + 1)) +"""The two steps' turn curves: the second starts 0x2000 off where the first left YAW.""" + + +def signed(v: int) -> int: + """A YAW difference as a signed 16-bit.""" + v &= 0xFFFF + return v - 0x10000 if v >= 0x8000 else v + + +def curve_at(keys: tuple[int, ...], cursor: float) -> int: + """The keys every STEER_KEY_STEP clip frames, joined by straight lines.""" + i = min(int(cursor / 2), len(keys) - 1) + if i >= len(keys) - 1: + return keys[-1] + return keys[i] + round((keys[i + 1] - keys[i]) * (cursor / 2 - i)) + + +def two_steps(g: Game, blend: int) -> tuple[int, int, int, int]: + """Step one (turn curve K1, to clip frame 40) hands over to step two (curve K2) in place; + runs to the second's first playing frame: (YAW the first step left, base, the ramp's start, + YAW on that frame).""" + g.lib.host_blend(blend) + g.poke(a.ENTITY.YAW, "H", 0x1000) + steer(g, keys=K1) + g.play(46, stop=40) + chained(g, 53, keys=K2) + while g.state("STARTED")[0] != 2: + g.frame() + before = yaw(g) # what the first step left + (base,) = g.steer_state("BASE", "H") + g.frame() # the second step's first playing frame + (ramp,) = g.steer_state("RAMP", "h") + return before, int(base), int(ramp), yaw(g) + + +def test_a_chained_step_keeps_the_base_and_ramps_the_jump_out(g: Game) -> None: + """YAW = base + curve + ramp; the ramp starts where YAW is continuous and falls linearly to 0 + as part 0's BLEND counts down, the blend read after the dispatch.""" + blend = 4 + before, base, ramp, first = two_steps(g, blend) + assert base == 0x1000 # kept: the first step's base, not YAW + assert g.state("STARTED") == (2,) and g.steer_state("KEEP", "B") == (0,) # taken + assert before == (0x1000 + curve_at(K1, 40.0)) & 0xFFFF + (n,) = g.steer_state("BLEND", "H") + assert n == blend + 1 + assert (base + curve_at(K2, 0) + ramp) & 0xFFFF == before # continuous at the switch + assert abs(signed(first - before)) <= abs(ramp) // n + 1 # a step of the ramp, no jump + jump = ramp + seen = [] + for _ in range(blend + 4): + (held,) = g.peek(g.block(0) + a.CLIP_BLOCK.BLEND, "i") + g.frame() + (cursor,) = g.steer_state("CURSOR") + want = base + curve_at(K2, cursor) + round(jump * min(held, n) / n if held > 0 else 0) + assert yaw(g) == want & 0xFFFF + seen.append((int(held), yaw(g))) + assert any(h > 0 for h, _ in seen) and seen[-1][0] == 0 # the ramp ran out with the blend + + +def test_a_chained_step_without_a_blend_has_no_ramp(g: Game) -> None: + before, base, ramp, first = two_steps(g, 0) + (cursor,) = g.steer_state("CURSOR") + assert g.steer_state("BLEND", "H") == (0,) + assert first == (base + curve_at(K2, cursor)) & 0xFFFF + + +def test_a_step_that_starts_on_a_host_clip_takes_the_entitys_yaw_as_base(g: Game) -> None: + g.lib.host_blend(3) + g.poke(a.ENTITY.YAW, "H", 0x1234) + steer(g, keys=K2) + g.play(46) + run(g, 3) + assert g.steer_state("BASE", "H") == (0x1234,) + (ramp,) = g.steer_state("RAMP", "h") + assert ramp == signed(-K2[0]) # YAW continuous at the switch + assert yaw(g) != 0x1234 + K2[0] # the jump is still being ramped out + + +def test_the_hunter_turn_carries_over_a_chain_through_base(g: Game) -> None: + """The base the first step turned is the one the second starts from.""" + hunter_at(g, 1000.0, 1000.0) + steer(g, turn=1, rate=0x200) + g.play(46, stop=24) + chained(g, 53, keys=K2) + while g.state("STARTED")[0] != 2: + g.frame() + (base,) = g.steer_state("BASE", "H") + g.frame() + assert 0 < base < 0x2000 and g.steer_state("BASE", "H") == (base,) diff --git a/packages/mhfu/src/mhfu/addresses.toml b/packages/mhfu/src/mhfu/addresses.toml index 7bef1fc2..ad543fdb 100644 --- a/packages/mhfu/src/mhfu/addresses.toml +++ b/packages/mhfu/src/mhfu/addresses.toml @@ -941,13 +941,16 @@ 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 = "next", type = "STEER_SPEC", doc = "the steering of the move running" } -BASE = { offset = "next", type = "u16", doc = "the entity's own YAW, without the clip's turn (YAW = BASE + the curve): YAW less the curve on the first playing frame, turned toward the target by the mode and by each loop" } +BASE = { offset = "next", type = "u16", doc = "the entity's own YAW, without the clip's turn (YAW = BASE + the curve + the ramp): YAW on a chain head, kept by a step started in place, turned toward the target by the mode and by 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 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" } +KEEP = { offset = "next", type = "u8", doc = "1: the step started in place (a chain), so its first playing frame keeps BASE; 0: it sets BASE to YAW" } +BLEND = { offset = "next", type = "u16", doc = "part 0's CLIP_BLOCK.BLEND right after the dispatch: the frames the ramp falls over; 0: none" } +RAMP = { offset = "next", type = "s16", doc = "YAW units the step's first playing frame adds on top of BASE and the curve so that YAW stays continuous (YAW before - BASE - the curve at START); it falls to 0 linearly as the part 0 BLEND counts down" } [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" @@ -962,7 +965,7 @@ SAMPLE_COUNT = { offset = "next", type = "u16", doc = "its travel samples; 0: it 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" } +AFTER = { offset = "next", type = "u8", doc = "slot played when this move ends on its clip, its length, its stop or a wall; 0xFF none. On the same carrier it plays in that AI frame, the carrier not entered again" } 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" } diff --git a/packages/mhfu/src/mhfu/live/moves.py b/packages/mhfu/src/mhfu/live/moves.py index 349a9bda..57cf59b6 100644 --- a/packages/mhfu/src/mhfu/live/moves.py +++ b/packages/mhfu/src/mhfu/live/moves.py @@ -25,7 +25,7 @@ from .. import addresses as a from .. import steering from ..entries import entry_clip -from ..views import View, f32, f32s, ptr, ptrs, u8, u8s, u16, u32, u32s +from ..views import View, f32, f32s, ptr, ptrs, s16, u8, u8s, u16, u32, u32s from .clips import Pack, bridge, monster from .session import Session from .shell_anim import STALLED, Bridge, Op @@ -287,6 +287,9 @@ class SteerState(View): yaw = u16(a.STEER_STATE.YAW) lunge_x = f32(a.STEER_STATE.LUNGE_X) lunge_z = f32(a.STEER_STATE.LUNGE_Z) + keep = u8(a.STEER_STATE.KEEP) + blend = u16(a.STEER_STATE.BLEND) + ramp = s16(a.STEER_STATE.RAMP) class MoveState(View): From 0eda33fdb94d2a405f51f259c1b26d292e2b2c74 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:40:26 +0100 Subject: [PATCH 3/6] framework: travel is in the entity's own yaw; the rules' docs say target The travel curve of a chained step is turned by base on its first frame, kept or taken from YAW, not by the drawn YAW (base plus the last clip's facing key and the ramp), as the donor's entity-frame motion is in the start yaw. The distance, receding and side conditions' docs and the own-move listing say target. Co-Authored-By: Claude Sonnet 5.5 --- framework/README.md | 4 +++- framework/include/mhfu/steer.h | 7 ++++--- framework/src/core/steer.cpp | 2 +- framework/tests/test_move.py | 21 ++++++++++++++++++- packages/mhfu-port/src/mhfu_port/behaviour.py | 15 ++++++------- packages/mhfu-port/src/mhfu_port/cli/own.py | 2 +- packages/mhfu/src/mhfu/addresses.toml | 2 +- packages/mhfu/src/mhfu/live/moves.py | 2 +- 8 files changed, 39 insertions(+), 16 deletions(-) diff --git a/framework/README.md b/framework/README.md index 1ff07555..7683c6c7 100644 --- a/framework/README.md +++ b/framework/README.md @@ -161,7 +161,9 @@ move's `start`: cumulative, x toward its left and z forward in its frame when th 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. +and the blend's hold are right) to POSITION, turned by the entity's own YAW when the move began +(the base the clips' facing rides on, as the donor's travel is in the start 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 target across the clip frames (lo, hi]: by diff --git a/framework/include/mhfu/steer.h b/framework/include/mhfu/steer.h index 1b502bc3..2c59dea1 100644 --- a/framework/include/mhfu/steer.h +++ b/framework/include/mhfu/steer.h @@ -104,7 +104,8 @@ typedef struct { uint16_t base; /* the entity's own YAW: the curve and the ramp add to it */ uint8_t wall; /* the last frame's MHFU_STEER_* */ float cursor; /* the cursor the step saw last */ - uint16_t yaw0, yaw; /* YAW on the first playing frame, and as the step left it */ + uint16_t yaw0, yaw; /* BASE on the first playing frame (travel's frame), and YAW as the step + * left it */ float lunge_x, lunge_z; /* the lunge's whole length, world axes, set on the first frame */ uint8_t keep; /* the step started in place: its first frame keeps `base` */ uint16_t blend; /* part 0's CLIP_BLOCK.BLEND right after the dispatch */ @@ -136,8 +137,8 @@ void mhfu_steer_sincos(uint16_t yaw, float *s, float *c); * sets it to YAW, and the ramp starts at the jump that keeps YAW continuous, falling to 0 as * clip->blend counts down from st->blend. Then POSITION moves by the change of the travel curve * and of the lunge between the cursor of the frame before (frame 1: clip->start) and this one: - * the curve turned by YAW on frame 1, the lunge along the bearing to `target` on frame 1, both - * scaled by ENTITY.SIZE_SCALE. Returns the wall result of the frame before, from frame 2, and + * the curve turned by the entity's own yaw (`base`) on frame 1, the lunge along the bearing to + * `target` on frame 1, both scaled by ENTITY.SIZE_SCALE. 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 *target, volatile mhfu_steer_state_t *st, uint32_t frame, const mhfu_steer_clip_t *clip); diff --git a/framework/src/core/steer.cpp b/framework/src/core/steer.cpp index dc5cad99..786e72b6 100644 --- a/framework/src/core/steer.cpp +++ b/framework/src/core/steer.cpp @@ -213,8 +213,8 @@ int mhfu_steer_move(void *entity, const mhfu_vec3_t *target, volatile mhfu_steer volatile uint32_t *to = (volatile uint32_t *)&st->now; volatile uint32_t *from = (volatile uint32_t *)&st->next; for (unsigned k = 0; k < sizeof(mhfu_steer_spec_t) / 4; k++) to[k] = from[k]; - st->yaw0 = *yaw; if (!st->keep) st->base = *yaw; + st->yaw0 = st->base; /* travel is in the entity's own frame, not the drawn one */ st->keep = 0; st->ramp = (int16_t)(uint16_t)(*yaw - st->base - curve(sp, start, end)); st->wall = MHFU_STEER_GO; diff --git a/framework/tests/test_move.py b/framework/tests/test_move.py index ca4c6ec6..edc264bd 100644 --- a/framework/tests/test_move.py +++ b/framework/tests/test_move.py @@ -1016,7 +1016,7 @@ def chained(g: Game, entry: int = 53, times: int = 1, **kw: int) -> None: if k in ("keys", "samples", "lunge"): continue setattr(mv, k, v) - spec = spec_of(g, kw.get("keys", ()), (), None) # type: ignore[arg-type] + spec = spec_of(g, kw.get("keys", ()), kw.get("samples", ()), None) # type: ignore[arg-type] g.lib.host_chain(ctypes.byref(mv), ctypes.byref(spec), times) @@ -1186,3 +1186,22 @@ def test_the_hunter_turn_carries_over_a_chain_through_base(g: Game) -> None: (base,) = g.steer_state("BASE", "H") g.frame() assert 0 < base < 0x2000 and g.steer_state("BASE", "H") == (base,) + + +def test_a_chained_steps_travel_goes_along_base_not_the_drawn_yaw(g: Game) -> None: + """The first clip ends with a facing key, so the drawn YAW is off base when the second step + starts; its travel is in the entity's own frame, the base.""" + g.poke(a.ENTITY.SIZE_SCALE, "f", 1.0) + g.poke(a.ENTITY.YAW, "H", 0x1000) + steer(g, keys=K1) + g.play(46, stop=40) + chained(g, 53, keys=K2, samples=((0, 0), (0, 100))) + while g.state("STARTED")[0] != 2: + g.frame() + start = at(g) + g.frame() + assert abs(signed(yaw(g) - 0x1000)) > 0x400 # drawn well off base + for _ in range(int(CLIP_END / SPEED)): + g.frame() + dx, dz = world(0x1000, 0, 100) + assert at(g) == pytest.approx((start[0] + dx, start[1] + dz), abs=0.02) diff --git a/packages/mhfu-port/src/mhfu_port/behaviour.py b/packages/mhfu-port/src/mhfu_port/behaviour.py index 7cd8603a..f003de28 100644 --- a/packages/mhfu-port/src/mhfu_port/behaviour.py +++ b/packages/mhfu-port/src/mhfu_port/behaviour.py @@ -61,7 +61,7 @@ def toml(key: str) -> dict[str, str]: BOARD_SIGNALS: int = addresses.EM_BOARD.SIGNALS.count or 0 """Counters and flags, and signals, one monster's board holds.""" SIDES = ("front", "left", "right", "behind") -"""The hunter's sectors of the monster's facing; bit k of a side mask is `SIDES[k]`.""" +"""The target's sectors of the monster's facing; bit k of a side mask is `SIDES[k]`.""" S16 = (-0x8000, 0x7FFF) """A counter's range.""" UNLIMITED_DIST = 1.0e9 @@ -246,17 +246,17 @@ def _event(name: Event) -> Kind: "condition", "Hunter distance", ( - Param("lo", "float", "Nearest", 0.0, lo=0, tip="The nearest the hunter may be"), + Param("lo", "float", "Nearest", 0.0, lo=0, tip="The nearest the target may be"), Param( "hi", "float", "Farthest", optional=True, lo=0, - tip="The farthest the hunter may be; else any distance", + tip="The farthest the target may be; else any distance", ), ), - "The hunter is this far from the monster, in world units", + "The monster's target is this far from it, in world units", summary=lambda v: _span(v["lo"], v.get("hi")), ), Kind( @@ -272,7 +272,7 @@ def _event(name: Event) -> Kind: tip="Away from the monster, or closer to it", ), ), - "The hunter is moving away from the monster or closer to it", + "The monster's target is moving away from it or closer to it", summary=lambda v: f"moving {v['way']}", ), Kind( @@ -386,7 +386,7 @@ def _event(name: Event) -> Kind: tip="Any of these sides of the monster; its left is its own", ), ), - "The hunter is on one of these sides of the monster", + "The monster's target is on one of these sides of it", summary=lambda v: ", ".join(v["sides"]), ), Kind( @@ -523,7 +523,8 @@ class Behaviour: class Rule: """A trigger the native seam evaluates every frame: when the live pair is `from_move`'s or in `from_main` and has stood `min_frames` (or own move `from_move` has played that long), the - hunter is within `dist` and receding or closing as asked, and every one of `conds` holds, + target (what the monster aims at: its AI target, the player without one) is within `dist` + and receding or closing as asked, and every one of `conds` holds, play `play`, a pair or an own move, and apply `effects`; then wait `cooldown` frames, at most `count` times (None: unlimited). A pair rule waits while an own move plays. With no `play` the rule only applies its effects and the scan goes on. diff --git a/packages/mhfu-port/src/mhfu_port/cli/own.py b/packages/mhfu-port/src/mhfu_port/cli/own.py index 8a634c12..da5a4ea8 100644 --- a/packages/mhfu-port/src/mhfu_port/cli/own.py +++ b/packages/mhfu-port/src/mhfu_port/cli/own.py @@ -80,7 +80,7 @@ def describe(m: manifest.Manifest, name: str, slot: int | None, shared: str | No s = mv.steer if s.turn == "hunter": window = "" if s.window is None else f" in frames {s.window[0]}-{s.window[1]}" - head += f" turns to the hunter at {s.rate or 'the host charge'}{window}" + head += f" turns to the target at {s.rate or 'the host charge'}{window}" if s.turn == "fixed": head += f" turns {s.angle:g} degrees" + (f" in frames {s.window}" if s.window else "") if mv.travel: diff --git a/packages/mhfu/src/mhfu/addresses.toml b/packages/mhfu/src/mhfu/addresses.toml index ad543fdb..38919254 100644 --- a/packages/mhfu/src/mhfu/addresses.toml +++ b/packages/mhfu/src/mhfu/addresses.toml @@ -944,7 +944,7 @@ NOW = { offset = "next", type = "STEER_SPEC", doc = "the steering of the move ru BASE = { offset = "next", type = "u16", doc = "the entity's own YAW, without the clip's turn (YAW = BASE + the curve + the ramp): YAW on a chain head, kept by a step started in place, turned toward the target by the mode and by 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" } +YAW0 = { offset = "next", type = "u16", doc = "BASE on the move's first playing frame, after the first frame chose it (kept or YAW): the entity's own yaw the travel curve is turned by" } 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" } diff --git a/packages/mhfu/src/mhfu/live/moves.py b/packages/mhfu/src/mhfu/live/moves.py index 57cf59b6..1dff0bed 100644 --- a/packages/mhfu/src/mhfu/live/moves.py +++ b/packages/mhfu/src/mhfu/live/moves.py @@ -386,7 +386,7 @@ class Played: """The emulated clock at the ask, in microseconds cut to 32 bits: the zero of every `t`.""" steer: Steer = NO_STEER yaw0: int = 0 - """YAW on the move's first playing frame.""" + """The entity's own yaw (BASE) on the move's first playing frame.""" yaw: int = 0 """YAW as the move's last step left it.""" From b73494b11e594812d49c156dbb7a2168340a565c Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:07:08 +0100 Subject: [PATCH 4/6] framework: a move's code turn, aim and aim-out; combos in the enter ids; 160 own moves Turn by code: a move's own yaw (base) follows the donor's entity-yaw curve, s16 YAW units every STEER_KEY_STEP clip frames from START (EM_TURNS pool, runs shared like keys and samples, STEER_SPEC.TURNS beside the samples). steer.turn = aim scales it to turn exactly the angle to the target seen on the first playing frame. MOVE.AIM_OUT spreads the angle still left at a hand-over in place over the successor's first AI frames. Combos: an enter id from OWN_MOVES to 255 names an EM_MOVES.COMBOS entry (first, then); the slot-32 stub files it on em75's hub, the brain plays first, and successor() takes then in place of first's AFTER, for that play only. mhfu.em_combo registers one and refuses a first that is empty or off the hub. OWN_MOVES is 160. Co-Authored-By: Claude Sonnet 5.5 --- framework/README.md | 38 ++-- framework/include/mhfu/em_vhook.h | 49 +++-- framework/include/mhfu/move.h | 8 +- framework/include/mhfu/steer.h | 35 +++- framework/lua/meta/mhfu.d.lua | 26 ++- framework/mods/lua_host/bind_em.cpp | 8 + framework/mods/lua_host/bind_move.cpp | 19 +- framework/src/core/em_vhook.cpp | 154 ++++++++++++---- framework/src/core/em_vhook_stubs.h | 16 +- framework/src/core/move.cpp | 22 ++- framework/src/core/steer.cpp | 58 ++++++ framework/tests/test_em_brain.py | 237 ++++++++++++++++++++++++- framework/tests/test_em_vhook_stubs.py | 34 +++- framework/tests/test_move.py | 234 +++++++++++++++++++++++- framework/tests/test_own_layout.py | 2 + packages/mhfu/src/mhfu/addresses.toml | 27 ++- 16 files changed, 867 insertions(+), 100 deletions(-) diff --git a/framework/README.md b/framework/README.md index 7683c6c7..f73fa65a 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` (128) of -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 +A port's own moves sit in a registry by slot (`mhfu.em_move`): `mhfu.addr.OWN_MOVES` (160) of +them, and the turn keys (`EM_KEYS`, 2944), travel samples (`EM_SAMPLES`, 1280), code-turn values +(`EM_TURNS`, 1024) and lunges (`EM_LUNGES`, 12) they share, in one 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 @@ -174,15 +174,33 @@ the player when it is 0 or not RAM: the turn reads it every frame, the lunge at the brain's distance and side gates too. A turn's `rate` is YAW units a frame at clip speed 1 (times the clip's speed / 2 in play) and turns the entity's own YAW, which the clip's turn rides on. An `after` on the same carrier plays in the AI frame its move ends, the carrier not entered again; it -keeps the entity's own YAW, and the jump in the clips' facing fades over its blend. 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. +keeps the entity's own YAW, and the jump in the clips' facing fades over its blend. 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. + +A move's own yaw can follow a code turn, MHP3rd's entity-yaw curves. `steer.code_turn` is the YAW +units turned since the move's `start`, cumulative, every `STEER_KEY_STEP` clip frames (4 hex digits +a value, signed 16 bits, at most `STEER_TURNS` a move; `mhfu.steering`). Each AI frame the entity's +own YAW (the base the clips' facing rides on) moves by the curve's change since the cursor's last +frame, as travel does, a loop included. `turn = "aim"` scales it by the angle to the target on the +move's first playing frame over the curve's total, so the move turns exactly that angle along the +curve's shape. A move's `aim_out` (AI frames) makes a hand-over in place carry what is still to +turn to the target at the switch into the successor's own YAW, linearly over its first `aim_out` +AI frames, on top of its own steering. 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 -carries on after its `enter` once the move and its `after` chain end and the carrier hands back. -`mhfu.em_status()` has `own_enters` and `own_last`, and the ring shows `(16, k)`. +stub enters the slot's carrier (the hub `(0,2)` for an empty slot, the game never sees main 16), +files a forced play, and the brain starts the move at the next AI step; the script carries on after +its `enter` once the move and its `after` chain end and the carrier hands back. `mhfu.em_status()` +has `own_enters` and `own_last`, and the ring shows `(16, k)`. + +An id k from `OWN_MOVES` to 255 is a combo, registered with `mhfu.em_combo(k, first, then)` once +the moves are: the stub enters the hub and files the id (it reads no slot past the registry), and +the brain plays slot `first`, which must ride the hub, with slot `then` following in place in the +AI frame it ends, as an `after` does and instead of `first`'s own. Only that play follows: any +other play of `first`, a reaction, a stop or a replacement drops the `then`. It is how one shared +move hands over to a different attack for each caller without a slot per pair. ## Rules on monster events diff --git a/framework/include/mhfu/em_vhook.h b/framework/include/mhfu/em_vhook.h index ece96d31..03a8904e 100644 --- a/framework/include/mhfu/em_vhook.h +++ b/framework/include/mhfu/em_vhook.h @@ -116,7 +116,7 @@ typedef struct { uint32_t gate_refused; /* tail cuts the gate refused */ uint32_t natural_rage; /* mhfu_em_natural_rage */ uint32_t own_enters; /* AI script enters of MHFU_OWN_MOVE_MAIN, whether the slot held a move or not */ - uint32_t own_last; /* slot + 1 of the last that played, 0 none */ + uint32_t own_last; /* slot or combo id + 1 of the last filed (a byte), 0 none */ } mhfu_em_status_t; /* 1 while a big monster's vtable is wrapped (from its spawn to the next quest_beginning). */ @@ -177,16 +177,25 @@ int mhfu_em_tip(const uint8_t (*pairs)[2], int n); * does not cut in before the next. A slot's FORCE is the call's, never the slot's. * * An AI script plays a slot too: `enter MHFU_OWN_MOVE_MAIN k 0`. The slot-32 stub enters the slot's - * carrier (em75's hub (0,2) for an empty or out-of-range slot) in place of the main the game - * has no table for, and files the same forced request mhfu_em_play does; the brain plays the move - * at the next AI step. The script's next pair waits for the carrier's hand-back, as after a native - * 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. */ + * carrier (em75's hub (0,2) for an empty slot) in place of the main the game has no table for, and + * files the same forced request mhfu_em_play does; the brain plays the move at the next AI step. + * An id from MHFU_EM_MOVES up names a combo (mhfu_em_combo): the stub enters the hub and files the + * id, never reading a slot past the registry, and the brain plays the combo's first move, whose + * THEN follows it in place. The script's next pair waits for the carrier's hand-back, as after a + * native 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 or id + 1 (a byte) of the last that was filed. */ #define MHFU_EM_MOVES MHFU_OWN_MOVES #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. */ +/* The pools' sizes, MHFU_EM_KEYS, _SAMPLES, _TURNS and _LUNGES, are consts in addresses.toml. */ + +/* struct EM_COMBO in addresses.toml: an enter id from MHFU_EM_MOVES up is a combo, the move + * `first` played on em75's hub and then `then` in place; 0xFF: none registered */ +typedef struct { + uint8_t first, then; +} mhfu_em_combo_t; +#define MHFU_EM_COMBO_IDS MHFU_EM_MOVES_COMBOS_COUNT /* the ids of a byte: combos are from MHFU_EM_MOVES */ /* struct EM_OWN in addresses.toml */ typedef struct { @@ -194,6 +203,7 @@ typedef struct { 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 */ + uint16_t turn_at, turn_count; /* its code turn in mhfu_em_moves_t.turns */ 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 */ @@ -207,23 +217,34 @@ typedef struct { uint32_t tag_slot, tag_started; /* the slot of the move player's move, if ours */ uint32_t plays, chained; uint32_t key_top, sample_top, lunge_top; /* pool entries in use */ + uint32_t turn_top; + uint32_t tag_then; /* the slot after the combo's first move, else NO_MOVE */ uint32_t req_force; /* REQ_SLOT starts while the notice runs */ mhfu_em_own_t moves[MHFU_EM_MOVES]; + mhfu_em_combo_t combos[MHFU_EM_COMBO_IDS]; /* by enter id: only MHFU_EM_MOVES.. are read */ uint16_t keys[MHFU_EM_KEYS]; mhfu_travel_sample_t samples[MHFU_EM_SAMPLES]; mhfu_steer_lunge_t lunges[MHFU_EM_LUNGES]; + int16_t turns[MHFU_EM_TURNS]; mhfu_steer_spec_t scratch; /* the steering handed to the move player */ } mhfu_em_moves_t; -/* Empties every slot and the pools: turn keys, travel samples, lunges. */ +/* Empties every slot and the pools: turn keys, travel samples, lunges, code turns. */ void mhfu_em_moves_clear(void); /* 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. */ + * samples, code turn and lunge are copied into the pools unless a pool holds that run already, + * anywhere, value for value (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. */ +/* Combo `id` (MHFU_EM_MOVES..255): an enter of it plays slot `first` on the hub, then `then` in + * place, as the AFTER of a move is (mhfu_em_chain), once `first` ends. 0 for an id that is no + * combo's, a slot with no move, or a `first` that does not ride the hub; the check is here, where + * a port registers, and the enter-action stub never trusts an id. Taken in place of `first`'s + * AFTER, if it has one, and only by this play: any other play, a reaction, a stop or a + * replacement of `first` drops it. Cleared by mhfu_em_moves_clear. */ +int mhfu_em_combo(int id, int first, int then); +/* Plays slot (or combo id) 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 or the combo none. */ int mhfu_em_play(uint32_t entity, int slot, int force); /* The slot of the move the move player runs (entering or playing), if it came from here; -1. */ int mhfu_em_playing(void); diff --git a/framework/include/mhfu/move.h b/framework/include/mhfu/move.h index 1a8f889b..2ab6ea9f 100644 --- a/framework/include/mhfu/move.h +++ b/framework/include/mhfu/move.h @@ -31,7 +31,9 @@ * place: the end's attacks are ended and the successor's entry goes to the executor in the same AI * frame, before the host step, so the carrier's handler (the hub's clip) never runs between the two. * The steering carries over (steer.h): the successor keeps the entity's own YAW, and the turn the - * clips' facings make is blended out over the successor's blend. + * clips' facings make is blended out over the successor's blend. A move with AIM_OUT also leaves the + * angle still to turn to the target at the switch, which the successor turns into its own YAW + * linearly over its first AIM_OUT AI frames, on top of its own steering. * * A move asked for while the monster's notice runs (the player's ENTITY.AWARE bit set, * COMBAT_MODE still 0: the roar, the hub, the AI script's combat entry) waits for it, up to @@ -85,7 +87,9 @@ typedef struct { uint8_t hold_script; /* 1: entering the back pair clears ENTITY.SCRIPT_WAKE, so the * 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; + uint8_t aim_out; /* AI frames; 0 none: a hand-over in place spreads the angle + * still to turn to the target over the successor's first + * aim_out AI frames (steer.h out_total) */ 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 diff --git a/framework/include/mhfu/steer.h b/framework/include/mhfu/steer.h index 2c59dea1..7988e383 100644 --- a/framework/include/mhfu/steer.h +++ b/framework/include/mhfu/steer.h @@ -25,6 +25,8 @@ enum { 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, or across the window */ + MHFU_STEER_AIM = 4, /* the code turn, scaled to turn exactly the angle to the target seen + * on the move's first frame; needs a code turn */ }; enum { @@ -34,7 +36,7 @@ enum { }; typedef struct { - uint8_t turn; /* MHFU_STEER_STILL .. MHFU_STEER_FIXED */ + uint8_t turn; /* MHFU_STEER_STILL .. MHFU_STEER_AIM */ uint8_t walls; /* nonzero: report walls ahead */ uint16_t rate; /* HUNTER, AWAY: most YAW units a frame; the Tigrex charge steers 64 */ int32_t total; /* FIXED: YAW units over the move, under one turn either way */ @@ -52,6 +54,9 @@ uint16_t mhfu_steer_bearing(float dx, float dz); uint16_t mhfu_steer_toward(uint16_t yaw, uint16_t want, uint16_t rate); /* Frame `frame`'s share of `total` over `frames`: the shares of frames 0..frames-1 sum to it. */ int32_t mhfu_steer_share(int32_t total, uint16_t frames, uint16_t frame); +/* The angle from `base` to the bearing of `target` from `entity`'s POSITION, the short way: the + * signed YAW units still to turn to face it; 0 where `target` is NULL or stands on the monster. */ +int16_t mhfu_steer_aim(const void *entity, const mhfu_vec3_t *target, uint16_t base); /* ENTITY.WALL_SECTORS bits within 22.5 degrees of `dir` (0xC0000003 for 0, EM75_COLLIDED's). */ uint32_t mhfu_steer_ahead(uint16_t dir); /* MHFU_STEER_GO, _WALL or _STUCK for one frame's ENTITY.WALL_SECTORS and STUCK_WALL. */ @@ -69,7 +74,7 @@ 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, _KEYS, _SAMPLES and _TRAVEL_SCALE are consts in addresses.toml */ +/* MHFU_STEER_KEY_STEP, _KEYS, _SAMPLES, _TURNS 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 { @@ -95,6 +100,11 @@ typedef struct { 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 */ + uint16_t turn_count; /* 0: no code turn */ + int16_t turns[MHFU_STEER_TURNS]; /* the donor's entity-yaw curve: YAW units turned since the + * move's start at clip frame start + k * KEY_STEP, + * cumulative, held past the last; the entity's own yaw + * (`base`) moves by its change, the keys ride on top */ } mhfu_steer_spec_t; /* struct STEER_STATE in addresses.toml */ @@ -110,6 +120,10 @@ typedef struct { uint8_t keep; /* the step started in place: its first frame keeps `base` */ uint16_t blend; /* part 0's CLIP_BLOCK.BLEND right after the dispatch */ int16_t ramp; /* the YAW jump at the switch, falling to 0 as the blend counts down */ + float turn_scale; /* the code turn's factor, set on the first frame: 1, or for AIM the + * angle to the target over the curve's total */ + int16_t out_total; /* the angle left at a hand-over from a move with `aim_out` ... */ + uint16_t out_frames; /* ... spread into `base` over the step's first AI frames; 0: none */ } mhfu_steer_state_t; /* Part 0's clip block as the move player reads it each AI frame. */ @@ -133,13 +147,16 @@ void mhfu_steer_sincos(uint16_t yaw, float *s, float *c); * NEXT; a cursor that went back (a loop) carries the curve's turn over; YAW = base + curve + ramp, * the base turned by the spec's mode (HUNTER and AWAY toward `target`, at the rate times * clip->speed / 2, only while the cursor, the clip frame, is in the window; FIXED with a window - * across it by the cursor). Frame 1 keeps `base` when the step started in place (st->keep), else - * sets it to YAW, and the ramp starts at the jump that keeps YAW continuous, falling to 0 as - * clip->blend counts down from st->blend. Then POSITION moves by the change of the travel curve - * and of the lunge between the cursor of the frame before (frame 1: clip->start) and this one: - * the curve turned by the entity's own yaw (`base`) on frame 1, the lunge along the bearing to - * `target` on frame 1, both scaled by ENTITY.SIZE_SCALE. Returns the wall result of the frame before, from frame 2, and - * then moves nothing. */ + * across it by the cursor), by the code turn (the change of `turns` between the cursor of the + * frame before and this one, times st->turn_scale: 1, or for AIM the angle to `target` on frame 1 + * over the curve's total, so the move turns exactly that angle) and by the share of st->out_total + * that a hand-over left for this frame. Frame 1 keeps `base` when the step started in place + * (st->keep), else sets it to YAW, and the ramp starts at the jump that keeps YAW continuous, + * falling to 0 as clip->blend counts down from st->blend. Then POSITION moves by the change of + * the travel curve and of the lunge between the cursor of the frame before (frame 1: + * clip->start) and this one: the curve turned by the entity's own yaw (`base`) on frame 1, the + * lunge along the bearing to `target` on frame 1, both scaled by ENTITY.SIZE_SCALE. 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 *target, volatile mhfu_steer_state_t *st, uint32_t frame, const mhfu_steer_clip_t *clip); diff --git a/framework/lua/meta/mhfu.d.lua b/framework/lua/meta/mhfu.d.lua index 5fcc71d2..2b1e8ef6 100644 --- a/framework/lua/meta/mhfu.d.lua +++ b/framework/lua/meta/mhfu.d.lua @@ -11,7 +11,7 @@ mhfu = {} ---The API's version, raised whenever a declaration here changes; a mod that needs something added ---in version N checks `mhfu.api_version >= N`. -mhfu.api_version = 10 +mhfu.api_version = 11 -- log, memory and world state (bind_memory.cpp) ---------------------------------------------- @@ -655,6 +655,7 @@ function mhfu.move_block() end ---@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 aim_out? integer AI frames, 0..255, default 0 = none: when this move hands over in place (`after` on its carrier), the angle still to turn to the target at the switch is spread linearly into the entity's own yaw over the successor's first `aim_out` AI frames, on top of its own steering ---@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 @@ -667,7 +668,7 @@ function mhfu.move_block() end ---How a move turns and stops (mhfu/steer.h); every field is optional. ---@class mhfu.MoveSteer ---@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 target (ENTITY.TARGET, the player without one) at `rate`, or `total` over `frames` +---@field turn? "still"|"hunter"|"away"|"fixed"|"aim" also: toward or away from the target (ENTITY.TARGET, the player without one) at `rate`, or `total` over `frames`; "aim" scales `code_turn` to turn exactly the angle to the target on the first playing frame (it needs one) ---@field rate? integer YAW units an AI frame toward or away at clip speed 1, times the clip's speed / 2 in play; 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), 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 @@ -676,6 +677,7 @@ function mhfu.move_block() end ---@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 code_turn? string the move's code turn, the donor's entity-yaw curve: 4 hex digits a value, a signed 16 bits of YAW units turned since the move's `start`, cumulative, every mhfu.addr.STEER_KEY_STEP clip frames, at most mhfu.addr.STEER_TURNS; each AI frame the entity's own yaw moves by the curve's change since the cursor's last frame (times the scale: 1, or for "aim" the angle to the target over the curve's total), held past the last value; the clip's facing (`curve`) rides on top ---@field lunge? mhfu.MoveLunge travel toward the target, by the target'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 target's XZ distance `d` is under `near`, else `base + range * min((d - near) / span, 1)` along the bearing to the target at the first playing frame, in world axes, spread by the clip cursor; `near`, `span`, `base` and `range` are times the monster's size. @@ -730,12 +732,25 @@ function mhfu.move_block() end ---@return boolean function mhfu.em_move(slot, spec) end ----Empties every own move slot. +---Empties every own move slot and every combo. ---@return true function mhfu.em_moves_clear() end ----Plays own move `slot` on ent at its next AI step; false while nothing is wrapped or the slot is ----empty. `force` starts it while the monster's notice runs, as `mhfu.move_play`'s. +---Combo `id` (mhfu.addr.OWN_MOVES..255): an AI script's `enter 16 id 0` plays own move slot `first` +---on em75's hub, then slot `next_slot` in place when `first` ends (as an `after` on the same carrier +---does), in place of `first`'s own `after`. Only that play follows: any other play of `first`, a +---reaction, a stop or a replacement drops the second. False for an id outside OWN_MOVES..255, an +---empty slot, or a `first` that does not ride the hub; register after the moves, which +---`mhfu.em_moves_clear` empties along with the combos. +---@param id integer +---@param first integer +---@param next_slot integer +---@return boolean +function mhfu.em_combo(id, first, next_slot) end + +---Plays own move `slot` (or combo `id`, OWN_MOVES..255) on ent at its next AI step; false while +---nothing is wrapped or the slot is empty or the combo none. `force` starts it while the monster's +---notice runs, as `mhfu.move_play`'s. ---@param ent integer ---@param slot integer ---@param force? boolean @@ -763,6 +778,7 @@ function mhfu.em_moves_status() end ---@field chained integer of them by `after` ---@field keys integer turn keys in use ---@field samples integer travel samples in use +---@field turns integer code-turn values in use ---@field lunges integer lunges in use -- fatigue (bind_em.cpp, include/mhfu/fatigue.h) --------------------------------------------------- diff --git a/framework/mods/lua_host/bind_em.cpp b/framework/mods/lua_host/bind_em.cpp index 0e033722..42e36f35 100644 --- a/framework/mods/lua_host/bind_em.cpp +++ b/framework/mods/lua_host/bind_em.cpp @@ -248,6 +248,13 @@ int lb_em_moves_clear(lua_State *L) return 1; } +int lb_em_combo(lua_State *L) +{ + lua_pushboolean(L, mhfu_em_combo((int)luaL_checkinteger(L, 1), (int)luaL_checkinteger(L, 2), + (int)luaL_checkinteger(L, 3))); + return 1; +} + int lb_em_play(lua_State *L) { lua_pushboolean(L, mhfu_em_play((uint32_t)luaL_checkinteger(L, 1), @@ -276,6 +283,7 @@ int lb_em_moves_status(lua_State *L) SF_INT("chained", r->chained); SF_INT("keys", r->key_top); SF_INT("samples", r->sample_top); + SF_INT("turns", r->turn_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 342f8707..85fed9f1 100644 --- a/framework/mods/lua_host/bind_move.cpp +++ b/framework/mods/lua_host/bind_move.cpp @@ -32,6 +32,9 @@ static void spec_of(lua_State *L, int t, mhfu_move_t *mv) "start and stop are clip frames, 0..65535"); mv->start = (uint16_t)start; mv->stop = (uint16_t)stop; + int aim_out = opt_field(L, t, "aim_out", 0); + luaL_argcheck(L, aim_out >= 0 && aim_out <= 0xFF, t, "aim_out is AI frames, 0..255"); + mv->aim_out = (uint8_t)aim_out; 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"); @@ -70,7 +73,7 @@ static void spec_of(lua_State *L, int t, mhfu_move_t *mv) lua_pop(L, 1); } -static const char *const TURNS[] = {"still", "hunter", "away", "fixed", NULL}; +static const char *const TURNS[] = {"still", "hunter", "away", "fixed", "aim", NULL}; static int hex(int c) { @@ -83,7 +86,7 @@ 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"); + luaL_argcheck(L, d >= 0, 2, "steer.curve, steer.travel and steer.code_turn are hex digits"); v = v * 16 + d; } return v; @@ -94,7 +97,9 @@ static int hex16(lua_State *L, const char *c) * 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 } */ + * 1/STEER_TRAVEL_SCALE units), code_turn = the move's code turn (4 hex digits a value, a signed + * 16 bits of YAW units, cumulative; turn = "aim" scales it to the angle to the target), + * 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); @@ -148,6 +153,14 @@ static void steer_of(lua_State *L, int t, mhfu_steer_spec_t *sp) sp->samples[sp->sample_count].z = (int16_t)hex16(L, c + i + 4); } lua_pop(L, 1); + lua_getfield(L, s, "code_turn"); + c = lua_isstring(L, -1) ? lua_tolstring(L, -1, &n) : NULL; + luaL_argcheck(L, !c || (n % 4 == 0 && n / 4 <= MHFU_STEER_TURNS), 2, + "steer.code_turn is up to STEER_TURNS values of 4 hex digits"); + for (size_t i = 0; c && i < n; i += 4) sp->turns[sp->turn_count++] = (int16_t)hex16(L, c + i); + lua_pop(L, 1); + luaL_argcheck(L, sp->steer.turn != MHFU_STEER_AIM || sp->turn_count, 2, + "steer.turn = \"aim\" needs steer.code_turn"); if (lua_getfield(L, s, "lunge") == LUA_TTABLE) { int l = lua_gettop(L); luaL_argcheck(L, lua_getfield(L, l, "window") == LUA_TTABLE, 2, diff --git a/framework/src/core/em_vhook.cpp b/framework/src/core/em_vhook.cpp index d8059e71..b6513399 100644 --- a/framework/src/core/em_vhook.cpp +++ b/framework/src/core/em_vhook.cpp @@ -29,12 +29,14 @@ * thread in the next AI frame through the engine's dispatcher * (MHFU_ENTER_ACTION), so it is provisioned too. * OWN ENTER slot-32 pre, after the table: an AI script's `enter MHFU_OWN_MOVE_MAIN k` - * is entered as own move k's carrier (em75's hub for an empty or out-of-range - * slot) and files the request mhfu_em_play files, forced; the brain plays the - * move at the next AI step. The script's wake the hub raises inside the run is - * cleared by SCRIPT_TICK after it, and move.cpp's start quiets the one the hub - * raises again, so the script's next pair cannot cut the move; the carrier hands - * back once the move (and its AFTER chain) ends, which wakes the script. + * is entered as own move k's carrier (em75's hub for an empty slot) and files the + * request mhfu_em_play files, forced; the brain plays the move at the next AI + * step. A k from MHFU_EM_MOVES up is a combo's: the hub, the id filed, the brain + * plays the combo's first move and its THEN follows in place. The script's wake + * the hub raises inside the run is cleared by SCRIPT_TICK after it, and + * move.cpp's start quiets the one the hub raises again, so the script's next pair + * cannot cut the move; the carrier hands back once the move (and its AFTER chain) + * ends, which wakes the script. * CUT the brain: while the tail cut waits for its drop (cut_waits), the * request, the plays and the rules wait, and the move player's start * with them; the events seen meanwhile reach the rules after the drop. @@ -309,6 +311,8 @@ static_assert(offsetof(mhfu_em_own_t, key_at) == MHFU_EM_OWN_KEY_AT, "EM_OWN lay 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, turn_at) == MHFU_EM_OWN_TURN_AT, "EM_OWN layout"); +static_assert(offsetof(mhfu_em_own_t, turn_count) == MHFU_EM_OWN_TURN_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"); @@ -319,18 +323,26 @@ static_assert(offsetof(mhfu_em_moves_t, tag_slot) == MHFU_EM_MOVES_TAG_SLOT, "EM 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, turn_top) == MHFU_EM_MOVES_TURN_TOP, "EM_MOVES layout"); +static_assert(offsetof(mhfu_em_moves_t, tag_then) == MHFU_EM_MOVES_TAG_THEN, "EM_MOVES layout"); +static_assert(offsetof(mhfu_em_moves_t, combos) == MHFU_EM_MOVES_COMBOS, "EM_MOVES layout"); +static_assert(sizeof(mhfu_em_combo_t) == MHFU_EM_COMBO_SIZE, "EM_COMBO layout"); +static_assert(offsetof(mhfu_em_combo_t, then) == MHFU_EM_COMBO_THEN, "EM_COMBO layout"); +static_assert(MHFU_EM_COMBO_IDS == 0x100 && MHFU_EM_MOVES < MHFU_EM_COMBO_IDS, + "an enter id is a byte, and combos are the ids past the slots"); 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, turns) == MHFU_EM_MOVES_TURNS, "EM_MOVES layout"); static_assert(offsetof(mhfu_em_moves_t, scratch) == MHFU_EM_MOVES_SCRATCH, "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_EM_KEYS <= 0xFFFF && MHFU_EM_SAMPLES <= 0xFFFF && MHFU_EM_TURNS <= 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) @@ -371,7 +383,9 @@ static void place_moves(void *at) R = (volatile mhfu_em_moves_t *)(((uintptr_t)at + 63) & ~(uintptr_t)63); volatile uint32_t *w = (volatile uint32_t *)R; for (unsigned k = 0; k < sizeof(mhfu_em_moves_t) / 4; k++) w[k] = 0; - R->tag_slot = MHFU_EM_NO_MOVE; + R->tag_slot = R->tag_then = MHFU_EM_NO_MOVE; + for (unsigned id = 0; id < MHFU_EM_COMBO_IDS; id++) + R->combos[id].first = R->combos[id].then = MHFU_EM_NO_MOVE; R->magic = MOVES_MAGIC; } @@ -492,7 +506,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, travel samples and lunge and all */ +/* slot's steering in the registry's scratch, keys, travel samples, code turn 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; @@ -506,6 +520,9 @@ static const volatile mhfu_steer_spec_t *steer_of(const volatile mhfu_em_own_t * 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)); + n = o->turn_count < MHFU_STEER_TURNS ? o->turn_count : MHFU_STEER_TURNS; + sp->turn_count = (uint16_t)n; + for (unsigned k = 0; k < n; k++) sp->turns[k] = R->turns[o->turn_at + k]; 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)); @@ -520,6 +537,7 @@ static void tag(uint32_t ent, uint32_t slot, const volatile mhfu_move_state_t *m { R->tag_slot = slot; R->tag_started = m->started + 1; /* the step starts it right after us */ + R->tag_then = MHFU_EM_NO_MOVE; /* a combo's first sets it again, after this */ R->plays++; fat_own(ent, slot); chg_own(ent, slot); @@ -537,19 +555,59 @@ static int fill(uint32_t slot, int force, mhfu_move_t *mv, const mhfu_steer_spec return 1; } -static int play_slot(uint32_t ent, uint32_t slot, const volatile mhfu_move_state_t *m, int force) +/* A move that is followed ends into its carrier with the AI script's wake cleared, so the host + * brain does not cut in before the move after it. The carrier again at the end, so a successor + * on another carrier follows; the hub it enters wakes the AI script, so the move player clears + * that wake. */ +static void follow(mhfu_move_t *mv) +{ + mv->hold_script = 1; + if (mv->back_main == MHFU_MOVE_NO_PAIR) { + mv->back_main = mv->carrier_main; + mv->back_sub = mv->carrier_sub; + mv->back_mode = 0; + } +} + +/* Plays slot on ent, forced or not; `then` (a slot, else MHFU_EM_NO_MOVE) follows it in place in + * place of its AFTER: a combo's first move. */ +static int play_slot(uint32_t ent, uint32_t slot, const volatile mhfu_move_state_t *m, int force, + uint32_t then) { mhfu_move_t mv; const mhfu_steer_spec_t *sp; if (!fill(slot, force, &mv, &sp)) return 0; + if (then != MHFU_EM_NO_MOVE) follow(&mv); mhfu_move_steer(sp); if (!mhfu_move_play(ent, &mv)) return 0; tag(ent, slot, m); + R->tag_then = then; return 1; } -/* The slot that plays when slot k's move ends: its AFTER, MHFU_EM_NO_MOVE for none. */ -static uint32_t successor(int k) { return R->moves[k].after; } +/* combo `id` is registered and both its moves are there */ +static bool combo_ready(uint32_t id) +{ + if (id < MHFU_EM_MOVES || id >= MHFU_EM_COMBO_IDS) return false; + const uint32_t first = R->combos[id].first, then = R->combos[id].then; + return first < MHFU_EM_MOVES && then < MHFU_EM_MOVES && R->moves[first].valid + && R->moves[then].valid; +} + +/* what an enter id or a request names: a slot, or a combo's first move with its THEN to follow */ +static int play_request(uint32_t ent, uint32_t id, const volatile mhfu_move_state_t *m, int force) +{ + if (id < MHFU_EM_MOVES) return play_slot(ent, id, m, force, MHFU_EM_NO_MOVE); + if (!combo_ready(id)) return 0; + return play_slot(ent, R->combos[id].first, m, force, R->combos[id].then); +} + +/* The slot that plays when slot k's move ends: the THEN of the combo it began, else its AFTER, + * MHFU_EM_NO_MOVE for none. The one place a successor is chosen. */ +static uint32_t successor(int k) +{ + return R->tag_then != MHFU_EM_NO_MOVE ? R->tag_then : R->moves[k].after; +} /* The move that just ended on its clip's end into its carrier or on a wall names a successor, which * plays this frame: that move's slot, else -1. */ @@ -1020,7 +1078,7 @@ static int fire(cfg_rule_t *r, uint32_t ent, const volatile mhfu_move_state_t *m { if (!quiet(r)) { if (r->play_move) { - if (!play_slot(ent, r->play_move - 1u, m, r->force)) return 0; + if (!play_slot(ent, r->play_move - 1u, m, r->force, MHFU_EM_NO_MOVE)) return 0; } else { enter(ent, r->to_main, r->to_sub, r->mode); } @@ -1138,11 +1196,11 @@ static int act(uint32_t ent, const volatile mhfu_move_state_t *m, const seen_t * const int due = chain_due(ent, m); if (R->req_ent == ent) { R->req_ent = 0; - play_slot(ent, R->req_slot, m, (int)R->req_force); + play_request(ent, R->req_slot, m, (int)R->req_force); } else if (react_take(ent, m, s)) { took = 1; } else if (due >= 0) { - if (play_slot(ent, successor(due), m, m->move.force)) R->chained++; + if (play_slot(ent, successor(due), m, m->move.force, MHFU_EM_NO_MOVE)) R->chained++; else R->tag_slot = MHFU_EM_NO_MOVE; /* an empty AFTER: not again */ } else { plays = 1; @@ -1566,7 +1624,10 @@ 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 = R->sample_top = R->lunge_top = 0; + for (unsigned id = 0; id < MHFU_EM_COMBO_IDS; id++) + R->combos[id].first = R->combos[id].then = MHFU_EM_NO_MOVE; + R->tag_then = MHFU_EM_NO_MOVE; + R->key_top = R->sample_top = R->lunge_top = R->turn_top = 0; } /* Where a pool of `top` units of `size` bytes holds the `n` units of `run` byte for byte, the @@ -1586,24 +1647,28 @@ static uint32_t find_run(const volatile void *pool, uint32_t top, const void *ru 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. */ +/* A slot is rewritten off (VALID 0) and what its keys, samples, code turn 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) { 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; + uint32_t t = s->turn_count > MHFU_STEER_TURNS ? MHFU_STEER_TURNS : s->turn_count; volatile mhfu_em_own_t *o = &R->moves[slot]; const bool lunge = s->lunge.hi != 0; 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 turn_at = find_run(R->turns, R->turn_top, s->turns, t, sizeof(int16_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 turn_new = turn_at == R->turn_top && t; 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, @@ -1615,20 +1680,21 @@ extern "C" int mhfu_em_move(int slot, const mhfu_move_t *mv, const mhfu_steer_sp (unsigned)(MHFU_EM_SAMPLES - R->sample_top)); return 0; } + if (turn_new && R->turn_top + t > MHFU_EM_TURNS) { + mhfu_log("[%s] own move %d: %u code-turn values, %u left", OWNER, slot, (unsigned)t, + (unsigned)(MHFU_EM_TURNS - R->turn_top)); + return 0; + } 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; } 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 - * enters wakes the AI script, so the move player clears that wake */ - o->move.hold_script = after != MHFU_EM_NO_MOVE; - if (after != MHFU_EM_NO_MOVE && mv->back_main == MHFU_MOVE_NO_PAIR) { - o->move.back_main = mv->carrier_main; - o->move.back_sub = mv->carrier_sub; - o->move.back_mode = 0; - } + mhfu_move_t own; + copy_words(&own, mv, sizeof(own)); + own.hold_script = 0; + if (after != MHFU_EM_NO_MOVE) follow(&own); /* so AFTER plays before the host brain picks */ + copy_words(&o->move, &own, sizeof(own)); copy_words(&o->steer, &s->steer, sizeof(s->steer)); if (key_new) { for (uint32_t k = 0; k < n; k++) R->keys[key_at + k] = s->keys[k]; @@ -1642,6 +1708,12 @@ extern "C" int mhfu_em_move(int slot, const mhfu_move_t *mv, const mhfu_steer_sp } o->sample_at = (uint16_t)sample_at; o->sample_count = (uint16_t)m; + if (turn_new) { + for (uint32_t k = 0; k < t; k++) R->turns[turn_at + k] = s->turns[k]; + R->turn_top += t; + } + o->turn_at = (uint16_t)turn_at; + o->turn_count = (uint16_t)t; if (lunge_new) { copy_words(&R->lunges[lunge_at], &s->lunge, sizeof(s->lunge)); R->lunge_top++; @@ -1655,10 +1727,30 @@ extern "C" int mhfu_em_move(int slot, const mhfu_move_t *mv, const mhfu_steer_sp return 1; } +extern "C" int mhfu_em_combo(int id, int first, int then) +{ + if (!R || id < MHFU_EM_MOVES || id >= (int)MHFU_EM_COMBO_IDS || first < 0 || then < 0 + || first >= MHFU_EM_MOVES || then >= MHFU_EM_MOVES) + return 0; + if (!R->moves[first].valid || !R->moves[then].valid) { + mhfu_log("[%s] combo %d: slot %d or %d is empty", OWNER, id, first, then); + return 0; + } + const volatile mhfu_move_t *f = &R->moves[first].move; + if (f->carrier_main != MHFU_MOVE_HUB_MAIN || f->carrier_sub != MHFU_MOVE_HUB_SUB) { + mhfu_log("[%s] combo %d: slot %d rides (%d,%d), not the hub", OWNER, id, first, + (int)f->carrier_main, (int)f->carrier_sub); + return 0; + } + R->combos[id].first = (uint8_t)first; + R->combos[id].then = (uint8_t)then; + return 1; +} + extern "C" int mhfu_em_play(uint32_t entity, int slot, int force) { - if (!R || !g_installed || !entity || slot < 0 || slot >= MHFU_EM_MOVES - || !R->moves[slot].valid) + if (!R || !g_installed || !entity || slot < 0 || slot >= (int)MHFU_EM_COMBO_IDS + || (slot < MHFU_EM_MOVES ? !R->moves[slot].valid : !combo_ready((uint32_t)slot))) return 0; R->req_ent = 0; R->req_slot = (uint32_t)slot; diff --git a/framework/src/core/em_vhook_stubs.h b/framework/src/core/em_vhook_stubs.h index 8f13480b..6681f233 100644 --- a/framework/src/core/em_vhook_stubs.h +++ b/framework/src/core/em_vhook_stubs.h @@ -324,11 +324,13 @@ static inline void emv_mul_const(emv_asm_t *a, int rd, int rs, int tmp, uint32_t /* An own-move enter, after the substitution table and the reaction replacement, with t0/t1 the * main and id as passed, t7 the config block and `moves` the registry's address (0: none). The id is - * a registry slot. The game never sees main MHFU_OWN_MOVE_MAIN: a1/a2 become the slot's carrier - * when the slot holds a move, else em75's hub; with a move, the play is filed as mhfu_em_play files - * it (EM_MOVES REQ_SLOT, REQ_FORCE 1, REQ_ENT the entity: the brain starts it at the next AI step, - * the AI script's wake quieted as for any move on the hub), and every store of a miss lands in the - * sink. The slot is clamped before its address is formed, so an id past the registry reads slot 0. + * a registry slot, or from MHFU_EM_MOVES up a combo's. The game never sees main + * MHFU_OWN_MOVE_MAIN: a1/a2 become the slot's carrier when the slot holds a move, else em75's hub (a + * combo's first move rides it); with a move or a combo, the play is filed as mhfu_em_play files it + * (EM_MOVES REQ_SLOT the id, REQ_FORCE 1, REQ_ENT the entity: the brain starts it at the next AI + * step, the AI script's wake quieted as for any move on the hub, and refuses an id with no combo), + * and every store of a miss lands in the sink. The slot is clamped before its address is formed, so + * an id past the slots reads slot 0 and the registry is never read past. * Clobbers t0, t1, t5, t6, t9, v0, v1; keeps a0, a3, t3, t4, t7, t8. */ static inline void emv_own_move(emv_asm_t *a, uint32_t moves) { @@ -362,6 +364,10 @@ static inline void emv_own_move(emv_asm_t *a, uint32_t moves) if (!moves) return; E(mips_movn(R_A1, R_T6, R_T0)); E(mips_movn(R_A2, R_T9, R_T0)); + E(mips_sltiu(R_T6, R_T1, MHFU_EM_MOVES_MOVES_COUNT)); + E(mips_xori(R_T6, R_T6, 1)); /* t6 = the id is past the slots ... */ + E(mips_and(R_T6, R_T6, R_T5)); /* ... of an own enter: a combo's */ + E(mips_or(R_T0, R_T0, R_T6)); /* t0 = a play is filed */ E(mips_lbu(R_V1, CFG_OWN_LAST, R_T7)); E(mips_addiu(R_T6, R_T1, 1)); E(mips_movn(R_V1, R_T6, R_T0)); diff --git a/framework/src/core/move.cpp b/framework/src/core/move.cpp index 7f5c38a4..55814c93 100644 --- a/framework/src/core/move.cpp +++ b/framework/src/core/move.cpp @@ -61,6 +61,7 @@ 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, aim_out) == MHFU_MOVE_AIM_OUT, "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"); @@ -107,6 +108,9 @@ static_assert(offsetof(mhfu_steer_spec_t, sample_count) == MHFU_STEER_SPEC_SAMPL "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(offsetof(mhfu_steer_spec_t, turn_count) == MHFU_STEER_SPEC_TURN_COUNT, + "STEER_SPEC layout"); +static_assert(offsetof(mhfu_steer_spec_t, turns) == MHFU_STEER_SPEC_TURNS, "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"); @@ -119,6 +123,12 @@ static_assert(offsetof(mhfu_steer_state_t, lunge_z) == MHFU_STEER_STATE_LUNGE_Z, static_assert(offsetof(mhfu_steer_state_t, keep) == MHFU_STEER_STATE_KEEP, "STEER_STATE layout"); static_assert(offsetof(mhfu_steer_state_t, blend) == MHFU_STEER_STATE_BLEND, "STEER_STATE layout"); static_assert(offsetof(mhfu_steer_state_t, ramp) == MHFU_STEER_STATE_RAMP, "STEER_STATE layout"); +static_assert(offsetof(mhfu_steer_state_t, turn_scale) == MHFU_STEER_STATE_TURN_SCALE, + "STEER_STATE layout"); +static_assert(offsetof(mhfu_steer_state_t, out_total) == MHFU_STEER_STATE_OUT_TOTAL, + "STEER_STATE layout"); +static_assert(offsetof(mhfu_steer_state_t, out_frames) == MHFU_STEER_STATE_OUT_FRAMES, + "STEER_STATE layout"); /* --- the engine ------------------------------------------------------------------------- */ @@ -269,6 +279,7 @@ static int begin(uint32_t ent) S->end = S->skipping = 0; S->end_pair = 0; S->steer.keep = 0; + S->steer.out_frames = 0; for (int k = 0; k < MHFU_ENTITY_CLIP_BLOCKS_COUNT; k++) { S->node[k] = 0; S->clip_end[k] = S->peak[k] = -1.0f; @@ -362,18 +373,27 @@ static void attacks(uint32_t ent, float prev, float cursor) /* The step ended and em_vhook names a successor on the same carrier: it takes this AI frame, before * the host step, so the carrier's handler never sees the clip end (the hub would dispatch its own - * clip) and is not entered again. The pair stays; the step's base is kept (steer.h). */ + * clip) and is not entered again. The pair stays; the step's base is kept (steer.h), and the angle + * to the target it still leaves goes to the successor to spread when the move has AIM_OUT. */ static int chain(uint32_t ent) { mhfu_move_t mv; const mhfu_steer_spec_t *sp; if (!mhfu_em_chain(ent, &mv, &sp)) return 0; + const uint8_t out = S->move.aim_out; + int16_t left = 0; + void *e = out ? direct(ent) : 0; + if (e) + left = mhfu_steer_aim(e, (const mhfu_vec3_t *)direct(mhfu_move_target_pos(ent)), + S->steer.base); end_attacks(ent); mhfu_em_mute_events(0); copy_words(&S->move, &mv, sizeof(mv)); copy_words(&S->steer.next, sp, sizeof(*sp)); if (begin(ent)) { S->steer.keep = 1; + S->steer.out_total = left; + S->steer.out_frames = out; dispatch(ent); } return 1; diff --git a/framework/src/core/steer.cpp b/framework/src/core/steer.cpp index 786e72b6..36a1cc23 100644 --- a/framework/src/core/steer.cpp +++ b/framework/src/core/steer.cpp @@ -129,6 +129,42 @@ INLINE float ramp(uint16_t lo, uint16_t hi, float at) INLINE int32_t round_to(float v) { return (int32_t)(v < 0 ? v - 0.5f : v + 0.5f); } +/* the code turn at clip frame `at`, `start` the frame of its first value: the values joined by + * straight lines, held outside them; 0 without any */ +INLINE float code_turn(const volatile mhfu_steer_spec_t *s, float start, float at) +{ + uint32_t n = s->turn_count < MHFU_STEER_TURNS ? s->turn_count : MHFU_STEER_TURNS; + if (!n) return 0; + 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 v = s->turns[i]; + if (t > 0) v += ((float)s->turns[i + 1] - v) * t; + return v; +} + +/* what the code turn turns in all, from its first value to its last */ +INLINE float code_turn_total(const volatile mhfu_steer_spec_t *s) +{ + uint32_t n = s->turn_count < MHFU_STEER_TURNS ? s->turn_count : MHFU_STEER_TURNS; + return n ? (float)s->turns[n - 1] - (float)s->turns[0] : 0.0f; +} + +/* the YAW units from `base` to the bearing of `target` from `pos`, the short way */ +INLINE int16_t aim_of(const volatile float *pos, const mhfu_vec3_t *target, uint16_t base) +{ + if (!target) return 0; + float dx = target->x - pos[0], dz = target->z - pos[2]; + if (dx == 0 && dz == 0) return 0; + return (int16_t)(uint16_t)(bearing(dx, dz) - base); +} + /* A turn rate at clip speed 1 as it is at `speed` (the donor's ent[0xAC4]): CLIP_BLOCK.SPEED is 2 * a frame calm, so the rate is times speed / 2. */ INLINE uint16_t paced(uint16_t rate, float speed) @@ -154,6 +190,12 @@ 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); } +int16_t mhfu_steer_aim(const void *entity, const mhfu_vec3_t *target, uint16_t base) +{ + return aim_of((const volatile float *)((const volatile uint8_t *)entity + MHFU_ENTITY_POSITION), + target, base); +} + 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) @@ -219,6 +261,11 @@ int mhfu_steer_move(void *entity, const mhfu_vec3_t *target, volatile mhfu_steer st->ramp = (int16_t)(uint16_t)(*yaw - st->base - curve(sp, start, end)); st->wall = MHFU_STEER_GO; before = start; + st->turn_scale = 1; + if (sp->steer.turn == MHFU_STEER_AIM) { + float total = code_turn_total(sp); + st->turn_scale = total != 0 ? (float)aim_of(pos, target, st->base) / total : 0.0f; + } st->lunge_x = st->lunge_z = 0; if (sp->lunge.hi && target) { float dx = target->x - pos[0], dz = target->z - pos[2]; @@ -266,6 +313,17 @@ int mhfu_steer_move(void *entity, const mhfu_vec3_t *target, volatile mhfu_steer st->base = (uint16_t)(st->base + share(sp->steer.total, sp->steer.frames, (uint16_t)(frame - 1))); } + if (sp->turn_count) { + const float s = st->turn_scale; + int32_t turned = round_to(s * code_turn(sp, start, cursor)) + - round_to(s * code_turn(sp, start, before)); + if (loop) + turned += round_to(s * code_turn(sp, start, end)) + - round_to(s * code_turn(sp, start, loop_start)); + st->base = (uint16_t)(st->base + turned); + } + if (frame >= 1 && frame <= st->out_frames) + st->base = (uint16_t)(st->base + share(st->out_total, st->out_frames, (uint16_t)(frame - 1))); int32_t jump = 0; if (st->blend && clip->blend > 0) jump = round_to((float)st->ramp * (float)(clip->blend < st->blend ? clip->blend : st->blend) diff --git a/framework/tests/test_em_brain.py b/framework/tests/test_em_brain.py index 6457098c..0545b30c 100644 --- a/framework/tests/test_em_brain.py +++ b/framework/tests/test_em_brain.py @@ -26,7 +26,7 @@ SPEED = 2.0 NO = ANY = 0xFF UNLIMITED = 0xFFFF_FFFF -CLIP, BACK, PAIR = 1, 2, 3 +CLIP, BACK, PAIR, STOPPED = 1, 2, 3, 4 PLAYING, DONE = 2, 4 KIND = a.MONSTER_EVENT_KIND.number NOTICED, FLINCH, BROKEN, TAIL = ( @@ -40,6 +40,7 @@ NO_PLAY = 4 # MHFU_EM_RULE_NO_PLAY RULES = a.EM_CFG.RULES.count or 0 MOVES = a.EM_MOVES.MOVES.count or 0 +IDS = a.EM_MOVES.COMBOS.count or 0 class Move(ctypes.Structure): @@ -59,7 +60,7 @@ class Move(ctypes.Structure): ("host_attacks", ctypes.c_uint8), ("force", ctypes.c_uint8), ("hold_script", ctypes.c_uint8), - ("_pad", ctypes.c_uint8), + ("aim_out", ctypes.c_uint8), ("start", ctypes.c_uint16), ("stop", ctypes.c_uint16), ] @@ -231,11 +232,12 @@ def own( window: tuple[int, int] = (0, 0), samples: Sequence[tuple[int, int]] = (), lunge: tuple[int, int, float, float, float, float] | None = None, + turns: Sequence[int] = (), **kw: int, ) -> int: """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).""" + turn at rate 64), `samples` its travel in sample units, `lunge` (lo, hi, near, span, + base, range) and `turns` its code turn.""" mv = Move(entry=entry, carrier_main=0, carrier_sub=2, back_main=NO) for k, v in kw.items(): setattr(mv, k, v) @@ -251,6 +253,8 @@ def own( struct.pack_into(" int: @@ -408,6 +412,7 @@ def lib(host_lib: Callable[..., ctypes.CDLL]) -> ctypes.CDLL: lib.host_reset.argtypes = [ctypes.c_float] lib.mhfu_em_host_frame.argtypes = [ctypes.c_uint32] lib.mhfu_em_play.argtypes = [ctypes.c_uint32, ctypes.c_int, ctypes.c_int] + lib.mhfu_em_combo.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_int] lib.mhfu_em_move.argtypes = [ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint8] lib.mhfu_em_rule.argtypes = [ctypes.c_int, ctypes.c_void_p] lib.mhfu_steer_init_spec.argtypes = [ctypes.c_void_p] @@ -541,6 +546,163 @@ def test_an_after_chain_runs_through_in_place(g: Game) -> None: assert g.moves("CHAINED") == 2 +COMBO = MOVES + 5 # an enter id past the slots + + +def test_a_combo_plays_first_then_then_in_place(g: Game) -> None: + """The enter of a combo's id files its play: the first move, and in the AI frame it ends the + second, the carrier not entered between them.""" + g.own(0, 46) + g.own(1, 53) + assert g.lib.mhfu_em_combo(COMBO, 0, 1) and g.lib.mhfu_em_play(ENT, COMBO, 1) + frames = played(g, 2 + int(CLIP_END / SPEED) + 2) + flat = [c for f in frames for c in f] + assert [(c[2], c[3]) for c in flat if c[0] == "E"] == [(0, 2)] + assert [c[2] for c in flat if c[0] == "X"] == [46, 53] + assert ("X", ENT, 53, 0, 0) in frames[2 + int(CLIP_END / SPEED) - 1] # the frame the first ends + assert (g.moves("PLAYS"), g.moves("CHAINED"), g.lib.mhfu_em_playing()) == (2, 1, 1) + assert g.moves("TAG_THEN") == NO # the second's play is its own: nothing follows it + g.frames(int(CLIP_END / SPEED)) + assert g.move("END", "B") == CLIP and g.moves("PLAYS") == 2 + + +def test_only_the_play_of_a_combo_is_followed(g: Game) -> None: + g.own(0, 46) + g.own(1, 53) + assert g.lib.mhfu_em_combo(COMBO, 0, 1) + assert g.lib.mhfu_em_play(ENT, 0, 1) # the slot itself, no combo + flat = [c for f in played(g, 2 + 2 * int(CLIP_END / SPEED)) for c in f] + assert [c[2] for c in flat if c[0] == "X"] == [46] and g.moves("CHAINED") == 0 + + +def test_a_combos_then_takes_the_place_of_the_firsts_after(g: Game) -> None: + g.own(0, 46, after=2) + g.own(1, 53) + g.own(2, 59) + assert g.lib.mhfu_em_combo(COMBO, 0, 1) + assert g.lib.mhfu_em_play(ENT, COMBO, 1) + flat = [c for f in played(g, 2 + 3 * int(CLIP_END / SPEED)) for c in f] + assert [c[2] for c in flat if c[0] == "X"] == [46, 53] + assert g.lib.mhfu_em_play(ENT, 0, 1) # played alone, its own after + flat = [c for f in played(g, 2 + 3 * int(CLIP_END / SPEED)) for c in f] + assert [c[2] for c in flat if c[0] == "X"] == [46, 59] + + +def test_a_combos_then_has_its_own_after_chain(g: Game) -> None: + g.own(0, 46) + g.own(1, 53, after=2) + g.own(2, 59) + assert g.lib.mhfu_em_combo(COMBO, 0, 1) and g.lib.mhfu_em_play(ENT, COMBO, 1) + flat = [c for f in played(g, 2 + 3 * (int(CLIP_END / SPEED) + 1)) for c in f] + assert [c[2] for c in flat if c[0] == "X"] == [46, 53, 59] + assert [(c[2], c[3]) for c in flat if c[0] == "E"] == [(0, 2)] and g.moves("CHAINED") == 2 + + +def test_a_combos_then_on_another_carrier_goes_through_it(g: Game) -> None: + g.own(0, 46) + g.own(1, 53, carrier_sub=3) + assert g.lib.mhfu_em_combo(COMBO, 0, 1) and g.lib.mhfu_em_play(ENT, COMBO, 1) + g.frames(2 + int(CLIP_END / SPEED)) + assert g.move("END", "B") == BACK and g.entered() == [(0, 2), (0, 2)] + flat = [c for f in played(g, 2) for c in f] + assert [(c[2], c[3]) for c in flat if c[0] == "E"] == [(0, 3)] and ("X", ENT, 53, 0, 0) in flat + assert (g.moves("CHAINED"), g.lib.mhfu_em_playing()) == (1, 1) + + +def test_a_reaction_drops_the_combos_then(g: Game) -> None: + g.own(0, 46) + g.own(1, 53) + assert g.lib.mhfu_em_combo(COMBO, 0, 1) and g.lib.mhfu_em_play(ENT, COMBO, 1) + g.frames(4) + g.pair = (4, 1) + flat = [c for f in played(g, 2 + 2 * int(CLIP_END / SPEED)) for c in f] + assert g.move("END", "B") == PAIR and ("X", ENT, 53, 0, 0) not in flat + assert g.moves("CHAINED") == 0 + assert g.lib.mhfu_em_play(ENT, 0, 1) # the first alone again: the dropped then does not come + flat = [c for f in played(g, 2 + 2 * int(CLIP_END / SPEED)) for c in f] + assert [c[2] for c in flat if c[0] == "X"] == [46] and g.moves("CHAINED") == 0 + + +def test_a_stop_or_a_replacement_drops_the_combos_then(g: Game) -> None: + g.own(0, 46) + g.own(1, 53) + g.own(2, 59) + assert g.lib.mhfu_em_combo(COMBO, 0, 1) and g.lib.mhfu_em_play(ENT, COMBO, 1) + g.frames(5) + g.lib.mhfu_move_stop() + flat = [c for f in played(g, 2 + 2 * int(CLIP_END / SPEED)) for c in f] + assert ("X", ENT, 53, 0, 0) not in flat and g.move("END", "B") == STOPPED + assert g.lib.mhfu_em_combo(COMBO, 0, 1) and g.lib.mhfu_em_play(ENT, COMBO, 1) + g.frames(5) + g.calls() + assert g.lib.mhfu_em_play(ENT, 2, 1) # replaced by another slot's play + flat = [c for f in played(g, 2 + 2 * int(CLIP_END / SPEED)) for c in f] + assert [c[2] for c in flat if c[0] == "X"] == [59] and g.moves("CHAINED") == 0 + + +def test_a_script_enter_of_a_combo_plays_both(g: Game) -> None: + """`enter 16 id 0` as the slot-32 stub turns it: the hub entered, the id filed; the script's + next pair waits until the second move ends.""" + g.own(0, 46) + g.own(1, 53) + assert g.lib.mhfu_em_combo(COMBO, 0, 1) + g.lib.mhfu_host_enter(ENT, *HUB, 1) + assert g.lib.mhfu_em_play(ENT, COMBO, 1) + g.poke(a.ENTITY.SCRIPT_WAKE, "B", 0) + g.calls() + seen = [] + for _ in range(4 + 2 * int(CLIP_END / SPEED)): + seen += script_frame(g) + assert [x[2] for x in seen if x[0] == "X"] == [46, 53] + assert ("E", ENT, *SCRIPT_PAIR, 1) not in seen and g.moves("CHAINED") == 1 + + +def file_play(g: Game, ident: int) -> None: + """What the slot-32 stub stores for an enter of `ident`, whether anything is there or not.""" + base = int(g.lib.mhfu_em_moves()) + for off, v in ( + (a.EM_MOVES.REQ_SLOT, ident), + (a.EM_MOVES.REQ_FORCE, 1), + (a.EM_MOVES.REQ_ENT, ENT), + ): + ctypes.memmove(base + int(off), struct.pack(" None: + g.own(0, 46) + g.own(1, 53) + assert g.lib.mhfu_em_combo(MOVES + 1, 0, 1) + file_play(g, ident) + g.frames(6) + assert g.moves("PLAYS") == 0 and g.lib.mhfu_em_playing() == -1 + + +def test_a_combo_that_is_not_there_is_refused(g: Game) -> None: + g.own(0, 46) + g.own(1, 53) + g.own(2, 59, carrier_sub=3) + assert g.lib.mhfu_em_combo(COMBO, 0, 1) == 1 + assert [g.lib.mhfu_em_combo(i, 0, 1) for i in (IDS, IDS + 1, -1, MOVES - 1, 0)] == [0] * 5 + assert g.lib.mhfu_em_combo(COMBO, 5, 1) == 0 and g.lib.mhfu_em_combo(COMBO, 0, 5) == 0 # empty + assert g.lib.mhfu_em_combo(COMBO, 2, 1) == 0 # its first rides another carrier + assert g.lib.mhfu_em_combo(COMBO, 0, MOVES) == 0 and g.lib.mhfu_em_combo(COMBO, -1, 1) == 0 + assert g.lib.mhfu_em_play(ENT, COMBO + 1, 1) == 0 # no combo there + assert g.lib.mhfu_em_play(ENT, IDS, 1) == 0 and g.lib.mhfu_em_play(ENT, -1, 1) == 0 + assert g.lib.mhfu_em_combo(COMBO, 0, 2) == 1 # its second may ride another + + +def test_clearing_the_moves_clears_the_combos(g: Game) -> None: + g.own(0, 46) + g.own(1, 53) + assert g.lib.mhfu_em_combo(COMBO, 0, 1) + g.lib.mhfu_em_moves_clear() + assert g.lib.mhfu_em_play(ENT, COMBO, 1) == 0 + g.own(0, 46) + g.own(1, 53) + assert g.lib.mhfu_em_play(ENT, COMBO, 1) == 0 # the combo went with the moves + + def test_rule_from_a_pair_plays_an_own_move(g: Game) -> None: g.own(2, 46) g.rule(0, from_mask=1 << 1, min_frames=3, dist_hi=600.0, play_move=3) @@ -683,6 +845,56 @@ def test_keys_have_room(g: Game) -> None: assert g.own(full, 46, keys=[full] * 256) and g.moves("KEY_TOP") == 256 +def test_turns_have_room(g: Game) -> None: + cap, most = a.EM_MOVES.TURNS.count, a.STEER_SPEC.TURNS.count + for slot, start in enumerate(range(0, cap, most)): + assert g.own(slot, 46, turns=[slot + 1] * min(most, cap - start)) + full = slot + 1 + assert g.moves("TURN_TOP") == cap and not g.own(full, 46, turns=[full + 100]) + assert g.own(full, 46, turns=[1] * 7) # held already: no room needed + g.lib.mhfu_em_moves_clear() + assert g.moves("TURN_TOP") == 0 + assert g.own(full, 46, turns=[full] * most) and g.moves("TURN_TOP") == most + + +def base_of(g: Game) -> int: + """The move player's own yaw, the base a code turn moves.""" + at = int(g.lib.mhfu_move_state()) + a.MOVE_STATE.STEER + a.STEER_STATE.BASE + return int(struct.unpack(" None: + """Each slot plays its own code turn out of the shared pool (a run held twice once); with + turn = aim it is scaled to the angle to the target, the hunter 0x4000 to the monster's left.""" + assert g.own(0, 46, turns=(0, 1000, 2000)) and g.own(1, 46, turns=(0, 500, 4000), turn=4) + assert g.own(2, 46, turns=(0, 1000, 2000)) and g.own(3, 46) + assert [g.slot(k, "TURN_AT", "H") for k in range(4)] == [0, 3, 0, 0] + assert [g.slot(k, "TURN_COUNT", "H") for k in range(4)] == [3, 3, 3, 0] + for slot, want in ((0, 2000), (1, 0x4000), (2, 2000), (3, 0)): + g.poke(a.ENTITY.YAW, "H", 0) + assert g.lib.mhfu_em_play(ENT, slot, 1) + g.frames(2 + int(CLIP_END / SPEED) + 2) + assert base_of(g) == want + + +def test_the_registry_carries_aim_out_through_a_chain(g: Game) -> None: + """A slot's aim_out reaches the move player with the move: the hand-over in place leaves the + angle to the target (the hunter, 0x4000 to the monster's left) to turn in over 5 AI frames.""" + g.own(0, 46, after=1, aim_out=5) + g.own(1, 53) + assert g.lib.mhfu_em_play(ENT, 0, 1) + g.frames(2 + int(CLIP_END / SPEED) + 1) + st = int(g.lib.mhfu_move_state()) + a.MOVE_STATE.STEER + total, frames = struct.unpack( + " 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) @@ -920,6 +1132,23 @@ def test_a_flinch_rule_ends_a_running_move(g: Game) -> None: assert ("X", ENT, 59, 0, 0) in g.calls() +def test_a_replaced_flinch_drops_the_combos_then(g: Game) -> None: + """A flinch rule's move replaces the combo's first mid-play: its own play, nothing follows + the first, and the flinch's move ends as any other.""" + g.own(0, 46) + g.own(1, 53) + g.own(2, 59) + assert g.lib.mhfu_em_combo(COMBO, 0, 1) + g.rule(0, on=FLINCH, play_move=3) + g.lib.mhfu_em_play(ENT, COMBO, 0) + g.frames(4) + g.flinch(0b100) + g.frame() + assert g.lib.mhfu_em_playing() == 2 and g.moves("TAG_THEN") == NO + flat = [c for f in played(g, 2 + 2 * int(CLIP_END / SPEED)) for c in f] + assert ("X", ENT, 53, 0, 0) not in flat and g.moves("CHAINED") == 0 + + def test_a_flinch_rule_needs_an_own_move(g: Game) -> None: g.rule(0, on=FLINCH, to_main=3, to_sub=6) g.frame() diff --git a/framework/tests/test_em_vhook_stubs.py b/framework/tests/test_em_vhook_stubs.py index 91634103..b393b874 100644 --- a/framework/tests/test_em_vhook_stubs.py +++ b/framework/tests/test_em_vhook_stubs.py @@ -364,11 +364,32 @@ def test_own_enter_of_an_empty_slot_goes_to_the_hub(stubs: Stubs, mips: Any) -> assert _req(stubs, m) == [0, 0, 0, 1, 0] # counted, no play filed -@pytest.mark.parametrize("sub", [SLOTS, SLOTS + 1, 200, 255]) -def test_own_enter_past_the_registry_goes_to_the_hub(stubs: Stubs, mips: Any, sub: int) -> None: +@pytest.mark.parametrize("sub", [SLOTS, SLOTS + 1, 200, 254, 255]) +def test_a_combo_id_enters_the_hub_and_files_the_play(stubs: Stubs, mips: Any, sub: int) -> None: + """An id from the slots up is a combo's: em75's hub, no main 16, the id filed as the slot is + (the brain refuses an id with no combo), and nothing read past the registry.""" m, got, strays = _run_own(stubs, mips, OWN, sub, valid=(0, 3)) # slot 0 holds a move + assert got == [ENTITY, *HUB, 1] + assert _req(stubs, m) == [ENTITY, sub, 1, 1, (sub + 1) & 0xFF] # own_last is a byte + assert strays == [] + ring = m.read( + RUN_CFG + stubs.lib.cfg_ring() + 4 * m.read(RUN_CFG + stubs.lib.cfg_ring_idx())[0] + ) + assert ring == [(1 << 16) | (OWN << 8) | sub] + + +def test_a_combo_id_with_no_registry_goes_to_the_hub(stubs: Stubs, mips: Any) -> None: + m, got, _ = _run_own(stubs, mips, OWN, SLOTS + 3, moves=0) assert got == [ENTITY, *HUB, 1] and _req(stubs, m) == [0, 0, 0, 1, 0] - assert strays == [] # an id past the slots reads slot 0, never past the registry + + +def test_a_second_combo_enter_replaces_the_play(stubs: Stubs, mips: Any) -> None: + m, _, _ = _run_own(stubs, mips, OWN, SLOTS + 3, valid=(3,)) + m.set_reg("a0", ENTITY) + for reg, v in (("a1", OWN), ("a2", 3), ("a3", 1), ("ra", CALLER), ("sp", 0x3F000)): + m.set_reg(reg, v) + m.run(STUB, CALLER, count=2000) + assert _req(stubs, m) == [ENTITY, 3, 1, 2, 4] # a slot's enter after a combo's: the slot's def test_own_enter_without_a_registry_goes_to_the_hub(stubs: Stubs, mips: Any) -> None: @@ -377,9 +398,10 @@ def test_own_enter_without_a_registry_goes_to_the_hub(stubs: Stubs, mips: Any) - @pytest.mark.parametrize("main", [0, 1, 4, 7, 15, 17, 255]) -def test_any_other_main_is_left_alone(stubs: Stubs, mips: Any, main: int) -> None: - m, got, _ = _run_own(stubs, mips, main, 3, valid=(3,)) - assert got == [ENTITY, main, 3, 1] and _req(stubs, m) == [0, 0, 0, 0, 0] +@pytest.mark.parametrize("sub", [3, SLOTS, 255]) +def test_any_other_main_is_left_alone(stubs: Stubs, mips: Any, main: int, sub: int) -> None: + m, got, _ = _run_own(stubs, mips, main, sub, valid=(3,)) + assert got == [ENTITY, main, sub, 1] and _req(stubs, m) == [0, 0, 0, 0, 0] def test_a_second_own_enter_replaces_the_play(stubs: Stubs, mips: Any) -> None: diff --git a/framework/tests/test_move.py b/framework/tests/test_move.py index edc264bd..0451eb62 100644 --- a/framework/tests/test_move.py +++ b/framework/tests/test_move.py @@ -42,7 +42,7 @@ class Move(ctypes.Structure): ("host_attacks", ctypes.c_uint8), ("force", ctypes.c_uint8), ("hold_script", ctypes.c_uint8), - ("_pad", ctypes.c_uint8), + ("aim_out", ctypes.c_uint8), ("start", ctypes.c_uint16), ("stop", ctypes.c_uint16), ] @@ -153,6 +153,8 @@ def lib(host_lib: Callable[..., ctypes.CDLL]) -> ctypes.CDLL: lib.mhfu_move_react.argtypes = [ctypes.c_int, ctypes.c_uint32, ctypes.c_void_p, ctypes.c_void_p] lib.host_react_enter.argtypes = [ctypes.c_uint32] * 3 lib.host_chain.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int] + lib.mhfu_steer_bearing.restype = ctypes.c_uint16 + lib.mhfu_steer_bearing.argtypes = [ctypes.c_float, ctypes.c_float] lib.mhfu_move_target_pos.restype = ctypes.c_uint32 lib.mhfu_move_target_pos.argtypes = [ctypes.c_uint32] return lib @@ -413,6 +415,8 @@ class Spec(ctypes.Structure): ("sample_count", ctypes.c_uint16), ("lunge", Lunge), ("samples", Sample * a.STEER_SAMPLES), + ("turn_count", ctypes.c_uint16), + ("turns", ctypes.c_int16 * a.STEER_TURNS), ] @@ -424,10 +428,11 @@ def spec_of( keys: tuple[int, ...] = (), samples: tuple[Point, ...] = (), lunge: tuple[int, int, float, float, float, float] | None = None, + turns: tuple[int, ...] = (), **kw: int, ) -> Spec: """A spec: turn `keys`, travel `samples` (world units), a `lunge` as (lo, hi, near, span, base, - range), and steer fields by name.""" + range), a code turn `turns` (YAW units, cumulative) and steer fields by name.""" spec = Spec() g.lib.mhfu_steer_init_spec(ctypes.byref(spec)) for k, v in kw.items(): @@ -440,6 +445,9 @@ def spec_of( spec.samples[i].x, spec.samples[i].z = steering.sample(x), steering.sample(z) if lunge: spec.lunge = Lunge(*lunge) + spec.turn_count = len(turns) + for i, v in enumerate(turns): + spec.turns[i] = v return spec @@ -448,10 +456,11 @@ def steer( keys: tuple[int, ...] = (), samples: tuple[Point, ...] = (), lunge: tuple[int, int, float, float, float, float] | None = None, + turns: tuple[int, ...] = (), **kw: int, ) -> None: """The spec the next move takes (spec_of's arguments).""" - g.lib.mhfu_move_steer(ctypes.byref(spec_of(g, keys, samples, lunge, **kw))) + g.lib.mhfu_move_steer(ctypes.byref(spec_of(g, keys, samples, lunge, turns, **kw))) def yaw(g: Game) -> int: @@ -465,6 +474,10 @@ def test_steer_spec_layout() -> None: 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 ( + Spec.turn_count.offset == a.STEER_SPEC.TURN_COUNT + and Spec.turns.offset == a.STEER_SPEC.TURNS + ) 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 @@ -1012,11 +1025,11 @@ def chained(g: Game, entry: int = 53, times: int = 1, **kw: int) -> None: """The next `times` ends are handed over in place to a move on `entry` (em_vhook's choke point), the move player's other arguments steer's.""" mv = Move(entry=entry, carrier_main=0, carrier_sub=1, back_main=NO_PAIR) + fields = {f for f, _ in Move._fields_} for k, v in kw.items(): - if k in ("keys", "samples", "lunge"): - continue - setattr(mv, k, v) - spec = spec_of(g, kw.get("keys", ()), kw.get("samples", ()), None) # type: ignore[arg-type] + if k in fields: + setattr(mv, k, v) + spec = spec_of(g, **{k: v for k, v in kw.items() if k not in fields}) # type: ignore[arg-type] g.lib.host_chain(ctypes.byref(mv), ctypes.byref(spec), times) @@ -1205,3 +1218,210 @@ def test_a_chained_steps_travel_goes_along_base_not_the_drawn_yaw(g: Game) -> No g.frame() dx, dz = world(0x1000, 0, 100) assert at(g) == pytest.approx((start[0] + dx, start[1] + dz), abs=0.02) + + +# --- the code turn, aim, and the residual a hand-over spreads + +TURN = 0x10000 + + +def turn_curve(turns: tuple[int, ...], cursor: float, start: int = 0) -> float: + """The code turn at clip frame `cursor`: straight lines every STEER_KEY_STEP frames, held past + the last value.""" + u = (cursor - start) / steering.STEP + if u <= 0: + return float(turns[0]) + if u >= len(turns) - 1: + return float(turns[-1]) + i = int(u) + return turns[i] + (turns[i + 1] - turns[i]) * (u - i) + + +def round_away(v: float) -> int: + return int(v + 0.5) if v >= 0 else -int(-v + 0.5) + + +def base_of(g: Game) -> int: + return int(g.steer_state("BASE", "H")[0]) + + +def playing(g: Game, frames: int) -> list[tuple[float, int]]: + """(the cursor the step saw, base) after each of `frames` AI frames from the first playing + one.""" + out = [] + for _ in range(frames): + g.frame() + if int(g.state("FRAMES")[0]) == 0 or g.state("STATE", "B")[0] == 1: + continue + out.append((g.steer_state("CURSOR")[0], base_of(g))) + return out + + +CODE_TURN = (0, 300, 900, 2400, 5000, 8000, 9000, 9100) + + +@pytest.mark.parametrize( + ("speed", "blend", "start"), [(2.0, 0, 0), (2.8, 0, 0), (2.0, 3, 0), (2.0, 0, 6)] +) +def test_the_code_turn_moves_base_along_its_curve( + g: Game, speed: float, blend: int, start: int +) -> None: + """Scale 1: base is where the curve puts it at the cursor, whatever the cursor's rate, the + blend's hold and the start; the keys ride on top and are not part of it.""" + g.lib.host_blend(blend) + g.speed = speed + g.poke(a.ENTITY.YAW, "H", 0x1000) + steer(g, turns=CODE_TURN, keys=K1) + g.play(46, start=start) + seen = playing(g, 2 + blend + int((CLIP_END - start) / speed)) + assert len(seen) > 8 + for cursor, base in seen: + assert base == (0x1000 + round_away(turn_curve(CODE_TURN, cursor, start))) & 0xFFFF + assert seen[-1][1] == (0x1000 + CODE_TURN[-1]) & 0xFFFF + held = [b for c, b in seen if c == start] + assert (len(held) > 0) == (blend > 0) and all(b == 0x1000 for b in held) + + +def test_a_loop_carries_the_code_turn_on(g: Game) -> None: + steer(g, turns=(0, 100, 200, 300)) + g.play(46) + playing(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 base_of(g) == 300 # the rest of the curve, then the part from the loop start + g.frame() + assert base_of(g) == 300 + 100 # the cursor is at 2 again + + +def bearing_of(g: Game, x: float, z: float) -> int: + return int(g.lib.mhfu_steer_bearing(x, z)) + + +SHAPE = (0, 3, 20, 55, 90, 100) +"""How a code turn grows, in percent of its total.""" + + +@pytest.mark.parametrize("degrees", [30, -30, 150, -150]) +@pytest.mark.parametrize("total", [0x4000, -0x2000, 0x7FFF]) +def test_aim_turns_exactly_the_angle_to_the_target(g: Game, degrees: int, total: int) -> None: + """s is the angle to the target on the first frame over the curve's total: any curve (its shape, + size and sign) turns the move exactly that, and the target moving later changes nothing.""" + turns = tuple(total * k // 100 for k in SHAPE) + g.poke(a.ENTITY.YAW, "H", 0x1000) + th = math.radians(degrees) + 0x1000 / TURN * math.tau + x, z = 1000 * math.sin(th), 1000 * math.cos(th) + aim_at(g, x, z) + steer(g, turns=turns, turn=4) + g.play(46) + playing(g, 3) + aim_at(g, -z, x) # read on the first frame only + seen = playing(g, 2 + int(CLIP_END / SPEED)) + want = bearing_of(g, x, z) + assert base_of(g) == want and seen[-1][1] == want + assert abs(signed(base_of(g) - 0x1000) - round(degrees * TURN / 360)) <= 1 + assert g.steer_state("TURN_SCALE")[0] == pytest.approx( + signed(want - 0x1000) / (turns[-1] - turns[0]), rel=1e-6 + ) + + +def test_aim_follows_the_curve_in_proportion(g: Game) -> None: + turns = tuple(100 * k for k in SHAPE) + aim_at(g, 1000.0, 0.0) # 0x4000 + steer(g, turns=turns, turn=4) + g.play(46) + for cursor, base in playing(g, 2 + int(CLIP_END / SPEED)): + assert base == round_away(0x4000 * turn_curve(turns, cursor) / turns[-1]) & 0xFFFF + + +@pytest.mark.parametrize("turns", [(0, 0, 0), ()]) +def test_aim_with_no_total_turns_nothing(g: Game, turns: tuple[int, ...]) -> None: + g.poke(a.ENTITY.YAW, "H", 0x1000) + aim_at(g, 1000.0, 0.0) + steer(g, turns=turns, turn=4) + g.play(46) + playing(g, 20) + assert base_of(g) == 0x1000 and g.steer_state("TURN_SCALE")[0] == 0.0 + + +def test_aim_at_a_target_on_the_monster_turns_nothing(g: Game) -> None: + g.poke(a.ENTITY.POSITION, "fff", 5.0, 0.0, 6.0) + aim_at(g, 5.0, 6.0) + steer(g, turns=(0, 1000, 2000), turn=4) + g.play(46) + playing(g, 10) + assert base_of(g) == 0 + + +def test_the_aim_is_the_target_not_the_hunter(g: Game) -> None: + hunter_at(g, 0.0, 1000.0) + aim_at(g, 1000.0, 0.0) + steer(g, turns=(0, 4000, 8000), turn=4) + g.play(46) + playing(g, 12) + assert base_of(g) == 0x4000 + + +def two_with_aim_out(g: Game, out: int, **chain: object) -> tuple[int, int, int]: + """A step with aim_out `out`, ended at clip frame 20, hands over in place to one on entry 53; + runs to the switch: (base there, the angle to the target then, the successor's first frame).""" + g.poke(a.ENTITY.YAW, "H", 0x0800) + aim_at(g, 1000.0, 0.0) + steer(g) + g.play(46, stop=20, aim_out=out) + chained(g, 53, **chain) # type: ignore[arg-type] + while g.state("STARTED")[0] != 2: + g.frame() + base = base_of(g) + return base, signed(bearing_of(g, 1000.0, 0.0) - base), int(g.state("FRAMES")[0]) + + +@pytest.mark.parametrize("out", [1, 6, 10]) +def test_aim_out_slides_the_residual_over_the_successors_first_frames(g: Game, out: int) -> None: + base, left, _ = two_with_aim_out(g, out) + assert left == 0x4000 - 0x0800 and g.steer_state("OUT_FRAMES", "H") == (out,) + got = [base_of(g)] + for _ in range(out + 4): + g.frame() + got.append(base_of(g)) + want = [base] + for f in range(1, out + 5): + want.append((want[-1] + (steering_share(left, out, f - 1) if f <= out else 0)) & 0xFFFF) + assert got == want and got[-1] == (base + left) & 0xFFFF + assert signed(got[1] - got[0]) == steering_share(left, out, 0) + + +def steering_share(total: int, frames: int, frame: int) -> int: + """Frame `frame`'s share of `total` over `frames`: the shares sum to it.""" + return total * (frame + 1) // frames - total * frame // frames + + +def test_aim_out_goes_on_top_of_the_successors_own_steering(g: Game) -> None: + base, left, _ = two_with_aim_out(g, 5, keys=(), turn=3, total=0x0600, frames=10) + for _ in range(20): + g.frame() + assert base_of(g) == (base + left + 0x0600) & 0xFFFF + + +def test_aim_out_needs_a_hand_over_in_place(g: Game) -> None: + """Without a successor in place the residual stays where it was: a plain next move starts at + its own first frame and takes nothing.""" + g.poke(a.ENTITY.YAW, "H", 0x0800) + aim_at(g, 1000.0, 0.0) + g.play(46, stop=20, aim_out=5) + for _ in range(2 + 12): + g.frame() + assert g.state("STATE", "B") == (DONE,) + g.play(53) + for _ in range(8): + g.frame() + assert g.steer_state("OUT_FRAMES", "H") == (0,) and base_of(g) == 0x0800 + + +def test_a_move_without_aim_out_hands_over_without_a_residual(g: Game) -> None: + base, _, _ = two_with_aim_out(g, 0) + assert g.steer_state("OUT_FRAMES", "H") == (0,) + for _ in range(6): + g.frame() + assert base_of(g) == base diff --git a/framework/tests/test_own_layout.py b/framework/tests/test_own_layout.py index 34ede1ff..b83ebc04 100644 --- a/framework/tests/test_own_layout.py +++ b/framework/tests/test_own_layout.py @@ -25,8 +25,10 @@ ("EM_KEYS", 8000), ("EM_SAMPLES", 901), ("EM_LUNGES", 33), + ("EM_TURNS", 2001), ("STEER_KEYS", 301), ("STEER_SAMPLES", 201), + ("STEER_TURNS", 201), ], ) def test_a_raised_capacity_agrees_with_the_c_structs( diff --git a/packages/mhfu/src/mhfu/addresses.toml b/packages/mhfu/src/mhfu/addresses.toml index 38919254..5d3ac501 100644 --- a/packages/mhfu/src/mhfu/addresses.toml +++ b/packages/mhfu/src/mhfu/addresses.toml @@ -847,6 +847,7 @@ 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)" } +AIM_OUT = { offset = 0x33, type = "u8", doc = "AI frames; 0 none. When this move hands over in place (a chain), the angle still to turn to the target at the switch is spread linearly into the entity's own yaw over the successor's first AIM_OUT AI frames, on top of its own steering" } 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" } @@ -894,7 +895,7 @@ EVENTS = { offset = "next", type = "ptr", doc = "the monster-event block (struct doc = "how a move turns and stops (steer.h mhfu_steer_t)" size = 0x10 [struct.STEER_PARAMS.fields] -TURN = { offset = 0x0, type = "u8", doc = "0 still, 1 toward the target, 2 away from it, 3 TOTAL over FRAMES" } +TURN = { offset = 0x0, type = "u8", doc = "0 still, 1 toward the target, 2 away from it, 3 TOTAL over FRAMES, 4 aim: the code turn scaled to turn exactly the angle to the target at the move's first playing frame" } 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 at clip speed 1 (TURN_TOWARD's step; the charge steers 64); the move player multiplies it by part 0's CLIP_BLOCK.SPEED / 2" } TOTAL = { offset = 0x4, type = "s32", doc = "fixed: YAW units over the move or the window, under one turn either way" } @@ -934,6 +935,8 @@ KEYS = { offset = 0x18, type = "u16[STEER_KEYS]", doc = "YAW every STEER_KEY_STE 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" } +TURN_COUNT = { offset = "next", type = "u16", doc = "TURNS in use; 0: the move has no code turn" } +TURNS = { offset = "next", type = "s16[STEER_TURNS]", doc = "the move's code turn, the donor's entity-yaw curve: value k is the YAW units turned since the move's START at clip frame START + k * STEER_KEY_STEP, cumulative; each AI frame the entity's own yaw moves by the curve's change since the cursor's last frame, times the scale (1; for aim, the angle to the target over the curve's total), held past the last; the clip's facing is KEYS, which ride on top" } [struct.STEER_STATE] doc = "the move player's steering (steer.h mhfu_steer_state_t), in MOVE_STATE" @@ -951,6 +954,9 @@ LUNGE_Z = { offset = "next", type = "f32", doc = "and in world z" } KEEP = { offset = "next", type = "u8", doc = "1: the step started in place (a chain), so its first playing frame keeps BASE; 0: it sets BASE to YAW" } BLEND = { offset = "next", type = "u16", doc = "part 0's CLIP_BLOCK.BLEND right after the dispatch: the frames the ramp falls over; 0: none" } RAMP = { offset = "next", type = "s16", doc = "YAW units the step's first playing frame adds on top of BASE and the curve so that YAW stays continuous (YAW before - BASE - the curve at START); it falls to 0 linearly as the part 0 BLEND counts down" } +TURN_SCALE = { offset = "next", type = "f32", doc = "the code turn's factor, set on the first playing frame: 1, or for aim the angle to the target over the curve's total (0 for a total of 0)" } +OUT_TOTAL = { offset = "next", type = "s16", doc = "the angle to the target left at a hand-over from a move with AIM_OUT, to spread into BASE" } +OUT_FRAMES = { offset = "next", type = "u16", doc = "AI frames OUT_TOTAL is spread over, from the step's first playing frame; 0: none" } [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" @@ -962,6 +968,8 @@ KEY_AT = { offset = "next", type = "u16", doc = "its first turn key in EM_MOVES. 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" } +TURN_AT = { offset = "next", type = "u16", doc = "its first code-turn value in EM_MOVES.TURNS" } +TURN_COUNT = { offset = "next", type = "u16", doc = "its code-turn values; 0: it has no code turn" } 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" } @@ -969,6 +977,13 @@ AFTER = { offset = "next", type = "u8", doc = "slot played when this move ends o 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_COMBO] +doc = "a combo in em_vhook's registry (em_vhook.h mhfu_em_combo_t): an AI script's `enter OWN_MOVE_MAIN id` with id from OWN_MOVES up plays FIRST on em75's hub, then THEN in place" +size = 0x2 +[struct.EM_COMBO.fields] +FIRST = { offset = 0x0, type = "u8", doc = "the slot played first, on the hub; 0xFF: no combo here" } +THEN = { offset = 0x1, type = "u8", doc = "the slot that follows it in place when FIRST ends" } + [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 = "auto" @@ -983,11 +998,15 @@ CHAINED = { offset = 0x18, type = "u32", doc = "of them by AFTER" } KEY_TOP = { offset = 0x1C, type = "u32", doc = "KEYS in use" } SAMPLE_TOP = { offset = "next", type = "u32", doc = "SAMPLES in use" } LUNGE_TOP = { offset = "next", type = "u32", doc = "LUNGES in use" } +TURN_TOP = { offset = "next", type = "u32", doc = "TURNS in use" } +TAG_THEN = { offset = "next", type = "u32", doc = "the slot that follows the move the move player runs when it is the first of a combo (its THEN, taken in place of its AFTER); 0xFF otherwise. Set by the play of a combo and cleared by any other play" } 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" } +COMBOS = { offset = "next", type = "EM_COMBO[256]", doc = "the combos by enter id: entry k is id k, so only OWN_MOVES..255 are read (a table of 256 whatever OWN_MOVES is, so the two cannot drift)" } 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" } +TURNS = { offset = "next", type = "s16[EM_TURNS]", doc = "every move's code turn, a value every STEER_KEY_STEP clip frames from START, cumulative YAW units" } SCRATCH = { offset = "next", type = "STEER_SPEC", doc = "the steering handed to the move player for the move it plays next" } [struct.FLINCH_PART] @@ -1212,14 +1231,16 @@ doc = "MOVE_STATE.END, why the move player's move ended: its clip ended (the car names = ["clip", "back", "pair", "stopped", "replaced", "refused", "lost", "wall", "stuck", "reaction"] [const] -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" } +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) instead and plays that move at the next AI step; a from OWN_MOVES up is a combo id: the hub, and the combo's first move played with its then in place" } 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" } +OWN_MOVES = { value = 160, 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; the enter ids from OWN_MOVES to 255 name combos (EM_MOVES.COMBOS)" } 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_TURNS = { value = 1024, doc = "code-turn values all own moves share in EM_MOVES.TURNS, a run held twice once (provisional: the port derivation sizes it)" } 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_TURNS = { value = 132, doc = "code-turn values one move holds (STEER_SPEC.TURNS): one every STEER_KEY_STEP clip frames, as STEER_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 c59aeafb5e5011ed3c333b1799b9bd07ea2c3eaf Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:28:05 +0100 Subject: [PATCH 5/6] mhfu, mhfu-port: code turn, aim, aim-out and combos in the manifest, the moves module and the live steer Manifest: Move.code_turn (s16 YAW units every 2 clip frames) and Move.aim_out, and Steer.turn = aim, which needs a code_turn; aim is a derived mode, not one an editor offers (DERIVED_TURNS). mhfu_port.moves counts the code turns in Pools by the registry's rule, refuses a code turn past its capacity, the clip or an s16, an aim without one, more combos than the table holds and a combo whose first is off the hub, and writes steer.code_turn, turn = aim, aim_out and combos = { { id, first, then } }. combos(m) lists the (first, then) own-move pairs of the [brain.pairs] lists, combo_id(m, first, then) their enter ids. mhfu.steering holds the one conversion of the code turn; mhfu.live.moves packs it, aim and aim_out; mhfu_port.lua registers the combos after the moves (mhfu.em_combo). Co-Authored-By: Claude Sonnet 5.5 --- framework/lua/lib/mhfu_port.lua | 28 ++- framework/tests/test_em_brain.py | 62 +++++-- framework/tests/test_mhfu_port.py | 85 +++++++++ packages/mhfu-port/src/mhfu_port/manifest.py | 32 +++- packages/mhfu-port/src/mhfu_port/moves.py | 135 +++++++++++--- packages/mhfu-port/tests/test_manifest.py | 38 ++++ packages/mhfu-port/tests/test_moves.py | 175 ++++++++++++++++++- packages/mhfu/src/mhfu/live/moves.py | 20 ++- packages/mhfu/src/mhfu/steering.py | 38 +++- packages/mhfu/tests/test_moves.py | 31 ++++ packages/mhfu/tests/test_steering.py | 22 +++ 11 files changed, 608 insertions(+), 58 deletions(-) diff --git a/framework/lua/lib/mhfu_port.lua b/framework/lua/lib/mhfu_port.lua index 64f7d2e2..2c730f93 100644 --- a/framework/lua/lib/mhfu_port.lua +++ b/framework/lua/lib/mhfu_port.lua @@ -207,9 +207,9 @@ function P.layout(name) end --- A port's moves and rules from mods/lib/_moves.lua, which `mhfu-port inject` generates ---- from the manifest with the clips module: { moves = {...}, rules = {...}, vars = {name = index}, ---- signals = {name = index}, sever_below = pct, natural_rage = false, fatigue = {...}, ---- charge = {...} }. Empty, logged once, without it. +--- from the manifest with the clips module: { moves = {...}, combos = { {id, first, then}, ... }, +--- rules = {...}, vars = {name = index}, signals = {name = index}, sever_below = pct, +--- natural_rage = false, fatigue = {...}, charge = {...} }. Empty, logged once, without it. function P.behaviour(name) local ok, t = pcall(require, name .. "_moves") if ok and type(t) == "table" then return t end @@ -243,8 +243,12 @@ P.is_own = is_own -- 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 turn keeps to or a fixed turn --- spreads across, `travel` the move's own travel by code, `lunge` toward the target). --- Default: the manifest's (P.behaviour) +-- spreads across, `travel` the move's own travel by code, `code_turn` its own yaw's +-- curve, `lunge` toward the target); its `aim_out` spreads the angle left to the target +-- over the next move's first AI frames. Default: the manifest's (P.behaviour) +-- combos { {id, first, then}, ... }: enter id `id` (mhfu.addr.OWN_MOVES..255, the brain +-- splice's `enter 16 id 0`) plays own move `first` on the hub, then `then` in place +-- (mhfu.em_combo); moves by name, registered after the moves; default: the manifest's -- 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: -- the manifest's @@ -270,6 +274,7 @@ function P.define(spec) species = spec.species, clips = spec.clips or P.layout(spec.name), moves = spec.moves or made.moves or {}, + combos = spec.combos or made.combos or {}, tip = spec.tip or made.tip, fatigue = spec.fatigue or made.fatigue, charge = spec.charge or made.charge, @@ -754,6 +759,19 @@ function Port:_arm_native() end end if made > 0 then log("[port:%s] %d own move(s) registered", self.name, made) end + local combos = 0 + for _, c in ipairs(self.combos) do + local first, after = self._slot[c[2]], self._slot[c[3]] + if first ~= nil and after ~= nil and mhfu.em_combo ~= nil + and mhfu.em_combo(c[1], first, after) then + combos = combos + 1 + else + log("[port:%s] combo %s ('%s', '%s') not registered: no such own move, a first off the " + .. "hub, an id past the table, or a framework without combos", + self.name, tostring(c[1]), tostring(c[2]), tostring(c[3])) + end + end + if combos > 0 then log("[port:%s] %d combo(s) registered", self.name, combos) end end for i = 1, MAX_RULES do local r = self._rules[i] diff --git a/framework/tests/test_em_brain.py b/framework/tests/test_em_brain.py index 0545b30c..181f3631 100644 --- a/framework/tests/test_em_brain.py +++ b/framework/tests/test_em_brain.py @@ -955,29 +955,31 @@ def test_the_registry_places_runs_where_pools_does(g: Game) -> None: 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) + spin = [10, -20, 30, -40, 50] 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)), + ([1, 2, 3, 4, 5, 6], run[:5], None, spin), + ([1, 2, 3, 4, 5, 6], run[:5], lunge, spin), + ([3, 4, 5], run[2:5], lunge, spin[1:4]), + ([7, 7], [], (0, 20, 100.0, 400.0, -300.0, 901.0), []), + ([6, 7], run, lunge, [-20, 30, 99]), + ([], [], None, []), + ([1, 2, 3, 4, 5, 6, 7], run[:5], (0, 20, 100.0, 400.0, -300.0, 901.0), spin + [60]), ] pools = Pools() - for slot, (keys, samples, lunge_) in enumerate(spec): + for slot, (keys, samples, lunge_, turns) 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_) + want = pools.add(keys, samples, record, turns) + assert g.own(slot, 46, keys=keys, samples=samples, lunge=lunge_, turns=turns) got = ( g.slot(slot, "KEY_AT", "H"), g.slot(slot, "SAMPLE_AT", "H"), g.slot(slot, "LUNGE_AT"), + g.slot(slot, "TURN_AT", "H"), ) - 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 + assert got == (want[0], want[1], NO if want[2] is None else want[2], want[3]) + tops = (g.moves("KEY_TOP"), g.moves("SAMPLE_TOP"), g.moves("LUNGE_TOP"), g.moves("TURN_TOP")) + assert tops == (len(pools.keys), len(pools.samples), len(pools.lunges), len(pools.turns)) + assert tops == (8, 13, 2, 14) # 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' @@ -1027,6 +1029,38 @@ def test_the_registry_holds_what_a_ports_capacity_check_counts(g: Game) -> None: assert tops == (12, 7, 2) # of 24 keys, 12 samples and 4 lunges, each run held once +def test_the_registry_holds_what_the_check_counts_for_shared_turns_and_combos(g: Game) -> None: + """A port whose own moves share code-turn runs (one inside another) and are paired into + combos, registered as `mhfu_port.lua` does: the tops are `moves.pools`', every combo is + accepted at the id `moves.combo_id` gives it, and playing one plays its two moves in order.""" + lay = layout.Layout({}, frames={9: 100}) + runs = {"a": [0, 100, 250], "b": [0, 100], "c": [0, 100, 250, 400], "d": [7, 8], "e": []} + text = PORT + for name, run in runs.items(): + text += f"[moves.{name}]\nanim = 9\ncarrier = [0, 2]\n" + text += f"code_turn = {run}\n" if run else "" + text += "[brain]\ndonor = 23\nsplice = 'x'\nentry = 'y'\n[brain.pairs]\n" + text += "'7,1' = ['a', 'b']\n'7,2' = ['c', 'a']\n'7,3' = ['a', 'b']\n'7,4' = ['e', 'd']\n" + text += "'7,5' = [[1, 2], 'a']\n" + m = manifest.loads(text) + assert moves.problems(m, lay) == [] + held = moves.pools(m, lay) + at = moves.slots(m) + for name, slot in at.items(): + assert g.own(slot, 40 + slot, turns=m.moves[name].code_turn or ()) + assert moves.combos(m) == [("a", "b"), ("c", "a"), ("e", "d")] + ids = [moves.combo_id(m, *c) for c in moves.combos(m)] + assert ids == [MOVES, MOVES + 1, MOVES + 2] + for (first, then), ident in zip(moves.combos(m), ids, strict=True): + assert ident is not None and g.lib.mhfu_em_combo(ident, at[first], at[then]) == 1 + assert g.moves("TURN_TOP") == len(held.turns) == 9 # b inside a; c is not a's run extended + assert [g.slot(at[n], "TURN_AT", "H") for n in "abcde"] == [0, 0, 3, 7, 0] + ident = moves.combo_id(m, "c", "a") + assert ident is not None and g.lib.mhfu_em_play(ENT, ident, 1) + flat = [c for f in played(g, 2 + 2 * int(CLIP_END / SPEED)) for c in f] + assert [c[2] for c in flat if c[0] == "X"] == [40 + at["c"], 40 + at["a"]] + + 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 8f6d073e..8b581ff0 100644 --- a/framework/tests/test_mhfu_port.py +++ b/framework/tests/test_mhfu_port.py @@ -650,6 +650,91 @@ def test_steer_travel_reaches_the_registry(lua: Any) -> None: lua.execute(STEER_TRAVEL) +STEER_AIM = """ +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 code turn, aim and aim_out reach the registry as they are +package.loaded.z_moves = { + moves = { + wheel = { entry = 9, slot = 0, aim_out = 6, + steer = { turn = "aim", code_turn = "00000bb8fffe7fff8000" } }, + }, + rules = {}, +} +local z = P.define{ name = "z", species = 75 } +z.ent = ENT +ticks(1) +local w = SEAM.moves[0] +assert(w.aim_out == 6 and w.steer.turn == "aim" and w.steer.code_turn == "00000bb8fffe7fff8000") +""" + + +def test_code_turn_and_aim_reach_the_registry(lua: Any) -> None: + memory(lua) + lua.execute(STEER_AIM) + + +COMBOS = """ +local SEAM = { moves = {}, combos = {}, clears = 0 } +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, SEAM.combos, SEAM.clears = {}, {}, SEAM.clears + 1 + return true +end +function mhfu.em_combo(id, first, after) + if id < mhfu.addr.OWN_MOVES then return false end -- the registry's refusal of a slot's id + SEAM.combos[id] = { first, after } + return true +end +local P = require("mhfu_port") +local OWN = mhfu.addr.OWN_MOVES + +-- a moves module's combos are registered by slot, after the moves; one that names a move the port +-- does not have, or that the registry refuses, is dropped and said once +package.loaded.z_moves = { + moves = { stamp = { entry = 46, slot = 0 }, spin = { entry = 9, slot = 1 }, + dash = { entry = 20, slot = 3 }, lunge = { main = 1, sub = 4 } }, + combos = { { OWN, "stamp", "spin" }, { OWN + 1, "spin", "ghost" }, { OWN + 2, "dash", "stamp" }, + { OWN + 3, "lunge", "spin" }, { 3, "stamp", "spin" } }, + rules = {}, +} +local z = P.define{ name = "z", species = 75 } +z.ent = ENT +ticks(1) +assert(SEAM.combos[OWN][1] == 0 and SEAM.combos[OWN][2] == 1) +assert(SEAM.combos[OWN + 2][1] == 3 and SEAM.combos[OWN + 2][2] == 0) +assert(SEAM.combos[OWN + 1] == nil and SEAM.combos[OWN + 3] == nil and SEAM.combos[3] == nil) +assert(count("3 combo%(s%) registered") == 0 and count("2 combo%(s%) registered") == 1) +assert(count("combo %d+ %('spin', 'ghost'%) not registered") == 1) +assert(count("combo %d+ %('lunge', 'spin'%) not registered") == 1) +assert(count("combo 3 %('stamp', 'spin'%) not registered") == 1) + +-- a redefine (hot reload) clears the registry and registers again +z = P.define{ name = "z", species = 75 } +z.ent = ENT +ticks(1) +assert(SEAM.clears == 2 and SEAM.combos[OWN] ~= nil and SEAM.combos[OWN + 2] ~= nil) + +-- a framework without combos, and a module without any, register none +mhfu.em_combo = nil +package.loaded.z_moves = { moves = { stamp = { entry = 46, slot = 0 } }, rules = {} } +z = P.define{ name = "z", species = 75 } +z.ent = ENT +ticks(1) +assert(next(SEAM.combos) == nil) +""" + + +def test_combos_are_registered_after_the_moves(lua: Any) -> None: + memory(lua) + lua.execute(COMBOS) + + FATIGUE = """ local SEAM = { moves = {}, fatigue = "unset", calls = 0 } function mhfu.em_installed() return true end diff --git a/packages/mhfu-port/src/mhfu_port/manifest.py b/packages/mhfu-port/src/mhfu_port/manifest.py index 9ee3ec28..f86d0e66 100644 --- a/packages/mhfu-port/src/mhfu_port/manifest.py +++ b/packages/mhfu-port/src/mhfu_port/manifest.py @@ -40,8 +40,11 @@ SKINS: tuple[Skin, ...] = typing.get_args(Skin) Shape = Literal["sphere", "capsule"] SHAPES: tuple[Shape, ...] = typing.get_args(Shape) -Turn = Literal["clip", "still", "hunter", "away", "fixed"] -TURNS: tuple[Turn, ...] = typing.get_args(Turn) +Turn = Literal["clip", "still", "hunter", "away", "fixed", "aim"] +DERIVED_TURNS: tuple[Turn, ...] = ("aim",) +"""Turn modes only a derivation makes: they need a `code_turn`, so an editor does not offer them.""" +TURNS: tuple[Turn, ...] = tuple(t for t in typing.get_args(Turn) if t not in DERIVED_TURNS) +"""The turn modes a person picks.""" # shared with the behaviour graph, which sits under this module and defines them ManifestError = behaviour.ManifestError @@ -217,7 +220,9 @@ class Steer: turn: Turn = "clip" """`clip` follows the clip's turn, `hunter` and `away` turn toward or away from the target (what the monster aims at: its AI target, the player without one) at `rate`, `fixed` turns - `angle` degrees over `frames` AI frames or across `window` by cursor, `still` not at all.""" + `angle` degrees over `frames` AI frames or across `window` by cursor, `still` not at all. + `aim` scales the move's `code_turn` to turn exactly the angle to the target at its first + playing frame, and needs one.""" rate: int | None = None """YAW units an AI frame at clip speed 1 for `hunter` and `away`, times the speed / 2 in play; None: the host charge's.""" @@ -288,6 +293,14 @@ class Move: 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 + code_turn: list[int] | None = None + """An own move's code turn, the donor's entity-yaw curve: cumulative YAW units (a signed 16 + bits each) every 2 clip frames from `start`. Each AI frame the entity's own yaw moves by the + curve's change since the last cursor, as `travel` moves it, the clip's facing riding on top; + `steer.turn = "aim"` scales it to the angle to the target.""" + aim_out: int = 0 + """AI frames: when this move hands over to its `after` in place, the angle still to turn to the + target at the switch is spread over the successor's first `aim_out` AI frames; 0: none.""" carrier: tuple[int, int] | None = None host_attacks: bool = False label: str = "" @@ -714,6 +727,13 @@ def _own_move(mv: Move, w: str) -> None: _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") + _need(s.turn != "aim" or bool(mv.code_turn), f"{w}.steer", "turn = aim needs a code_turn") + _need( + mv.code_turn is None or all(-0x8000 <= v < 0x8000 for v in mv.code_turn), + w, + "code_turn values are signed 16 bits", + ) + _need(0 <= mv.aim_out <= 0xFF, w, "aim_out is 0 to 255 AI frames") if s.window is not None: _need( s.turn in ("hunter", "away", "fixed"), @@ -789,14 +809,16 @@ def _validate(m: Manifest) -> None: "stop", "travel", "lunge", + "code_turn", + "aim_out", "carrier", "host_attacks", ) _need( all(getattr(mv, k) == getattr(plain, k) for k in fields), w, - "attack, steer, length, start, stop, travel, lunge, carrier and host_attacks are " - "for an own move", + "attack, steer, length, start, stop, travel, lunge, code_turn, aim_out, 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 b2573224..615ff93c 100644 --- a/packages/mhfu-port/src/mhfu_port/moves.py +++ b/packages/mhfu-port/src/mhfu_port/moves.py @@ -11,10 +11,11 @@ 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). 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. +frames it plays from and to, when it sets them (`length` counts AI frames instead), and `aim_out`. +The steer carries the move's own `travel` (`mhfu.steering.hex_of`), `lunge` and `code_turn` +(`mhfu.steering.turns_hex`, with `turn = "aim"` where the manifest says so) 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. @@ -22,8 +23,12 @@ the charge-up moves' gains and the transform move, by name. `slots` is the one numbering of own moves: this module writes it, `mhfu_port.lua` registers by it -and the brain splice's `enter OWN_MOVE_MAIN k 0` names it. The manifest it is given has the own -moves `[brain.pairs]` derive from the donor (`brain.own.expand`), which `cli.build.modules` adds. +and the brain splice's `enter OWN_MOVE_MAIN k 0` names it. `combos` is the same for the enter ids +past the slots: each (first, then) pair of own moves a `[brain.pairs]` list names is one id +(`combo_id`), written as `combos = { { id, "first", "then" } }`, which `mhfu_port.lua` registers +(`mhfu.em_combo`) and the splice enters as `enter OWN_MOVE_MAIN id 0`. The manifest it is given +has the own moves `[brain.pairs]` derive from the donor (`brain.own.expand`), which +`cli.build.modules` adds. """ from __future__ import annotations @@ -42,7 +47,7 @@ from . import behaviour from .behaviour import Rule from .layout import Layout -from .manifest import OWN_MOVES, Lunge, Manifest, ManifestError, Move, span_problems +from .manifest import OWN_MOVES, Lunge, Manifest, ManifestError, Move, pair_of, span_problems from .rig import Tip Pair = tuple[int, int] @@ -58,12 +63,22 @@ """Turn keys the framework holds for all of a port's own moves.""" SLOTS: int = OWN_MOVES """Own moves the framework's registry holds.""" +IDS: int = addresses.EM_MOVES.COMBOS.count or 0 +"""Enter ids, a byte's: the slots, then the combos.""" +COMBOS: int = IDS - SLOTS +"""Combos the framework holds: the enter ids past the slots.""" 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.""" +TURNS: int = steering.MOST_TURNS +"""Code-turn values the move player holds for one move.""" +TURN_POOL: int = addresses.EM_MOVES.TURNS.count or 0 +"""Code-turn values the framework holds for all of a port's own moves.""" +COMBO_HUB: Pair = HUBS[75] +"""The carrier a combo's first move rides: the enter stub enters it, em75's alert hub.""" def _place(pool: list[T], run: Sequence[T]) -> int: @@ -88,23 +103,27 @@ def _place(pool: list[T], run: Sequence[T]) -> int: @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.""" + slot by slot (em_vhook.cpp `find_run` is this rule): a move's turn keys, travel samples, lunge + record and code turn 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) + turns: list[int] = 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).""" + turns: Sequence[int] = (), + ) -> tuple[int, int, int | None, int]: + """Register one move's runs; where each starts: keys, samples, the lunge's index (None for + no lunge), code turn.""" at = (_place(self.keys, keys), _place(self.samples, samples)) - return *at, None if lunge is None else _place(self.lunges, [lunge]) + lunge_at = None if lunge is None else _place(self.lunges, [lunge]) + return *at, lunge_at, _place(self.turns, turns) def slots(m: Manifest) -> dict[str, int]: @@ -114,6 +133,32 @@ def slots(m: Manifest) -> dict[str, int]: return {name: k for k, name in enumerate(own[:SLOTS])} +def combos(m: Manifest) -> list[tuple[str, str]]: + """The (first, then) own-move pairs a manifest's combos are: every `[brain.pairs]` list value + `[x, y]` whose x and y are both own moves with a slot, each pair once, in sorted order. The + framework holds `COMBOS` (`problems` names the rest); a pair's place here is its enter id.""" + if m.brain is None: + return [] + own = slots(m) + found: set[tuple[str, str]] = set() + for key in m.brain.pairs: + names = m.brain.targets(pair_of(key)) + if len(names) == 2 and isinstance(names[0], str) and isinstance(names[1], str): + if names[0] in own and names[1] in own: + found.add((names[0], names[1])) + return sorted(found) + + +def combo_id(m: Manifest, first: str, then: str) -> int | None: + """The enter id of the combo (first, then): `SLOTS` plus its place in `combos`, what the + splice enters (`enter OWN_MOVE_MAIN id 0`); None for a pair that is none or past the + framework's table.""" + try: + return SLOTS + combos(m)[:COMBOS].index((first, then)) + except ValueError: + return None + + def hub(species: int) -> Pair | None: """The carrier an own move on `species` rides by default; None where none is known.""" return HUBS.get(species) @@ -198,17 +243,19 @@ def pools(m: Manifest, layout: Layout) -> Pools: entry(m, name, layout) keys = keys_of(curve(m, name, layout)) samples = steering.samples(mv.travel or ()) + turns = steering.turn_values(mv.code_turn 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) + out.add(keys, samples, record, turns) 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 - `frames`; a lunge or window outside `[start, stop]` (the clip's end for no stop) or with a + """What the move player cannot carry of an own move's travel, code turn, lunge, aim and fixed + turn window, as (where under the move, text): travel samples or code-turn values past its + capacity, its reach or the clip's `frames`; an aim with no code turn or an `aim_out` that is + not a byte; 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 @@ -224,6 +271,22 @@ def motion_problems(mv: Move, frames: int | None) -> list[tuple[str, str]]: f"its {len(mv.travel)} samples reach frame {last}, past the clip's {frames}", ) ) + try: + steering.turn_values(mv.code_turn or ()) + except ValueError as err: + out.append((".code_turn", str(err))) + last = mv.start + steering.STEP * (len(mv.code_turn or ()) - 1) + if frames is not None and mv.code_turn and last > frames: + out.append( + ( + ".code_turn", + f"its {len(mv.code_turn)} values reach frame {last}, past the clip's {frames}", + ) + ) + if mv.steer.turn == "aim" and not mv.code_turn: + out.append((".steer", "turn = aim needs a code_turn")) + if not 0 <= mv.aim_out <= 0xFF: + out.append((".aim_out", f"{mv.aim_out} is not 0 to 255 AI frames")) if (lunge := mv.lunge) is not None: lo, hi = lunge.window if not 0 <= lo < hi <= 0xFFFF: @@ -245,9 +308,11 @@ 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 (`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.""" + travel samples, code-turn values or lunges than the framework holds (`Pools`: a run it holds + twice is held once), travel or a code turn past the clip, an aim without a code turn, a lunge + or fixed turn window outside `[start, stop]`, no carrier; more combos than the framework + holds, a combo whose first move does not ride the hub; rules on the flinch whose moves ride + different carriers.""" out: list[str] = [] if (n := sum(mv.own for mv in m.moves.values())) > SLOTS: out.append(f"moves: {n} own moves, the framework holds {SLOTS}") @@ -285,8 +350,26 @@ def problems(m: Manifest, layout: Layout, known: Collection[int] | None = None) f"moves: the own moves' travel needs {len(held.samples)} samples, the framework " f"holds {SAMPLE_POOL}" ) + if len(held.turns) > TURN_POOL: + out.append( + f"moves: the own moves' code turns need {len(held.turns)} values, the framework " + f"holds {TURN_POOL}" + ) if len(held.lunges) > LUNGES: out.append(f"moves: {len(held.lunges)} lunges, the framework holds {LUNGES}") + pairs = combos(m) + if len(pairs) > COMBOS: + out.append(f"moves: {len(pairs)} combos, the framework holds {COMBOS}") + for first, then in pairs: + try: + ride = carrier(m, first) + except ManifestError: + continue # its move's own problem, above + if ride != COMBO_HUB: + out.append( + f"moves.{first}: the first of combo ({first}, {then}) rides {ride}, a combo's " + f"first rides the hub {COMBO_HUB}" + ) flinch = set() try: rules = behaviour.compile(m) @@ -351,11 +434,15 @@ def _steer(m: Manifest, name: str, layout: Layout) -> dict[str, Any]: elif s.turn == "fixed": out |= {"turn": "fixed", "total": s.angle} out |= {"window": list(s.window)} if s.window is not None else {"frames": s.frames} + elif s.turn == "aim": + out["turn"] = "aim" 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 mv.code_turn: + out["code_turn"] = steering.turns_hex(mv.code_turn) if (lunge := mv.lunge) is not None: out["lunge"] = { "window": list(lunge.window), @@ -383,6 +470,8 @@ def _own_move(m: Manifest, name: str, layout: Layout, slot: int) -> dict[str, An out["start"] = mv.start if mv.stop: out["stop"] = mv.stop + if mv.aim_out: + out["aim_out"] = mv.aim_out if mv.host_attacks: out["host_attacks"] = True out["steer"] = _steer(m, name, layout) @@ -478,7 +567,11 @@ def lua( for name, mv in m.moves.items(): body = _own_move(m, name, layout, at[name]) if mv.own else pair_move(mv) out.append(f" {lua_key(name)} = {lua_value(body)},") - out += [" },", " rules = {"] + out += [" },"] + if pairs := combos(m)[:COMBOS]: + listed = [[SLOTS + k, first, then] for k, (first, then) in enumerate(pairs)] + out.append(f" combos = {lua_value(listed)},") + out += [" rules = {"] out += [f" {lua_value(_rule(r))}," for r in behaviour.compile(m)] out += [" },"] for k, names in (("vars", behaviour.vars(m)), ("signals", behaviour.signals(m))): diff --git a/packages/mhfu-port/tests/test_manifest.py b/packages/mhfu-port/tests/test_manifest.py index 4dccb5ce..e4398ff3 100644 --- a/packages/mhfu-port/tests/test_manifest.py +++ b/packages/mhfu-port/tests/test_manifest.py @@ -1,5 +1,6 @@ # SPDX-License-Identifier: GPL-3.0-or-later # SPDX-FileCopyrightText: 2026 sp00ktober +import typing from pathlib import Path import pytest @@ -751,3 +752,40 @@ def test_a_host_pair_move_has_no_travel_or_lunge(): ): with pytest.raises(ManifestError, match="travel, lunge"): M.loads(MINIMAL + pair + extra) + + +AIMED = ( + "[moves.t]\nanim = 9\naim_out = 6\ncode_turn = [0, 3000, -2]\n[moves.t.steer]\nturn = 'aim'\n" +) + + +def test_code_turn_aim_and_aim_out_round_trip(): + m = M.loads(MINIMAL + AIMED) + mv = m.moves["t"] + assert (mv.code_turn, mv.aim_out, mv.steer.turn) == ([0, 3000, -2], 6, "aim") + assert M.loads(M.dumps(m)) == m + plain = M.loads(MINIMAL + "[moves.t]\nanim = 9\n").moves["t"] + assert (plain.code_turn, plain.aim_out) == (None, 0) + + +def test_aim_is_derived_not_picked(): + """An editor offers TURNS; aim needs a code turn only a derivation has.""" + every = set(typing.get_args(M.Turn)) + assert "aim" in every and "aim" not in M.TURNS and M.DERIVED_TURNS == ("aim",) + assert set(M.TURNS) | set(M.DERIVED_TURNS) == every + + +@pytest.mark.parametrize( + ("old", "new", "why"), + [ + ("code_turn = [0, 3000, -2]\n", "", "turn = aim needs a code_turn"), + ("code_turn = [0, 3000, -2]\n", "code_turn = []\n", "turn = aim needs a code_turn"), + ("-2]", "32768]", "code_turn values are signed 16 bits"), + ("-2]", "-32769]", "code_turn values are signed 16 bits"), + ("aim_out = 6", "aim_out = 256", "aim_out is 0 to 255"), + ("aim_out = 6", "aim_out = -1", "aim_out is 0 to 255"), + ], +) +def test_a_code_turn_and_aim_out_are_checked(old, new, why): + with pytest.raises(ManifestError, match=why): + M.loads(MINIMAL + AIMED.replace(old, new)) diff --git a/packages/mhfu-port/tests/test_moves.py b/packages/mhfu-port/tests/test_moves.py index 133be3a0..f087c1cb 100644 --- a/packages/mhfu-port/tests/test_moves.py +++ b/packages/mhfu-port/tests/test_moves.py @@ -287,19 +287,180 @@ def test_runs_the_registry_holds_twice_count_once(): 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) == ( + assert p.add([1, 2, 3], [(0, 0), (1, 1)], b"a", [5, 6, 7]) == (0, 0, 0, 0) + assert p.add([1, 2, 3], [(0, 0), (1, 1)], b"a", [5, 6, 7]) == (0, 0, 0, 0) + assert p.add([2, 3], [(1, 1)], b"b", [6, 7]) == (1, 1, 1, 1) + assert p.add([3, 4], [(0, 0), (2, 2)], None, [7, 8]) == (3, 2, None, 3) + assert p.add([3], [(2, 2)], b"a") == (2, 3, 0, 0) + assert p.add([], []) == (0, 0, None, 0) + assert (p.keys, p.samples, p.lunges, p.turns) == ( [1, 2, 3, 3, 4], [(0, 0), (1, 1), (0, 0), (2, 2)], [b"a", b"b"], + [5, 6, 7, 7, 8], ) +AIMED = ( + OWN + + """ +[moves.turn] +anim = 9 +carrier = [0, 2] +aim_out = 6 +code_turn = [0, 3000, -2, 32767, -32768] +[moves.turn.steer] +turn = "aim" +""" +) + + +def test_code_turn_aim_and_aim_out_reach_the_module(): + text = moves.lua(_m(AIMED), LAYOUT, {6}) + assert ( + "turn = { entry = 9, slot = 3, carrier = { 0, 2 }, aim_out = 6, steer = { walls = true, " + 'turn = "aim", code_turn = "00000bb8fffe7fff8000" } },' in text + ) + plain = moves.lua(_m(), LAYOUT, {6}) + assert "code_turn" not in plain and "aim_out" not in plain and '"aim"' not in plain + + +def test_aim_needs_a_code_turn(): + with pytest.raises(ManifestError, match="moves.turn.steer: turn = aim needs a code_turn"): + _m(AIMED.replace("code_turn = [0, 3000, -2, 32767, -32768]\n", "")) + m = _m(AIMED) + m.moves["turn"].code_turn = None # a derived move never went through the load + assert moves.problems(m, LAYOUT, {6}) == ["moves.turn.steer: turn = aim needs a code_turn"] + + +def test_code_turn_values_and_aim_out_are_bytes_and_shorts(): + with pytest.raises(ManifestError, match="code_turn values are signed 16 bits"): + _m(AIMED.replace("32767", "40000")) + with pytest.raises(ManifestError, match="aim_out is 0 to 255"): + _m(AIMED.replace("aim_out = 6", "aim_out = 256")) + m = _m(AIMED) + m.moves["turn"].aim_out = -1 + m.moves["turn"].code_turn = [0, 40000] + assert moves.problems(m, LAYOUT, {6}) == [ + "moves.turn.code_turn: 40000 is past the +-32768 YAW units a code-turn value holds", + "moves.turn.aim_out: -1 is not 0 to 255 AI frames", + ] + + +def test_a_pair_move_has_no_code_turn_or_aim_out(): + plain = HEAD + OWN + for extra in ("code_turn = [0, 1]", "aim_out = 3"): + with pytest.raises(ManifestError, match="code_turn, aim_out, carrier and host_attacks"): + manifest.loads(plain.replace("hold_max = 12\n", f"hold_max = 12\n{extra}\n", 1)) + + +def test_code_turn_past_the_clip_or_the_move_player_is_refused(): + m = _m(AIMED) + mv = m.moves["turn"] + mv.code_turn = [0] * 51 # frames 0, 2 .. 100: the clip's last + assert moves.problems(m, LAYOUT, {6}) == [] + mv.code_turn = [0] * 52 + assert moves.problems(m, LAYOUT, {6}) == [ + "moves.turn.code_turn: its 52 values reach frame 102, past the clip's 100" + ] + mv.start = 24 + mv.code_turn = [0] * 40 + assert "its 40 values reach frame 102, past the clip's 100" in moves.problems(m, LAYOUT, {6})[0] + mv.start, mv.code_turn = 0, [0] * (moves.TURNS + 1) + found = moves.problems(m, LAYOUT, {6}) + assert f"moves.turn.code_turn: {moves.TURNS + 1} code-turn values, a move holds" in found[0] + + +def test_pool_of_code_turns(): + lay = layout.Layout({}, frames={9: 400}) + k = moves.TURN_POOL // moves.TURNS + 1 + many = "".join( + f"[moves.t{i}]\nanim = 9\ncarrier = [0, 1]\ncode_turn = {[i + 1] * moves.TURNS}\n" + for i in range(k) + ) + assert moves.problems(_m(many), lay, {6}) == [ + f"moves: the own moves' code turns need {moves.TURNS * k} values, the framework holds " + f"{moves.TURN_POOL}" + ] + same = "".join( + f"[moves.t{i}]\nanim = 9\ncarrier = [0, 1]\ncode_turn = {[7] * moves.TURNS}\n" + for i in range(k) + ) + assert moves.problems(_m(same), lay, {6}) == [] + assert len(moves.pools(_m(same), lay).turns) == moves.TURNS + + +PAIRS = """ +[brain] +donor = 23 +splice = 'base_3' +entry = 'base_12' +[brain.pairs] +'7,13' = ['stamp', 'spin'] +'7,15' = ['dash', 'stamp'] +'7,16' = ['stamp', 'spin'] +'7,17' = [[1, 2], 'stamp'] +'7,18' = ['dash', [1, 4]] +'7,19' = 'stamp' +'7,20' = ['dash', 'stamp', 'spin'] +""" + + +def test_combos_are_the_pairs_lists_of_two_own_moves(): + """Both moves own and with a slot: a host pair or a list of three is none, a pair listed twice + is one combo; sorted, the enter id is the place past the slots.""" + m = _m(OWN + PAIRS) + assert moves.combos(m) == [("dash", "stamp"), ("stamp", "spin")] + assert moves.combo_id(m, "dash", "stamp") == moves.SLOTS + assert moves.combo_id(m, "stamp", "spin") == moves.SLOTS + 1 + assert [moves.combo_id(m, *c) for c in (("spin", "stamp"), ("dash", "lunge"), ("a", "b"))] == [ + None + ] * 3 + assert moves.problems(m, LAYOUT, {6}) == [] + text = moves.lua(m, LAYOUT, {6}) + ids = f'{{ {moves.SLOTS}, "dash", "stamp" }}, {{ {moves.SLOTS + 1}, "stamp", "spin" }}' + assert f" combos = {{ {ids} }},\n rules = {{" in text + assert "combos" not in moves.lua(_m(), LAYOUT, {6}) and moves.combos(_m()) == [] + + +def test_a_derived_move_is_a_combos_member(): + """`own` stands for the move the build derives from the pair, here p3_7_21: an own move of the + manifest the module is written from.""" + text = OWN + "[moves.p3_7_21]\nanim = 9\n" + PAIRS + "'7,21' = ['own', 'stamp']\n" + assert ("p3_7_21", "stamp") in moves.combos(_m(text)) + + +def test_a_combos_first_rides_the_hub(): + m = _m(OWN + PAIRS) + m.brain.pairs["7,13"] = ["spin", "stamp"] # type: ignore[union-attr] # spin rides (0, 1) + assert ("spin", "stamp") in moves.combos(m) + assert moves.problems(m, LAYOUT, {6}) == [ + "moves.spin: the first of combo (spin, stamp) rides (0, 1), a combo's first rides the hub " + "(0, 2)" + ] + + +def test_more_combos_than_the_table_holds_are_refused(): + names = [f"m{i:02}" for i in range(14)] + own = "".join(f"[moves.{n}]\nanim = 9\ncarrier = [0, 2]\n" for n in names) + every = [(a, b) for a in names for b in names if a != b][: moves.COMBOS + 1] + pairs = "".join(f"'7,{k}' = ['{a}', '{b}']\n" for k, (a, b) in enumerate(every)) + m = manifest.loads(HEAD + own + PAIRS.split("[brain.pairs]")[0] + "[brain.pairs]\n" + pairs) + lay = layout.Layout({}, frames={9: 400}) + assert len(moves.combos(m)) == moves.COMBOS + 1 + assert moves.problems(m, lay, {6}) == [ + f"moves: {moves.COMBOS + 1} combos, the framework holds {moves.COMBOS}" + ] + last = moves.combos(m)[-1] + assert ( + moves.combo_id(m, *last) is None and moves.combo_id(m, *moves.combos(m)[0]) == moves.SLOTS + ) + + +def test_the_ids_are_a_byte(): + assert moves.IDS == 256 and moves.COMBOS == 256 - moves.SLOTS + + 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 1dff0bed..f517a087 100644 --- a/packages/mhfu/src/mhfu/live/moves.py +++ b/packages/mhfu/src/mhfu/live/moves.py @@ -47,7 +47,7 @@ """How a MOVE_END name reads, where its name alone does not say it.""" ENDS: dict[int, str] = {a.MOVE_END.number(n): WORDS.get(n, n) for n in a.MOVE_END.names} """MOVE_STATE.END in words.""" -TURNS = ("still", "hunter", "away", "fixed") +TURNS = ("still", "hunter", "away", "fixed", "aim") """STEER_PARAMS.TURN by name.""" FULL_TURN = 0x10000 """ENTITY.YAW units in a turn.""" @@ -97,6 +97,9 @@ class Move: stop: int = 0 """Clip frame `part`'s cursor ends the move at, whatever the blend and the rate (MOVE.STOP); 0: none.""" + aim_out: int = 0 + """AI frames (MOVE.AIM_OUT): a hand-over in place spreads the angle still to turn to the target + over the successor's first that many AI frames; 0: none.""" @classmethod def unpack(cls, raw: bytes) -> Move: @@ -127,6 +130,7 @@ def unpack(cls, raw: bytes) -> Move: bool(raw[f.FORCE]), start, stop, + raw[f.AIM_OUT], ) def pack(self) -> bytes: @@ -148,6 +152,7 @@ def pack(self) -> bytes: struct.pack_into(" bytes: """struct STEER_SPEC.""" @@ -230,6 +238,9 @@ def pack(self) -> bytes: if self.lunge: raw = self.lunge.pack() out[f.LUNGE : f.LUNGE + len(raw)] = raw + turns = steering.turn_values(self.code_turn) + struct.pack_into(" Steer: window = struct.unpack_from(" Steer: (window[0], window[1]), steering.path_of(held), Lunge.unpack(raw[f.LUNGE : f.LUNGE + (a.STEER_LUNGE.size or 0)]), + struct.unpack_from(f"<{min(n_turns, steering.MOST_TURNS)}h", raw, f.TURNS), ) @@ -290,6 +303,9 @@ class SteerState(View): keep = u8(a.STEER_STATE.KEEP) blend = u16(a.STEER_STATE.BLEND) ramp = s16(a.STEER_STATE.RAMP) + turn_scale = f32(a.STEER_STATE.TURN_SCALE) + out_total = s16(a.STEER_STATE.OUT_TOTAL) + out_frames = u16(a.STEER_STATE.OUT_FRAMES) class MoveState(View): diff --git a/packages/mhfu/src/mhfu/steering.py b/packages/mhfu/src/mhfu/steering.py index 73f0dc1c..2d9baa23 100644 --- a/packages/mhfu/src/mhfu/steering.py +++ b/packages/mhfu/src/mhfu/steering.py @@ -1,12 +1,15 @@ # 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). +"""A move's travel curve and code turn as the game holds them, so that what a port's moves module +writes and what `mhfu.live.moves` puts in the game agree. Travel is 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). The code turn is the donor's +entity-yaw curve: cumulative YAW units every `STEP` clip frames from the move's start, a signed 16 +bits each (`steer.code_turn`, 4 hex digits a value). hex_of([(0, 0), (-2.5, 40)]) # "00000000fff60000..." for the module samples([(0, 0), (-2.5, 40)]) # [(0, 0), (-10, 160)] for the game + turns_hex([0, 3000, -2]) # "00000bb8fffe" for the module """ from __future__ import annotations @@ -23,6 +26,8 @@ """Samples one move holds.""" REACH = 0x8000 / SCALE """A sample's x and z are within -REACH and REACH (exclusive) world units.""" +MOST_TURNS = int(addresses.STEER_TURNS) +"""Code-turn values one move holds.""" Point = tuple[float, float] @@ -48,6 +53,31 @@ def path_of(held: Iterable[tuple[int, int]]) -> tuple[Point, ...]: return tuple((x / SCALE, z / SCALE) for x, z in held) +def turn_values(values: Iterable[int]) -> list[int]: + """`values`, a code turn's YAW units, as the game holds them; ValueError past `MOST_TURNS` or a + signed 16 bits.""" + out = list(values) + if len(out) > MOST_TURNS: + raise ValueError(f"{len(out)} code-turn values, a move holds {MOST_TURNS}") + for v in out: + if not -0x8000 <= v < 0x8000: + raise ValueError(f"{v} is past the +-32768 YAW units a code-turn value holds") + return out + + +def turns_hex(values: Iterable[int]) -> str: + """A code turn as a moves module's `steer.code_turn`.""" + return "".join(f"{v & 0xFFFF:04x}" for v in turn_values(values)) + + +def parse_turns(text: str) -> tuple[int, ...]: + """A `steer.code_turn` string's values.""" + if len(text) % 4 or not all(c in "0123456789abcdefABCDEF" for c in text): + raise ValueError("code_turn is 4 hex digits a value") + words = [int(text[i : i + 4], 16) for i in range(0, len(text), 4)] + return tuple(w - 0x10000 if w >= 0x8000 else w for w in words) + + 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)) diff --git a/packages/mhfu/tests/test_moves.py b/packages/mhfu/tests/test_moves.py index 14f961fd..964aafb6 100644 --- a/packages/mhfu/tests/test_moves.py +++ b/packages/mhfu/tests/test_moves.py @@ -3,9 +3,11 @@ """The move player's host side: the MOVE bytes and the report.""" import struct +from dataclasses import replace import pytest from mhfu import addresses as a +from mhfu import steering from mhfu.cli import main from mhfu.cli import moves as cli from mhfu.live import moves @@ -36,6 +38,12 @@ def test_start_and_stop_round_trip() -> None: assert moves.Move(53).pack()[a.MOVE.START :] == bytes(4) +def test_aim_out_round_trips() -> None: + raw = moves.Move(53, aim_out=10).pack() + assert raw[a.MOVE.AIM_OUT] == 10 and moves.Move.unpack(raw).aim_out == 10 + assert moves.Move(53).pack()[a.MOVE.AIM_OUT] == 0 + + def test_attack_word() -> None: assert cli._attack("6@56") == moves.Attack(6, 56) assert cli._attack("6@56-90") == moves.Attack(6, 56, 90) @@ -92,6 +100,27 @@ def test_travel_and_lunge_pack_as_the_spec(): assert plain[a.STEER_SPEC.SAMPLE_COUNT :] == bytes(len(plain) - a.STEER_SPEC.SAMPLE_COUNT) +def test_code_turn_packs_as_the_spec(): + sp = moves.Steer(turn="aim", code_turn=(0, 3000, -2, 32767, -32768)) + b = sp.pack() + assert ( + len(b) == a.STEER_SPEC.size + and moves.TURNS[b[a.STEER_SPEC.STEER + a.STEER_PARAMS.TURN]] == "aim" + ) + assert struct.unpack_from(" None: 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) + + +def test_turns_round_trip() -> None: + values = [0, 3000, -2, 32767, -32768] + text = steering.turns_hex(values) + assert text == "00000bb8fffe7fff8000" + assert steering.parse_turns(text) == tuple(values) and steering.parse_turns("") == () + + +@pytest.mark.parametrize("bad", ["0", "000", "0000000", "000g"]) +def test_parse_turns_refuses(bad: str) -> None: + with pytest.raises(ValueError, match="4 hex digits"): + steering.parse_turns(bad) + + +def test_the_most_and_the_reach_of_a_code_turn_are_refused() -> None: + assert steering.turns_hex([0] * steering.MOST_TURNS) + with pytest.raises(ValueError, match=f"a move holds {steering.MOST_TURNS}"): + steering.turn_values([0] * (steering.MOST_TURNS + 1)) + for v in (32768, -32769): + with pytest.raises(ValueError, match="a code-turn value holds"): + steering.turn_values([v]) From 480180420438e130a50b033c75f4e73e4f825e48 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:35:09 +0100 Subject: [PATCH 6/6] framework: a combo's THEN follows its first move's AFTER chain Co-Authored-By: Claude Opus 5.5 --- framework/include/mhfu/em_vhook.h | 11 ++++++----- framework/src/core/em_vhook.cpp | 25 +++++++++++++++---------- framework/tests/test_em_brain.py | 9 +++++---- 3 files changed, 26 insertions(+), 19 deletions(-) diff --git a/framework/include/mhfu/em_vhook.h b/framework/include/mhfu/em_vhook.h index 03a8904e..74b0a1bb 100644 --- a/framework/include/mhfu/em_vhook.h +++ b/framework/include/mhfu/em_vhook.h @@ -180,11 +180,12 @@ int mhfu_em_tip(const uint8_t (*pairs)[2], int n); * carrier (em75's hub (0,2) for an empty slot) in place of the main the game has no table for, and * files the same forced request mhfu_em_play does; the brain plays the move at the next AI step. * An id from MHFU_EM_MOVES up names a combo (mhfu_em_combo): the stub enters the hub and files the - * id, never reading a slot past the registry, and the brain plays the combo's first move, whose - * THEN follows it in place. The script's next pair waits for the carrier's hand-back, as after a - * native 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 or id + 1 (a byte) of the last that was filed. */ + * id, never reading a slot past the registry, and the brain plays the combo's first move; its THEN + * follows that move's chain in place. The script's next pair waits for the carrier's hand-back, as + * after a native 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 or id + 1 (a byte) of + * the last that was filed. */ #define MHFU_EM_MOVES MHFU_OWN_MOVES #define MHFU_EM_NO_MOVE 0xFFu #define MHFU_EM_NO_LUNGE 0xFFu /* mhfu_em_own_t.lunge_at: no lunge */ diff --git a/framework/src/core/em_vhook.cpp b/framework/src/core/em_vhook.cpp index b6513399..77d3c03f 100644 --- a/framework/src/core/em_vhook.cpp +++ b/framework/src/core/em_vhook.cpp @@ -32,11 +32,11 @@ * is entered as own move k's carrier (em75's hub for an empty slot) and files the * request mhfu_em_play files, forced; the brain plays the move at the next AI * step. A k from MHFU_EM_MOVES up is a combo's: the hub, the id filed, the brain - * plays the combo's first move and its THEN follows in place. The script's wake - * the hub raises inside the run is cleared by SCRIPT_TICK after it, and - * move.cpp's start quiets the one the hub raises again, so the script's next pair - * cannot cut the move; the carrier hands back once the move (and its AFTER chain) - * ends, which wakes the script. + * plays the combo's first move, and its THEN follows that chain in place. The + * script's wake the hub raises inside the run is cleared by SCRIPT_TICK after + * it, and move.cpp's start quiets the one the hub raises again, so the script's + * next pair cannot cut the move; the carrier hands back once the move (and its + * AFTER chain) ends, which wakes the script. * CUT the brain: while the tail cut waits for its drop (cut_waits), the * request, the plays and the rules wait, and the move player's start * with them; the events seen meanwhile reach the rules after the drop. @@ -569,8 +569,8 @@ static void follow(mhfu_move_t *mv) } } -/* Plays slot on ent, forced or not; `then` (a slot, else MHFU_EM_NO_MOVE) follows it in place in - * place of its AFTER: a combo's first move. */ +/* Plays slot on ent, forced or not; `then` (a slot, else MHFU_EM_NO_MOVE) follows in place once its + * AFTER chain ends: a combo's first move. */ static int play_slot(uint32_t ent, uint32_t slot, const volatile mhfu_move_state_t *m, int force, uint32_t then) { @@ -602,11 +602,11 @@ static int play_request(uint32_t ent, uint32_t id, const volatile mhfu_move_stat return play_slot(ent, R->combos[id].first, m, force, R->combos[id].then); } -/* The slot that plays when slot k's move ends: the THEN of the combo it began, else its AFTER, - * MHFU_EM_NO_MOVE for none. The one place a successor is chosen. */ +/* The slot that plays when slot k's move ends: its AFTER, else the THEN of the combo whose chain it + * ends, MHFU_EM_NO_MOVE for none. The one place a successor is chosen. */ static uint32_t successor(int k) { - return R->tag_then != MHFU_EM_NO_MOVE ? R->tag_then : R->moves[k].after; + return R->moves[k].after != MHFU_EM_NO_MOVE ? R->moves[k].after : R->tag_then; } /* The move that just ended on its clip's end into its carrier or on a wall names a successor, which @@ -636,7 +636,12 @@ extern "C" int mhfu_em_chain(uint32_t ent, mhfu_move_t *mv, const mhfu_steer_spe || was->back_main != was->carrier_main || was->back_sub != was->carrier_sub) return 0; if (!fill(slot, was->force, mv, steer)) return 0; + const uint32_t then = R->tag_then; tag(ent, slot, m); + if (then != MHFU_EM_NO_MOVE && slot != then) { /* still the first's chain: THEN waits */ + R->tag_then = then; + follow(mv); + } R->chained++; return 1; } diff --git a/framework/tests/test_em_brain.py b/framework/tests/test_em_brain.py index 181f3631..ca5146e9 100644 --- a/framework/tests/test_em_brain.py +++ b/framework/tests/test_em_brain.py @@ -575,15 +575,16 @@ def test_only_the_play_of_a_combo_is_followed(g: Game) -> None: assert [c[2] for c in flat if c[0] == "X"] == [46] and g.moves("CHAINED") == 0 -def test_a_combos_then_takes_the_place_of_the_firsts_after(g: Game) -> None: +def test_a_combos_then_follows_the_firsts_after_chain(g: Game) -> None: g.own(0, 46, after=2) g.own(1, 53) g.own(2, 59) assert g.lib.mhfu_em_combo(COMBO, 0, 1) assert g.lib.mhfu_em_play(ENT, COMBO, 1) - flat = [c for f in played(g, 2 + 3 * int(CLIP_END / SPEED)) for c in f] - assert [c[2] for c in flat if c[0] == "X"] == [46, 53] - assert g.lib.mhfu_em_play(ENT, 0, 1) # played alone, its own after + flat = [c for f in played(g, 2 + 3 * (int(CLIP_END / SPEED) + 1)) for c in f] + assert [c[2] for c in flat if c[0] == "X"] == [46, 59, 53] + assert [(c[2], c[3]) for c in flat if c[0] == "E"] == [(0, 2)] and g.moves("CHAINED") == 2 + assert g.lib.mhfu_em_play(ENT, 0, 1) # played alone: its own after only flat = [c for f in played(g, 2 + 3 * int(CLIP_END / SPEED)) for c in f] assert [c[2] for c in flat if c[0] == "X"] == [46, 59]