At a glance

In the game
Scripted actor motion in the field - the script VM's 0x22 EXEC_MOVE plays one of these programs on an actor
Magic / marker
None - a 1024-entry offset table followed by records
Lives in
Each scene's scene_asset_table entry (the second PROT entry of the scene's CDNAME block), descriptor 4, type byte 0x05; loaded to the MOVE base _DAT_8007B888 on area transition
Retail reader
FUN_800204F8 (playhead advance), the move-VM dispatcher FUN_80023070 for the per-frame bytecode
Parser
crates/mdt (MoveBuffer), legaia_asset::scene_asset_table::move_descriptor, engine-core::scene_bundle::extract_move_payload
Confidence
Confirmed - consumer disassembly for the layout; per-scene source verified by mednafen save-state diff against _DAT_8007B888

How a move is found

1Script asks for a moveThe field VM's 0x22 EXEC_MOVE hands an actor a move_id.script VM 2Pick a bufferActor flag 0x01000000 set → alternate base _DAT_8007B75C; else MOVE for ids < 0x400, MOVE2 for >= 0x400.FUN_800204F8 3Offset tableoffset_table[move_id & 0x3FF]; zero means "no record for this id".crates/mdt 4Record headerClamp the playhead, pick the frame divisor, find the per-frame data.layout 5Run the bytecodeThe per-frame data is move-VM bytecode walked by the 71-opcode dispatcher.move VM

Layout the consumer reads

The buffer starts with a fixed-size offset table indexed by the low 10 bits of the move id:

OffsetSizeFieldMeaning
+0x0004 × 1024offset_table[]Indexed by move_id & 0x3FF. 0 = no record; otherwise a byte offset into the buffer
offset_table[id]variesrecordOne record per used id (below)

Each record:

OffsetSizeFieldMeaning
+0x001reserved-
+0x011flagsBit 0 = "use frame divisor"
+0x022max_position_x16Clamps the playhead at (this * 16) - 1
+0x042reserved-
+0x061divisorOnly consulted when flags & 1
+0x07~max_position_x16 * 16per-frame dataMove-VM bytecode

FUN_800204F8 clamps actor[0x68] (the playhead) to [0, max_position_x16 * 16), advances it by actor[0x6A] (frame delta) optionally divided by record[6], and reads the per-frame data into the per-actor animation state. The same header shape drives ANM clip playback - the two formats share the frame-cursor machinery.

On-disc source - per-scene scene_asset_table slot 4

(PROT.DAT is the disc's single big archive; CDNAME is the name map that groups consecutive entries into per-scene blocks.) The MOVE base is populated per scene during area transitions, not from a boot-time entry. Every scene block's second PROT entry - the one classified scene_asset_table - carries an Asset(0x05) = Move descriptor whose payload is that scene's runtime table.

OffsetSizeFieldMeaning
+0x004count7
+0x044meta1Per-scene meta value the loader carries forward
+0x087 × 8descriptors(u32 type_size, u32 data_offset) each; descriptor[4].type_byte == 0x05 is the Move payload
data_offsetsizepayloadIndependently LZS-compressed stream decompressing to exactly size bytes

Examples verified by mednafen save-state diff against _DAT_8007B888:

Scene blockSlot-1 PROT entryMove sizeNotes
dolk (60)0061_dolk.BIN0xE370 (58224)Loaded as MOVE at 0x800E412C (Drake Castle save)
suimon (77)0078_suimon.BIN0x09A0 (2464)Loaded at 0x801355D0 (Suimon-block saves)
map01 (85)0086_map01.BIN0x7E30 (32304)Loaded at 0x8011A624 (every map01-resident save, incl. menu and battle states layered on it)

The descriptor's data_offset is measured into the bundle entry's extended on-disc footprint (Archive::read_entry). Several scenes' Move offsets fall past the TOC-indexed end into trailing sectors, so readers must use the extended footprint (ProtIndex::entry_bytes_extended) rather than Archive::read_entry_indexed; engine-core::scene_bundle::extract_move_payload is the canonical pattern, and scene_asset_table::move_descriptor the typed slot accessor:

let s = legaia_asset::scene_asset_table::detect(&prot_bytes)?;
let move_descriptor = s.move_descriptor()?; // type_byte = 0x05

The MOVE2 base (_DAT_8007B840) is zero across every observed save state, suggesting only a few scenes populate an alternate table; the analogous "Move2" descriptor type in scene_scripted_asset_table has not been observed in the corpus.

The move_program_no name

The extraction files named 0972 / 0973 move_program_no.BIN are not move tables. Under the +2 CDNAME numbering shift they sit in the other_game block: 0972 is the fishing minigame overlay (dev other1) and 0973 the 1-sector OTHER2 dev module. The move_program_no define actually covers extraction 0970..0971 - a \DATA\MOV*.STR FMV program / path table (the block names MOVie program numbers; see the STR FMV table), not Tactical-Arts moves. mdt classify correctly reports that neither file matches the runtime layout above.

History: the "flat 128-byte record array" reading

Before the numbering shift was understood, 0972 / 0973 were parsed as a flat 128-byte record array of move data; that was a loose parse of overlay code and data. crates/mdt still surfaces both verdicts (OffsetTableLayout / FlatRecordTable / Unknown) so the CLI can say which shape a file has. Other dissolved readings: do-not-re-walk.

Caveat: MoveBuffer::parse over-reads the offset table

Real per-scene buffers use far fewer than 1024 ids (most 8-30) and pack record data densely right after the real table end, so MoveBuffer::parse keeps reading record bytes as offsets. Most of those point past the buffer and are counted as bogus_offsets, which makes the strict fitness() score (used - 2*bogus) strongly negative for valid retail data. Use MoveBuffer::looks_like_move_buffer() instead - it requires records.len() > 0 && used > bogus, which real per-scene buffers pass while random data fails - and classify()'s OffsetTableLayout verdict routes through it, so the CLI reports the same shape the engine accepts.

How we know

FunctionAddressWhat it provesDump
Move-table consumerFUN_800204F8Offset-table indexing, record header fields, playhead clamp / advance, the MOVE / MOVE2 / alternate-base routingfuncs/800204f8.txt
Move-VM dispatcherFUN_80023070The per-frame data is bytecode for the 71-opcode move VMfuncs/80023070.txt
Per-scene source_DAT_8007B888 diffs (dolk / suimon / map01 saves)The MOVE base is refilled per scene from slot-4 of the scene's asset tablemednafen automation
MOVE2 base_DAT_8007B840Zero across every observed save state - the alternate table is rare or unusedsave-state corpus

CLI

mdt classify <PATH>                        # which layout?
mdt records  <PATH> --limit 8              # decode as flat record table
mdt slots    <PATH> --limit 8              # decode as offset-table layout

Source of record: docs/formats/mdt.md.

See also