At a glance

Pick the scenario that matches what you want out of the disc. Each is two or three commands.

I want...ToolScenario
Textures as PNGasset tim-scan + tim convert-dirTextures
Field / battle meshes as OBJasset tmd-scan + tmd dump-obj3D models
A monster with its animations, in Blenderasset monster-archive --glbMonsters
A whole town in Unity / VRChat / Blenderlegaia-engine export-glbWhole scenes
Voice lines and ambience as WAVxa demux-disc-allStreamed audio
Instrument samples and sound effectsvab extract --wavSound banks
The music sequencesseq find / seq infoBackground music
Movie framesmdec decode-strMovies
Item / spell / steal / new-game tables as textasset item-tables and siblingsGame-data tables
What every shop sells, with pricesasset shop-stockShop inventories
The dialog fontfont-extractDialog font
One pipeline stage at a timelzs-decode, asset decode/stream/extractManual chain

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

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_scan

tim_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-cluts

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

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

The 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 live in 0867, not in the player-battle files

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

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

Siblings: 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 count byte over-counts: it includes a trailing run of price-0 template ids - usually the "Ra-Seru Meta $N" placeholders 0x01/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 0x49 is 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> N names the content at extraction entry N - 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.

Reference 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:

1
Disc → files
./disc-extract extract "/path/to/disc.bin" extracted/
disc-extract
2
PROT.DAT → entries
./prot-extract extract extracted/PROT.DAT extracted/PROT --cdname extracted/CDNAME.TXT
prot-extract
3
Classify every entry
./asset categorize extracted/PROT - run this first.
asset
4
Is it LZS?
./lzs-decode probe extracted/PROT/<entry>.BIN
lzs-decode
5
Decompress every section
./lzs-decode container extracted/PROT/<entry>.BIN out_dir/
lzs-decode
6
One section
./asset decode extracted/PROT/<entry>.BIN --type-size 0x.. --offset 0x.. --mode lzs
asset
7
Streaming container
./asset stream <entry>.BIN lists chunks; ./asset extract <entry>.BIN --out out_dir/ unpacks the sub-assets.
asset

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 container dumps every section to out_dir/. asset decode pulls a single section instead: take the --type-size and --offset from an asset describe row of the same file, and pass --mode raw for the small uncompressed control sections (--mode lzs for the compressed ones). Reach for decode when you want one section, or when the container header table doesn't auto-walk cleanly.
  • Streaming/pack bundle - asset stream lists its chunks and asset extract unpacks 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 print magic MISMATCH on 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.
"Decompresses without error" is not a validity signal

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

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

See also