ANM animation container Confirmed
Asset type 0x06 from the asset-type dispatcher. Implementation: crates/anm.
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%)
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 today.
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 (currently 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 in the overlay dump but not extracted in the current function-coverage pass.
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. The full handler body requires a targeted dump of 0x801e2630..0x801e2670 within overlay_0897.bin.
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.