本文将带你了解世嘉 Dreamcast 与 PlayStation 2 游戏中出现的一种 LZSS 类压缩算法变体,看看这些游戏如何用更少的光盘空间存储贴图、音效、字体等资源。
背景:为什么游戏需要压缩?
在 20 世纪 90 年代末到 2000 年代初,游戏机主要依靠 CD-ROM 和 DVD-ROM 读取资源。与现代 SSD 相比,光盘不仅传输速度有限,随机寻道的延迟也更高。压缩资源可以减少存储空间占用和读取的数据量。
LZSS(Lempel–Ziv–Storer–Szymanski)是一类常见的字典压缩算法。它的核心思想很简单:重复出现的数据不必反复保存,只需记录它曾经出现的位置和长度。
例如,原始数据是:
| |
解压器可以先输出 ABC,再用一个引用表示“复制之前的数据 3 个字节”,重复两次即可还原原文。
因此,压缩数据通常包含两种内容:
- 明文(Literal):直接输出一个字节或一个字。
- 引用(Reference):从已经解压的数据中复制一段内容。
解压器需要知道每一段数据属于哪一种。BZ 和 BZW 的一个共同特点,就是使用**控制掩码(Control Mask)**来标记接下来应该执行什么操作。
8-bit BZ:一个控制字节,八次操作
控制掩码:决定接下来怎么解压
BZ 每次读取一个控制字节,其中的 8 个比特分别控制接下来的 8 次解码操作。
| |
解码时,解压器按照约定的位顺序逐位检查掩码。根据你逆向得到的实现,其规则是:
| |
例如,某一位为 1,解压器就读取一个字节并原样输出;如果为 0,则继续读取引用编码,确定从之前的数据中复制多少内容。
这里的关键是:控制掩码只负责告诉解压器执行哪种操作,实际要输出的数据仍然来自后续的明文或引用编码。
引用编码:短引用与长引用
当控制位为 0 时,BZ 根据下一个字节的最高位,判断引用采用哪种编码。
1. 短引用:一个字节
如果最高位为 1,这个字节本身就包含了回溯距离和复制长度。
| |
对应的解码公式为:
| |
其中:
length表示需要复制的字节数,范围为 2~9。offset是相对于当前输出位置的负偏移,范围为 -16~-1。
例如,offset = -3、length = 4,就表示从当前输出位置往前数 3 个字节,复制 4 个字节到输出末尾。
短引用只需占用一个字节,因此适合表示距离较近、长度较短的重复片段。
2. 长引用:两个字节
如果最高位为 0,解压器会再读取一个字节。两个字节共同表示回溯距离和复制长度。
| |
对应的解码公式为:
| |
其中,offset 的范围为 -128~-1,长度编码可表示 1~256。由于长度为 1 的情况被用作特殊标记,正常的长引用长度为 2~256。
与短引用相比,长引用多占用一个字节,但可以表示更远的回溯距离和更长的重复片段。
EOF:用特殊引用结束解压
BZ 用长引用中第二个字节为 0(即 length = 0)作为流结束标记。因为长引用的长度实际是 bVar2 + 1,bVar2 = 0 时本应表示"长度 1",这个无意义的短引用被"征用"为 EOF。这是一种典型的保留值复用设计——节省了一个字节的结束标记。
需要注意,这种 EOF 规则属于具体格式的约定,并不是所有 LZSS 算法都采用相同的结束方式。
为什么同时需要短引用和长引用?
可以把两种引用理解成两种不同长度的“快捷指令”:
- 短引用:只占一个字节,适合附近的小段重复数据。
- 长引用:占两个字节,能够表达更长的复制长度和更远的回溯距离。
BZ 的短引用只能回溯 16 个字节,而长引用可以回溯最多 128 个字节。这里的偏移是相对于当前输出位置计算的负偏移。
因此,不能把整个 BZ 算法的回溯范围简单理解为只有 16 字节;16 字节只是短引用所能覆盖的距离。
BZW:以 16-bit word 为单位
BZW 与 BZ 的基本思想相同,但它将解码单位从 8-bit 字节改成了 16-bit word(字)。
这意味着解压器不再一次处理一个字节,而是一次处理两个字节。相应地,控制掩码、明文读取和回溯复制也都需要按照 word 的边界进行处理。
控制掩码:一次控制 16 次操作
BZ 使用 8-bit 控制掩码,BZW 则使用 16-bit 控制掩码,每个掩码控制接下来的 16 次解码操作。
| |
这里的 swap_u16 会交换 16-bit 数值的高、低字节。
为什么需要交换?BZW 的格式是大端序设计(Saturn 血统),PS2 是小端序平台,因此掩码和引用字段必须做字节交换。字面字因为只是字节搬运,不需要交换。
明文与引用:一次读取一个 word
在 BZW 中,一次明文操作会读取一个 16-bit word,并将其写入输出缓冲区:
| |
如果控制位表示引用,则读取一个 word 作为引用编码。先交换引用编码的字节(原因同上),再解析长度和回溯偏移:
| |
其中:
length表示复制的 word 数量。offset表示相对于当前输出位置的负偏移,范围为 -256~-1。offset = -3表示从当前输出位置之前的第 3 个 word 开始复制。
这里的距离单位是 word,而不是字节。例如,回溯 3 个 word 就相当于回溯 6 个字节。
EOF:保留长度为 1 的编码
BZW 使用长度编码 1 作为特殊结束标记。因为长度计算公式为:
| |
当长度编码的低 8 位为 0 时,解码得到的长度就是 1。根据这套格式的约定,该编码被用作 EOF。
| |
这意味着正常的引用不会使用长度为 1 的编码。如果实际压缩器确实不会生成单 word 引用,那么这个编码就可以安全地用于结束标记。
最终输出:将 word 还原为字节流
解压完成后,BZW 需要把 word 数组转换成字节数组。按照当前实现,每个 word 先输出低字节,再输出高字节:
| |
例如,word 的数值为 0x1234,输出字节就是:
| |
原因同上。
BZ 与 BZW 的区别
| 特性 | BZ | BZW |
|---|---|---|
| 控制掩码 | 8 bit,控制 8 次操作 | 16 bit,控制 16 次操作 |
| 解码单位 | 1 byte | 1 word(2 bytes) |
| 短引用 | 1 字节,长度 2~9 | 无 |
| 长引用 | 2 字节,正常长度 2~256 | 1 word,正常长度 2~256 |
| 回溯距离 | 短引用 1~16 字节;长引用 1~128 字节 | 1~256 word,即 2~512 字节 |
| EOF | 长引用第二字节为 0 | 引用解码长度为 1 |
| 输出方式 | 直接输出字节 | word 展平为字节 |
表中的距离均指回溯距离的大小,而不是负偏移本身。
写在最后:老算法的现代价值
研究 BZ 与 BZW 的意义,不只是复原一批旧游戏资源,更在于理解游戏引擎如何在存储、读取和解码之间做出取舍。
首先,压缩格式的价值不只在于压缩率。控制掩码、固定宽度的引用编码和简单的回溯复制,都让解压器能够以相对直接的流程还原数据。对于资源读取频繁、内存有限的旧主机,这类设计有实际意义。不过,具体算法是否更快、更省内存,仍需要结合硬件、数据分布和实现方式判断。
其次,逆向分析可以帮助我们区分算法与封装。文件头、控制掩码、引用编码、结束标记和输出字节序,都是压缩格式的一部分。即使两个游戏使用相似的 LZSS 思路,只要其中某个约定不同,就可能无法共用同一个解压器。
最后,BZ 与 BZW 也提醒我们:面对年代久远、缺乏文档的游戏资源,不必一开始就猜测格式名称。找到解压函数,追踪输入读取、控制位判断、回溯复制和输出过程,再用实际样本验证,往往比仅凭文件扩展名或魔数更可靠。
对于复古游戏研究而言,能解释数据如何被编码、如何被还原,比给它贴上一个看似熟悉的算法名称更重要。
欢迎关注我的公众号,第一时间获取最新文章。
