Extracting assets: step-by-step scenarios
Textures to PNG, meshes to OBJ, monsters to Blender-ready glTF, music and voice lines to WAV, movies to frames - one scenario at a time, with the exact commands. Everything runs against your own disc image or the extracted/ tree it produces.
Prerequisite
Every scenario below assumes the one-shot pipeline has run (getting started):
./legaia-extract "/path/to/Legend of Legaia (USA).bin" --out extracted
Commands 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
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_scan
tim_scan/ gets one directory per PROT entry holding files named raw_off<HEX>.tim (found in the raw bytes) or lzs<i>_off<HEX>.tim (found inside LZS section i); convert-dir writes a .png next to each. For a single texture:
./tim convert extracted/tim_scan/<entry>/raw_off<HEX>.tim -o out.png
./tim convert <file>.tim --all-cluts # one PNG per palette row
The 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. So after the sweep it lands at extracted/tim_scan/0046_vell/lzs0_off13368.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 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. Format: TIM.
3D models → OBJ
Legaia's meshes are a custom TMD variant (Legaia TMD). 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
dump-obj writes mesh_obj0.obj etc. - vertices and faces, decoded through the Legaia-specific primitive walker. Note: 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)
The monster archive (PROT entry 0867_battle_data) carries each enemy's mesh, texture page, and battle animations. One command bundles all three into a .glb:
./asset monster-archive extracted/PROT/0867_battle_data.BIN # list all 186
./asset monster-archive extracted/PROT/0867_battle_data.BIN --id 1 --glb monster1.glb
The listing prints every monster's name 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. Formats: battle-data pack, monster animation.
Enemy stats are not in the player-battle files 0865/0866. If a hex editor shows enemy names like Evil Fly near the end of one of those files, that is an over-read tail (see below), not their real home. 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.
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).
Streamed audio (voice + ambience) → WAV
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_WAV
About 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. Format: XA-ADPCM.
Sound banks (instruments + SFX) → WAV
VAB banks hold the sampled instruments and sound effects (VAB):
./vab list extracted/PROT/<entry>.BIN
./vab extract extracted/PROT/<entry>.BIN --out vab_out --wav
On 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> # full event disassembly
./seq json <file> --offset 0x<N> # machine-readable parse
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. Format: PsyQ SEQ; which track is which: music-track disambiguation.
Movies → PNG frames
The disc's FMVs land in extracted/MOV/. Decode them frame-by-frame:
./mdec scan-str extracted/MOV/MV1.STR # list frames + dimensions
./mdec decode-str extracted/MOV/MV1.STR -o frames/ # frame_0001.png, ...
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). Background: cutscene subsystem.
Game-data tables from the executable
The game executable extracted/SCUS_942.54 carries the static stat tables. The asset hub prints them as readable, joined listings:
./asset item-tables extracted/SCUS_942.54 # item names + effects/stat bonuses
./asset spell-names extracted/SCUS_942.54 # spells/arts, MP cost, target
./asset steal-table extracted/SCUS_942.54 # what each monster can have stolen
./asset new-game extracted/SCUS_942.54 # starting party + inventory
Siblings: accessory-passive, sfx-table, level-up, worldmap-menu. Formats: item table, spell table, steal table, new-game template.
The dialog 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. Format: dialog font.
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 # classify every entry (run this first)
./lzs-decode probe extracted/PROT/<entry>.BIN # is it an LZS container?
./lzs-decode container extracted/PROT/<entry>.BIN out_dir/ # decompress every section
./asset decode extracted/PROT/<entry>.BIN --type-size 0x.. --offset 0x.. --mode lzs # one section
./asset stream extracted/PROT/<entry>.BIN # list a streaming container's chunks
./asset extract extracted/PROT/<entry>.BIN --out out_dir/ # unpack a streaming container's 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 writes each TIM aslzs<i>_off<HEX>.tim.
One trap worth knowing: LZS "decompresses without error" is not a validity signal - the 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?
Some PROT entries declare a TOC window far larger than their real on-disc footprint (the sector gap to the next entry), and prot-extract writes the full declared window - so those .BIN files carry the next entry's bytes in their tail. The clearest case is the battle-data cluster: entries 0865 (Gala) and 0866 (Terra) each declare ~16 MB but really own 0x6F000 / 0x17800, and both oversized windows spill into the monster archive 0867 - which is why enemy text shows up at the end of a player-battle file. prot-extract list flags every such entry with an OVR column and shows its true footprint next to the declared size. To resolve a specific offset - "which file really owns the bytes my hex editor is showing at 0x17855 of 0866?" - use locate:
./prot-extract locate extracted/PROT.DAT 0x17855 --in-entry 866 --cdname extracted/CDNAME.TXT
It translates the in-file offset to an absolute PROT.DAT offset, names the true owning entry (here 0867), and warns when the offset lands in an over-read tail rather than the entry's own data. Drop --in-entry to pass an absolute PROT.DAT offset directly. Browser equivalent: the asset viewer's "Locate a PROT.DAT offset" panel does the same lookup client-side against your own disc. To avoid the trap entirely, re-extract with prot-extract extract --clamp-footprint: every .BIN is trimmed to its true footprint so no file carries a neighbour's bytes (trailing overlays are kept); the default stays un-clamped so in-.BIN offsets match the TOC-declared windows locate --in-entry assumes.