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_actor_formats::monster_archive (animations, idle_animation, action_tags, juggle_windows, animation_cue_tracks); playback engine-battle::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.

PROT 867monster archive slot (id-1) * 0x14000LZS stream at slot+4 decoded block stat record+0x4A count, +0x4C u32 offsets[] TMD mesh+0x04 texture pool+0x08 action entry i head +0x00..+0x8Btag, beats, effects, cues, motion +0x8C packed streamparts x frames x 9 bytes
From the archive to one keyframe. The stat record's +0x4C offset array points at each action entry; each entry is a fixed head followed by its own keyframe stream.
OffsetSizeFieldMeaning
+0x00u8action tagSemantic tag (idle / walk / flinch / knockdown / attack …), not an index - table below
+0x04, +0x08u32clip-morph lanes1-based index (0 = none) the loader turns into a pointer to a clip morph
+0x88u32stream pointerZero on disc; the loader points it at the +0x8C stream
+0x0Ci16root speedSigned per-tick drive of the actor along its facing while the clip plays
+0x0Ei16end stepHow far the clip leaves the body from where it began (below); 0 on every idle
+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
+0x76u8event gateZero lets a queued clip commit mid-clip at the first beat
+0x77u8sub-idAlso the after-image ring id (+0x10)
+0x78u8ratePlayback rate (1 or 2 across the retail corpus); a clip lasts frames × 8 / rate ticks
+0x7Au8status / impact selectorTint flash + status roll on the struck actor; the same enum as a move-power record's +0x0A
+0x84u8loop holdSeeds a finite loop-hold budget on the actor
+0x85, +0x86u8 × 2loop windowFirst / last frame of a window the clip parks or replays inside (tested before the natural end)
+0x87u8solo flagNon-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:

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 / 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 a 0xFF not-found sentinel (ported as find_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:

RouteTaken whenCaster clip
action phase 0x28 → 0x29spell class ≥ 0x14 (or id ≥ 0x65), not 0x63+0x1E0
Spirit band 0x3Cclass < 0x14 and id < 0x65 - heals and buffs+0x1E7
capture band 0x28 → 0x6Eclass 0x63 - most monster specialsneither: 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.

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.

First beatentry +0x10 Juggle window+0x1F7 = frame < beat Damage kerneljuggle counter grows while it is up Anim commit FUN_8004AD80re-stage while up: cue cursor = 1 Mid-clip commitqueued clip cuts in once beat + 2 < frame
The window byte has exactly two writers (both in the anim tick, pinned by a write watch) and these readers; it also paces the arts-input band. A flinch’s beat over its rate is the window: Gobu Gobu’s beat 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:

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-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 k2 k3 k4 0x1000 0 full weight clip frame
The lane weight against the clip cursor (frames in 12.4 fixed point): a linear ramp from 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.glb

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

How we know

Decoder, installer and commit routines
FunctionAddressWhat it provesDump
Packed-stream decoderFUN_8004998C9-byte / six-field unpack; linear + angle-wrap interpolation with the Euler-flip retry8004998c.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
Clip morphsFUN_800495C8 / FUN_80049858 / FUN_8005B038Envelope weight, save and in-place blend; restore800495c8.txt, 80049858.txt
Texture-pool loaderFUN_8005546815 palettes + 4bpp page80055468.txt
Slot loaderFUN_800542C8(id-1) × 0x14000 slot, literal 0x28-sector read at 0x80054608, LZS from slot + 4800542c8.txt
AI cast pickFUN_801E9FD4 (battle overlay)Reverse slot pairing; clip written to +0x1E7 / +0x1E0 at 0x801EA53C / 0x801EA540overlay dump
Runtime capturePCSX-Redux watch of the Delilas castsEntry chains of the casts; the two writers of +0x1F7probe harness

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

See also