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_asset::{scene_tmd_stream, scene_vab_stream, scene_v12_table, scene_asset_table, scene_event_scripts}; categorize runs the detectors in order
Confidence
Confirmed - each walker is read off its retail consumer; corpus tiling asserted by disc-gated tests
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 +4most BGM
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 of 6 or 7 descriptorsone per scene
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 meta1 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.

OffsetFieldMeaning
+0x00u32 chunk0(type 0x00 << 24) | tmd_size
+0x04Legaia TMDmagic 0x80000002, flags 0, 1..=64 objects; fills tmd_size bytes
+0x04 + sizetail chunks[u32 header][payload] each; type 0x01 = one bare TIM upload, type 0x02 or size 0 = stop, anything else skipped
after stopzero paddingto 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. The engine's field loader uses battle_tim_chunks to skip these battle-only TIMs; the row-479 NPC palettes are not field-resident, matching retail.

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_asset::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_asset::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, about 17% of all entries.

OffsetFieldMeaning
+0x00u32 chunk0type 0x00, size N (low byte always 0x20: 32-byte aligned)
+0x04VABpmagic 0x56414270
+0x08version7 in retail; gate accepts <= 10, <= 128 programs and tones
+0x0C..VAB bodyheader tail, programs, tones, VAG offsets (vab)
+0x04 + Ntail chunksstandard chunks until terminator or end of file - the SEQ sits here at a non-zero offset (seq)

Carriers: 119 of the 123 entries in the CDNAME vab_01 cluster, 53 in sound_data2, 19 in music_01, 14 across monster_data / battle_data, plus scattered hits. 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.

OffsetSizeFieldMeaning
+0x00u16N + 4directory end offset
+0x02u16 × 20x0012, 0kind-0 offset + count (empty)
+0x06u16 × 20x0014, paramkind-1 offset + count (0..=192) - the records
+0x0Au16 × 2N, 0kind-2 (empty)
+0x0Eu16 + u32N + 2, 0kind-3 (empty) + pad
+0x14param × 4recordstrigger records, grouped by byte 2 into regions; byte 3 always 0x01
0x800paddingthe 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).

History: "prescript at +0x800 of the v12 entry"

This page once described the entry as a "scene header + event-script bundle" with the prescript at 0x800. That was the over-read entry size appending the neighbour. See prot and do-not-re-walk.

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).

OffsetFieldMeaning
+0x00u32 count6 or 7 descriptors
+0x04u32 meta1varies; purpose unknown
+0x08count × (u32 type_size, u32 data_offset)type_size = (type << 24) | decompressed_size, the packing the asset-type dispatcher takes directly; data_offset is file-relative
+8 + count*8payloadsdescriptor 0 starts here; later ones 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

The mesh pool is the descriptor walk

The Tmd descriptor is an asset::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. Parser scene_asset_table::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. Coverage: engine-core/tests/v12_bundle_man_disc.rs.

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. Verified against every classified entry by scene_asset_table_walk_real; the runtime mesh pool by scene_mesh_pool_walk_disc.

History: a strict count == 7 detector missed the count-6 towns, 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 stays, pinned at zero matches by extract/tests/validation_suite.rs, so a reader regression fails a test instead of resurrecting the phantom.

History: the composite that ~6% of entries once matched

Every historical match was an over-read: the "table at the next 0x800 boundary" was the next PROT entry's ordinary offset-0 table seen through a window that ran past the entry (prot). The detector's gate: u16[0] count in 1..=4096, offsets[0] = 2 + count*2, monotonic in-bounds offsets, and a valid count-7 table at the next 0x800-aligned position.

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.

OffsetFieldMeaning
+0x00u32 total_sizeclaimed in-memory size, larger than the entry
+0x04TMD headermagic, flags 0, 1..=8 objects, 28-byte object table
after tableprimitivestruncated 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.

OffsetFieldMeaning
+0x00u16 countretail 2..=71
+0x02u16 offsets[count]offsets[0] = 2 + count*2, non-decreasing, in-bounds
+offsets[i]record16-bit-word-aligned move-VM stager: [i16 model_sel][u16 flags][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_asset::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. Pinned by scene_event_records_word_aligned_real, prescript_move_stager_records_real (78 entries / 1855 records, all valid stager leads) and the consumer census scene_prescript_consumer_census_disc.rs; 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_8001E1B4FUN_8001F7C0FUN_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_800252ECFUN_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