This document is the engineering reference. For a user-facing intro, see
framework/README.md. For the chronological session log of how this came
to be, see docs/agent_session_log.md.
Let mod authors hook into MHFU's in-game events (quest start, area load, monster spawn, player death, …) and react in real Python or real C, without each mod author having to redo the reverse-engineering work for the writer instructions inside the game binary.
The framework owns the event → MIPS address mapping. Mod authors own the event → behavior mapping.
The mod-author-facing API is the same in both backends:
mhfu_on_<event>(callback)
What differs is how the callback fires.
┌────────────┐ WebSocket ┌────────────┐
│ Mod.py │ ◀─ HookFireContext ─◀──┐ │ PPSSPP │
│ │ │ │ │
│ register()│ │ │ CPU │
│ ↓ │ │ │ ┃ │
│ on_event()│ │ │ ▼ writes │
└──────┬─────┘ │ │ cell │
│ │ │ │
▼ │ │ PPSSPP │
┌──────────────────────────────┐ │ │ memory.bp │
│ EventDispatcher │◀───────┘ │ fires: │
│ (mhfu_bot/events/dispatcher)│ │ cpu │
│ │ adds │ halt │
│ Memory write BP on cell ──▶ │ memory │ │
│ on_step handler reads │ BP ▶ │ │
│ regs+cell, dispatches to │ │ │
│ all subscribed callbacks │ │ │
└──────────────────────────────┘ └────────────┘
- Trigger: PPSSPP's memory write BP with
change=Trueandlog=True. - Dispatch:
EventDispatcher._handle_step()runs as a background task (NOT inside the WS recv loop — that would deadlock on awaited RPCs). - Per-event filter:
_is_event_fire()returnsFalsefor spurious writes (e.g. area_index transitions 0→3 are not "entered"; only →98 is). - Auto-resume: after dispatch,
client.resume()is scheduled so PPSSPP keeps running.
Strengths: zero MIPS patching, easy hot-iterate, mod is plain Python. Limits: dispatch latency ≈ multi-frame because PPSSPP is paused per event; only suitable for rare events (quest start, area transitions), not per-frame hot paths.
┌─────────────────┐ ┌──────────────────────┐
│ mod.prx │ │ mhfu_framework.prx │
│ │ │ │
│ module_start │ ─call─▶│ mhfu_register_event() │
│ mhfu_on_*(cb) │ │ (appends to list) │
└─────────────────┘ └──────┬───────────────┘
│
▼
┌──────────────────────────────────────┐
│ install_trampolines() [TODO] │
│ For each event anchor PC: │
│ ◦ build wrapper in .cave section │
│ ◦ patch anchor with J cave; NOP │
│ ◦ sceKernelIcacheInvalidateRange │
└──────────────┬───────────────────────┘
│
game runs … │ … reaches anchor PC
│
▼
┌──────────────────────────────────────┐
│ Wrapper in cave │
│ save $a0..$a3, $ra │
│ JAL mhfu_dispatch_quest_beginning │
│ restore │
│ run displaced insn0, insn1 │
│ J anchor+8; NOP │
└──────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ mhfu_dispatch_quest_beginning(...) │
│ walk callback list │
│ invoke each with ctx │
└──────────────────────────────────────┘
Same registration surface; per-event struct contexts (mhfu_event_ctx_t
for quest events, mhfu_map_section_ctx_t for section traversal,
mhfu_monster_spawn_ctx_t for spawns). The dispatch happens inside the
emulated PSP CPU, at native speed. No WS round-trip, no breakpoint pause.
Some writes PPSSPP's memory.breakpoint.add doesn't catch — the
sv.q-driven area_index 98 → arbitrary-section transition and the
entity-registry slot pointer writes are both invisible to mem-BPs
under change=True AND change=False. To keep the same mod-facing
event API regardless of trigger reliability, the dispatcher carries
TWO modes (see events.addresses.EventMode):
MEM_BP_WRITE— memory write BP on the event's cell. Used bymhfu_on_quest_beginning(quest_timer) andmhfu_on_quest_entered(area_index → 98).POLL— async loop at the event'spoll_interval_sreading the watched cells. Used bymhfu_on_map_section_entered(area_index) andmhfu_on_monster_spawned(entity registry slots 1..20).
The PRX backend resolves the same events differently — for
area_index it leans on the per-frame-executing writer PC
0x0884CDFC and does change-detection inside the wrapper (one
trampoline fires BOTH quest_entered and map_section_entered); for
monster spawns it runs a user-mode poll thread at 5 Hz.
MIPS R4000 / Allegrex instruction encoders.
encoder.py— one function per opcode we need:jal,j,nop,lui,ori,addiu,lw,sw,jr,move,beq,bne, plusli32(two-instruction 32-bit constant load).trampoline.py— higher-level templates:build_call_trampoline,build_prefix_postfix_wrapper,long_jump. These are what the hook installer composes into a real trampoline.tests.py— golden-vector tests for every encoder. Run withpython -m mhfu_bot.mips.tests. All 14 currently pass.
Critical facts:
- MIPS R4000 J/JAL encode a 28-bit absolute address (26-bit immediate
≪ 2). All MHFU user RAM lives in one 256 MiB region, so any
intra-game J fits in a single 4-byte instruction. We don't need
the
lui+ori+jrlong-jump form except for cross-region targets (scratchpad, kernel) which we never hit. - Branch delay slot: every J/JAL/branch executes the next instruction before the jump takes effect. Trampoline templates always emit the delay slot explicitly. Getting this wrong is the classic MIPS-hook footgun.
Hook installation primitives (currently the PRX path's design, re-targetable for live mode if we ever want trampoline-backed live hooks).
code_cave.py— bump allocator over a zero-filled region of user RAM. Default0x08AE0000(verified empty in MHFU EU at the quest-ready savestate — seescripts/find_code_cave.py). Slots are fixed-size (20 bytes = 5 instructions). Cave is verified empty before first use; raises if anything's already there.registry.py—Hook(one per patch site) +HookRegistry(process-global). Multiple callbacks can subscribe to a single Hook;fire()walks the list with per-callback exception isolation.install.py—install_hook(client, registry, cave, target_addr): reads displaced bytes, allocates a slot, writes the trampoline, arms a BP at slot start, patches the call site.
Mod-facing high-level API.
addresses.py— the single source of truth for which PC each named event hooks. EU only today; multi-region structure is in place (just needs population).dispatcher.py— the live-mode implementation. Carries both trigger backends: memory-BP halts with per-event predicate filter- background-task dispatch (for
mhfu_on_quest_*); async POLL loops with per-event change attribution (formhfu_on_map_section_entered+mhfu_on_monster_spawned). Builds per-event struct contexts (MapSectionContext,MonsterSpawnContext) so mod authors get named fields.
- background-task dispatch (for
api.py— module-level convenience functions (mhfu_on_quest_beginning,mhfu_on_quest_entered,mhfu_on_map_section_entered,mhfu_on_monster_spawned,install_all_events) and the process-global dispatcher singleton.
How a new event becomes a framework primitive:
- Make a savestate that lets you trigger the event with a single known input.
- Identify candidate cells that change at the event moment. Often
you already know them (HP, timer, area_index, etc.); if not, use
the existing diff-sampling methodology in
docs/agent_session_log.md. - Run
scripts/re_quest_events.py(or a fork with your cells) to capture writer PCs via memory write BPs +cpu.steppingevents. The script auto-resumes PPSSPP after each halt and persists hits todocs/quest_event_re.json. - Pick a PC:
- Prefer EBOOT-resident PCs (range
0x0884xxxx..0x0887xxxx) over overlay-loaded PCs (range0x09Axxxxx) — overlay addresses can drift across runs. - If multiple PCs fire near the same event, pick the one whose
change=Truewrite best matches the event semantics.
- Prefer EBOOT-resident PCs (range
- Add to
addresses.py(Python side) andmhfu_framework_addresses.h(C side). Keep them in lock-step. - Update the per-event predicate in
dispatcher.py::_is_event_fireif needed (filters out spurious writes). - Add a
mhfu_on_<event>convenience function inapi.pyand an enum entry + dispatcher inframework/prx/src/framework.c. - Smoke test: add a
printcallback inmods/hello_world/mod.pyand re-runscripts/run_hello_world_mod.py.
Initial design was to set execution BPs at the writer PC. That turned out to fire every frame — the writer instruction is reached during normal play but only changes the cell at quest start. So the BP fires constantly, PPSSPP halts every frame, input dies.
Memory BPs with change=True solve this cleanly: PPSSPP only halts
when the cell value actually transitions. Same fire-on-event semantic
as the user wanted; no per-frame pause storm.
The PPSSPP WS client's recv loop awaits the stepping handler. If the
handler awaits an RPC (like cpu.getAllRegs), the RPC's response
arrives on the WS, but the recv loop is blocked inside the handler
and can't deliver it. Deadlock.
Fix: handler schedules the real work via asyncio.create_task and
returns immediately. Recv loop continues processing messages,
including responses to the in-flight RPC. The task gets its response,
finishes dispatch, and schedules cpu.resume.
-
Code cave location is region-specific.
0x08AE0000is verified empty in MHFU EU at the quest-ready savestate. If you load a state with overlays in that region, the cave verification will raise. Runscripts/find_code_cave.pyagainst your scenario to pick a different base. -
Overlay-loaded PCs are not stable hook targets. Functions in the high RAM range (
0x09Axxxxx) move between sessions. Always prefer EBOOT-range anchors. -
One quest-start triggers
mhfu_on_quest_beginningtwice in our current setup. quest_timer transitions at least twice during the setup chain. Add a dedup-by-state filter if your mod needs strict once-per-quest semantics. -
PRX trampoline installer is implemented (2026-05-25) in
framework/prx/src/framework.c::install_trampoline_for(). Builds wrappers in a 64-byte-aligned BSS code cave using the in-PRX MIPS encoder (src/mips_encoder.h), patches anchors withj cave; nop, flushes dcache + icache viasceKernelDcacheWritebackInvalidateAll/sceKernelIcacheInvalidateAll. Mirrors the Python-side hook installer one-for-one; same wrapper shape. End-to-end verified 2026-05-25 Section 17.4 — first runtime mod (popo_growth) visually confirmed in real gameplay. -
PPSSPP mem-BPs miss some write paths (Section 17.4). The sv.q-driven
area_index98 → 99 transition and the entity- registry slot pointer writes don't firecpu.steppingevents with reasonmemory.breakpointunder any combination ofchange=True/False. Live framework events for those targets use the POLL trigger mode instead. If you discover an event that reproduces this behavior, default to POLL — diagnosing PPSSPP's mem-BP coverage is not a productive yak. -
PPSSPP wedges MHFU at boot with two plugin PRXes co-loaded (Section 17.4). Framework alone reaches the main menu in ~12 s; framework + any second plugin (even a no-op that just calls
mhfu_on_*) keeps screen state at 0 forever. Workaround in source today isMHFU_EMBED_POPO_GROWTH 1inframework/prx/src/framework.cinlining mod logic into the framework PRX; canonical standalone mod source remains atframework/prx/mods/popo_growth_prx/for when the co-load issue is resolved. -
size_scalemirrors must all be written. The per-entity size scalar at+0x024has mirrors at+0x220,+0x224,+0x228,+0x270and the game's per-frame sync routine restores+0x024from one of them inside one frame (≤16 ms). Mods that want a visible size change must write all five cells. (mhfu_entity_set_size()does this for you.)
The sections above describe the original monolithic design
(framework/prx/src/framework.c, one 2840-line TU with #if MHFU_EMBED_*
mod blocks). That file is gone. The event model, anchor PCs, trampoline
layout, poll-based spawn detection and the PPSSPP/JIT findings all carry
over unchanged — only the code organization changed. This section is
authoritative for the new layout; treat the older "Layout"/"source" notes
as historical.
framework.c did three jobs at once (loader/dispatcher, six embedded mods
gated by compile flags, and a running RE narrative in block comments). Mods
reached into core internals (g_aggressors, agg_sort_by_slot), hand-
collided sentinel offsets, and patched raw addresses with no arbitration —
two mods touching the same vtable slot or code word would corrupt silently.
include/mhfu/ PUBLIC SDK (extern "C"): events, memory, entity,
hooks, mod, mips, addresses, mhfu umbrella
src/core/ C++ core, one job per TU:
registry.cpp event registration (priority fan-out) + dispatch + spawn poll
trampoline.cpp event trampolines + code cave + SMC/cache helpers
hookmgr (hooks.cpp) ownership table + conflict detection + central restore
modtable.cpp walk MHFU_MOD descriptors, resolve needs/conflicts, init/shutdown
memory/entity/region/log/bootstrap.cpp
mods/<id>/mod.cpp one mhfu_mod_t descriptor each (MHFU_MOD(...))
build/mods.manifest the single selector: which mods compose into the PRX
Compiled -fno-exceptions -fno-rtti -fno-threadsafe-statics, no STL, no
virtuals, no global constructors (verified: .ctors is just the empty
ffffffff 00000000 sentinels; no _GLOBAL__sub_I/__cxa_guard). So it
links under psp-gcc with no libstdc++ runtime and there is no static-init-
order hazard. HookManager state is a POD file-scope table initialised by
an explicit mhfu_hookmgr_init() from bootstrap, never a file-scope
object with a constructor. All public API is extern "C" so export hashes
and the C SDK header match.
MHFU_MOD(...) emits a const mhfu_mod_t into a linker section
mhfu_mods; GNU ld auto-provides __start_mhfu_mods / __stop_mhfu_mods,
which modtable.cpp walks. bootstrap calls each init() after resolving
.needs (dependency ordering, multi-pass) and .conflicts (symmetric;
refuses the later claimant). Adding a mod = drop a mod.cpp + add its path
to build/mods.manifest; the Makefile turns each manifest line into a
mods/<path>/mod.o. This replaces every MHFU_EMBED_* flag.
hooks.h exposes two hook kinds:
- Events —
mhfu_hook_event(id, cb, priority)(and themhfu_on_*sugar at priority 0). Fan-out; many mods, highest priority first. - Exclusive patches —
mhfu_hook_function(addr,stub,owner),mhfu_hook_vtable(slot,fn,owner),mhfu_patch_word(addr,word,owner). Each claims ONE address; the manager records{kind, addr, orig, owner}. A second claimant on the same address getsMHFU_HOOK_CONFLICTand is refused — no silent fight.mhfu_unhook_owner(id)restores every patch a mod made; the mod table calls it automatically on shutdown, so mods no longer carry their own save/restore code.
Builds green (Docker pspdev); default manifest = diag + tigrex_inject
(parity with the historical live behavior); mod-table collects exactly the
manifest's descriptors; exports intact; no global ctors. Pre-refactor source
is in git at commit 0f634cc.
In-PPSSPP runtime verified 2026-05-30 (cold boot on the snow Giadrome
quest): typed API + mhfu_quest_replace_monster(GIADROME, TIGREX) from
MHFU_EVENT_QUEST_TARGETS_BUILDING retags the parsed buffer, the engine
loads the Tigrex model + spawns at Giadrome's native coords, and the
Tigrex roams sections 6/7/8 normally — visually found in section 6
(area_index=100). The mod's tame thread keeps him calm + size 0.5×.
See memory snow-map-section6-and-draw-interp.
True drop-in (the framework sceKernelLoadModules mod PRXes from a folder
instead of static composition) hinges on whether PPSSPP's 2-plugin wedge
recurs when the framework loads the second module rather than the
plugin host. Seam: load_mods() was the stub for this; in the new core it
would live in a src/modtable dynamic loader. Spike: build one trivial mod
as a separate .prx exporting nothing, have the framework LoadModule +
StartModule it after its own init, and watch for the boot wedge. If it
loads, wire descriptor discovery over loaded modules; if it wedges, approach
A (static) stays the shipping path. The mod-author API is identical either
way.