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:

  1. Calls FUN_80020DE0 (actor allocator).
  2. Reads the per-record offset from the ANM payload at _DAT_8007B7C8 + (id*4) + 4.
  3. Stores (anm_base + record_offset) in actor[+0x4C] (the per-actor anim record pointer).
  4. Writes 0xB to actor[+0x56] (animation state byte) and 100 to actor[+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):

  1. Guard: reads actor[+0x5C]; skips the whole handler if ≤ 0.
  2. Calls FUN_800204f8 (a0 = actor) - actor advance / move tick.
  3. Loads s6 = actor[+0x4C] - the ANM record pointer.
  4. Calls FUN_80056798 (BIOS vector 0xa0/0x2F) while advancing the

field VM PC by 2 in the delay slot.

  1. The 40-byte body at 801e2630..801e2670 uses s6 and 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.

OffsetLengthPurpose
+0x1D816 BPer-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).
+0x1F418 BPer-frame anim flag accumulator. Pre-anim values are zero; mid-anim transitions to a stamped run of 0x11 bytes once the action engages.
+0x23416 B4-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 byteBehaviour
0x02Handshake. 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.
0x04Action engaged. If actor's anim-flag at +0x14C is zero, set actor[+0x1DA] = 7. Else copy actor[+0x1F2] into +0x1DA.
0x05OR actor[+0x1DC] |= 4.
0x07Set actor[+0x1DA] = 8.
0x08OR actor[+0x1DC] |= 8.
otherNo 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]:

OffsetTypePurpose
+0x00u8 kindKind byte (see table above).
+0x0Ei16Movement-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).
+0x56u16Sub-state counter; ticked during 0x02 -> 0x04 transition.
+0x76 / +0x77 / +0x78u8Flag / adjust / multiplier bytes.
+0x84u8Max-frame byte; consumer stamps actor[+0x21B] = +0x84 (previous-action sentinel) and actor[+0x176] = +0x84 << 4 (frame-counter cap).
+0x85u8Loop-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.
+0x86u8Loop-trigger frame index (the frame at which the loop-target lookup kicks in).
+0x87u8Special-effect ID; non-zero values dispatched to FUN_8004E13C.
+0x88ptrPointer to nested per-frame data array. See Nested per-frame data below.
+0x172 / +0x176u16Counter 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 +0 is the per-frame loop count: FUN_80048A08 line 749 reads **(byte **)(consumer + 0x88) as the inner render loop bound.
  • Header byte +1 is the frame count: FUN_8004AD80 line 1367 reads *(byte *)(*(+0x88) + 1) and stamps (byte+1 - 1) * 16 into the actor's frame-counter cap. FUN_80047430 line 990 divides per-frame velocity by this byte to get the per-frame translation step.
  • Frame stride is B * 9 and the body starts at offset +2: FUN_8004998C line 1040 reads pbVar15 = 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 factor 0..=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) if frame_index == +0x86 - 1 and actor[+0x21B] != 0.
  • Frame frame_count - 1 if frame_index == frame_count - 1.
  • frame_index + 1 otherwise.

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.

See also