At a glance

Where
The monster archive, extraction PROT 0867 (CDNAME block monster_data), one block per enemy; per-action entries reached through the monster record's +0x4C offset array. The player battle files carry the same entry family
Size
Entry head 0x8C bytes, then [parts][frames] + frames × parts 9-byte keyframe records
Parser
legaia_asset::monster_archive (animations, idle_animation, action_tags, juggle_windows, animation_cue_tracks); playback engine-core::battle_anim; export monster_gltf
Confidence
Confirmed - decoder, draw, installer and commit routines all traced; the "contact" reading of the event-frame list is Inferred
Used by
Enemy table (idle loops + glb export), battle scene, battle action SM, battle formulas (juggle window)

Where it lives

Each monster's decoded archive block is: stat record with its +0x4C action-offset array, name, TMD mesh, the per-action entries, then the texture pool. The +0x4A count says how many entries there are. Each entry is an action descriptor - not just a spell - with a semantic tag, costs, effect and sound cue scripts, and a packed keyframe stream at +0x8C.

tag ptrs beats effect script sound cues cost / rate keyframes u8 0 on disc u8[4] 8 x 8 bytes 8 x u16 pair +0x74 / +0x78 to end +0x00 +0x04 +0x10 +0x14 +0x54 +0x74 +0x8C u8 parts u8 frames frames x parts records, 9 bytes each = 6 packed 12-bit fields (tx ty tz rx ry rz) +0x8C expanded:
One per-action entry. Everything before +0x8C is the head; the keyframe stream runs to the entry's end.
OffsetSizeFieldMeaning
+0x00u8action tagSemantic tag (idle / walk / flinch / knockdown / attack …), not an index - table below
+0x04, +0x08, +0x88u32runtime pointersZero on disc; the loader fills them, +0x88 pointing at the +0x8C stream
+0x10u8[4]event framesZero-terminated ascending list of the clip's beats; drives the juggle window
+0x148 × 8effect scriptUp to eight [gate][effect][x][y][z] records placing the action's visual effects
+0x548 × 4sound cues[u16 frame][u16 cue] pairs, truncated at the first zero cue
+0x74u8AGL costAction cost
+0x77u8sub-id
+0x78u8ratePlayback rate (1 or 2 across the retail corpus); a clip lasts frames × 8 / rate ticks
+0x8C-packed stream[u8 parts][u8 frames] then frames × parts 9-byte records

Keyframe records

Each part record is 9 bytes holding six 12-bit fields. Low bytes sit at [0,1,3,4,6,7]; the high nibbles pack into [2,5,8]:

tx = b0 | (b2 & 0x0f) << 8      ty = b1 | (b2 & 0xf0) << 4
tz = b3 | (b5 & 0x0f) << 8      rx = b4 | (b5 & 0xf0) << 4
ry = b6 | (b8 & 0x0f) << 8      rz = b7 | (b8 & 0xf0) << 4
  • tx, ty, tz: sign-extended 12-bit translation (-2048..2047) in model units.
  • rx, ry, rz: unsigned 12-bit Euler angles, 4096 = one full turn.
  • One part = one TMD object. The part count equals the object count for over 98% of actions (one model carries an extra static object).
  • The transform is absolute model-space, not a delta: world = Rz·Ry·Rx · v_local + t. Frame 0 is the assembled rest pose; a raw monster TMD drawn without it is a heap of parts piled at the origin.

Action tags and the reaction map

When a monster takes a hit, the game must find "the flinch" or "the knockdown" without knowing which entry index that is for this monster. The first byte of every entry is a semantic tag that makes the lookup possible:

TagMeaning
0idle loop (always entry 0)
1walk / approach cycle
2, 3light hit reactions (flinch variants)
4knockdown (heavy hit / death fall)
5get-up
7, 8, 9ready / recover / defeat poses (player files)
0x0Bblock
0x0C..0x1Fcastable spell / special actions; 0x0D..0x0F are the monster's attack moves
0x20, 0x21, 0x22attack pre-approach / close-in / victory (monster files)

At battle start the monster installer scans the entry table once and caches the entry index of each tag in {2, 3, 4, 5, 0x0B} into five actor bytes at +0x1EF..+0x1F3; the party installer hardcodes [2,3,4,5,0xB] because player files store that family in order. Rules of the scan:

  • Single forward pass, no early exit - a duplicated tag resolves to the last match.
  • "Absent" is zero; a monster with no knockdown entry reuses its light flinch.
  • The damage primitive stages the target's reaction from this map; the anim commit chains a finished knockdown into the get-up while the actor lives.
  • Attack animations (tags 0x20 / 1 / 0x21 / 0x22) use a different, first-match search with a 0xFF not-found sentinel (ported as find_action_by_tag). Do not build the reaction map from it.

The anim id is the entry index

Per actor, +0x1DA is the queued anim id and +0x1D9 the current one. The id is the entry index - the commit routine installs entry = offsets[id] with no remap table. Retail's idle id is 0. Party ids ≥ 0x10 take the dynamic-slot path of the player battle files.

A special attack can be a chain of entries

An action id is one entry, but a move need not be. The per-spell cast modules write actor[+0x1DA] directly, so a boss signature attack is several entries staged in sequence. Nothing in the archive marks the grouping; the chain lives in the module's code.

ModuleCastCaster stages (entry indices)Closing entry
PROT 958Gi, 0x790x0A → 0x0B → 0x0C → 0x0A → 0x0B0x0D
PROT 959Che, 0x7A0x0A → 0x0Bnone
PROT 960Lu, 0x7B0x0E → 0x0C → 0x0D0x0F

Stages are addressed by entry index, not tag (Gi's and Che's are all tagged 0x23), and stages do not share a rate byte, so a chain lasts sum(frames_i × 8 / rate_i) ticks. Pinned by a staging-site scan of the modules and per-frame capture of natural duel casts.

Beats, effects and sound cues

Event frames (+0x10..+0x13)

Up to four ascending clip frame indices - the action's significant beats. The per-frame anim tick uses the first beat for two things: a queued clip commits mid-clip once beat + 2 < frame, and the actor byte +0x1F7 latches frame < beat - the battle's juggle window. Every flinch and block entry carries exactly one early beat; the attack band carries one to four. Reader monster_archive::juggle_windows.

Effect script (+0x14..+0x53)

Up to eight 8-byte records walked once per frame to place dust, impact flashes and emitter trains:

OffsetSizeFieldMeaning
+0u8frame_gateSkip while frame + 1 < gate; 0 ends the walk
+1u8effect& 0x7F == 0x7F terminates and installs move power; & 0x80 selects a direct 2D spawn, else the effect-pool index (effect § bit-7 multiplex)
+2, +4, +6i16 × 3off_x/y/zActor-local offsets scaled by the render node's mesh scale and rotated by its facing; Y is subtracted

Port engine-core::action_effect_script::step_effect_script.

Sound cues (+0x54..+0x73)

Eight [u16 frame][u16 cue] pairs, truncated at the first zero cue - footsteps, swing whooshes, the per-punch impacts of the Delilas flurries. The per-frame player resumes at the actor's cue cursor and hands each cue to the sound ring (layout on sfx-table); cue ids ≥ 0x100 bypass the ring for the XA player. Reader monster_archive::animation_cue_tracks.

Playback

The battle draw keeps a 12.4 fixed-point phase per actor: frame index = phase >> 4, sub-frame fraction = phase & 0xF. The decoder interpolates between frame i and i+1 - linear for translation, shortest-path wrap for rotation - into a pose buffer of six shorts per object, which the draw loop applies per object through the GTE. Each tick advances phase += (frame_dt × speed_scale × rate) >> 1, where the actor's speed scale is normally 4 and rate is the entry's +0x78 byte.

The engine mirrors this: battle_anim::MonsterAnimPlayer decodes each monster's idle clip into a loop cursor whose tick() produces a PoseFrame - the same shape the field ANM player produces - and the renderer poses the mesh with R·v + T about each object's local origin. The reaction staging and the knockdown → get-up chain are played; the cross-blend into a queued clip is a known simplification.

Vertex-blend variants

Two sibling routines write the decoded pose onto the object vertices in the per-actor draw buffer. One is a straight copy. The other is a morph variant: it derives a 12-bit blend factor from the phase against a per-object frame-range table (bytes [1..=4] of the entry) and lerps each vertex toward its next-frame target - sub-keyframe morphing on top of the rigid decode. The port keeps to the rigid decode (engine-vm/anim_vm.rs) and leaves morphing to the renderer.

Texture pool

The same archive block carries the model's colours: a 0x1E0-byte region of fifteen 16-colour palettes followed by a 4bpp index page (256 rows tall, 128 or 256 texels wide). Each TMD primitive picks its palette from the low six bits of its CBA and samples the page at its UVs - the same index-then-palette lookup the PSX GPU performs from VRAM (TIM, renderer). 186 of the 194 archive slots carry a clean mesh + pool; the rest are filler ids.

Export (glTF)

legaia_asset::monster_gltf::export_glb packs mesh, baked texture and every action into one .glb. The rigid-per-object model maps straight onto glTF node animation: each TMD object becomes a node, the keyframe translation and Euler rotation (recomposed as a quaternion in Rz·Ry·Rx order) drive its channels, and a root node rotates the rig 180° about X for glTF's Y-up. The per-primitive palettes are baked into a vertical atlas with each vertex's V remapped into its band.

asset monster-archive … --id N --glb enemy.glb

The enemy table exposes the same export as a per-enemy download button.

How we know

FunctionAddressWhat it provesDump
Packed-stream decoderFUN_8004998C9-byte / six-field unpack; linear + angle-wrap interpolation8004998c.txt
Battle drawFUN_80048A0812.4 phase at draw +0x68; per-object GTE apply80048a08.txt
Anim-node tickFUN_80047430Phase advance with rate byte; event-frame consumers at 0x80047918 / 0x80047E28 (juggle latch +0x1F7)80047430.txt
Monster installerFUN_80054CB0Tag scan 0x80055338..0x80055408 into +0x1EF..+0x1F3; flinch fallback at 0x8005542880054cb0.txt
Party installerFUN_80053CB8Hardcoded [2,3,4,5,0xB]80053cb8.txt
Anim commitFUN_8004AD80id = entry index; knockdown → get-up chain8004ad80.txt
Damage primitiveFUN_800402F4Reaction staged from the tag map800402f4.txt
First-match tag searchFUN_80050E2CAttack-anim lookup, 0xFF sentinel80050e2c.txt
Effect-script walkerFUN_801DEA50 (battle overlay)Gate / effect / offset semanticsoverlay_battle_action_801dea50.txt
Vertex blendFUN_800495C8 / FUN_80049858 / FUN_8005B038Morph vs straight copy800495c8.txt, 80049858.txt
Texture-pool loaderFUN_8005546815 palettes + 4bpp page80055468.txt
Captureautorun_delilas_enemy_cast_watch.luaEntry chains of the Delilas casts; the two writers of +0x1F7scripts/pcsx-redux/

Open: the caller of the anim-node tick is not in the dump corpus. Source of record: docs/formats/monster-animation.md.

See also