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_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:
| 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.
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 = 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 / +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. 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
| 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), next-entry rule, two-entry lerp, Euler-flip retry past 0xC00 | 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 |
| Clip bank selection | FUN_800204F8 at 0x8002055C | Binds from _DAT_8007B888 (type-5 buffer); FUN_80024CFC instead reads the type-6 CLUT-walk table and arms walker state 0xB | funcs/800204f8.txt, funcs/80024cfc.txt |
| MOVE dispatch (type 5) | FUN_8001F05C, store at 0x8001F3A8 | Allocates the clip bundle into _DAT_8007B888 (move_malloc_err); the anm_malloc_err arm stores _DAT_8007B7C8 at 0x8001F3E4 | 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 | The runtime copy byte-matches the decoded bundle; slot 0 = walk, 1 = idle; no run record | probe 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.
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0x00 | 8 | header | Clip header, stamped by the tick: +0 = N, +2 = 1 (one frame), +6 = 1 |
+0x08 | 8 × N | output slots | One ordinary 8-byte entry per object, written by the tick |
+0x08 + 8N | 24 × N | source poses | Per 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]
+0x5A | Handler block in the actor tick | Role |
|---|---|---|
0x01 | - | Common stages only (the ladder never tests 1) |
0x02 | shares with 0x06 at 0x80021E90..0x80021FA4 | Rate-integrated channel block (+0x34.. stepped by +0x40.., >> 6) |
0x03 | 0x800226E8..0x800228A0 | CLUT-cell HSV integrate, then FUN_80019D50 |
0x04 | 0x80022CC8..0x80022EE4 | VRAM rect wrap-scroll |
0x05 | 0x80021FB4..0x800226D8 | Positional SFX emitter |
0x06 | 0x80021E90..0x80021FA4, 0x80022F0C..0x80023040 | The kind-2 block, then the two-pose tween above |
0x07 | 0x80022C30..0x80022CB8 | Spline / 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:
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0x1D8 | 16 | anim state | Mostly zero pre-anim (01 77 at +0x1D7..8); e.g. 00 11 00 27 00 03 03 0F 0E 19 27 00 mid-anim |
+0x1F4 | 18 | per-clip counters | Zero pre-anim; a stamped run of 0x11 bytes mid-anim |
+0x234 | 16 | entry pointer queue | 4-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.
| Offset | Type | Field | Meaning |
|---|---|---|---|
+0x00 | u8 | action tag | 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) |
+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 |
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.
| 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 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.