Monster (enemy) battle animation Confirmed
How the battle monsters move. Legaia's enemies are rigid-body puppets: each body part is a separate object of the monster's TMD mesh, and every action - idle, walk, attack, knockdown - is a stream of per-part position + rotation keyframes stored next to the mesh in the monster archive. This is a different system from the ANM container that drives player and field actors. Mesh, texture and every animation export together as a standard .glb.
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+0x4Coffset array. The player battle files carry the same entry family - Size
- Entry head
0x8Cbytes, then[parts][frames]+frames × parts9-byte keyframe records - Parser
legaia_asset::monster_archive(animations,idle_animation,action_tags,juggle_windows,animation_cue_tracks); playbackengine-core::battle_anim; exportmonster_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.
+0x8C is the head; the keyframe stream runs to the entry's end.| Offset | Size | Field | Meaning |
|---|---|---|---|
+0x00 | u8 | action tag | Semantic tag (idle / walk / flinch / knockdown / attack …), not an index - table below |
+0x04, +0x08, +0x88 | u32 | runtime pointers | Zero on disc; the loader fills them, +0x88 pointing at the +0x8C stream |
+0x10 | u8[4] | event frames | Zero-terminated ascending list of the clip's beats; drives the juggle window |
+0x14 | 8 × 8 | effect script | Up to eight [gate][effect][x][y][z] records placing the action's visual effects |
+0x54 | 8 × 4 | sound cues | [u16 frame][u16 cue] pairs, truncated at the first zero cue |
+0x74 | u8 | AGL cost | Action cost |
+0x77 | u8 | sub-id | |
+0x78 | u8 | rate | Playback 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:
| Tag | Meaning |
|---|---|
0 | idle loop (always entry 0) |
1 | walk / approach cycle |
2, 3 | light hit reactions (flinch variants) |
4 | knockdown (heavy hit / death fall) |
5 | get-up |
7, 8, 9 | ready / recover / defeat poses (player files) |
0x0B | block |
0x0C..0x1F | castable spell / special actions; 0x0D..0x0F are the monster's attack moves |
0x20, 0x21, 0x22 | attack 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 a0xFFnot-found sentinel (ported asfind_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.
| Module | Cast | Caster stages (entry indices) | Closing entry |
|---|---|---|---|
| PROT 958 | Gi, 0x79 | 0x0A → 0x0B → 0x0C → 0x0A → 0x0B | 0x0D |
| PROT 959 | Che, 0x7A | 0x0A → 0x0B | none |
| PROT 960 | Lu, 0x7B | 0x0E → 0x0C → 0x0D | 0x0F |
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:
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0 | u8 | frame_gate | Skip while frame + 1 < gate; 0 ends the walk |
+1 | u8 | effect | & 0x7F == 0x7F terminates and installs move power; & 0x80 selects a direct 2D spawn, else the effect-pool index (effect § bit-7 multiplex) |
+2, +4, +6 | i16 × 3 | off_x/y/z | Actor-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.glbThe enemy table exposes the same export as a per-enemy download button.
How we know
| Function | Address | What it proves | Dump |
|---|---|---|---|
| Packed-stream decoder | FUN_8004998C | 9-byte / six-field unpack; linear + angle-wrap interpolation | 8004998c.txt |
| Battle draw | FUN_80048A08 | 12.4 phase at draw +0x68; per-object GTE apply | 80048a08.txt |
| Anim-node tick | FUN_80047430 | Phase advance with rate byte; event-frame consumers at 0x80047918 / 0x80047E28 (juggle latch +0x1F7) | 80047430.txt |
| Monster installer | FUN_80054CB0 | Tag scan 0x80055338..0x80055408 into +0x1EF..+0x1F3; flinch fallback at 0x80055428 | 80054cb0.txt |
| Party installer | FUN_80053CB8 | Hardcoded [2,3,4,5,0xB] | 80053cb8.txt |
| Anim commit | FUN_8004AD80 | id = entry index; knockdown → get-up chain | 8004ad80.txt |
| Damage primitive | FUN_800402F4 | Reaction staged from the tag map | 800402f4.txt |
| First-match tag search | FUN_80050E2C | Attack-anim lookup, 0xFF sentinel | 80050e2c.txt |
| Effect-script walker | FUN_801DEA50 (battle overlay) | Gate / effect / offset semantics | overlay_battle_action_801dea50.txt |
| Vertex blend | FUN_800495C8 / FUN_80049858 / FUN_8005B038 | Morph vs straight copy | 800495c8.txt, 80049858.txt |
| Texture-pool loader | FUN_80055468 | 15 palettes + 4bpp page | 80055468.txt |
| Capture | autorun_delilas_enemy_cast_watch.lua | Entry chains of the Delilas casts; the two writers of +0x1F7 | scripts/pcsx-redux/ |
Open: the caller of the anim-node tick is not in the dump corpus. Source of record: docs/formats/monster-animation.md.