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
Engine port
engine-core::SceneResources::build_targeted + legaia_asset parsers (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.
1Scene name"town01" resolves through CDNAME.TXT to a block of consecutive PROT entriesFUN_8003E6BC 2Sector readthe block's .LZS bundle entry streams raw into the shared asset bufferFUN_80021934 3Descriptor walkcount word, then 8-byte descriptors: slot i = i-th descriptor, payload at base + data_offsetFUN_80020224 4LZS decodeeach payload is its own compressed stream, inflated into a separate targetFUN_8001A55C 5Type bytehigh 8 bits of the size word pick a handler from a 21-entry tableFUN_8001F05C 6HandlerTIM → VRAM upload; TMD → mesh pool; MES / ANM / MAN → RAM tablesper type

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

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; 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_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.

// 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

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

See also