RefPack compression

Most members of resource.hog are compressed with RefPack, Electronic Arts' LZ77 variant, also called QFS and named FB10 after the two bytes its header starts with.

A stream is a sequence of commands. Each copies a short run of literal bytes straight from the input, then usually repeats a run of bytes that already appeared in the output. Four command encodings cover progressively longer matches and distances, and a fifth ends the stream.

Size Field
1 Flags
1 0xFB
3 or 4 Compressed size, only when the flags say so
3 or 4 Decompressed size

Sizes are big-endian. The flag bits that matter:

Bit Meaning
0x01 A compressed size precedes the decompressed size
0x80 Sizes are 4 bytes rather than 3

Every stream in this game uses 10 FB: neither bit set, so the header is five bytes and carries only the decompressed size. It is also the one form the game expands: hog_read_file (0x004C7F60) reads a member's first two bytes big-endian and compares them with 0x10FB, and takes any other member as it is stored. refpack.gameExpands makes the same test. The longest header, with both bits set, is ten bytes.

Commands

The first byte selects the encoding. In the table, literals is how many bytes are copied from the input before the match, length is how many bytes the match repeats, and distance is how far back in the output it starts.

First byte Bytes Literals Length Distance
0x00-0x7F 2 b0 & 3 ((b0 >> 2) & 7) + 3 ((b0 & 0x60) << 3) + b1 + 1
0x80-0xBF 3 (b1 >> 6) & 3 (b0 & 0x3F) + 4 ((b1 & 0x3F) << 8) + b2 + 1
0xC0-0xDF 4 b0 & 3 ((b0 & 0x0C) << 6) + b3 + 5 ((b0 & 0x10) << 12) + (b1 << 8) + b2 + 1
0xE0-0xFB 1 ((b0 & 0x1F) << 2) + 4 none none
0xFC-0xFF 1 b0 & 3 none none, and the stream ends

So the reachable ranges are matches of 3 to 10 within 1 KiB, 4 to 67 within 16 KiB, and 5 to 1028 within 128 KiB; literal runs are 4 to 112 bytes and always a multiple of four, except for the up to three literals a short match or the final command can carry.

A match may overlap the bytes it is producing. A distance of one with a length of five repeats the previous byte five times, so the copy has to proceed one byte at a time rather than as a block move.

Decompressing

sltool hog extract <archive> <dir>     # decompresses members as it extracts

The decompressor is src/formats/refpack.zig. It validates as it goes: a command that runs past the end of the input, a match reaching before the start of the output, or a total that disagrees with the header's decompressed size are all rejected rather than producing truncated output.

Every compressed member of resource.hog decompresses to exactly the size its header declares.

Compressing

refpack.compressAlloc writes a stream of the one form the game expands: 10 FB, a 3-byte size, the commands, and a terminator with nothing after it. It is a lazy parse over hash chains, not EA's compressor, so its streams differ from the shipped ones while expanding to the same bytes. Each match takes the smallest form that holds it, as every match in the shipped streams does. Matches reach back at most 131071 bytes, the furthest the shipped streams go, though the long form's field holds one more.

Literals are written as runs of 4 to 112 bytes, and the last 0 to 3 before a match ride in its command, or in the terminator at the end. A payload of 16777216 bytes or more has no stream: the game reads only 3-byte sizes.

The encoder keeps inPlaceExcess as it writes, from what each command stands for, rather than reading its stream back. refpack.Compressor compresses one payload after another with the same match finder, as the .HOG writer does for an archive's members; compressAlloc is one for a single payload.

What the game's expansion demands

hog_unpack (0x004C8480) allocates the expanded size plus 0x2800 bytes, reads the stream to the end of that block, and calls refpack_expand (0x004CC350) to expand it from the start of the same block. The expansion checks nothing, so a stream loads only where the output never overtakes the input still to read. At the start of the stream, and after each command, the input still to read less the output still to write is at most 0x2800: refpack.inPlaceExcess is the largest of those values. Two shapes break it. A stream longer than its data by more than 0x2800 puts the read before the block. Or a run of matches puts the output far ahead, and literals then take the input past it, after which the expansion reads its own output as commands and overwrites the heap.

The literals of a payload that does not compress take 1 byte in 112 for their control bytes, so compressAlloc fails with error.NotInPlace for one of 1146212 bytes or more, and for a compressible one with a tail of that size. Every shipped stream keeps within the bound, the tightest, smp3d.fat, at 486.

Where there is no stream, or none that loads, the member is stored as it is, which the game reads verbatim. A payload that itself begins 10 FB cannot be stored so: the game would take it for a stream. refpack.loadsInPlace tells whether a stream expands to its declared size within the bound, which the .HOG writer checks before it keeps a member that begins 10 FB (writing an archive).

The flags byte of a stream must be exactly 0x10. hog_read_file expands a member only when it begins 10 FB, and refpack_expand takes bit 0 as the compressed size's presence, which would make it read that size as the expanded one, and has no path for bit 7 (4-byte sizes), which is why a member holds at most 16777215 bytes.

The bound, its two shapes and the shipped streams' values are from a comment on #113 by the Starlancer-OSS project (its documentation is licensed CC BY 4.0), which ran refpack_expand on generated streams under emulation: 1146211 bytes of literals load and 1146212 do not, and neither does 2000000 zero bytes followed by a tail of literals that takes the input 2 bytes past the slack. Unverified: here, where OpenReliant cannot run refpack_expand; the tests check inPlaceExcess against the same figures.

Edit this page on GitHub. The documentation is under CC BY-SA 4.0.