Extracting assets: step-by-step scenarios
The disc is a sealed box of textures, meshes, music, voice lines and movies, each in a PlayStation-era format no modern program opens. This page turns each of them into something you can look at or listen to - PNG, OBJ, Blender-ready glTF, WAV, frame sequences - one scenario at a time, with the exact commands. Everything runs against your own disc image or the extracted/ tree it produces.
At a glance
Pick the scenario that matches what you want out of the disc. Each is two or three commands.
| I want... | Tool | Scenario |
|---|---|---|
| Textures as PNG | asset tim-scan + tim convert-dir | Textures |
| Field / battle meshes as OBJ | asset tmd-scan + tmd dump-obj | 3D models |
| A monster with its animations, in Blender | asset monster-archive --glb | Monsters |
| A whole town in Unity / VRChat / Blender | legaia-engine export-glb | Whole scenes |
| Voice lines and ambience as WAV | xa demux-disc-all | Streamed audio |
| Instrument samples and sound effects | vab extract --wav | Sound banks |
| The music sequences | seq find / seq info | Background music |
| Movie frames | mdec decode-str | Movies |
| Item / spell / steal / new-game tables as text | asset item-tables and siblings | Game-data tables |
| What every shop sells, with prices | asset shop-stock | Shop inventories |
| The dialog font | font-extract | Dialog font |
| One pipeline stage at a time | lzs-decode, asset decode/stream/extract | Manual chain |
Prerequisite
Every scenario below assumes the one-shot pipeline has run (getting started):
./legaia-extract "/path/to/Legend of Legaia (USA).bin" --out extractedCommands use the bare ./tool form from a release archive; source builds live at target/release/. Paths like extracted/... resolve against the current directory - run from the same place each time.
Textures → PNG
Every texture on the disc is a PSX TIM image, and most of them are buried inside compressed containers. The bulk texture path is a two-command chain: sweep every PROT entry for TIM images (raw and inside LZS compression), then convert the hits.
./asset tim-scan extracted/PROT --out extracted/tim_scan./tim convert-dir extracted/tim_scantim_scan/ gets one directory per PROT entry, holding files named <source>_off<OFFSET>_<W>x<H>_<BPP>bpp.tim. <source> is raw (found in the entry's raw bytes) or lzs<i> (found inside LZS section i), and <OFFSET> is the byte offset in that source, upper-case hex zero-padded to six digits. convert-dir writes a .png next to each. For a single texture:
./tim convert extracted/tim_scan/<entry>/<tim-file> -o out.png./tim convert <file>.tim --all-clutsThe filename is how a texture-catalog label maps to a file on disk. A label like PROT 46 · LZS sec 0 +0x13368 · 128×128 4bpp · foliage reads as: PROT entry 46 (0046_vell), inside LZS section 0, at offset 0x13368. Every one of those fields is in the name, so after the sweep it lands at extracted/tim_scan/0046_vell/lzs0_off013368_128x128_4bpp.tim - convert that one file. tim-scan does the LZS decompression itself, so this is the whole texture path; you never need to decompress sections by hand (the manual chain below is for the rarer non-texture cases).
Many TIMs carry several palettes (CLUTs) - a character atlas decoded with the wrong row looks like static, so try --all-cluts (one PNG per palette row) before assuming a texture is broken. The strict per-texture inventories the pipeline already wrote (prot_tim_catalog.tsv, prot_tim_deep_catalog.tsv) map every TIM to its owning entry and offset.
3D models → OBJ
Legaia's meshes are a custom TMD variant - close enough to Sony's PsyQ format to look familiar, different enough that a stock TMD tool mis-walks them. Sweep and export:
./asset tmd-scan extracted/PROT --out extracted/tmd_scan./tmd dump-obj extracted/tmd_scan/<entry>/raw_off<HEX>.tmd --out mesh.objdump-obj writes exactly one file, at the path you give it - no extension is appended, so name it .obj yourself. Every object in the TMD becomes an o object_NNN_... group inside that one file, carrying vertices and faces decoded through the Legaia-specific primitive walker.
tmd info prints a psx-walk: FAIL line on every valid Legaia TMD - that is the diagnostic confirming the file is the Legaia variant rather than a standard PsyQ TMD, not an error.
Monsters → glTF (opens in Blender)
Every enemy you fight lives in one archive - PROT entry 0867_battle_data - which carries its mesh, texture page, stat record and battle animations. One command bundles all of that into a .glb:
./asset monster-archive extracted/PROT/0867_battle_data.BIN./asset monster-archive extracted/PROT/0867_battle_data.BIN --id 1 --glb monster1.glbThe bare listing prints every one of the 186 monsters' names and battle stats; the .glb drags straight into Blender (or any glTF viewer) with geometry, material, and animation tracks intact. --obj / --texture-png export the pieces separately; --anim lists the decoded actions.
To edit a monster, --dump-block writes one monster's whole decoded block (stat record + name + mesh + animations) to a file and --write-block re-packs your edited copy into the archive in place - no slot-offset or LZS math. To do the same directly against a disc image (with a shareable PPF out the other end), use legaia-patcher monster-block instead (modding + translation).
Enemy stats are not text you can spot in a hex editor, and they are not in the player-battle files 0865/0866. Always read enemy data through asset monster-archive on entry 0867, which starts each monster's LZS stream at the right slot base; a raw lzs-decode of the whole file only expands the first stream it meets. If a .BIN for 0865 or 0866 ends in enemy names, it was extracted by an older tool that over-read into 0867 - see the over-read trap.
Whole scenes → glTF (Unity / VRChat / Blender)
One command bakes an entire town or field map into portable glTF: the assembled world (ground, terrain, buildings - textured from the scene's real VRAM, placed exactly as the game places them), one animated .glb per NPC with its clips, the animated props (Rim Elm's windmills, doors), and a manifest.json that records where everything stands:
./legaia-engine export-glb --scene town01 --out glb-export./legaia-engine export-glb --all-scenes --out glb-export./legaia-engine export-glb --items --out glb-export--items adds every equipment item - all four characters' weapons, Ra-Seru, armour, headgear and footwear - as animated .glbs: an item-alone cut with its grip repaired (a grabbable prop) and the exact palette cut with its host limb, the same two files the characters page offers per download.
The world .glb drags into Blender like the monster export above. For building the scene as a private VRChat world - colliders on the retail walk surface, villagers looping their idle clips, doors that teleport you inside, a spawn in the village centre - the VRChat world guide walks the whole chain with the repo's Unity kit. As with everything on this page, the output is decoded from your own disc: keep it local, keep uploads private.
Streamed audio (voice + ambience) → WAV
The battle shouts, the cutscene voices and the ambient loops are not in the sound banks - they stream straight off the disc as CD-XA audio. The pipeline already demuxes this into extracted/XA_WAV/. To run just that step - it reads the raw disc, not the extracted tree:
./xa demux-disc-all "/path/to/Legend of Legaia (USA).bin" --out extracted/XA_WAVAbout three seconds on a modern machine for the disc's 34 .XA files → 316 per-channel WAVs, each decoded at the true sample rate and stereo mode read from its CD-XA subheaders. Prefer this over xa convert, which operates on Form-1 dumps and has to guess the rate.
Sound banks (instruments + SFX) → WAV
VAB banks hold the sampled instruments the music is played on and the sound effects the game triggers:
./vab list extracted/PROT/<entry>.BIN./vab extract extracted/PROT/<entry>.BIN --out vab_out --wavOn the wrapped BGM entries (the music_01 block) the scan warns about a truncated trailing header after the real banks extract - that is expected on those entries, and the exit code stays 0.
Background music (SEQ) - the wrapped-BGM story
Retail BGM is not a bare SEQ file: each music PROT entry wraps it as [chunk header][VAB][chunk header][SEQ], so the SEQ sits at a non-zero offset. seq find locates it:
./seq find extracted/PROT/0990_music_01.BIN./seq info extracted/PROT/0990_music_01.BIN --offset 0x<from-find>./seq events <file> --offset 0x<N>./seq json <file> --offset 0x<N>find scans for pQES magics and reports each candidate offset with whether the full event stream parses. Without --offset, info/events/json try offset 0 and then fall back to the first parseable magic automatically - so ./seq info extracted/PROT/0990_music_01.BIN alone also works; the explicit offset just makes the choice visible.
Movies → PNG frames
The disc's FMVs land in extracted/MOV/. Decode them frame-by-frame:
./mdec scan-str extracted/MOV/MV1.STR./mdec decode-str extracted/MOV/MV1.STR -o frames/scan-str lists frames and dimensions; decode-str writes frame_0001.png onwards. PNG is the default output (--format ppm for the raw variant); --max-frames N stops early. To watch a movie with its audio instead, use legaia-engine play-str (playing + viewing).
Game-data tables from the executable
Item names, spell costs, what each monster lets you steal, what a new game starts you with - these are static tables inside the game executable extracted/SCUS_942.54. The asset hub prints them as readable, joined listings:
./asset item-tables extracted/SCUS_942.54./asset spell-names extracted/SCUS_942.54./asset steal-table extracted/SCUS_942.54./asset new-game extracted/SCUS_942.54Siblings: accessory-passive, sfx-table, level-up, worldmap-menu.
Shop inventories - a table joined from two files
A town's stock is not in a shop table. It is inline in that scene's MAN field-VM script (op 0x49, sub-op 0), in the same place the scene's chests and doors live. The item names and prices come from somewhere else entirely - the static item record table in SCUS_942.54 (DAT_80074368, 0xC stride, file offset 0x64B68; the price is the u16 at +2). shop-stock opens both and joins them:
./asset shop-stock --prot extracted/PROT.DAT --scus extracted/SCUS_942.54 --cdname extracted/CDNAME.TXT./asset shop-stock --prot extracted/PROT.DAT --scus extracted/SCUS_942.54 --cdname extracted/CDNAME.TXT --scene bylon--scene matches the CDNAME block name; --entry N takes a single extraction index instead; --json emits the same data for scripting. Output:
== bylon (entry 0053, MAN +0x406a) - "Morlang" decodes 10 ids, sells 7
0. 0x22 Survival Knife 180
...
6. 0x60 Electric Shoes 560
7. 0x01 Ra-Seru Meta $1 0 <- unsellable tail (record padding, not stock)
Three things to read carefully
- "decodes 10 ids, sells 7" is two different numbers on purpose. The record's
countbyte over-counts: it includes a trailing run of price-0template ids - usually the "Ra-Seru Meta $N" placeholders0x01/0x02/0x03. The shop UI stops at the priced run, so that tail is structural padding, not stock. The listing flags it instead of truncating, because a reader shown only one of the two numbers will assume the other. - Sites are found by scanning, not by walking the script. A shop's
0x49is often gated behind a dialogue confirm-picker whose option-jump table desyncs a linear disassembler before it ever reaches the op - a walk silently misses such a shop (Biron Monastery's vendor is one) and reports one too few, with no error. The scan is deliberate. - The entry column is in extraction-index space; the scene column is not. CDNAME
#define <name> Nnames the content at extraction entryN - 2, so the scene label is resolved through that shift and can disagree with the extraction filename near a block boundary.
The curated shop tables are the other half of this - when the decoded record and the human-readable table agree, both are confirmed.
The dialog font
The text you read in dialog boxes is drawn from a proportional bitmap font. The pipeline's final step writes extracted/font/ (atlas PNG, tile-page sheet, widths CSV, metadata) - the engine and asset-viewer load text from it. To rebuild it alone, no emulator needed:
./font-extract --disc "/path/to/Legend of Legaia (USA).bin" --out extracted/font--disc also accepts an already-extracted PROT.DAT. The alternative --save mode reads a mednafen save state's live VRAM instead - exactly one of the two is required. Either way the glyph bitmaps are all that mode supplies: the width and escape tables come from the executable, read before the mode even branches, so --scus (default extracted/SCUS_942.54) has to resolve or the run fails on that file rather than on the disc.
The manual chain (when you want one stage at a time)
legaia-extract is these stages composed; each also runs standalone. The first two build the extracted/ tree; the rest operate on one PROT entry at a time:
./disc-extract extract "/path/to/disc.bin" extracted/./prot-extract extract extracted/PROT.DAT extracted/PROT --cdname extracted/CDNAME.TXT./asset categorize extracted/PROT - run this first../lzs-decode probe extracted/PROT/<entry>.BIN./lzs-decode container extracted/PROT/<entry>.BIN out_dir/./asset decode extracted/PROT/<entry>.BIN --type-size 0x.. --offset 0x.. --mode lzs./asset stream <entry>.BIN lists chunks; ./asset extract <entry>.BIN --out out_dir/ unpacks the sub-assets.Which <entry> do I pass?
<entry> is a filename from extracted/PROT/, and every file there is named NNNN_<name>.BIN where NNNN is the zero-padded PROT index. A catalog or site label that reads PROT 46 means entry 46, i.e. 0046_vell.BIN - not 0446. The four-digit prefix is the index; don't add or drop digits. When a label and a filename disagree on the <name> half, trust the number: CDNAME names are shifted +2 against the #define numbers (CDNAME).
Which command for which entry?
Run asset categorize first - it prints each entry's format class, so you know whether an entry is an LZS container, a streaming/pack bundle, a raw scene bundle, or overlay code before you pick a decoder. Then:
- LZS container -
lzs-decode containerdumps every section toout_dir/.asset decodepulls a single section instead: take the--type-sizeand--offsetfrom anasset describerow of the same file, and pass--mode rawfor the small uncompressed control sections (--mode lzsfor the compressed ones). Reach fordecodewhen you want one section, or when the container header table doesn't auto-walk cleanly. - Streaming/pack bundle -
asset streamlists its chunks andasset extractunpacks the TIM/TMD sub-assets. Both expect the DATA_FIELD chunk format; pointed at a flat blob (e.g. a decompressed LZS section) they misread the leading bytes as a chunk header and printmagic MISMATCHon every "chunk" - that means it is the wrong tool for that input, not a corrupt file. - Textures, in any container - skip the section plumbing and use
asset tim-scan(Textures → PNG); it decompresses LZS sections itself and names each hit for the section it came out of.
The LZS decoder's ring buffer initialises to zeros, so most random input decodes to plausible-looking bytes. Always check the decoded output for the expected magic (LZS). This is why lzs-decode probe reports no plausible sections on a non-container entry rather than emitting garbage - a clean "this isn't LZS" is the useful answer.
The over-read trap: which file owns a byte offset?
An entry's real size is the sector gap to the next entry, but some entries declare a far larger window in the TOC, and reading the declared window walks straight into the following entry's bytes. Clearest case: battle-data entries 0865 / 0866 each declare ~16 MB but really own 0x6F000 / 0x17800, and both declared windows run into the monster archive 0867.
prot-extract extract writes each .BIN as the entry's own sectors, so a freshly extracted tree does not carry the trap. It bites through a .BIN extracted under the declared window, or a note of the form "entry N, offset K" measured against one - the bytes are on the disc where the note says, but the named entry is the wrong owner. prot-extract list prints size beside decl_span and flags every disagreeing row OVR; locate resolves a specific offset to its true owner:
./prot-extract locate extracted/PROT.DAT 0x17855 --in-entry 866 --cdname extracted/CDNAME.TXTIt translates the in-file offset to an absolute PROT.DAT offset, names the true owning entry (here 0867), and says plainly when the offset runs past the entry you asked about. Drop --in-entry to pass an absolute offset directly.