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,trackeddecode,parse_container,compress; 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 control byte ahead of every eight items says which is which. 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 (3054) - so the first back-references can only reach the zero-filled tail of the buffer.
- 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. |
Pseudocode
let mut window = [0u8; 4096];
let mut window_pos = 0xFEE;
let mut control = 0u32;
while !done {
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 above is the trick that lets the control register tell the decoder when to refill: every shift right pulls a 1 bit into bit 8, and after 8 shifts bit 8 reaches the test position and triggers a refill from input[src].
Container format
Some PROT entries are not one stream but several: a length-prefixed array of independently-compressed sections concatenated together (the player.lzs-style wrapper the player battle files use). crates/lzs::parse_container walks that wrapper; the standalone-shaped containers described in asset descriptor are recognised by the same crate.
Encoding (re-packing)
The retail game ships only the decoder; there is no Sony encoder to reverse. crates/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, validated by a disc-gated round-trip over the real PROT corpus. - The lazy step matters for in-place editing: it packs tightly enough that a re-packed asset fits its original footprint even where that footprint has no compressed slack (every scene MAN but one fits its exact original span; a purely greedy parse overshot each by a handful of bytes).
A linear-history match at distance d maps onto the ring-buffer back-reference base (0xFEE + i - d) & 0xFFF; capping the emitted distance at 4096 - MAX_MATCH keeps every in-copy read (including the self-overlapping RLE 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. Standalone-shaped LZS containers (with the descriptor-pair walker in asset descriptor) are also recognised by crates/lzs.
How we know
| 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 disc-gated tests | decompress(compress(x)) == x over the whole PROT corpus; re-packed MANs fit their original spans | - |
Source of record: docs/formats/lzs.md.