Everything Blender needs for rigged big-monster import/export is now decoded from static analysis alone:
- Geometry →
tools/mhff/psp/pmo.py - Skeleton + bind-pose →
tools/skeleton.py(PAC sub-0,0xC0000000blob; validated on Tigrex + 66 monsters) — see "✅ SKELETON + BIND-POSE SOLVED". - Animation keyframes →
tools/anim.py(PAC sub-3, P3rd-style pack; validated on Tigrex, 22 anims) — see "✅ KEYFRAME STREAM SOLVED". - Mesh↔bone binding → the PMO's own bone palette, both games — NOT positional
(
docs/PMO_MODEL_FORMAT.md;pmo._attach_influences/pmo_p3rd.parse). - Interpolation → cubic
spline()with ease-in/out tangents.
The engine classes (Joint/Hierarchy, Joint::update, builders) are named + laid out
via the decomp + decrypted EU EBOOT (tools/eboot_dis.py). Small monsters (em01/Popo)
have no skeleton sub-resource and ride a rigid/shared path.
- Decrypted EBOOT =
workspace/extracted/PSP_GAME/SYSDIR/BOOT.BIN(an ELF;EBOOT.BINis the~PSP-encrypted one). Single PT_LOAD: file0x25b4→ VA0x08804000, size0x1c7a90— covers all0x088xxxxx/0x089xxxxxcode. - Disassembler harness:
tools/eboot_dis.py(<va> [n],--words,--xref). - Address delta: in the animation/skeleton CODE region the decomp's JP addresses
map to EU by a constant +8 (verified:
Joint::update,pmo::compile,pmo::drawWeight,Joint::funceach land exactly on a prologue;Joint::funcEU0x0885F928== our previously-pinned tree-walker). The +8 holds for code only — vtable/data symbols (e.g.__vt__5Joint) sit in a different region with their own delta; verify per-symbol.
struct bind_pose { // 0x80 bytes
ScePspFMatrix4 transform; // +0x00
ScePspFVector3 scale; // +0x40
ScePspFVector3 rotation; // +0x4C
ScePspFVector3 position; // +0x58
u16 f0x64[14]; // +0x64
};
struct Joint { // a BONE; ~0x250 bytes; virtual (vtable @+0x00)
// vptr; { ~Joint(); update(Matrix4* parent, Matrix4* out, float x,y,z); }
ScePspFVector3 f0x4; // +0x04
ScePspFMatrix4 f0x10; // +0x10
ScePspFMatrix4 f0x50; // +0x50
ScePspFMatrix4 localPose; // +0x90 (Joint::update writes/uses this)
ScePspFMatrix4 globalPose; // +0xD0
u16 f0x110, f0x112, key, f0x116; // +0x110.. ; `key` @+0x114 = anim node id
float f0x118, f0x11c; // +0x118
ScePspFMatrix3 f0x120; // +0x120 (3x3, 36B)
Joint *parent, *sibling, *child; // +0x144 / +0x148 / +0x14C (tree links)
bind_pose bind; // +0x150 (scale @+0x190 — Joint::update reads this)
bind_pose alt_bind; // +0x1D0
};
struct Hierarchy { // the SKELETON; virtual
u8 unknown_0x4[0x10C]; // +0x04
Joint *roots[4]; // +0x110 (up to 4 root chains)
u16 root_count; // +0x120
u16 chain_count; // +0x122
u32 joint_count; // +0x124
u8 unknown_0x128[4]; // +0x128
u32 *motion_table; // +0x12C <-- the animation/keyframe source
u16 unknown_0x130[4]; // +0x130
u16 unknown_0x138[4]; // +0x138
};
struct ModelBase : Draw { // owns the renderable: transform, pmo, tmh, hierarchy
ScePspFMatrix4 transform; pmo model_pmo; tmh model_tmh; Hierarchy hierarchy; ...
};| Function | EU VA | Role |
|---|---|---|
Joint::update(Matrix4* parent, Matrix4* out, f,f,f) |
0x08860328 |
compose bone global from parent×local, scaled by xyz = bind.scale |
Joint::func(Joint*, u32 key) (tree walk) |
0x0885F928 |
find joint by key (+0x114); -1/0xFFFF = empty |
Joint::func_eboot_0885FFB4(Vector4*, int, f, f) |
0x0885FFBC |
keyframe→transform sampler (candidate) |
pmo::compile(void*, pmo_header*, pmo_mesh_data*) |
0x088623DC |
build renderable from PMO |
pmo::draw(Hierarchy*, tmh*, Matrix4*) |
0x08861694 |
rigid skinned draw (per-mesh bone) |
pmo::drawWeight(Hierarchy*, tmh*, Matrix4*) |
0x088646F0 |
blended skinned draw |
pmo::drawMesh / drawWeightMesh |
0x0886171C / 0x0886477C |
per-mesh variants |
ModelBase::compile_pmo / compile_tmh |
0x08862510 / 0x088626D0 |
model load |
world_matrix(Vec3*,Vec3*,Vec3*,int,Matrix4*) |
0x08861E9C |
build matrix from pos/rot/scale |
func_eboot_088641B8(Hierarchy*) |
0x088641C0 |
Hierarchy setup (decomp extern) |
- The bind-pose is a known structure (
bind_pose: a 4×4 matrix PLUS decomposed scale/rotation/position vec3s), stored perJoint, in aHierarchyof up to 4 root chains. It was never a mystery format — it's runtime engine state. - Monster PMOs carry zero bone data (verified: 90/90 monster PMOs have
bone_data_offset == material_data_offset, i.e. empty — seedocs/PMO_MODEL_FORMAT.md). The skeleton is a separate PAC sub-resource, not part of the PMO. - RESOLVED below: the big-monster skeleton + bind-pose IS on disk — PAC
sub-resource 0, a
0xC0000000blob — andJointtrees are built from it at load. See "✅ SKELETON + BIND-POSE SOLVED". (Hierarchy.motion_tablepoints at the keyframe stream, PAC sub-resource 3.)
The animation/skeleton format was the #1 blocker for monster model export with full rigging. The findings below predate the skeleton-class decode above.
Big-monster animation = PAC sub-resource 3, a P3rd-style animation pack. Parser:
tools/anim.py (validated on Tigrex: 22 animations, bone counts match the
skeleton, all size-chains exact). The engine interpolates with the cubic spline()
(ease-in/out tangents, decompiled in model_base.cpp).
Container header 0x18: u32 magic(0x64) ; u32 header_size(0x18) ; u32 slot_count ;
u32 ? ; u32 ? ; u32 first_offset
Offset table @+0x14: slot_count × u32 — offset to an anim block, or 0xFFFFFFFF = empty.
Every level is a section { u32 (0x80000000 | tag) ; u32 count ; u32 size }; size
includes the 12-byte header and chains exactly to the next sibling:
Animation block: count = bone_count ; +0x0C u32 loop ; +0x10 f32 loop_start ;
header 0x14, then `bone_count` bone records (1 per skeleton bone, in order).
Bone record: tag = channel bitmask (rotX/Y/Z=0x08/10/20, locX/Y/Z=0x40/80/100,
sclX/Y/Z=0x200/400/800) ; count = #channels. Empty bone = 12B header only.
Channel record: tag = channel type ; count = #keyframes ; then the keyframes.
Keyframe (8B): s16 value ; s16 frame ; s16 ease_in ; s16 ease_out.
Quantization (P3rd sibling): rotation 4096 = 90°, location 16 = 1.0, scale 256 = 1.0.
Verified on Tigrex (file_06134) anim[0]: 24 bone records (= the skeleton's 24 bones),
bone 2 (root) = rot+loc (mask 0x1F8, 6 channels), bones 3–23 = rot XYZ (mask 0x38);
a sample rotation channel runs frames 0→39→77→113→150 with values ≈ −27° (sane), ease
tangents present. ⇒ 1:1 anim↔skeleton bone mapping; the pack holds up to 100 animation
slots (22 used on Tigrex), each an action (idle / walk / roar / …).
The MHP3rd in-quest moveset is the same recursive data as the MHFU 0x64 pack but with
compact u16 bone/channel headers (cross-checked byte-for-byte against
AsteriskAmpersand/Kurogami2134 p3rd_monster_anim.bt). Parser:
mhfu_model.anim.parse_p3rd (was a stub returning tracks=[] — that's why no conversion
existed before; now fully decoded, 16 tests):
BLOCK : u32 bone_count ; u32 block_size ; u32 loop ; u32 pad
BONE : u16 num_channels ; u16 record_size
CHANNEL : u16 channel_bit (SAME bits: 0x40=locX …) ; u16 kf_count ; u32 chan_size(=8+8*kf)
KEYFRAME: <4h> value, frame, ease_in, ease_out (IDENTICAL to MHFU 0x64)
Container header is variable-size: word1 = header_size; the LAST header word (at
header_size-4) = first_anim; the slot table is at header_size+4 with length
(first_anim - header_size - 4)/4 (NOT word0/word2 — that was the old decode-0-anims bug;
header_size is 0x18 for small monsters, 0x20 for big). Same quantization (rot 4096=90°,
loc /16, scl /256 — shared.py). Real in-quest movesets: file_03997–file_04016 (small/
medium monsters) and the raw .anim big monsters file_05142–file_05424 (42-48
bones). The authentic Brute moveset = file_05250 (77 clips). Per-monster anim→skeleton
bone maps (bone_offset/missing_bones) are in the upstream skipped_bones.md.
Cross-game port → MHFU 0x64 3-stream (anim_ingame.swap_anim_to_realmotion,
from_flat_anim): bone_match.match_skeletons builds the source→host joint correspondence
(by bind-world position); each source track is re-emitted on the host joint it drives,
re-sorted to native channel order, SCALE channels DROPPED (the engine's bit→ordinal table
0x089A5C44 only maps rot+loc; scale → OOB crash), padded/partitioned to the host count
(Tigrex = 45, split 31/9/5). Cross-rig fixes (2026-06-22): unmatched host joints →
rest_bone (identity rot + bind pos, NOT empty_bone which zeroes the matrix → collapse);
a host chain LONGER than the source (Tigrex tail 5 vs Brute 4) uses bone_match.fill_unmatched
- skinning
exclude=so the extra joint rests without kinking (a parent+child sharing one source compounds rotation → tail whip). Driven end-to-end byport_p3rd.port_monster(seedocs/PMO_MODEL_FORMAT.md).
Cross-rig fix (2026-06-24) — leading root-chain alignment (bone_match._fix_leading_root_chain).
The position matcher is greedy nearest-by-bind-world; it fails on the collapsed structural root
chain — the leading run of bones all at the origin (zero-length structural joints). The MHFU
Tigrex host has 3 origin bones, the MHP3rd Brute source 2, so greedy paired host0↔src0,
host1↔src1 and left host joint 2 (the HIP) UNMATCHED. The hip carries the body's vertical
positioning locY (≈4809 on the Brute) — undriven, the mesh renders ~a lower-body-height below the
(correctly-grounded) entity origin and the monster looks sunk into the floor (this was long
misdiagnosed as a "terrain-registration gap" — it is NOT; the engine grounds the entity correctly,
memory brute-terrain-sink-re). FIX = a post-pass in match_skeletons that detects the leading
origin run in each skeleton (Lh, Ls) and re-aligns them from the TAIL (host[Lh-1]↔src[Ls-1],
…), leaving surplus LEADING host bones as placeholders (None) — exactly the native layout's leading
empty joints. No-op when the chains are equal length (safe for equal-root / same-rig pairs).
Result on the Brute: host2(hip) ← src1, branch matches unchanged (avg bind-dist 14.3), all 26
model tests pass, in-game = stands on his feet.
Clip TIMING — rest-bone pad spans the clip's REAL length (2026-06-29). Each clip's length is
governed by its own keyframes: the engine plays a clip to its max keyframe frame, then loops iff
the clip's loop flag is set — there is NO separate per-action duration. Proven two ways: (a) the
native Tigrex clips are stored UNPADDED at varied real lengths (26–520 frames) and play fine; (b) the
Tigrex action-descriptor table (0x09D5A580 = file_06108+0x40440, 8-byte rows: byte0=action_id+8,
byte1 flag, two small params 0..16) carries no frame-count field. Consequence for porting:
from_flat_anim must span its rest_bone padding (unmatched / shortfall joints) to the clip's real
length (clip_end = max keyframe frame over the source tracks), NOT a fixed 180. A short clip padded
to 180 plays its motion then FREEZES on the last pose until frame 180 before it loops/ends — looks
like the monster "stops abruptly / hangs." Trimming the pad restores the monster's authored timing
(Brute: 77/77 clip lengths now match the source moveset; the 50 short clips no longer freeze-pad).
The source loop flag is carried through verbatim (Block(loop=anim.loop)), so loops were always
correct — the bug was purely the pad length. (Modders: anim clips <pac> lists every slot's real
length + loop; pair with the live anim sweep for the a1→slot map.)
Clip SLOT MAPPING — the porter maps by INDEX, and that silently loses clips
(2026-08-29). from_flat_anim re-emits source clip N into host slot N. Two
consequences no structural check catches, because the output is well-formed
either way:
- a source clip whose index is not a populated slot in the host pack is
DROPPED — the MHP3rd Zinogre (
file_05341, 42 clips) lost slots 16, 22, 27, 33, 39, 63, 66 and 67 porting onto the Tigrex, becausefile_06185has no slot there. 34 of 42 survive; - a host slot with no source clip of that index is filled with a copy of the
source's slot 1 (its idle) — 30 of the Tigrex's 64 slots on that build. So a
third of the
a1space plays idle, and "I forced a1 51 and he just stood there" is a successful override onto filler, not a failed one.
Everything that DOES land keeps its authored length and loop flag exactly (34/34
verified byte-wise), and a1 == the slot index 1:1 in game (16/16 clip runs over
two cold boots). Audit any build with
tools/port_clip_probe.py --pac <built> --coverage <source moveset>.
This closes animation export. Full static pipeline now available:
geometry (pmo.py) + skeleton & bind-pose (skeleton.py) + animation (anim.py) +
implicit mesh↔bone mapping → everything Blender needs for rigged monster import/export.
Followed the build path statically in BOOT.BIN (tools/eboot_dis.py + decomp source
joint.cpp/model_base.cpp). The skeleton is constructed at model-load time, NOT read
from the model file:
-
Joint-tree builder = EU
0x0886789C(inobj_manager, the em/player object manager). Signaturebuilder(Hierarchy* hier, void* skel_src, Joint* joint_buf, float sx, sy, sz). Called from 5 model-type setup sites (0x08867804,c60,e2c,0x08868138,3ec) — one per model class (monster / player body / armor / …). Each site:compile_pmo→ builder →func_eboot_088641B8(hier)(the per-frame "update all roots" driver: loopshier->root_count@+0x120, callsJoint-FK on eachhier->roots[i]@+0x110). -
The builder allocates
joint_countJoints of stride0x250(confirmssizeof Joint), sets each Joint's vtable to0x089BAB08, zeroes the tree links (+0x144parent /+0x148sibling /+0x14Cchild) and keyframe arrays (+0x1B4bind.f0x64/+0x234alt_bind.f0x64), writeshier->joint_count(+0x124) andhier->chain_count(+0x122). -
skel_srcrecord format (per bone, walked by the builder;func_eboot_08860054fills eachJoint.bindfrom it):Off Field +0x00 flags (high bit 0x80000000toggles the joint count)+0x04 joint count (header record) +0x08 record size (variable stride → src += [src+8])+0x0C bind-transform data (→ func_eboot_08860054→Joint.bind.transform)+0x10 parent joint index (−1 = none; → joint_buf + idx*0x250)+0x14 child joint index (−1 = none) +0x18 sibling joint index (−1 = none) +0x50 chain / root id (maxed into hier->chain_count)Tree links are stored as indices into the joint array (resolved to pointers at build) — so the on-disk/in-RAM skeleton is an index-linked bone list with variable-length records, each carrying its own bind transform.
-
The monster's
skel_srcpointer (a1) is passed into the setup function from the em-object spawn;joint_bufis freshly allocated (jal 0x8859b44, sizejoint_count*0x250); the scale comes frommodel+0x60/0x64/0x68; theHierarchyis embedded atmodel+0x80.
The 0x0886789C builder above turned out to be the player/equipment path (5 vtable
setup methods; its skel_src is PAC resource index 1 — but a monster PAC's sub-file 1
is the TMH, and monster PMO bone_data is empty, so that path is not the monster's).
The monster path is separate and was proven by scanning the Tigrex overlay
(file_06108, em75) for jals into the EBOOT model code:
- The overlay drives a skeleton every frame — it calls
pmo::drawWeightMesh(0x0886477C),world_matrix(0x08861E9C), the per-JointFK op (0x08860648),ObjBase::testAnimation(0x08865CB8), and the per-frame draw funcs (0x08864348×280). ⇒ monsters are bone-animated via the sameJoint/Hierarchysystem. (The earlier "rigid-skinned" finding is about vertex→bone weights — there are none, binding is per-mesh — NOT about the absence of bones. The bones exist and animate.) - The overlay does not build the skeleton (no
Joint::new/ builder /pmo::compilecalls) — it only drives an already-built one. It reaches into the model-loader module (0x088dcebc,0x088de060).
Monster skeleton builder = EU 0x088dc40c (in the em/character model-loader module,
the second Joint::operator new caller). builder(Hierarchy* hier, Joint* joint_buf, void* skel_src, float sx,sy,sz):
- reads
joint_countvia0x088dc748(skel_src)→hier->joint_count(+0x124); - allocates
joint_countJoints @stride0x250; per-joint init via0x088dc720. skel_srcheader is identical to the player path:flags @+0x00(bit0x80000000adjusts the count),count @+0x04, then the variable-stride per-bone records documented above.- Called once at em-spawn from
0x088dc38c, whereskel_src = <resource-accessor 0x088b89b0>(resource_array, 0)(a registered resource, index 0 of the em model's resource set — distinct from the player PAC layout). The Joint buffer is allocated from the ObjManager arena ([0x08A62C5C] + 0x80000); scale frommodel+0x60/64/68; theHierarchyis embedded atmodel+0x80.
skel_src is sub-resource 0 of the big-monster model PAC, a 0xC0000000 skeleton
blob — the same format the iOS port uses (m2jean/mhfu-ios-pmo-plugin). Parser:
tools/skeleton.py (validated on Tigrex + 66 monsters).
- File mapping:
file_id = em_id + 0x17AB, and the DATA.BIN extract index == file_id, so monster PAC =file_0{em_id+0x17AB}.bin. Big monsters (those with anem*.ovloverlay) carry a skeleton:file_06111–file_06159all have sub-0 = skeleton (17–61 bones). Small monsters (Popo/em01 =file_06060) do NOT — their sub-0 is the PMO; they ride a rigid/shared path. - Big-monster PAC layout:
[0]=skeleton (0xC0000000),[1]=PMO,[2]=TMH,[3]=animation (0x64… keyframes). (Small-monster PAC:[0]=PMO, [1]=TMH, [2]=PMO2, [3,4]=float blobs, [5]=HITS— no skeleton.) - Skeleton blob format (matches engine builder
0x088dc40c+ iOS):Verified: Tigrex (em75,Header 0x1C: u32 magic 0xC0000000 ; u32 bone_count ; u32 total_size ; u32 ×4 per bone (section, size from +0x08, = 0x10C on Tigrex): +0x00 u32 section_magic 0x40000001 +0x04 u32 flag (1) +0x08 u32 section_size +0x0C s32 index +0x10 s32 parent (-1 = none) +0x14 s32 left_child +0x18 s32 right_sibling +0x1C 3f bind scale (+1.0 pad) +0x2C 3f bind rotation (euler; +1.0 pad) +0x3C 3f bind position (offset from parent; +1.0 pad) … remainder zero/aux within the 0x10C sectionfile_06134) = 25 bones, single root (bone 0), coherent tree (spine 0→1→2→3; bone 3 = limb/head/tail hub; symmetric left/right limbs with mirrored ±X positions; tail chain 21→22→23). The engine builds oneJoint(0x250) per bone, links parent/child/sibling by index, stores scale/rotation/position intoJoint.bind, thenJoint::updateruns FK from these.
This closes the bind-pose blocker. Full rigged-export inputs now available statically:
geometry (pmo.py) + skeleton & bind-pose (skeleton.py) + implicit mesh↔bone mapping.
The only remaining piece for animation export is decoding PAC sub-resource 3 (the
0x64… keyframe stream) against the spline() interpolator / P3rd keyframe shape —
keyframes, not the skeleton.
skel_src container traced to DataManager (2026-06-13). The setup function
(0x088dc1f0) reads its source container from a DataManager entries slot:
buffer = entries[*].buffer where the entries array base is the global [0x09A4F0D0]
(EU) and the slot is taken at offset +0x60, guarded by the entry's loaded bit
(flags & 2). Then:
skel_src = 0x088b89b0(buffer, 0)— sub-resource 0 of that container;model/pmo = 0x088b89b0(buffer, 1)— sub-resource 1 (fed tocompile_pmo, mesh count at+0x1c).
So this container's layout is {[0] = skeleton, [1] = model} — the opposite order
from a monster model PAC ([0]=PMO), i.e. skel_src is NOT in the model PAC; it
lives in a separate DataManager-loaded file. 0x088b89b0(buf, i) just indexes that
file's count + (offset,size)… sub-table (same shape as a PAC) and returns
&entry[i] when its size word is non-zero.
The one remaining link (last mile): identify which file_id is loaded into that
DataManager slot (DataManager::load(slot, file_id, size)) for a given monster — that
file's sub-resource 0 is the skeleton blob. Then decode the blob against the per-bone
record table above (flags@0, count@4, recsize@8, bind-data@0xC, parent/child/sibling idx@0x10/14/18, chain@0x50) → a fully static monster bind-pose + skeleton export.
Everything up to the file_id is mapped; the file_id↔slot binding is the open piece
(continue the backtrace from 0x088dc1f0's callers, or read the live DataManager
entries to see which file sits in the slot). The format itself is no longer unknown.
Kurogami2134/blender_p3rd_anim (external) is a Blender import+export addon for
Monster Hunter Portable 3rd monster animations — the direct successor engine to
MHP2G/MHFU. It fully decodes a per-bone / per-channel keyframe format that is almost
certainly close to MHFU's (same engine family). Use it as the template when decoding
MHFU's Prog payload.
Per-animation layout (P3rd):
Header 0x10: 3if = u32 bone_count, u32 size, u32 loop(0/1), f32 loop_start
per bone (BoneAnimation):
u16 transform_count, u16 size
per transform:
u16 type (bitflag), u16 keyframe_count, u32 size
KeyFrame[] : 4×s16 = s16 value, u16 frame, s16 ease_in, s16 ease_out (8B each)
empty bone = (u16 0, u16 4)
Transform type bitflags: rot X/Y/Z = 0x08/0x10/0x20, loc X/Y/Z = 0x40/0x80/0x100,
scale X/Y/Z = 0x200/0x400/0x800.
Value quantization (decode math):
- rotation:
4096 units = 90°→radians = raw * radians(90/4096) - location:
16 units = 1.0 - scale:
256 units = 1.0
Animation-pack container (P3rd): u32 ?, u32 header_size@0x04; header = header_size/4
ints; anim count = (header[-1] - header_size - 4) / 4; offset table at header_size+4
(4B entries), -1 = empty slot → seek to that anim. (See the Head/Prog note below
— MHFU's wrapper uses the same 0xFFFFFFFF empty-slot convention.)
| Tool | Game | Covers | Note |
|---|---|---|---|
Kurogami2134/blender_p3rd_anim |
MHP3rd | monster anim import+export | keyframe format above; sibling engine |
m2jean/mhfu-ios-pmo-plugin |
MHFU iOS | PMO + skeleton + anim (Noesis) | iOS port (PMO v2.0); blueprint for skeleton node-tree |
svanheulen/mhff |
MH PSP | PMO/TMH/PAC parse | vendored in tools/mhff/ |
replydev/mhfu-hd-retexture-eu |
MHFU EU PSP | texture replacement | proves TMH swap on our exact target |
skipped_bones.md in the P3rd repo lists, per monster id, the skeleton bones with no
geometry — this is exactly the skip list that makes MHFU's implicit mesh↔bone mapping
work (see docs/PMO_MODEL_FORMAT.md; em01: 31 meshes + skipped {8,15,25} = 34 bones).
| File Type | Magic | Location | Purpose |
|---|---|---|---|
| Motion Tables | Head /Prog |
motion/*.bin | Animation lookup/data |
| AHI Files | None (null start) | emmodel/em32/ | Unknown (animation/hitbox?) |
| emmodel/*.pac |
Location: Various indices, pattern motion/*_tbl.bin
Known files:
motion/plcom_tbl.bin(index 61) - Player common animationsmotion/w00_tbl.bintow10_tbl.bin(3377-3387) - Weapon animationsmotion/em04_tbl.bin(6323) - Monster em04 animationsmotion/em10_tbl.bin(6324) - Monster em10 animations
IMPORTANT (refined 2026-06-13): only 2 loose em*_tbl.bin motion files exist
(em04, em10) — and both are dead ends for keyframes (em04's Prog registry is empty;
em10 is opaque-packed; see the decoded section above). The real monster animation
lives inside each big-monster model PAC as sub-resource 3 (0x64… keyframe stream),
loaded alongside the skeleton (sub-0) and PMO (sub-1). So "most monsters share/overlay/
runtime anims" is superseded: big monsters each ship their own keyframes in their PAC.
+0x00: "Head " (5 bytes, space-padded)
+0x05: Padding (3 bytes)
+0x08: Unknown uint32
+0x0C: Unknown uint32
+0x10: Data size?
+0x14: More header data...
+0x20: "Prog" section marker
+0x24: Prog size
+0x28: Animation count?
+0x2C: Padding (0xFF bytes)
...
Animation data follows
Parser: tools/motion.py (decodes the wrapper + em04 joint table; reports
em10 as opaque). Findings against the two on-disk monster motion tables:
em04_tbl (file_06323, 2048 B): valid Head/Prog wrapper —
Head sections=2 declared_size=0x770
Prog @0x20 size=0x360 anim_slots=63
…but the Prog animation offset table is 100% empty — all 63 slots are
0xFFFFFFFF. Zero P3rd-shaped keyframe animations are registered on disk. The
only payload is a trailing per-joint parameter table: 41 × 14-byte records,
anchor = u16 0xFF00, layout {sep u16, joint-id u16 (doubled byte, 60…), c1 u16=200, c2 u16=200, scale-hi u16 (upper half of an f32 ≈ 3.4–4.0), 0 u16, seq u16} — bone
scale/length-ish constants, not keyframes. A second undecoded section follows
[0x380, 0x580).
em10_tbl (file_06324, 133 KB): no Head/Prog magic at all — a distinct
packed/high-entropy container (u32 count = 54 @0x10, raw stream from 0x18, not
length-prefixed). Does not match the P3rd struct.
⇒ Wall: the P3rd per-bone/9-channel keyframe struct does not map onto either on-disk monster motion table. There is no populated P3rd-shaped keyframe payload to decode here. This corroborates the standing finding that monster skeletal animation is overlay-embedded / runtime, not file-resident — the same class of blocker as the skeleton bind-pose. Decoding real monster keyframes needs MIPS disasm of the overlay anim loader or live PPSSPP RE, not static file parsing.
(The 0xFFFFFFFF empty-slot convention in the Prog table does match the P3rd
animation-pack container — so the wrapper is the same engine family; only the
inner payload was never populated for em04 on disk.)
The weapon motion tables (w00-w10) are actually PAC containers:
weapon_motion.pac:
├── [0] PMO - Weapon trail/effect model
└── [1+] Animation keyframe data
The animation data after the PMO contains:
- Bone reference indices (0x00-0x0F patterns)
- Compressed keyframe data
- Timing information
Only found for em32 (Rajang), size 250KB.
Structure:
+0x00: 16 null bytes
+0x10: Data begins
- First byte appears to be entry count or type
- Variable-length records follow
- Appears to be compressed/encoded
Hypothesis: AHI = Animation/Hitbox Information
- May contain both animation keyframes and per-frame hitbox states
- Format appears to use some form of delta encoding
| Piece | State | Where |
|---|---|---|
| Skeleton / bone hierarchy | SOLVED | big-mon PAC sub-resource 0, 0xC0000000 blob — tools/skeleton.py |
| Bind-pose (per-bone scale/rot/pos + tree) | SOLVED | same blob (bone section +0x1C/2C/3C) |
| Mesh↔bone binding | SOLVED | the vgroup bone palette, both games — draw order is only a fallback for palette-less PMOs (see docs/PMO_MODEL_FORMAT.md) |
| Runtime skeleton classes + FK | SOLVED | Joint/Hierarchy, Joint::update, builder 0x088dc40c (above) |
| Keyframe interpolation method | KNOWN | cubic spline() w/ ease tangents (decomp model_base.cpp) |
| Keyframe stream encoding | SOLVED | big-mon PAC sub-resource 3 — tools/anim.py (see "✅ KEYFRAME STREAM SOLVED") |
| em04/em10 loose motion tables | dead end | tools/motion.py (em04 registry empty, em10 opaque) |
The em32.ahi (Rajang) and Head/Prog notes below are retained as historical
reference; the live monster-animation source is the per-PAC sub-resource 3, not these.
tools/anim.py's model (pack header 0x18, slot table @hsize-4, bone records
{mask,nch,bsz}, block header 0x14) is the MHP3rd / lobby form. The in-game
anim the engine actually plays (native Tigrex file_06185 sub-3) is a DIFFERENT,
RECURSIVE 3-stream form — decoded live via the per-frame interpolator chain
0x0885fa0c → 08863198 → 088630d0 → 08863668. Critical for any cross-game port.
- THREE parallel streams populate three tables — main, sub1, sub3 — that
partition the skeleton's bones (native Tigrex main block
bc=31, sub1bc=9, sub3bc=5= 45). Each animation = THREE blocks (one/stream), all under the SAME slot index; tables 1/3/5 stay empty for Tigrex. Block tags carry0x80000000.
⚠️ The header description that used to sit here — "five(0x64, suboffset)pairs, then the main 100-slot table @0x34" — was wrong by one slot. Corrected 2026-08-24; see the section below.
- Blocks are RECURSIVE sections
{0x80000000|tag, u32 count, u32 size}down to keyframes (top anim block has extraloop@+0xC/loop_start@+0x10). The per-frame interpolator0x08863668reads each keyframe section's FRAME-TIMElh [sect+0xE], compares to the current framef12, and[sect+4]as a count. - The per-frame tick requires the anim and skeleton to describe the SAME bones.
Proven: unmodified native Tigrex anim + a Brute 46-bone skeleton crashes the
per-frame walker identically (
0x088636ac, wild read) — the per-bone traversal lands on aloop_startfloat used as an index. A ported monster's anim MUST be authored in this format matching its OWN skeleton; you cannot scaffold with another species' anim.
Implication: a real cross-game anim port needs a recursive in-game encoder
(nested {0x80000000|tag,count,size} sections + the 3 stream tables + 0x38 header),
not anim.py's flat form. anim.py's encoder is correct only for byte-identical
RESHAPE of an existing in-game pack, not for synthesis.
anim_ingame.py is that recursive encoder: parse_ingame/encode_ingame round-trip
native file_06185 sub[3] BYTE-EXACT (1008288 B); from_flat_anim converts a flat
(lobby/MHP3rd) pack → the 3-stream in-game form; make_static_pose/rest_bone build
bind/rest poses. Tests: tools/mhfu_model/tests/test_anim_ingame.py (5 pass). Wired into
the Blender addon (blender_mhfu/exporter.export_ingame_bindpose_pac + operator
EXPORT_OT_mhfu_monster_ingame).
Two hard engine rules, both PROVEN LIVE (Brute port, see docs/BRUTE_TIGREX_PORT.md):
- NO scale channels. The per-frame FK (
0x08863198) reads each channel's ctype low-u16 as a bit and indexes table0x089A5C44, which maps ONLY rotation (0x08/0x10/0x20 → 3/4/5) + location (0x40/0x80/0x100 → 6/7/8). Scale bits (0x200/0x400/0x800) index OOB → garbage joint ptr → crash (Write @0x0D8C59B0, PC 0x08863250). Native Tigrex anims never carry scale;from_flat_animdrops bits ≥0x200. - The bone partition must match the HOST, not the injected skeleton. When hosting on
the Tigrex slot the engine iterates
entity+0x1a4= 45 bones (split 31/9/5); a 42-bone anim runs the joint walk off the end → junk keyframe ptr → crash (Read @f3e9dc32, PC 0x088630d0, the keyframe interpolator). - A bind/static pose with EMPTY bone sections COLLAPSES the mesh (joints not computed) —
every bone needs real keyframes; skeleton
bind_rotis all-zeros (rotation is anim-only).
Full debugging arc + key addresses: docs/BRUTE_TIGREX_PORT.md.
Rule 2 above is stated as "match the HOST", which is what the retarget path does. A source-skeleton port ships its own rig and picks its own partition, and the rule that governs it is stricter than "any three numbers that sum right":
Each stream is ONE COMPLETE SUBTREE, occupying a contiguous run of bone indices.
The native Tigrex file_06185 is the proof, and it is not a coincidence of numbers:
| stream | bones | subtree | what it is |
|---|---|---|---|
| 0 | 0–30 (31) | root + spine + forelimbs + both hind legs | the body |
| 1 | 31–39 (9) | child of b3 (chest), reaching z +687 | neck + head + jaw |
| 2 | 40–44 (5) | child of b2 (hip), reaching z −763 | the tail |
| 3 | 45–47 | a second root chain | not animated |
So the partition is the rig's own shape, and native rigs are authored body-first with
the head and tail as the two TRAILING subtrees. That is the only reason the old
[animated − 14, 9, 5] formula ever looked right.
The partition is also the playback rule, and reading it back is where it bites.
anim_ingame.to_flat_anim concatenates the populated streams in stream order, and for a
whole-rig clip that concatenation is joint order — track i drives joint i. But not
every slot is whole-rig: on every Tigrex-framed pack seen so far — the native
file_06185, zinogre_v10.bin and the built Brute — slots 24 and 25 exist in the
SECOND POPULATED stream only. They are a 9-track head-and-neck clip meant to play
over an idle body.
to_flat_anim(streams=(0, 2, 4)). So "the second populated stream" is ig.streams[2],
and the joint bases below are indexed over the populated streams, not over all six.
Flattened, those 9 tracks come back as tracks 0–8 with nothing in front of them, so
anything that assumes "track i == joint i" drives the root, the spine and a
foreleg with the neck's curves and folds the monster in half. The joint base of stream
k is the total width of the populated streams before it (31 / 9 / 5 → bases 0 / 31 /
40), which is derivable from the file: a stream's block carries the same bone count on
every clip it holds. mhfu_monster_editor.core.scene.stream_joint_bases does exactly
that, and Scene.clip(24).track_to_joint maps 0–8 → 31–39.
Measured on the native file_06185, slot 24 at frame 180:
| read | joints that move | worst displacement |
|---|---|---|
| positionally (wrong) | 3–14 — spine, both forelegs | 337 u |
| at the stream base (right) | 32–39 — the head/neck chain | 134 u |
mhfu_model.stretch.sample is positional and does not know this — it is the FK
oracle, not a clip loader — so tools/port_stretch.py --all mis-poses slots 24 and 25.
It has not changed a verdict (the native Tigrex still measures a 0 u cross-fork
tear with them in), but do not read a tear number reported for those two slots as
meaning anything.
🔴 An MHP3rd rig owes us none of that. The Zinogre (file_05339, 51 bones) branches
at b1 into a front half and a rear half, so its head sits at bones 18–23, in the middle
of the index order. Slicing off the last 14 bones puts a hind leg in the head stream
and splits stream 1 into three disconnected fragments (roots 32, 38, 39). Counts still
agree, so it loads and animates — it just drives the wrong limbs from the wrong clip.
187 of MHP3rd's 212 in-quest rigs need reordering; the formula is right for 25.
The fix (skeleton.derive_stream_partition + reorder_bones, wired into
port_p3rd): find the subtree holding the most −Z bone (the tail) and the most
+Z one (the head), then emit a permutation that moves them to the end. The whole
rest of the port follows the same permutation — bone links are rebuilt from the parent
array, the skin palette is remapped through it, and the anim bone_map is built from it.
Two properties make this trustworthy rather than plausible:
- On the native Tigrex it returns
[31, 9, 5]and the identity permutation — it rediscovers the shipped answer without being told it. - Across all 212 in-quest MHP3rd rigs: every parent still precedes its child, no stream falls below 2 bones, and 210 give one complete subtree per stream (the 2 exceptions are extra root chains landing in stream 0, which is legal — stream 0 is the remainder).
{leaf} strands the leaf's own children in the body stream while the leaf
moves to the end, putting a child ahead of its parent. That bit 13 of the 212 rigs.
Audit any built PAC with python tools/verify_port.py <pac> --model <src> --geo <geo>;
it passes on the native file_06185, which is what makes its checks worth anything.
It catches a defect that already shipped. Run over the four Brute builds sitting on the
memstick, the three RETARGET builds (v58, v64, v67 — host rig) pass, and
brute_tigrex_v63_clipfix.bin, the one built through the source-skeleton path, fails with
stream 1 has 2 roots [35, 30]; stream 2 has 2 roots [39, 40] — its head and tail streams are
fragments, exactly as predicted.
Every animation container in both games has the same header, and it is nothing but a list of stream tables::
+0x00 N × (u32 slot_count, u32 table_offset) # N = hsize/8 - 1
+hsize-8 u32 0
+hsize-4 u32 data_start (= end of the last slot table)
+hsize the slot tables, back to back, then the blocks
So the words earlier revisions called magic and hsize are really stream 0's
slot count and stream 0's table offset. magic was always 0x64 only because
100 slots is the common case, and hsize "happens" to equal the header size
because stream 0's table starts right after the header.
| hsize | streams | files | where |
|---|---|---|---|
0x18 |
2 | 47 | MHFU |
0x20 |
3 | — | MHP3rd (file_05250, Brute) |
0x28 |
4 | 18 | MHFU |
0x38 |
6 | 36 | MHFU (big monsters — Tigrex file_06185) |
Validation, on every file: pairs[0].offset == hsize, the offsets chain exactly
(off[i+1] == off[i] + count[i]*4), and the last table ends at data_start.
Counts are per stream — file_06111 alternates 100/105 (0x64/0x69), which
is why they cannot be derived from the first sub-table's offset.
Two different wrong readings were in the tree, and both shift a monster's moveset by a slot:
anim_ingame.parse/encode_ingameread the main table at0x34— thedata_startword. Every monster gained a phantom slot-0 clip aliasing its first real block, engine slot N was written at our slot N+1, and engine slot 99 got the pad word.anim.pydid the same viatbase = hsize - 4.anim.parse_p3rdread athsize + 4(from an external reference template) with a single table of(data_start - hsize - 4)/4entries — which skips stream 0's slot 0 and concatenates every stream into one flat slot space.
Three independent invariants pin the correct offset, and all three fail at 0x34:
- main's empty-slot set is identical to sub3's (38 of 100 on Tigrex, differing
from sub1's only at 24/25); at
0x34it is a shifted set. - all 62/62 co-occupied slots agree on clip length; at
0x34, 40 of 44 disagree by exactly one slot. encode_ingame(parse_ingame(x)) == xbyte-for-byte on 36/36 of MHFU's 0x38-header anim subs — including the three 100/105 monsters, which the old uniform-count reader could not reproduce at all.
Why it hid for two months. Parse and encode shared the same wrong offset, so a
round trip cancelled out; and a bind-pose build aliases ONE block into every slot,
where a one-slot shift is invisible. It only bites when real motion is authored per
slot — which is exactly the Brute port. See docs/BRUTE_TIGREX_PORT.md.
file_05250 (Brute) declares 3 streams: 70 slots, 20 slots, and an empty one. Both
populated streams carry the full 43-bone rig, so they are two independent clip
sets — the opposite of MHFU's 0x38 layout, where streams 0/2/4 partition ONE rig
31+9+5. Read as a single 89-slot table, set 1 lands behind set 0 at a +69 offset and
set 0 is shifted by one. parse_p3rd(blob, stream=0) is the monster's main moveset:
58 clips in slots 1..65.
- Skeleton (iOS, same format):
m2jean/mhfu-ios-pmo-plugin - Keyframe shape (sibling engine):
Kurogami2134/blender_p3rd_anim - Engine source (decompiled):
tools/mhfu_external/mhp2g-decomp(joint.cpp,model_base.cpp) - Disasm harness:
tools/eboot_dis.py; skeleton parser:tools/skeleton.py
This invalidated every cross-game animation this repo had produced, and it was
assumed rather than checked for months: both mhfu_model/port_p3rd.py and
blender_mhfu/fk_bake.py did for i, track in enumerate(anim.tracks) and bound track
i to bone i. MHFU works that way. MHP3rd does not.
Kurogami2134/blender_p3rd_anim's import_p3a.py is explicit:
for bone in range(bone_count):
while bone + skipped_bones in missing_bones:
skipped_bones += 1
do_bone_anim(file, armature.pose.bones[bone + bone_offset + skipped_bones], ...)skeleton_bone = record_index + bone_offset + skipped_so_far
Walk the skeleton from bone_offset, stepping OVER a per-monster list of bones the
moveset never drives. That repo ships the lists in skipped_bones.md; they are
mirrored in tools/mhfu_model/p3rd_anim_map.py:SKIPPED_BONES.
What it cost. The Zinogre (em040) has a 51-bone rig and 37 records. Positionally that covers bones 0–36, so the whole tail (39–45) and part of the jaw got no data, froze at bind pose while the body animated, and the skin spanning the boundary stretched — visible in-game as a rubber-band at the severable tail segment, plus a mis-posed head and a frozen fore-claw. Mapped correctly the same 37 records cover bones 1–46, tail included. No data was ever missing.
skipped_bones.md does not give it (the addon
defaults to 2). The test that decides it is structural, and it is the FORK RULE:
Every record carrying LOCATION channels must land on a joint at or ABOVE the body fork — the first joint with more than one child, where the rig splits front from rear.
A location channel translates its joint's whole subtree. Land one BELOW the fork and it
lifts one half of the animal and not the other, leaving the waist geometry to span the
gap. mhfu_model.p3rd_anim_map.body_fork() / loc_below_fork() compute it.
em040: fork = bone 1 (children 2 = chest/neck/head/forelegs, 25 = hind legs/tail)
offset 0 -> loc records on bones 0, 1 ✅ at and above the fork
offset 1 -> loc records on bones 1, 2 ❌ bone 2 is the FRONT branch only
offset 2 -> loc records on bones 2, 3 ❌ worse
Two corroborations for offset 0 on em040: the 37 records then consume the 37 real body bones exactly, ending on bone 45 (the tail tip); and what is left over is the skip list plus 46–50, the severed-tail chain, which the native Tigrex also leaves undriven (its own is 45–47). Offset 1 instead leaves bone 0 — the actual root — undriven and drives bone 46, the severed tail's root.
He is not in skipped_bones.md at all, so p3rd_anim_map had no row for him and he
silently took DEFAULT_BONE_OFFSET (2) for months.
em058: fork = bone 1 (children 2, 29, 34, 39)
offset 0 -> loc records on bones 0, 1 ✅ at and above the fork
offset 1 -> loc records on bones 1, 2 ❌ bone 2 is the FRONT branch only
offset 2 -> loc records on bones 2, 3 ❌ what he was actually built and rendered at
The same two corroborations land: his 43 records at offset 0 consume bones 0–42 exactly — every real body bone — and what is left undriven is 43/44/45, a second orphan ROOT chain whose shape (a +327 step, then −186 segments) matches em040's severed-tail carve object at 46–50. Only 2 mesh groups are dominated by it.
🔴 How to get the em id: read it out of the data. The two files immediately BEFORE a
monster's model pac are its AI overlays, and an MWo3 header carries the name at offset
32 — file_05246 says em058m0.ovl outright. Guessing from monster order is how this
stayed unmapped.
render_anim_clips.py re-skinned with auto_skin: a blended
nearest-bone guess smears a wrong map into something plausible. It only became visible
once the renderer used the PMO's own palette. Do not read "the render looks fine" as
"the map is right" unless the skin is authentic.
The native MHFU Tigrex obeys the same rule. Its own flat clips, 64 clips, 45 tracks:
| track | bone | parent | bind | channels | keys |
|---|---|---|---|---|---|
| 0 | 0 | −1 | (0,0,0) | rot only | 76 |
| 1 | 1 | 0 | (0,0,0) | loc + rot | 526 |
| 2 | 2 | 1 | (0,0,0) | loc + rot | 4493 |
| 3 | 3 | 2 | (0,0,50.5) | rot | 3328 |
Its fork is bone 2 (children 3, 21, 26, 40), and its loc channels sit on 1 and 2 —
at and above it.
Recorded because each looks convincing and cost a day.
- "Native carries loc on bones 1 and 2, so the port should too." Comparing INDEX positions instead of TREE positions. It is only the same statement when the fork is in the same place, and it is not: native forks at 2, the Zinogre at 1.
- LEFT/RIGHT symmetry (
score_offsets) scored offset 1 at 0.7615 against 0.6424 at 2 and 0.4231 positional. The score cannot pick an offset at all: a mirrored pair stays a mirrored pair under any whole-rig shift, so it only rejects mappings that break the pairing — like a positional read against a skip list. Use it to sanity-check the skip list; use the fork rule for the offset.
What offset 1 looked like: structurally flawless — 9/9 in verify_port, correct
clip coverage, correct rest floor — and visibly broken. It hoisted the front half of the
animal ~230 units, so he reared permanently and the skin from shoulders to tail base
became one long flat sheet. In game that reads as an "L-shaped back"; it is a mapping
fault, not a rigging or skinning one.
🟢 Also fixes the animated count. The driven range ends at bone 46, so
animated = 47 — derived, not guessed. Do NOT set it to the full bone count: see
agent_memory_map.md "Bone-count rule" (partition == FK walk hangs the joint builder).
✅ blender_mhfu/fk_bake.py takes the map now (sample(anim, bone_of_record=…), wired
from render_anim_clips.py via p3rd_anim_map.em_for_model_pac). Renders made before
2026-08-30 — including tmp/*_moveset/*.mp4 — show the OLD wrong pose; re-render before
trusting one. MHFU_CLIP_POSITIONAL=1 reproduces the old behaviour for comparison.
Leave bone_of_record=None for a BUILT MHFU PAC: those clips are positional.
A bone whose parent is −1 while not being bone 0 roots an independent chain the
animation never reaches, and it carries real geometry:
| rig | orphan chain | verts | |
|---|---|---|---|
MHP3rd Zinogre file_05339 |
46 → 47, 48 → 49 → 50 | 165 (4.0%) | tapering, −120 z per link |
native MHFU Tigrex file_06185 |
45 → 46 → 47 | 150 (3.6%) | same shape |
It is the carvable object the game drops when the tail is cut. Both monsters have a cuttable tail; both carry one; the chain steps −120 z per link exactly like the live tail chain does (Zinogre bones 39→40→41→43→44→45). It is authored on its own root because once severed it lies in world space, not on the animal — which is also why it is unanimated, why nothing else parents to it, and why hiding it leaves no hole in the model.
🔴 Do not drop it and do not re-weight it onto the body. Dropping removes a gameplay
object; re-weighting tore one contiguous 165-vertex object across the head, spine, hip,
tail and legs (see the monster-porting skill).
🟢 The engine does NOT draw it before the tail is cut (confirmed in game 2026-08-30: a ported Zinogre whose severed tail sits at flank height shows nothing protruding). So its DATUM does not matter during normal play, even though an MHP3rd rig's origin is at the hip (~435 units up) while a native rig's is on the ground. It will matter the day a ported tail is actually severed — a separate job.
render_port_views.py, MHFU_VIEW_HILITE) before judging a port, or it
reads as a deformation bug in the body.
| Resource | Covers |
|---|---|
Monkbreh/MHFU-Texture-Port |
higher-res texture port for MHFU |
m2jean/mhfu-ios-pmo-plugin |
MHFU iOS PMO + skeleton (already used here) |
AsteriskAmpersand/PMO-Importer |
PMO parsing reference (already used here) |
Kurogami2134/pmo_export |
PMO export |
Kurogami2134/MHP3rd-Game-FIle-List guide/guide.md |
P3rd modding guide — where "bone offset" / "bones to skip" are documented |
Kurogami2134/blender_p3rd_anim |
the record→bone mapping above, plus skipped_bones.md |
MHFU carries three monsters brought over from Monster Hunter Frontier:
Hypnocatrice, Lavasioth and Queen Vespoid. Hypnocatrice is a restructure of
the Yian Kut-Ku and Lavasioth of the Plesioth — i.e. Capcom used the retarget-onto-an
-existing-rig path — while Queen Vespoid is an entirely new structure, the
analogue of our source-skeleton path. Their PACs are therefore native controls for what
a correct port looks like in MHFU's own format, beyond the Tigrex.
Open: their em ids / file numbers are not yet identified here.