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_actor_formats::player_anm, legaia_actor_formats::character_pack::field_locomotion_anm
Engine
engine-scene::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.

One record - 16 + 8 × bones × frames bytes:

An ANM record is a grid of 8-byte entries, one row per frame and one column per bone. Each entry holds a translation and a rotation, and bone i poses TMD object i directly. one record: frames x bones entries of 8 bytes bone 0 bone 1 bone 2 ... last frame 0 frame 1 frame 2 ... stored row by row: all of frame 0, then frame 1 ... translation bytes 0..4: 3 x 12-bit rotation bytes 5..7 the 8-byte entry for (frame 1, bone 2) TMD object 2 this frame every vertex: R * v + T, no parent bone
"Bone" here means one rigid mesh piece: column i moves TMD object i on its own, so there is no skeleton to walk. Playback steps down the rows and interpolates between neighbouring frames.
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.

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 = bank + offsets[id & 0x3FF]. The bank is the scene's MOVE slot (_DAT_8007B888), _DAT_8007B840 for ids >= 0x400, or DAT_8007B75C when actor flag +0x10 bit 0x01000000 is set
+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. It is the same header bit that selects the clip tick's scaled step, so a clip either scales its step and blends, or does neither. The weight is the cursor's low nibble. The second entry is the same part one frame later; on the last frame it is the entry itself when the clamp bit +0x62 & 8 is set, else the part's frame-0 entry (the loop wrap).

Translations lerp as cur + (((next - cur) * frac) >> 4). Angles go through a wraparound-aware interpolator with the next frame as from: (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.

Each angle call adds its arc length to a counter and journals its pair. If the three arcs sum past 0xC00, the blender redoes the angles against the next frame's equivalent Euler triple (x + 0x800, -(y + 0x800), z + 0x800) - the same orientation by a shorter total path. Results land in scratchpad 0x1F8002C0 before the GTE load. A whole-frame cursor (fraction zero) poses exactly as the single-entry decode.

The same header bit slows the clip: a gated clip steps (rate*2 + div - 1) / div sixteenths a tick instead of the rate, so the common divisor-4 clips play at half speed - a live capture on Rim Elm matched that rule on every call it checked. In the port: PlayerAnmBundle::sample_bone (two-frame sampler), blend_bone_transform (blend with the flip retry), lerp_angle_12 (angle interpolator).

Where the clip bank lives, and the play_anm_by_id misreading

The scene's clip bundle rides type byte 0x05: the asset-type dispatcher allocates it with the move_malloc_err string into _DAT_8007B888, and the clip driver binds from that buffer. The dispatcher's anm_malloc_err arm is type 0x06, whose buffer _DAT_8007B7C8 holds the scene's CLUT-walk table, not clips - the enum labels and the contents are crossed.

FUN_80024CFC, long called play_anm_by_id, reads that type-6 buffer (_DAT_8007B7C8 + id*4 + 4), stores the entry in actor[+0x4C] and writes state 0xB to actor[+0x56]. State 0xB in the per-actor tick FUN_8001ADA4 is the CLUT-walk step: it advances a per-entry frame counter and issues a 16×1 MoveImage per frame. It spawns palette-cycling walkers, not animation clips (field ambient FX).

In RAM a loaded section 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.

How we know

Provenance: clip driver, entry decode, angle interpolator, pose renderer, captures
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), next-entry rule, two-entry lerp, Euler-flip retry past 0xC00funcs/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
Clip bank selectionFUN_800204F8 at 0x8002055CBinds from _DAT_8007B888 (type-5 buffer); FUN_80024CFC instead reads the type-6 CLUT-walk table and arms walker state 0xBfuncs/800204f8.txt, funcs/80024cfc.txt
MOVE dispatch (type 5)FUN_8001F05C, store at 0x8001F3A8Allocates the clip bundle into _DAT_8007B888 (move_malloc_err); the anm_malloc_err arm stores _DAT_8007B7C8 at 0x8001F3E4funcs/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 probesThe runtime copy byte-matches the decoded bundle; slot 0 = walk, 1 = idle; no run recordprobe harness

Deep dive

Kind 6: the two-pose tween record (the other record body)

For a kind-6 actor (actor[+0x5A] = 6, below), actor[+0x4C] points at a runtime record the tick rewrites every frame: a one-frame clip in the ordinary 8-byte encoding, followed by the two poses it is tweened from. N = *(actor[+0x44]) is the object count; total size 8 + 32N.

OffsetSizeFieldMeaning
+0x008headerClip header, stamped by the tick: +0 = N, +2 = 1 (one frame), +6 = 1
+0x088 × Noutput slotsOne ordinary 8-byte entry per object, written by the tick
+0x08 + 8N24 × Nsource posesPer object twelve i16, read by the tick; each lane's dst sits 12 bytes after its src

Per object the tick computes src + ((dst - src) * actor[+0x22]) >> 12 on five lanes and packs them (0x80022F0C..0x80023040): source +0x06 / +0x08 / +0x0A become T0 / T1 / T2, +0x02 >> 4 goes into both rot-X and rot-Z, +0x04 >> 4 into rot-Y; the halfwords at +0x00 / +0x0C are not read. crates/anm models the record as KeyframeReader / BoneKeyframe (infer_bone_count recovers N from the size), but its field order (src_pos, dst_pos, src_rot, dst_rot) and /256 factor do not match these lanes. Disc records whose size does not fit 16 + 8 × bones × frames are surfaced by anm scan-non-keyframe --histogram.

Dispatch byte actor[+0x5A]
+0x5AHandler block in the actor tickRole
0x01-Common stages only (the ladder never tests 1)
0x02shares with 0x06 at 0x80021E90..0x80021FA4Rate-integrated channel block (+0x34.. stepped by +0x40.., >> 6)
0x030x800226E8..0x800228A0CLUT-cell HSV integrate, then FUN_80019D50
0x040x80022CC8..0x80022EE4VRAM rect wrap-scroll
0x050x80021FB4..0x800226D8Positional SFX emitter
0x060x80021E90..0x80021FA4, 0x80022F0C..0x80023040The kind-2 block, then the two-pose tween above
0x070x80022C30..0x80022CB8Spline / curve-driven

These are the kinds of the move-VM / ambient-effect actors the tick serves; none of them is the ordinary clip path, which is the clip driver FUN_800204F8. Only kind 6's second block reads actor[+0x4C]. The span 0x800228B8..0x80022B80 is the default motion block that kinds 3 and 5 branch past, not a kind handler.

In the port: engine-vm::DispatchByte and engine-vm::actor_tick (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, loads actor[+0x4C], calls the BIOS vector 0xA0/0x2F, then runs a 40-byte body at 0x801E2630..70 over that pointer and the return value; that body is not decoded.

Battle-side anim state (the battle actor record)

Battle-actor animation state is not ANM: the battle actor record (0x2D4 bytes, slot 0 at 0x800EC9E8) points at a battle action entry with a 9-byte packed stream (monster animation owns the entry; the party's entries are in the battle data pack). A quiet-frame vs mid-strike save pair pins three actor regions:

OffsetSizeFieldMeaning
+0x1D816anim stateMostly zero pre-anim (01 77 at +0x1D7..8); e.g. 00 11 00 27 00 03 03 0F 0E 19 27 00 mid-anim
+0x1F418per-clip countersZero pre-anim; a stamped run of 0x11 bytes mid-anim
+0x23416entry pointer queue4-deep history of action-entry pointers, back-shifted through +0x238..+0x244 by FUN_80047430 when a new entry activates; FUN_80049348 shadows the head into the render context

The pointer names a battle action entry, not an ANM clip. The staged-anim commit FUN_8004AD80 tests its first byte - the entry's action tag - once per commit: 0x02 becomes 0x04 in place when the battle's first monster is 0xB3 / 0xB5 and the actor's HP is 0; 0x04 stages the get-up +0x1F2 into actor[+0x1DA] on a living actor, entry 7 on a dead party member, and clears +0x1DC; 0x05 / 0x08 OR 4 / 8 into actor[+0x1DC]; 0x07 stages entry 8; anything else raw playback. The captured mid-strike entry carries tag 0x18: raw playback.

OffsetTypeFieldMeaning
+0x00u8action tagSee 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)
+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

Those are entry fields; the consumers also read actor-side fields: the position vectors +0x34 / +0x38 and +0x44 / +0x48 (copied into the render context), the loop-hold counter +0x176 (stamped entry[+0x84] << 4), the render flag set +0x1BA (copied to render-ctx +0x7A), the loop counter +0x21B, and the speed scale +0x21D (0 / 2 / 4 / 8 observed), which FUN_80047430 multiplies into the cursor step and FUN_80049348 reads as 8 / actor[+0x21D], the stride of its child-actor walk. Engine views: engine-vm::anim_vm::OpaqueAnimRecord, OpaqueRecordKind, ActorAnimState - which names +0x21D an LOD step; the battle player the hosts run is engine-battle::battle_anim, not these views.

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 12-bit values, a translation then an Euler rotation:

byte[0] | (byte[2] & 0x0F) << 8   → tx
byte[1] | (byte[2] & 0xF0) << 4   → ty
byte[3] | (byte[5] & 0x0F) << 8   → tz
byte[4] | (byte[5] & 0xF0) << 4   → rx
byte[6] | (byte[8] & 0x0F) << 8   → ry
byte[7] | (byte[8] & 0xF0) << 4   → rz

The decoder FUN_8004998C sign-extends the three translations (bit 11 set ORs in 0xF000) and masks the three angles with andi 0xfff (unsigned, 4096 = a full turn); engine-vm::anim_vm::BoneFrame sign-extends all six, which the decoder does not. 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: docs/formats/anm.md.

See also