ANM animation container Confirmed
Every time Vahn walks across Rim Elm, a villager shifts on their feet, or a house door swings open, the motion comes from an ANM clip: a list of per-bone positions and rotations, one set per frame, that the field engine plays back over a TMD mesh. It is the animation format of the field - party, NPCs and props. Battle monsters and the party's battle poses use other formats (monster animation, battle data pack).
At a glance
- Where
- Party clips: PROT extraction entry 0874, section 1. NPC / prop clips: the type-
0x05section 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 × framesbytes; 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.
frames blocks of bones 8-byte entries, and 8 bytes of padding.| Offset | Size | Field | Meaning |
|---|---|---|---|
+0x00 | 4 | count | Number of records |
+0x04 | 4 × count | byte_offsets[] | Absolute byte offset of each record; offsets[i+1] - offsets[i] = record size |
offsets[i] | varies | records[] | Record bodies, layout below |
Record header
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0x00 | 1 | bones | Bone count = number of animated TMD objects; must equal the mesh's object count or the renderer skips the draw |
+0x01 | 1 | scale | Bit 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 |
+0x02 | 2 | frames | Frame count (3..60 across the corpus) |
+0x04 | 2 | marker_1 | Always 0x080C |
+0x06 | 2 | flag | Low byte = the scaled step's divisor (2 / 4 in the field corpus) |
+0x08 | 8 × bones × frames | frames | Frame-major 8-byte entries |
end - 8 | 8 | padding | Zero 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.
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0 | 1 | low8(T0) | Translation X, low byte |
+1 | 1 | low8(T1) | Translation Y, low byte |
+2 | 1 | high4(T1) << 4 | high4(T0) | The two high nibbles (sign bits live here) |
+3 | 1 | low8(T2) | Translation Z, low byte |
+4 | 1 | ? << 4 | high4(T2) | T2's high nibble is the low nibble of byte 4; the high nibble is never read |
+5 | 1 | rot X | u8 << 4 |
+6 | 1 | rot Y | same |
+7 | 1 | rot Z | same |
- 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
iwith a flatR·v + Tabout the object origin - there is no bone hierarchy to accumulate. Boneidrives TMD objecti. - 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 bundle | Per-scene NPC / prop bundle | |
|---|---|---|
| Who plays it | Vahn, Noa, Gala on the field, plus the savepoint crystal | Every NPC, scripted actor and animated prop in one scene |
| Where | PROT extraction entry 0874, section 1 | Each scene block's first asset-table slot, third descriptor, type byte 0x05 |
| Records | 23: 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 plays | Bank 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 |
| Parser | character_pack::field_locomotion_anm | player_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 field | Role |
|---|---|
+0x4C | Bound clip pointer = pack_base + offsets[id] |
+0x5C / +0x5E | Requested / bound clip id; a mismatch rebinds and resets the cursor |
+0x62 | Mode bits: hold 0x0002, clamp 0x0008 (clear = loop), reverse 0x0080, end latch 0x0100, restart request 0x0200 |
+0x68 | Frame cursor, 1/16-frame units; last position = frames * 16 - 1 |
+0x6A | Per-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
| Function | Address | What it proves | Dump |
|---|---|---|---|
| Clip driver / frame cursor | FUN_800204F8 | Header fields read at +0/+1/+2/+6; cursor, step, mode bits | funcs/800204f8.txt |
| Entry decode + blend | FUN_8001BE80 | Nibble packing (andi 0xf at 0x8001BF38), two-entry lerp | funcs/8001be80.txt |
| Angle interpolator | FUN_8001D088 | Sequential short-arc guards at 0x8001D090..B8 | funcs/8001d088.txt |
| Pose renderer | FUN_8001B964 | Flat R·v + T per object; bone-count sanity check; frame = cursor >> 4 | funcs/8001b964.txt |
| Actor tick | FUN_80021DF4 | Dispatch-byte ladder; opcode-6 keyframe block at 0x80022EC4..0x80023040 | funcs/80021df4.txt |
| Play by id | FUN_80024CFC | Offset lookup, +0x4C / +0x56 / +0x68 writes | funcs/80024cfc.txt |
| ANM dispatch (type 6) | FUN_8001F05C case 6 | Allocates _DAT_8007B7C8 with the preamble | funcs/8001f05c.txt |
| Rotation builders | FUN_8004638C, FUN_8004629C, FUN_800461A4 | Z, Y, X matrix order; sin/cos tables at DAT_80070A2C / DAT_8007122C | per-function dumps |
| Save-state captures | town01 field anchor; walk / idle pad probes | Runtime copy at DAT_8007B7C8 byte-matches the decoded bundle; slot 0 = walk, 1 = idle; no run record | probe 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.
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0x00 | 8 | header | a, b, marker_1, marker_2 |
+0x08 | 8 × N | output slots | Per-bone packed pos + rot deltas, written by the tick |
+0x08 + 8N | 24 × N | keyframe data | Per 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]
+0x5A | Handler block in the actor tick | Role |
|---|---|---|
0x01 | - | Pose-snap only |
0x02 | shares with 0x06 at 0x80021E90.. | Per-bone keyframe-style |
0x03 | 0x800226DC.. | Path / state-write |
0x04 | 0x80022CBC..0x80022EE4 | Damp / spring-decay |
0x05 | 0x800228B0..0x80022B80 | Path-alt, geometry from actor[+0x80] |
0x06 | 0x80021EA0..0x80021FA4 | Keyframe interpolation - fully traced and ported |
0x07 | 0x80022C24..0x80022CC0 | Spline / 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:
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0x1D8 | 16 | anim-PC | Zero pre-anim (sentinel 01 77 at +0x1D7..8); incrementing per-bone counters mid-anim |
+0x1F4 | 18 | flag accumulator | Zero pre-anim; a run of 0x11 bytes once the action engages |
+0x234 | 16 | record pointer queue | 4-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.
| Offset | Type | Field | Meaning |
|---|---|---|---|
+0x00 | u8 | kind | See above |
+0x0E | i16 | movement scale | Per-frame translation = angle_lookup * scale * frame_index / frame_count |
+0x14..+0x53 | 8 × 8 B | effect script | Per-frame visual-effect placements, walked by FUN_801DEA50 (monster animation) |
+0x34, +0x44 | vec | positions A / B | Copied into the render context |
+0x56 | u16 | sub-state counter | Ticked during 0x02 → 0x04 |
+0x76..+0x78 | u8 | flag / adjust / multiplier | Adjust is added to a per-arm constant; multiplier is per-frame |
+0x84 | u8 | max frame | Stamped to actor[+0x21B]; actor[+0x176] = max << 4 |
+0x85 / +0x86 | u8 | loop target / trigger | At frame == trigger - 1 (and +0x21B != 0) the next frame is the loop target |
+0x87 | u8 | special-effect id | Non-zero goes to FUN_8004E13C |
+0x88 | ptr | nested frame data | Below |
+0x176 | u16 | frame counter cap | |
+0x1BA | u16 | render flags | Copied 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.
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0x00 | 1 | bones_per_frame (B) | Inner render-loop bound |
+0x01 | 1 | frame_count (N) | Cap = (N - 1) * 16; velocity divides by it, so zero is a runtime divide-by-zero |
+0x02 | N × B × 9 | keyframes | Frame 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.