At a glance

Where
Party clips: PROT extraction entry 0874, section 1. NPC / prop clips: the type-0x05 section of every scene's first asset-table slot. Both LZS-compressed.
Marker
No magic word; every record header carries marker_1 = 0x080C
Size / stride
Record = 16 + 8 × bones × frames bytes; one 8-byte entry per (bone, frame)
Parser
crates/anm, legaia_asset::player_anm, legaia_asset::character_pack::field_locomotion_anm
Engine
engine-core::field_env::PropAnim, engine-vm::anim_vm, engine-vm::actor_tick
Confidence
Confirmed - decode and playback traced in disassembly; decoded bundles byte-match the runtime copies in save states
Used by
field locomotion, field script VM, characters and NPC viewers

Container and record layout

A container is an offset table followed by records. Each record is an 8-byte header, the frames, then 8 zero bytes of padding. Frames are stored frame-major: all of frame 0's bones, then frame 1's, and so on.

container count offsets[count] record 0 record 1 ... one record header 8 B frame 0: bones x 8 B frame 1: bones x 8 B ... frame b-1 pad 8 B header = bones, frames, 0x080C, flag size = 16 + 8 * bones * frames
The offset table points at records; each record is a header, frames blocks of bones 8-byte entries, and 8 bytes of padding.
OffsetSizeFieldMeaning
+0x004countNumber of records
+0x044 × countbyte_offsets[]Absolute byte offset of each record; offsets[i+1] - offsets[i] = record size
offsets[i]variesrecords[]Record bodies, layout below

Record header

OffsetSizeFieldMeaning
+0x001bonesBone count = number of animated TMD objects; must equal the mesh's object count or the renderer skips the draw
+0x011scaleBit 0 set: per-tick step becomes (rate*2 + div - 1) / div. Clear for records 0..8 of every field bundle, set for records 9+ and every PROT 1203 record
+0x022framesFrame count (3..60 across the corpus)
+0x042marker_1Always 0x080C
+0x062flagLow byte = the scaled step's divisor (2 / 4 in the field corpus)
+0x088 × bones × framesframesFrame-major 8-byte entries
end - 88paddingZero bytes

The size equation holds byte-exact across every record of every scene bundle; the per-scene detector (player_anm::find_in_entry) validates it before declaring a bundle parsed.

The 8-byte (bone, frame) entry

One bone's translation and rotation for one frame. Translations are nibble-packed signed 12-bit joint offsets in actor-local space; rotations are one byte per axis, shifted left 4 to a 12-bit PSX angle (4096 = 360°). Port: player_anm::BoneTransform::decode.

OffsetSizeFieldMeaning
+01low8(T0)Translation X, low byte
+11low8(T1)Translation Y, low byte
+21high4(T1) << 4 | high4(T0)The two high nibbles (sign bits live here)
+31low8(T2)Translation Z, low byte
+41? << 4 | high4(T2)T2's high nibble is the low nibble of byte 4; the high nibble is never read
+51rot Xu8 << 4
+61rot Ysame
+71rot Zsame
  • Sign-extend each translation: if (v & 0x800) v |= 0xFFFFF000.
  • The three angles compose the rotation matrix in Z, Y, X order (post-multiplication).
  • The renderer poses object i with a flat R·v + T about the object origin - there is no bone hierarchy to accumulate. Bone i drives TMD object i.
  • Frame 0 of an idle clip is the rest pose: it places each TMD object at its joint with its rest orientation.
History: the "byte 4 high nibble" phrasing

Earlier prose placed high4(T2) in the high nibble of byte 4. The instruction sequence lbu v0,0x4(t3); andi v0,v0,0xf; sll v0,v0,0x8 at 0x8001BF30..0x8001BF3C reads the low nibble, and the parser always did. See do-not-re-walk.

Two families of clips

The same record layout reaches the runtime from two places, never mixed: the party's clips ship once for the whole game, while every town carries its own pool of NPC and prop clips. A field actor's clip pointer (actor[+0x4C]) points into one or the other.

Party locomotion bundlePer-scene NPC / prop bundle
Who plays itVahn, Noa, Gala on the field, plus the savepoint crystalEvery NPC, scripted actor and animated prop in one scene
WherePROT extraction entry 0874, section 1Each scene block's first asset-table slot, third descriptor, type byte 0x05
Records23: three 7-record banks (Vahn 0..=6, Noa 7..=13, Gala 14..=20, 10 bones) + record 21 (3-bone savepoint loop) + record 22 (2-bone aux clip)69-72 per field scene (town01: 69)
Which record playsBank slot 0 = walk, slot 1 = idle; the other slots are not plain locomotion (no "run" exists - holding the pad never leaves the walk record)The MAN placement header's anim_id byte = record index + 1 (0 = none) - see placement resolution
Parsercharacter_pack::field_locomotion_anmplayer_anm::find_in_entry

The party meshes ship nobj = 12 but only 10 objects are animated: groups 10 and 11 are equipment-swap templates that are never rendered (character mesh).

The battle-form sibling: PROT 1203

PROT 1203 is the same container with 30 records for the Baka Fighter battle-form meshes (PROT 1204): three 9-record banks (Vahn 0..=8 15 bones, Noa 9..=17 16 bones, Gala 18..=26 15 bones) plus three 10-bone records 27..=29. Within a bank: 0 idle, 1..=3 attacks, 4 special, 5..=7 hit / knockdown, 8 win flourish. It is not what a real battle poses the party with - that is each character's own stream in its player battle file.

Playback

The clip driver runs once per actor per tick. It binds the requested clip, then advances a frame cursor in 1/16-frame units; the renderer poses from cursor >> 4. The decoder blends two adjacent frames by the low 4 cursor bits when the header's scale bit is set.

Actor fieldRole
+0x4CBound clip pointer = pack_base + offsets[id]
+0x5C / +0x5ERequested / bound clip id; a mismatch rebinds and resets the cursor
+0x62Mode bits: hold 0x0002, clamp 0x0008 (clear = loop), reverse 0x0080, end latch 0x0100, restart request 0x0200
+0x68Frame cursor, 1/16-frame units; last position = frames * 16 - 1
+0x6APer-tick step (through the header's scale bit and divisor)

Scripts drive all of it through the field VM: ops 0x2B / 0x2C / 0x2D set, clear or wait on a mode bit, 0x22 <id> selects a clip, and 0x4C sub-ops set the rate and the two pose snaps. That is what swings a town's doors open (field locomotion).

Interpolation details

The entry decoder reads two entries and blends them when the gate header[+1] & 1 is set; otherwise the current frame is emitted as-is. Translations lerp as a + (((b - a) * frac) >> 4). Angles go through a wraparound-aware interpolator: (to + ((from - to) * frac >> 4)) & 0xFFF, with the pair first brought onto the short arc by two sequential guards - add a turn to to when from - to >= 0x800, then to from when the updated to - from >= 0x800. Inputs exactly half a turn apart fire both guards, which cancel, so that case resolves forward. Results land in scratchpad 0x1F8002C0 before the GTE load.

BoneTransform::decode models the un-interpolated arm; player_anm::lerp_angle_12 is the angle half of the blend for consumers chasing frame-exact parity. Retail also accumulates |from - to| into a global and journals the pair into a slot table; neither affects the transform and the port omits both.

Entry point and allocator preamble

play_anm_by_id(id, actor) primes an actor: allocate, read the record offset from the loaded container at _DAT_8007B7C8 + id*4 + 4, store base + offset in actor[+0x4C], write 0xB to state byte actor[+0x56] and 100 to the cursor. The actor tick reads +0x4C whenever dispatch byte actor[+0x5A] is 2 or 6. Field-VM op 0x34 sub-op 3 is the sibling path into the same slot.

In RAM the loaded container carries a 16-byte allocator preamble the disc form lacks (back_ptr, forward_ptr, forward_ptr_2, expanded_size); crates/anm::peel_preamble strips it. The scene bundle tags the section 0x05 ("MOVE" in AssetType) while the loader that allocates it is the labelled ANM dispatch - the runtime case selector and the enum label disagree.

How we know

FunctionAddressWhat it provesDump
Clip driver / frame cursorFUN_800204F8Header fields read at +0/+1/+2/+6; cursor, step, mode bitsfuncs/800204f8.txt
Entry decode + blendFUN_8001BE80Nibble packing (andi 0xf at 0x8001BF38), two-entry lerpfuncs/8001be80.txt
Angle interpolatorFUN_8001D088Sequential short-arc guards at 0x8001D090..B8funcs/8001d088.txt
Pose rendererFUN_8001B964Flat R·v + T per object; bone-count sanity check; frame = cursor >> 4funcs/8001b964.txt
Actor tickFUN_80021DF4Dispatch-byte ladder; opcode-6 keyframe block at 0x80022EC4..0x80023040funcs/80021df4.txt
Play by idFUN_80024CFCOffset lookup, +0x4C / +0x56 / +0x68 writesfuncs/80024cfc.txt
ANM dispatch (type 6)FUN_8001F05C case 6Allocates _DAT_8007B7C8 with the preamblefuncs/8001f05c.txt
Rotation buildersFUN_8004638C, FUN_8004629C, FUN_800461A4Z, Y, X matrix order; sin/cos tables at DAT_80070A2C / DAT_8007122Cper-function dumps
Save-state capturestown01 field anchor; walk / idle pad probesRuntime copy at DAT_8007B7C8 byte-matches the decoded bundle; slot 0 = walk, 1 = idle; no run recordprobe harness

Deep dive

Opcode-6 keyframe tables (the other record body)

Records consumed via animation opcode 0x06 carry a per-bone src/dst keyframe table the actor tick interpolates in place, with the bone count taken from the actor's mesh context. Total size 8 + 32N.

OffsetSizeFieldMeaning
+0x008headera, b, marker_1, marker_2
+0x088 × Noutput slotsPer-bone packed pos + rot deltas, written by the tick
+0x08 + 8N24 × Nkeyframe dataPer bone 12 i16: src_pos.xyz, dst_pos.xyz, src_rot.xyz, dst_rot.xyz

The tick multiplies (dst - src) by the per-actor interpolation factor actor[+0x22] and writes the packed result to the output slots. crates/anm::KeyframeReader is the typed accessor; infer_bone_count recovers N from the record size. Records whose body is empty are placeholder slots the tick skips; bodies not a multiple of 32 are opcode-specific layouts with no known interpreter (anm scan-non-keyframe --histogram lists them).

Dispatch byte actor[+0x5A]
+0x5AHandler block in the actor tickRole
0x01-Pose-snap only
0x02shares with 0x06 at 0x80021E90..Per-bone keyframe-style
0x030x800226DC..Path / state-write
0x040x80022CBC..0x80022EE4Damp / spring-decay
0x050x800228B0..0x80022B80Path-alt, geometry from actor[+0x80]
0x060x80021EA0..0x80021FA4Keyframe interpolation - fully traced and ported
0x070x80022C24..0x80022CC0Spline / curve-driven

engine-vm::DispatchByte exposes the values; the per-arm physics tick is ported in crates/engine-vm/src/actor_tick.rs (see the per-arm breakdown). A second, overlay-resident path in the field-VM dispatcher (FUN_801DE840, sub-dispatch table 0x801CEF88) gates on actor[+0x5C] > 0, calls the clip driver, then completes frame selection in a 40-byte body at 0x801E2630..70 that is untraced.

Battle-side anim state (the battle actor record)

In battle a different consumer poses actors, but per-actor state still lives in the 0x2D4-byte battle actor record (slot 0 at 0x800EC9E8). A quiet-frame vs mid-somersault save pair pins three regions:

OffsetSizeFieldMeaning
+0x1D816anim-PCZero pre-anim (sentinel 01 77 at +0x1D7..8); incrementing per-bone counters mid-anim
+0x1F418flag accumulatorZero pre-anim; a run of 0x11 bytes once the action engages
+0x23416record pointer queue4-deep history of control-struct pointers, back-shifted by FUN_80047430 when a new record activates

The pointer names a runtime control struct, not bytecode. The per-frame consumer FUN_8004AD80 dispatches on its first byte: 0x02 handshake (advance to 0x04 when the global action byte is -0x4D / -0x4B), 0x04 action engaged (actor[+0x1DA] = 7 or copy of +0x1F2), 0x05 / 0x08 OR 4 / 8 into actor[+0x1DC], 0x07 actor[+0x1DA] = 8, anything else raw playback. The captured somersault record is kind 0x18: raw playback.

OffsetTypeFieldMeaning
+0x00u8kindSee above
+0x0Ei16movement scalePer-frame translation = angle_lookup * scale * frame_index / frame_count
+0x14..+0x538 × 8 Beffect scriptPer-frame visual-effect placements, walked by FUN_801DEA50 (monster animation)
+0x34, +0x44vecpositions A / BCopied into the render context
+0x56u16sub-state counterTicked during 0x02 → 0x04
+0x76..+0x78u8flag / adjust / multiplierAdjust is added to a per-arm constant; multiplier is per-frame
+0x84u8max frameStamped to actor[+0x21B]; actor[+0x176] = max << 4
+0x85 / +0x86u8loop target / triggerAt frame == trigger - 1 (and +0x21B != 0) the next frame is the loop target
+0x87u8special-effect idNon-zero goes to FUN_8004E13C
+0x88ptrnested frame dataBelow
+0x176u16frame counter cap
+0x1BAu16render flagsCopied to render-ctx +0x7A

actor[+0x21D] is a per-actor LOD step (8 / max(v, 1)) used to skip child actors during the render pass. Engine views: engine-vm::anim_vm::OpaqueAnimRecord, OpaqueRecordKind, ActorAnimState.

Nested per-frame data (battle)

The buffer at struct[+0x88] carries the bone keyframes the battle renderer interpolates each tick.

OffsetSizeFieldMeaning
+0x001bones_per_frame (B)Inner render-loop bound
+0x011frame_count (N)Cap = (N - 1) * 16; velocity divides by it, so zero is a runtime divide-by-zero
+0x02N × B × 9keyframesFrame stride B * 9

Each 9-byte bone block packs six sign-extended 12-bit values as two [i16; 3] vectors:

byte[0] | (byte[2] & 0x0F) << 8   → vec_a.x
byte[1] | (byte[2] & 0xF0) << 4   → vec_a.y
byte[3] | (byte[5] & 0x0F) << 8   → vec_a.z
byte[4] | (byte[5] & 0xF0) << 4   → vec_b.x
byte[6] | (byte[8] & 0x0F) << 8   → vec_b.y
byte[7] | (byte[8] & 0xF0) << 4   → vec_b.z

Bit 11 set ORs in 0xF000. The cursor actor[+0x68] holds the frame index in bits 4..15 and a sub-frame factor in bits 0..3; with a non-zero factor the renderer lerps toward the next frame (a + (b - a) * frac >> 4), where "next" is the loop target, N - 1 on the terminal frame, or frame + 1. Validated against FUN_8004AD80, FUN_80048A08, FUN_8004998C, FUN_80047430. Engine accessors: engine-vm::anim_vm::NestedFrameData, BoneFrame, ActorAnimState.

Capture recipes behind each pin and the disc-gated tests that hold them: docs/formats/anm.md.

See also