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 0x3F carries a scene name; op 0x3E door-warps reach only the seven minigames
In the port
engine-scene::SceneResources::build_targeted + the format parsers (legaia_scene_formats::{scene_asset_table, scene_tmd_stream}, legaia_actor_formats::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.
scene name"town01" CDNAME block: consecutive PROT entries +0 .MAPwalk + trigger grid +1 .PCHwalk-on triggers +2 prescriptevent scripts +3 .LZS bundlethe scene's assets asset bufferstreamed during the fade descriptor walkslot i = i-th descriptor LZS decodeone stream per slot type dispatchertype = word >> 24 TIMupload to VRAM TMDregister in mesh pool MES / ANM / MANRAM tables, scripts
A field scene load. The sidecars (+0..+2) are staged into a per-scene buffer; the bundle at +3 is what the walk turns into live assets. A battle stacks its own loader on top of what is already resident, through the same dispatcher.

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 offsetFileWhat it is
+0.MAPFixed 0x12000-byte collision / trigger map (walkability grid)
+1.PCHWalk-on trigger patch, exactly one sector (v12 table)
+2prescriptThe event-script prescript sister entry
+3.LZSThe scene asset bundle: count word, then typed assets (scene bundles)
+6BGM baseWhere 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:

StateDoes
0DAT_8007B648 = 0x80; seed the 70-frame fade countdown gp+0x710 = 0x46.
1 / 3Poll 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. The port’s reader is legaia_scene_formats::scene_asset_table, checked against every classified entry on the disc.

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 0x3E with op0 ≥ 100 stores sub_id = op0 - 100 and 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_id 0..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
GlobalRole
DAT_80084548Active scene name string (max 8 chars)
DAT_80084540Current scene PROT base index (short)
DAT_8007BAE8Warp handler's backup of the scene name (8 bytes)
DAT_8007B768Pending destination PROT index; 0xFFFF = none
DAT_8007BA34Pending 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.

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.

CaseLoads
6The befect_data bundle (effect; extraction 0870..0873)
7, 8The active monsters' sound banks from monster.snd via a per-monster LBA pair (audio)
0xEInitialises the runtime effect 2-pack wrapper
0xFFThe 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. Type 0x01 → LoadImage (VRAM upload); type 0x02 → 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, and the port reproduces that policy rather than blasting every TIM into VRAM: it collects the palette rows and texture-page boxes the scene's meshes sample, then uploads each TIM only where it will not collide with a referenced palette row (SceneResources::build_targeted). Field and battle differ in scope:

  • Battle uploads everything, including the meshes and TIM chunks inside scene_tmd_stream entries 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.

  • Global id (≥ 2000): the change test and the load both use music_01_base + (id − 2000).
  • Scene-local id (< 2000): the change test compares scene_block + 6 + id, but the load index is overwritten with global slot 2 (0x800245A4..0x800245BC in FUN_800243F0), so no scene bank is ever staged as music.

Global id 2000+i is sound-test track i and loads extraction 988+i, a flat base. The curated track list's entry map is piecewise (988+i for i ≤ 67, 990+i for i ≥ 68, gap at 1056/1057), which from track 68 disagrees with what the resolver loads. Resolver: engine-system::music_labels; the full cue list is on music tracks.

Sound and stream loaders

LoaderRole
Sound bankLoads 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 assetsBuilds 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

FunctionAddressWhat it provesDump
Asset-type dispatcherFUN_8001F05CType byte split (>> 24), 21-entry handler table at 0x80010638.ghidra/scripts/funcs/8001f05c.txt
Descriptor walkerFUN_80020224Count word + 8-byte descriptors; positional slots; OR-ed status word. Sole runtime caller in the field overlay's MAIN_INIT.80020224.txt
Field loaderFUN_8001F7C0 + FUN_800255B8Sidecar staging layout; path roots DATA\FIELD\ and h:\PROT\FIELD\.8001f7c0.txt
Footprint helperFUN_80020310Sums three entries' sector sizes with the TOC gap math (toc[i+3] - toc[i+2]).80020310.txt
Scene changeFUN_8001FD44Name copy, pending-index staging, re-entry guard.8001fd44.txt
Transition streamerFUN_800219345-state fade + stream machine; hands off to mode 2.80021934.txt
Battle bundleFUN_800520F011-case slot machine; monster sound-bank LBA pairs.800520f0.txt
Chunk walkerFUN_8001FE70TMD-then-TIM-chunks layout; type-0x01 LoadImage; terminator rule.8001fe70.txt
Minigame warpFUN_80025980 / FUN_80026018Scene-name backup / restore; overlay parameter +0x4D; coin-bank commit.80025980.txt
Mesh registerFUN_80026B4CPool stores in walk order with a post-incremented index.80026b4c.txt
BGM resolverFUN_800243F0The < 2000 scene-local / global split.800243f0.txt
Disc corpus-The walk verified against every classified entry; the pochi / v12 skips verified across every scene.-

Full write-up: docs/subsystems/asset-loader.md.

See also