MDT - move tables (Tactical Arts) Confirmed
When a scene tells an actor to "do move 12" - a villager's wave, a door swinging, a scripted
stumble - the frames come from the scene's move table: a buffer the engine keeps in RAM with one
program per move id. Each program is bytecode for the game's move VM,
a small interpreter dedicated to animation and motion. Every scene ships its own table inside its asset
bundle; there is no single global one. And a naming trap for anyone browsing an extracted disc: the files
literally named move_program_no.BIN are not move tables at all (below).
At a glance
- In the game
- Scripted actor motion in the field - the script VM's
0x22 EXEC_MOVEplays 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_tableentry (the second PROT entry of the scene's CDNAME block), descriptor 4, type byte0x05; loaded to theMOVEbase_DAT_8007B888on area transition - Retail reader
FUN_800204F8(playhead advance), the move-VM dispatcherFUN_80023070for 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
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:
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0x000 | 4 × 1024 | offset_table[] | Indexed by move_id & 0x3FF. 0 = no record; otherwise a byte offset into the buffer |
offset_table[id] | varies | record | One record per used id (below) |
Each record:
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0x00 | 1 | reserved | - |
+0x01 | 1 | flags | Bit 0 = "use frame divisor" |
+0x02 | 2 | max_position_x16 | Clamps the playhead at (this * 16) - 1 |
+0x04 | 2 | reserved | - |
+0x06 | 1 | divisor | Only consulted when flags & 1 |
+0x07 | ~max_position_x16 * 16 | per-frame data | Move-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.
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0x00 | 4 | count | 7 |
+0x04 | 4 | meta1 | Per-scene meta value the loader carries forward |
+0x08 | 7 × 8 | descriptors | (u32 type_size, u32 data_offset) each; descriptor[4].type_byte == 0x05 is the Move payload |
data_offset | size | payload | Independently LZS-compressed stream decompressing to exactly size bytes |
Examples verified by mednafen save-state diff against _DAT_8007B888:
| Scene block | Slot-1 PROT entry | Move size | Notes |
|---|---|---|---|
dolk (60) | 0061_dolk.BIN | 0xE370 (58224) | Loaded as MOVE at 0x800E412C (Drake Castle save) |
suimon (77) | 0078_suimon.BIN | 0x09A0 (2464) | Loaded at 0x801355D0 (Suimon-block saves) |
map01 (85) | 0086_map01.BIN | 0x7E30 (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
| Function | Address | What it proves | Dump |
|---|---|---|---|
| Move-table consumer | FUN_800204F8 | Offset-table indexing, record header fields, playhead clamp / advance, the MOVE / MOVE2 / alternate-base routing | funcs/800204f8.txt |
| Move-VM dispatcher | FUN_80023070 | The per-frame data is bytecode for the 71-opcode move VM | funcs/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 table | mednafen automation |
| MOVE2 base | _DAT_8007B840 | Zero across every observed save state - the alternate table is rare or unused | save-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.