Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/formats/ai_script.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 27 additions & 5 deletions framework/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<name>_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.
Expand Down Expand Up @@ -203,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,
Expand Down Expand Up @@ -239,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
Expand Down
107 changes: 107 additions & 0 deletions framework/include/mhfu/charge.h
Original file line number Diff line number Diff line change
@@ -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 <stdint.h>

#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 */
7 changes: 5 additions & 2 deletions framework/include/mhfu/em_vhook.h
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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.
*
Expand Down
1 change: 1 addition & 0 deletions framework/include/mhfu/mhfu.h
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
48 changes: 46 additions & 2 deletions framework/lua/lib/mhfu_port.lua
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,8 @@ end

--- A port's moves and rules from mods/lib/<name>_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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
47 changes: 46 additions & 1 deletion framework/lua/meta/mhfu.d.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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) ----------------------------------------------

Expand Down Expand Up @@ -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<integer, integer[]> 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;
Expand Down
Loading
Loading