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_actor_formats::monster_archive(animations,idle_animation,action_tags,juggle_windows,animation_cue_tracks); playbackengine-battle::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.
+0x4C offset array points at each action entry; each entry is a fixed head followed by its own keyframe stream.| Offset | Size | Field | Meaning |
|---|---|---|---|
+0x00 | u8 | action tag | Semantic tag (idle / walk / flinch / knockdown / attack …), not an index - table below |
+0x04, +0x08 | u32 | clip-morph lanes | 1-based index (0 = none) the loader turns into a pointer to a clip morph |
+0x88 | u32 | stream pointer | Zero on disc; the loader points it at the +0x8C stream |
+0x0C | i16 | root speed | Signed per-tick drive of the actor along its facing while the clip plays |
+0x0E | i16 | end step | How far the clip leaves the body from where it began (below); 0 on every idle |
+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 |
+0x76 | u8 | event gate | Zero lets a queued clip commit mid-clip at the first beat |
+0x77 | u8 | sub-id | Also the after-image ring id (+0x10) |
+0x78 | u8 | rate | Playback rate (1 or 2 across the retail corpus); a clip lasts frames × 8 / rate ticks |
+0x7A | u8 | status / impact selector | Tint flash + status roll on the struck actor; the same enum as a move-power record's +0x0A |
+0x84 | u8 | loop hold | Seeds a finite loop-hold budget on the actor |
+0x85, +0x86 | u8 × 2 | loop window | First / last frame of a window the clip parks or replays inside (tested before the natural end) |
+0x87 | u8 | solo flag | Non-zero freezes every other actor for the clip (the "spotlight" during a special) - not a sound cue |
+0x8C | - | packed stream | [u8 parts][u8 frames] then frames × parts 9-byte records |
The archive slot moves whole
The archive is a flat array of fixed 0x14000-byte slots, slot = (id - 1) × 0x14000. The loader reads the slot's full 0x28 sectors whatever the block costs, then LZS-decodes from slot + 4 until the stream's own terminator. The run between a block's compressed stream and the slot end is transferred and never interpreted - declared slack, not a hidden format. Slots whose head word is 0x10 hold a raw PSX TIM instead of a monster. The loader reads one eight slots past any formation id above 0xB2 - Cort's two forms - and uploads it to texture page 0x16 and CLUT row 485 whatever its header says: the page the Mystic Shield's sphere is drawn from.
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 / knockout taunt - 0x22 plays when the monster's attack downs its target while a party member still stands (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.
Which clip a monster's spell plays
A cast's clip is not looked up by spell id. The AI picker counts the record's live magic slots, rolls k = rand() % count, and takes the k-th entry tagged 0x23 (counting from index 2) as the clip - the spell is magic slot count - 1 - k, so the pairing runs in reverse. It writes the clip to both +0x1E0 and +0x1E7, and the cast route decides which is played:
| Route | Taken when | Caster clip |
|---|---|---|
action phase 0x28 → 0x29 | spell class ≥ 0x14 (or id ≥ 0x65), not 0x63 | +0x1E0 |
Spirit band 0x3C | class < 0x14 and id < 0x65 - heals and buffs | +0x1E7 |
capture band 0x28 → 0x6E | class 0x63 - most monster specials | neither: the cast module stages literal entry indices of its own |
Because the capture modules hard-code entry indices, a module only lines up with the monsters built for it; some stage nothing on the caster, which then holds its idle through the cast in retail too. Melee bodies first walk the caster into reach and hold on the range poll; nothing walks it home afterwards. The port replays each body’s staged clips at the head of the capture band, except where a body places its stores inside its arms - those clips go in on the tick the arm passes, as retail stages them (legaia_engine_cast::cast_module_ticks::CAPTURE_CASTER_STAGES).
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.
8 at rate 2 is 16 ticks at the normal speed scale.Four-slot lists read a heap-dependent byte. The routine returns the beat as an index taken from the low byte of entry + 0x13, so for every list shorter than four entries - every reaction entry - the beat is simply the first byte. A full four-slot list, which only some attack-band entries carry, hands the tick a byte at entry[0x23 + 4k] where k depends on where the battle heap placed the monster block, so no disc byte decides it. Both fights measured from save states read 0, which keeps the window down for the whole clip; the port takes that measured value (battle_action::FOUR_SLOT_GATE_FRAME).
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-effects::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.
The anim commit seeds the cursor. Every commit stores 0; a commit that re-stages the clip already playing while the juggle byte is up stores 1 instead when the entry is the light flinch (+0x1EF / +0x1F0), or the block (+0x1F3) on a party seat or with a first cue id of 0x64 or more. A juggled re-hit therefore restarts its reaction without the track’s first cue. Port: clip_ladder::recommit_cue_cursor.
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 the next entry (i+1, the loop window's start, or the queued clip's frame 0) - linear for translation, shortest-path wrap for rotation - into a pose buffer of six shorts per object.
When a part's three angle steps total more than 0xC00 it blends the angles again against the next frame's equivalent Euler triple (x+0x800, -(y+0x800), z+0x800), the same retry the field blender runs; about one blended part sample in ninety on the disc takes it, 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 8 and rate is the entry's +0x78 byte.
On the last frame of a clip the decoder cross-blends toward frame 0 of the queued entry, so transitions tween rather than snap. A clip with a loop window (+0x85..+0x86) parks or replays inside it before the natural-end test runs; the hold is a finite budget seeded from +0x84, and a cast module can end a park early - timing choreography, not a terminal hold. The decoder also leaves the pose centroid on the actor: the average part translation, rotated by the facing and scaled, is added to the live position to give the body position the range checks measure.
The end-of-clip step (+0x0E)
A lunge ends one step forward without a snap. The decoder adds +0x0E to the Z delta of the last frame's blend into the queued frame 0, carrying the body forward in model space; at the natural end the anim tick moves the actor by the same step along its facing (+0x34 += sin · step >> 12, +0x38 += cos · step >> 12) and the pose snaps back to frame 0 at the new position. An event-cut clip moves it pro-rated by frame / frames. The tick skips the move while the root latch is up or a dead actor has no Seru staged.
In the port: engine-battle::battle_anim::MonsterAnimPlayer (blend kernel engine-battle-vm::battle_pose_blend), end-step kernels engine-battle-vm::battle_action::motion.
Clip morphs (+0x04 / +0x08)
An entry's +0x04 and +0x08 are two morph lanes: per-vertex deltas over the monster's own mesh that the battle draw blends in while the clip plays, on top of the rigid part motion. Each lane points at four envelope keys and a run list - the same record shape as a field scene's VDF morph:
k1 to k2, a hold to k3, a linear fall to k4. Equal keys skip a ramp.The per-actor draw blends each lane in just before it draws the actor and undoes it just after: for every run it saves the authored vertices, adds delta × weight >> 12 to them in place, and after the draw copies the saved vertices back, second lane first, so the next frame starts from the rest pose. Every one of the disc's 313 lanes parses, and each run stays inside the object it names.
In the port: legaia_actor_formats::monster_archive::{clip_morph_table, clip_morph_weight, apply_clip_morphs}; World::battle_morphed_tmd blends the playing clip's lanes, and both the native window and the browser play page draw the blended mesh.
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_actor_formats::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
Decoder, installer and commit routines
| Function | Address | What it proves | Dump |
|---|---|---|---|
| Packed-stream decoder | FUN_8004998C | 9-byte / six-field unpack; linear + angle-wrap interpolation with the Euler-flip retry | 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 |
| Clip morphs | FUN_800495C8 / FUN_80049858 / FUN_8005B038 | Envelope weight, save and in-place blend; restore | 800495c8.txt, 80049858.txt |
| Texture-pool loader | FUN_80055468 | 15 palettes + 4bpp page | 80055468.txt |
| Slot loader | FUN_800542C8 | (id-1) × 0x14000 slot, literal 0x28-sector read at 0x80054608, LZS from slot + 4 | 800542c8.txt |
| AI cast pick | FUN_801E9FD4 (battle overlay) | Reverse slot pairing; clip written to +0x1E7 / +0x1E0 at 0x801EA53C / 0x801EA540 | overlay dump |
| Runtime capture | PCSX-Redux watch of the Delilas casts | Entry chains of the casts; the two writers of +0x1F7 | probe harness |
Open: the caller of the anim-node tick is not in the dump corpus. Source of record: docs/formats/monster-animation.md.