MDT - move tables (Tactical Arts) Confirmed
The runtime buffer format consumed by FUN_800204F8 (Tactical Arts move-table consumer; the same function the script VM opcode 0x22 EXEC_MOVE invokes). Implementation: crates/mdt.
Overview
The per-frame data inside each MDT record is bytecode for the move VM - see subsystems/move-vm.md for the 71-opcode dispatcher (FUN_80023070) that walks it.
Layout the consumer reads
buf ← _DAT_8007B888 (MOVE) or _DAT_8007B840 (MOVE2)
+0x000 u32 offset_table[1024] ; indexed by (move_id & 0x3FF)
; entry == 0 means "no record for this id"
; otherwise entry is a byte offset into `buf`
at offset_table[id]:
record:
+0x00 u8 reserved
+0x01 u8 flags ; bit 0 = "use frame divisor"
+0x02 u16 max_position_x16 ; clamps the playhead at (this * 16) - 1
+0x04 u16 reserved
+0x06 u8 divisor ; only consulted when flags & 1
+0x07+ per-frame data ; size = max_position_x16 * 16 (approx)
Routing: if actor flag bit 0x01000000 is set, use the alternate base _DAT_8007B75C. Otherwise use MOVE for move_id < 0x400 and MOVE2 for move_id >= 0x400.
The per-frame interpretation in FUN_800204F8 clamps actor[0x68] (current playhead) to [0, max_position_x16 * 16), advances by actor[0x6A] (frame delta) optionally divided by record[6], and reads the per-frame data into the per-actor animation state.
CDNAME mismatch
The CDNAME-named 0972 / 0973 move_program_no.BIN files are flat 128-byte stride record arrays - they don't match the runtime buffer layout above. mdt classify flags this.
crates/mdt parses both layouts and surfaces a verdict (OffsetTableLayout / FlatRecordTable / Unknown).
Caveat: MoveBuffer::parse over-reads past the real table boundary
Real per-scene Move buffers have offset tables shorter than the consumer-facing 1024-entry mask (most use 8-30 ids) and pack record data densely past the real table end. MoveBuffer::parse keeps reading u32s past the real boundary, where record bytes masquerade as offsets. Most of those over-read entries point past the buffer end and get counted as bogus_offsets, so the strict MoveBuffer::fitness() score (used - 2*bogus) is strongly negative for valid retail data (e.g. 0086_map01.BIN: used=1020 bogus=973 → fitness=-926). Use MoveBuffer::looks_like_move_buffer() instead: it requires records.len() > 0 && used > bogus, which 75/79 retail per-scene Move buffers pass while random / non-Move data still fails.
classify()'s OffsetTableLayout verdict also routes through looks_like_move_buffer, so the CLI reports the same shape the engine accepts.
On-disc source - per-scene scene_asset_table slot 4
The MOVE base pointer (_DAT_8007B888) is populated per scene during area transitions, not from a single boot-time PROT entry. Every per-scene CDNAME block's second PROT entry (the slot-1 entry classified as scene_asset_table) carries an Asset(0x05) = Move descriptor - that descriptor's payload is the runtime MOVE table for that scene.
PROT entry at scene_block + 1 ← class = scene_asset_table
u32 count = 7
u32 meta1
7 × (u32 type_size, u32 data_offset) ← descriptor[4].type_byte == 0x05 (Move)
...payload...
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 as MOVE at 0x801355D0 (Suimon-block saves). |
map01 (85) |
0086_map01.BIN |
0x7E30 (32304) |
Loaded as MOVE at 0x8011A624 (every map01-resident save, including the menu and battle states layered on top of map01). |
The meta1 u32 in the scene_asset_table header is the per-scene meta value the loader carries forward. Each descriptor (including desc[4] = Move) is its own independently LZS-compressed stream at data_offset bytes into the bundle entry's extended on-disc footprint (Archive::read_entry), decompressing to exactly size bytes. Several scenes have Move descriptor offsets that fall past the TOC-indexed end and into trailing-overlay sectors - readers must use the extended footprint (or ProtIndex::entry_bytes_extended) rather than Archive::read_entry_indexed. See engine-core::scene_bundle::extract_move_payload for the canonical pattern.
scene_asset_table::move_descriptor exposes the slot lookup as a typed accessor:
let s = legaia_asset::scene_asset_table::detect(&prot_bytes)?;
let move_descriptor = s.move_descriptor()?; // type_byte = 0x05
The MOVE2 (_DAT_8007B840) base is zero across every observed save state, suggesting it's only populated by a small number of scenes that need an alternate move table; the analogous "Move2" descriptor type in scene_scripted_asset_table hasn't been observed in the corpus yet.
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