At a glance

Where
The PROT entries every CDNAME scene block seats: [.MAP][.PCH trigger sidecar][prescript][asset bundle] for towns; battle stages as bare-TMD streams; BGM as VAB streams
Magic
None shared - each shape is told apart by the content at +4 (Legaia TMD 0x80000002, VABp, a descriptor count, a u16 record count)
Parser
legaia_scene_formats::{scene_tmd_stream, scene_v12_table, scene_asset_table, scene_event_scripts}, legaia_container_formats::scene_vab_stream; legaia_disc_audit::categorize runs the detectors in order
Confidence
Confirmed - each walker is read off its retail consumer; corpus tiling checked against every entry
Used by
Asset loader, Battle scene, Field scene viewer
ShapeWhat it carries in the gameTold apart byEntries
scene_tmd_streamA battle-stage backdrop mesh plus its textures and NPC palettesLegaia TMD magic at +4182
scene_vab_streamA music track: instrument bank, then the sequenceVABp at +4218
scene_v12_tableThe scene's walk-on trigger tiles (dev name .PCH)Three algebraically tied header words; exactly one sector97
scene_event_scriptsMove-VM stager records for the scene's ambient props[u16 count][u16 offsets] at 0101
scene_asset_tableThe town / dungeon bundle: MAN script, meshes, texturesu32 count, descriptor 0 at 8 + count*888 (+17 classed lzs_container)
scene_scripted_asset_tableNothing - a composite retail does not shipPrescript + table at the next sector0
tmd_size_prefixA truncated mesh prefixClaimed size larger than the entry34
scene block .MAP (0x12000) .PCH (1 sector) prescript scene_asset_table bundle bundle count total size count x (type|size, offset) LZS payloads: TIMs, TMD pack, MAN, MES, ... +0 +4 +8, 8 bytes each +8 + count*8
A town's scene block is four consecutive PROT entries; the bundle at the end is a count-prefixed descriptor table over LZS-compressed payloads.

scene_tmd_stream - bare-TMD prefix

The battle-stage layout: a 3D mesh up front, textures behind it. TMD is the PlayStation's model format (Legaia's own variant, see tmd); TIM is its texture format. The battle loader's custom walker reads it - not the standard streaming dispatcher, even though the chunk-header packing is identical.

+0x00chunk0 = (0 << 24) | tmd_sizeu32
+0x04Legaia TMD (magic 0x80000002, 1..64 objects)tmd_size
+0x04+sizetail chunks: [hdr][TIM]…type 1 = TIM
stop (type 2 / size 0), zero pad

Each tail chunk is [u32 header][payload]: type 0x01 uploads one bare TIM, type 0x02 or size 0 stops, anything else is skipped. Zero padding runs to the entry's sector-aligned end.

An entry holds exactly one stream: the walker returns past the terminator and its single caller (battle init) calls it once. The type-0x01 meaning differs from the standard dispatcher, where 0x01 is a TIM list - the crash trap in the lede. These TIMs (including the row-479 NPC palettes) are battle-only; a field loader skips them.

The leading TMD is a half shell, drawn twice

Rendered raw, one of these meshes looks like half a bowl - sky dome, mountain ring and far ground cut along a plane through the origin. That is what the entry authors: battle init registers the TMD once but links two background actors to it, the second under a per-stage transform that closes the circle. Object 0 is the shell; object 1 never draws. In all 182 entries the open side is -X (129), -Z (49) or +X (4) - never +Z, where the party sits. Placement lives in legaia_scene_formats::battle_backdrop (battle); scene_tmd_stream::shell_shape returns the object-0 box plus its open side.

Detection rules and reading code
  1. buf.len() >= 32; TMD magic at +4; flags word at +8 is 0 (runtime sets it to 1 after pointer fixup); 1 <= nobj <= 64.
  2. Chunk0 type byte is 0; its size is 4-aligned, at least 12 + nobj * 28, and 4 + size <= buf.len().
  3. The tail at 4 + size walks at least one valid chunk header or a terminator.
use legaia_scene_formats::scene_tmd_stream;
if let Some(s) = scene_tmd_stream::detect(&buf) {
    let tmd = legaia_tmd::parse(&buf[s.tmd_range()])?;  // bare TMD, no wrapper
    for chunk in &s.tail_chunks { /* (type, size, payload) */ }
}
for c in scene_tmd_stream::battle_tim_chunks(&buf) {
    // c.payload_offset = inner PSX TIM magic offset; c.source is Tail for every retail entry
}

sub_streams returns exactly one block per entry; both it and battle_tim_chunks keep a post-terminator scan as a regression detector for a buffer that spans more than one entry (WalkSource::Continuation).

History: an earlier reading described "concatenated sub-streams" with 0006_town01 carrying a second stream at 0x14000. That was the over-reading entry-size expression; the "second stream" is PROT entry 0007. See do-not-re-walk.

scene_vab_stream - VAB-prefix

Same outer wrapper, but the leading chunk is a Sony VAB (a sound bank: instrument programs plus sample data) and the tail chunks are standard DATA_FIELD chunks - usually the SEQ that plays on the bank. The single largest VAB carrier on the disc: 218 of the 1233 entries.

+0x00chunk0 = (0 << 24) | Nu32
+0x04VABpu32
+0x08version (7)u32
+0x0Cheader tail, programs, tones, VAG offsets
+0x04+NDATA_FIELD chunks: VAG bodies, SEQ…

The chunk-0 size's low byte is always 0x20 (32-byte aligned). The gate accepts version <= 10 and <= 128 programs and tones. The bank is carried as two chunks - the header part up front, the VAG sample bodies in a later chunk - and the SEQ sits in a tail chunk at a non-zero offset; six entries put the SEQ before the body chunk (vab, seq).

Carriers: 119 of the 123 entries in the CDNAME vab_01 cluster, 77 in music_01, 11 in sound_data2, and a few pairs and singles elsewhere (teien, monster_test, other1, battle_data, …). Read with scene_vab_stream::detect, then legaia_vab::parse_header(buf, s.vab_range().start).

scene_v12_table - the .PCH walk-on trigger sidecar

When you step onto a doorway tile, something has to know that tile is special. This one-sector entry is that table: a four-kind sub-table directory whose only populated kind holds the scene's walk-on trigger records. One per scene, 97 on the disc; full field semantics on scene-v12-table.

+0x00N+4u16
+0x02kind 0: 0x12, 04 B
+0x06kind 1: 0x14, param4 B
+0x0Akind 2: N, 04 B
+0x0Ekind 3: N+2, 0, pad6 B
+0x14param × 4-byte trigger records
pad to 0x800

Only kind 1 is populated: param (0..=192) four-byte records, grouped by byte 2 into regions, byte 3 always 0x01. The entry ends at 0x800; the prescript that follows is the next PROT entry.

The three tied words (N = 4 * param + 22) make the detector exact: 97 hits, zero false positives. The scene's real field-VM scripts are not here - they live in the scene MAN (script VM).

scene_asset_table - the town / dungeon bundle

The bundle the field loader reads when entering a scene: a count-prefixed descriptor table, then one LZS-compressed payload per descriptor (LZS is Legaia's compression, see lzs).

+0x00countu32
+0x04total decompressed sizeu32
+0x08descriptor 0: (type<<24 | size), offset8 B
descriptors 1..count-18 each
+8+count×8LZS payloads

type_size = (type << 24) | decompressed_size is the packing the asset-type dispatcher takes directly; data_offset is file-relative. Descriptor 0's payload starts right after the table, and later payloads often run past the TOC-indexed end into the trailing sectors.

Slot i is the i-th descriptor; there is no separate indirection table. The runtime walker reads count, then dispatches each payload by type_size >> 24 (chain pinned under asset loader). The seven canonical kinds: TimList (textures), Tmd (meshes), Man (the scene's map data - encounter tables, actor placements, field scripts), Mes (dialog), Move (animation programs), Anm, Vdf.

Type tupleNotes
(1, 2, 3, 4, 5, 6, 7)Standard count-7 bundle
(1, 3, 4, 5, 6, 7, 0x14)Skips Tmd; trailing Flag(0x14) sentinel
(2, 3, 4, 5, 6, 7, 0x14)Skips TimList
(10, 2, 3, 4, 5, 6, 7)Leading Flag(0xA) sentinel
(1, 2, 3, 4, 6, 7, 0x14)Skips Move
(2, 3, 5, 6, 7, 0x14)count-6 early town (town01): MAN at index 1
(10, 2, 3, 5, 6, 7)count-6 early town (town0c): MAN at index 2
(1, 3, 5, 6, 0x14)count-5 (bubu1, edbubu): the canonical seven minus Tmd and Vdf, MAN at index 1

Retail bounds count nowhere - the walker reads it and loops that many descriptors, so any bound in a reader is a heuristic. The strong signal is the anchor: descriptor 0's offset equals 8 + count × 8. Two scenes, bubu1 and edbubu, ship a count-5 table that carries a MAN; a 6-or-7-only reader finds no MAN for them. Thirteen more short tables (the MAN-less count-4 dungeon form, e.g. dolk2, rikuroa, taiku) walk exactly like the long ones. One detail is easy to lose in the decompiled C: the payload pointer is formed in the jal's delay slot.

+0x04 is the bundle's total decompressed size

The header's second word equals sum(descriptor[i].size) exactly - each size being that descriptor's decompressed byte count - so it records how many bytes the bundle unpacks to. The identity holds in every table of this family on the disc: the 88 classed scene_asset_table plus the 17 classed lzs_container (the count-4/5 MAN-less dungeon form and the character / effect containers), 105 of 105. It exceeds the carrying entry's byte length in all 105, which rules out the "file size" and "sector count" readings.

Retail never reads it: the walker takes count from +0x00 and steps descriptors from +0x08, and a sweep of every dumped function for a load off the table base finds reads at offset 0x0 only. So it is an authoring total - a consistency check an editor must maintain (SceneAssetTable::total_size_is_consistent), not something the game enforces.

The bytes after the last stream are not the bundle's

A bundle's content ends where its last LZS stream stops being consumed, inside the entry's last sector. The non-zero bytes above that look like data but are an earlier PROT entry's bytes at the same file offsets: the packer wrote every entry out of one buffer, in TOC order, without clearing it. That prediction reproduces the slack of every bundle byte for byte. An editor can write zeros there, and a parser should never read it (byte accounting).

The FLAG slot is the pochi fill file

A type-0x14 FLAG descriptor declares 1927 bytes, and the 239-byte stream behind it decompresses to the pochi fill file - byte-identical to the fill in every filler slot. It is always the table's last descriptor (28 of the 105 count-prefixed tables carry one), it is not a count-4/5 marker, and the dispatcher answers 0x14 without touching the payload, so it is never decompressed at runtime. Three of the four leading Flag(0x0A) descriptors are that file again.

One FLAG descriptor is data-bearing. urudre1's descriptor 0 is typed 0x0A but declares 343,480 bytes, and its stream really does unpack to that length - opening with a pack header of 85 members, in the slot its sibling count-7 bundles give their texture list. The dispatcher answers 0x0A the same way it answers the fill, so the descriptor walk never unpacks it - and it is a byte-identical second copy of the live texture pack that ships as the next entry, 0456, which the loader streams instead.

A Flag descriptor streams an extra file

Types 0x0A / 0x0F / 0x14 allocate nothing: the dispatcher returns (case << 8) and exits. The walker ORs every descriptor's return into one status word, and the field init shifts that right by 8 and passes it to FUN_8002541C, which loads one more file by that mode - 0x0A = tim.dat, 0x0F = move.mdt, 0x14 = <scene>.pac. So a Flag in the descriptor list is a request to stream the block's +4 entry, and the corpus matches block by block: 28 blocks with Flag(0x14) hold a DATA_FIELD stream there, 4 with Flag(0x0A) hold a bare pack, and 64 with no Flag reserve the slot with a pochi filler. Details: field-pack.

The mesh pool is the descriptor walk

The Tmd descriptor is an container-formats::pack of every static mesh in the scene (town01: 121 meshes). The runtime mesh pool gains one slot per pack member, in pack order, after the five resident party / save-point meshes - Rim Elm is 5 + 114 = 119 slots. Placement of those meshes comes from the field map object table (field locomotion). A byte sweep for TMD magic is not a substitute: a scene block's bytes carry meshes the walk never registers. A Flag(0x14) table's meshes also include its streamed .pac: the mode-0x14 stream registers any TMD-pack chunk's members behind the table's own, and ten scenes with no mesh slot (e.g. chitei2, balden, bubu1) get their whole environment that way. Parser scene_asset_table::mesh_pool / pac_mesh_pool.

Extended footprint, v12-embedded tables, and the kingdom bundles

Extended footprint. Descriptor offsets address the bundle's on-disc footprint including the trailing sectors the per-PROT TOC crops off, so Scene::load fetches bundle and lzs_container entries with ProtIndex::entry_bytes_extended. The lzs_container case is the dungeon shape: rikuroa ships its environment pack as a standalone container (extraction 156, 77 TMDs) whose streams start inside the TOC window and run into the trailing sectors.

v12-embedded tables. Some dungeon scenes carry a whole asset table inside their scene_v12_table entry at 0x1000. find_bundle scans a v12 entry's 0x800-aligned offsets for the first table with a MAN descriptor (BundleSource::V12Embedded). The v12-family dungeons rikuroa and dolk2 have no MAN-bearing bundle at all: their scene MAN is the type-3 chunk of the block's standalone data_field_streaming entry (extraction 157 / 70), resolved by field_man_payload.

Kingdom bundles (PROT 0086 / 0245 / 0392). Slot 5 (type 6) is not a field-actor ANM pack but the CLUT-walk animation table: a 516-byte LZS table, identical across the three kingdoms, of eight 16×1 walker entries (ocean head + shoreline shimmer) - world map, parser clut_walk. Their slot-0 TIM list also interleaves non-TIM raw CLUT-block records ([u32, u32] prefix + a bare CLUT, no 0x10 magic) - the walk's parked strips at VRAM rows 498..505 (clut_walk::park_strips).

Reading code. scene_asset_table::resolve(buf) returns the table plus its base; slots() reproduces the positional walk and payload_range(slot, base) resolves a slot's span.

History: a strict count == 7 detector missed the count-6 towns, and its {6, 7} successor missed the two count-5 scenes, producing the "no MAN in the static bundle" reading; a runtime write-watchpoint on the MAN buffer settled it. The "v12 entry embeds the MAN at 0x1000" reading for rikuroa/dolk2 decoded the next block through the over-read; see scene-v12-table.

scene_scripted_asset_table - a shape retail does not have

A composite pairing a prescript at offset 0 with a 7-asset table at the next sector boundary. It matches no retail entry: the real layout seats the prescript and the bundle as separate entries. The detector is kept only as a guard that must match nothing.

tmd_size_prefix - truncated TMD-prefix

Sister of scene_tmd_stream for the truncated case: same header, but the claimed total size is larger than the on-disc payload. All object pointers fall inside the claimed size, so the file is a genuine prefix of a larger resource, not a broken header. 34 entries, all 12 KB (6 sectors), 2 or 4 objects each.

+0x00total_size (> entry)u32
+0x04TMD header, 1..=8 objects
28-byte object table
primitives, cut at the sector boundary

Confidence: the shape is Confirmed, the consumer Unknown - the loader probably allocates total_size and either zero-fills the tail or streams the rest from another entry.

scene_event_scripts - the prescript

The entry a scene block seats between its .PCH sidecar and its bundle: a [u16 count][u16 offsets[count]] table whose records stage the scene's ambient props - a swaying sign, a spinning windmill - for the move VM. 101 entries, 100 of them at slot 2 of a CDNAME block.

+0x00count (2..=71)u16
+0x02offsets[count]u16 each
2+count×2record 0
records 1..

offsets[0] = 2 + count*2, non-decreasing, in-bounds. Each record is a 16-bit-word-aligned move-VM stager: [i16 model_sel][u16 reserved][bytecode]; most open 0xFFFF 0x0000 (a transform node) and end with the 0x0008 Halt word.

These records are not field-VM bytecode, however natural that looks. They are the per-scene sibling of the per-summon stagers (legaia_overlay_images::summon_overlay): same record format, same consumer. Record 0 on towns is the scene's master ambient stager - a fixed 768-byte run of 8-byte spawn rows. The scene's field-VM scripts live in the MAN.

The runtime chain: a MAN script installs a record by id through the stager installer, which reads base + 2 + id*2 from the bundle relocated to RAM; the part-stager attaches it to an actor; the move VM runs record + 4 each frame. Walking every scene MAN for the install op shows this is the bundle's only consumer: partition-1 effect actors install record 0 on entry, partition-2 cutscene timelines install per-shot ids.

Detection tiers and evidence

detect (the read every consumer uses) requires the table shape with count >= 3 and at least 45% of records opening with the 0xFFFF 0x0000 sentinel. detect_structural is the format's true boundary: count >= 2, word-aligned offsets, non-empty last record - 101 hits, zero false positives. At count == 1 the anchor collapses to "the second u16 is 4", byte-identical to a bse_bank header, so the floor is 2. Twenty-three carriers sit under the rate floor (geremi, tunnela, tunnelb, edson carry no transform node at all), which is why the categorizer runs the structural tier last, after bse_bank.

Running the field-VM disassembler over these records yields a 65-88% decode-error rate; the words are 16-bit aligned with the high byte zero on about 83% of body words, and the opcodes sit below the field VM's 0x22 floor. Parser scene_event_scripts::move_stager_records, scanner man_field_scripts::scene_stager_installs.

How we know

FunctionAddressWhat it proves
Battle backdrop walkerFUN_8001FE70, caller FUN_800513F0Tail chunk dispatch (type 1 = single TIM via FUN_800198E0, type 2 / size 0 = stop); called once per entry; two background actors on one TMD
Standard streaming dispatcherFUN_8001F05C / FUN_8002541CType 1 = TIM list here - the mis-dispatch trap
Scene bundle walkerFUN_80020224 (chain FUN_8001E1B4 → FUN_8001F7C0 → FUN_8001F05C)Reads count at +0, dispatches base + data_offset per descriptor
Mesh registrarFUN_80026B4C; pool DAT_8007C018; bias DAT_8007B6F8 via FUN_80020F88Pool = pack members in order after the five resident meshes; magic checked, not gated
MAN buffer watchpoint_DAT_8007B898 (autorun_man_source.lua)count-6 town bundles carry the MAN at descriptor 1 / 2
Stager installer / part stager / move VMFUN_800252EC → FUN_80021B04 (tick FUN_80021DF4) → FUN_80023070; bundle at _DAT_8007B8D0Prescript records are move-VM stagers, installed by id
Field-VM dispatcherFUN_801DE840, via FUN_8003A1E4Field scripts come from the MAN, never the prescript
CLUT-walk stepperFUN_8001ADA4 case 0xB; table at DAT_8007B7C8Kingdom slot 5 is the ocean / shoreline animation table

Dumps: ghidra/scripts/funcs/<addr>.txt. Source of record: docs/formats/scene-bundles.md.

See also