Asset loader
How the running game gathers everything a scene needs - meshes, textures, palettes, sound banks, scripts - out of PROT.DAT, the disc's single big archive of 1233 numbered entries. Battle, field, sound, BGM: each scene type has its own loader, but they all share one idiom: a dev branch that loads by PROT entry number directly, and a retail branch that resolves dev-style file paths through the CDNAME name map into those same numbers.
How it works
At runtime, "load this scene" doesn't mean "load one file". A single battle scene needs character meshes, monster meshes, the active CLUTs (palettes stored as rows of framebuffer pixels), the per-monster sound bank, the visual-effect script archive, plus all the text the battle dialog box might print. They're scattered across dozens of PROT entries. (The function names on this page - FUN_8001F05C and friends - are RAM addresses in our disassembler trace of the original code; the game shipped without symbols.)
Each scene type has its own loader function whose job is to go fetch all that. They all share the same shape:
- The loader is a small state machine with one numbered case per asset slot.
- Each case calls the asset-type dispatcher
FUN_8001F05Cwith the bytes for one entry. - Some cases also kick off async CD reads to stage things into memory while the rest of the scene is initialising.
There's a dev/retail split that runs through nearly every loader. Dev builds load by PROT index directly - the index numbers are baked in. Retail builds resolve dev-style paths (h:\PROT\FIELD\<scene>\name) through FUN_8003E6BC, which uses the CDNAME.TXT name map to find the corresponding PROT index. Both branches end up at the same files; only the lookup mechanism differs. That's why there is no literal "scene N uses PROT entries [a, b, c]" table anywhere in the executable - the table is the CDNAME block layout itself.
The asset descriptor walker (FUN_80020224) is the loop that walks a per-scene asset descriptor and calls the dispatcher per descriptor. It has zero static callers in SCUS_942.54 (the game's main executable), which would suggest "dead code" - except its sole runtime caller is in the town overlay (a chunk of code the game streams into RAM on demand), at the start of every field session. So it is exercised by retail gameplay; the call just lives in RAM.
Battle bundle
- Function
FUN_800520F0- Shape
- 11-case state machine
Notable cases:
| Case | Loads |
|---|---|
| 6 | The befect_data bundle (PROT 0x369–0x36B) |
| 0xE | Initialises the runtime effect 2-pack wrapper via FUN_801DE914 |
| 0xFF | Dispatches 0x801F17F8, the side-band streaming-effect handler that streams summon.dat and readef.dat |
Two cases call FUN_8003E104(monster_idx, slot, dst_buf) to populate slots 7 and 8 with the active battle's monster sound banks - the per-monster body of h:\mpack\monster.snd. Each monster has a (start_lba, end_lba+1) entry pair in the TOC at 0x801C8980 - 0x10. See Audio → "Monster sound bank" for the full loader contract.
The asset-viewer's --bundle battle mode overlays the extraction 865–890 tim_scan window - an empirically-tuned VRAM (PS1 video memory) set spanning the player battle files, the befect cluster, and the sound_data2 streams (the directory labels carry the +2 filename shift) - so character meshes have the right CLUT bindings.
Per-PROT walker (FUN_8001FE70)
One battle-scene state (in FUN_800513F0, around 0x80051A50) calls FUN_8001FA88(scene_base + slot, 0, dst_buf) to load a per-scene PROT entry into the working buffer, then FUN_8001FE70(dst_buf) to walk its chunk list. The walker is the dispatch path for the scene_tmd_stream layout - leading TMD body followed by streaming chunks - and is different from the standard FUN_8002541C streaming walker:
- First chunk: read
chunk0_header = u32 LEat offset 0. Low 24 bits = TMD body size. Round up to 32-byte alignment, allocate a buffer of that size at_DAT_8007B864, copy the TMD body in viaFUN_8003D26C. - Loop: advance by
(prev_size & ~3) + 4to the next chunk header. Readheader; ifheader & 0xFFFFFF == 0, exit (terminator). Otherwise:- If
(header >> 24) == 0x01-> callFUN_800198E0(payload_ptr)(LoadImage). - If
(header >> 24) == 0x02-> exit (explicit terminator). - Other types are skipped silently (the loop advances without uploading).
- If
- Returns once the terminator is hit. It returns
param_1 + 1- a pointer to the word just past the terminating header, i.e. the start of the next region. TheFUN_80026B4CTMD register call that follows hooks the parsed TMD into the per-scene mesh pointer table at0x8007C018 + idx*4.
This is the path that uploads field NPC palettes to VRAM row 479 - they're plain PSX TIMs wrapped in type-0x01 chunks, dispatched only during battle init. See npc-palette.html for the cross-save corroboration and scene-bundles.html → "Streaming tail" for the type-byte table.
One entry, one sub-stream (the falsified “two-list” / continuation case)
A scene_tmd_stream entry holds exactly one complete [chunk0 TMD][type-0x01 TIM chunks][terminator] sub-stream, then zero padding to its sector-aligned end. FUN_8001FE70 walks that one sub-stream and returns param_1 + 1 (past the terminator); the single static caller FUN_800513F0 (battle init) calls it once - the s3 < 4 loop above the call is 4-party-member setup - which is all an entry contains.
Earlier notes described some entries as holding more than one sub-stream concatenated (0006_town01: sub-stream 0 at 0x0, sub-stream 1 with its own leading TMD at 0x14000), “verified across the town01 / town0b / town0c clusters”. That reading is falsified. It was an artifact of the superseded PROT entry-size expression, which over-read every entry into its successor.
Entry 0006 is exactly 0x14000 bytes, and the “second sub-stream” is PROT entry 0007, whose TOC start is exactly 0x14000 further on: its own leading TMD is 0x2c20 and its own tail TIMs are at 0x2c24 / 0xae48 - the stale offsets minus 0x14000. The town0b / town0c “confirmations” are clusters of the same four-entry layout, so each over-read reproduced the artifact rather than testing it.
scene_tmd_stream::sub_streams returns one block per entry and battle_tim_chunks reports every chunk as Tail; both keep their post-terminator scan as a regression detector for a buffer that spans more than one entry. The engine's field-mode loader skips all these battle-only TIMs (row-479 palettes aren't field-resident).
Field / town scene loader
- Functions
FUN_8001F7C0+FUN_800255B8- Path roots
DATA\FIELD\andh:\PROT\FIELD\<scene>\
The loader builds paths under DATA\FIELD\ and h:\PROT\FIELD\<scene>\ (per-scene extension family:
.MAP collision/trigger file at raw block base +0, .PCH trigger patch at +1,
event-scripts sister at +2, .LZS asset bundle at +3, BGM base at +6 - raw
CDNAME-define index space) and walks the scene asset table at the .LZS slot to pull each typed file in turn. The
on-disc form is the canonical 7-typed-asset bundle (07 00 00 00 lead).
The descriptor offsets past the first are file-relative against the loaded raw footprint (= the bundle entry's extended on-disc footprint, Archive::read_entry), not relative to a decompressed working buffer: the walker hands base + data_offset to the dispatcher as the source of an independent LZS stream, which it then decompresses into a separate target. The offsets routinely run past the TOC-indexed end into the trailing-overlay sectors the per-PROT TOC crops off - e.g. 0588_juui1.BIN's indexed view is 67584 B but desc[4].data_offset is 177413, valid against the 186368 B extended footprint.
The asset chain shape is "load the scene asset table, walk each descriptor, then load each typed sub-asset via the dispatcher". The slot→asset mapping itself is positional + offset-based and fully pinned (see the descriptor-walker section below); what remains partial is the runtime cross-reference stitching between already-loaded sub-assets (e.g. a placed actor in the MAN naming a TMD-pack index), which the loader resolves from live pointers.
WARP opcode (0x3E) → minigame door-warp flow
- Field-VM opcode
0x3Ewithop0 ≥ 100- Mode-24 handler
FUN_80025980(OTHER INIT)
When the field VM executes opcode 0x3E with op0 ≥ 100, it stores sub_id = op0 - 100 in DAT_8007ba34 and writes game mode 0x18 (24, OTHER INIT) - the minigame entry mode. The mode-24 handler FUN_80025980 backs up the active scene name (memcpy(0x8007BAE8, 0x80084548, 8)) plus the scene-id word DAT_80084540, then loads the per-sub-id minigame code overlay via FUN_8003EBE4(sub_id + 0x4D) (sub_id ≥ 6 adds 2 first) and calls its init entry.
Only 7 distinct WARP destinations exist (sub_ids 0–6). The loader values 0x4D..0x55 are raw loader parameters, not extraction PROT indices - they resolve to extraction entries 0972..0980 (prot_index = param + 0x37F; fishing = 0972, slot machine = 0975, Baka Fighter = 0976, dance = 0980, plus two dev modules and the monster-roster arena). Each overlay's init entry sits at an overlay-resident address (0x801CF070, 0x801CE8A0, etc.). A genuine warp's op0 is therefore always 100..=106; the placement classifier uses this range (plus the absence of the 0x80 cross-context prefix) to reject text-desync phantom warps - see the World map entity-classification notes.
The op carries no destination scene name. The "name handling" is the backup/restore of the current scene: on minigame exit the return handler FUN_80026018 restores the name (memcpy(0x80084548, 0x8007BAE8, 8)) and DAT_80084540, commits the session winnings into the casino coin bank, and hands off to mode 2 (MAIN INIT), whose per-scene initializer reloads the restored scene. Named scene transitions are a different opcode entirely - the 0x3F named scene-change below.
| 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 |
Full chain, sub-id → overlay table, and provenance: field VM and docs/subsystems/script-vm.md § 0x3E WARP. The DefaultMapIdResolver in engine-core::scene uses CDNAME blocks in ascending PROT-index order as a positional approximation; the retail warp only supports the 7 minigame destinations, not the full CDNAME scene list.
Name-based scene change (FUN_8001FD44) + the transition streamer (FUN_80021934)
Distinct from the door-warp above, the engine has a name-based scene-change packet: 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 (_DAT_8007B8C2 != 0, the retail arm: directly into DAT_8007B768;
the == 0 dev arm: _DAT_8007B9A0 at gp+0x688), both from the resolved halfword at 0x80084540;
s_ERR_CHANGE_PACKET guards re-entry while a packet is pending (_DAT_8007BA3C). The field VM reaches it through
opcode 0x3F, which carries the destination name inline in the bytecode ([0x3F][i16 index][u8
name_len][name][entry_x][entry_z][dir]) - so most in-game scene transitions, including overworld town/dungeon entry, carry a
recoverable destination name. This path is not constrained to the 7 door-warp sub_ids; it is also the opening opdeene →
town01 hand-off path (see boot). See ghidra/scripts/funcs/8001fd44.txt.
It then spawns the scene-transition streaming actor into the system pool at _DAT_8007C34C via
FUN_80020DE0(&DAT_80070734, _DAT_8007C34C) - descriptor 0x80070734 of the 24-byte spawn-descriptor
family at 0x800705FC..0x80070763 (just below, and phase-misaligned with, the mode table at 0x8007078C;
layout [+4 0xFFFF0000][+8 handler][+0xC flags]; the spawner copies +8 into actor+0x0C and
zeroes the state counter actor+0x1A - see ghidra/scripts/funcs/80020de0.txt). The handler is
FUN_80021934 (real entry 3 instructions before the 0x80021940 prologue; see
ghidra/scripts/funcs/80021940.txt), a 5-state SM over actor+0x1A (jump table 0x80010760):
- State 0:
DAT_8007B648 = 0x80, seed the 70-frame countdowngp+0x710 = 0x46. - States 1/3: poll stream progress via
FUN_8003DE7C(1). - State 2 (dev): stream raw TOC entry
DAT_8007B768 + 3- the destination block's.LZS/ scene_asset_table slot - into_DAT_8007B85Cby index (FUN_8001EEF0→FUN_8003EB98→FUN_8003E8A8); byte size cached atgp+0x73C. - State 4 (retail, once the countdown lapses): rotate the name buffers (
0x80084558←0x80084548←0x800915C8), setDAT_8007B768 = _DAT_8007B9A0, build the literal pathDATA_FIELD\<scene>.LZS(base string0x800106C4, suffix0x8007B3CC) and stream it into_DAT_8007B85Cby name, then hand off with_DAT_8007B9EC = 1and_DAT_8007B83C = 2(mode 2 MAIN INIT).
So the descriptor table FUN_80020224 walks at _DAT_8007B85C is the raw .LZS
bundle streamed there at transition time - the count word lands at the buffer base because the whole file starts with
it; there is no relocation step. Dev and retail converge on the same content: raw scene_base + 3
is the .LZS entry (extraction index define + 1, e.g. map01 →
extraction 0086, verified descriptor heads across the corpus). Sibling descriptors in the same family include
0x800705FC (handler FUN_801D1344, the intro-skip dialog actor) and 0x8007065C (the
op-0x49 name-entry setup record, see boot).
Asset descriptor walker
- Function
FUN_80020224- Sole runtime caller
- Town overlay's
FUN_801D6704(MAIN_INIT) at0x801D6B0Cwitha0 = 0 - Result stored at
0x80087AF8
Walks the asset descriptor format and calls the asset-type dispatcher per descriptor. So the walker IS exercised by retail gameplay, just not from a static call site inside SCUS_942.54.
The mapping is positional - there is no separate slot→asset indirection table; the descriptor's data_offset field is the indirection. The chain, traced from the field init at FUN_801D6704:
FUN_8001E1B4allocates a single 0x62C00-byte asset buffer once at boot and stores its base at_DAT_8007b85c.- The transition streamer
FUN_80021934(spawned byFUN_8001FD44- see the scene-change section above) has already streamed the destination's raw.LZSbundle (raw TOC entryscene_base + 3) wholesale into_DAT_8007b85cduring the transition fade, so the count word sits at the buffer base with no relocation step.FUN_8001F7C0separately stages the scene's three sidecars into the per-scene buffer*(0x1F8003EC):DATA\FIELD\<scene>.MAPat+0,DATA\FIELD\<scene>.PCH(the scene_v12_table walk-on trigger patch; zero-fill0x800when absent) at+0x12000, andefect.datat+0x12800(= _DAT_8007B8D0). Seeghidra/scripts/funcs/8001f7c0.txt. FUN_80020224readscount = *base, then forslot in 0..countcallsdispatch(base + descriptor[slot].data_offset, type_size, …), descriptors atbase + 8 + slot*8, OR-ing the per-slot return codes into a status word.FUN_8001F05Csplitstype = type_size >> 24andsize = type_size & 0x00FF_FFFF, then jumps via the table at0x80010638 + type*4(type bound< 0x15).
So slot i ⇒ the i-th 8-byte descriptor; payload at base + data_offset; handler keyed
by type_size >> 24. scene_asset_table::resolve returns the table plus the base it is relative to
for both the bare variant (base 0) and the prescript-prefixed scripted variant (base at a 0x800-aligned offset past the event prescript);
SceneAssetTable::slots reproduces the walk and payload_range(slot, base) resolves a slot's payload span. A
disc-gated corpus test (scene_asset_table_walk_real) verifies the walk against every classified entry (88 bare + 79 scripted).
The base the walker receives is _DAT_8007b85c because the transition streamer put the raw bundle there (statically pinned); for the scripted variant the count word sits at its 0x800-aligned
in-file offset past the prescript, which the static resolver reconstructs structurally.
CLUT-data scattering
Many character meshes reference CLUT (palette) rows that live in different PROT entries from their TMD source. The runtime asset chain stitches them together - the loader puts the relevant TIMs into VRAM before the TMD is rendered.
Engines drive this from SceneResources::build_targeted: parse every TMD in the scene's CDNAME block, collect the union of all prim-target rectangles (CLUT rows + texture-page UV bboxes the meshes sample), then walk every TIM and decide per-block whether to upload it - suppressing the image block when it would land on a CLUT row another mesh references, and vice-versa. Matches the retail field loader's "DMA only the texture bytes the current scene's meshes need" pattern and avoids the 4bpp-vs-256-wide CLUT collisions that would otherwise drop 80%+ of textured prims through the prim filter. See renderer → Engine-side targeted upload + shared blocks for the engine-side wiring.
Field-shared CDNAME blocks
FIELD_SHARED_BLOCKS = ["init_data", "player_data"] is the set of CDNAME blocks the retail field engine keeps resident in VRAM across scene transitions.
The field-character meshes and textures both originate from PROT 0874 - the retail player_data / player.lzs container FUN_8001E890 loads by disc index 0x36c. Its sections: §0 = the 5-TMD character mesh pack that populates DAT_8007C018[0..4], §1 = effect / vdf models, §2 = the field-character texture pack - eight TIMs uploaded to VRAM (the three Vahn/Noa/Gala atlas pages at texpage (832, 256) with per-character CLUTs on row 478). See character-mesh § textures and world-map-overlay § Disc-side source of [0..4].
Label caveat: the extraction file named player_data is 0876 - the +2 filename shift - a VAB + empty TIM_LIST + SEQ trailer with no TMDs. init_data (PROT 0) holds shared UI / sprite tiles. SceneHost::enter_field_scene passes both blocks to build_targeted so the player atlas survives every town / dungeon transition without being re-uploaded per scene.
The legacy SceneResources::build (no shared blocks, unfiltered upload) is preserved for tests + diagnostic surfaces; engines should prefer build_targeted for production scene loads. The asset-viewer's --vram-extra-dir flag remains the manual workaround for browsing extracted tim_scan/ dirs that aren't tied to a CDNAME scene.
Field vs battle dispatch (SceneLoadKind)
SceneResources::build_targeted_with_options(scene, shared, BuildOptions { kind }) lets callers pick the dispatch path the build mimics:
SceneLoadKind::Battle(legacy default ofbuild_targeted): uploads every TIM the scanner finds AND parses every TMD the scanner finds. Includes the leading TMD + type-0x01 TIM chunks insidescene_tmd_streamPROT entries (whichFUN_8001FE70walks during battle init) and every TMD + TIM embedded insidebattle_datarecords (which theFUN_8001E890chain loads at boot for battle init). Town01 keep ratio: 99.3% under disc-gated test.SceneLoadKind::Field: skips both sources.scene_tmd_streamentries are excluded entirely - their leading TMDs go to the battle character TMD register (_DAT_8007B864), never rendered from a field scene, and their type-0x01 TIM chunks upload the same mesh's textures.battle_datarecords are skipped at parse + upload time - the pack is battle-init resident, not part of field VRAM. Retail field saves carry row 479 fb_x=0..256 = zero.
SceneHost::enter_field_scene uses SceneLoadKind::Field so the engine port matches the retail dispatch boundary. BuildOptions::default stays Battle so legacy build_targeted calls keep their previous semantics.
Battle-boot pre-load (build_battle_boot_vram)
SceneResources::build_battle_boot_vram(battle_data_scenes) builds a VRAM blob from the player battle files (BATTLE_BOOT_BLOCKS = ["edstati3", "battle_data"] - the two extraction labels covering the retail battle_data block, extraction 863..866; non-pack entries in either block fail detection and are skipped). It walks every record's LZS stream, uploads any standard-PSX-TIM textures it finds, and invokes the descriptor-driven CLUT pass via battle_data_pack::clut_uploads. The retail engine performs an equivalent pre-pass via FUN_8001E890 at boot or first-battle entry so battle-init has the character meshes resident before the scene-specific FUN_8001FE70 walk fires.
The CLUT pass is a documented no-op until the battle_data post-TMD descriptor at u32[3..0x20] is pinned (see battle-data-pack). Engines that want the API in place can call build_battle_boot_vram to walk the pack and accept any TIM-shaped textures it does carry; once descriptor decoding lands, the same call also surfaces the per-record (fb_x, fb_y) CLUT placements without further wiring changes. The returned VRAM is intentionally separate from the scene's field VRAM - battle init merges it with the scene-specific upload pass; field rendering does not.
Diagnosing missing CLUTs
To find which PROT entry provides a missing CLUT row, run:
asset clut-finder <vram_x> <vram_y> --extracted-root extracted
This walks extracted/tim_scan/<entry>/*.tim and reports every TIM whose CLUT covers the requested VRAM cell.
For scene-level diagnostics straight off the disc image (no extracted tree required), legaia-engine clut-trace --scene <name> --disc <bin> walks every dropping MissingClut prim in a CDNAME scene, groups by (cba, depth), and reports the suppliers found across the whole PROT corpus by rectangle containment. Pair with --runtime-vram <bin> (mednafen save state captured via mednafen-state vram-dump --out-bin) to mark which missing rows are populated at runtime (engine loader gap) vs absent everywhere (mesh references unreachable CLUT - likely needs a sub-pack walker port).
Row-479 NPC CLUTs: scene_tmd_stream type-0x01, not battle_data
The four town01 NPC TMDs at field intersections sample CLUT row y=479 slots x=128..240 (CBA = 0x77C8..0x77CF). The source is the matching scene_tmd_stream entries in town01's own CDNAME block, wrapped in type-0x01 chunk headers that FUN_8001FE70 dispatches during battle init (see row-479 NPC CLUTs). Retail field saves carry row 479 = zero because retail field-mode rendering never has those CLUTs resident either.
Superseded readings
- A
scene_tmd_streamentry was read as holding several concatenated sub-streams - first as a "continuation TIM list" tail, then as a self-contained second sub-stream with its own leading TMD. Both readings are falsified: an entry holds one sub-stream, and the "second" one is the next PROT entry, seen through the superseded over-reading entry-size expression. - The row-479 NPC palettes were hypothesised to live inside the
battle_datablock (the player battle files, extraction 863..866) and reach VRAM via a boot pre-load; the byte-match corpus on battle-data-pack refutes that - they are scene-local type-0x01 chunks.
SceneResources::build_targeted_with_options(.., SceneLoadKind::Field) matches this dispatch boundary: it excludes both the leading TMD and the type-0x01 TIMs from every scene_tmd_stream entry, so the field-mode TMD pool drops the battle character meshes that retail wouldn't render either. The 388-prim "MissingClut" measurement that surfaced under the previous battle-mode default disappears - those prims belonged to meshes that simply aren't loaded in field mode.
The player battle file parser remains the entry point for battle-init: legaia_asset::battle_data_pack decompresses every TMD slot's LZS stream and exposes the embedded Legaia TMDs + 32-byte layout header. The post-TMD texture/CLUT pool layout is partially TBD - the descriptor at u32[3..0x20] points at specific palette positions but the encoding isn't pinned. build_battle_boot_vram wires the API in place so once descriptor decoding lands, battle scenes pick up the per-record (fb_x, fb_y) CLUTs without further integration work.
VRAM oracle (engine vs runtime)
legaia-engine vram-oracle --scene <name> --disc <bin> --runtime-vram <bin> rebuilds the scene's targeted VRAM and reports per-band overlap counts (top half / texpage primary / texpage CLUTs) against the runtime ground truth. --diff-png <path> writes a 1024x512 colour-coded RGBA8 diff (red = runtime-only / gap, green = engine-only / extras, blue = both non-zero with different content, greyscale = exact match). --tiles adds a 64x64 tile-by-tile breakdown so a specific page region's coverage can be inspected.
Music / SFX selection (BGM lookup)
Documented in detail under the field VM → "BGM lookup table" section. The short version: the BGM ID is a PROT-relative offset, not a literal table lookup.
// FUN_800243F0
if (bgm_id < 2000) {
prot_idx = scene_local_base + 6 + bgm_id; // _DAT_80084540 + 6 + bgm_id
} else {
prot_idx = global_pool_base + (bgm_id - 2000); // _DAT_8007BC64 + bgm_id - 2000
}
The "BGM table" is the CDNAME.TXT per-scene block layout. There's no separate BGM index in SCUS_942.54.
Sound bank loader and streaming-asset loader
| Function | Role |
|---|---|
FUN_8001FA88 |
Sound subsystem init / .dpk loader. Loads bse.dat master bank once at boot, then per-scene .dpk files via the path-based opener with h:\main\bg\domepack\<name>.dpk. Dev builds bypass the path-builder and load PROT index 0x37A (sound_data2) plus param_1 + 5 directly. |
FUN_8001FC00 |
Streaming-asset loader. Builds paths under the sound\ prefix; the XA / .pac / STR consumer. Same dev/retail split as the field loader. |
Full breakdown in Audio.
Top-level extraction pipeline
legaia-extract (the binary in crates/extract) drives the offline preservation pipeline:
verify → disc → PROT → categorize → streaming-format extract → TIM → PNG
See the extraction tooling page for per-stage CLI invocations.