Legaia LZS compression Confirmed
Every time the game loads a town, a battle arena, a monster or a menu texture, the first thing it does is unpack it. Almost everything on the disc - meshes, textures, scene scripts, the whole monster archive - is squeezed with one small compressor so that more of the game fits on a single CD and streams off it faster. That compressor is a Legaia-flavoured LZSS: the classic scheme that replaces repeated byte runs with short back-references into a sliding window of recent output. It is reverse-engineered byte-for-byte from the executable's decompressor (how we know); the from-scratch implementation also carries the encoder the disc patcher needs to put edited assets back.
At a glance
- In the game
- Invisible to the player - it is the unpack step behind every loading pause. Meshes, textures, scene MANs and the monster archive all pass through it.
- Magic / marker
- None - a bare LZSS bitstream. The decoded bytes carry the magic (TIM, TMD, MES, ...), which is why you must check them.
- Lives in
- Wrapped around most PROT.DAT entries; selected by the asset-type dispatcher when its
copy_onlyflag is zero. - Retail reader
FUN_8001A55C(decoder only - Sony's packer never shipped)- Parser
crates/lzs-decompress,decompress_tracked,parse_container/decompress_container_strict,compress/compress_optimal; CLIlzs-decode- Confidence
- Confirmed - traced from the decoder's disassembly and round-tripped over the whole PROT corpus.
How it works
LZSS keeps a window of the most recent output. Each item in the stream is either a literal (one byte, copied straight through) or a back-reference ("copy n bytes from position p of the window"). A flag byte ahead of every eight items says which is which. The stream carries no length and no end marker, so the caller always supplies the decompressed size. Legaia's variant makes three specific choices:
- 4096-byte sliding ring buffer (the dictionary of recent output that back-references copy from), initialised to zero.
- Initial write position 0xFEE (4078).
- Control byte: 8 bits, LSB-first - bit set = literal, bit clear = back-reference.
Token layout
| Item | Size | Bits | Meaning |
|---|---|---|---|
| control byte | 1 | bit 0 first | Set = next item is a literal; clear = next item is a back-reference. Refilled after 8 items. |
| literal | 1 | - | Byte copied to the output and written into the window at the cursor. |
| back-reference | 2 | b0[7:0] + b1[7:4] = 12-bit window position; b1[3:0] = length − 3 | Copy 3..18 bytes starting at the absolute window position, each also written at the cursor. |
A back-reference packs a 12-bit absolute window position and a 4-bit length into two bytes:
Pseudocode
let mut window = [0u8; 4096];
let mut window_pos = 0xFEE;
let mut control = 0u32;
while out.len() < expected_output_size {
if (control & 0x100) == 0 {
control = (input[src] as u32) | 0xFF00;
src += 1;
}
if (control & 1) != 0 {
// LITERAL: copy 1 byte
let v = input[src]; src += 1;
out.push(v);
window[window_pos] = v;
window_pos = (window_pos + 1) & 0xFFF;
} else {
// BACK-REF: 2 bytes encode (12-bit absolute window position, 4-bit length-3)
let b0 = input[src] as u32;
let b1 = input[src + 1] as u32;
src += 2;
let base = b0 | ((b1 & 0xF0) << 4);
let len = (b1 & 0x0F) + 3;
for n in 0..len {
let v = window[(base + n as u32) as usize & 0xFFF];
out.push(v);
window[window_pos] = v;
window_pos = (window_pos + 1) & 0xFFF;
}
}
control >>= 1;
}
The 0xFF00 mask is how the control register knows when to refill. Each shift right moves one of the eight set high bits down; after eight shifts bit 8 is clear, and the test at the top of the loop reads the next flag byte.
A back-reference copies byte by byte and writes each byte to the ring as it goes, so a reference may overlap its own output (the run-length case). The copy stops early if it reaches the expected output size.
Container format
Some PROT entries hold several independently compressed sections behind a small header. The header is the asset descriptor layout: two u32 header words, then 8-byte (size, byte_offset) pairs, where the size's low 24 bits are the section's decompressed length and its high byte is the asset type.
| Function | Behaviour |
|---|---|
parse_container | Scans the pair table heuristically; stops at a zero size or offset, an offset past the file, or a non-ascending offset. |
decompress_container | Decodes every section it finds. |
decompress_container_strict | Also rejects a section whose input consumption runs past the next section's offset. Use this one for detection. |
decompress_tracked | Decodes one stream and returns the input bytes consumed, which is what the strict gate compares. |
Encoding (re-packing)
The retail game ships only the decoder; there is no Sony encoder to reverse. legaia_lzs::compress is an encoder written fresh for re-packing edited assets - it is what lets the randomizer / disc patcher put a modified monster, script or texture back into the same slot on the disc:
- An LZSS matcher with one-step lazy matching (defer a match by a byte when the next position yields a strictly longer one) whose output the retail decoder accepts byte-for-byte - not a bit-exact clone of Sony's packer.
- Correctness criterion:
decompress(compress(x)) == x, checked by round-tripping every compressed entry on the disc. - The lazy step matters for in-place editing. A purely greedy parse overshoots a scene MAN's original footprint by a handful of bytes, and those footprints have no compressed slack; with the lazy step every scene MAN but one fits its exact original span.
compress_optimalis a slower shortest-encoding parse (a dynamic program over the token graph). Its output is never longer thancompress's, and it closes the last couple of bytes on the few retail streams whose footprint has zero slack.
A linear-history match at distance d maps onto the ring-buffer back-reference position (0xFEE + i - d) & 0xFFF, where i is the output position; capping the emitted distance at 4096 - 18 keeps every in-copy read (including the self-overlapping run-length case where d < len) unambiguous. It does real compression (not literal-only), so re-packed streams fit the slack in fixed-size slots like the monster archive's 0x14000-byte records - the story told in the disc-patching write-up on LZS slots.
Where LZS is consumed
The asset-type dispatcher - the routine that routes each loaded asset to its format handler - calls the LZS path when its copy_only flag is zero. See asset type dispatcher. The descriptor-pair containers above are walked by FUN_80020224 (asset descriptor).
How we know
The algorithm is read straight off the retail decoder's disassembly, and the from-scratch decoder and encoder round-trip every LZS entry on the disc.
Provenance: functions and dumps
| Function | Address | What it proves | Dump |
|---|---|---|---|
| Retail decoder | FUN_8001A55C | The complete algorithm above - ring size, 0xFEE start, control-byte order, token encoding | funcs/8001a55c.txt |
| Asset-type dispatcher | FUN_8001F05C | When the LZS path is taken (copy_only == 0) | funcs/8001f05c.txt |
| Round-trip validation | crates/lzs | decompress(compress(x)) == x over the whole PROT corpus; re-packed MANs fit their original spans | - |
Source of record: docs/formats/lzs.md.