Scene-prefixed asset bundles Confirmed
When the game loads a town, dungeon or battle it pulls that scene's assets - 3D geometry, textures,
sound banks, scripts - out of PROT.DAT, the disc's single big archive. This page catalogues the container shapes
those per-scene entries use. They all lead with the same 4-byte (type << 24) | size chunk word,
so they look alike; what tells them apart is the content magic at offset 4, and handing one to the wrong walker
crashes.
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 TMD0x80000002,VABp, a descriptor count, au16record count) - Parser
legaia_asset::{scene_tmd_stream, scene_vab_stream, scene_v12_table, scene_asset_table, scene_event_scripts};categorizeruns 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
| Shape | What it carries in the game | Told apart by | Entries |
|---|---|---|---|
scene_tmd_stream | A battle-stage backdrop mesh plus its textures and NPC palettes | Legaia TMD magic at +4 | 182 |
scene_vab_stream | A music track: instrument bank, then the sequence | VABp at +4 | most BGM |
scene_v12_table | The scene's walk-on trigger tiles (dev name .PCH) | Three algebraically tied header words; exactly one sector | 97 |
scene_event_scripts | Move-VM stager records for the scene's ambient props | [u16 count][u16 offsets] at 0 | 101 |
scene_asset_table | The town / dungeon bundle: MAN script, meshes, textures | u32 count of 6 or 7 descriptors | one per scene |
scene_scripted_asset_table | Nothing - a composite retail does not ship | Prescript + table at the next sector | 0 |
tmd_size_prefix | A truncated mesh prefix | Claimed size larger than the entry | 34 |
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.
| Offset | Field | Meaning |
|---|---|---|
+0x00 | u32 chunk0 | (type 0x00 << 24) | tmd_size |
+0x04 | Legaia TMD | magic 0x80000002, flags 0, 1..=64 objects; fills tmd_size bytes |
+0x04 + size | tail chunks | [u32 header][payload] each; type 0x01 = one bare TIM upload, type 0x02 or size 0 = stop, anything else skipped |
| after stop | zero padding | 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. 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
buf.len() >= 32; TMD magic at+4; flags word at+8is 0 (runtime sets it to 1 after pointer fixup);1 <= nobj <= 64.- Chunk0 type byte is 0; its size is 4-aligned, at least
12 + nobj * 28, and4 + size <= buf.len(). - The tail at
4 + sizewalks 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.
| Offset | Field | Meaning |
|---|---|---|
+0x00 | u32 chunk0 | type 0x00, size N (low byte always 0x20: 32-byte aligned) |
+0x04 | VABp | magic 0x56414270 |
+0x08 | version | 7 in retail; gate accepts <= 10, <= 128 programs and tones |
+0x0C.. | VAB body | header tail, programs, tones, VAG offsets (vab) |
+0x04 + N | tail chunks | standard 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.
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0x00 | u16 | N + 4 | directory end offset |
+0x02 | u16 × 2 | 0x0012, 0 | kind-0 offset + count (empty) |
+0x06 | u16 × 2 | 0x0014, param | kind-1 offset + count (0..=192) - the records |
+0x0A | u16 × 2 | N, 0 | kind-2 (empty) |
+0x0E | u16 + u32 | N + 2, 0 | kind-3 (empty) + pad |
+0x14 | param × 4 | records | trigger records, grouped by byte 2 into regions; byte 3 always 0x01 |
… 0x800 | padding | 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).
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).
| Offset | Field | Meaning |
|---|---|---|
+0x00 | u32 count | 6 or 7 descriptors |
+0x04 | u32 meta1 | varies; purpose unknown |
+0x08 | count × (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*8 | payloads | descriptor 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 tuple | Notes |
|---|---|
(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.
| Offset | Field | Meaning |
|---|---|---|
+0x00 | u32 total_size | claimed in-memory size, larger than the entry |
+0x04 | TMD header | magic, flags 0, 1..=8 objects, 28-byte object table |
| after table | primitives | truncated 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.
| Offset | Field | Meaning |
|---|---|---|
+0x00 | u16 count | retail 2..=71 |
+0x02 | u16 offsets[count] | offsets[0] = 2 + count*2, non-decreasing, in-bounds |
+offsets[i] | record | 16-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
| Function | Address | What it proves |
|---|---|---|
| Battle backdrop walker | FUN_8001FE70, caller FUN_800513F0 | Tail 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 dispatcher | FUN_8001F05C / FUN_8002541C | Type 1 = TIM list here - the mis-dispatch trap |
| Scene bundle walker | FUN_80020224 (chain FUN_8001E1B4 → FUN_8001F7C0 → FUN_8001F05C) | Reads count at +0, dispatches base + data_offset per descriptor |
| Mesh registrar | FUN_80026B4C; pool DAT_8007C018; bias DAT_8007B6F8 via FUN_80020F88 | Pool = 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 VM | FUN_800252EC → FUN_80021B04 (tick FUN_80021DF4) → FUN_80023070; bundle at _DAT_8007B8D0 | Prescript records are move-VM stagers, installed by id |
| Field-VM dispatcher | FUN_801DE840, via FUN_8003A1E4 | Field scripts come from the MAN, never the prescript |
| CLUT-walk stepper | FUN_8001ADA4 case 0xB; table at DAT_8007B7C8 | Kingdom slot 5 is the ocean / shoreline animation table |
Dumps: ghidra/scripts/funcs/<addr>.txt. Source of record: docs/formats/scene-bundles.md.