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_only flag is zero.
Retail reader
FUN_8001A55C (decoder only - Sony's packer never shipped)
Parser
crates/lzs - decompress, tracked decode, parse_container, compress; CLI lzs-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.
One control byte governs the next eight items; a back-reference copies from the 4 KB ring buffer input stream control lit lit ref (2 B) lit ref (2 B) ... 8 items per control byte, LSB first ring buffer (4096 B) recent output (wraps at 0xFFF) zeros at start 0x000 0xFEE first write 0xFFF copies len bytes from window[base..]
Each control byte flags the next eight items as literal or back-reference. A back-reference names an absolute window position, not a distance behind the write cursor.

Token layout

ItemSizeBitsMeaning
control byte1bit 0 firstSet = next item is a literal; clear = next item is a back-reference. Refilled after 8 items.
literal1-Byte copied to the output and written into the window at the cursor.
back-reference2b0[7:0] + b1[7:4] = 12-bit window position; b1[3:0] = length − 3Copy 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

FunctionAddressWhat it provesDump
Retail decoderFUN_8001A55CThe complete algorithm above - ring size, 0xFEE start, control-byte order, token encodingfuncs/8001a55c.txt
Asset-type dispatcherFUN_8001F05CWhen the LZS path is taken (copy_only == 0)funcs/8001f05c.txt
Round-trip validationcrates/lzs disc-gated testsdecompress(compress(x)) == x over the whole PROT corpus; re-packed MANs fit their original spans-

Source of record: docs/formats/lzs.md.

See also