ANM animation container Confirmed
ANM is the keyframe-animation container for player characters and field actors - the walk cycles, idle loops, and scripted motion the field engine plays back. Each record stores per-bone position/rotation keyframes that the runtime interpolates between to pose a TMD mesh. Asset type 0x06 from the asset-type dispatcher. Implementation: crates/anm.
It is not the only animation format on the disc, and picking the wrong one is the usual mistake. Battle monsters use a separate per-object keyframe stream inside the monster archive (monster animation), and the party's battle poses come from the player battle files' own streams rather than from here (character mesh). ANM data itself reaches you from two distinct bundles - the party locomotion bank and the per-scene NPC bank; see disc sources.
Layout
u32 count
u32 byte_offsets[count] // each is a byte offset into the buffer
records[] // per-record bodies; offsets[i+1] - offsets[i] = record size
Each record begins with an 8-byte header:
u16 a // varies (3..14 observed) - likely record kind / opcode
u16 b // varies (0..40 observed) - likely frame count
u16 marker_1 // = 0x080C in every record observed
u16 marker_2 // = 0x0002 (78%) or 0x0004 (22%)
Disc sources - locomotion bundle + per-scene bundle
Where to find the data: two ANM-container families feed the field actors, both in the frame-stream record layout (per-record header [a=bones][b=frames][marker 0x080C][flag], body = frames × bones × 8-byte entries, size 16 + 8·bones·frames; parser legaia_asset::player_anm):
- Party locomotion bundle - PROT 0874 §1 (entry 0874 of
PROT.DAT, the disc's single big archive, sub-section 1). The walk / run / idle clip set every field scene poses the party with: 23 records = three 7-record character banks (Vahn0..=6, Noa7..=13, Gala14..=20, all 10-bone) + a 3-bone savepoint loop (record 21) + a 2-bone aux clip (record 22). Bank slot 1 = the standing idle (all three live party actors'+0x4Crecord pointers sit at bank offset +1 in the town01 field anchor; frame 0 = the field rest-pose assembly transform), bank slot 0 = the walk loop (live pad-driven pin: the pointer flips record 1 → 0 while moving and back on stop). Byte-identical (post-LZS) to the runtime container. Parser entry:legaia_asset::character_pack::field_locomotion_anm. - Per-scene bundle - the scene's first asset-table slot, type-0x05 section. The NPC / scene-actor clip pool (town01: 69 records at runtime
DAT_8007B7C8; villager idles at records 14/17, children at 10/12). Which record an NPC plays comes from its MAN placement header: the record'sanim_idbyte = bundle record index + 1 (0= none), installed into the actor+0x5Chalfword at spawn - see the placement-header resolution.
Each 8-byte (bone, frame) entry decodes - FUN_8001BE80, ported at player_anm::BoneTransform - into three nibble-packed signed 12-bit translations + three u8 rotation angles (<< 4 to PSX 12-bit); the renderer (FUN_8001B964) poses TMD object i with bone i's flat R·v + T, requiring the actor's object-table count (FUN_80024D78, filled straight off the pool TMD) to equal the record's bone count.
A battle-form ANM bank also exists at PROT 1203 (other5), but it matches the PROT 1204 pack's own object order (the Baka Fighter / viewer configuration) - real battles pose the assembled meshes from the player files' record[0] TRS streams instead (see battle-data-pack § battle animations).
Per-record body - animation opcode 6
For records consumed via animation opcode 0x06 (the bulk of retail ANM data), the body after the header is a per-bone keyframe table, not opcode bytecode. The per-frame interpreter is the canonical actor tick FUN_80021DF4 in SCUS_942.54 (block at 0x80022ec4..0x80023040), which walks the table indexed by a bone count sourced from the actor's mesh context. Layout:
+0..+8 header (a, b, marker_1, marker_2)
+8..+(8 + 8*N) per-bone OUTPUT slots - written by the tick
(8 bytes per bone: packed pos+rot deltas)
+(8 + 8*N)..+(8 + 32*N) per-bone KEYFRAME data - read by the tick
(24 bytes per bone = 12 little-endian i16
shorts: src_pos.xyz, dst_pos.xyz,
src_rot.xyz, dst_rot.xyz)
Total record size for opcode-6 records is 8 + 32*N bytes for N bones. The tick reads the 12 shorts, multiplies the (dst - src) deltas by actor[+0x22] (the per-actor interpolation factor - driven from the field-VM frame counter), and writes the resulting 8 packed bytes back into the OUTPUT slots.
crates/anm exposes the typed accessor KeyframeReader for this layout. The bone count is supplied by the caller (the actor's mesh context owns it at runtime); offline tooling can use KeyframeReader::infer_bone_count to recover it from the record size when it fits the equation exactly.
Public entry point - play_anm_by_id
FUN_80024CFC (play_anm_by_id(id, actor, ?) in SCUS) is the writer that primes an actor for animation playback:
- Calls
FUN_80020DE0(actor allocator). - Reads the per-record offset from the ANM payload at
_DAT_8007B7C8 + (id*4) + 4. - Stores
(anm_base + record_offset)inactor[+0x4C](the per-actor anim record pointer). - Writes
0xBtoactor[+0x56](animation state byte) and100toactor[+0x68](frame counter).
The actor tick FUN_80021DF4 then reads actor[+0x4C] whenever actor[+0x5A] is 2 or 6 and runs the keyframe interpolation pass described above. Other animation opcodes (set in actor[+0x5A]) gate different per-record body layouts; only opcode 6 is fully traced.
Connection to other systems
The field/event script VM opcode 0x34 sub-op 3 plays a 3D animation by indexing into an ANM container and handing the entry to func_0x800252EC. That sibling path lands the same actor[+0x4C] slot the actor tick consumes.
Non-keyframe records
Records whose body size is not a multiple of 32 don't fit the opcode-6 keyframe layout. Two structural sub-classes:
| Body | Sub-class | Notes |
|---|---|---|
| 0 bytes | Empty / stub | Placeholder slot; the actor tick skips it |
| Not a multiple of 32 | Irregular body | Opcode-specific layout; interpreter unknown |
Use anm scan-non-keyframe 'extracted/PROT/*.BIN' --histogram to surface these across the corpus. The subcommand silently skips non-ANM files (safe to glob), and --histogram prints the top-8 byte distribution per record to help fingerprint the layout.
Dispatch byte at actor[+0x5A]
FUN_80021DF4 ladders through actor[+0x5A] (u16) and routes to a per-opcode handler block. Observed values:
actor[+0x5A] |
Handler block in FUN_80021DF4 |
Status |
|---|---|---|
0x01 |
(TBD) | Snap variant - pose-snap only |
0x02 |
shares with 0x06 at 0x80021E90.. |
Per-bone keyframe-style |
0x03 |
0x800226DC.. |
Path / state-write variant |
0x04 |
0x80022CBC..0x80022EE4 |
Damp / spring-decay variant |
0x05 |
0x800228B0..0x80022B80 |
Path-alt - reads geometry from actor[+0x80] |
0x06 |
0x80021EA0..0x80021FA4 |
Keyframe interpolation - fully traced + ported |
0x07 |
0x80022C24..0x80022CC0 |
Spline / curve-driven variant |
The crates/engine-vm DispatchByte enum exposes those values as a typed dispatch - DispatchByte::from_byte(actor[+0x5A]) and DispatchByte::handled_natively() for the cases the keyframe pose decoder can drive on its own (only Keyframe).
The per-arm physics tick (the part that isn't per-record bytecode - i.e. position / velocity / acceleration math, the SFX emitter at dispatch 0x05, and the per-arm render submissions for 0x04 and 0x07) is fully ported in crates/engine-vm/src/actor_tick.rs. Cross-cutting effects surface via TickEvent so engines can fold them into their own audio mixer / scene graph / move-VM driver. See the actor-VM doc for the per-arm breakdown.
The per-frame interpreter for non-opcode-6 records is partially overlay-resident. FUN_80024CFC only primes the actor (actor[+0x4C] = record pointer, actor[+0x56] = 0xB); a handler in the town overlay (FUN_801DE840, overlay 0897) reads actor[+0x4C] at 801e260c via a sub-dispatch table at 0x801CEF88 (routes by opcode & 0xF, 16 entries):
- Guard: reads
actor[+0x5C]; skips the whole handler if ≤ 0. - Calls
FUN_800204f8(a0 = actor) - actor advance / move tick. - Loads
s6 = actor[+0x4C]- the ANM record pointer. - Calls
FUN_80056798(BIOS vector0xa0/0x2F) while advancing the field VM PC by 2 in the delay slot. - The 40-byte body at
801e2630..801e2670usess6and the return value to complete the frame-selection logic; this segment is not yet traced.
This path is gated on actor[+0x5C] and is distinct from the opcode-6 keyframe path in FUN_80021DF4 (which gates on actor[+0x5A] == 6).
See ghidra/scripts/funcs/overlay_0897_801de840.txt line ~3389 for the disassembly.
Per-actor anim state offsets
A pre-action / mid-action save pair (a quiet battle frame vs an in-flight somersault strike) pins the per-actor anim state to a small named region inside the 0x2D4-byte battle actor record. Slot-0 actor record base = 0x800EC9E8; the slots continue at + 0x2D4 for each subsequent slot.
| Offset | Length | Purpose |
|---|---|---|
+0x1D8 | 16 B | Per-actor anim-PC. Pre-anim is mostly zero with a sentinel 01 77 at +0x1D7..+0x1D8; mid-anim holds incrementing per-bone counters (e.g. 00 11 00 27 00 03 03 0F 0E 19 27 00). |
+0x1F4 | 18 B | Per-frame anim flag accumulator. Pre-anim values are zero; mid-anim transitions to a stamped run of 0x11 bytes once the action engages. |
+0x234 | 16 B | 4-pointer anim dispatch table (4 × u32, all the same value). Pre-anim = 0x8015CC30; mid-anim = 0x801621D0. The pointer is bumped by +0x55A0 bytes between the two captures - the loader has paged a different ANM record into a different position in the heap for the strike. |
The anim-record header at the post-anim dispatch pointer reads as a 24-byte control block:
+0x00 u32 len = 0x18
+0x04 u32 reserved
+0x08 u32 reserved
+0x0C u32 field_C (= 4 in the captured record - frame count?)
+0x10 u32 field_10 (= 5)
+0x14 u32 field_14 (= 0x00299307 - dispatch flags / first opcode word?)
+0x18 u32 field_18 (= 0x0140017E - first opcode block)
These offsets are codified as engine_core::capture_observations::battle_action_animation and exercised by the disc-gated test battle_action_anim_pair_pins_dispatch_pointer_table_and_anim_pc_window.
Per-record consumer struct - SCUS-resident, kind-byte dispatch
The pointer stored at actor[+0x234] (and shadowed into the render context at +0x4C via FUN_80049348) points at a runtime per-record control struct, not a bytecode program. The per-frame consumer is the ladder in FUN_8004AD80 (SCUS_942.54), which dispatches on the byte at offset +0x00:
kind byte | Behaviour |
|---|---|
0x02 | Handshake. When the global character action byte (unaff_gp + 0x9F4) is -0x4D / -0x4B AND actor[+0x14C] == 0, advance to kind 0x04 and tick the per-record counter at +0x56. |
0x04 | Action engaged. If actor's anim-flag at +0x14C is zero, set actor[+0x1DA] = 7. Else copy actor[+0x1F2] into +0x1DA. |
0x05 | OR actor[+0x1DC] |= 4. |
0x07 | Set actor[+0x1DA] = 8. |
0x08 | OR actor[+0x1DC] |= 8. |
| other | No kind-specific branch (raw playback; fields below still drive the renderer). |
So the dispatch is a flat struct consumed via field-offset reads, not an instruction-pointer entry into a switch. The 24-byte header reads as kind 0x18 (= 24) for the captured strike record, which falls into the "other" arm.
Per-record struct fields
Consumers read these offsets within the struct pointed at by actor[+0x234]:
| Offset | Type | Purpose |
|---|---|---|
+0x00 | u8 kind | Kind byte (see table above). |
+0x0E | i16 | Movement-scaling factor. FUN_80047430 integrates per-frame translation as (angle_lookup * +0x0E * frame_index) / frame_count (where frame_count is the byte at *(+0x88) + 1). |
+0x56 | u16 | Sub-state counter; ticked during 0x02 -> 0x04 transition. |
+0x76 / +0x77 / +0x78 | u8 | Flag / adjust / multiplier bytes. |
+0x84 | u8 | Max-frame byte; consumer stamps actor[+0x21B] = +0x84 (previous-action sentinel) and actor[+0x176] = +0x84 << 4 (frame-counter cap). |
+0x85 | u8 | Loop-target frame index. When frame_index == +0x86 - 1 and actor[+0x21B] != 0, the per-bone interpolator sources the "next" frame from *(+0x88) + 2 + +0x85 * bones * 9 instead of the linear frame_index + 1 slot. |
+0x86 | u8 | Loop-trigger frame index (the frame at which the loop-target lookup kicks in). |
+0x87 | u8 | Special-effect ID; non-zero values dispatched to FUN_8004E13C. |
+0x88 | ptr | Pointer to nested per-frame data array. See Nested per-frame data below. |
+0x172 / +0x176 | u16 | Counter slots. |
actor[+0x21D] (NOT a consumer-struct field - it's a byte on the actor record itself) is a per-actor LOD-step byte. FUN_80049348 reads it as lod_step = 8 / max(actor[+0x21D], 1) and uses the result to skip child actors during the render pass. Observed values are 0 / 2 / 4 / 8, mapping to lod_step = 8 / 4 / 2 / 1. The crates/engine-vm view onto this is anim_vm::ActorAnimState.
The +0x234..+0x244 slot in the actor record is a 4-deep history queue of dispatch pointers (so the renderer can blend between the previous and current ANM record across a transition).
crates/engine-vm::anim_vm::OpaqueAnimRecord wraps a borrowed buffer at the dispatch-pointer base and exposes typed accessors for these fields; OpaqueRecordKind classifies the kind byte. Engines that re-host the struct should resolve the +0x88 pointer in their own address space before consuming the per-frame data.
Nested per-frame data
The buffer pointed at by consumer[+0x88] carries the per-frame bone keyframes the renderer interpolates each tick. Layout (validated against FUN_8004AD80, FUN_80048A08, FUN_8004998C, FUN_80047430):
+0x00 u8 bones_per_frame (B)
+0x01 u8 frame_count (N)
+0x02..+0x02 + N*B*9 N frames × B bones × 9-byte keyframe
- Header byte
+0is the per-frame loop count:FUN_80048A08line 749 reads**(byte **)(consumer + 0x88)as the inner render loop bound. - Header byte
+1is the frame count:FUN_8004AD80line 1367 reads*(byte *)(*(+0x88) + 1)and stamps(byte+1 - 1) * 16into the actor's frame-counter cap.FUN_80047430line 990 divides per-frame velocity by this byte to get the per-frame translation step. - Frame stride is
B * 9and the body starts at offset+2:FUN_8004998Cline 1040 readspbVar15 = pbVar20 + frame_index * B * 9 + 2.
Each per-bone 9-byte block encodes six packed sign-extended 12-bit signed values, laid out 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
The packing pairs adjacent low bytes ([0]/[1], [3]/[4], [6]/[7]) with shared high-nibble bytes ([2], [5], [8]). For each unpacked 12-bit value, if bit 11 (0x800) is set, the consumer ORs 0xF000 to sign-extend (FUN_8004998C lines 1055..1062). The two vectors are treated as runtime angle / pose deltas; their renderer-side semantic is lost in compilation but the byte layout is exact.
Frame counter / sub-frame interpolation
The actor's actor[+0x68] field is a u16 frame counter:
- bits
[4..15](high 12 bits): frame index (used to seek into the per-frame data above). - bits
[0..3](low 4 bits): sub-frame interpolation factor0..=15.
When the sub-frame factor is non-zero, FUN_8004998C lerps each bone component-wise toward the next frame using the formula dst = a + (b - a) * frac >> 4. The "next" frame is one of:
- Frame
+0x85(the loop target) ifframe_index == +0x86 - 1andactor[+0x21B] != 0. - Frame
frame_count - 1ifframe_index == frame_count - 1. frame_index + 1otherwise.
Engine-side accessors
crates/engine-vm::anim_vm exposes the layout as typed views: OpaqueAnimRecord wraps the consumer struct at actor[+0x234], NestedFrameData wraps the buffer pointed at by +0x88 and exposes bones_per_frame / frame_count / frame(i) / bone(f, b) / interpolate(f, next, frac), BoneFrame carries the unpacked vec_a / vec_b triplets and round-trips through from_9_bytes / to_9_bytes, and ActorAnimState exposes the actor-side +0x21D LOD step (lod_step_factor), the previous-action sentinel at +0x21B, and the frame counter at +0x68 with frame_index / sub_frame_factor extractors.
Allocator preamble
When the dispatcher (FUN_8001f05c case 6) loads ANM data, the malloc'd buffer at _DAT_8007B7C8 carries a 16-byte allocator preamble before the payload:
+0x00 back_ptr (RAM ptr - usually base - 0xC or similar)
+0x04 forward_ptr (RAM ptr to next allocation)
+0x08 forward_ptr_2 (RAM ptr - sometimes 0)
+0x0C expanded_size (u32 - payload byte length)
+0x10 -- payload starts here --
crates/anm::peel_preamble strips it; the on-disc form has no preamble.