我用Python重写红色警戒2(一):我是如何击穿红警2(RA2)底层资产协议的
“就为了在屏幕上画出一个动员兵,我差点被 1999 年的 C++ 编译器逼疯。”
这是我最近在用 Python 和 Pygame 从零开始手写一款 2D RTS 游戏引擎(代号:ChronoStorm)时,发出的最绝望的感慨。
在这个连独立游戏都标配虚幻5、Unity,各种现成资产满天飞的时代,我偏偏选择了一条最原始、最硬核,也最折磨人的路:彻底逆向解析《红色警戒2》(Red Alert 2)的底层资产协议,用现代语言重构一个原汁原味的 Isometric(等距视角)引擎。
我本以为,读取一张 20 年前的 2D 像素图片能有多难?不就是写个 Parser 吗?
直到我一头撞上了 Westwood(西木头)当年布下的“叹息之墙”。
🧱 第一层地狱:RSA 与 Blowfish 的连环计
《红警2》的所有核心资产,都打包在一个叫 ra2.mix 的几百兆大文件里。这就像是一个巨大的集装箱,我的目标是把里面的 local.mix 拖出来,再从中找出动员兵的动画序列帧 cons.shp 和专属调色板 unittem.pal。
我写好了基础的二进制流读取代码,却发现读出来的数据全是一锅乱码。
原来,Westwood 在 1999 年就丧心病狂地在游戏资产包里使用了 RSA (320-bit) + Blowfish 的非对称加密!
在查阅了无数的散落文档和开源代码后,我得知游戏包的头部藏着一个 80 字节的 RSA 密钥块。我需要用一个写死在游戏程序里的庞大公钥(十六进制长度令人发指)去解密这 80 个字节,从而计算出真正的 56 字节 Blowfish 密钥,再去解密文件的字典索引。
我熟练地调出 Python 的 pycryptodome 库,把公钥塞进去,一顿位运算操作猛如虎。
结果控制台无情地吐出了一个错误: ❌ 解密失败,得到异常的文件数: 62256 (通常是乱码巧合)
一个红警的 MIX 包里怎么可能有六万多个文件?密码学是严谨的数学,错一个 bit,满盘皆输。
👻 第二层地狱:跨越 20 年的内存幽灵(Endianness Trap)
我开始怀疑是 Python 的大整数转换有问题,又或者是 RSA 的零填充(Zero Padding)在作祟。我反复修改着将整数转回 bytes 的逻辑,尝试了各种右对齐补零的方法。
在这期间,我去参考了著名开源引擎 OpenRA 的 C# 源码。我发现他们在解密 Blowfish 时,写了大量的 SwapBytes 函数来翻转字节序(Endianness)。
“原来如此!”我一拍大腿,Westwood 用的是非标准的小端序(Little-Endian)Blowfish!我立马在 Python 里手写了一个翻转 4 字节的魔改包装器:
运行!……依然是乱码。有时候文件数变成了 43224,有时候变成了 49940。
就在我即将绝望,准备放弃纯内存读取,直接用第三方工具把文件解压出来拉倒的时候,我看到了一个针对 RA2 mix 文件的 Python 库源码。
那一段源码像是一记响亮的耳光打在我的脸上:Westwood 用的就是最标准、最纯正的 Blowfish!根本不需要翻转字节序!
那为什么 OpenRA 要翻转?因为 C# 语言的 BinaryReader.ReadUInt32() 天生就是按系统底层的小端序读数据的,他们读反了,所以不得不翻转回来!而 Python 的 bytes 读取是最底层的流,根本不需要画蛇添足!
我自作聪明加的“魔改翻转器”,反而把原本正确的数据打乱了。
去掉所有多余的伪装,大道至简。一行 decrypted_blowfish_key += decrypted.rstrip(b"\x00") 秒杀了所有的对齐问题。
当控制台终于打印出 🔓 MIX 破译成功!真实包含文件数: 21 时,我长舒了一口气。

添加图片注释,不超过 140 字(可选)
🕳️ 第三层地狱:被藏起来的 4 个字节
你以为拿到字典索引就结束了?太天真了。
在解密出文件数为 21 后,引擎依然崩溃了:struct.error: unpack requires a buffer of 12 bytes。
我拿着计算器算了一遍又一遍。每个文件索引占 12 字节(4字节ID + 4字节偏移 + 4字节大小)。21 个文件应该是 252 字节。 我从 Blowfish 解密出的第一块 8 字节里,扣掉了 2 字节的文件数和 4 字节的数据总大小,剩下的 2 字节正好是字典的开头。
结果,我在计算剩余需要读取的解密块长度时,脑抽写错了一个减法,导致最后少读了 4 个字节!就这 4 个字节,让最后一个文件索引凑不够 12 字节,当场暴毙。
补上这 4 个字节后,ChronoStorm 引擎终于成功在内存中扒开了 ra2.mix 的外衣,并从中精准提取出了 local.mix,拿到了动员兵的原始文件 cons.shp。
🧥 扒掉动员兵的最后一件外衣:Format 80 LCW 压缩
当我把提取出的 cons.shp 喂给写好的解析器时,控制台再次给我泼了一盆冷水: IndexError: index out of range
《红警2》的单位图像,并不是直接存放像素数组的,而是被 Westwood 使用了一套祖传的 Format 80 (LCW - 类似 LZ77 字典压缩) 算法死死锁住。
我用 Python 复刻了这套包含各种位运算、短偏移和长偏移拷贝的解压算法。但是,由于 Python 列表切片赋值的底层机制(dst[dp:dp+count] = src[sp:sp+count]),如果文件提前结束,Python 会自作聪明地把我的数组“切短”。这就导致我在后续按坐标渲染像素时,直接越界报错。
没办法,我只能给解压算法加上最严苛的“防弹衣”边界保护:
def _decompress_format80_safe(self, src, expected_size):
"""真正的 LCW 算法,加入了绝对安全的越界保护"""
dst = bytearray(expected_size)
sp = dp = 0
while sp < len(src) and dp < expected_size:
cmd = src[sp]
sp += 1
if cmd == 0x80:
break
elif (cmd & 0x80) == 0:
count = cmd
if count == 0: continue
# 安全的字节拷贝,防止切片缩小数组
for _ in range(count):
if sp < len(src) and dp < expected_size:
dst[dp] = src[sp]
dp += 1
sp += 1
elif (cmd & 0x40) == 0:
count = (cmd & 0x3F) + 3
if sp >= len(src): break
offset = src[sp]
sp += 1
pos = dp - offset
for _ in range(count):
if pos >= 0 and dp < expected_size:
dst[dp] = dst[pos]
if dp < expected_size: dp += 1
pos += 1
else:
count = (cmd & 0x3F) + 3
if sp + 1 >= len(src): break
offset = struct.unpack('<H', src[sp:sp+2])[0]
sp += 2
pos = dp - offset
for _ in range(count):
if pos >= 0 and dp < expected_size:
dst[dp] = dst[pos]
if dp < expected_size: dp += 1
pos += 1
return dst
除此以外,我还发现帧头不仅不是 22 字节,而是 24 字节;图像中除了 Format 80,还混杂着大量的 RLEZero 透明像素压缩……
在这场与 1999 年程序员的跨时空斗法中,我见识到了在那个内存极其昂贵的年代,前辈们是如何榨干每一个 bit 的价值的。
🚩 降临
当所有的拦路虎都被清理干净,引擎的事件循环(Event Loop)开始转动。
纯黑的屏幕上,先是浮现出我用代码绘制的绿色等距视角(Isometric)菱形网格。紧接着,那个穿着棕色大衣、戴着防毒面具、手握波波沙冲锋枪的苏联动员兵,带着 20 年前最原汁原味的复古像素色彩,稳稳地降落在了网格坐标 (5, 5) 的位置。

那一刻,之前所有的报错、绝望、想砸键盘的冲动,全都烟消云散。这大概就是独立游戏开发最纯粹的浪漫吧——你亲手创造的虚拟空间里,终于有了第一个呼吸着的生命。
虽然现在他只是静静地站在那里,但接下来的日子里,我会让他跟随鼠标转动方向、加入 A* 寻路算法在菱形网格上移动、甚至为他配备开火逻辑。
尾声
以上,就是我的 2D RTS 引擎 ChronoStorm 开发日志的“最痛”一集。
如果你对独立游戏开发感兴趣,对 AI 如何介入并加速底层游戏引擎构建的过程感到好奇,或者只是单纯地想看一个游戏开发者如何被各种离谱的 Bug 疯狂折磨然后反杀,欢迎关注我的频道/专栏
在接下来的更新中,我会把这段文字拍成实机演示的 Devlog(开发日志)视频。我会记录下如何用几行三角函数让动员兵 360 度转身,以及如何手搓出一套丝滑的 RTS 寻路系统。
这只是一个开始,指挥官们,Welcome Back。
GitHub项目地址:ChronoStorm引擎
更多推荐


所有评论(0)