Asset type dispatcher Confirmed
How the game knows what a loaded blob of bytes is. A texture, a 3D model and a page of dialogue all arrive from the disc as anonymous bytes; what tells them apart is a single type byte travelling alongside each one - 0 for a TIM texture, 2 for a TMD mesh, 4 for MES dialogue, and so on. One routine in the executable reads that byte and routes the data to the right format handler, decompressing on the way if needed. Every per-format branch in the engine is reached through it.
At a glance
- Magic
- none - the type byte is the top 8 bits of a
u32packed with the 24-bit size - Where on disc
- every DATA_FIELD chunk header and every asset descriptor carries one
- Dispatcher
FUN_8001F05CinSCUS_942.54- Parser
crates/container-formats/src/lib.rs::AssetType- Confidence
- Confirmed - the dispatcher is dumped in full, and the developers' own malloc-error strings name each type (how we know)
- Used by
- asset loader, LZS decoder (the compressed path)
What a type byte is
Legaia's containers do not label their contents with a per-format magic word the way a PNG or a ZIP does. The container that carries an asset - a streaming chunk or a descriptor table - stores a one-byte code next to its size, and the code alone decides what happens next: which buffer is allocated, whether the bytes are copied or LZS-decompressed, and which subsystem is told "your data has arrived". The code space is a dozen values, fixed in the executable, so the same table serves every scene, battle and menu on the disc.
copy_only argument selects copy versus LZS decode; the caller ORs the returned bits into a "what was in this stream" summary.Calling convention
result = FUN_8001f05c(byte *src_data, u32 type_and_size, int param3, int copy_only);
The second argument is the same little-endian word a DATA_FIELD chunk header stores, so in memory it reads size first, type last:
type_and_sizepacks type in the high 8 bits, size in the low 24 bits.copy_only != 0→ the asset is uncompressed and is copied into its buffer.copy_only == 0→ the asset is LZS-compressed and goes through the LZS decoder, the game's dictionary decompressor.
Type table
The full type space, with the bit each type contributes to the return value. The names are the developers' own: each type has a per-type malloc-error string in the executable.
| Type byte | Name | What it is | Handling | Return |
|---|---|---|---|---|
0x00 | TIM | one texture | 0x11800-byte buffer. TIM | 0x01 |
0x01 | TIM_LIST | a pack of textures | 0x70800-byte buffer. pack | 0x01 |
0x02 | TMD | a pack of meshes | Mesh installer called per submesh. TMD | 0x02 |
0x03 | MAN | scene script + tables | Raw load | 0x04 |
0x04 | MES | dialog text | MES | 0x08 |
0x05 | MOVE | animation bundle | Raw load. Carries the per-scene NPC / prop ANM bundle despite the label | 0x10 |
0x06 | ANM | CLUT-walk table | Not an ANM clip source - the palette-cycling table for water and kingdom scenes | 0x20 |
0x07 | VDF | vertex-deformation pack | Morph-delta pack, post-processed per sub-entry | 0x40 |
0x08 | SIN | - | Raw load | 0x80 |
0x09 | TMD2 | one mesh | Single bare TMD blob, same format as one TMD-pack member; parse with crates/tmd::parse | 0 or asset-error bit |
0x0B | MOVE2 | animation bundle | Raw load with cleanup of the prior buffer | 0x10 (same as MOVE) |
0x0A | FLAG | marker | No malloc, no decompress, no register | 0xA00 |
0x0F | FLAG | marker | As above | 0xF00 |
0x14 | FLAG | marker | As above | 0x1400 |
Type-byte details: the MOVE / ANM label swap, and VDF
0x05 MOVEis where the per-scene NPC / prop ANM bundle lives on disc: a canonical ANM container withmarker_1 = 0x080Crecords (ANM). Its arm allocates with themove_malloc_errstring into_DAT_8007B888, the buffer the clip driverFUN_800204F8binds clips from.0x06 ANMcarries the CLUT-walkMoveImagewalker table despite itsanm_malloc_errlabel: case 6 installs it atDAT_8007B7C8,FUN_80024CFCspawns one walker actor per entry (state0xB), andFUN_8001ADA4case0xBsteps it. Populated in 12 bundles - the 3 kingdoms plus 9 water / waterfall field scenes - and a 4-bytecount = 0placeholder everywhere else. See field ambient FX; parserlegaia_scene_formats::clut_walk::from_scene_bundle.0x07 VDFis installed atDAT_8007B7DCand post-processed byFUN_8001FBCCper sub-entry (pointer table0x80083E58). Populated in 61 scene bundles; parserlegaia_scene_formats::scene_vdf.
Return-value bitfield
Every return is a small bitfield - one bit per data-bearing type, and type << 8 for the markers. Both callers (the streaming walker and the descriptor walker) OR every return into one accumulator, so the result summarises what a whole stream contained:
& 0x00FF)TIM 0x01 · TMD 0x02 · MAN 0x04 · MES 0x08 · MOVE 0x10 · ANM 0x20 · VDF 0x40 · SIN 0x80& 0xFF00)type << 8: 0x0A, 0x0F, 0x14 OR togetherWhy FLAG types exist
The FLAG cases let the dispatcher accept chunks whose type byte falls outside the data-bearing range without aborting the streaming walk. The walker still skips the data bytes (advance = 4 + (size & ~3)), but the dispatcher never reads them. FLAG chunks are stream-level out-of-band markers: any caller that looks at the returned bitfield can detect "this stream contained a 0x14-typed marker" without the marker carrying a parsed asset. Where the markers are consumed is open. It is not the bytes past the streaming terminator: the DATA_FIELD trailer is last-sector fill that nothing reads.
In the port
legaia_container_formats::AssetType mirrors the table one variant per type byte, with Flag(u8) for the three markers and Unknown(u8) for everything else - the same silent no-op the retail dispatcher performs (below).
Who calls the dispatcher
Three call sites exist, and only one is visible from the executable's static call graph. The others are reached from overlays - chunks of MIPS code the game loads into RAM on demand, where most game logic lives. Zero static callers does not mean dead.
| Caller | Address | Reached from |
|---|---|---|
Streaming walker, 0x14 (DATA_FIELD) branch | FUN_8002541C | SCUS_942.54 static call graph |
| Descriptor-pair walker | FUN_80020224 | Town/field overlay: FUN_801D6704 → 0x801D6B0C with a0 = 0; see asset descriptor |
| In-overlay DATA_FIELD walk | 0x801D4DA8 | PROT 0976, the Baka Fighter minigame overlay. It re-implements the same walk, passes copy_only = 1, and discards the return value — so the flag bitfield above is a property of the two SCUS callers only. |
What an unrecognised type byte does
242 of the 256 possible bytes are unrecognised: the seven in-range gaps 0x0C, 0x0D, 0x0E, 0x10–0x13, and everything from 0x15 up. The range check sltiu $v0, $v1, 0x15 sends the high ones to 0x8001F660, and the jump table sends the seven gaps to exactly the same address.
That address is not a default handler — it is the function's return tail. The accumulator register is zeroed on entry and nothing on an unrecognised path writes it, so the call returns 0 having done nothing: no allocation, no decompression, no copy, no registration, and no error raised. It is a silent no-op. A reference sweep of the jump table's own address range across 84 images finds one hit, the instruction that indexes it, so nothing installs a handler into the empty slots at runtime.
How we know
Provenance: the dispatcher, its two decode paths, and the malloc-error strings
| Function | Address | What it proves | Dump |
|---|---|---|---|
| Asset-type dispatcher | FUN_8001F05C | The type table, buffer sizes, copy-vs-decode branch and return bits | ghidra/scripts/funcs/8001f05c.txt |
| memcpy | FUN_8001A8B0 | The copy_only != 0 path | ghidra/scripts/funcs/8001a8b0.txt |
| LZS decoder | FUN_8001A55C | The copy_only == 0 path | ghidra/scripts/funcs/8001a55c.txt |
| Mesh installer | FUN_80026B4C | Called per submesh for TMD, directly for TMD2 | ghidra/scripts/funcs/80026b4c.txt |
| Malloc-error strings | s_Tim_Malloc_Err_800104E8, s_tmd_malloc_err_80010504, … | The developers' own name for each type byte | SCUS_942.54 string table |
Source of record: docs/formats/asset-type.md.