From 0072d1b9b78d3e2686bacc82f5767b983c911c8c Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:36:58 +0200 Subject: [PATCH 1/5] feat(framework): 96 own-move slots The registry, the fatigue helper's own costs and the stub's clamp follow EM_MOVES.MOVES: the move block is 12796 bytes (was 8764), the em_vhook block grows by the 96 cost bytes. Co-Authored-By: Claude Sonnet 5.5 --- framework/README.md | 9 ++++--- packages/mhfu/src/mhfu/addresses.toml | 36 +++++++++++++-------------- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/framework/README.md b/framework/README.md index ce0c889b..e6d26360 100644 --- a/framework/README.md +++ b/framework/README.md @@ -130,10 +130,11 @@ 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`). The seam's brain, in C on the -AI step, plays one through the move player when asked (`mhfu.em_play`), when the move before it -ends on its clip, its length or a wall and names it as its `after`, or when a rule says -(`play_move`, and `from_move` to fire while one plays). `mhfu_port.lua` does all of it from the +A port's own moves sit in a registry by slot (`mhfu.em_move`): `mhfu.addr.EM_MOVES.MOVES_COUNT` +(96) of them, and the turn keys they share (2048), in one 12.8 KB partition block. The seam's +brain, in C on the AI step, plays one through the move player when asked (`mhfu.em_play`), when the +move before it ends on its clip, its length or a wall and names it as its `after`, or when a rule +says (`play_move`, and `from_move` to fire while one plays). `mhfu_port.lua` does all of it from the port's generated `_moves.lua`: `port:move("stamp")` (`ports/README.md` "Moves and behaviour"). Its `tip` goes to `mhfu.em_tip`: each tip joint takes its carrier's pose after the species brain, so the tail stays whole until it drops. diff --git a/packages/mhfu/src/mhfu/addresses.toml b/packages/mhfu/src/mhfu/addresses.toml index 1f88e12a..9f7523d1 100644 --- a/packages/mhfu/src/mhfu/addresses.toml +++ b/packages/mhfu/src/mhfu/addresses.toml @@ -938,7 +938,7 @@ VALID = { offset = 0x4C, type = "u32", doc = "1 once written; 0 while it is rewr [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 = 0x21FC +size = 0x31BC [struct.EM_MOVES.fields] MAGIC = { offset = 0x00, type = "u32", doc = "0x564F4D45 (EMOV)" } REQ_ENT = { offset = 0x04, type = "ptr", doc = "a play asked for (mhfu_em_play): the monster, taken at its next AI step; 0 none" } @@ -948,10 +948,10 @@ TAG_STARTED = { offset = 0x10, type = "u32", doc = "MOVE_STATE.STARTED of that m PLAYS = { offset = 0x14, type = "u32", doc = "moves started from here: asked, by a rule or by AFTER" } CHAINED = { offset = 0x18, type = "u32", doc = "of them by AFTER" } KEY_TOP = { offset = 0x1C, type = "u32", doc = "KEYS in use" } -MOVES = { offset = 0x20, type = "EM_OWN[48]", doc = "the moves by slot" } -KEYS = { offset = 0xFE0, type = "u16[2048]", doc = "every move's turn keys, YAW every 2 clip frames" } -SCRATCH = { offset = 0x1FE0, type = "STEER_SPEC", doc = "the steering handed to the move player for the move it plays next" } -REQ_FORCE = { offset = 0x21F8, type = "u32", doc = "1: the play asked for starts while the monster's notice runs (MOVE.FORCE)" } +MOVES = { offset = 0x20, type = "EM_OWN[96]", doc = "the moves by slot" } +KEYS = { offset = 0x1FA0, type = "u16[2048]", doc = "every move's turn keys, YAW every 2 clip frames" } +SCRATCH = { offset = 0x2FA0, type = "STEER_SPEC", doc = "the steering handed to the move player for the move it plays next" } +REQ_FORCE = { offset = 0x31B8, type = "u32", doc = "1: the play asked for starts while the monster's notice runs (MOVE.FORCE)" } [struct.FLINCH_PART] doc = "one part's flinch state (ENTITY.FLINCH_PARTS)" @@ -1064,7 +1064,7 @@ COST = { offset = 0x2, type = "s16", doc = "stamina taken; halved at low HP; neg [struct.FATIGUE_CFG] doc = "what a fatigue helper is set to (fatigue.h mhfu_fatigue_cfg_t), written by mhfu_em_fatigue" -size = 0x178 +size = 0x1D8 [struct.FATIGUE_CFG.fields] MAX = { offset = 0x00, type = "s32", doc = "stamina maximum (MHP3rd's Brute 1000)" } STARTS = { offset = 0x04, type = "s32[2]", doc = "stamina at the monster's first AI step, one of the two by the board's generator (Brute 400, 600)" } @@ -1074,27 +1074,27 @@ LOW_HP_PCT = { offset = 0x14, type = "u8", doc = "costs halve at or below this p CALM = { offset = 0x15, type = "u8", doc = "1: the species' own anger starts no rage while tired" } PAIR_COUNT = { offset = 0x16, type = "u16", doc = "PAIRS in use" } PAIRS = { offset = 0x18, type = "FATIGUE_PAIR[64]", doc = "the cost of entering a pair" } -OWN = { offset = 0x118, type = "s16[48]", doc = "the cost of starting an own move, by EM_MOVES slot; one per slot" } +OWN = { offset = 0x118, type = "s16[96]", doc = "the cost of starting an own move, by EM_MOVES slot; one per slot" } [struct.FATIGUE] doc = "em_vhook's fatigue block (fatigue.h mhfu_fatigue_t): one per wrapped species, for the board's monster; a new monster resets everything after CFG" -size = 0x1A0 +size = 0x200 [struct.FATIGUE.fields] ENABLED = { offset = 0x00, type = "u32", doc = "1 once mhfu_em_fatigue armed it; the quest's end and mhfu_em_clear turn it off" } CFG = { offset = 0x04, type = "FATIGUE_CFG", doc = "the settings" } -STAMINA = { offset = 0x17C, type = "s32", doc = "0..CFG.MAX; set at the monster's first AI step after arming" } -TIRED = { offset = 0x180, type = "u32", doc = "1 during a tired spell: gauge A reads low" } -LEFT = { offset = 0x184, type = "u32", doc = "AI frames of the tired spell left" } -SEEN = { offset = 0x188, type = "u32", doc = "1 once the first AI step chose the start" } -PAIR = { offset = 0x18C, type = "u32", doc = "(main << 8) | sub the last AI frame saw; a different one is charged" } -HELD = { offset = 0x190, type = "u32", doc = "1 while this block keeps the species' anger off (CFG.CALM, tired)" } -CHARGES = { offset = 0x194, type = "u32", doc = "costs charged" } -SPELLS = { offset = 0x198, type = "u32", doc = "tired spells begun" } -GAUGE = { offset = 0x19C, type = "s32", doc = "ENTITY.GAUGE_A as this block last wanted it" } +STAMINA = { offset = 0x1DC, type = "s32", doc = "0..CFG.MAX; set at the monster's first AI step after arming" } +TIRED = { offset = 0x1E0, type = "u32", doc = "1 during a tired spell: gauge A reads low" } +LEFT = { offset = 0x1E4, type = "u32", doc = "AI frames of the tired spell left" } +SEEN = { offset = 0x1E8, type = "u32", doc = "1 once the first AI step chose the start" } +PAIR = { offset = 0x1EC, type = "u32", doc = "(main << 8) | sub the last AI frame saw; a different one is charged" } +HELD = { offset = 0x1F0, type = "u32", doc = "1 while this block keeps the species' anger off (CFG.CALM, tired)" } +CHARGES = { offset = 0x1F4, type = "u32", doc = "costs charged" } +SPELLS = { offset = 0x1F8, type = "u32", doc = "tired spells begun" } +GAUGE = { offset = 0x1FC, type = "s32", doc = "ENTITY.GAUGE_A as this block last wanted it" } [struct.EM_BLOCK] doc = "em_vhook's partition block from its config block on (em_vhook.cpp place_block): the config block, the tail's map and the cut's wait, then the board and the fatigue block; the block's address moves with the PRX build, a debugger finds it by EM_CFG.CANARY" -size = 0xEE8 +size = 0xF48 [struct.EM_BLOCK.fields] CFG = { offset = 0x00, type = "EM_CFG", doc = "the config block" } BOARD = { offset = 0xD08, type = "EM_BOARD", doc = "the board, after the tail: sizeof(tail_t) is 0x28" } From e4e78ec1566ea8708f89c880d00599e6cfc77a46 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:49:10 +0200 Subject: [PATCH 2/5] feat(framework): a charge helper answers the species' query slot em_vhook wraps MONSTER_VTABLE.QUERY (+0xDC, the AI script's switch_query) with a frame-free branchless leaf that answers the board's monster from a table and leaves every other entity to the species. The helper keeps the gauge, charged state, durability and cooldown in C on the AI step: charge-up own moves raise the gauge by HP band, hits chip the gauge or the durability, a full gauge plays the transform own move, a discharge pair or no durability discharges. CHARGE_QUERY_CHARGED/COOLDOWN are the shared constants. Co-Authored-By: Claude Sonnet 5.5 --- framework/include/mhfu/charge.h | 107 ++++++++ framework/include/mhfu/em_vhook.h | 7 +- framework/include/mhfu/mhfu.h | 1 + framework/src/core/charge_logic.h | 149 ++++++++++ framework/src/core/em_vhook.cpp | 232 +++++++++++++++- framework/src/core/em_vhook_stubs.h | 43 ++- framework/tests/charge_host.cpp | 24 ++ framework/tests/test_charge.py | 259 ++++++++++++++++++ framework/tests/test_em_brain.py | 363 +++++++++++++++++++++++++ framework/tests/test_em_vhook_stubs.py | 135 ++++++++- packages/mhfu/src/mhfu/addresses.toml | 55 +++- 11 files changed, 1353 insertions(+), 22 deletions(-) create mode 100644 framework/include/mhfu/charge.h create mode 100644 framework/src/core/charge_logic.h create mode 100644 framework/tests/charge_host.cpp create mode 100644 framework/tests/test_charge.py diff --git a/framework/include/mhfu/charge.h b/framework/include/mhfu/charge.h new file mode 100644 index 00000000..abd05827 --- /dev/null +++ b/framework/include/mhfu/charge.h @@ -0,0 +1,107 @@ +/* SPDX-License-Identifier: MIT */ +/* SPDX-FileCopyrightText: 2026 sp00ktober */ +/* Charge: a gauge, a charged state and a cooldown for the wrapped species' monster, shown to its AI + * script through the species' query (the script's `switch_query`, vt + MONSTER_VTABLE.QUERY). + * + * MHP3rd's Zinogre charges up (a gauge filled by charge-up moves), transforms at 100 and stays + * charged until hits wear its durability out; its script asks the species for "charged" (query + * CHARGE_QUERY_CHARGED) and "the cooldown runs" (CHARGE_QUERY_COOLDOWN). MHFU's em75 answers every + * query 0, so em_vhook wraps the slot: a frame-free, branchless stub answers the board's monster + * from the table in the charge block (`answers`, by arg), and every other entity, and any arg + * past 7, reaches the species' own query. The helper keeps the state in C on em_vhook's AI step + * (the board's monster only), and it stays with the monster across areas: only a new monster of + * the species, mhfu_em_charge and the quest's end reset it. + * + * Each AI frame, from the first one after arming: + * - the cooldown counts down while the monster is not charged; + * - a hit (ENTITY.HP lower than the frame before; hits within one frame count once) takes + * `chip_gauge` from the gauge while the monster is not charged, `chip_durability` from the + * durability while it is; + * - a charged monster whose pair is in `pairs` (the knockout, the traps), or whose durability is + * 0, discharges: charged 0, gauge 0, cooldown `cooldown_out`; + * - with the gauge at 100 and the move player idle, the `transform` own move is played, forced + * (the request mhfu_em_play files); the pair set is refused, as MHP3rd refuses in a knockout. + * When the move player takes a charge-up own move (a slot of `ups`, whoever asked for it: the AI + * script, a rule, Lua), the gauge gains the up's gain[0] at or above 95 % of MAX_HP, gain[1] at or + * above 75 %, gain[2] at or above 40 %, gain[3] below, and the cooldown is `cooldown_up`. When it + * takes the transform slot, from the helper or from anywhere else: charged 1, durability 100, + * gauge 100. A charged monster takes no gain from a charge-up. + * + * The pure arithmetic is src/core/charge_logic.h; the seam is em_vhook.cpp's brain. The calls + * only write the charge block, so they are safe from any thread. */ +#ifndef MHFU_CHARGE_H +#define MHFU_CHARGE_H + +#include + +#include "addresses.gen.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define MHFU_CHARGE_MAX_UPS MHFU_CHARGE_CFG_UPS_COUNT /* charge-up moves */ +#define MHFU_CHARGE_MAX_PAIRS MHFU_CHARGE_CFG_PAIRS_COUNT /* host pairs that discharge */ +#define MHFU_CHARGE_ANSWERS_N MHFU_CHARGE_ANSWERS_COUNT /* the query's answers: args 0..7 */ +#define MHFU_CHARGE_NO_SLOT 0xFFu /* mhfu_charge_cfg_t.transform: none */ +#define MHFU_CHARGE_FULL 100 /* the gauge that transforms, and the durability it starts with */ +#define MHFU_CHARGE_ASK_GAP 30 /* AI frames between two transform plays asked for */ + +/* struct CHARGE_UP in addresses.toml */ +typedef struct { + uint8_t slot, _pad; + int16_t gain[4]; /* by HP: at least 95 %, 75 %, 40 % of MAX_HP, below */ +} mhfu_charge_up_t; + +/* struct CHARGE_PAIR */ +typedef struct { + uint8_t main, sub; +} mhfu_charge_pair_t; + +/* struct CHARGE_CFG: what mhfu_em_charge takes */ +typedef struct { + uint8_t transform; /* own-move slot, MHFU_CHARGE_NO_SLOT for none */ + uint8_t up_count; /* ups[] in use, at most MHFU_CHARGE_MAX_UPS */ + uint8_t pair_count; /* pairs[] in use, at most MHFU_CHARGE_MAX_PAIRS */ + uint8_t _pad; + int16_t chip_gauge; /* 0..100 */ + int16_t chip_durability; /* 0..100 */ + uint16_t cooldown_up; /* AI frames */ + uint16_t cooldown_out; + mhfu_charge_up_t ups[MHFU_CHARGE_MAX_UPS]; + mhfu_charge_pair_t pairs[MHFU_CHARGE_MAX_PAIRS]; +} mhfu_charge_cfg_t; + +/* struct CHARGE: em_vhook's block. The query stub reads ANSWERS and ENTITY by offset. */ +typedef struct { + uint32_t answers[MHFU_CHARGE_ANSWERS_N]; + uint32_t entity; /* the monster the stub answers for; 0 none */ + uint32_t asked; /* queries the stub answered */ + uint32_t enabled; + mhfu_charge_cfg_t cfg; + int32_t gauge, durability, cooldown; + uint32_t charged; + uint32_t seen; /* the first AI step took the baseline */ + uint32_t hp; /* ENTITY.HP the last AI frame saw */ + uint32_t xform_at; /* AI tick of the last transform play asked for */ + uint32_t up_starts, transforms, discharges, hits; +} mhfu_charge_t; + +typedef struct { + uint32_t armed, charged; + int32_t gauge, durability, cooldown; + uint32_t up_starts, transforms, discharges, hits, asked; +} mhfu_charge_status_t; + +/* Arms the helper with cfg (copied), for the board's monster from its next AI step; NULL turns it + * off, as the quest's end and mhfu_em_clear do. 1 when it took effect; 0 for a cfg it refuses + * (logged), or when the species' query slot could not be wrapped. The same settings again change + * nothing, the state included. */ +int mhfu_em_charge(const mhfu_charge_cfg_t *cfg); + +void mhfu_em_charge_status(mhfu_charge_status_t *out); + +#ifdef __cplusplus +} +#endif +#endif /* MHFU_CHARGE_H */ diff --git a/framework/include/mhfu/em_vhook.h b/framework/include/mhfu/em_vhook.h index 972d6fac..e7d727f3 100644 --- a/framework/include/mhfu/em_vhook.h +++ b/framework/include/mhfu/em_vhook.h @@ -1,8 +1,8 @@ /* SPDX-License-Identifier: MIT */ /* SPDX-FileCopyrightText: 2026 sp00ktober */ /* em_vhook: the core wraps slots of the spawned big monster's species vtable, - * MONSTER_VTABLE.ENTER_ACTION (provisions a behaviour pair), .AI_STEP (every frame), .ANIM_EVENTS - * and OBJ_VTABLE.BRAIN (the tail tip). + * MONSTER_VTABLE.ENTER_ACTION (provisions a behaviour pair), .AI_STEP (every frame), .ANIM_EVENTS, + * .QUERY (the AI script's switch_query) and OBJ_VTABLE.BRAIN (the tail tip). * * substitution the engine's (main, id) is rewritten before enter-action runs, so our * pair is provisioned like a native one; @@ -23,6 +23,9 @@ * by a rule, or after the move before them; * fatigue a stamina for the board's monster, charged per pair entered and own move * started, shown to the AI script as gauge A low (mhfu/fatigue.h); + * charge a gauge, a charged state and a cooldown for the board's monster, moved by + * charge-up own moves, hits and a transform own move, answered to the AI script's + * switch_query (mhfu/charge.h); * step a C function on every AI frame, on the game thread, before the host step; * tail tip a port's tip joints posed from carriers of the live tail until the drop. * diff --git a/framework/include/mhfu/mhfu.h b/framework/include/mhfu/mhfu.h index 228ebbb0..091eb8d8 100644 --- a/framework/include/mhfu/mhfu.h +++ b/framework/include/mhfu/mhfu.h @@ -23,6 +23,7 @@ #include "ovl_reloc.h" #include "em_vhook.h" #include "fatigue.h" +#include "charge.h" #include "move.h" #include "monster_events.h" #include "npc.h" diff --git a/framework/src/core/charge_logic.h b/framework/src/core/charge_logic.h new file mode 100644 index 00000000..6f3b252c --- /dev/null +++ b/framework/src/core/charge_logic.h @@ -0,0 +1,149 @@ +/* SPDX-License-Identifier: MIT */ +/* SPDX-FileCopyrightText: 2026 sp00ktober */ +/* The charge helper's arithmetic and state machine (mhfu/charge.h), on the block em_vhook keeps. + * Free of PSP headers and of game memory, so the host test compiles it (tests/charge_host.cpp); + * em_vhook.cpp's brain reads the monster and calls these. */ +#ifndef MHFU_CORE_CHARGE_LOGIC_H +#define MHFU_CORE_CHARGE_LOGIC_H + +#include + +#include "mhfu/charge.h" + +typedef volatile mhfu_charge_t chg_t; + +/* Why a configuration cannot run, or NULL. */ +static inline const char *chg_check(const mhfu_charge_cfg_t *c) +{ + if (c->up_count > MHFU_CHARGE_MAX_UPS) return "too many charge-ups"; + if (c->pair_count > MHFU_CHARGE_MAX_PAIRS) return "too many discharge pairs"; + if (c->transform != MHFU_CHARGE_NO_SLOT && c->transform >= MHFU_EM_MOVES_MOVES_COUNT) + return "a transform slot past the registry"; + for (unsigned k = 0; k < c->up_count; k++) { + if (c->ups[k].slot >= MHFU_EM_MOVES_MOVES_COUNT) return "a charge-up slot past the registry"; + for (int b = 0; b < 4; b++) + if (c->ups[k].gain[b] < 0 || c->ups[k].gain[b] > MHFU_CHARGE_FULL) + return "a gain is 0..100"; + } + if (c->chip_gauge < 0 || c->chip_gauge > MHFU_CHARGE_FULL + || c->chip_durability < 0 || c->chip_durability > MHFU_CHARGE_FULL) + return "a chip is 0..100"; + return 0; +} + +/* The HP band of a charge-up's gain: 0 at or above 95 % of max_hp, 1 at or above 75 %, 2 at or + * above 40 %, else 3; no maximum reads as full HP. */ +static inline int chg_band(uint32_t hp, uint32_t max_hp) +{ + if (!max_hp || hp * 100u >= 95u * max_hp) return 0; + if (hp * 100u >= 75u * max_hp) return 1; + return hp * 100u >= 40u * max_hp ? 2 : 3; +} + +/* The index of the charge-up of own-move slot `slot`, or -1. */ +static inline int chg_up_index(const volatile mhfu_charge_cfg_t *c, uint32_t slot) +{ + for (uint32_t k = 0; k < c->up_count && k < MHFU_CHARGE_MAX_UPS; k++) + if (c->ups[k].slot == slot) return (int)k; + return -1; +} + +/* The host pair is in the discharge set. */ +static inline int chg_pair_discharges(const volatile mhfu_charge_cfg_t *c, uint32_t pair) +{ + for (uint32_t k = 0; k < c->pair_count && k < MHFU_CHARGE_MAX_PAIRS; k++) + if (c->pairs[k].main == (pair >> 8) && c->pairs[k].sub == (pair & 0xFF)) return 1; + return 0; +} + +/* A new monster, or none: the state after the configuration goes back to nothing seen. */ +static inline void chg_reset(chg_t *c) +{ + for (unsigned k = 0; k < MHFU_CHARGE_ANSWERS_N; k++) c->answers[k] = 0; + c->entity = 0; + c->gauge = c->durability = c->cooldown = 0; + c->charged = c->seen = c->hp = c->xform_at = 0; + c->up_starts = c->transforms = c->discharges = c->hits = 0; +} + +/* What the query slot says for the state: "charged" and "the cooldown runs", nothing else. */ +static inline void chg_answers(chg_t *c) +{ + for (unsigned k = 0; k < MHFU_CHARGE_ANSWERS_N; k++) c->answers[k] = 0; + c->answers[MHFU_CHARGE_QUERY_CHARGED] = c->charged != 0; + c->answers[MHFU_CHARGE_QUERY_COOLDOWN] = c->cooldown > 0; +} + +/* The first AI step: the baseline the hits are measured against. */ +static inline void chg_start(chg_t *c, uint32_t entity, uint32_t hp) +{ + c->entity = entity; + c->hp = hp; + c->seen = 1; +} + +/* One AI frame of the cooldown, which only runs while the monster is not charged. */ +static inline void chg_tick(chg_t *c) +{ + if (!c->charged && c->cooldown > 0) c->cooldown--; +} + +/* The move player took own move `slot`: a charge-up raises the gauge by the HP band and starts + * the cooldown (1); a charged monster takes nothing. */ +static inline int chg_up_start(chg_t *c, uint32_t slot, uint32_t hp, uint32_t max_hp) +{ + const int k = chg_up_index(&c->cfg, slot); + if (k < 0 || c->charged) return 0; + int32_t g = c->gauge + c->cfg.ups[k].gain[chg_band(hp, max_hp)]; + if (g > MHFU_CHARGE_FULL) g = MHFU_CHARGE_FULL; + c->gauge = g; + c->cooldown = c->cfg.cooldown_up; + c->up_starts++; + return 1; +} + +/* The move player took the transform's slot (1): charged, full gauge and durability. */ +static inline int chg_transform_start(chg_t *c, uint32_t slot) +{ + if (c->cfg.transform == MHFU_CHARGE_NO_SLOT || slot != c->cfg.transform) return 0; + c->charged = 1; + c->durability = c->gauge = MHFU_CHARGE_FULL; + c->transforms++; + return 1; +} + +/* A hit: the gauge's chip while not charged, the durability's while charged. */ +static inline void chg_hit(chg_t *c) +{ + volatile int32_t *v = c->charged ? &c->durability : &c->gauge; + const int32_t chip = c->charged ? c->cfg.chip_durability : c->cfg.chip_gauge; + *v = *v > chip ? *v - chip : 0; + c->hits++; +} + +/* Charged to not: gauge and durability 0, the cooldown starts; 1 on that edge. */ +static inline int chg_discharge(chg_t *c) +{ + if (!c->charged) return 0; + c->charged = 0; + c->gauge = c->durability = 0; + c->cooldown = c->cfg.cooldown_out; + c->discharges++; + return 1; +} + +/* A charged monster in a discharge pair or with no durability left discharges; 1 on that edge. */ +static inline int chg_settle(chg_t *c, uint32_t pair) +{ + if (!c->charged) return 0; + if (c->durability > 0 && !chg_pair_discharges(&c->cfg, pair)) return 0; + return chg_discharge(c); +} + +/* The gauge is full, nothing is charged and a transform is set: the helper plays it. */ +static inline int chg_wants_transform(const chg_t *c) +{ + return !c->charged && c->gauge >= MHFU_CHARGE_FULL && c->cfg.transform != MHFU_CHARGE_NO_SLOT; +} + +#endif /* MHFU_CORE_CHARGE_LOGIC_H */ diff --git a/framework/src/core/em_vhook.cpp b/framework/src/core/em_vhook.cpp index c03bfedd..9ffa07e1 100644 --- a/framework/src/core/em_vhook.cpp +++ b/framework/src/core/em_vhook.cpp @@ -1,7 +1,7 @@ /* SPDX-License-Identifier: MIT */ /* SPDX-FileCopyrightText: 2026 sp00ktober */ /* - * em_vhook: takes over a big monster's AI by wrapping four slots of its species + * em_vhook: takes over a big monster's AI by wrapping five slots of its species * vtable. A species' AI is an overlay reached through a vtable in the EBOOT; the * slots are writable and re-read on every dispatch, and a wrapper that tail-calls * the original is indistinguishable from the original. Only a word is written. @@ -13,6 +13,8 @@ * attacks, effects, sounds * OBJ_VTABLE.BRAIN the per-frame brain around the AI step, which * poses the joints (POSE_UPDATE, ATTACH) + * slot 53, MONSTER_VTABLE.QUERY the species' answer to the AI script's switch_query: + * (entity, arg) * * The stubs read a config block on every dispatch, so all of this is retargetable * from Lua without a rebuild (public surface: mhfu/em_vhook.h): @@ -59,6 +61,12 @@ * species' own gate. * BUDGET slot-32 post + slot-29 one-shot: the ENTITY.ACTION_BUDGET * override; the stubs carry it, but nothing arms it. + * CHARGE the brain keeps a gauge, a charged state and a cooldown for the board's + * monster, moved by the charge-up own moves it starts, the hits it takes and the + * pairs it enters, and the transform own move it plays when the gauge is full; the + * query slot's stub answers the AI script from the block's table (mhfu/charge.h): + * a frame-free leaf returning answers[arg] for that monster and tail-calling the + * species' own query for every other, a data write like the other slots. * FATIGUE the brain keeps a stamina for the board's monster, charged when it enters a * pair and when one of our own moves starts, and writes gauge A for the AI * script's if_gauge_a_low to read as "tired" (mhfu/fatigue.h). @@ -92,6 +100,7 @@ * the dwell counter and the distance. */ #include "mhfu/call.h" +#include "mhfu/charge.h" #include "mhfu/em_vhook.h" #include "mhfu/events.h" #include "mhfu/fatigue.h" @@ -102,6 +111,7 @@ #include "mhfu/monster_events.h" #include "internal.h" #include "em_vhook_stubs.h" +#include "charge_logic.h" #include "fatigue_logic.h" #include @@ -218,6 +228,30 @@ static_assert(offsetof(mhfu_fatigue_t, cfg) == MHFU_FATIGUE_CFG, "FATI static_assert(offsetof(mhfu_fatigue_t, stamina) == MHFU_FATIGUE_STAMINA, "FATIGUE layout"); static_assert(offsetof(mhfu_fatigue_t, pair) == MHFU_FATIGUE_PAIR, "FATIGUE layout"); static_assert(offsetof(mhfu_fatigue_t, gauge) == MHFU_FATIGUE_GAUGE, "FATIGUE layout"); +static_assert(sizeof(mhfu_charge_up_t) == MHFU_CHARGE_UP_SIZE, "CHARGE_UP layout"); +static_assert(offsetof(mhfu_charge_up_t, gain) == MHFU_CHARGE_UP_GAIN, "CHARGE_UP layout"); +static_assert(sizeof(mhfu_charge_pair_t) == MHFU_CHARGE_PAIR_SIZE, "CHARGE_PAIR layout"); +static_assert(sizeof(mhfu_charge_cfg_t) == MHFU_CHARGE_CFG_SIZE, "CHARGE_CFG layout"); +static_assert(offsetof(mhfu_charge_cfg_t, pair_count) == MHFU_CHARGE_CFG_PAIR_COUNT, + "CHARGE_CFG layout"); +static_assert(offsetof(mhfu_charge_cfg_t, chip_gauge) == MHFU_CHARGE_CFG_CHIP_GAUGE, + "CHARGE_CFG layout"); +static_assert(offsetof(mhfu_charge_cfg_t, chip_durability) == MHFU_CHARGE_CFG_CHIP_DURABILITY, + "CHARGE_CFG layout"); +static_assert(offsetof(mhfu_charge_cfg_t, cooldown_up) == MHFU_CHARGE_CFG_COOLDOWN_UP, + "CHARGE_CFG layout"); +static_assert(offsetof(mhfu_charge_cfg_t, cooldown_out) == MHFU_CHARGE_CFG_COOLDOWN_OUT, + "CHARGE_CFG layout"); +static_assert(offsetof(mhfu_charge_cfg_t, ups) == MHFU_CHARGE_CFG_UPS, "CHARGE_CFG layout"); +static_assert(offsetof(mhfu_charge_cfg_t, pairs) == MHFU_CHARGE_CFG_PAIRS, "CHARGE_CFG layout"); +static_assert(sizeof(mhfu_charge_t) == MHFU_CHARGE_SIZE, "CHARGE layout"); +static_assert(offsetof(mhfu_charge_t, entity) == MHFU_CHARGE_ENTITY, "CHARGE layout"); +static_assert(offsetof(mhfu_charge_t, asked) == MHFU_CHARGE_ASKED, "CHARGE layout"); +static_assert(offsetof(mhfu_charge_t, cfg) == MHFU_CHARGE_CFG, "CHARGE layout"); +static_assert(offsetof(mhfu_charge_t, gauge) == MHFU_CHARGE_GAUGE, "CHARGE layout"); +static_assert(offsetof(mhfu_charge_t, hp) == MHFU_CHARGE_HP, "CHARGE layout"); +static_assert(offsetof(mhfu_charge_t, hits) == MHFU_CHARGE_HITS, "CHARGE layout"); +static_assert(MHFU_CHARGE_ANSWERS == 0 && MHFU_CHARGE_ANSWERS_N == 8, "CHARGE layout"); #define CFG_CANARY_VAL 0x5645484Bu /* 'VEHK' */ @@ -242,20 +276,28 @@ static_assert(sizeof(tail_t) % 4 == 0, "zeroed word by word after the config"); * They come from the user partition, and only pointers live here. */ #define RET_INSNS 4 /* jr ra; nop, padded to 16 bytes */ #define STUBS (STUB_AI_INSNS + STUB_ACT_INSNS + STUB_EVT_INSNS + 2 * MHFU_WRAP_WORDS \ - + RET_INSNS) -#define BLOCK_BYTES (STUBS * 4 + CFG_SIZE + sizeof(tail_t) + sizeof(mhfu_em_board_t) \ - + sizeof(mhfu_fatigue_t) + 128) + + STUB_QRY_INSNS + RET_INSNS) +#define BLOCK_BYTES (STUBS * 4 + MHFU_EM_BLOCK_SIZE + 128) +static_assert(CFG_SIZE + sizeof(tail_t) + sizeof(mhfu_em_board_t) + sizeof(mhfu_fatigue_t) + + sizeof(mhfu_charge_t) == MHFU_EM_BLOCK_SIZE, "EM_BLOCK layout"); +static_assert(CFG_SIZE + sizeof(tail_t) == MHFU_EM_BLOCK_BOARD, "EM_BLOCK layout"); +static_assert(MHFU_EM_BLOCK_BOARD + sizeof(mhfu_em_board_t) == MHFU_EM_BLOCK_FATIGUE, + "EM_BLOCK layout"); +static_assert(MHFU_EM_BLOCK_FATIGUE + sizeof(mhfu_fatigue_t) == MHFU_EM_BLOCK_CHARGE, + "EM_BLOCK layout"); static uint32_t *g_stub_ai; static uint32_t *g_stub_act; static uint32_t *g_stub_evt; static uint32_t *g_stub_brain; /* the OBJ_VTABLE.BRAIN wrapper (wrap.h) */ static uint32_t *g_stub_gate; /* the OBJ_VTABLE.CUT_GATE wrapper */ +static uint32_t *g_stub_qry; /* the MONSTER_VTABLE.QUERY stub */ static uint32_t *g_stub_ret; static em_vhook_cfg_t *g_cfgp; static volatile tail_t *T; static volatile mhfu_em_board_t *g_board; /* after the tail: the board the rules and Lua share */ static volatile mhfu_fatigue_t *g_fat; /* after the board: the fatigue helper's block */ +static volatile mhfu_charge_t *g_chg; /* after the fatigue block: the charge helper's */ static volatile mhfu_em_moves_t *R; /* the own-move registry */ #define MOVES_MAGIC 0x564F4D45u /* 'EMOV' */ @@ -300,15 +342,15 @@ static void place_block(uint8_t *base) g_stub_brain = (uint32_t *)(base + (STUB_AI_INSNS + STUB_ACT_INSNS + STUB_EVT_INSNS) * 4); g_stub_gate = (uint32_t *)(base + (STUB_AI_INSNS + STUB_ACT_INSNS + STUB_EVT_INSNS + MHFU_WRAP_WORDS) * 4); + g_stub_qry = (uint32_t *)(base + (STUBS - RET_INSNS - STUB_QRY_INSNS) * 4); g_stub_ret = (uint32_t *)(base + (STUBS - RET_INSNS) * 4); g_cfgp = (em_vhook_cfg_t *)(base + STUBS * 4); T = (volatile tail_t *)(base + STUBS * 4 + CFG_SIZE); g_board = (volatile mhfu_em_board_t *)(base + STUBS * 4 + CFG_SIZE + sizeof(tail_t)); g_fat = (volatile mhfu_fatigue_t *)(base + STUBS * 4 + CFG_SIZE + sizeof(tail_t) + sizeof(mhfu_em_board_t)); - for (unsigned k = 0; k < (CFG_SIZE + sizeof(tail_t) + sizeof(mhfu_em_board_t) - + sizeof(mhfu_fatigue_t)) / 4; k++) - ((uint32_t *)g_cfgp)[k] = 0; + g_chg = (volatile mhfu_charge_t *)(base + STUBS * 4 + MHFU_EM_BLOCK_CHARGE); + for (unsigned k = 0; k < MHFU_EM_BLOCK_SIZE / 4; k++) ((uint32_t *)g_cfgp)[k] = 0; cfg_reset_live(); } @@ -374,8 +416,10 @@ static uint32_t g_orig_ai; static uint32_t g_orig_act; static uint32_t g_orig_evt; static uint32_t g_orig_gate; /* the species' own CUT_GATE */ +static uint32_t g_orig_qry; /* the species' own QUERY */ #endif static int g_installed; +static int g_qry_wrapped; /* MONSTER_VTABLE.QUERY carries the charge stub */ /* --- the brain: slot 29 calls it every AI frame, before the C step. ---- * @@ -451,6 +495,7 @@ static const volatile mhfu_steer_spec_t *steer_of(const volatile mhfu_em_own_t * } static void fat_own(uint32_t ent, uint32_t slot); +static void chg_own(uint32_t ent, uint32_t slot); /* the move player's next move is slot's, on ent: what ours() reads */ static void tag(uint32_t ent, uint32_t slot, const volatile mhfu_move_state_t *m) @@ -459,6 +504,7 @@ static void tag(uint32_t ent, uint32_t slot, const volatile mhfu_move_state_t *m R->tag_started = m->started + 1; /* the step starts it right after us */ R->plays++; fat_own(ent, slot); + chg_own(ent, slot); } static int play_slot(uint32_t ent, uint32_t slot, const volatile mhfu_move_state_t *m, int force) @@ -474,6 +520,18 @@ static int play_slot(uint32_t ent, uint32_t slot, const volatile mhfu_move_state return 1; } +/* The move that just ended on its clip's end into its carrier or on a wall names an AFTER, 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) + return -1; + return k; +} + /* 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) { @@ -674,11 +732,60 @@ static void fat_off(void) fat_forget(); } +/* --- charge: a gauge, a charged state and a cooldown for the board's monster, answered through + * the species' query slot (mhfu/charge.h) ------------------------------------------------------ */ + +/* A new monster, or none: nothing seen, and the query stub answers for nobody. */ +static void chg_forget(void) { chg_reset(g_chg); } + +static void chg_off(void) +{ + g_chg->enabled = 0; + chg_forget(); +} + +/* Each AI frame of the board's monster: the baseline, the cooldown, the hits, a discharge, and the + * transform once the gauge is full and the move player has nothing of ours to finish. */ +static void chg_frame(uint32_t ent, uint32_t pair, const volatile mhfu_move_state_t *m) +{ + chg_t *c = g_chg; + if (!c->enabled || ent != g_board->entity) return; + const uint32_t hp = mhfu_mem_read_u16(ent + MHFU_ENTITY_HP); + if (!c->seen) { + chg_start(c, ent, hp); + } else { + chg_tick(c); + if (hp < c->hp) chg_hit(c); + c->hp = hp; + chg_settle(c, pair); + const uint32_t now = g_cfgp->ai_ticks; + if (chg_wants_transform(c) && hp && !busy(m) && chain_due(ent, m) < 0 && !R->req_ent + && !chg_pair_discharges(&c->cfg, pair) + && (!c->xform_at || now - c->xform_at >= MHFU_CHARGE_ASK_GAP) + && mhfu_em_play(ent, c->cfg.transform, 1)) + c->xform_at = now; + } + chg_answers(c); +} + +/* The move player took own move `slot` on ent: the transform's start charges, a charge-up's + * raises the gauge. */ +static void chg_own(uint32_t ent, uint32_t slot) +{ + chg_t *c = g_chg; + if (!c->enabled || !c->seen || ent != g_board->entity) return; + if (!chg_transform_start(c, slot)) + chg_up_start(c, slot, mhfu_mem_read_u16(ent + MHFU_ENTITY_HP), + mhfu_mem_read_u16(ent + MHFU_ENTITY_MAX_HP)); + chg_answers(c); +} + /* ent's board: counters, signals and parts 0, the generator reseeded; ent 0 for none yet. The * monster is due its anger threshold. */ static void board_reset(uint32_t ent) { fat_forget(); + chg_forget(); volatile uint32_t *w = (volatile uint32_t *)g_board; for (unsigned k = 0; k < sizeof(mhfu_em_board_t) / 4; k++) w[k] = 0; g_thr_ent = 0; @@ -978,16 +1085,14 @@ static int act(uint32_t ent, const volatile mhfu_move_state_t *m, const seen_t * { scan(ent, m, s, 1); int took = 0, plays = 0; - int k = ours(m); + 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); } else if (react_take(ent, m, s)) { took = 1; - } else 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) { - if (play_slot(ent, R->moves[k].after, m, m->move.force)) R->chained++; + } else if (due >= 0) { + if (play_slot(ent, R->moves[due].after, m, m->move.force)) R->chained++; else R->tag_slot = MHFU_EM_NO_MOVE; /* an empty AFTER: not again */ } else { plays = 1; @@ -1073,6 +1178,7 @@ static uint32_t brain(uint32_t ent) fat_frame(ent, pair); const volatile mhfu_move_state_t *m = mhfu_move_state(); if (!m || !R) return 0; + chg_frame(ent, pair, m); seen_t seen = {}; seen.edges = mhfu_monster_events_frame(ent, m->entity == ent && m->skipping, &seen.parts, &seen.broke); @@ -1206,6 +1312,23 @@ static void build_events_stub(uint32_t original) } for (int k = n; k < STUB_EVT_INSNS; k++) g_stub_evt[k] = MIPS_NOP; } + +/* --- MONSTER_VTABLE.QUERY: the charge block's monster answered from its table, any other + * entity by the species' own query. Frame-free and branchless (emv_build_query_stub). 0 when the + * stub does not fit. --- */ +static int build_query_stub(uint32_t original) +{ + int overflow = 0; + int n = emv_build_query_stub(g_stub_qry, STUB_QRY_INSNS, (uint32_t)(uintptr_t)g_chg, original, + (uint32_t)(uintptr_t)g_stub_ret, &overflow); + if (overflow) { + mhfu_log("[%s] query stub is %d insns > STUB_QRY_INSNS %d - no charge", OWNER, n, + STUB_QRY_INSNS); + return 0; + } + for (int k = n; k < STUB_QRY_INSNS; k++) g_stub_qry[k] = MIPS_NOP; + return 1; +} #endif /* ------------------------------------------------------------ public API */ @@ -1365,6 +1488,7 @@ extern "C" void mhfu_em_clear(void) g_natural = 1; g_thr_pending = 1; fat_off(); + chg_off(); } /* --- the tail tip --- */ @@ -1563,6 +1687,60 @@ extern "C" void mhfu_em_fatigue_status(mhfu_fatigue_status_t *out) out->gauge = g_fat->gauge; } +/* --- charge --- */ + +extern "C" int mhfu_em_charge(const mhfu_charge_cfg_t *cfg) +{ + if (!g_chg) return 0; + if (!cfg) { + const int was = g_chg->enabled != 0; + chg_off(); + if (was) mhfu_log("[%s] charge off", OWNER); + return 1; + } + if (const char *bad = chg_check(cfg)) { + mhfu_log("[%s] charge refused: %s", OWNER, bad); + return 0; + } + if (g_installed && !g_qry_wrapped) { + mhfu_log("[%s] charge refused: the species' query slot is not wrapped", OWNER); + return 0; + } + /* the same settings again change nothing, the state included */ + const volatile uint32_t *live = (const volatile uint32_t *)&g_chg->cfg; + const uint32_t *next = (const uint32_t *)cfg; + int same = g_chg->enabled != 0; + for (unsigned k = 0; same && k < sizeof(*cfg) / 4; k++) same = live[k] == next[k]; + if (same) return 1; + g_chg->enabled = 0; /* off while we write, as a rule's LEFT */ + copy_words(&g_chg->cfg, cfg, sizeof(*cfg)); + chg_forget(); + g_chg->enabled = 1; + mhfu_log("[%s] charge: transform slot %d, %u charge-up(s), chips %d/%d, cooldowns %u/%u, " + "%u discharge pair(s)", OWNER, + cfg->transform == MHFU_CHARGE_NO_SLOT ? -1 : (int)cfg->transform, + (unsigned)cfg->up_count, (int)cfg->chip_gauge, (int)cfg->chip_durability, + (unsigned)cfg->cooldown_up, (unsigned)cfg->cooldown_out, (unsigned)cfg->pair_count); + return 1; +} + +extern "C" void mhfu_em_charge_status(mhfu_charge_status_t *out) +{ + if (!out) return; + *out = mhfu_charge_status_t(); + if (!g_chg) return; + out->armed = g_chg->enabled; + out->charged = g_chg->charged; + out->gauge = g_chg->gauge; + out->durability = g_chg->durability; + out->cooldown = g_chg->cooldown; + out->up_starts = g_chg->up_starts; + out->transforms = g_chg->transforms; + out->discharges = g_chg->discharges; + out->hits = g_chg->hits; + out->asked = g_chg->asked; +} + /* --- rage and the cut's gate --- */ extern "C" int mhfu_em_rage(int on) @@ -1598,6 +1776,7 @@ static void quest_reset(void) { R->tag_slot = MHFU_EM_NO_MOVE; fat_off(); + chg_off(); board_reset(0); } @@ -1651,6 +1830,23 @@ static void wrap_brain(uint32_t vt) static int g_gate_wrapped; +/* The species' query slot, wrapped for the charge helper: a vtable swap, claimed with the rest. It + * is not needed for the rest, so a failure is logged and mhfu_em_charge refuses. */ +static void wrap_query(uint32_t vt) +{ + const uint32_t slot = vt + MHFU_MONSTER_VTABLE_QUERY; + g_orig_qry = mhfu_mem_read_u32(slot); + if (!g_orig_qry || !build_query_stub(g_orig_qry)) { + mhfu_log("[%s] query 0x%08X: no wrapper, no charge", OWNER, (unsigned)g_orig_qry); + return; + } + mhfu_hook_flush_caches(); + mhfu_hook_rc_t rc = mhfu_hook_vtable(slot, (uint32_t)(uintptr_t)g_stub_qry, OWNER); + g_qry_wrapped = rc == MHFU_HOOK_OK; + mhfu_log("[%s] query 0x%08X -> 0x%08X%s", OWNER, (unsigned)g_orig_qry, + (unsigned)(uintptr_t)g_stub_qry, g_qry_wrapped ? "" : ": claim failed, no charge"); +} + /* The cut's gate of this species, wrapped on the first percent; a vtable swap, claimed with the * rest and restored with them. 0 when it cannot be. */ static int wrap_gate(void) @@ -1722,6 +1918,7 @@ static void install_for(uint32_t entity) (unsigned)(uintptr_t)g_stub_act, (unsigned)g_orig_evt, (unsigned)(uintptr_t)g_stub_evt); wrap_brain(vt); + wrap_query(vt); } static void uninstall(void) @@ -1731,6 +1928,7 @@ static void uninstall(void) g_installed = 0; g_tip_wrapped = 0; g_gate_wrapped = 0; + g_qry_wrapped = 0; T->count = 0; g_cfgp->step_fn = 0; mhfu_em_clear(); @@ -1800,6 +1998,7 @@ extern "C" int mhfu_em_init(void) place_moves(g_host_moves); g_installed = 1; g_tip_wrapped = 1; + g_qry_wrapped = 1; g_host_step = 0; rage_reset(); mhfu_move_init(); @@ -1812,6 +2011,15 @@ extern "C" void mhfu_em_host_latch(uint32_t vtable) { T->vtable = vtable; } /* the species' gate slot as its wrapper runs it: gate_call, calling mhfu_host_gate for the original */ extern "C" uint32_t mhfu_em_host_gate(uint32_t ent) { return gate_call(ent); } +/* the species' query slot as its stub runs it: the table for the charge block's monster, 0 for + * the species' own (the game_task default) and for an arg past the table */ +extern "C" uint32_t mhfu_em_host_query(uint32_t ent, uint32_t arg) +{ + const int own = g_chg->entity && g_chg->entity == ent; + if (own) g_chg->asked = g_chg->asked + 1; + return own && arg < MHFU_CHARGE_ANSWERS_N ? g_chg->answers[arg] : 0; +} + /* the monster spawned, as on_spawn's */ extern "C" void mhfu_em_host_spawn(uint32_t ent) { board_reset(ent); } diff --git a/framework/src/core/em_vhook_stubs.h b/framework/src/core/em_vhook_stubs.h index 841ecd8a..8f13480b 100644 --- a/framework/src/core/em_vhook_stubs.h +++ b/framework/src/core/em_vhook_stubs.h @@ -1,7 +1,7 @@ /* SPDX-License-Identifier: MIT */ /* SPDX-FileCopyrightText: 2026 sp00ktober */ /* - * em_vhook's three stubs, assembled word by word. Plain C with no PSP SDK (only + * em_vhook's four stubs, assembled word by word. Plain C with no PSP SDK (only * , mhfu/mips.h and addresses.gen.h), so the same builders also compile * on the host, where a check disassembles the words and asserts: * - no branch in either stub: every decision is a MOVN/MOVZ select, so the JIT @@ -99,6 +99,7 @@ #define STUB_AI_INSNS 480 #define STUB_ACT_INSNS 272 #define STUB_EVT_INSNS 16 +#define STUB_QRY_INSNS 24 /* -------------------------------------------------------------------------- */ /* register aliases used below */ @@ -547,6 +548,46 @@ static inline int emv_build_events_stub(uint32_t *out, int cap, uint32_t cfg, return A.i; } +/* --- the species' query (MONSTER_VTABLE.QUERY), what the AI script's switch_query asks: + * query(entity, arg). For the charge block's monster the answer is its table's entry (v0 = + * answers[arg], 0 past the table) and the ret stub returns it; any other entity, or no monster + * yet, runs the species' own query with a0 and a1 as they came. Frame-free and branchless: the + * table is read whatever the entity, and one jr goes to the original or to the ret stub. ---- */ +static_assert(MHFU_CHARGE_ANSWERS_COUNT == 8, "the stub masks the arg with 7"); +static inline int emv_build_query_stub(uint32_t *out, int cap, uint32_t chg, uint32_t original, + uint32_t ret_stub, int *overflow) +{ + emv_asm_t A = { out, 0, cap, 0 }; + emv_asm_t *a = &A; + + E(mips_lui(R_T7, (uint16_t)(chg >> 16))); + E(mips_ori(R_T7, R_T7, (uint16_t)chg)); + E(mips_lw(R_T0, MHFU_CHARGE_ENTITY, R_T7)); + E(mips_xor(R_T1, R_T0, R_A0)); + E(mips_sltiu(R_T1, R_T1, 1)); /* the entity is the block's */ + E(mips_sltu(R_T0, R_ZERO, R_T0)); /* the block has one */ + E(mips_and(R_T1, R_T1, R_T0)); /* t1 = answer it */ + E(mips_sltiu(R_T2, R_A1, MHFU_CHARGE_ANSWERS_COUNT)); /* the arg is in the table */ + E(mips_andi(R_T3, R_A1, MHFU_CHARGE_ANSWERS_COUNT - 1)); + E(mips_sll(R_T3, R_T3, 2)); + E(mips_addu(R_T3, R_T3, R_T7)); + E(mips_lw(R_V0, MHFU_CHARGE_ANSWERS, R_T3)); + E(mips_movz(R_V0, R_ZERO, R_T2)); /* an arg past the table answers 0 */ + E(mips_lw(R_T4, MHFU_CHARGE_ASKED, R_T7)); + E(mips_addu(R_T4, R_T4, R_T1)); + E(mips_sw(R_T4, MHFU_CHARGE_ASKED, R_T7)); + E(mips_lui(R_T9, (uint16_t)(original >> 16))); + E(mips_ori(R_T9, R_T9, (uint16_t)original)); + E(mips_lui(R_T6, (uint16_t)(ret_stub >> 16))); + E(mips_ori(R_T6, R_T6, (uint16_t)ret_stub)); + E(mips_movn(R_T9, R_T6, R_T1)); + E(mips_jr(R_T9)); + E(MIPS_NOP); + + if (overflow) *overflow = A.overflow; + return A.i; +} + /* the no-op callee the conditional call lands on when it does not fire */ static inline int emv_build_ret_stub(uint32_t *out) { diff --git a/framework/tests/charge_host.cpp b/framework/tests/charge_host.cpp new file mode 100644 index 00000000..b1028ef5 --- /dev/null +++ b/framework/tests/charge_host.cpp @@ -0,0 +1,24 @@ +/* SPDX-License-Identifier: MIT */ +/* SPDX-FileCopyrightText: 2026 sp00ktober */ +/* The charge helper's pure parts (src/core/charge_logic.h) for the host test, through ctypes. */ +#include "charge_logic.h" + +extern "C" { +const char *chg_host_check(const mhfu_charge_cfg_t *c) { return chg_check(c); } +int chg_host_band(uint32_t hp, uint32_t max_hp) { return chg_band(hp, max_hp); } +int chg_host_up_index(mhfu_charge_t *c, uint32_t slot) { return chg_up_index(&c->cfg, slot); } +int chg_host_pair(mhfu_charge_t *c, uint32_t pair) { return chg_pair_discharges(&c->cfg, pair); } +void chg_host_reset(mhfu_charge_t *c) { chg_reset(c); } +void chg_host_answers(mhfu_charge_t *c) { chg_answers(c); } +void chg_host_start(mhfu_charge_t *c, uint32_t entity, uint32_t hp) { chg_start(c, entity, hp); } +void chg_host_tick(mhfu_charge_t *c) { chg_tick(c); } +int chg_host_up_start(mhfu_charge_t *c, uint32_t slot, uint32_t hp, uint32_t max_hp) +{ + return chg_up_start(c, slot, hp, max_hp); +} +int chg_host_transform_start(mhfu_charge_t *c, uint32_t slot) { return chg_transform_start(c, slot); } +void chg_host_hit(mhfu_charge_t *c) { chg_hit(c); } +int chg_host_discharge(mhfu_charge_t *c) { return chg_discharge(c); } +int chg_host_settle(mhfu_charge_t *c, uint32_t pair) { return chg_settle(c, pair); } +int chg_host_wants_transform(mhfu_charge_t *c) { return chg_wants_transform(c); } +} diff --git a/framework/tests/test_charge.py b/framework/tests/test_charge.py new file mode 100644 index 00000000..ceeae944 --- /dev/null +++ b/framework/tests/test_charge.py @@ -0,0 +1,259 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 sp00ktober +"""The charge helper's arithmetic and state machine (src/core/charge_logic.h) on the host. The +brain's use of it, against a game, is in test_em_brain.py; the query stub's, in +test_em_vhook_stubs.py.""" + +import ctypes +import struct +from collections.abc import Callable + +import pytest +from mhfu import addresses as a + +C = a.CHARGE +G = a.CHARGE_CFG +NO_SLOT = 0xFF +FULL = 100 +CHARGED, COOLDOWN = int(a.CHARGE_QUERY_CHARGED), int(a.CHARGE_QUERY_COOLDOWN) +PAIR_KO = (11 << 8) | 0 + + +@pytest.fixture(scope="module") +def lib(host_lib: Callable[..., ctypes.CDLL]) -> ctypes.CDLL: + lib = host_lib("tests/charge_host.cpp") + u32, vp = ctypes.c_uint32, ctypes.c_void_p + lib.chg_host_check.restype = ctypes.c_char_p + lib.chg_host_check.argtypes = [vp] + lib.chg_host_band.argtypes = [u32, u32] + lib.chg_host_up_index.argtypes = [vp, u32] + lib.chg_host_pair.argtypes = [vp, u32] + for name in ("reset", "answers", "tick", "hit"): + getattr(lib, f"chg_host_{name}").argtypes = [vp] + lib.chg_host_start.argtypes = [vp, u32, u32] + lib.chg_host_up_start.argtypes = [vp, u32, u32, u32] + lib.chg_host_transform_start.argtypes = [vp, u32] + lib.chg_host_discharge.argtypes = [vp] + lib.chg_host_settle.argtypes = [vp, u32] + lib.chg_host_wants_transform.argtypes = [vp] + return lib + + +class Block: + """A CHARGE block in memory: a Zinogre-like setting unless told otherwise.""" + + def __init__( + self, + *, + transform: int = 9, + ups: tuple[tuple[int, tuple[int, int, int, int]], ...] = ( + (3, (20, 15, 10, 5)), + (4, (30, 25, 20, 10)), + ), + pairs: tuple[tuple[int, int], ...] = ((11, 0), (11, 1)), + chip_gauge: int = 5, + chip_durability: int = 10, + up: int = 420, + out: int = 900, + ) -> None: + self.buf = ctypes.create_string_buffer(int(C.size or 0)) + self.cfg = ctypes.create_string_buffer(int(G.size or 0)) + struct.pack_into(" int: + return int(struct.unpack_from("<" + fmt, self.buf, int(getattr(C, field)))[0]) + + def put(self, field: str, v: int, fmt: str = "i") -> None: + struct.pack_into("<" + fmt, self.buf, int(getattr(C, field)), v) + + def answers(self) -> list[int]: + return list(struct.unpack_from("<8I", self.buf, int(C.ANSWERS))) + + +def test_layout() -> None: + assert C.CFG + G.size == C.GAUGE + assert (C.ANSWERS.count, G.UPS.count, G.PAIRS.count) == (8, 8, 8) + assert (CHARGED, COOLDOWN) == (0, 6) + assert a.MONSTER_VTABLE.QUERY == 0xDC and a.EM_BLOCK.CHARGE + C.size == a.EM_BLOCK.size + + +def test_gain_bands_by_hp(lib: ctypes.CDLL) -> None: + band = lib.chg_host_band + assert [band(hp, 100) for hp in (100, 95, 94, 75, 74, 40, 39, 1, 0)] == [ + 0, + 0, + 1, + 1, + 2, + 2, + 3, + 3, + 3, + ] + assert band(5, 0) == 0 # no maximum: full + assert band(9500, 10000) == 0 and band(9499, 10000) == 1 + + +def test_a_charge_up_raises_the_gauge_by_hp_and_starts_the_cooldown(lib: ctypes.CDLL) -> None: + b = Block() + lib.chg_host_start(b.buf, 0x1000, 100) + assert lib.chg_host_up_start(b.buf, 3, 100, 100) == 1 + assert (b.get("GAUGE"), b.get("COOLDOWN"), b.get("UP_STARTS", "I")) == (20, 420, 1) + assert lib.chg_host_up_start(b.buf, 3, 80, 100) == 1 # >= 75 % + assert lib.chg_host_up_start(b.buf, 3, 50, 100) == 1 # >= 40 % + assert b.get("GAUGE") == 20 + 15 + 10 + assert lib.chg_host_up_start(b.buf, 4, 10, 100) == 1 # another up, low HP + assert b.get("GAUGE") == 55 + assert lib.chg_host_up_start(b.buf, 4, 100, 100) == 1 and b.get("GAUGE") == 85 + assert lib.chg_host_up_start(b.buf, 4, 100, 100) == 1 and b.get("GAUGE") == FULL # clamped + assert lib.chg_host_up_start(b.buf, 5, 100, 100) == 0 and b.get("GAUGE") == FULL # no up + + +def test_a_charged_monster_takes_no_gain(lib: ctypes.CDLL) -> None: + b = Block() + lib.chg_host_transform_start(b.buf, 9) + b.put("COOLDOWN", 0) + assert lib.chg_host_up_start(b.buf, 3, 100, 100) == 0 + assert (b.get("GAUGE"), b.get("COOLDOWN"), b.get("UP_STARTS", "I")) == (FULL, 0, 0) + + +def test_the_transform_start_charges(lib: ctypes.CDLL) -> None: + b = Block() + assert lib.chg_host_transform_start(b.buf, 3) == 0 # an up is not the transform + assert (b.get("CHARGED", "I"), b.get("TRANSFORMS", "I")) == (0, 0) + assert lib.chg_host_transform_start(b.buf, 9) == 1 + assert (b.get("CHARGED", "I"), b.get("GAUGE"), b.get("DURABILITY")) == (1, FULL, FULL) + assert b.get("TRANSFORMS", "I") == 1 + nothing = Block(transform=NO_SLOT) + assert lib.chg_host_transform_start(nothing.buf, NO_SLOT) == 0 # no slot is not slot 255 + assert nothing.get("CHARGED", "I") == 0 + + +def test_a_hit_chips_the_gauge_then_the_durability(lib: ctypes.CDLL) -> None: + b = Block(chip_gauge=5, chip_durability=40) + b.put("GAUGE", 12) + lib.chg_host_hit(b.buf) + lib.chg_host_hit(b.buf) + lib.chg_host_hit(b.buf) + assert (b.get("GAUGE"), b.get("DURABILITY"), b.get("HITS", "I")) == (0, 0, 3) # not below 0 + lib.chg_host_transform_start(b.buf, 9) + lib.chg_host_hit(b.buf) + assert (b.get("GAUGE"), b.get("DURABILITY")) == (FULL, 60) + lib.chg_host_hit(b.buf) + lib.chg_host_hit(b.buf) + assert b.get("DURABILITY") == 0 and b.get("CHARGED", "I") == 1 # settle discharges, not hit + + +def test_durability_at_zero_discharges(lib: ctypes.CDLL) -> None: + b = Block(chip_durability=50, out=900) + lib.chg_host_transform_start(b.buf, 9) + lib.chg_host_hit(b.buf) + assert lib.chg_host_settle(b.buf, 0) == 0 + lib.chg_host_hit(b.buf) + assert lib.chg_host_settle(b.buf, 0) == 1 + assert (b.get("CHARGED", "I"), b.get("GAUGE"), b.get("DURABILITY")) == (0, 0, 0) + assert (b.get("COOLDOWN"), b.get("DISCHARGES", "I")) == (900, 1) + assert lib.chg_host_settle(b.buf, 0) == 0 and b.get("DISCHARGES", "I") == 1 # once + + +def test_a_discharge_pair_discharges_at_once(lib: ctypes.CDLL) -> None: + b = Block() + lib.chg_host_transform_start(b.buf, 9) + assert lib.chg_host_settle(b.buf, (2 << 8) | 10) == 0 + assert lib.chg_host_settle(b.buf, PAIR_KO) == 1 + assert (b.get("CHARGED", "I"), b.get("COOLDOWN")) == (0, 900) + assert lib.chg_host_settle(b.buf, PAIR_KO) == 0 # not charged: nothing to discharge + assert lib.chg_host_pair(b.buf, (11 << 8) | 1) == 1 and lib.chg_host_pair(b.buf, 11 << 9) == 0 + + +def test_the_cooldown_runs_only_while_not_charged(lib: ctypes.CDLL) -> None: + b = Block() + b.put("COOLDOWN", 3) + for want in (2, 1, 0, 0): + lib.chg_host_tick(b.buf) + assert b.get("COOLDOWN") == want + b.put("COOLDOWN", 5) + lib.chg_host_transform_start(b.buf, 9) + lib.chg_host_tick(b.buf) + assert b.get("COOLDOWN") == 5 # frozen while charged + lib.chg_host_discharge(b.buf) + assert b.get("COOLDOWN") == 900 + lib.chg_host_tick(b.buf) + assert b.get("COOLDOWN") == 899 + + +def test_answers_are_charged_and_cooldown_only(lib: ctypes.CDLL) -> None: + b = Block() + lib.chg_host_answers(b.buf) + assert b.answers() == [0] * 8 + b.put("COOLDOWN", 7) + lib.chg_host_answers(b.buf) + assert b.answers() == [0, 0, 0, 0, 0, 0, 1, 0] + b.put("CHARGED", 1, "I") + b.put("COOLDOWN", 0) + lib.chg_host_answers(b.buf) + assert b.answers() == [1, 0, 0, 0, 0, 0, 0, 0] + b.put("COOLDOWN", 900) # cooldown running while charged: both + lib.chg_host_answers(b.buf) + assert b.answers()[CHARGED] == 1 and b.answers()[COOLDOWN] == 1 + + +def test_the_transform_is_wanted_at_a_full_gauge_uncharged(lib: ctypes.CDLL) -> None: + b = Block() + for gauge, charged, want in ((99, 0, 0), (100, 0, 1), (100, 1, 0), (0, 0, 0)): + b.put("GAUGE", gauge) + b.put("CHARGED", charged, "I") + assert lib.chg_host_wants_transform(b.buf) == want + none = Block(transform=NO_SLOT) + none.put("GAUGE", FULL) + assert lib.chg_host_wants_transform(none.buf) == 0 + + +def test_reset_keeps_the_settings(lib: ctypes.CDLL) -> None: + b = Block() + lib.chg_host_start(b.buf, 0x1000, 50) + lib.chg_host_up_start(b.buf, 3, 100, 100) + lib.chg_host_transform_start(b.buf, 9) + lib.chg_host_answers(b.buf) + lib.chg_host_reset(b.buf) + for f in ("ENTITY", "SEEN", "HP", "CHARGED", "UP_STARTS", "TRANSFORMS", "XFORM_AT"): + assert b.get(f, "I") == 0, f + assert (b.get("GAUGE"), b.get("DURABILITY"), b.get("COOLDOWN")) == (0, 0, 0) + assert b.answers() == [0] * 8 + assert lib.chg_host_up_index(b.buf, 4) == 1 and lib.chg_host_up_index(b.buf, 9) == -1 + assert b.buf[int(C.CFG) : int(C.CFG) + int(G.size or 0)] == b.cfg.raw + + +def test_start_takes_the_baseline(lib: ctypes.CDLL) -> None: + b = Block() + lib.chg_host_start(b.buf, 0x1000, 420) + assert (b.get("ENTITY", "I"), b.get("HP", "I"), b.get("SEEN", "I")) == (0x1000, 420, 1) + + +def check(b: Block, lib: ctypes.CDLL) -> bytes | None: + return lib.chg_host_check(ctypes.byref(b.buf, int(C.CFG))) # type: ignore[no-any-return] + + +def test_check_accepts_the_defaults_and_refuses_the_rest(lib: ctypes.CDLL) -> None: + assert check(Block(), lib) is None + assert check(Block(transform=NO_SLOT), lib) is None + slots = int(a.EM_MOVES.MOVES.count or 0) + assert check(Block(transform=slots), lib) == b"a transform slot past the registry" + assert check(Block(transform=slots - 1), lib) is None + assert check(Block(ups=((slots, (1, 1, 1, 1)),)), lib) == b"a charge-up slot past the registry" + assert check(Block(ups=((3, (1, 1, 1, 101)),)), lib) == b"a gain is 0..100" + assert check(Block(ups=((3, (1, -1, 1, 1)),)), lib) == b"a gain is 0..100" + assert check(Block(chip_gauge=-1), lib) == b"a chip is 0..100" + assert check(Block(chip_durability=101), lib) == b"a chip is 0..100" + b = Block() + struct.pack_into(" None: self.lib = lib @@ -306,6 +319,50 @@ def gauge(self, value: int | None = None) -> int: self.poke(a.ENTITY.GAUGE_A, "i", value) return int(self.peek(a.ENTITY.GAUGE_A, "i")[0]) + def charge( + self, + *, + transform: int = 2, + ups: dict[int, tuple[int, int, int, int]] | None = None, + pairs: tuple[tuple[int, int], ...] = (), + chip_gauge: int = 0, + chip_durability: int = 0, + up: int = 420, + out: int = 900, + ) -> int: + """mhfu_em_charge with these settings (an up gains 100 at any HP unless told); what it + returned.""" + ups = {0: (100, 100, 100, 100)} if ups is None else ups + c = a.CHARGE_CFG + buf = ctypes.create_string_buffer(int(c.size or 0)) + struct.pack_into(" ChgStatus: + st = ChgStatus() + self.lib.mhfu_em_charge_status(ctypes.byref(st)) + return st + + def ask(self, arg: int, ent: int = ENT) -> int: + """The species' query as the stub answers it.""" + return int(self.lib.mhfu_em_host_query(ent, arg)) + + def hp(self, hp: int) -> None: + self.poke(a.ENTITY.HP, "H", hp) + + def until(self, done: Callable[[], bool], limit: int = 60) -> int: + """AI frames until done(); asserts it comes.""" + for k in range(limit + 1): + if done(): + return k + self.frame() + raise AssertionError(f"not in {limit} frames") + @pytest.fixture(scope="module") def lib(host_lib: Callable[..., ctypes.CDLL]) -> ctypes.CDLL: @@ -349,6 +406,10 @@ def lib(host_lib: Callable[..., ctypes.CDLL]) -> ctypes.CDLL: lib.mhfu_host_enter.argtypes = [ctypes.c_uint32] * 4 lib.mhfu_em_fatigue.argtypes = [ctypes.c_void_p] lib.mhfu_em_fatigue_status.argtypes = [ctypes.c_void_p] + lib.mhfu_em_charge.argtypes = [ctypes.c_void_p] + lib.mhfu_em_charge_status.argtypes = [ctypes.c_void_p] + lib.mhfu_em_host_query.restype = ctypes.c_uint32 + lib.mhfu_em_host_query.argtypes = [ctypes.c_uint32, ctypes.c_uint32] return lib @@ -1843,3 +1904,305 @@ def test_the_gauge_is_never_0_through_a_fight(g: Game) -> None: assert g.gauge() >= 1 assert (g.gauge() <= LOW) == bool(g.fat().tired) assert g.fat().spells >= 2 + + +# --- charge + +CHARGED_Q, COOLDOWN_Q = int(a.CHARGE_QUERY_CHARGED), int(a.CHARGE_QUERY_COOLDOWN) +UP_A, UP_B, XFORM = 0, 1, 2 # own-move slots: two charge-ups and the transform +XFORM_ENTRY = 59 + + +def charge_armed(g: Game, **kw: object) -> None: + """Three own moves, the helper armed on a monster at full HP, and its first AI step taken.""" + g.own(UP_A, 46) + g.own(UP_B, 53) + g.own(XFORM, XFORM_ENTRY) + g.poke(a.ENTITY.MAX_HP, "H", 100) + g.hp(100) + assert g.charge(**kw) # type: ignore[arg-type] + g.frame() + + +def start(g: Game, slot: int) -> None: + """The move player takes own move `slot` in the next AI frame, as for the script's enter.""" + assert g.lib.mhfu_em_play(ENT, slot, 1) + g.frame() + + +def charged(g: Game, **kw: object) -> None: + """Armed and charged: one charge-up that fills the gauge, then the transform.""" + charge_armed(g, **kw) + start(g, UP_A) + g.until(lambda: g.chg().charged == 1) + g.calls() + + +def test_charge_answers_for_nobody_before_the_first_ai_step(g: Game) -> None: + g.own(UP_A, 46) + assert g.charge() == 1 and g.chg().armed == 1 + assert [g.ask(k) for k in range(8)] == [0] * 8 and g.chg().asked == 0 + g.frame() + assert g.chg().armed == 1 and g.ask(CHARGED_Q) == 0 and g.chg().asked == 1 + + +def test_a_charge_up_raises_the_gauge_by_hp_and_starts_the_cooldown(g: Game) -> None: + ups = {UP_A: (20, 15, 10, 5), UP_B: (40, 30, 25, 5)} + for hp, gain in ((100, 20), (95, 20), (94, 15), (75, 15), (74, 10), (40, 10), (39, 5), (1, 5)): + g.lib.mhfu_em_host_quest() + g.lib.mhfu_em_init() + g.lib.mhfu_em_host_spawn(ENT) + charge_armed(g, ups=ups) + g.hp(hp) + g.frame() # a hit, which the gauge's chip (0) does not mind + start(g, UP_A) + st = g.chg() + assert (st.gauge, st.cooldown, st.up_starts) == (gain, 420, 1), hp + assert (g.ask(COOLDOWN_Q), g.ask(CHARGED_Q)) == (1, 0) + + +def test_the_cooldown_runs_down_every_ai_frame(g: Game) -> None: + charge_armed(g, ups={UP_A: (10, 10, 10, 10)}, up=5) + assert g.ask(COOLDOWN_Q) == 0 + start(g, UP_A) + seen = [] + for _ in range(6): + seen.append((g.chg().cooldown, g.ask(COOLDOWN_Q))) + g.frame() + assert seen == [(5, 1), (4, 1), (3, 1), (2, 1), (1, 1), (0, 0)] + + +def test_a_full_gauge_plays_the_transform_after_the_charge_up(g: Game) -> None: + charge_armed(g) + start(g, UP_A) + assert g.chg().gauge == 100 and g.lib.mhfu_em_playing() == UP_A + seen = [] + for _ in range(40): + g.frame() + seen += g.calls() + if g.chg().charged: + break + played = [c[2] for c in seen if c[0] == "X"] + assert played[:1] == [46] # the transform waited for the up to end + st = g.chg() + assert (st.charged, st.gauge, st.durability, st.transforms) == (1, 100, 100, 1) + assert (g.ask(CHARGED_Q), g.ask(COOLDOWN_Q)) == (1, 1) + seen = [] + for _ in range(30): + g.frame() + seen += g.calls() + assert [c[2] for c in seen if c[0] == "X"] == [XFORM_ENTRY] # asked once, and played + assert g.chg().transforms == 1 and g.lib.mhfu_em_playing() == -1 + + +def test_the_transform_waits_for_the_chain_of_the_move_before(g: Game) -> None: + charge_armed(g) + g.own(UP_A, 46, after=UP_B) + start(g, UP_A) + g.calls() + seen = [] + for _ in range(80): + g.frame() + seen += g.calls() + assert [c[2] for c in seen if c[0] == "X"] == [46, 53, XFORM_ENTRY] # the chain, then ours + assert g.chg().charged == 1 + + +def test_no_transform_slot_never_plays_one(g: Game) -> None: + charge_armed(g, transform=0xFF) + start(g, UP_A) + seen = [] + for _ in range(40): + g.frame() + seen += g.calls() + assert (g.chg().gauge, g.chg().charged) == (100, 0) + assert XFORM_ENTRY not in [c[2] for c in seen if c[0] == "X"] + + +def test_hits_wear_the_gauge_then_the_durability(g: Game) -> None: + charge_armed(g, ups={UP_A: (50, 50, 50, 50)}, chip_gauge=8, chip_durability=40) + start(g, UP_A) + assert g.chg().gauge == 50 + g.hp(90) + g.frame() + assert (g.chg().gauge, g.chg().hits) == (42, 1) + g.hp(95) # healing is no hit + g.frame() + g.frame() + assert (g.chg().gauge, g.chg().hits) == (42, 1) + g.hp(60) # several hits in one AI frame count once + g.frame() + assert (g.chg().gauge, g.chg().hits) == (34, 2) + + +def test_a_charged_monster_is_discharged_by_hits(g: Game) -> None: + charged(g, chip_gauge=8, chip_durability=40, out=700) + assert g.chg().durability == 100 + for left, hp in ((60, 90), (20, 80)): + g.hp(hp) + g.frame() + st = g.chg() + assert (st.charged, st.durability, st.gauge) == (1, left, 100) + g.hp(70) + g.frame() + st = g.chg() + assert (st.charged, st.gauge, st.durability, st.cooldown) == (0, 0, 0, 700) + assert (st.discharges, st.hits) == (1, 3) + assert (g.ask(CHARGED_Q), g.ask(COOLDOWN_Q)) == (0, 1) + g.frames(700) + assert g.ask(COOLDOWN_Q) == 0 + + +def test_the_cooldown_stands_while_charged(g: Game) -> None: + charged(g, up=500) + c = g.chg().cooldown + g.frames(25) + assert g.chg().cooldown == c and g.ask(COOLDOWN_Q) == 1 + + +def test_a_discharge_pair_discharges_a_charged_monster_at_once(g: Game) -> None: + charged(g, pairs=((11, 0), (11, 1))) + g.go((2, 10)) + assert g.chg().charged == 1 + g.go((11, 1)) + st = g.chg() + assert (st.charged, st.gauge, st.cooldown, st.discharges) == (0, 0, 900, 1) + assert g.ask(CHARGED_Q) == 0 + + +def test_the_transform_is_refused_in_a_discharge_pair(g: Game) -> None: + charge_armed(g, pairs=((11, 0),)) + start(g, UP_A) + g.pair = (11, 0) # the knockout: it ends the move, the helper must not transform + seen = [] + for _ in range(40): + g.frame() + seen += g.calls() + assert g.chg().charged == 0 and XFORM_ENTRY not in [c[2] for c in seen if c[0] == "X"] + g.pair = (1, 3) + g.until(lambda: g.chg().charged == 1, 10) + + +def test_the_state_stays_across_an_area_change(g: Game) -> None: + charged(g, chip_durability=30) + g.hp(95) + g.frame() + before = g.chg() + for section, pair in ((0x0102, (2, 10)), (0x0103, (1, 3)), (0x0104, (2, 11))): + g.poke(a.ENTITY.SECTION, "H", section) # the monster moves to the player's next area + g.go(pair) + g.frames(3) + st = g.chg() + assert (st.charged, st.durability, st.gauge) == (1, before.durability, 100) + assert (g.ask(CHARGED_Q), g.ask(COOLDOWN_Q)) == (1, 1 if st.cooldown else 0) + + +def test_a_new_monster_starts_afresh_and_keeps_the_settings(g: Game) -> None: + charged(g) + g.lib.mhfu_em_host_spawn(ENT2) + st = g.chg() + assert (st.armed, st.charged, st.gauge, st.up_starts, st.transforms) == (1, 0, 0, 0, 0) + assert g.ask(CHARGED_Q) == 0 and g.ask(CHARGED_Q, ENT2) == 0 # nobody owns it yet + struct.pack_into(" None: + charged(g, chip_durability=10) + struct.pack_into(" None: + charged(g, chip_durability=10) + assert [g.ask(k) for k in range(12)] == [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0] + + +def test_the_charge_ends_with_none_a_clear_or_the_quest(g: Game) -> None: + for end in ( + lambda: g.lib.mhfu_em_charge(None), + g.lib.mhfu_em_clear, + g.lib.mhfu_em_host_quest, + ): + g.lib.mhfu_em_host_quest() + charged(g) + assert g.ask(CHARGED_Q) == 1 + end() + st = g.chg() + assert (st.armed, st.charged, st.gauge) == (0, 0, 0) + assert g.ask(CHARGED_Q) == 0 and g.ask(COOLDOWN_Q) == 0 + g.hp(10) + g.frames(2) + assert g.chg().hits == 0 # nothing counts + + +def test_arming_again_with_the_same_settings_keeps_the_state(g: Game) -> None: + charged(g) + assert g.charge() == 1 + assert (g.chg().charged, g.chg().transforms) == (1, 1) + assert g.charge(chip_gauge=1) == 1 # other settings start afresh + g.frame() + assert (g.chg().charged, g.chg().armed) == (0, 1) + + +def test_a_refused_setting_arms_nothing_and_leaves_the_armed_ones(g: Game) -> None: + slots = a.EM_MOVES.MOVES.count or 0 + assert g.charge(transform=slots) == 0 and g.charge(ups={slots: (1, 1, 1, 1)}) == 0 + assert g.charge(ups={0: (1, 1, 1, 101)}) == 0 and g.charge(chip_gauge=-1) == 0 + assert g.charge(chip_durability=101) == 0 + assert g.chg().armed == 0 + charge_armed(g) + assert g.charge(chip_gauge=-1) == 0 and g.chg().armed == 1 + + +def test_a_transform_started_from_anywhere_charges(g: Game) -> None: + charge_armed(g) + start(g, XFORM) # the script, a rule or Lua plays it with the gauge low + st = g.chg() + assert (st.charged, st.gauge, st.durability, st.transforms) == (1, 100, 100, 1) + + +def test_a_charge_up_a_rule_or_the_script_plays_counts(g: Game) -> None: + charge_armed(g, ups={UP_A: (30, 30, 30, 30), UP_B: (5, 5, 5, 5)}) + g.rule(0, from_mask=1 << 1, min_frames=2, play_move=UP_B + 1, count=1) + g.frames(3) + assert g.chg().gauge == 5 + g.until(lambda: g.lib.mhfu_em_playing() == -1, 40) + script_enters(g, UP_A) + script_frame(g) + assert (g.chg().gauge, g.chg().up_starts) == (35, 2) + + +def test_a_charged_monster_gains_nothing_from_a_charge_up(g: Game) -> None: + charged(g) + c = g.chg().cooldown + start(g, UP_A) + st = g.chg() + assert (st.up_starts, st.cooldown) == (1, c) + + +def test_a_dead_monster_does_not_transform(g: Game) -> None: + charge_armed(g) + start(g, UP_A) + g.hp(0) + g.frames(40) + assert (g.chg().charged, g.chg().gauge) == (0, 100) + + +def test_the_transform_waits_for_the_tail_cut(g: Game) -> None: + charge_armed(g) + start(g, UP_A) + g.until(lambda: g.lib.mhfu_em_playing() == -1, 40) + cut(g) # em75's tail cut, the drop to come + seen = [] + for _ in range(3): + g.frame() + seen += g.calls() + assert g.chg().charged == 0 and XFORM_ENTRY not in [c[2] for c in seen if c[0] == "X"] + drop(g) + g.until(lambda: g.chg().charged == 1, 20) diff --git a/framework/tests/test_em_vhook_stubs.py b/framework/tests/test_em_vhook_stubs.py index 59d0d2c0..91634103 100644 --- a/framework/tests/test_em_vhook_stubs.py +++ b/framework/tests/test_em_vhook_stubs.py @@ -12,7 +12,7 @@ from mhfu import addresses from mhfu.mips import decode from rabbitizer import Instruction -from unicorn import UC_HOOK_MEM_READ, UcError +from unicorn import UC_HOOK_MEM_READ, UC_HOOK_MEM_WRITE, UcError BASE = int(addresses.USER_RAM) + 0x100000 # where the words are decoded CFG = BASE + 0x4000 @@ -20,6 +20,8 @@ ORIG_AI = int(addresses.USER_RAM) + 0x200000 ORIG_ACT = ORIG_AI + 0x100 BRAIN_FN = ORIG_AI + 0x200 +ORIG_QRY = ORIG_AI + 0x300 +CHG = BASE + 0x5000 SP, T7 = 29, 15 SHIM = """ @@ -29,6 +31,9 @@ { return emv_build_ai_stub(o, STUB_AI_INSNS, cfg, orig, ret, brain, ov); } int act(uint32_t *o, uint32_t cfg, uint32_t orig, uint32_t moves, int *ov) { return emv_build_act_stub(o, STUB_ACT_INSNS, cfg, orig, moves, ov); } +int qry_cap(void) { return STUB_QRY_INSNS; } +int qry(uint32_t *o, uint32_t chg, uint32_t orig, uint32_t ret, int *ov) +{ return emv_build_query_stub(o, STUB_QRY_INSNS, chg, orig, ret, ov); } int cfg_size(void) { return CFG_SIZE; } int cfg_step(void) { return CFG_STEP_FN; } int evt(uint32_t *o, uint32_t cfg, uint32_t orig, uint32_t ret, int *ov) @@ -48,8 +53,8 @@ class Stubs: - def __init__(self, lib: ctypes.CDLL, ai: list[int], act: list[int]) -> None: - self.lib, self.ai, self.act = lib, ai, act + def __init__(self, lib: ctypes.CDLL, ai: list[int], act: list[int], qry: list[int]) -> None: + self.lib, self.ai, self.act, self.qry = lib, ai, act, qry self.cfg_size, self.act_frame = lib.cfg_size(), lib.act_frame() @@ -62,8 +67,10 @@ def stubs(host_lib: Callable[..., ctypes.CDLL], tmp_path_factory: pytest.TempPat n_ai = lib.ai(out, CFG, ORIG_AI, RET, BRAIN_FN, ctypes.byref(ov)) ai = list(out[:n_ai]) n_act = lib.act(out, CFG, ORIG_ACT, BASE + 0x8000, ctypes.byref(ov)) + act = list(out[:n_act]) + n_qry = lib.qry(out, CHG, ORIG_QRY, RET, ctypes.byref(ov)) assert not ov.value, "a stub outgrew its slot" - return Stubs(lib, ai, list(out[:n_act])) + return Stubs(lib, ai, act, list(out[:n_qry])) def _ins(words: list[int]) -> list[Instruction]: @@ -90,7 +97,7 @@ def _imm(w: int) -> int: def test_branchless(stubs: Stubs) -> None: - for words in (stubs.ai, stubs.act): + for words in (stubs.ai, stubs.act, stubs.qry): assert not any(i.isBranch() for i in _ins(words)) @@ -382,3 +389,121 @@ def test_a_second_own_enter_replaces_the_play(stubs: Stubs, mips: Any) -> None: m.set_reg(reg, v) m.run(STUB, CALLER, count=2000) assert _req(stubs, m) == [ENTITY, 7, 1, 2, 8] + + +# --- the species' query, MONSTER_VTABLE.QUERY, answered for the charge block's monster + +QRY_RET = 0x13000 # the ret stub: jr ra +CHG_AT = 0x70000 # the charge block in the machine +OWNER = ENTITY + 0x1000 +SAVED = ( + "s0", + "s1", + "s2", + "s3", + "s4", + "s5", + "s6", + "s7", + "gp", + "sp", + "fp", + "ra", + "a0", + "a1", + "a2", + "a3", +) + + +def _run_query( + stubs: Stubs, + mips: Any, + entity: int, + arg: int, + owner: int = OWNER, + answers: tuple[int, ...] = (), +) -> tuple[Any, int, list[int]]: + """Runs the query stub as the AI script's switch_query calls it: (entity, arg), the block's + monster `owner` with `answers`. Returns the machine, where it went (the species' own query or + the caller) and the data addresses it touched.""" + out, ov = (ctypes.c_uint32 * 64)(), ctypes.c_int(0) + n = stubs.lib.qry(out, CHG_AT, RUN_ORIG, QRY_RET, ctypes.byref(ov)) + assert not ov.value + m = mips() + m.write(STUB, list(out[:n])) + m.write(QRY_RET, [mips.jr("ra"), mips.NOP]) + m.write(CHG_AT + int(addresses.CHARGE.ANSWERS), [*answers, *[0] * (8 - len(answers))]) + m.write(CHG_AT + int(addresses.CHARGE.ENTITY), [owner]) + touched: list[int] = [] + m.uc.hook_add( + UC_HOOK_MEM_READ | UC_HOOK_MEM_WRITE, + lambda _u, _t, addr, _s, _v, _d: touched.append(addr), + ) + for k, reg in enumerate(SAVED): + m.set_reg(reg, 0x1000 + k if reg not in ("a0", "a1", "ra", "sp") else 0) + for reg, v in (("a0", entity), ("a1", arg), ("ra", CALLER), ("sp", 0x3F000)): + m.set_reg(reg, v) + went: list[int] = [] + for stop in (RUN_ORIG, CALLER): + m.at(stop, lambda mm, s=stop: (went.append(s), mm.uc.emu_stop())) + with suppress(UcError): + m.uc.emu_start(STUB, 0xFFFFFFF0, count=200) + assert len(went) == 1 + return m, went[0], touched + + +def _asked(m: Any) -> int: + return int(m.read(CHG_AT + int(addresses.CHARGE.ASKED))[0]) + + +def test_query_stub_is_frame_free_and_ends_in_one_jr(stubs: Stubs) -> None: + assert _sp_uses(stubs.qry) == [] and len(stubs.qry) <= stubs.lib.qry_cap() + ins = _ins(stubs.qry) + assert not any(i.isJumpWithAddress() for i in ins) + assert ins[-2].isJump() and not ins[-2].isJrRa() and ins[-1].isNop() + assert sum(1 for i in ins if i.isJump()) == 1 + + +def test_query_answers_the_blocks_monster_from_its_table(stubs: Stubs, mips: Any) -> None: + table = (1, 0, 5, 0, 7, 0, 1, 9) + for arg, want in enumerate(table): + m, went, _ = _run_query(stubs, mips, OWNER, arg, answers=table) + assert went == CALLER and m.reg("v0") == want, arg + assert _asked(m) == 1 + + +def test_query_past_the_table_answers_0(stubs: Stubs, mips: Any) -> None: + for arg in (8, 9, 15, 16, 0x100, 0x7FFFFFFF, 0xFFFFFFFF): + m, went, _ = _run_query(stubs, mips, OWNER, arg, answers=(1,) * 8) + assert went == CALLER and m.reg("v0") == 0, arg + + +def test_query_of_any_other_entity_runs_the_species_own(stubs: Stubs, mips: Any) -> None: + for entity in (ENTITY, OWNER + 0x800, OWNER - 4, 0): + m, went, _ = _run_query(stubs, mips, entity, 6, answers=(1,) * 8) + assert went == RUN_ORIG and m.reg("a0") == entity and m.reg("a1") == 6 + assert _asked(m) == 0 + + +def test_query_with_no_monster_runs_the_species_own(stubs: Stubs, mips: Any) -> None: + for entity in (0, ENTITY, OWNER): + m, went, _ = _run_query(stubs, mips, entity, 0, owner=0, answers=(1,) * 8) + assert went == RUN_ORIG and _asked(m) == 0 + + +def test_query_keeps_the_callers_registers(stubs: Stubs, mips: Any) -> None: + for entity in (OWNER, ENTITY): + m, _, _ = _run_query(stubs, mips, entity, 6, answers=(1,) * 8) + want = { + reg: 0x1000 + k for k, reg in enumerate(SAVED) if reg not in ("a0", "a1", "ra", "sp") + } + assert {reg: m.reg(reg) for reg in want} == want + assert (m.reg("a0"), m.reg("a1"), m.reg("ra"), m.reg("sp")) == (entity, 6, CALLER, 0x3F000) + + +def test_query_touches_only_its_block(stubs: Stubs, mips: Any) -> None: + size = int(addresses.CHARGE.size or 0) + for entity, arg in ((OWNER, 3), (OWNER, 0xFFFFFFFF), (ENTITY, 6)): + _, _, touched = _run_query(stubs, mips, entity, arg, answers=(1,) * 8) + assert touched and all(CHG_AT <= t < CHG_AT + size for t in touched), (entity, arg) diff --git a/packages/mhfu/src/mhfu/addresses.toml b/packages/mhfu/src/mhfu/addresses.toml index 9f7523d1..74890d50 100644 --- a/packages/mhfu/src/mhfu/addresses.toml +++ b/packages/mhfu/src/mhfu/addresses.toml @@ -495,6 +495,7 @@ PICK_ACTION = { offset = 0x20, type = "ptr", doc = "vt[8] action picker, ACTION_ AI_STEP = { offset = 0x7C, type = "ptr", doc = "per-frame AI step (slot 29), about 30 dispatches a second" } ENTER_ACTION = { offset = 0x88, type = "ptr", doc = "enter_action(entity, main, id, mode) (slot 32): provisions a behaviour pair and ends in ACT_SET" } ANIM_EVENTS = { offset = 0x80, type = "ptr", doc = "the playing entry's events at clip frames (slot 30; em75: attacks, effect and sound emitters, keyed by ENTITY.ANIM_INPUT); writes no body state but its own countdown" } +QUERY = { offset = 0xDC, type = "ptr", doc = "query(entity, arg) (slot 53): the species' answer to the AI script's switch_query (0x79) with arg; the game_task default (em75, shared by 76, 81 and 88) answers 0. em_vhook wraps it to answer the charge helper's table for one monster" } [struct.PLAYER] doc = "player character struct at PLAYER_STRUCT, one of seven same-class slots; holds no stats" @@ -1092,13 +1093,63 @@ CHARGES = { offset = 0x1F4, type = "u32", doc = "costs charged" } SPELLS = { offset = 0x1F8, type = "u32", doc = "tired spells begun" } GAUGE = { offset = 0x1FC, type = "s32", doc = "ENTITY.GAUGE_A as this block last wanted it" } +[struct.CHARGE_UP] +doc = "one charge-up own move of the charge helper (charge.h mhfu_charge_up_t): starting it raises the gauge" +size = 0xA +[struct.CHARGE_UP.fields] +SLOT = { offset = 0x0, type = "u8", doc = "its EM_MOVES slot" } +GAIN = { offset = 0x2, type = "s16[4]", doc = "gauge gained when it starts, by the monster's HP: at least 95 percent of MAX_HP, at least 75, at least 40, below" } + +[struct.CHARGE_PAIR] +doc = "a host pair of the charge helper's discharge set (charge.h mhfu_charge_pair_t)" +size = 0x2 +[struct.CHARGE_PAIR.fields] +MAIN = { offset = 0x0, type = "u8", doc = "ENTITY.MAIN_STATE" } +SUB = { offset = 0x1, type = "u8", doc = "ENTITY.SUB_STATE" } + +[struct.CHARGE_CFG] +doc = "what a charge helper is set to (charge.h mhfu_charge_cfg_t), written by mhfu_em_charge" +size = 0x6C +[struct.CHARGE_CFG.fields] +TRANSFORM = { offset = 0x00, type = "u8", doc = "the EM_MOVES slot played when the gauge is full; 0xFF none" } +UP_COUNT = { offset = 0x01, type = "u8", doc = "UPS in use" } +PAIR_COUNT = { offset = 0x02, type = "u8", doc = "PAIRS in use" } +CHIP_GAUGE = { offset = 0x04, type = "s16", doc = "gauge a hit takes while the monster is not charged" } +CHIP_DURABILITY = { offset = 0x06, type = "s16", doc = "durability a hit takes while it is charged" } +COOLDOWN_UP = { offset = 0x08, type = "u16", doc = "AI frames the cooldown runs after a charge-up starts (MHP3rd 420)" } +COOLDOWN_OUT = { offset = 0x0A, type = "u16", doc = "AI frames it runs after the monster discharges (MHP3rd 900)" } +UPS = { offset = 0x0C, type = "CHARGE_UP[8]", doc = "the charge-up moves" } +PAIRS = { offset = 0x5C, type = "CHARGE_PAIR[8]", doc = "host pairs that discharge a charged monster at once (the knockout, the traps)" } + +[struct.CHARGE] +doc = "em_vhook's charge block (charge.h mhfu_charge_t): one per wrapped species, for the board's monster; a new monster resets everything after CFG" +size = 0xC4 +[struct.CHARGE.fields] +ANSWERS = { offset = 0x00, type = "u32[8]", doc = "what the query slot (MONSTER_VTABLE.QUERY) answers for ENTITY, by arg: CHARGE_QUERY_CHARGED and CHARGE_QUERY_COOLDOWN are the helper's, the rest 0; an arg past 7 is answered 0" } +ENTITY = { offset = 0x20, type = "ptr", doc = "the monster the query stub answers for: the board's, once it has taken an AI step since arming; 0 none, then the species' own query runs" } +ASKED = { offset = 0x24, type = "u32", doc = "queries the stub answered" } +ENABLED = { offset = 0x28, type = "u32", doc = "1 once mhfu_em_charge armed it; the quest's end and mhfu_em_clear turn it off" } +CFG = { offset = 0x2C, type = "CHARGE_CFG", doc = "the settings" } +GAUGE = { offset = 0x98, type = "s32", doc = "0..100, raised by the charge-ups, lowered by hits while not charged; 100 plays the transform" } +DURABILITY = { offset = 0x9C, type = "s32", doc = "100 when the transform starts, lowered by hits while charged; at 0 the monster discharges" } +COOLDOWN = { offset = 0xA0, type = "s32", doc = "AI frames left; counts down while not charged; answered as CHARGE_QUERY_COOLDOWN while above 0" } +CHARGED = { offset = 0xA4, type = "u32", doc = "1 from the transform's start to the discharge; answered as CHARGE_QUERY_CHARGED" } +SEEN = { offset = 0xA8, type = "u32", doc = "1 once the first AI step took the baseline" } +HP = { offset = 0xAC, type = "u32", doc = "ENTITY.HP as the last AI frame saw it: a fall is a hit" } +XFORM_AT = { offset = 0xB0, type = "u32", doc = "EM_CFG.AI_TICKS of the last transform play the helper asked for; it asks again a second later at the earliest" } +UP_STARTS = { offset = 0xB4, type = "u32", doc = "charge-ups started" } +TRANSFORMS = { offset = 0xB8, type = "u32", doc = "transforms started" } +DISCHARGES = { offset = 0xBC, type = "u32", doc = "discharges: durability at 0, or a pair of PAIRS" } +HITS = { offset = 0xC0, type = "u32", doc = "hits counted" } + [struct.EM_BLOCK] -doc = "em_vhook's partition block from its config block on (em_vhook.cpp place_block): the config block, the tail's map and the cut's wait, then the board and the fatigue block; the block's address moves with the PRX build, a debugger finds it by EM_CFG.CANARY" -size = 0xF48 +doc = "em_vhook's partition block from its config block on (em_vhook.cpp place_block): the config block, the tail's map and the cut's wait, then the board, the fatigue block and the charge block; the block's address moves with the PRX build, a debugger finds it by EM_CFG.CANARY" +size = 0x100C [struct.EM_BLOCK.fields] CFG = { offset = 0x00, type = "EM_CFG", doc = "the config block" } BOARD = { offset = 0xD08, type = "EM_BOARD", doc = "the board, after the tail: sizeof(tail_t) is 0x28" } FATIGUE = { offset = 0xD48, type = "FATIGUE", doc = "the fatigue block, after the board" } +CHARGE = { offset = 0xF48, type = "CHARGE", doc = "the charge block, after the fatigue block" } [enum.EM_COND] doc = "a brain rule's condition, EM_OP.OP - 1: hp_at_least/hp_below HP percent of MAX_HP against VALUE; broken/not_broken part ARG in EM_BOARD.BROKEN; enraged/calm ENTITY.FLAGS 0x20; side the hunter in a sector of the monster's facing in mask ARG (90 degrees each, left the monster's own); chance VALUE percent; var_* EM_BOARD.VARS[ARG] against VALUE" From 78f21782bf4d5f031ead656b30365d170e5a57e5 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:51:54 +0200 Subject: [PATCH 3/5] feat(framework): mhfu.em_charge and the port's charge table mhfu.em_charge arms the helper from a table (ups by slot, transform, chips, cooldowns, discharge pairs), em_charge_status reads it; mhfu_port.lua arms a moves module's charge table with the port, names through the one slot map, and drops it with the port. Lua API version 10. Co-Authored-By: Claude Sonnet 5.5 --- framework/lua/lib/mhfu_port.lua | 48 +++++++++++++- framework/lua/meta/mhfu.d.lua | 47 +++++++++++++- framework/mods/lua_host/bind_em.cpp | 97 +++++++++++++++++++++++++++++ framework/tests/test_mhfu_port.py | 91 +++++++++++++++++++++++++++ 4 files changed, 280 insertions(+), 3 deletions(-) diff --git a/framework/lua/lib/mhfu_port.lua b/framework/lua/lib/mhfu_port.lua index eb98c013..00cc61e2 100644 --- a/framework/lua/lib/mhfu_port.lua +++ b/framework/lua/lib/mhfu_port.lua @@ -208,8 +208,8 @@ 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 = {...} }. Empty, ---- logged once, without it. +--- 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 @@ -256,6 +256,9 @@ P.is_own = is_own -- fatigue a stamina for the monster, charged per pair it enters and own move it starts, shown to -- the AI script as gauge A low (mhfu.em_fatigue, mhfu.Fatigue); `own` keys are slots or -- own move names; default: the manifest's, none +-- charge a gauge, a charged state and a cooldown for the monster, answered to the AI script's +-- switch_query (mhfu.em_charge, mhfu.Charge): `ups` keys and `transform` are slots or +-- own move names; default: the manifest's, none -- -- Returns a port handle. Safe to call again (hot reload): the injector is armed once per boot. function P.define(spec) @@ -268,6 +271,7 @@ function P.define(spec) moves = spec.moves or made.moves or {}, tip = spec.tip or made.tip, fatigue = spec.fatigue or made.fatigue, + charge = spec.charge or made.charge, _vars = spec.vars or made.vars or {}, -- board counter name -> index _signals = spec.signals or made.signals or {}, -- board signal name -> index _sever_below = spec.sever_below or made.sever_below, -- the tail cuts only below this HP percent @@ -677,6 +681,39 @@ function Port:_seam_fatigue() return t end +--- The port's charge table as mhfu.em_charge takes it: `ups` keyed by slot and `transform` a slot, +--- a move's name resolved to its slot. An up that is not an own move of the port is dropped, said +--- once; a charge whose transform is not one is dropped whole, said once; nil without a table. +function Port:_seam_charge() + local c = self.charge + if c == nil then return nil end + local function lost(what, k) + local key = "charge:" .. self.name .. ":" .. what .. ":" .. tostring(k) + if not P._once[key] then + P._once[key] = true + log("[port:%s] charge %s '%s' dropped: no such own move", self.name, what, tostring(k)) + end + end + local t = {} + for k, v in pairs(c) do t[k] = v end + if c.ups ~= nil then + t.ups = {} + for k, gains in pairs(c.ups) do + local slot = k + if type(k) == "string" then slot = self._slot[k] end + if slot == nil then lost("up", k) else t.ups[slot] = gains end + end + end + if type(c.transform) == "string" then + t.transform = self._slot[c.transform] + if t.transform == nil then + lost("transform", c.transform) + return nil + end + end + return t +end + --- Install every claim, rule and the tail tip on the seam. Runs on the first tick the seam --- is live for this port, and again after a redefine (hot reload). Idempotent: --- the table is rewritten slot by slot, unused slots cleared. @@ -756,6 +793,13 @@ function Port:_arm_native() log("[port:%s] fatigue: %s", self.name, ok and "armed" or "refused (see the framework log)") end end + if mhfu.em_charge ~= nil then + local c = self:_seam_charge() + local ok = mhfu.em_charge(c) == true -- nil turns it off + if c ~= nil then + log("[port:%s] charge: %s", self.name, ok and "armed" or "refused (see the framework log)") + end + end if self.tip then if mhfu.em_tip == nil then if not P._once["tip:" .. self.name] then diff --git a/framework/lua/meta/mhfu.d.lua b/framework/lua/meta/mhfu.d.lua index 1e3702a0..62dee722 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 = 9 +mhfu.api_version = 10 -- log, memory and world state (bind_memory.cpp) ---------------------------------------------- @@ -789,6 +789,51 @@ function mhfu.em_fatigue_status() end ---@field spells integer tired spells begun ---@field gauge integer gauge A as the helper last wanted it +-- charge (bind_em.cpp, include/mhfu/charge.h) --------------------------------------------------- +-- MHP3rd's Zinogre charges up a gauge, transforms at 100 and stays charged until hits wear it +-- out; its script asks the species "charged" (query `mhfu.addr.CHARGE_QUERY_CHARGED`) and "the +-- cooldown runs" (`CHARGE_QUERY_COOLDOWN`), which em75 answers 0 to. The helper keeps that state in +-- C for the wrapped species' monster and answers those two queries to the AI script's +-- `switch_query` (the species' query slot is wrapped); any other query, and any other monster, gets +-- the species' own answer. + +---Arms the charge helper for the board's monster, from its next AI step; nil turns it off. The +---gauge gains when the move player starts a charge-up (`ups`, by the monster's HP), a hit chips +---the gauge (not charged) or the durability (charged), a full gauge plays the transform own move +---once the move player is idle, durability 0 or a discharge pair discharges. False for settings it +---refuses (logged), too many ups or pairs, or when the query slot could not be wrapped. The same +---settings again change nothing, the state included. +---@param t? mhfu.Charge +---@return boolean +function mhfu.em_charge(t) end + +---The charge helper's state. +---@return mhfu.ChargeStatus +function mhfu.em_charge_status() end + +---Settings for `mhfu.em_charge`; every field is optional. +---@class mhfu.Charge +---@field ups? table up to `mhfu.addr.CHARGE_CFG.UPS_COUNT` charge-up own moves by slot, each { g95, g75, g40, glow }: the gauge it gains at or above 95, 75, 40 percent of the monster's HP, and below (a `mhfu_port` module may key it by the move's name) +---@field transform? integer own-move slot played when the gauge is full (a name in a `mhfu_port` module); default none +---@field chip_gauge? integer gauge a hit takes while not charged, 0..100, default 0 +---@field chip_durability? integer durability a hit takes while charged, 0..100, default 0 +---@field cooldown_up? integer AI frames of cooldown after a charge-up starts, default 420 +---@field cooldown_out? integer AI frames after a discharge, default 900 +---@field discharge? integer[][] up to `mhfu.addr.CHARGE_CFG.PAIRS_COUNT` host { main, sub } pairs that discharge a charged monster at once + +---What `mhfu.em_charge_status()` returns. +---@class mhfu.ChargeStatus +---@field armed boolean +---@field charged boolean +---@field gauge integer 0..100 +---@field durability integer 100 at the transform, 0 when not charged +---@field cooldown integer AI frames left, counted while not charged +---@field ups integer charge-ups started +---@field transforms integer transforms started +---@field discharges integer +---@field hits integer hits counted +---@field asked integer queries answered to the AI script + -- reactions and monster events (bind_move.cpp, bind_monster_events.cpp) ---------------------- ---Plays `spec` in place of the monster's reaction `kind` from the next one on, until called again; diff --git a/framework/mods/lua_host/bind_em.cpp b/framework/mods/lua_host/bind_em.cpp index 70dcf02a..0fe42256 100644 --- a/framework/mods/lua_host/bind_em.cpp +++ b/framework/mods/lua_host/bind_em.cpp @@ -376,3 +376,100 @@ int lb_em_fatigue_status(lua_State *L) #undef SF_INT return 1; } + +/* --- charge (mhfu/charge.h) --- */ + +/* t.ups = { [slot] = { g95, g75, g40, glow } }, a table at the top of the stack, into c: the ups + * by slot, so the same table is always the same settings. 0 when there are too many. */ +static int charge_ups(lua_State *L, mhfu_charge_cfg_t *c) +{ + int n = 0; + lua_pushnil(L); + while (lua_next(L, -2)) { + luaL_argcheck(L, lua_isinteger(L, -2), 1, "ups is keyed by slot"); + luaL_argcheck(L, lua_istable(L, -1), 1, "an up is { g95, g75, g40, glow }"); + if (n >= MHFU_CHARGE_MAX_UPS) { + lua_pop(L, 2); + return 0; + } + const lua_Integer slot = lua_tointeger(L, -2); + luaL_argcheck(L, slot >= 0 && slot < MHFU_EM_MOVES, 1, "an up slot past the registry"); + mhfu_charge_up_t u = {}; + u.slot = (uint8_t)slot; + for (int b = 0; b < 4; b++) { + lua_rawgeti(L, -1, b + 1); + const lua_Integer v = lua_isnil(L, -1) ? 0 : luaL_checkinteger(L, -1); + luaL_argcheck(L, v >= -32768 && v <= 32767, 1, "a gain past a s16"); + u.gain[b] = (int16_t)v; + lua_pop(L, 1); + } + int at = n++; + for (; at > 0 && c->ups[at - 1].slot > u.slot; at--) c->ups[at] = c->ups[at - 1]; + c->ups[at] = u; + lua_pop(L, 1); + } + c->up_count = (uint8_t)n; + return 1; +} + +/* t.discharge = { {main, sub}, ... } at the top of the stack, into c */ +static int charge_pairs(lua_State *L, mhfu_charge_cfg_t *c) +{ + const int n = (int)luaL_len(L, -1); + if (n > MHFU_CHARGE_MAX_PAIRS) return 0; + for (int i = 0; i < n; i++) { + lua_rawgeti(L, -1, i + 1); + luaL_checktype(L, -1, LUA_TTABLE); + lua_rawgeti(L, -1, 1); + lua_rawgeti(L, -2, 2); /* main, sub at -2, -1 */ + const lua_Integer m = luaL_checkinteger(L, -2), s = luaL_checkinteger(L, -1); + luaL_argcheck(L, m >= 0 && m <= 255 && s >= 0 && s <= 255, 1, "a pair past a byte"); + c->pairs[i].main = (uint8_t)m; + c->pairs[i].sub = (uint8_t)s; + lua_pop(L, 3); + } + c->pair_count = (uint8_t)n; + return 1; +} + +int lb_em_charge(lua_State *L) +{ + if (lua_isnoneornil(L, 1)) { + lua_pushboolean(L, mhfu_em_charge(0)); + return 1; + } + luaL_checktype(L, 1, LUA_TTABLE); + mhfu_charge_cfg_t c = {}; + c.transform = (uint8_t)tbl_in(L, 1, "transform", MHFU_CHARGE_NO_SLOT, 0, 255); + c.chip_gauge = (int16_t)tbl_in(L, 1, "chip_gauge", 0, -32768, 32767); + c.chip_durability = (int16_t)tbl_in(L, 1, "chip_durability", 0, -32768, 32767); + c.cooldown_up = (uint16_t)tbl_in(L, 1, "cooldown_up", 420, 0, 65535); + c.cooldown_out = (uint16_t)tbl_in(L, 1, "cooldown_out", 900, 0, 65535); + int fits = 1; + if (lua_getfield(L, 1, "ups") == LUA_TTABLE) fits = charge_ups(L, &c); + lua_pop(L, 1); + if (fits && lua_getfield(L, 1, "discharge") == LUA_TTABLE) fits = charge_pairs(L, &c); + lua_pop(L, 1); + lua_pushboolean(L, fits && mhfu_em_charge(&c)); + return 1; +} + +int lb_em_charge_status(lua_State *L) +{ + mhfu_charge_status_t st; + mhfu_em_charge_status(&st); + lua_newtable(L); +#define SF_INT(name, v) do { lua_pushinteger(L, (lua_Integer)(v)); lua_setfield(L, -2, name); } while (0) + lua_pushboolean(L, (int)st.armed); lua_setfield(L, -2, "armed"); + lua_pushboolean(L, (int)st.charged); lua_setfield(L, -2, "charged"); + SF_INT("gauge", st.gauge); + SF_INT("durability", st.durability); + SF_INT("cooldown", st.cooldown); + SF_INT("ups", st.up_starts); + SF_INT("transforms", st.transforms); + SF_INT("discharges", st.discharges); + SF_INT("hits", st.hits); + SF_INT("asked", st.asked); +#undef SF_INT + return 1; +} diff --git a/framework/tests/test_mhfu_port.py b/framework/tests/test_mhfu_port.py index d4006204..e72ce246 100644 --- a/framework/tests/test_mhfu_port.py +++ b/framework/tests/test_mhfu_port.py @@ -690,6 +690,97 @@ def test_fatigue_of_a_hand_written_port(lua: Any) -> None: lua.execute(FATIGUE_SPEC) +CHARGE = """ +local SEAM = { moves = {}, charge = "unset", calls = 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 = {}; return true end +function mhfu.em_charge(t) SEAM.charge, SEAM.calls = t, SEAM.calls + 1; return true end +local P = require("mhfu_port") +local MOVES = { up_a = { entry = 46, slot = 0 }, up_b = { entry = 9, slot = 3 }, + roar = { entry = 20, slot = 4 }, lunge = { main = 1, sub = 4 } } + +-- a moves module's charge table is armed with the port; `ups` keys and `transform` by slot or by +-- the move's name, the rest as it is +package.loaded.z_moves = { + moves = MOVES, rules = {}, + charge = { + ups = { up_a = { 20, 15, 10, 5 }, [3] = { 30, 25, 20, 10 }, ghost = { 1, 1, 1, 1 }, + lunge = { 1, 1, 1, 1 } }, + transform = "roar", chip_gauge = 5, chip_durability = 10, cooldown_up = 420, + cooldown_out = 900, discharge = { { 11, 0 }, { 11, 1 } }, + }, +} +local z = P.define{ name = "z", species = 75 } +z.ent = ENT +ticks(1) +local c = SEAM.charge +assert(c.transform == 4 and c.chip_gauge == 5 and c.chip_durability == 10) +assert(c.cooldown_up == 420 and c.cooldown_out == 900) +assert(c.ups[0][1] == 20 and c.ups[0][4] == 5 and c.ups[3][2] == 25 and c.ups.up_a == nil) +assert(c.ups.ghost == nil and c.ups.lunge == nil and c.ups[1] == nil) -- a stranger, a pair move +assert(#c.discharge == 2 and c.discharge[2][2] == 1) +assert(count("charge up 'ghost' dropped") == 1 and count("charge up 'lunge'") == 1) +assert(count("charge: armed") == 1 and SEAM.calls == 1) + +-- a redefine from the same module arms it again (the framework keeps an unchanged one as it is); +-- one whose module has none turns it off +z = P.define{ name = "z", species = 75 } +ticks(1) +assert(SEAM.calls == 2 and SEAM.charge.transform == 4) +package.loaded.z_moves = { moves = MOVES, rules = {} } +z = P.define{ name = "z", species = 75 } +ticks(1) +assert(SEAM.calls == 3 and SEAM.charge == nil) +assert(count("charge up 'ghost' dropped") == 1, "said once") + +-- a transform that is no own move of the port drops the charge, said once +package.loaded.z_moves = { moves = MOVES, rules = {}, charge = { transform = "lunge" } } +z = P.define{ name = "z", species = 75 } +ticks(1) +z = P.define{ name = "z", species = 75 } +ticks(1) +assert(SEAM.charge == nil and count("charge transform 'lunge' dropped") == 1) +assert(count("charge: armed") == 2, "the two armings before it, none after") +""" + + +def test_charge_is_armed_with_the_port(lua: Any) -> None: + memory(lua) + lua.execute(CHARGE) + + +CHARGE_SPEC = """ +local SEAM = { charge = "unset" } +function mhfu.em_installed() return true end +function mhfu.em_move(slot, t) return true end +function mhfu.em_moves_clear() return true end +function mhfu.em_charge(t) SEAM.charge = t; return false end +local P = require("mhfu_port") + +-- a hand-written port gives its own, with the slots as numbers; a refusal is logged and the port +-- goes on +local h = P.define{ name = "h", species = 75, moves = { a = { entry = 1 }, b = { entry = 2 } }, + charge = { transform = 1, ups = { [0] = { 10, 10, 10, 10 } } } } +h.ent = ENT +ticks(1) +assert(SEAM.charge.transform == 1 and SEAM.charge.ups[0][1] == 10) +assert(count("charge: refused") == 1) + +-- the names resolve through the same slot map as an own move's +local n = P.define{ name = "n", species = 75, moves = { a = { entry = 1 }, b = { entry = 2 } }, + charge = { transform = "b", ups = { a = { 1, 2, 3, 4 } } } } +n.ent = ENT +ticks(1) +assert(SEAM.charge.transform == 1 and SEAM.charge.ups[0][4] == 4) +""" + + +def test_charge_of_a_hand_written_port(lua: Any) -> None: + memory(lua) + lua.execute(CHARGE_SPEC) + + RELINK = """ local SEAM = { moves = {}, rules = {} } function mhfu.em_installed() return true end From 8243dac3caaed787fe28f811511c48c7bc4f9e23 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:54:03 +0200 Subject: [PATCH 4/5] docs(framework): the charge helper Co-Authored-By: Claude Sonnet 5.5 --- framework/README.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/framework/README.md b/framework/README.md index e6d26360..cb1ccff2 100644 --- a/framework/README.md +++ b/framework/README.md @@ -204,6 +204,27 @@ the port (`own` keyed by move name there too); the quest's end, `mhfu.em_clear() `mhfu.em_fatigue(nil)` drop it, and `mhfu.em_fatigue_status()` reads the state. Do not arm it on a species whose AI step drains gauge A itself (those that call `GAUGE_A_LATCH`). +## Charge + +MHP3rd's Zinogre charges a gauge, transforms at 100 and stays charged until hits wear it out; its +AI script asks the species "charged?" (`mhfu.addr.CHARGE_QUERY_CHARGED`, 0) and "is the cooldown +running?" (`CHARGE_QUERY_COOLDOWN`, 6) with `switch_query`, which em75 answers 0 to. +`mhfu.em_charge{ ... }` (`include/mhfu/charge.h`) keeps that state in C for the wrapped species' +monster and answers the two: em_vhook wraps the species' query slot (`MONSTER_VTABLE.QUERY`) with a +frame-free, branchless stub that returns the helper's table for that monster, and the species' own +answer for every other entity and arg. + +The gauge rises by `ups[slot] = { g95, g75, g40, glow }` (at 95, 75, 40 percent of the monster's HP, +and below) when the move player starts that own move, whoever asked, and `cooldown_up` frames of +cooldown start; they run down while it is not charged. A hit (an AI frame in which its HP fell) +takes `chip_gauge` from the gauge, or `chip_durability` from the durability while charged. At 100, +once the move player is idle and no chain is due, it plays the `transform` own move, forced: its +start sets charged, durability 100, gauge 100. Durability 0 or a `discharge` pair (the knockout, the +traps) discharges it: gauge 0, `cooldown_out` frames of cooldown. The state stays across areas; a new +monster, the quest's end, `mhfu.em_clear()` and `mhfu.em_charge(nil)` reset or drop it, and +`mhfu.em_charge_status()` reads it. A moves module's `charge` table does all of it with the port, +its `ups` keys and `transform` by move name or slot. + ## AI scripts from the stick `mhfu brain deploy EM [FILE]` writes a species' AI script as an `MHAI` stick container, @@ -240,7 +261,7 @@ declared API; it needs a host `c++` (else those tests skip) and `lupa`, which `u ## Status -Runs in PPSSPP and on a real PSP under PRO CFW, for MHFU EU only. The Lua API is version 6 +Runs in PPSSPP and on a real PSP under PRO CFW, for MHFU EU only. The Lua API is version 10 (`mhfu.api_version`); `mhfu.clone_combat` is experimental and can crash the game. ## Licence From 07666e63788237ae04b559ab65483924a78240f4 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:56:53 +0200 Subject: [PATCH 5/5] docs: switch_query on a species with the charge helper Co-Authored-By: Claude Sonnet 5.5 --- docs/formats/ai_script.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/formats/ai_script.md b/docs/formats/ai_script.md index 1d2fdb72..758eede3 100644 --- a/docs/formats/ai_script.md +++ b/docs/formats/ai_script.md @@ -84,6 +84,11 @@ and `if_in_section` match a night area by its day twin, `switch_area` a night ma for an empty or out-of-range slot) and plays the move at the next AI step, forced past the notice. The run resumes after it when the move and its `after` chain end and the carrier hands back, as after a native pair. Without the framework the game has no table for main 16. +- `switch_query a` (0x79) asks the species' own query, `MONSTER_VTABLE.QUERY(entity, a)`: em75's is + game_task's default, which answers 0. With the framework's charge helper armed (`mhfu.em_charge`) + its stub answers a port's monster 1 to `addresses.CHARGE_QUERY_CHARGED` (0) while the monster is + charged and to `CHARGE_QUERY_COOLDOWN` (6) while the charge cooldown runs, and 0 to every other + arg; any other monster gets the species' answer. - A raised interrupt bit in `SCRIPT_PENDING` switches the next run to the header's routine for it. ## Variables