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. Two of them (scene_tmd_stream, scene_vab_stream) open with
the same 4-byte (type << 24) | size chunk header a DATA_FIELD stream uses, with type = 0;
the standard streaming walker reads that as its TIM slot and produces confident nonsense. Retail's loaders key on
the content magic at offset 4 instead, so identify the shape before picking a walker.
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_scene_formats::{scene_tmd_stream, scene_v12_table, scene_asset_table, scene_event_scripts},legaia_container_formats::scene_vab_stream;legaia_disc_audit::categorizeruns 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
| 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 | 218 |
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, descriptor 0 at 8 + count*8 | 88 (+17 classed lzs_container) |
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.
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
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_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.
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.
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).
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 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 |
(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.
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.
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
| 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.