Session 32, 2026-05-30 — first pass at finding monster-AI / cutscene
scripting infrastructure. Live RE was driven against the new save state
giadrome_intro_sec6_edge (UI slot 5 / file _4.ppst) — player just
outside snow sec5→sec6 boundary in the giadrome urgent quest. Walking
straight forward crosses into sec6 and the engine plays a ~20s+
scripted intro sequence (camera locks on the giadrome, monster runs
through scripted poses + roar, player input suspended).
Caveat — INTERIM. All findings below are pinned in the cutscene context. Whether the same scripting mechanism drives normal-fight AI is plausible but not yet verified. Items marked OPEN still need follow-up RE.
- The engine has two distinct scripting primitives:
TaskBase+TaskManager— the top-level engine-wide coroutine system (GameTask, FadeTask, …). Used for high-level program states. Not the per-monster AI driver.- A per-entity scripting-slot processor — the function
z_un_088637C4at EBOOT0x088637C4walks an array of "anim slots" on a given entity, with stride0x40and slot count atentity+0x122 (u16). Each slot is a small FSM with timer + target floats + state byte + enable byte. This is the system the giadrome cutscene uses to puppeteer the monster.
screen_state @ 0x08A8CA48low byte takes value0x06during scripted cutscenes. That byte is the cutscene-mode oracle (NEW — joins the existing0x11=in-area,0x01=menu/loading set).- The cutscene fires NO new top-level task (TaskMgr
active[1]/[3]stay NULL through the entire sequence). The driver writes the monster's per-entity slot array directly. - All 4 monsters (Popo, Anteka, Tigrex, Giadrome) share identical vtable[+0x0C..+0x2C] entries — strong confirmation that the generic-AI-engine claim from Section 19 is correct.
TaskBase (from tools/mhfu_external/mhp2g-decomp/include/task_base.hpp):
struct TaskBase {
typedef void (TaskBase::*mem_fn)();
virtual ~TaskBase();
virtual void update();
virtual void load() = 0;
mem_fn action; // PTMF, 8 bytes
u8 overlay_group;
u8 load_status;
s8 load_delay;
u32 id;
u32 next_id;
};Per-instance offsets (live):
+0x00 vtable
+0x04 action mem_fn (lo: fn-ptr / vtbl-offset)
+0x08 action mem_fn (hi: this-adjust)
+0x0C overlay_group (u8)
+0x0D load_status (u8)
+0x0E load_delay (s8)
+0x0F padding
+0x10 id (u32)
+0x14 next_id (u32)
vt[+0x00] = offset_to_top (0)
vt[+0x04] = typeinfo (NULL — RTTI suppressed)
vt[+0x08] = D0 dtor (non-deleting)
vt[+0x0C] = D1 dtor (deleting)
vt[+0x10] = update()
vt[+0x14] = load()
TaskManager** 0x08A8B1E8
TaskManager* (live) 0x08B0C6A4
+0x00 active[4] (TaskBase*[4])
+0x10.. slab[0x14000] (in-place task storage)
Currently observed: active[0]=GameTask (vtable 0x089BAC6C),
active[2]=FadeTask (vtable 0x089BAA94). Slots [1] / [3] stay
NULL through the entire giadrome intro.
TaskBase::update():
if (!is_overlay_loaded()) return;
if (action) (this->*action)();GameTask / FadeTask both override update() directly (vtable
slot +0x10) — they don't use the PTMF path. Subclasses are free to
use either mechanism. So as a modder you have two seams:
- Vtable swap on
vt[+0x10]of a task instance. - Write a non-zero
actionPTMF + leaveupdate()as the base.
TaskBase does not drive monster AI. ObjBase / monster entities
inherit from a different hierarchy (ModelBase → ObjBase, see
include/obj_base.hpp). The monster's per-tick advance is on
ObjBase.vt[5] = vtable_0x14 — already pinned in Section 22.
z_un_088637C4 @ 0x088637C4 EBOOT
- Called via JALR (no static J/JAL callers in EBOOT — confirmed
by full
0x08800000..0x08D00000scan intools/disasm_cutscene_driver.py). - Argument:
a0= pointer to an entity (or some scripted object). - The fn does not appear in the first 12 slots of any monster
vtable, including the entity's primary vtable. OPEN: must be
in a secondary vtable (multiple inheritance, see
cpp-secondary-vtablesmemory) or a different parent's vt.
prologue:
s5 = a0 ; save entity
v1 = (a0+0x110) ; sub-struct ptr (script data)
v0 = (a0+0x122) u16 ; slot count
v1 = (v1).+0x14C ; deref another level
s2 = v1 + 0x150 ; world-pos anchor
if (v0 == 0) goto epilogue ; bail if no slots
s1 = s5 ; s1 = entity (will stride)
s0 = s5 ; s0 = entity (paired u16 walk)
s4 = 0 ; loop index
loop body @0x0886380C:
(lots of fp arithmetic on s1+0x10..+0x3F)
...
; key writers:
SWC1 f0, 0x10(s1) ; @0x088639A4 — main phase-timer cell
SWC1 f0, 0x40(s5) ; transform translate row .x
SWC1 f0, 0x44(s5) ; transform translate row .y
SWC1 f0, 0x48(s5) ; transform translate row .z
loop tail @0x08863B30:
v0 = (s5+0x122) u16 ; reload slot count
s4 += 1
s1 += 0x40 ; advance to next slot
if (s4 < v0) goto loop body
s0 += 2 ; parallel u16-stride array
epilogue: return s3
Slot[k] starts at entity + k*0x40. Used fields, from the loop disasm:
| Offset | Read by | Written by | Inferred role |
|---|---|---|---|
| +0x10 | LWC1 f12,0x10(s1) and LWC1 f0,0x10(s1) |
SWC1 f0,0x10(s1) @0x088639A4 / @0x088638D0 / @0x08863A30 / @0x088639C0 |
phase timer (float, accumulates per tick) |
| +0x14 | LWC1 f1,0x14(s1) |
— | timer increment per tick |
| +0x18 | LWC1 f1,0x1C(s1) (target?) |
— | upper-bound for timer |
| +0x1C | (target?) | — | target value / endpoint |
| +0x24 | LWC1 f2,0x24(s1) |
— | secondary value (delta?) |
| +0x30 | LW v0,0x30(s1) then dec |
SW v0,0x30(s1) |
iteration counter (decrements toward 0) |
| +0x34 | LWC1 f1,0x34(s1) |
SWC1 f1,0x34(s1) / ADD.S accumulate |
secondary timer/accumulator |
| +0x3C | LHU v0,0x3C(s1) then OR 1 |
SH v0,0x3C(s1) |
status u16 — bit 0 flags "done", bit 1 flags "secondary" |
| +0x3E | LB v0,0x3E(s1) |
SB zero,0x3E(s1) |
state byte (s8) |
| +0x3F | — | SB zero,0x3F(s1) (loop top clears each pass) |
enable byte (cleared on every tick) |
entity+0x110→ some script-data sub-struct ptr.(entity+0x110).+0x14C→ another struct ptr.+ 0x150of that =s2in the driver;s2.+0x30 / +0x34 / +0x38hold target world position floats (XYZ).- The driver writes those floats to
entity+0x40/+0x44/+0x48— i.e. drives the entity's translation row directly from scripted target coords.
entity+0x122 (u16) was 0 when I sampled mid-cutscene via Python
poll, even though the loop ran at least 3 iterations earlier (writer
PC fired at slot index 2 = entity+0x80). The slot count is set up
per-tick or per-sub-phase by some upstream system and cleared/zeroed
quickly. OPEN — pin the writer of +0x122 and +0x110 to find
that upstream system.
These are entity cells the cutscene driver (or peer code in the same frame) writes:
+0x010..+0x018— heading vec components (cell+0x10is the main phase-timer for the slot ats1=entity; matches earlier finding that+0x10is engine-OUTPUT, not motion input).+0x040..+0x048— translation row (writes froms2.+0x30..+0x38).+0x080..+0x0CF— slot[2] (the giadrome intro's main sub-phase slot —+0x0090phase timer = float ramp 0..~410 then resets,+0x00BC= sub-state u8 cycling 3/6/7/8,+0x00C8= anim fractional accumulator).+0x01D4— high-byte counter (0x101 → 0x201 → 0x301 across sub-phases — anim-step ID).+0x01F4— packed yaw u16 (the cutscene steers the giadrome's facing every sub-phase).+0x0324— PRNG seed lo (jumps0x3E9 ↔ 0x3EA).+0x0334— AI-state byte:0 → 3. The value3is a NEW state not in popo's observed set{1,2,5,10}. OPEN — meaning of state 3 (cutscene-puppeteer mode?).
While probing, dumped first 12 vtable slots of:
giadrome 0x089BC950
popo 0x089BC560
anteka 0x089BC074
tigrex 0x089BB69C
Result — these slots are identical across all 4 species:
vt[+0x0C] = 0x09AC4D30
vt[+0x10] = 0x09AC47C0
vt[+0x14] = 0x08864634
vt[+0x1C] = 0x088646AC
vt[+0x20] = 0x08865254 (popo vt[8] — anim probability picker, prior RE)
vt[+0x24] = 0x0886574C
vt[+0x28] = 0x08865810
vt[+0x2C] = 0x09ADA4B0
Only vt[+0x08] and vt[+0x18] differ (each species' specific
overrides). Strongly reinforces CLAUDE.md ai-engine-is-generic:
build one editor / mod base for all monsters at once.
0x08A8CA48 (u8) screen_state
0x11 = in-area, controllable
0x01 = menu / loading
0x06 = SCRIPTED CUTSCENE PLAYBACK (NEW)
Sequence on sec5→sec6 cross: 0x11 → 0x01 (loading) → 0x06 (cutscene).
Did not return to 0x11 within my 18s window — cutscene >~14s.
Decomp puts an Evdemo singleton with a trigger fn:
struct Evdemo : Singleton<Evdemo> { /* 0xA0 bytes opaque */ };
bool func_eboot_088D0824(Evdemo *, u32 demo_id, u8);JP singleton ptr cell is at 0x09A4ADAC (overlay range). EU
equivalent OPEN — reads NULL at both 0x09A4ADAC and
0x09A4ADAC + 0x4F30 (the static-data delta). Likely shifted within
the overlay; needs a static scan of 0x09A40000..0x09A60000 for a
plausible 0xA0-byte block + back-resolve the ptr cell.
If we can find Evdemo + call func_eboot_088D0824(ev, demo_id, 0)
from a PRX, we can trigger custom scripted scenes by demo_id.
- (A) Vtable swap on the entity-tick path — already proven on
popo vt[8] (Section 22). We can override how a monster ticks by
swapping the entity's vtable[+0x10] (= primary
updateslot). - (B) Mem-BP on
entity+0x122at next session — when written, capture the writer PC. That gives us the slot installer — the function that activates scripted slots on an entity. With that PC- its args we can synthesise our own slot installations and run custom monster animations on demand from a mod.
- (C) Re-use
z_un_088637C4directly — once we know slot fields fully, allocate a slot record in a known cell of the entity, set+0x122to slot count, write target floats toentity+0x110→...→+0x150and the engine ticks them for free. - (D) Evdemo route — once EU Evdemo singleton is pinned, calling
func_eboot_088D0824(ev, our_id, 0)triggers a cutscene by id. Existing demo_ids can be enumerated by static scan offunc_eboot_088D0824's switch table. Mod could ship its own demo-id handlers if we can extend the table.
| Tool | Purpose |
|---|---|
tools/probe_task_system.py |
Live-dump TaskManager / FadeTask / Quest singletons (EU). One-shot recon. |
tools/probe_task_dump.py |
Live-dump TaskBase instances (vtable, action, fields). |
tools/probe_pre_intro.py |
Pre-intro snapshot: entity registry + scan static region for overlay ptrs. |
tools/re_event_trace.py |
Generalised live event tracer. CLI: state name + watches + analog inputs + duration. Outputs chronological diff stream per watch. Reusable for any "drive game into state X, see what changes" RE. |
tools/find_cutscene_writer.py |
Trigger cutscene + mem-BP on +0x0090 to capture writer PC. |
tools/disasm_cutscene_driver.py |
Trigger cutscene + disasm z_un_088637C4 + scan EBOOT for J/JAL callers + snapshot key cells. |
tools/dump_giadrome_slots.py |
Trigger cutscene + dump entity vtable / +0x110 / +0x122 / slot array + cross-species vtable comparison. |
re_event_trace.py is the keeper — future "drive + observe" tasks
should reuse it rather than write new ad-hoc snippets.
The slot-installer hunt produced a major reframe of the
interpretation above. A code-BP at z_un_088637C4 entry captured
a0 = 0x090BD5B0 = giadrome + 0x80, NOT the giadrome entity itself.
Re-mapping all offsets against entity+0x80 as the base:
| Driver-arg offset | Equivalent entity offset | Existing CLAUDE.md role |
|---|---|---|
a0 + 0x110 |
entity + 0x190 |
action-list ptr (read by applier z_un_08863e70, CLAUDE.md §19) |
a0 + 0x122 |
entity + 0x1A2 |
Action count (loop bound) (already documented) |
a0 + 0x14C deref |
within action descriptor | descriptor-walk field |
So z_un_088637C4 is the per-frame action-list walker of the
existing generic AI engine — not a new system. The "scripting
slots" I described above are the action-descriptor slots already
documented at entity+0x1A0..+0x1FF in CLAUDE.md agent_memory_map.md,
with stride 0x40 and count capped by +0x1A2.
Caller function = z_un_08865044 (EBOOT). 0x6F0 bytes into that
function is the JALR to z_un_088637C4. The caller does big VFPU /
transform / quaternion work (it's a render-matrix pipeline), so this
is the SKELETON UPDATE call site — it invokes the action-list walker
to advance the entity's running animation each frame.
The "cutscene driver" I was hunting is just the action-list walker
running on a custom action list installed at entity+0x190 during
the scripted intro.
CLAUDE.md §19 traces the normal-AI install path:
dispatcher 0x09AC5400 (overlay) reads entity+0x324 seed →
vt[8] = 0x08865254 (probability lookup over entity+0x1AC
species table) → returns 32-bit anim ID → z_un_08863e70
(applier) applies it via z_un_0885f9a0.
For the cutscene, the dispatcher path must be bypassed: something
writes entity+0x190 directly with a custom action-list ptr (and
entity+0x1A2 count). Candidate installer locations:
- A cutscene-specific overlay function that mirrors the dispatcher's output side but consumes a hardcoded action list (from quest data).
- The Evdemo
func_eboot_088D0824trigger function (EU equivalent still unknown, but Section 32b shows its action would land here).
To run a custom scripted animation on any monster:
- Allocate a custom action-descriptor block in RAM (format = pairs
of
(u16 action_id, u16 duration_ticks)per CLAUDE.md §19e.2, plus the action data the picker indexes into). - Write its ptr into
entity+0x190(= the action-list ptr). - Write the action count into
entity+0x1A2(u16). - The engine's per-frame walker
z_un_088637C4will tick through it for free.
This is the same surface used by the cutscene system — proven by the giadrome intro live RE. Modders writing per-monster behaviour just need to populate the action-descriptor block + flip those two cells.
Driving the modding-framework override-event design (Q-set on
on_bigmonster_action_decided). Re-RE'd the full per-entity AI tick
chain end-to-end. (See §32g for why the originally-proposed
anim_decided event was dropped.)
Found by walking back from captured ra=0x08865734 after a code-BP
at the walker entry. Six function prologues between 0x08865250 and
0x08865800; the one immediately before ra (= 0x08865648) is
the real per-frame per-entity tick:
z_un_08865648(entity): ; called per-frame for every entity
v0 = z_un_08865c88(entity, 0x20000)
if v0 != 0: skip transform_builder + root-motion (paused?)
else:
z_un_088652dc(entity) ; transform_builder (Section 25)
z_un_08863b70(entity+0x80, &locbuf, entity+0x10) ; root-motion delta from anim
entity+0x200 += locbuf (VFPU VADD.T) ; world-pos integrate
for slot in 0..entity+0x1A2:
if entity+(slot*0x40)+0xBE != 0: continue ; slot's direction byte already set
if (entity+0x1A8+slot) u8 == 0: continue ; per-slot trigger flag off
v0 = entity->vt[8](entity, entity+0x1B8+slot*2) ; PROBE — non-zero = install
if v0 == 0: continue
v0 = entity->vt[8](entity, entity+0x1B8+slot*2) ; PICK — returns u32 action_id
z_un_0885f9a0(entity+0x190, v0, slot, 1) ; INSTALL action_id into slot
z_un_088637c4(entity+0x80) ; walker tick — advance slot timers/flags
z_un_0885f9a0 (installer, just 27 insns) does NOT write a slot
cell directly — it chains through an action→anim resolve:
z_un_0885f9a0(a0=action_list_ptr, a1=action_id, a2=slot, a3=1):
anim_node_ptr = z_un_0885f928(action_list_ptr, action_id)
; ACTION → ANIM resolve — walks the species anim graph by u16 ID
; (per CLAUDE.md §19c.5: MSB-set IDs are no-op markers)
if anim_node_ptr == 0: return ; no anim found, slot left alone
z_un_0885f848(action_list_ptr, anim_node_ptr, action_id, slot, t0=1)
; APPLY — installs anim_node_ptr into slot[slot]+0x38, sets timers
popo vt @ 0x089BC560 vt[8] = 0x08865254
anteka vt @ 0x089BC074 vt[8] = 0x08865254
tigrex vt @ 0x089BB69C vt[8] = 0x08865254
giadrome vt @ 0x089BC950 vt[8] = 0x08865254 <-- live, vt ptr read from slot 1
All 4 species share vt[8]. One hook covers every monster. Cements the generic-AI-engine claim from Section 19.
Per-slot (stride 0x40, base entity+0x80, slot count at entity+0x1A2):
| Offset | Type | Role |
|---|---|---|
+0x10 |
f32 | anim time accumulator (advances by f12 per call) |
+0x14 |
f32 | anim time delta (ramp) |
+0x18 |
f32 | anim phase target (post-blend time) |
+0x1C |
f32 | anim length / max time |
+0x24 |
f32 | secondary ramp |
+0x30 |
u32 | timer countdown (loop iter limit) |
+0x34 |
f32 | secondary time accumulator |
+0x38 |
u32 | anim_node_ptr (set by installer) |
+0x3C |
u16 | flags (bit0 = phase-1 done) |
+0x3D-0x3E |
s8 | direction byte (BE check at tick entry) |
+0x3F |
u8 | active flag (cleared at slot start) |
Parallel arrays on the entity:
entity+0x1A8 + slotu8 — per-slot trigger flag (skip slot if 0).entity+0x1B0 + slot*2u16 — per-slot phase value 0..100 (normalised f).entity+0x1B8 + slot*2u16 — per-slot input passed to vt[8] ($a1).
Slot 0's root-motion delta is mirrored to entity+0x40/0x44/0x48
(the transform translation row).
| Event | Hook site | Type | Layer |
|---|---|---|---|
on_bigmonster_action_decided |
vt[8] = 0x08865254 swap (Section 22 pattern) |
sync override, $v0 mutable | behavioral pick |
on_bigmonster_ai_step |
enter z_un_08865648 (per-frame per-entity) |
observe-only | tick |
on_bigmonster_damaged (2026-06-30) |
5 Hz monster-poll HP-drop edge (reuses g_last_hp[]) |
observe-only | poll |
on_bigmonster_damaged (lua mhfu.on_bigmonster_damaged(ent,type,amount,hp,slot)) fires
when a big monster TAKES damage — poll-derived (coalesces multi-hits per 200 ms, NOT
frame-accurate; zero new engine detours). Use it to REACT to being hit (re-assert a forced
action, trigger a custom move). For frame-accurate flinch SUPPRESSION intercept
on_bigmonster_action instead (the engine dispatches the hit-reaction through the executor).
The two action hooks are EBOOT-resident. Section 26 JIT bypass (install at
TITLE/MENU before first quest tick) applies to 0x08865648. vt[8] swap
is JIT-immune (data lookup).
outcome_ptr(returned by vt[8]) = pointer into the per-species probability table atentity+0x1AC. Pointing at the chosen "action" record — a behavioral outcome with associated anim asset references embedded.slot_state_ptr(returned byz_un_0885f928) = the per-slot state-container node found by walking the action_list tree against the slot index. Where state lives, not what to play.
So action_decided lets mod replace WHAT the monster does. (We
considered an anim_decided hook on z_un_0885f928 — see §32g below
for why it was dropped.)
The framework initially proposed two override layers
(action_decided + anim_decided) on the theory that z_un_0885f928
transformed vt[8]'s output. RE'ing both functions live disproved that:
vt[8] = z_un_08865254 (34 insns) returns the picked outcome:
0x3E8sentinel ("no species table at +0x1AC")0("no entry matched")- else
prob_table_base + offset— a pointer INTO the species probability table atentity+0x1AC. Smoke confirmed live values like0x094270f8,0x094D69F4,0x09503F38— overlay-range pointers into the species table data.
z_un_0885f928 (30 insns) is NOT a transform of vt[8]'s output. It
takes (a0=action_list, a1=root_node_ptr (= action_list_ptr), a2=slot_idx)
and walks the action_list tree searching for a node whose +0x114 u16
== slot_idx. Returns that slot's state-container node (or 0).
Independent of vt[8].
Both values then flow into the applier z_un_0885f848 separately:
a1= slot_state_ptr (from z_un_0885f928)a2= outcome_ptr (from vt[8])
So action_decided alone captures all behavioral override surface. The
former anim_decided would have hooked a structural state-container
lookup, which mods don't typically want.
Decision: removed mhfu_on_bigmonster_anim_decided from the SDK.
If a future need for hooking the slot-state lookup arises, it can be
re-added as on_bigmonster_slot_state_resolved (different event with
different semantics — not the same as the engine's anim resolve).
Update (§32k, 2026-06-03): the action-selection seam is SOLVED — see Section 32k.
entity+0x334(#5) is the executor's "mode" byte (descr[6]- derived); the descriptor sub-record format (#3) is the 8-byte rows documented in §32k. Remaining big item: dump the descriptor table + force-cyclea1to enumerate/name each species' full moveset.
- Find the explicit installer call that writes
entity+0x190during cutscene init. Once we have its PC + arg signature we can call it directly from a PRX. - Verify normal AI usage — sample
entity+0x1A2for popo during normal aggression. If non-zero, action-list walker is the same mechanism for combat AI. - Slot field format — re-RE the action-descriptor sub-records
pointed at by the
+0x14Cderef. CLAUDE.md §19e.1 has partial info; reconcile with whatz_un_088637C4reads (now mapped above). - EU Evdemo singleton — still deferred (3 approaches failed Section 32b). $gp-relative scan next time.
- Map
state byte = 3atentity+0x334— observe when else it appears; correlate with action ID. - Disasm
z_un_0885f928(action→anim resolver) — confirm it has no third pick layer and document its anim-graph traversal.
mhfu_on_bigmonster_ai_step(cb, priority) is now LIVE-installed via an
entry-detour on z_un_08865648 (the per-frame per-entity AI tick).
JAL-caller scan of EBOOT + overlay found only 2 call sites:
| Caller | Region | Context |
|---|---|---|
0x09A74ECC |
OVL_A | per-entity tick chain (a0=entity from $s0) |
0x09AD3D6C |
OVL_B | transform/init path (reads +0x1F0/1F4/1F8 first) |
Both are in overlay (0x09Axxxxx), so we can't wrap a call-site behind
the TITLE/MENU gate (overlay isn't loaded then; expect-check fails).
Instead we patch the EBOOT entry of z_un_08865648 itself.
Prologue (5 insns, only first 2 displaced):
0x08865648 ADDIU sp,sp,-0x20 = 0x27BDFFE0 <- replaced with J wrapper
0x0886564C SW ra,0xC(sp) = 0xAFBF000C <- replaced with NOP
0x08865650 SW s2,0x8(sp) = 0xAFB20008 (untouched — function resumes here)
0x08865654 SW s1,0x4(sp) = 0xAFB10004
0x08865658 MOVE s2,a0 = 0x00809021
wrapper:
addiu sp,sp,-0x20 ; our frame
sw ra,0x18(sp) ; save caller ra
sw a0,0x10(sp) ; save entity arg
jal mhfu_ai_step_dispatch_c ; helper(a0=entity)
nop
lw a0,0x10(sp) ; restore a0
lw ra,0x18(sp) ; restore ra
addiu sp,sp, 0x20 ; pop our frame
addiu sp,sp,-0x20 ; replay displaced insn 1
sw ra,0xC(sp) ; replay displaced insn 2
j 0x08865650 ; resume function at entry+0x08
nop ; J delay slot
Two deferred-quiet-patches land both words in the same poll iteration so the JIT race window is microseconds.
[ai] action_decided installed on 4 species vt[8] slots
[ai] ai_step entry detour queued @ 0x08865648 (wrapper 0x09D87F50)
[hook] 'mhfu_ai' word @0x08865648 = 0x0a761fd4 (was 0x27bdffe0) <- J 0x09D87F50
[install] quiet-patched 0x08865648 for 'mhfu_ai'
[hook] 'mhfu_ai' word @0x0886564c = 0x00000000 (was 0xafbf000c) <- NOP
[install] quiet-patched 0x0886564c for 'mhfu_ai'
Wrapper disasm matches the spec exactly. JAL target lands on the
PRX-resident mhfu_ai_step_dispatch_c (0x09D677F4 in this build).
species_entry @ 0x09BC0FB0(0x09BB87C0 + 0x4B * 0x1D0).+0x0C = 0x09BCF138is NOT an action list — it's hitzone data: 16-byte (u16,u16) header(0x0708, 0x2328), then a hitzone-material byte table (00/01/02/03 quadrants), then u32 numerics + float hitbox/range values.entity+0x640deref points at a per-entity scratch list with 8-byte records(u8 idx, u8 0xFF marker, u32 zeros, u16 param_a, u16 param_b)— looks like per-action damage/range params, not the action repertoire.- Action selection for tigrex flows through vt[8] (
0x08865254) reading the probability table atentity+0x1AC= 0x09426760, and vt[8] returns POINTERS into that table — not u16 action IDs.
Override mods on tigrex therefore return pointer values (e.g.
0x094270F8, 0x094D69F4 — observed engine picks in the giadrome smoke
log) rather than enum IDs. A u16 action-id enum doesn't apply cleanly,
so mhfu/ai_actions.h omits TIGREX_ACTION_*.
species_entry @ 0x09BC1350(0x09BB87C0 + 0x4D * 0x1D0).+0x0C = 0x09D58570(runtime-allocated in RAM heap; not present in giadrome_intro_sec6_edge but populated in tigrex_s6).- Same
(u16 id, u16 duration_ticks)record format as popo/anteka after an 8-byte header. 34 actions — enumerated inmhfu/ai_actions.hasGIADROME_ACTION_0x????. Tigrex_s6 was the state where the list is fully resident.
Giadrome is classified "big" in the quest UI (counts as a quest target) but its AI shape is the same as small monsters — fixed action list, duration ticks, vt[8] returns u16 IDs not pointers.
Per CLAUDE.md §19g.3 this is the "big-monster runtime action list table".
First 11 records have format (u8 action_id, u8 0xFF, u8 0, u8 sub_idx, u16 0, u16 duration_ticks) with action IDs {0x00, 0x14, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1C, 0x1D, 0x1E, 0x1F} and duration 236 ticks each.
After offset 0x58 the data switches to floats (per-action range/hitbox
params). This is the source for big-monster types whose +0x640 is
runtime-swapped (type 0x3A per §19g.3 — not tigrex / not giadrome). Not
needed for the current SDK; revisit when a 0x3A monster is RE'd.
Was a hand-maintained static array per species. Now walks the species table at runtime:
if (type == 0x4B) return 1; // tigrex — pointers
if (type not in {0x45, 0x46, 0x4D}) return 0;
list_ptr = *(u32 *)(0x09BB87C0 + type*0x1D0 + 0x0C);
walk (u16 id, u16 dur) records after 8-byte header until 0xFFFF/0.Auto-updates when species data changes, removes a stale-table bug class.
A common confusion: "isn't the picker the better hook than the per-frame tick?" — yes, and we ALREADY hook the picker. There are two distinct events with very different roles:
| Event | Hook target | Purpose |
|---|---|---|
action_decided |
vt[8] = 0x08865254 (the picker) |
OVERRIDE — return value flows through 0885f9a0 → 0885f848 |
ai_step |
entry of z_un_08865648 (per-frame tick) |
OBSERVE-ONLY — fires before the slot loop runs vt[8] internally |
The per-frame tick z_un_08865648 is the FUNCTION inside which vt[8] gets
called per-slot. Hooking its entry gives observability ("a big monster is
about to tick"), but trying to override an action there would race with
the engine's own vt[8] call later in the same function.
action_decided is the right surface for "force a different action":
intercepts vt[8]'s return immediately before 0885f9a0 consumes it.
Tigrex's vt[8] returns a per-run pointer into entity+0x1AC's nested sub-tables, not a stable u16 ID. To let mods address tigrex actions symbolically:
tools/dump_tigrex_inputs.pyparses framework.log and emitsTIGREX_VT8_INPUT_0x????macros for each observed vt8_input value (50 action inputs + 4 probe inputs from 1860 logged calls).- The framework's
action_decideddispatcher unconditionally snoops every(vt8_input, engine_id)pair into a per-species cache (96 entries each). Snooping happens even with zero subscribers, so the cache populates itself as soon as the framework boots into a quest. mhfu_action_ptr_for(type, input)returns the cached ptr — 0 if the engine hasn't picked that input yet this run.
Mod pattern:
static uint32_t pin_to_charge(const mhfu_action_decision_ctx_t *c,
uint32_t engine_value)
{
if (c->monster_type == MON_TIGREX) {
uint32_t p = mhfu_action_ptr_for(MON_TIGREX, TIGREX_VT8_INPUT_0x04B1);
if (p) return p;
}
return engine_value;
}Limitation: actions the engine never picks naturally (e.g. rare enrage poses) never enter the cache, so they can't be pinned. Eager resolution (call vt[8] directly to populate the cache) is deferred because vt[8] is a 95-insn compound function with VFPU writes and a small RNG path — calling it speculatively would perturb engine state. See "What full RE costs" below.
If a future mod needs eager resolution (force an action the engine has never picked) the work is:
- Disasm vt[8] @
0x08865254— full ~95 insns. ~1-2h. - Trace data flow against the live
entity+0x1ACstructure (decoded as(u32 weight, u32 value)pairs +0xFFFFFFFFsentinels in §32h, but the value→ptr mapping needs more work). ~1-2h. - Identify the input→output computation: is it
table_base + offsetor weighted-random across a bucket or nestedlookup(input).sub_table[idx]? ~1-2h. - Replicate as pure C
mhfu_action_ptr_for_eager(entity, input)that mirrors the lookup without VFPU / RNG. ~1h. - Validate against
tools/out/tigrex_inputs/observed.txt(54 known pairs). ~30min.
Total: ~4-8h focused work. Open questions that may grow scope:
- Does vt[8] depend on entity state beyond
+0x1AC(heading, stress, player distance)? If yes, the resolver needs the same state as input. - Does the RNG path make different calls return different ptrs for the same input? Then "the resolved ptr" isn't a function — would need to expose the bucket of candidates.
For now: passive cache is good enough for the vast majority of mods.
Building on the action_decided framework from §32d-32i. While building the
tigrex_spin mod (replace Giadrome quest's monster with Tigrex, lock
into TIGREX_ANGRY_SPIN after first natural pick, release on HP drop) we
disasm'd the per-tick slot loop body and identified the engine-side
state cells that gate AI tick advancement.
Per-tick driver. Body:
0x088656A0 lhu $v0, 0x1a2($s2) ; slot count = entity+0x1A2 (u16)
0x088656A4 beqz $v0, end
0x088656A8 move $s1, $zero ; s1 = slot = 0
0x088656AC move $s0, $s2 ; s0 = entity (input cursor base)
LOOP @ 0x088656B0:
0x088656B0 sll $v0, $s1, 6 ; v0 = slot * 0x40
0x088656B4 addu $v0, $s2, $v0
0x088656B8 lb $v0, 0xbe($v0) ; SKIP FLAG @ entity + slot*0x40 + 0xBE
0x088656BC bnel $v0, $zero, tail ; if non-zero, slot is busy → skip
0x088656C4 addu $v0, $s2, $s1
0x088656C8 lbu $v0, 0x1a8($v0) ; ENABLE BYTE @ entity + slot + 0x1A8
0x088656CC beqz $v0, tail ; if zero, slot disabled → skip
0x088656D4 lw $t9, ($s2) ; vtable
0x088656D8 lhu $a1, 0x1b8($s0) ; INPUT @ entity + slot*2 + 0x1B8 (u16)
0x088656DC lw $t9, 0x20($t9) ; vt[8]
0x088656E0 jalr $t9 ; CALL 1 — gate
0x088656E4 move $a0, $s2 ; delay
0x088656E8 beqz $v0, tail ; gate returned 0 → skip slot
0x088656F0..0x088656FC ; CALL 2 — same args, descriptor result
0x08865700 move $a0, $s2
0x08865704 lw $a0, 0x190($s2) ; entity+0x190 = anim struct ptr
0x08865708 move $a1, $v0 ; vt[8] return = action ptr
0x0886570C move $a2, $s1 ; slot index
0x08865710 jal 0x885f9a0 ; install_action(anim_struct, ptr, slot, $a3=1)
0x08865714 addiu $a3, $zero, 1
tail @ 0x08865718:
0x08865718 lhu $v0, 0x1a2($s2)
0x0886571C addiu $s1, $s1, 1 ; slot++
0x08865720 sltu $v0, $s1, $v0
0x08865724 bnez $v0, LOOP
0x08865728 addiu $s0, $s0, 2 ; input cursor += 2
end @ 0x0886572C:
0x0886572C jal 0x88637c4 ; scripting slot processor
0x08865730 addiu $a0, $s2, 0x80
Per-slot inputs:
entity + slot*0x40 + 0xBEu8 — skip flag (busy)entity + slot + 0x1A8u8 — enable byteentity + 0x1B8 + slot*2u16 — input cursor for vt[8]
Big-mon overlay path 0x09AC52DC has the same structural pattern; the
slot count is loaded from entity+0x640 instead of +0x1A2. Both
patched live via Section 26 quiet-gate; overlay body
(0x09AC52E4..0x09AC54C0) is JIT-overwritten and can't be disasm'd from
RAM, but symmetry to the EBOOT path is high.
vt[8] is called TWICE per slot per tick. Both calls use identical
input. CALL 1 is a probability gate (return 0 → engine beqz skips slot).
CALL 2 returns the actual install ptr. Implications: action_decided
post-hook (the framework event) fires twice per slot per tick.
Pure deterministic probability picker. No RNG read in the function.
Input a1 decomposed:
| Slice | Use |
|---|---|
a1 / 1000 |
category index |
(a1 % 1000) / 100 |
table row index ×8 stride |
a1 % 100 |
probability threshold |
Algorithm:
a2 = entity+0x1AC(action table ptr). If null → return 1000 (default).- Compute
idx = (a1 % 1000) / 100 * 8. - Load
v0 = action_table[idx]. If(a1 % 100) < v0→ returnaction_table[idx + 4](success ptr). - Else fallback:
v1 = action_table[443 + (a1 % 100) * 4]. Ifv1 != -1→ returnaction_table + v1. Else 0.
Pure function of (entity+0x1AC, a1). Same input → same output. → The
framework's passive cache (snoop on action_decided) is safe.
Three slots per big monster; each slot drives one body segment. A coordinated full-body action requires all 3 slots playing the action's per-part descriptor concurrently. SPIN's natural per-slot inputs (live captured on Tigrex):
| Slot | Input | vt[8] returns (Tigrex em13 model) | Likely role |
|---|---|---|---|
| 0 | 0x0413 | 0x0946ECFC | Head / front |
| 1 | 0x04DB | 0x094E8360 | Body / mid |
| 2 | 0x05A3 | 0x0950DBC8 | Tail / spinner |
Stride 200 between slot inputs is consistent across actions (general
pattern observed in polling — e.g. another action's triple was
(1017, 1217, 1417)). The 200 stride is the engine's per-slot offset
into the action table.
Forcing slot-2-only SPIN: tail spins indefinitely while head + body
do unrelated picks → visual chaos. Forcing the full triple under a
naive override defeated the per-slot natural gate (vt[8] CALL 1 → 0
when slot idle) → install thrash → AI tick rate dropped to 0.7% normal.
The working pattern: cache all 3 slots' natural SPIN ptrs from the
arming-frame action_decided event (engine already iterates 0→1→2),
then return cached ptrs only when engine_value != 0 (preserves gate).
Engine accumulates "exhausted" bits during forced repeat-fire. After
3-4 SPINs the AI tick halts entirely (per-entity frame counter
entity+0x092 stops advancing). The cells the engine zeroes / sets
during exhaustion:
| Offset | Type | Role |
|---|---|---|
+0x1A8..0x1AA |
u8×3 | per-slot enable bytes — engine zeroes after exhaustion |
+0x1B8/A/C |
u16×3 | per-slot input cursors — engine zeroes after exhaustion |
+0x29C |
u32 | stress-response output mode (0 during SPIN) |
+0x32C |
u16 | AI param scratch (0 during SPIN) |
+0x414 |
u32 | bitfield — climbs 0xa → 0x12c over 3 SPINs (red herring; not the freeze cause) |
+0x4B8 |
u32 | ⭐ THE freeze gate. Baseline 0. Engine OR's bits 0x100 | 0x10000 during forced spins. Halts AI tick when set. |
+0x54E |
u16 | natural play decrementer (1225 → 0); red herring |
+0x550 |
u16 | stamina-like counter (9000 baseline); secondary gate, decays ~10 per 333ms |
+0x648/4C |
f32 | paired (450 / 1000) — likely rage cur/max |
The tigrex_spin v0.12 mod refreshes +0x4B8 = 0 and re-primes input
cursors + enable bytes + +0x550 = 9000 on every action_decided fire.
Once +0x4B8 is held at 0 the engine never enters exhausted state →
SPIN repeats indefinitely until HP drops (release on first HP delta).
All 3 slot containers have identical shape; vary only in +0x38 (the
installed anim ptr written by z_un_0885F9A0):
| Container offset | Type | Notes |
|---|---|---|
+0x10 |
f32 | per-slot timer (observed 56.0 → 332.0 evolve) |
+0x14 |
f32 | per-slot scalar (1.25f default) |
+0x24 |
f32 | per-slot scalar (0.25f default) |
+0x38 |
u32 | installed action ptr — the slot's anim |
+0x3C |
u32 | flag word (=0x1 after install) |
+0x3F |
u8 | running flag (0 = idle, ≠0 = playing) |
mhfu_action_ptr_for(monster_type, vt8_input) from §32i resolves to
exactly the +0x38 value the applier writes.
Each step has a one-shot script in this session's /tmp/. Repeat for
any entity-level wedge:
- Capture baseline (
/tmp/baseline.py) — full 31KB entity dump (stride 0x7A00 for Tigrex; 0x4A60 for Popo; 0x4530 for Anteka) to/tmp/<entity>_baseline.binBEFORE the wedge condition. - Wide static scan (
/tmp/wide_scan.py) — sweep the baseline for paired f32 cur/max signatures, u16 counters, isolated bit flags across the full stride. Surfaces stamina/HP/timer candidates. - Live diff poll (
/tmp/live_diff.py) — 3 Hz x 6 min logging every cell change vs baseline. Lets the engine wedge naturally while recording. Post-analyze for monotonic ratchets. - Unfreeze probe (
/tmp/unfreeze.py) — for each top suspect, one shot write the baseline value; watchentity+0x092for advance within ~1.5s. The cell that unfreezes is the gate. - Bisect (
/tmp/find_gate.py) — when single-cell probe is null but bulk reset of all diff cells unfreezes, binary-split the diff set. Found+0x4B8in 5 iterations from 52 candidates.
The bisect step is the key new tool. It scales to any single-cell-gate mystery without requiring upstream RE.
The prior force surfaces (action_input = vt[8] input mutate; action_decided
= vt[8] return swap) both fire per slot and only reliably see one slot
for true-big-mon (tigrex), so forcing through them desyncs the body parts →
crash (§32j). This section pins the single upstream decision point and ships a
crash-free force hook around it.
RE'd by mem-write-BP on entity+0x324 (the live per-slot action triple) →
writer PC 0x09AC5458 (sh $s2,0x324($s0)), then clean memory.disasm of the
enclosing function (raw RAM reads return JIT 0x68XX markers — use the
debugger's disassembler). Tools: tools/re_base_decision_writers.py,
tools/re_disasm_slot_loop.py, tools/re_selector_640_writer.py,
tools/re_executor_a1_livetest.py; dumps in tools/out/tigrex_base_writers/.
Function = f(a0=entity, a1=action_id, a2, a3):
0x09AC5228 addiu sp,sp,-0x40 ; sw ra,0x2C(sp) ... ; prologue
0x09AC5258 sw a1, 0x3C(sp) ; save the action id
; (if entity+0x638 bit0: remap idx via descr[a1].byte3,
; sw 0x09BD38F0 -> entity+0x640 = the type-0x3A table)
0x09AC52D8 sll fp, <row idx>, 3 ; fp = action_row * 8
0x09AC52DC loop: a2 = fp + [entity+0x640] ; -> descriptor row
X = descr[0] ; action-id byte
s2 = (X + 0x3E8 + slot*0xC8) & 0xFFFF ; per-slot vt8 input
sh s2, 0x324(s0) ; -> +0x324/6/8 (s0+=2)
t9 = entity->vtable[0x20]; v0 = t9(entity,s2) ; vt[8] resolve
if v0: z_un_08863e70(...) ; applier
; loop s3++, s1+=0xC8, s0+=2, s6+=0x40 while s3 < [entity+0x1A2]
a1 IS the action id. The three per-slot cells +0x324/6/8 are derived
from one byte as X + 0x3E8 + slot*0xC8 — coherent by construction, which is
exactly why poking one slot desyncs. Override a1 at entry and the engine fans
your action to every body slot through its own vt[8] + applier.
Relation to the documented inputs: a1 = vt8_input(slot2) − 0x578
(= vt8_input(slot0) − 0x3E8). Tigrex: ANGRY_SPIN 0x05A3→a1=0x2B,
IDLE_STAND 0x0598→a1=0x20, IDLE_WALK 0x057B→a1=0x03, ANGRY_CHARGE
0x0589→a1=0x11.
The "brain" is a state machine in the 0x09D2xxxx overlay keyed on
entity+0x1D5; per state it calls jal 0x09AC5228 with a hardcoded/computed
a1 (e.g. caller 0x09D26558: li a1,0x33; jal 0x09AC5228). Live-verified:
a0=Tigrex, a1=0x33 ⇒ slot0 input 0x041B = 0x33+0x3E8.
include/mhfu/ai.h + src/core/ai.cpp; Lua mhfu.on_bigmonster_action(fn)
where fn(ctx{entity,type,action_id}) -> action_id. Entry-detour on
0x09AC5228: cave wrapper saves a0/a2/a3/ra, calls
mhfu_bigmonster_action_dispatch_c(entity,a1), puts the return in $a1,
replays the displaced prologue (addiu sp,-0x40 / sw ra,0x2C(sp)), resumes
0x09AC5230. Filtered by mhfu_entity_is_big_monster (the executor is
generic — also fires for other entities). Lua path marshals to the exec thread
(game-thread Lua-corruption fix).
- Re-patch survives section roams. Overlay code is JIT-fragile. The
overlay-loaded helper (
mhfu_ai_overlay_loaded_helper, postfix on loaderjal 0x0884E9F8) no longer one-shots: whenever the probe at0x09AC5228reads the original prologue0x27BDFFC0(fresh/re-memcpy'd overlay, JIT-cold) it re-arms + re-applies the patch. Plus amap_section_enteredre-patch fallback.install_overlay_action_hookreuses the cave wrapper (no leak) and only patches when bytes are original (never over a marker — Section 25 trap). read_u32(0x09AC5228) == 0x68XXis NOT a dead hook. PPSSPP writes its JIT EMUHACK marker back into code RAM after translating ourJ; execution still routes through the wrapper. (Earlier "hook silent after roam" was actually the freeze gate, not a dead patch.)- Freeze gate
entity+0x4B8(§32j). Forced repeat-fire makes the engine OR in0x10000 | 0x100(observedfrz=0x00010100) → AI tick halts → executor stops being called → monster frozen (aggro/eye/stamina persist). Clear+0x4B8each fire in the cb, and from the workermhfu_tick(the halted AI tick can't run the per-fire clear, so the worker is the live unfreeze net).
A visible engaged Tigrex locked into continuous ANGRY_SPIN: no crash,
aggro intact (yellow-eye + run-away stamina drain persist = engine
side-systems untouched), moves + spins sustained. Reference mod:
mods/lua_host/scripts/tigrex_spin.lua.
entity+0x640 → base ptr (0x09D5A580 = em75.ovl/file_06108 + 0x40440;
remap 0x09BD38F0 when entity+0x638 bit 0 set). 8-byte rows indexed by a1
(base + a1*8).
LIVE-VERIFIED layout (2026-06-28, full table dumped off the running Brute):
the Tigrex table is 123 rows (a1 0x00..0x7A; at a1=123 the b0 self-index
ramp restarts → the next block). Each row:
| byte | meaning |
|---|---|
| b0 | = a1 (self-index ramp, verified 0x00..0x7A) |
| b1 | category: 0xFF normal (121 rows) / 0x01 (only a1 24,25) |
| b2–b5 | 0x00 |
| b6,b7 | two small even params (0,2,4,6,8,10,16) — purpose TBD (not the clip) |
The clip slot is NOT stored in the row — it is DERIVED from a1: the applied
per-slot input is a1 + 0x3E8 (live entity+0x324 = 1005 ⇒ a1 5), resolved by
vt[8] 0x08865254 → clip ptr in the entity+0x1AC table. So to reach a clip you
just force its a1 (no row authoring); the row only needs to exist with a valid
category.
Consequence for porting: the table has 123 action rows ≥ the ~100 clip slots
in a big-mon anim pack, so on the Tigrex host every clip is reachable by forcing
some a1 0..122 — no descriptor injection is needed to make a ported moveset
accessible to Lua AI scripting. Descriptor injection only matters for (a) a host
with fewer rows than your clips, or (b) custom hitboxes (NOT in this row — separate
data, see hitzone RE). Binary only — no human-readable move strings; the
<SPECIES>_ACTION_* names are hand-authored by watching forced actions.
Tooling: anim table <slot> dumps this table live; anim sweep <slot> [lo hi]
force-cycles a1 via on_bigmonster_action and logs the resulting clip ptr to build
the a1 → clip map (src/mhfu_bot/cli/commands/moveset.py).
EU CWCheat "Auto-Paint Bosses" _L 0x008B3A6A 0x000000FF → real 0x090B3A6A,
u8 0xFF, re-written ≥2 Hz (mhfu.write_u8(0x090B3A6A,0xFF) in mhfu_tick).
Marker shows the logical position even when the monster is render-unbound
(entity+0x008==0).
AI override-event API (Section 32d–32i, 2026-05-30; action-force seam
2026-06-03, all verified live). The framework exposes priority-chain
sync-override events on the generic monster-AI engine — mhfu/ai.h.
- ★ Action force (COHERENT OVERRIDE — use this to force big-mon actions)
—
mhfu_on_bigmonster_action(cb, priority)(Luamhfu.on_bigmonster_action). Entry-detour on the big-mon action EXECUTOR0x09AC5228=f(entity, a1 = action_id, …).cb(ctx{entity,type,action_id}, a1) -> a1. The executor fans the returned id to all 3 body-part slots itself (per-slot input =a1 + 0x3E8 + slot*0xC8) through the engine's own vt[8] resolver + applier → coherent, no desync, no crash, and aggro/music/ stamina side-systems keep running.a1 = vt8_input(slot2) - 0x578(= slot0_input − 0x3E8); e.g. Tigrex ANGRY_SPINa1=0x2B, IDLE_STANDa1=0x20. VERIFIED 2026-06-03: visible Tigrex locked into continuous ANGRY_SPIN, no crash, no freeze. Three gotchas, all handled by the reference modmods/lua_host/scripts/tigrex_spin.lua:- Re-patch survives roams. It's overlay code (JIT-fragile). Installed
from the overlay-loaded helper, which now RE-FIRES (one-shot dropped) →
re-applies the patch in the JIT-cold window after a section roam
re-memcpy's the overlay. Plus a
map_section_enteredre-patch fallback. read_u32(0x09AC5228) == 0x68XXmarker ≠ dead hook — it's PPSSPP's JIT translation of OURJ; execution still routes through the wrapper.- Freeze gate
entity+0x4B8— forced repeat-fire makes the engine OR in0x10000|0x100and halt the AI tick (frozen, aggro persists). Zero+0x4B8each fire AND from a worker-thread net (the halted AI tick can't run the per-fire clear, so the worker revives it live).
- Re-patch survives roams. It's overlay code (JIT-fragile). Installed
from the overlay-loaded helper, which now RE-FIRES (one-shot dropped) →
re-applies the patch in the JIT-cold window after a section roam
re-memcpy's the overlay. Plus a
- Picker / per-slot resolver (OBSERVE; SUPERSEDED for forcing) —
mhfu_on_bigmonster_action_decided(cb, priority): vt[8] swap on shared0x08865254, JIT-immune.cb(ctx, engine_value) -> new_value. For small-mon-shape (POPO/ANTEKA/GIADROME) it returns a flat u16 action id and still works to force; for true-big-mon (TIGREX) it fires PER-SLOT and forcing through it desyncs the body → crash — useon_bigmonster_actioninstead.on_bigmonster_action_input(vt[8] input mutate, same caveat: only one slot calls back → desync) is likewise superseded for forcing. Keep these for telemetry / small-mon. - Per-frame tick (OBSERVE) —
mhfu_on_bigmonster_ai_step(cb, priority): entry detour onz_un_08865648. NOT for action override (engine already uses vt[8] inside this function); for telemetry, distance checks, etc. Quiet-gated at TITLE/MENU (Section 26 JIT bypass). Verified live: word @0x08865648 =J 0x09D87F50(0x0A761FD4), word @0x0886564C = NOP. - Lifecycle —
mhfu_on_bigmonster_spawn/_death(observe, 5 Hz poll). - Dropped:
anim_decided— RE showedz_un_0885f928does a slot-index tree lookup returning the per-slot state-container, not a transform of vt[8]'s output (docs/AI_SCRIPTING_ENGINE.md §32g).
Two AI classes (docs/AI_SCRIPTING_ENGINE.md §32h):
| Class | Species | vt[8] returns | Override pattern |
|---|---|---|---|
| small-mon-shape | POPO, ANTEKA, GIADROME | u16 action ID (zero-ext u32) | return <SPECIES>_ACTION_* macro value |
| true-big-mon | TIGREX | POINTER into entity+0x1AC sub-table | return ptr from cache; see below |
Quest UI lumps tigrex+giadrome as "big monster" (counted as targets); the
internal AI tier differs. Tigrex needs richer per-action data (anim group,
hitbox geom, range floats), so engine uses descriptor pointers instead of
flat IDs. The per-slot vt[8] returns distinction above matters for the
SUPERSEDED action_decided path; the recommended on_bigmonster_action seam
sidesteps it entirely — you return one flat a1 action id for either class
and the executor handles the fan-out + (for tigrex) the ptr resolution.
Action descriptor table (the engine's per-species move "database", RE'd
2026-06-03): base ptr at entity+0x640 (0x09D5A580; type-0x3A remap table
0x09BD38F0 swapped in when entity+0x638 bit 0 set). 8-byte rows indexed by
a1: +0 action-id byte, +1 slot/body-part kind, +3 remap index, +4
param (→+0x32C), +6 param (→+0x334 mode). Binary only — NO
human-readable move names exist in the game. The <SPECIES>_ACTION_* /
TIGREX_VT8_INPUT_* names in ai_actions.h + the LABEL table are
hand-authored by observing forced actions in-game. Dump the table + force-cycle
a1 via on_bigmonster_action to enumerate + name a species' full moveset.
Auto-paint map cheat (find a roaming monster): EU CWCheat "Auto-Paint
Bosses" _L 0x008B3A6A 0x000000FF → real addr 0x090B3A6A, write u8 0xFF
continuously (≥2 Hz). Applied live from Lua via mhfu.write_u8(0x090B3A6A, 0xFF) in mhfu_tick. Shows big monsters on the in-game map like a paintball.
Marker shows the LOGICAL position even when the monster is not being drawn
(e.g. a swap-spawned Tigrex culled by the +0x29A section-mismatch gate, or one
genuinely in another section).
Species action enums (mhfu/ai_actions.h, regenerated from tigrex_s6):
POPO_ACTION_0x????— 13.ANTEKA_ACTION_0x????— 40.GIADROME_ACTION_0x????— 34.TIGREX_VT8_INPUT_0x????— 50 (+4 probe).- Tigrex's
+0x0Cspecies data is hitzone, not an action list, so it getsTIGREX_VT8_INPUT_*(stable u16 inputs) instead of action IDs; the ptr the engine resolves to is per-run. mhfu_action_is_valid(type, id)runtime-walks the species table.
Tigrex ptr-resolution helper (Section 32i, passive cache):
uint32_t mhfu_action_ptr_for(uint8_t monster_type, uint16_t vt8_input);Framework snoops every (input, ptr) pair from action_decided callbacks
into a 96-entry-per-species cache (even with zero subscribers). Mods call
the lookup with a TIGREX_VT8_INPUT_* macro and get the engine's last
resolved ptr — or 0 if the engine hasn't picked that action yet this run
(cache warmup). Eager resolver (calling vt[8] directly) deferred because
vt[8] has VFPU + small-RNG side effects; passive cache covers ~99% of
practical mod work.
Other SDK helpers:
mhfu_monster_name(type)(mhfu/ids.h) —"POPO"/"ANTEKA"/"TIGREX"/"GIADROME", or"0xNN"fallback. For logging.mhfu_action_cache_size(type)+mhfu_action_cache_entry(type, i, …)for cache introspection.
Reference mod: framework/prx/mods/experimental/ai_demo/mod.cpp.
RE'd why a species-swapped big monster (Giadrome→Tigrex; native model via
brute_tigrex.lua CAPTURE_NATIVE=true) roar-loops and never fights, using the fork's
slot-7 native Tigrex (charges ~6 s after load) as the working reference. This is the
layer ABOVE the action executor — the high-level AI state machine that decides to ENTER
combat.
Brain state machine (Tigrex, overlay 0x09D26xxx):
- Outer AI state =
entity+0x299(u8, 0..0x21). The per-frame brain driver0x09D33EA0reads it, bounds-checks (sltiu at,v1,0x22), andjrs through the jump table0x09D626F0([base + state*4],jr v1@0x09D33ECC) to that state's handler. Native and swap were BOTH+0x299=4→ same handler → the divergence is a data flag, not the dispatch. - Inner brain phase =
entity+0x1D5(u8). Each state-handler sub-switches on it. - State-4 handler
0x09D26648= roar→charge: phase 0 fires the ROAR through the action executor0x09AC5228with a1=0x36, sets+0x523=1; phase 1 waits for the roar anim (+0x76Atracker); phase 2 → escalation gate →+0x1D5=3(sb@0x09D2675C) +jal 0x09D26158(combat-enter: sets+0x4B5=1, dispatchesentity->vt[0x88]) → the monster charges.
⛔ RETRACTED 2026-08-23 — "on the swap
+0xBCbit0 is stuck at 1" does not reproduce. A clean Giadrome→Tigrex swap cycles+0x299through 2→3→11→4→5→8,+0x1D5reaches 3 and+0xBCbit0 clears — it escalates normally, charges, and kills the hunter (51/52/11 damage, measured with every small monster pinned at HP 0). The 2026-07-01 capture was taken with the Brute asset inject on and with per-tick maintenance writes to the monster; both are since shown to be the actual cause. The gate mechanism below is correct and stays — it is only the "the swap is stuck here" observation that is withdrawn. →docs/BRUTE_TIGREX_PORT.md§2026-08-23.
THE ESCALATION GATE = entity+0xBC bit 0. The phase-2 path (0x09D26744):
lhu +0xBC; andi 1; bne !=0 → skip; it writes +0x1D5=3 only when bit0 == 0.
+0xBC is a fast-toggling per-frame flag. On the native it reaches 0 often at
brain 2 → escalates reliably. On the swap it is stuck at 1 in 44/45 brain-2
frames → the escalation almost never fires → the monster loops roar↔notice forever
(+0x1D5 oscillates 1↔2, never 3; +0x4B5 never sets).
Proven: force-clearing the swap's +0xBC &= ~1 at high rate DID fire combat-enter
(+0x4B5 reached 1), but the engine re-sets bit0 every frame, so the clears race it →
on-screen the monster "wiggles, completes no animation." Force-poking confirms the gate
but is not a clean fix — there is a per-frame WRITER of +0xBC bit0 whose condition holds
on the swap but clears on the native (lead: roar-completion; the roar tracker +0x76A
differed native-vs-swap, and brain phase 1 waits on it → the swap's roar never "completes"
→ bit0 never clears).
Retractions / corollaries: the ROAR itself dispatches through the executor
0x09AC5228 (a1=0x36), so a swap showing "0 executor calls" via the framework detour =
the detour was dead on that instance (JIT-wiped), NOT the executor being absent.
Target also differs (native→player +0x2F4=0x090B3440; swap→cat 0x090BDC40) but forcing
target never helped → symptom, not cause.
Open / next: write-BP +0xBC on the swap to find the per-frame bit0 SETTER + its
condition (why perpetually "not ready" on the swap); compare to native. That points to
either a real fix (satisfy roar-completion / the missing wiring) or confirms it's
spawn-time combat wiring → the native add-target/relocate spawn path.
Key cells: gate +0xBC bit0, brain +0x1D5, outer state +0x299, combat-enter flag
+0x4B5, roar tracker +0x76A. Code: dispatcher 0x09D33EA0 (tbl 0x09D626F0),
handler 0x09D26648, escalate write 0x09D2675C, combat-enter 0x09D26158,
roar-via-executor 0x09AC5228 a1=0x36.
Tooling note: the fork's debugger savestate.load command did NOT reload here
(no-op). Reliable reload = relaunch --state slot7 (lifecycle.launch_ppsspp(state=)),
which loads the slot DIRECTLY (fast, no full boot). Native slot 7 = ULES01213_1.01_6.ppst
(area 109); charges ~6 s after in-area then dies to an Anteka → basecamp (reload to see it
again). Full narrative → memory swap-bigmon-combat-latch-gate (sessions 3/3b/3c).
§33b — UPDATE (2026-07-01, session 4): +0xBC is a SYMPTOM; combat-enter IS reached but resolves to RE-ALERT
The write-BP on +0xBC supersedes the "gate = +0xBC bit0" framing above. bit0 is
honest animation state, and the escalation is NOT actually stuck — it fires and lands in
the wrong place.
+0xBCbit0 = "root-bone clip still playing this frame." Per-frame SETTER =0x088639B0in the EBOOT anim/clip interpolator (0x08863xxx), NOT the overlay brain:sh v0,0x3C(s1)withs1 = ent+0x80(root-bone clip state;+0x3C(s1) = ent+0xBC). It unconditionally ORs bit0 whenever the clip has not passed its end — gate0x088638E0 c.le (phase+speed), clip_end; when past end it takes the clear path (0x08863A40/0x08863A68:andi 0xFFFE; sh). Clip-state fields atent+0x80:+0x10phase,+0x14speed,+0x18loop-restart,+0x1Cend,+0x3C(=+0xBC) flags (bit0 playing / bit1 loop / bit2 set on the roar). So bit0 clears cleanly at every clip's end; the swap just cycles roar(end=292)→short→short→roar, and bit0 only clears during the short clips while+0x1D5==1, never mid-roar while+0x1D5==2 → the+0x1D5==2 && bit0==0coincidence the gate needs never happens. (A cat hit flinch-interrupts the roar → bit0 clears at a catchable moment → brief engage — HITL-confirmed.)- combat-enter
0x09D26158IS reached (stack-walk at a+0x299write-BP, live nested chain):driver 0x09D33F1C → state-4 handler (ret 0x09D2676C) → combat-enter (ret 0x09D261A4; jalr vt[0x88] @0x09D2619C) → combat_step vt[0x88] 0x09D3D608 → overlay dispatch 0x09D3D79C (idx = s2&0xFFFF into table 0x09D62D68; idx 0 → 0x09D3D7C8) → 0x09D3C8D8 (action-dispatch on a1) → 0x09AC8690 → enter_state2 0x09AC87E8 → set_ai_state 0x09AC8818(state=2). So the swap's virtual "combat step" resolves toenter_state2(state=2)= RE-ALERT (resets+0x1D5=0, sets+0x4B7=1) → the roar→charge phase machine restarts → the loop. combat-enter passes mode a2=2 (itsv0==2branch0x09D26184); mode 2 → dispatch idx 0 → action-id switch default → the state-2 path. set_ai_state 0x09AC8818(a0, a1→+0x298, a2→+0x299)is the generic AI-state setter: writes+0x298/+0x299, zeroes+0x1D5/6/7, callsz_un_08865cb8. Wrapperenter_state2 0x09AC87E8clears+0x769, sets+0x4B7=1. (The §33 "+0x4B5=1= combat-enter" is imprecise — the reached path sets+0x4B7;+0x4B5stayed 0.)- Swap targets the CAT (
+0x2F4=0x090BDC40,+0x542=1), not the player (0x090B3440). vtable =0x089BB69C,vt[0x88]=0x09D3D608.
Refined root: the escalation FIRES but its vt[0x88] combat-step resolves to re-alert
because the mode/action combat-enter passes (=2) — and/or the target being the cat — selects
the "return to alert" dispatch branch. +0xBC is downstream coupling, not the lever (poking
it races the clip writer → "wiggle"). Next (HITL + fork): capture the same chain on the
native slot-7 Tigrex and diff (a) combat-enter's mode arg (0x09D2617x), (b) dispatch idx
s2&0xFFFF (table 0x09D62D68), (c) +0x2F4 target — the differing one is why native
charges. Still points to Path 2 (native add-target/relocate spawn wires real combat) as
the fix. New code addrs: clip-flag setter 0x088639B0 (+0xBC), clip state ent+0x80,
combat_step 0x09D3D608, dispatcher 0x09D3D79C + table 0x09D62D68, action-dispatch
0x09D3C8D8, enter_state2 0x09AC87E8, set_ai_state 0x09AC8818. Full narrative → memory
swap-bigmon-combat-latch-gate (session 4).
The combat-latch problem is not a gate to poke — it's that a bare emId SWAP reuses the
quest's single, incompletely-provisioned target group, so the monster can posture (roar/
notice) but never commits. A natively-ADDED monster gets a fresh target GROUP + its own
manager + full provisioning (buildTargets → mgr_ctor → provision_driver → resource_reg),
and that fights + deals damage.
- API:
mhfu_quest_add_monster(quest, id[, x, z])(Luamhfu.quest_add_monster) in aQUEST_TARGETS_BUILDINGsubscriber — appends a list-A node + splits the new monster intotarget[1](group 1) + bumpsQuest+0x67C=2. Same-family = resident overlay, no relocation. - Proven (native Tigrex quest): add a 2nd Tigrex → BOTH the native and the added one deal real damage to the player (killed twice; combat states 8–22 targeting the player). The bare swap deals 0.
- General path for a different-family quest (Giadrome→Brute), shipped in
mods/lua_host/scripts/dup_test.lua: the one AI-overlay slot follows the emId + the add-forge is disarmed, so a bare different-family add has no overlay → REPLACE Giadrome→Tigrex first (overlay+node0 = Tigrex), ADD a 2nd Tigrex (same family now → the fighter), suppress the primary (teleport off-map + clear the visible bit). Ride the Brute v63 model inject (inject_relocate 6185) on top → the ported Brute renders, animates with his own moveset, engages, and kills. No crash from inject+replace+add. - Visibility fix: the added fighter spawns in the intro section, not the player's; forcing
+0x29Aat the 2 Hzmhfu_tickcan't hold (the engine re-derives the section tracker from POSITION each frame). A one-time physical relocate into the player's section STICKS (the engine then maintains it) AND kicks it from idle into combat. - Gotchas: (1) forcing a specific action via
on_bigmonster_actionSETS the freeze gate+0x4B8(0x100|0x10000) faster than a 2 Hz clear → the monster freezes ("stuck standing") — let natural combat run instead. (2)cli_bridge.luahookson_bigmonster_actionat priority 90; a lower-priority force loses the chain. (3) hitbox↔anim DESYNC: v63 ships the Brute's OWN clips but the Tigrex overlay drives the actions/hitbox timing → an attack hitbox can be active during a non-attack pose ("killed by touching him"); ship v58 (Tigrex-rig retarget) for synced hitboxes, v63 for authentic moves. Timer freeze: hold0x09A05DD0+0x09A050F8(~2 Hz). Full detail → memoryswap-bigmon-combat-latch-gate(§4d–4f).
⚠️ SUPERSEDED on the mechanism, 2026-08-26 (§33–34). Host-side ownership below is right; "bound to the ACTION ID" is wrong. They belong to the behaviour state(entity+0x298, entity+0x299);a1is the animation channel.
Measured on the ported Brute, in-game, by forcing one id on two builds of the same monster and
filming both (tools/anim_capture.sh <pac> 51):
brute_tigrex_v64_nativeanim— Brute mesh over the PRISTINE native skeleton/anim.a1=51plays the Tigrex roar; the hunter covers his ears.brute_tigrex_v67_hostslots— fully ported P3rd clips.a1=51plays the Brute's rock throw — and the hunter still covers his ears. No projectile, no knockback, no damage.
So the executor dispatches action 51 and the engine runs 51's semantics; the port only changes
what is drawn. Confirmed against damage across ids on the ported build: a1=48 (host = bite)
lands −53, a1=51 (host = roar, no damage component) never lands anything.
Where the semantics are NOT. The 8-byte descriptor row (entity+0x640 → 0x09D5A580) does
not carry them. Live dump around the tested ids:
a1 | b0 b1 b2..b5 p6 observed
47 | 47 255 0 0x0004 wind pressure
48 | 48 255 0 0x0400 bite — DAMAGES on the port
51 | 51 255 0 0x0606 roar (host) / rock throw (port) — no damage
53 | 53 255 0 0x0A08
54 | 54 255 0 0x0606 jump forward
a1=51 shares p6=0x0606 with 46, 49 and 54, so p6 is a mode/param code, not an effect
selector. The per-action hitbox/effect definition must therefore hang off the vt[8]-resolved
action pointer — the (input, ptr) pairs the framework already snoops into its per-species
cache (mhfu_action_ptr_for()).
ANSWERED 2026-08-26 — and the premise was wrong. The definition is not behind the
vt[8]-resolved pointer; that pointer resolves an ANIMATION. Per-action semantics are code in
the species overlay behind switch(+0x298) -> switch(+0x299). So it is the second branch: the
semantics have to be synthesised framework-side — and that is now demonstrated end to end (§34).
- A HELD force is inert. Rewriting every dispatch to one id plays the clip but deals no
damage — the native control managed 0 damage over 5155 co-located ticks at 66 units. Pulse it
(fire once, pass through for ~8 ticks). →
monster-ai - The effect window is not the dispatch. Syncing a probe to the executor fire catches the effect only ~40 % of the time even on the native build (2 of 5 trials). Count locks; never average them (0 %, 6 %, 105 %, 234 % averages to "no effect" and hides the finding).
Everything below comes from static analysis of the shipped overlays with the new
offline tools (tools/mips_dis.py, tools/ovl_explore.py, tools/em_moveset.py) —
no emulator, no cold boot. Structure (switch tables, call graph, register flow) is
solid; the labels ("main state 0 is the ground set") are inference and are marked
where they matter. The live cross-checks that would settle them are listed at the end.
Context that made this legible: MH Frontier's AI has been documented publicly
(github.com/Paxlord/frontier-ai-doc-dump) as an interpreted bytecode whose central
opcode is em_cmd_act_set (0x05) [main_state][sub_state][flags]. MHFU is the same
engine lineage and has the same two-field action model — but compiled, not
interpreted. That framing is what turned a pile of jump tables into a moveset.
| channel | driven by | lands in | owns |
|---|---|---|---|
| animation | executor 0x09AC5228(entity, a1) |
entity+0x324/6/8 (a1 + 0x3E8 + slot*0xC8) |
which clip the three body-part slots play |
| behaviour | act_set(entity, main, sub, mode) |
entity+0x298 / +0x299 |
which per-action CODE runs — hitboxes, effects, transitions |
They are set together by the engine and are independent when you force them.
mhfu_on_bigmonster_action hooks the executor, so it moves the animation channel
only — the monster keeps running whatever move its brain selected. That is the
mechanism behind "the Brute plays the rock throw while the hunter covers his ears":
the Tigrex AI had genuinely chosen the roar, and the port had filed the Brute's
rock-throw keyframes into the roar's clip slot (§33e).
0x09AC8690 act_set(entity, main_state, sub_state, mode) <- the public one; em75 calls this
0x09AC87E8 thin wrapper
0x09AC8818 the writer:
+0x298 = main; +0x299 = sub
+0x460/+0x461 = the PREVIOUS pair (kept for transitions)
+0x1D5/6/7 = 0 (the per-action phase counters)
+0x1A8..+0x1AB = 0 (per-slot enable bytes)
+0x1B0..+0x1BC = 0 (per-slot input cursors)
The mode argument (0..4) selects a variant path. Clearing the slot cursors is why
act_set and the executor cooperate: act_set retires the old animation so the new
action's handler can install its own.
act_set is called from ~40 sites in game_task.ovl and 8 in em75.ovl — it is
generic engine code, not per-species, so it works for every big monster.
em75.ovl never writes +0x298/+0x299 — it only reads them, in the per-frame action
tick 0x09D36A08:
0x09D36A08 main = +0x298; sub = +0x299; copy both to +0x460/+0x461
switch (main) over 8 cases -> a per-main sub-dispatcher:
| main | dispatcher | sub_states |
|---|---|---|
| 0 | 0x09D33EA0 |
34 |
| 1 | 0x09D340F8 |
43 |
| 2 | 0x09D34390 |
25 |
| 3 | 0x09D34528 |
108 |
| 4 | 0x09D34F08 |
32 |
| 5/6/7 | 0x09D35140 / 0x09D351E0 / 0x09D352F0 |
no switch |
Each sub_state case calls one handler. A handler is a small phase machine on
+0x1D5: phase 0 starts the clip via the executor, phase 1 waits for it, then
transitions. The roar, (main 0, sub 2) = 0x09D26520, is the canonical shape and is
the function the old notes quoted as li a1,0x33; jal 0x09AC5228 — it drives a1=51.
tools/em_moveset.py <ovl> --states prints the whole table for any species:
231 (main,sub) -> handler -> animation-id rows for em75, e.g.
(3, 0) handler 0x09D2C470 anim a1 -> 43
(3, 1) handler 0x09D2C550 anim a1 -> 102,103
(3, 6) handler 0x09D2CC10 anim a1 -> 47,48
The open question from 2026-08-25 was whether a port could ship its own move semantics as data in its relocated overlay. It cannot. Effects are emitted from inside the handler functions, as literal arguments:
spawn_effect(entity, effect_id, bone) em75 0x09D36B58
-> 0x09ACB3E0(entity, effect_id, ...) game_task, biases effect_id per species
-> 0x08883B54(...) EBOOT, the actual spawn
Attributed to their behaviour states (tools/em_moveset.py + the spawn xref):
(0,19) / (0,20) effect 85, 87, 79 at bone 37
(1,22)(1,26)(1,41)(1,42) effect 60 at bone 33
(1,23) effect 60 at bone 33
Timing is frame_reached(entity, frame) = 0x09D36BF0 — 1970 call sites in em75,
the single hottest function in the overlay. So a move is literally
if (frame_reached(F)) spawn_effect(id, bone), written per species in MIPS.
There is no per-action hitbox table to inject. The 8-byte descriptor rows at
0x09D5A580 stay what §32k said they were, and the model PAC has no HITS
sub-resource in this family (checked: native file_06185 and the port are both
skeleton/pmo/tmh/anim ×2), so the port is not dropping hitbox data either.
Consequence for an arbitrary port (the Zinogre bar): a monster running on a host
overlay will always behave like the host. Authentic semantics have to be
synthesised framework-side — branch (b). The primitives are all generic engine
code and are now located: act_set for the behaviour channel, the executor for the
animation channel, spawn_effect/0x09ACB3E0 for effects at a bone, and the slot
container (entity+0x80+slot*0x40) for frame timing.
v67_hostslots was built with fill_slots="host", which reproduces the host PAC's
occupancy slot for slot — by position, not by meaning. Host slot 51 is the roar;
the Brute clip that happened to land there is the rock throw. So the observed GIF is
the engine working correctly: the AI chose the roar, the roar's effect fired, and the
renderer drew whatever keyframes now live in slot 51.
a1=48 damaged and forced a1=51 did not. Forcing a1 provably cannot change a move's
semantics (with a1 pinned, the behaviour channel moved through 8 states untouched), so
that correlation was never causal — most likely the sample conflated the forced id with
whatever the monster's brain was independently doing.
- Log
entity+0x298/+0x299next to every forceda1— proves the channels are independent and names the state the monster was really in. - Call
act_set(entity, main, sub, mode)from Lua and confirm a complete move runs (animation + effect + damage). That is the AI-scripting seam mod authors want. - Force
a1while holding(main,sub)fixed to separate the two effects cleanly.
tools/mips_dis.py— MIPS32/Allegrex disassembler (integer + cop1; VFPU as raw).tools/ovl_explore.py— MWo3 reader:map/dis/func/xref/switch/hex. Reads the file on disk, so it is free and immune to the JIT-marker problem that makes live disassembly of already-translated code useless.tools/em_moveset.py— per-species move table: id ->act_set(main,sub,mode), and--statesfor(main,sub)-> handler -> animation ids. Verified to run on em01, em20, em40 as well as em75.
Every one of the 17 em*.ovl overlays has the identical action tick: a
switch(entity+0x298) over exactly 8 main states, each fanning out into a
switch(entity+0x299) of per-action handlers. 1658 (main,sub) rows in total.
| overlay | action tick | rows | overlay | action tick | rows |
|---|---|---|---|---|---|
| em75 (Tigrex) | 0x09D36A08 |
231 | em33 | 0x09D1E278 |
72 |
| em20 | 0x09D1B438 |
156 | em21 | 0x09D1D230 |
69 |
| em01 | 0x09D2A508 |
142 | em59 | 0x09D251F0 |
65 |
| em54 | 0x09D2C380 |
135 | em02 | 0x09D1E5B0 |
62 |
| em82 | 0x09D1C208 |
117 | em58 | 0x09D204A0 |
58 |
| em15 | 0x09D1AF60 |
107 | em55 | 0x09D1E4A8 |
49 |
| em17 | 0x09D1D1D0 |
107 | em07 | 0x09D1E298 |
19 |
| em83 | 0x09D1D618 |
91 | em40 | 0x09D1B4D8 |
88 |
| em14 | 0x09D1E3A0 |
90 |
switch_of() recognised a jump-table base only when the %lo
immediate was > 0x1000, which is true of em75 (addiu a1, a1, 11088) and false of
em02 (addiu a1, a1, 1688). Ten overlays therefore reported "no action tick" while
containing the exact same code shape. Never filter an address by the size of its low
half — test where the computed address lands (here: inside .data). Fixed by
resolving lui+addiu per register and range-checking the result; want_key=0x298
was added at the same time so the species switch in the same function is not mistaken
for the action dispatch.
The tick is also reachable structurally rather than by pattern: every monster vtable
has slot 8 = 0x08865254 (the shared action picker), and slot 31 (+0x7C) is the
species AI entry. 38 tables carry that signature; the 17 whose slot 31 points into the
em-overlay range are the big monsters, the rest point into game_task — which is the
small-monster/EBOOT AI split, independently confirmed.
Three cross-checks, two cold boots, native Tigrex (INJECT_BRUTE=false) so the port's
slot mapping could not confound anything. The a1 these runs log is the id the handler
passed to the executor, before any clip lookup, so the result is about the engine and
not about any particular animation pack. Tools: the two-channel probe in
mods/lua_host/scripts/brute_dmg.lua, tools/check_two_channel.py.
Logged (entity+0x298, entity+0x299, a1) at every executor dispatch during natural
play. 44 distinct triples over 28 behaviour states, 450 state transitions.
=> agreement: 26 ok, 0 mismatched, 0 not in the table
Every state the monster entered was in the offline table, and every one asked for an
animation the table predicted — including the multi-id states ((2,1) → 11,15,19,32;
(1,0) → 1,3). Two states the static pass could only mark "(computed)" were resolved
live: (1,2) → a1 5,6 and (2,13) → a1 14,30.
The one initial mismatch was a bug in our extractor, and the live run caught it.
(0,5) predicted 54, live said 46. The handler is
lbu v1, 0x1E8(s0) ; species
bnel v1, 81, ... ; branch-LIKELY
addiu a1, zero, 46 ; delay slot — annulled unless the branch is taken
addiu a1, zero, 54 ; species == 81
a1 is species-conditional; a branch-likely annuls its delay slot when not taken, so
the slot is the other arm, not part of the straight-line path. Walking it linearly
reported one arm as if it were the only one. em_moveset.py now keeps both
((0,5) -> 46,54) and the check is 26/26.
With a1 pinned to 51 through mhfu_on_bigmonster_action, the behaviour channel visited
8 distinct (main,sub) states and the force overrode 8 distinct natural a1
values (1, 6, 15, 18, 30, 32, 90…). The monster's brain never noticed. So hooking the
executor moves the animation channel only and cannot change what a move does —
which is exactly why the 08-25 forced-a1 runs could not produce working attacks.
act_set reimplemented with plain memory writes (the framework has no native-call
binding yet): save the previous pair to +0x460/+0x461, write +0x298/+0x299, zero
+0x1D5..+0x1D7. Pulsed every 24 ticks, animation channel untouched.
[actset] (1,0) -> (3,6) #48 t=1152
[state] main=3 sub=6 (a1=47) t=1152
[brute] ACTION a1=47 (x48) main=3 sub=6 t=1152
[brute] ACTION a1=48 (x48) main=3 sub=6 t=1154
51 pulses → 51 × a1=47 → 51 × a1=48, and 47/48 were dispatched from no other state
in the entire run. Writing two bytes and a phase counter makes the engine run the
(3,6) handler, which then drives its own two-step animation sequence (windup at t,
strike two ticks later) — precisely the 47,48 pair the offline table predicts.
That is the AI-scripting seam: a mod picks the monster's move by writing (main, sub),
and the engine supplies the animation, the timing and the effects. No native call, no
PRX rebuild, no per-tick maintenance.
The first attempt showed the sequence but not damage (flown with --keep-hp, which pins
hunter HP by design). Re-flown without it, forcing (3,0) = ANGRY_SPIN — chosen
because the spin barely moves the monster and its hitbox is an AoE around him, so simply
standing close is enough to be hit.
[actset] (0,4) -> (3,0) d=232 #10 t=900 <- the Lua write
[state] main=3 sub=0 (a1=43) t=900 <- engine enters (3,0), drives a1=43
[hit] -33 HP d=347 main=3 sub=0 t=905 <- 5 ticks later
[actset] (0,4) -> (3,0) d=265 #12 t=924
[state] main=3 sub=0 (a1=43) t=924
[hit] -48 HP d=447 main=3 sub=0 t=929 <- 5 ticks later
RESULT hp 100 -> 0 — the hunter was killed after 60 s co-located.
| drop | distance | state | verdict |
|---|---|---|---|
| −9 | 4733 | natural (1,0) | too far to be this monster |
| −9 | 8923 | natural (0,9) | too far |
| −77 | 654 | natural (1,4) | the monster's own attack — the control |
| −12 | 418 | forced (3,0) | trip-sized (≤15) |
| −33 | 347 | forced (3,0) | connected attack |
| −48 | 447 | forced (3,0) | connected attack |
The attribution is airtight: of 9 entries into (3,0), ZERO happened without a pulse
within 2 ticks. The monster never chose the spin by itself in this run, so every spin
was ours, and both attack-magnitude drops land 5 ticks after a pulse with the monster in
reach. The natural −77 is a useful control: the engine's own moves hit for ~77, so a
forced move at 33–48 is a real connected hit and not a rounding artefact.
Conclusion: a mod can pick a monster's move by writing (main, sub), and the engine
supplies the animation, the timing, the hitbox and the damage. That is the whole
framework-side-synthesis thesis demonstrated end to end.
Sample honesty: 2 clean hits (plus one trip) out of 9 forced spins. The spin has a
radius and the hunter was being walked around by --pursue, so misses are expected;
this measures "can it hit", not a hit rate.
| # | check | result |
|---|---|---|
| 1 | log +0x298/+0x299 beside forced a1 |
✅ 26/26 agreement; caught a real extractor bug |
| 2 | drive act_set from Lua, see a complete move |
✅ 51/51 exact a1 sequence, and it DEALS DAMAGE (−33, −48; hunter killed) |
| 3 | force a1 with (main,sub) free → independence |
✅ 8 states / 8 overridden ids |
Still open — the 08-25 tension is now half-resolved. §33e flagged that "forced a1=48
damages, a1=51 does not" is unexplained by the two-channel model. §34b says forcing a1
cannot change a move's semantics at all, so that correlation cannot have been causal —
most likely the sample conflated the forced id with whatever the monster's brain was
independently doing. It should be retired, not repaired; the honest replacement
experiment is 34c-without---keep-hp.
- A wedged debugger does not stop the game.
memory.read_u16 timed outkilled the host-side bot ~730 s in, and the Lua probe kept logging for another ~800 ticks — which is where most of the §34b force data came from. Readframework.logbefore assuming a wedged leg produced nothing. - A run that ends in-quest leaves a signed contract, and the next boot's elder offers
to cancel instead of opening the board (
quests.pyhandles it, but can burn its three retries). Cost one leg; a re-run cleared it. [lua_host] compile FAILED brute_dmg.lua: not enough memorywhen a script is copied while the previous game still runs is the known hot-reload OOM — harmless, the cold boot that follows compiles fine. Check which boot the ARMED line belongs to.
A -9 HP drop recorded during the spin run was written up as map cold damage. There
is no cold damage in MHFU — coldness drains max stamina faster and never takes HP.
The drop is unattributable to the big monster regardless (it happened at d = 7101,
and during the walk phase), so the conclusion stands, but the reasoning was wrong and
tools/dmg_experiment.py carried the same wrong belief in its docstring.
The small-damage source that does exist near a big monster is TRIP-OVER damage — it walking into the hunter, historically under 15 points. So for the spin test the bar for "the forced attack connected" is a drop > ~15 with the monster in reach; anything smaller and close is a trip, and anything at all while the monster is far is neither.
Section 34 — The offline move table, CONFIRMED LIVE on a native Tigrex; and a THIRD channel (2026-08-26)
§33 recovered (main,sub) -> handler -> anim a1 for em75 by disassembling the overlay.
That was inference. tools/native_ai_probe.py loads tigrex_s6 — a native Tigrex, the
positive control — and snapshots its whole entity struct at 5 Hz through complete
notice→roar→charge→bite ramps. The two line up.
Live pairs against tools/em_moveset.py --states, one cold boot:
(0, 7) a1 80 offline 80 idle/roam
(1, 2) a1 5 offline (computed)
(0, 4) a1 54 +0x523=1 offline 54 THE ROAR (clip end 292)
(1, 3) a1 2 offline 2 reorient (clip end 56)
(0, 2) a1 51 offline 51 2nd roar (clip end 370)
(1, 4) a1 17 offline 17 THE CHARGE (clip end 110)
(3, 5) a1 42 then 41 offline 41,42 the attack (clip end 26, then 214)
(3, 4) a1 41 offline 41,42
(0, 5) a1 46 offline 46,54
(2, 1) a1 15,11,19 offline 11,15,19,32
(3, 6) a1 47 offline 47,48
(0, 8) a1 24 offline 24,25
(0, 9) a1 25 offline 24,25
em_moveset.py can be trusted as the move table. Use it instead of guessing clip ids.
Note (2, 1): it is not one animation, it is a three-clip sequence keyed on +0x1D5 —
phase 1 → a1 15 (62 frames), phase 2 → a1 11 (50), phase 3 → a1 19 (140). Most handlers are
shaped like this. A move is a program, and its animation ids are its instructions.
entity+0x324 + slot*2 is one u16 per slot, holding a1 + 0x3E8 + slot*0xC8.
The three slots are the animation pack's three BONE-PARTITION STREAMS, not three
arbitrary layers. MHFU's 0x38 anim layout partitions ONE rig across streams 0/2/4 — for the
Tigrex, 31 + 9 + 5 = 45 bones (monster-porting, docs/ANIMATION_FORMAT.md) — and the
runtime resolves one action id separately in each. Live: with all three inputs on the same
a1, the three resolved anim-node pointers were distinct in 715 of 715 samples
(a1=54 → 0x0948BC34, 0x094EFFEC, 0x09511CD8). The gaps, 0x643B8 then 0x21CEC,
sit at a ~3:1 ratio against a 31:9 bone ratio — the sizes the partition predicts.
(Slot-index ↔ stream-index is the natural reading of that alignment, not something this
run measured directly; confirming it means reading a resolved pointer back against the
loaded pack's stream table.)
So one action is one animation sliced three ways, dispatched three times.
Independent corroboration from the 2026-06 work: the spin was recorded then as "three
per-slot inputs 0x0413, 0x04DB, 0x05A3" without a decode. Under a1 + 0x3E8 + slot*0xC8 those are all a1 = 43 — and em_moveset gives (3,0) -> a1 43, the spin.
The formula and that observation were derived two months apart from different data.
Two functions write the inputs:
| writer | reaches | signature |
|---|---|---|
executor 0x09AC5228 |
all three slots | (entity, a1) |
per-slot applier 0x09AC5520 |
ONE slot | (a0=entity, a1=biased input, a2=speed, a3=mode, t0=slot) |
0x09AC5520 computes v1 = entity + (t0<<1) + 0x324, sh a1, 0(v1), then
+0x32C+slot*2 = mode, +0x334+slot*2 = speed>>1, resolves the clip through vt[8] and
installs it with 0x08863E70(entity+0x80, node, mode, speed, slot). It also skips the
restart when the slot already holds that input (beql v0, a1 → speed 0), which is how the
engine avoids re-triggering a clip that is already running.
And this is how the engine makes one body part do something else. (0,8)'s handler
0x09D26B10 calls the executor with a1=24, then immediately overrides slots 0 and 2 back
to idle — i.e. bones 0–30 and 40–44 keep standing while bones 31–39 play clip 24:
0x09D26B70 jal 0x09AC5228 a1 = 24 ; whole body
0x09D26BA0 jal 0x09AC5520 a1 = 1001, t0 = 0 ; slot 0 <- a1 1
0x09D26BB8 jal 0x09AC5520 a1 = 1401, t0 = 2 ; slot 2 <- a1 1
and the live trace reads back exactly that: a1 = 1/24/1, in 106 samples. (0,9) is the
same handler with a1=25 → 1/25/1.
🔴 This is the shape of "his head is playing a different animation from his body" — and the engine does it on purpose. For a PORT it is a hazard rather than a feature: the injected pack must populate the same slot index coherently in all three streams. Fill slot N in stream 0 and leave it mismatched in stream 2, and that bone range plays whatever sits at that offset while the rest of the body plays the clip.
41 call sites in em75.ovl reach 0x09AC5520, and they are concentrated, not scattered:
33 in 0x09D32490 <- called from ONE place: 0x09D351E0, the MAIN 6 dispatcher
3 in 0x09D26398 <- handler (0,0)
3 in 0x09D26308 <- slot helper, called only by 0x09D26B10
2 in 0x09D26B10 <- handlers (0,8) and (0,9)
That is a partial answer to §33c's loose end — mains 5/6/7 have no sub-state switch
because they are not move banks. Main 6 at least is a bank of 33 per-slot animation
writes, dispatched straight out of 0x09D351E0. (Mains 5 and 7 are unexplained; the live
trace never entered any of the three, so this is structure, not observation.)
For everything a monster does in ordinary combat — mains 0..3, which is all the live trace ever showed — the executor is the only writer and all three slots stay in agreement.
mhfu_on_bigmonster_action hooks only 0x09AC5228, so a forced clip is a whole-body write
that a per-slot call can then partially overwrite. Slots disagreeing is the engine's
normal composition mechanism, not a bug — but it means "force one a1" is not the same
operation the engine performs.
Same ramp three times (two fired; the third is a clean negative — see below):
+0x5DC0 → 1 first, while the monster is still in its roam state. This is detection, and it is gated on the species sight radius0x09BC1030(Tigrex 5000): zeroing it prevents the flip, restoring it produces one within ~7 s, and the cycle where the hunter stood 5753 units away — outside 5000 — never engaged at all.(0,4)a154,+0x523=1— the roar.(1,3)a12 — reorient onto the target.(0,2)a151 — second roar.(1,4)a117,+0x1D5climbing to 3 — the charge; 4687 → 893 units.(3,5)/(3,4)a142 → 41 — the attack.
⛔ +0x4B5 and +0x4B7 are not the latch. Through all of the above on a native
monster, +0x4B7 never left 0, and +0x4B5 only ever appeared as a one-sample pulse
at (0,2)/(0,1). §33's "combat-enter sets +0x4B5" described a transient.
+0x2F4 read PLAYER for the entire run including while calmed — the target selector is
not the combat gate either. (This state has no Felyne; that is the confound §33b's
cat-vs-player comparison could not control for.)
- Do not override
a1. The handler already picks the right clip, in the right order, with the right length. A forced id replaces a clip the phase machine is timing against — the machine keeps its own schedule, so the clip restarts mid-move and never completes. Driveact_set(main, sub)and let the animation follow. - If a port's clips are numbered differently, remap them in the asset, not at runtime.
- An AI-scripting API should expose the move table (
em_moveset.pyoutput) as data, andact_setas the verb.mhfu_on_bigmonster_actionstays useful for observing which action is running; it is the wrong tool for choosing one. - A "monster is in combat" predicate should read
+0x5DCplus the(main,sub)pair against the table, not any of the+0x4Bxflags.
§34a gave (main,sub) -> a1. The hop nobody had pinned was a1 -> which clip in the PAC, and capture_action_anim_map.py was written on the assumption it could only be
resolved live. It resolves offline, and the answer is that there is no mapping to resolve:
Evidence 1 — every live a1 lands on a filled slot. Take the 715-sample native trace,
take each body-slot's a1, and look it up in file_06185's anim sub as a slot index in
the stream that body-slot reads (0/1/2 -> streams 0/2/4): 2145 of 2145 hit a populated
slot, 0 miss. The native Tigrex fills 62/64/62 of 100 and leaves 38 empty per stream, so
a wrong mapping would miss constantly.
Evidence 2 — the asymmetric handler is explained by the asset. (0,8)/(0,9) drive
a1 24/25 into body-slot 1 only, pushing slots 0 and 2 to a1 1 (§34b). Slots 24 and 25
exist only in stream 2, and carry 9 tracks — stream 2's whole bone partition:
slot s0 s2 s4 sum
1 31 9 5 45 idle — a whole-rig clip
54 31 9 5 45 the roar
17 31 9 5 45 the charge
24 0 9 0 9 HEAD-ONLY, stream 2 only
25 0 9 0 9 HEAD-ONLY, stream 2 only
The handler puts 24 in body-slot 1 because that is the only slot that has it. MHFU ships partial clips that animate one bone partition and are designed to be layered over an idle body — a head-and-neck movement while the body stands. That is the engine's own version of "the head is doing something else", and it is a feature.
So the whole chain is now data:
act_set(main, sub) -> em_moveset row -> a1 == PAC anim slot index
|
body-slot 0/1/2 read streams 0/2/4, 31+9+5 = 45 bones
tools/slot_catalog.py prints it as a labelling template: slot -> the (main,sub) pairs
that drive it, whether the host and the port fill it, and whether the port's clip there is
unique or aliased.
em_moveset enumerates what the dispatcher can reach. Now that a1 is known to be
the animation slot index (§34e), each row can be checked against the species' own PAC —
and 16 of the Tigrex's 74 driven slots have no clip in file_06185 or file_06134, in
any stream:
55 56 63 64 66 84 85 86 87 88 89 91 92 93 94 99
Slot 92 is referenced by 13 behaviour pairs, joint-most in the whole table, and the
monster has no animation for it. Those pairs cannot animate, which is an offline
predictor for the rule monster-ai records empirically ("a pair the engine never enters
bounces straight back out" — 411 of 411 forced moves lasted one tick). Check the slot has a
clip before spending a cold boot forcing the pair.
tools/slot_catalog.py prints the list. The likely reading is that em75.ovl carries generic
or variant code paths the shipped Tigrex never takes; it has not been chased further.