Asset loader
What happens between "walk through a door" and "the next room is on screen". The game gathers everything a scene needs - meshes, textures, palettes, sound banks, scripts - out of PROT.DAT, the disc's single big archive of numbered entries. Battle, field, sound and music each have their own loader, but they all funnel into one dispatcher and one descriptor walk.
At a glance
- Source archive
- PROT.DAT, 1233 entries; names resolve through CDNAME.TXT
- Loaders
- One per scene type - battle, field / town, sound, streams (table below)
- Common router
- The asset-type dispatcher: an 8-bit type byte picks a handler (TIM, TMD, MES, ANM, …)
- Scene change
- Field-VM op
0x3Fcarries a scene name; op0x3Edoor-warps reach only the seven minigames - Engine port
engine-core::SceneResources::build_targeted+legaia_assetparsers (scene_asset_table,scene_tmd_stream,battle_data_pack)- Confidence
- Confirmed - disassembly + corpus round-trips (how we know)
Two traps
Slot → asset mapping is positional: slot i is the i-th descriptor, and the descriptor's own data_offset is the whole indirection - there is no lookup table to find. And descriptor offsets are relative to the entry's whole on-disc footprint, so an offset that looks out-of-bounds usually is not.
How it works
"Load this scene" never means "load one file". A battle needs character meshes, monster meshes, palettes (CLUTs - colour tables stored as rows of framebuffer pixels), a sound bank, effect scripts and dialog text, scattered across dozens of PROT entries. Each scene type has a loader that fetches all of it, and every loader shares one shape:
- A small state machine with one numbered case per asset slot.
- Each case hands one entry's bytes to the asset-type dispatcher.
- Some cases kick off async CD reads so data streams in while the scene initialises.
Each format on the way down has its own page: PROT.DAT (the archive), CDNAME (the name map), LZS (the compression), asset-type + asset-descriptor (the router and its 8-byte records). Function names like FUN_8001F05C are RAM addresses from the disassembly - the game shipped without symbols.
Field / town scene loader
The "walk into a room" path: every town, dungeon and field map goes through it. The scene's name becomes a family of consecutive PROT entries, addressed from the block base:
| Raw offset | File | What it is |
|---|---|---|
+0 | .MAP | Fixed 0x12000-byte collision / trigger map (walkability grid) |
+1 | .PCH | Walk-on trigger patch, exactly one sector (v12 table) |
+2 | prescript | The event-script prescript sister entry |
+3 | .LZS | The scene asset bundle: count word, then typed assets (scene bundles) |
+6 | BGM base | Where scene-local music ids count from (below) |
The loader stages the first three sidecars into a per-scene buffer, sizing the read by summing three consecutive entries' sector counts with the standard PROT TOC math.
Name-based scene change
Doors, stairs, town entry from the overworld and the prologue hand-off all carry a destination name: field-VM opcode 0x3F embeds it inline in the bytecode, so nearly every in-game transition has a recoverable destination. The scene-change routine copies the name into the pending-scene buffer, then spawns a transition streaming actor that streams the destination block's .LZS bundle into the shared asset buffer during the fade, and finally flips the game mode to MAIN INIT. Dev and retail branches converge on the same content: raw scene_base + 3 is the bundle entry, verified across the corpus.
Deep dive: the transition streamer state machine
FUN_8001FD44(name_ptr) copies the target CDNAME string into 0x8007050C, syncs it to the active buffer 0x80084548 (FUN_8001D7F8), and stages the destination index from the resolved halfword at 0x80084540 (retail arm: into DAT_8007B768; dev arm: _DAT_8007B9A0); s_ERR_CHANGE_PACKET guards re-entry while a packet is pending. It then spawns the streaming actor via FUN_80020DE0(&DAT_80070734, _DAT_8007C34C) - descriptor 0x80070734 of the 24-byte spawn-descriptor family at 0x800705FC..0x80070763 (layout [+4 0xFFFF0000][+8 handler][+0xC flags]). The handler is FUN_80021934 (real entry 3 instructions before the 0x80021940 prologue), a 5-state machine over actor+0x1A:
| State | Does |
|---|---|
| 0 | DAT_8007B648 = 0x80; seed the 70-frame fade countdown gp+0x710 = 0x46. |
| 1 / 3 | Poll stream progress via FUN_8003DE7C(1). |
| 2 (dev) | Stream raw TOC entry DAT_8007B768 + 3 into _DAT_8007B85C by index. |
| 4 (retail) | After the countdown: rotate the name buffers (0x80084558 ← 0x80084548 ← 0x800915C8), build the literal path DATA_FIELD\<scene>.LZS, stream it by name, then set _DAT_8007B83C = 2 (mode 2, MAIN INIT). |
Sidecar staging: .MAP at buffer +0, .PCH at +0x12000 (zero-filled sector when absent), efect.dat at +0x12800. Sibling spawn descriptors in the same family: 0x800705FC (intro-skip actor) and 0x8007065C (name-entry setup) - both on the boot page.
Asset descriptor walker
Once the bundle is in RAM, one loop turns it into live assets - and its one surprise is that there is nothing clever in it. The walker reads a count word, then for each slot hands base + data_offset to the dispatcher, which splits the descriptor word into type = word >> 24 and size = word & 0xFF_FFFF and jumps through the handler table.
The walk is also what fills the mesh pool: two dispatcher cases (type 0x02 TMD pack, type 0x09 bare mesh) register meshes into the runtime pointer table in walk order, past the resident 5-mesh head (the party). A magic-byte sweep of a scene block is not equivalent - see scene bundles.
The walker has zero static callers in the main executable; its sole caller lives in the field overlay (code streamed into RAM on demand), at the start of every field session. Engine side: scene_asset_table::resolve handles both the bare bundle and the prescript-prefixed variant, and a disc-gated corpus test verifies the walk against every classified entry.
Door warps into minigames (opcode 0x3E)
The fishing pond, the casino machines, the Baka Fighter booth, the dance floor: stepping onto those is a different kind of transition. The script hands over a small number instead of a name, the game swaps in that minigame's whole code overlay, and remembers where you came from.
- Op
0x3Ewithop0 ≥ 100storessub_id = op0 - 100and flips the game mode to OTHER INIT. - The handler backs up the active scene name and id, loads the per-sub-id minigame overlay, and calls its init entry.
- Only 7 destinations exist (
sub_id0..6): fishing, slot machine, Baka Fighter, dance, plus two dev modules and the monster-roster arena. - The op carries no destination name: on exit the return handler restores the backed-up name, commits casino winnings, and hands off to MAIN INIT, which reloads the restored scene.
Globals, overlay indices and the phantom-warp filter
| Global | Role |
|---|---|
DAT_80084548 | Active scene name string (max 8 chars) |
DAT_80084540 | Current scene PROT base index (short) |
DAT_8007BAE8 | Warp handler's backup of the scene name (8 bytes) |
DAT_8007B768 | Pending destination PROT index; 0xFFFF = none |
DAT_8007BA34 | Pending warp sub_id (0..6); read by FUN_80025980 |
The overlay loader values 0x4D..0x55 (FUN_8003EBE4(sub_id + 0x4D); sub_id ≥ 6 adds 2 first) are raw loader parameters, not extraction indices - they resolve to extraction entries 0972..0980 (prot_index = param + 0x37F; fishing = 0972, slot machine = 0975, Baka Fighter = 0976, dance = 0980). A genuine warp's op0 is always 100..=106; the placement classifier uses that range (plus the absence of the 0x80 cross-context prefix) to reject text-desync phantom warps. The engine's DefaultMapIdResolver approximates ids positionally over CDNAME blocks; retail only ever warps to the 7 minigames.
Full chain and provenance: field VM § 0x3E WARP; the mode-word trap ("0x19 alone cannot tell fishing from dance") is on the boot page. Play them on the minigames page.
Battle bundle
A battle starts from inside a field scene, so its loader (an 11-case state machine) stacks on top of what's already resident: monsters, their sound banks, effect scripts, and the side-band streams that summons and special attacks pull from mid-fight.
| Case | Loads |
|---|---|
| 6 | The befect_data bundle (effect; extraction 0870..0873) |
| 7, 8 | The active monsters' sound banks from monster.snd via a per-monster LBA pair (audio) |
| 0xE | Initialises the runtime effect 2-pack wrapper |
| 0xFF | The side-band streaming handler for summon.dat / readef.DAT (summon-readef) |
The party's own battle meshes are not loaded here: they are assembled per character from the player battle files' equipment sections - see battle-data-pack and character-mesh.
Deep dive: the per-PROT chunk walker (FUN_8001FE70)
One battle-scene state calls FUN_8001FA88(scene_base + slot, 0, dst_buf) then FUN_8001FE70(dst_buf) to walk a scene_tmd_stream entry - a leading TMD body followed by streaming chunks. It differs from the standard streaming walker:
- Chunk 0 header: low 24 bits = TMD body size; round to 32-byte alignment, allocate at
_DAT_8007B864, copy the TMD in. - Loop: advance by
(prev_size & ~3) + 4. Header low-24 zero = terminator. Type0x01→ LoadImage (VRAM upload); type0x02→ explicit terminator; others skipped. - The register call that follows hooks the parsed TMD into the mesh pointer table.
Each entry holds exactly one [TMD][TIM chunks][terminator] sub-stream, then padding to its sector-aligned end. This is the path that uploads the field NPC palettes to VRAM row 479 - plain TIMs in type-0x01 chunks, dispatched only during battle init (npc-palette). scene_tmd_stream::sub_streams mirrors the walk and keeps a post-terminator scan as a regression detector.
History: the "two sub-streams per entry" reading
Some entries were once described as holding a second concatenated sub-stream. That reading is falsified: it was an artifact of a superseded PROT entry-size expression that over-read every entry into its successor - the "second sub-stream" is simply the next PROT entry. See do-not-re-walk.
Filling VRAM: the targeted upload
A PS1 texture is useless without its palette, and Legaia keeps the two in different places: a character's mesh sits in one PROT entry while the palette rows it samples come from another. Retail uploads only the texture bytes the current scene's meshes need, in a fixed order; the port reproduces that policy rather than blasting every TIM into VRAM.
SceneResources::build_targeted parses every TMD in the scene's block, collects the union of CLUT rows and texture-page boxes the meshes sample, then uploads each TIM block only where it will not collide with a referenced palette row. Field and battle differ in scope:
- Battle uploads everything, including the meshes and TIM chunks inside
scene_tmd_streamentries and the player battle files. - Field skips both - those are battle-init residents. Retail field saves carry VRAM row 479 as zeros, which is the boundary the engine matches.
- Two CDNAME blocks (
init_data,player_data) stay resident across field transitions - they carry the field-form party meshes and atlas from PROT 0874. - Two entry classes are never uploaded: v12 trigger sidecars (they are trigger data, not textures) and pochi filler slots (reserved one-sector fills; see pochi).
Deep dive: shared blocks, battle boot pre-load, diagnostics
Field-shared blocks. The field-form character meshes and textures both originate from PROT 0874 (player.lzs, loaded by disc index 0x36C): §0 = the 5-TMD party mesh pack, §1 = effect models, §2 = the field texture pack (Vahn / Noa / Gala atlas pages at texpage (832, 256), CLUTs on row 478). The extraction file named player_data is 0876 - the +2 filename shift - and holds a VAB + SEQ trailer, no meshes.
Battle boot pre-load. build_battle_boot_vram walks the player battle files (extraction 863..866), uploads their standard TIMs and runs the descriptor-driven CLUT pass (battle_data_pack::clut_uploads); retail does the equivalent at boot or first battle so character meshes are resident before the scene-specific chunk walk fires. The result is merged into battle VRAM only, never field VRAM.
Row-479 NPC CLUTs. Town NPC meshes sample CLUT row y=479; the source is the scene's own scene_tmd_stream entries, not the player battle files (byte-match corpus on battle-data-pack).
Diagnostics. asset clut-finder <x> <y> reports every extracted TIM whose CLUT covers a VRAM cell. legaia-engine clut-trace --scene <name> --disc <bin> groups a scene's missing-CLUT prims and finds suppliers across the whole PROT corpus; legaia-engine vram-oracle diffs the rebuilt VRAM against a runtime dump (--diff-png writes a colour-coded 1024×512 image). Parity policy on the engine page.
Music selection (BGM lookup)
When a scene asks for "music track 2016", there is no music table to consult. A global id is an offset into the shared music bank. A scene-local id (below 2000) looks like an offset into the scene's own CDNAME block, but that index only drives the "did the track change" test - the load itself is overwritten with global slot 2.
// FUN_800243F0
if (bgm_id < 2000) {
change_idx = scene_block + 6 + bgm_id; // compared only
load_idx = global_pool_base + 2; // 0x800245A4..0x800245BC
} else {
change_idx = load_idx = global_pool_base + (bgm_id - 2000);
}
Global id 2000+i is sound-test track i, but the music_01 bank is piecewise in extraction space (988+i for i ≤ 67, 990+i for i ≥ 68, gap at 1056/1057) - not a flat base. Resolver: engine-core::music_labels; the full cue list is on music tracks.
Sound and stream loaders
| Loader | Role |
|---|---|
| Sound bank | Loads bse.dat (the battle SFX descriptor bank) at battle-scene setup, then per-scene .dpk files by path (dev builds load by PROT index directly). |
| Streaming assets | Builds paths under the sound\ prefix; the XA / .pac / STR consumer. Same dev / retail split as the field loader. |
Full breakdown in Audio. The offline twin of the whole ladder is legaia-extract: verify → disc → PROT → categorize → extract → TIM → PNG - per-stage invocations on the extraction page, results browsable in the asset viewer.
How we know
| Function | Address | What it proves | Dump |
|---|---|---|---|
| Asset-type dispatcher | FUN_8001F05C | Type byte split (>> 24), 21-entry handler table at 0x80010638. | ghidra/scripts/funcs/8001f05c.txt |
| Descriptor walker | FUN_80020224 | Count word + 8-byte descriptors; positional slots; OR-ed status word. Sole runtime caller in the field overlay's MAIN_INIT. | 80020224.txt |
| Field loader | FUN_8001F7C0 + FUN_800255B8 | Sidecar staging layout; path roots DATA\FIELD\ and h:\PROT\FIELD\. | 8001f7c0.txt |
| Footprint helper | FUN_80020310 | Sums three entries' sector sizes with the TOC gap math (toc[i+3] - toc[i+2]). | 80020310.txt |
| Scene change | FUN_8001FD44 | Name copy, pending-index staging, re-entry guard. | 8001fd44.txt |
| Transition streamer | FUN_80021934 | 5-state fade + stream machine; hands off to mode 2. | 80021934.txt |
| Battle bundle | FUN_800520F0 | 11-case slot machine; monster sound-bank LBA pairs. | 800520f0.txt |
| Chunk walker | FUN_8001FE70 | TMD-then-TIM-chunks layout; type-0x01 LoadImage; terminator rule. | 8001fe70.txt |
| Minigame warp | FUN_80025980 / FUN_80026018 | Scene-name backup / restore; overlay parameter +0x4D; coin-bank commit. | 80025980.txt |
| Mesh register | FUN_80026B4C | Pool stores in walk order with a post-incremented index. | 80026b4c.txt |
| BGM resolver | FUN_800243F0 | The < 2000 scene-local / global split. | 800243f0.txt |
| Corpus tests | - | scene_asset_table_walk_real verifies the walk against every classified entry; field_ground_texture_pages_disc.rs guards the pochi / v12 skips. | crates/asset, crates/engine-core |
Full write-up: docs/subsystems/asset-loader.md.