mhfu_framework.prx, the plugin that runs mods inside MHFU (EU, ULES01213): a C++ core, the mods
composed into it at build time, and a Lua host that runs mods from the memory stick and reloads
them when they change. mhfu_boot.prx loads it on a real PSP.
Needs docker, uv and make.
make -C framework # both PRXs, compiled in the pinned pspdev image
make -C framework clean| Output | |
|---|---|
build/mhfu_framework.prx |
the plugin |
build/mhfu_boot.prx |
the real-PSP loader: a kernel plugin that loads the framework from its own directory once the game shows the title or a menu |
build/*.elf |
the same with symbols and relocations |
build/gen/addresses.gen.h, addresses_table.gen.inc |
the address map, rendered from packages/mhfu; Lua reads it as mhfu.addr, an entry built when first read |
build/gen/lua_api.gen.h, .inc |
the mhfu table's C side, rendered from lua/meta/mhfu.d.lua |
mods.manifest lists the mods linked into the plugin, one directory under mods/ per line; #
disables one. Every mod directory is compiled either way.
A mod is a .lua file in the memory stick's mods/ (below). This one logs every big monster as
it spawns:
mhfu.on_bigmonster_spawn(function(_, species, slot, hp)
mhfu.log(string.format("[hello] species %d in slot %d, hp %d", species, slot, hp))
end)A mod may also define a global mhfu_tick(): it runs at 2 Hz in a quest area and while the hunter
roams Pokke village; menus, dialogue, prompt zones and loads pause it.
lua/examples/ has five to start from: an animation override, a second big monster in a quest,
the ported Brute Tigrex and Zinogre, and a small Zinogre that follows the hunter around the
village (village_follower.lua, on the village NPC calls mhfu.npc_*).
PSP/PLUGINS/mhfu_framework/
mhfu_framework.prx
mhfu_boot.prx real PSP only
plugin.ini PPSSPP only
mods/*.lua Lua mods, loaded at boot and reloaded when they change
mods/lib/*.lua Lua libraries, run by require and reloaded when they change
brain/*.mhai AI script containers, installed by a mod (`mhfu brain deploy`)
framework.log written at run time
plugin.ini for PPSSPP (it ignores a file without both sections; the addresses are EU only).
memory = 64 gives the emulated PSP the extra RAM that model injection, entity clones, relocated
overlays and the debug shell's bridge live in:
[games]
ULES01213 = true
[options]
type = prx
filename = mhfu_framework.prx
name = MHFU Framework
version = 1
memory = 64On a real PSP under PRO CFW, seplugins/game.txt names mhfu_boot.prx instead:
ms0:/PSP/PLUGINS/mhfu_framework/mhfu_boot.prx 1
The plugin loads on a cold boot only; after loading a PPSSPP savestate it no longer runs.
lua/ |
|
|---|---|
meta/mhfu.d.lua |
the mhfu API, every function and constant with its doc; the build registers exactly what it declares |
lib/ |
_prelude.lua, embedded and run before any mod, and mhfu_port.lua, the ported-monster runtime |
tools/ |
cli_bridge.lua, the in-game side of mhfu shell |
examples/ |
small mods to start from |
A library in mods/lib/ runs once, when a mod first requires it:
local port = require("mhfu_port"). Require at a mod's top level; later, while the Memory Stick
may be busy, require refuses to read a new library. A mod that needs a newer API checks
mhfu.api_version. Errors from the boot load are written to framework.log right after it;
lines logged while the Memory Stick may be busy (a quest load) follow within half a second of play.
Mods share one 350 KB Lua heap. framework.log gives live= after each mod and library loads,
and VM ready the largest free block, which bounds what a hot reload can compile.
An editor with the Lua language server (VS Code's Lua extension) gets completion and hover docs
from .luarc.json at the repo root; mhfu.addr needs make -C framework build/gen/addresses.gen.lua once. Outside this repo, add framework/lua/meta and
framework/lua/lib to workspace.library.
mhfu.move_play(ent, { entry = 46, attacks = { { 56, 6 } } }) plays executor entry 46 as a
move on a big monster: the move rides a native carrier pair (default (0,2), the Tigrex's
alert hub), its clip goes on every body part, attack id 6 spawns when the clip crosses frame 56,
and when the clip ends the carrier hands back to the monster's own brain. A reaction, a hit
that flinches it for one, ends the move at once. An attack can also end at a frame
({ 56, 6, 80 }), and while the move plays the host's own attacks and effects for the entry
its clip sits in are skipped (host_attacks = true keeps them). It runs in C on the AI step
em_vhook wraps (include/mhfu/move.h), so it needs a cold boot and a spawned big monster. From
the debugger: mhfu move ride <port>, then mhfu move play 46 --attack 6@56-80. A move asked
for while the monster's notice runs (it knows the player, not yet in combat) waits for the roar
and the combat entry; the call's force starts it at once (mhfu.move_play(ent, spec, true)).
mhfu.monster_state(ent) says whether it noticed the player, is in combat, runs its notice.
start and stop are clip frames: the clip starts at start (the executor's a3, so every part
agrees and an attack before it never spawns) and the move ends when the timing part's cursor
reaches stop, as the clip's end or length would end it. Neither depends on the blend (2 to 5
AI frames held at start) or on the cursor's rate (2.0 a frame, 2.8 enraged); length counts
AI frames from the dispatch and does.
The same AI step finds what a mod may answer, one AI frame after the engine's change at most,
raised on the 5 Hz registry poll (include/mhfu/monster_events.h): mhfu.on_bigmonster_noticed,
_combat_entered and _combat_left (the yellow eye beside the player's name), _flinch (with
the part), _part_broken (once per part, when its flinch count reaches its species' break row,
so two parts sharing a break id both raise it), _tail_cut, _enraged and _calmed
(ENTITY.FLAGS bit 0x20), each fn(ev) with the entity, the AI frame, the pair and the part.
mhfu.move_react("flinch", ent, { entry = 78 }) plays a move in place of the monster's flinch:
the engine counts the flinch and applies the damage, then enters the move's carrier instead of
(4,x), and the move plays from the next AI step. From the debugger: mhfu events --follow 60.
A port's own moves sit in a registry by slot (mhfu.em_move): mhfu.addr.EM_MOVES.MOVES_COUNT
(96) of them, and the turn keys they share (EM_MOVES.KEYS_COUNT, 5632), in one 20.3 KB
partition block. The seam's
brain, in C on the AI step, plays one through the move player when asked (mhfu.em_play), when the
move before it ends on its clip, its length, its stop or a wall and names it as its after, or when a rule
says (play_move, and from_move to fire while one plays). mhfu_port.lua does all of it from the
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.
An own move's steer (mhfu.MoveSteer) sets how YAW follows its clip: a turn curve, a turn
toward or away from the hunter at rate units a frame, a fixed turn, walls that end the move. The
hunter and away turns apply on every frame of the move unless the steer has a window = { from, to }: then only while the clip frame (part 0's cursor) is in [from, to), as the donor turns in
a cursor window.
An AI script plays own move k with enter 16 k 0 (mhfu.addr.OWN_MOVE_MAIN): the enter-action
stub enters the slot's carrier (the hub (0,2) for an empty or out-of-range slot, the game never
sees main 16), files a forced play, and the brain starts the move at the next AI step; the script
carries on after its enter once the move and its after chain end and the carrier hands back.
mhfu.em_status() has own_enters and own_last, and the ring shows (16, k).
A brain rule (mhfu.em_rule) with on = "<event>" fires in C in the AI frame the event is seen,
part narrowing a flinch or a break; capacity mhfu.addr.EM_CFG.RULES_COUNT. A rule on the
flinch owns the reaction replacement while installed: a flinch of its part, in a frame its gates
and conditions hold, enters its own move's carrier instead of (4,x), and the move plays from the
next AI frame. force = true plays a rule's move past the notice wait. While em75's tail cut
(4,4) waits for its drop, on a model with the mesh 1 the dropped tail draws, requests, plays and
rules wait with it, and the events, signals and rage requests seen meanwhile reach them after it.
ports/README.md "Moves and behaviour" has the manifest's side.
A rule also takes conds that must all hold ({ "hp_below", 0, 30 }: op, arg, value; the ops are
mhfu.EmCond: HP percent, broken parts, enraged or calm, the hunter's side of the monster, a
chance rolled last, a counter against a number), effects it applies each time it fires
(var_add, var_set on a counter; enrage, calm), a signal that fires it in place of on,
and no_play = true: it plays nothing and applies its effects. Each AI frame the rules that play
nothing run first, every one that holds wherever it sits among the others, so a counter counts the
frame's events whatever plays; then the first rule that plays and holds fires, seeing the
counters as they stand. The counters (mhfu.em_var), signals (mhfu.em_signal) and the parts
broken so far are the monster's board, reset with a new monster; a port names them in its moves
module's vars and signals (port:var, port:fire). The board holds
mhfu.addr.EM_BOARD.VARS_COUNT counters and SIGNALS_COUNT signals, a rule
mhfu.addr.EM_RULE.CONDS_COUNT conditions and EFFECTS_COUNT effects. The partitions em_vhook
takes grow with the rules and moves; its log lines give their sizes.
The enrage and calm effects (and mhfu.em_rage(true | false), port:enrage(), port:calm(),
which queue the same on the board for the brain, since Lua must not write the monster's flags from
its thread) use the engine's own levers: enrage sets the start pending in ENTITY.FLAGS unless
the monster is enraged, so the engine's rage start runs in the next AI frame (it roars), and
calm leaves one frame on ENTITY.RAGE_TIMER while it is, so the engine's own end runs.
mhfu.em_natural_rage(false) raises the species' anger threshold once per monster so damage never
starts rage (true restores it). mhfu.em_sever_gate(pct) wraps the species' tail-cut gate
(OBJ_VTABLE.CUT_GATE, a vtable swap) so the tail cuts only below pct percent of the monster's
HP; the species' own gate still runs on the cuts it allows. A port's moves module sets the last
two with natural_rage = false and sever_below = <pct>; the quest's end drops both.
MHFU's monsters tire of nothing and its AI script cannot count, so mhfu.em_fatigue{ ... }
(include/mhfu/fatigue.h) keeps a stamina for the wrapped species' monster in C and shows it to
the script as gauge A low: if_gauge_a_low is true at or below 30 % of gauge A, which em75 never
tests and nothing drains. The brain charges a cost when the monster enters a pair (pairs = { { main, sub, cost } }) and when the move player starts one of our own moves (own = { [slot] = cost }), halved at or below low_hp percent of HP and never while enraged or tired; at 0 stamina
the monster is tired for tired AI frames (gauge A low, calm keeps its anger from starting rage)
and then has refill stamina. The first AI step picks the stamina from starts by the board's
generator, and a new monster starts afresh. A moves module's fatigue table does all of it with
the port (own keyed by move name there too); the quest's end, mhfu.em_clear() and
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).
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; mhfu-port writes it from the donor's code
with [brain] charge (docs/formats/p3rd_ai_script.md).
mhfu brain deploy EM [FILE] writes a species' AI script as an MHAI stick container,
brain/brain_row_NN.mhai, and a mod beside the others, mods/brain_row_NN.lua, that calls
mhfu.brain_install(path) (mhfu brain build writes the container alone). The framework checks
the container, stages it and points the species row's header words at it now and after every quest
load re-reads the overlay (include/mhfu/brain.h): a monster that starts its script later runs it,
one already running keeps the game's. brain_install returns the row, or nil and why; installing a
row again replaces it, and the replaced image stays where it is, never freed, unless the container
is the row's live one, which stages nothing ([brain] row N id X unchanged); mhfu.BRAIN_MAX rows
fit. mhfu.brain_status(row) gives the container's id, where the image sits and, as maps, the
map types whose header word points into it now. The container is staged in PPSSPP's memory = 64
window when there is one, else in a block of user-partition memory (a real PSP, PPSSPP's 32 MB
mode), taken at install and capped at 256 KB for the session; framework.log says which, as
staged raw or staged partition. The mod is one of the 16 scripts hot reload tracks, and
reloads, so installs again, when it changes. docs/formats/ai_script.md has the container.
Checked in PPSSPP with an edited Tigrex script (--lane, a cold boot into a quest with a native
Tigrex): framework.log gives [brain] row 75: 17 word(s) to image ... once per quest load, the
monster's script words lie in the image and every instruction it dispatches runs there, and a
second quest without a cold boot repoints the row again (brain_status(75).patched counts both).
C calls a game function through mhfu_call(fn, a0, ...) (mhfu/call.h, word arguments only).
The game's VFPU code stores quads into its stack frame, which needs 16-byte alignment, and
psp-gcc keeps frames at 8: a direct call halts the game at the first such store.
tests/test_engine_calls.py refuses one.
uv run pytest framework compiles framework sources for the host and runs the Lua against the
declared API; it needs a host c++ (else those tests skip) and lupa, which uv sync installs.
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.
MIT.