逆向工程NovaLogic的Comanche地形图
NovaLogic的Comanche游戏以一种伪装格式存储地形,并非真正的PCX文件。本文逐字节拆解地图,解码RLE数据流和末尾隐藏的调色板,以重建可渲染的地形。
中文
复制

NovaLogic 的 Comanche 所用的 Voxel Space 地形渲染技术,是我最喜欢的算法之一。本文讲述解码原版游戏文件的过程,包括渲染地形所需的 heightmap 和 color 数据。

2026 年 9 月 29 日 · 15 分钟阅读

我一直着迷于 3D 地形渲染,着迷于计算机图形重建地貌的方式。有时我觉得这份着迷来自我在巴西南部潘帕斯草原长大的经历——那里的自然景色一直铺展到视线尽头。丘陵、植被、开阔的地平线,还有深浅不一的绿色林地与 Araucaria 树。

Campos de Cima da Serra。巴西 Rio Grande do Sul。
另一些时候,我又怀疑,渲染出第一片 OpenGL 地形时的那份激动,不过是在圆另一个梦:看到那些未被触碰的风景,没有人类留下的伤疤,也没有步步紧逼的所谓进步。借助计算机图形,我可以想象另一个世界……一个像我记忆中的世界,而不是我被迫看着在眼前一点点崩塌的那个。

使用旧式(固定管线)OpenGL 1.4 的 3D 地形示例
话虽如此,我并不是从用 OpenGL 画漂亮的 3D 地形起步的。90 年代初没有硬件加速,也没有高分辨率显示器。那时候每个像素都得靠软件(CPU)手工绘制。那是 MS-DOS 游戏、VGA 与 SVGA 图形、低多边形、平面着色和抖动 3D 世界的黄金年代。
当时的飞行模拟游戏大多用多边形渲染地形。每个 3D 物体都由顶点构成,这些顶点拼出多边形场景,再经过投影和光栅化,逐扫描线、逐像素地画出来。

F29 Retaliator by Digital Image Design
在那个多数 3D 游戏还在用多边形图形的年代,NovaLogic 的 Comanche: Maximum Overkill 已经能画出连绵起伏的山脉、幽深的峡谷和带明暗的谷地,看上去几乎像照片,而且这一切跑在家用 386 机器上。
背后的技术叫 Voxel Space,由 Kyle Freeman 创造。这大概是我有史以来最喜欢的算法之一,我们之前的一期视频里已经讲过它的原理:
Comanche Terrain Rendering (Voxel Space Algorithm)
如果你看过我们的 Voxel Space 教程,就知道我们用了一组 1024×1024 的图像:一张颜色图,以及每个层级一张高度图。
可你有没有想过,这些图像是从哪来的?游戏里从来没有附带过任何 GIF 文件。要生成那些 GIF,我们得把它们从原始游戏数据里挖出来。
这篇文章要做的正是这件事。我们会打开 1992 年那款游戏附带的文件,查看原始字节,弄清它的格式,然后写一个小的 ANSI C 程序,把它们转成任何看图软件都能打开的 BMP 图像。你会看到,答案远没有你想象的那么玄乎——而这本身就是个教训。
盯紧目标!
在打开十六进制编辑器之前,先弄清楚最终结果该长什么样,会有帮助。Voxel Space 地形只需要两样数据:
- 高度图:一张网格,每个字节是该点处的地形高度。
- 颜色图:一张同样大小的网格,每个字节是该点处地面的颜色,光照和阴影已经烘焙进去了。

两张网格都是 1024×1024。也就是每张图正好 1,048,576 字节,即 1 MB。颜色图用的是 8 位索引色,所以每个字节不是 RGB 值,而是 256 色调色板里的一个索引。这正好适合 VGA 的 mode 13h,它同样是 256 色。
记住这个数字:1,048,576。只要找到能解压出正好这么大体积的东西,方向就对了。
翻一翻游戏目录
原版游戏的目录是典型的 90 年代初风格,满是 8.3 文件名(名字 8 个字符加扩展名 3 个字符)。在我们手上这份里,能看到若干 .RLE 文件(大概是精灵图和音效)、.MIS 文件(多半是任务数据),还有一堆 .DTA 文件。其中八个文件光看体积就很扎眼:

这些文件似乎是成对出现的,编号 1 到 4,前缀分别是 C 和 D。
我第一个猜测是 C 代表 "color",D 代表 "depth"(高度图)。它们全都不到 1 MB,所以里面装的东西一定是压缩过的。
逆向一个文件格式时,我做的第一件事永远是用十六进制编辑器看文件开头几个字节。下面是 C1.DTA 的开头:

前八个字节拼出来是 Kyle DTA。这是 Kyle Freeman 本人留下的签名,每个地图文件开头都有。看起来四个 C 文件和四个 D 文件也都是同样的开头。
剩下的部分又给了三条线索:
- 偏移
0x08处是ff 03 ff 03。按 16 位小端整数读,就是 1023 和 1023,跟 1024×1024 近得令人生疑。 - 偏移
0x42处是00 04,也就是 1024。 - 头部似乎到偏移
0x80(128)结束。一段零字节之后,数据的性质完全变了。
头部之后紧接着的内容是这样的:
00000080: 01 02 c2 01 02 01 c5 03 01 02 01 03 01 02 c2 01 ................
00000090: 02 04 c2 02 01 02 01 03 c2 01 02 01 c7 03 02 04 ................
注意有多少字节是 C2、C5 或 C7,以及每个这样的字节后面都跟着一个看起来普普通通的小数值。如果你跟 DOS 时代的图像格式打过交道,这里应该已经心里有数了。
伪装起来的 PCX
128 字节的头部、以最大坐标形式存储的图像尺寸(1023 而不是 1024)、以及一个高两位经常被置位的字节流——这些全是 PCX 的特征。PCX 是 ZSoft Paintbrush 的格式,80 年代末到 90 年代初在 DOS 上遍地都是。

ZSoft PC Paintbrush
注意: PCX 是 PiCture eXchange 的缩写,由位于美国佐治亚州玛丽埃塔的 ZSoft 公司开发——这家公司如今已经不存在了。它曾是 PC Paintbrush 的原生文件格式,也是最早被广泛接受的 DOS 图像标准之一。后来它自然被更先进的图像格式取代,比如 BMP、JPEG 和 PNG。PCX 文件通常存储调色板索引图像,颜色数从 2 色、4 色到 16 色、256 色不等,不过该格式后来也扩展出了真彩色(24 位)图像的记录能力。
一个正常的 PCX 文件以字节 0x0A(ZSoft 的“厂商”ID)开头,随后是版本号、编码标志和每像素位数。接下来是四个 16 位坐标:xmin、ymin、xmax 和 ymax。这些字段加起来正好占 8 个字节。
看起来 NovaLogic 就是这么干的!他们拿了一个标准的 8 位 PCX 文件,把开头 8 个字节覆盖成了文本 Kyle DTA。其余字段都还在原来的位置上:
| 偏移 | 大小 | PCX 字段 | C1.DTA 中的值 |
|---|---|---|---|
0x00 | 8 | 厂商、版本、编码、每像素位数、xmin、ymin | 被替换为 Kyle DTA |
0x08 | 2 | xmax | 1023 |
0x0A | 2 | ymax | 1023 |
0x0C | 4 | 水平和垂直 DPI | 1024、768(被忽略) |
0x10 | 48 | 16 色 EGA 调色板 | 未使用 |
0x40 | 1 | 保留 | 0 |
0x41 | 1 | 颜色平面数 | 1 |
0x42 | 2 | 每扫描行字节数 | 1024 |
0x44 | 60 | 调色板信息与填充 | 全为零 |
0x80 | ... | 压缩后的像素数据 | RLE 流 |
宽度是 xmax + 1,高度是 ymax + 1,也就是 1024×1024。一个平面、每像素一字节,意味着每个像素对应一个 8 位索引——这正是我们要找的东西。
为什么要这样藏一个 PCX 文件?只能猜了。也许是为了不让我们用 Deluxe Paint 打开地图,也许只是让加载程序借此确认拿到的是正确类型的文件。
不过这种伪装只停留在表面。只要把标准 PCX 文件头的那 8 个字节补回去(0a 05 01 08 00 00 00 00),再把文件重命名为 .PCX,任何支持 PCX 的图像查看器都能打开这张地图。

ZSoft PC Paintbrush 打开 C1.DTA 颜色表
有意思的是:DPI 字段在各文件之间并不一致。C2.DTA 写的是 800×600,而 C3.DTA 和 C4.DTA 写的是 1024×1024。这些值多半只是记录了美术师保存文件时所用工具的设置。
解码 RLE 流
PCX 文件格式用的是一种再简单不过的压缩方案:游程编码(RLE)。
如果你上过我们的 NES 汇编编程课程,大概还记得用 RLE 解码卡带里的关卡数据。思路很简单……不存 05 05 05 05 05 05 05,而是存 07 05(“七个 05”)。地形纹理中相邻像素值相同的可能性很大,所以这有望省下相当可观的空间。解码开销也极低,这在 386 PC 上很重要。
PCX 的规则是:
- 如果某个字节的最高两位被置位(
b >= 0xC0),这个字节就是计数器。它的低六位表示后面那个字节要重复多少次。 - 否则,这个字节就是字面像素值,原样复制。
来解码前面看到的那段数据的开头,01 02 c2 01 02 01 c5 03:
01 -> 01
02 -> 02
c2 01 -> 01 01 (0xC2 & 0x3F = 2 copies)
02 -> 02
01 -> 01
c5 03 -> 03 03 03 03 03 (0xC5 & 0x3F = 5 copies)
有一个坑值得提一下!如果单个像素的值是 0xC0 或更高呢?解码器会把它误认成计数器。编码器避免这个问题的办法是:这类值一律写成游程为 1 的形式,C1 D7 就表示一个值为 0xD7 的像素。由于一个游程最长只能是 63 个像素,你也会看到同一种颜色的长条被拆成好几段游程。
在 C 里,整个解码器一个循环就够了。data 存放整个文件,我们从 128 字节文件头之后开始读:
/*****************************************
* PCX run-length decoding routine
* Starts right after the 128-byte header
******************************************/
pos = 128;
n = 0;
while (n < total && pos < size) {
unsigned char b = data[pos++];
if ((b & 0xC0) == 0xC0) {
int count = b & 0x3F;
unsigned char value = data[pos++];
while (count-- && n < total) {
img->pixels[n++] = value;
}
} else {
img->pixels[n++] = b;
}
}
在 Comanche 的地图里,游程从不跨行延续,所以我们可以把整张图当作一条长流来解码。正好 1,048,576 个像素之后循环结束,这正是我们希望看到的数字。
藏在末尾的调色板
我们拿到了上百万字节的颜色索引,但索引指向什么?文件头里那 48 字节的调色板只够放 16 种颜色,而 Comanche 的地图根本不用它。
PCX 版本 5 把调色板放在文件最末尾,以此支持 256 色图像。格式很简单:一个标记字节 0x0C,后面跟 256 组 RGB 三元组(768 字节)。我们的解码器正好停在 1,048,576 像素处,此时文件里还剩多少字节?八张图全都是 769 字节,也就是标记加调色板。
/***********************************
* 256-color palette: Marker 0x0C.
* Followed by 768 bytes at the end
************************************/
if (size >= 769 && data[size - 769] == 0x0C) {
memcpy(img->palette, data + size - 768, 768);
}
如果你打算在真实的 VGA 硬件上使用这些颜色,有个细节值得注意:PCX 每个分量存 8 位(0 到 255),而 VGA DAC 每个分量只接受 6 位(0 到 63)。在 mode 13h 下把调色板送到端口 0x3C8/0x3C9 时,要先把每个值右移 2 位。
有个发现挺有意思:高度图文件也有调色板!而且不是简单的灰度渐变,而是一张伪彩色调色板,按每 16 级色阶分成若干色带:先是蓝色,然后是橙色,等等,再往上还有一条灰色带,未使用的条目则填成洋红。用这个调色板在画图程序里打开一个 D 文件,你会看到一张等高线图,每条色带覆盖 16 个高度单位。我会试着向 Kyle Freeman 本人求证,但我完全可以想象 NovaLogic 的美术就是用这个视图来绘制和检查地形文件的。
对我们来说,先忽略高度调色板,改用普通灰度渐变,这样像素值就是高度:黑色是最低点,越亮越高。四张图的高度只从 0 到大约 120,所以图像看起来会相当暗。这很正常:这些值是原始海拔,不是给人看的图片。
写一个 8 位 BMP
理解了 PCX 格式之后,生成这些文件的 BMP 版本应该不难。
现在内存里已经有了像素和调色板,接下来要把它们存成现代工具能读懂的格式。我用 BMP,因为未压缩的 8 位 BMP 大概是最容易手写的图像格式:两个小头部、一张调色板,再加上原始像素。
结构如下:
| 部分 | 大小 | 内容 |
|---|---|---|
BITMAP FILEHEADER | 14 字节 | BM 签名、文件大小、像素数据偏移量 |
BITMAP INFOHEADER | 40 字节 | 宽度、高度、1 个平面、每像素 8 位、无压缩 |
| 颜色表 | 256 × 4 字节 | 每个颜色按蓝、绿、红、0 排列 |
| 像素数据 | 高度 × 行大小 | 每像素一字节,行填充到 4 字节的倍数 |
有三个坑,第一次写的人都会踩:
- BMP 存储颜色用的是 BGR,不是 RGB,而且每个颜色占 4 字节而不是 3 字节。
- 高度为正时,行是自下而上存储的:图像的最后一行在文件里排在最前面。
- 所有字段都是小端序。我们会逐字节写入,这样无论 CPU 的字节序如何,代码行为都一致。
先写两个辅助函数,用于按小端序写入 16 位和 32 位数值:
static void write_u16(FILE *f, unsigned int v) {
fputc(v & 0xFF, f);
fputc((v >> 8) & 0xFF, f);
}
static void write_u32(FILE *f, unsigned long v) {
write_u16(f, (unsigned int)(v & 0xFFFF));
write_u16(f, (unsigned int)(v >> 16));
}
下面是写出整个文件的函数:
static int save_bmp(const char *filename, const image_t *img) {
FILE *f;
int i, y;
unsigned long row_size = (img->width + 3) & ~3UL; /* rows padded to 4 bytes */
unsigned long pixel_offset = 14 + 40 + 256 * 4;
unsigned long file_size = pixel_offset + row_size * img->height;
f = fopen(filename, "wb");
if (!f) {
return 0;
}
/* BITMAPFILEHEADER (14 bytes) */
fputc('B', f); fputc('M', f);
write_u32(f, file_size);
write_u32(f, 0); /* reserved */
write_u32(f, pixel_offset);
/* BITMAPINFOHEADER (40 bytes) */
write_u32(f, 40);
write_u32(f, img->width);
write_u32(f, img->height); /* positive = rows stored bottom-up */
write_u16(f, 1); /* planes */
write_u16(f, 8); /* bits per pixel */
write_u32(f, 0); /* no compression */
write_u32(f, row_size * img->height);
write_u32(f, 2835); /* 72 DPI */
write_u32(f, 2835);
write_u32(f, 256); /* colors used */
write_u32(f, 0);
/* palette: BMP wants blue, green, red, reserved */
for (i = 0; i < 256; i++) {
fputc(img->palette[i * 3 + 2], f);
fputc(img->palette[i * 3 + 1], f);
fputc(img->palette[i * 3 + 0], f);
fputc(0, f);
}
/* pixel rows, last row first */
for (y = img->height - 1; y >= 0; y--) {
unsigned long pad;
fwrite(img->pixels + (long)y * img->width, 1, img->width, f);
for (pad = img->width; pad < row_size; pad++) {
fputc(0, f);
}
}
fclose(f);
return 1;
}
我们的地图宽 1024 像素,本身就是 4 的倍数,所以填充循环从不会执行。我还是保留了它,这样函数对任意宽度都适用。
那 GIF 呢?
我们在最初的 voxel space 教程/代码里用的图片是 GIF。那为什么不直接写 GIF?因为 GIF 用 LZW 方法压缩像素,而一个支持可变码长的正确 LZW 编码器,本身就够写一整篇文章了。
一点历史:90 年代的大部分时间里,LZW 都被 Unisys 的专利覆盖。1994 年底 Unisys 宣布要收取授权费时,外界的反应之大,直接催生了 PNG 格式。
好消息是我们不需要它。我们的 BMP 是 8 位索引加 256 色调色板,和 GIF 完全一样,转换过去不会有任何损失。用 ImageMagick 之类的工具就能搞定。
整合起来
把所有东西粘成一个小型命令行工具。每个解码后的图像用一个简单的 struct 保存:
typedef struct {
int width, height;
unsigned char *pixels; /* width * height palette indices */
unsigned char palette[768]; /* 256 RGB triplets, 8 bits each */
} image_t;
加载器把整个文件读进内存,检查 Kyle DTA 签名,从文件头读出尺寸,然后运行前面看到的 RLE 循环和调色板拷贝:
static unsigned int read_u16(const unsigned char *p) {
return p[0] | (p[1] << 8);
}
static int load_dta(const char *filename, image_t *img) {
FILE *f;
long size, pos, n, total;
unsigned char *data;
f = fopen(filename, "rb");
if (!f) {
return 0;
}
fseek(f, 0, SEEK_END);
size = ftell(f);
fseek(f, 0, SEEK_SET);
data = (unsigned char *)malloc(size);
if (fread(data, 1, size, f) != (size_t)size || memcmp(data, "Kyle DTA", 8) != 0) {
fclose(f);
free(data);
return 0;
}
fclose(f);
img->width = read_u16(data + 8) + 1; /* xmax + 1 */
img->height = read_u16(data + 10) + 1; /* ymax + 1 */
total = (long)img->width * img->height;
img->pixels = (unsigned char *)malloc(total);
/* ... RLE decoding loop ... */
/* ... palette copy ... */
free(data);
return 1;
}
最后,main() 接收一个地图编号,转换一对文件中的两个文件,并把高度图的调色板换成灰度渐变:
int main(int argc, char *argv[]) {
char in_name[256], out_name[256];
image_t color, height;
int map, i;
if (argc < 2) {
printf("usage: dta2bmp <map number 1-4>\n");
return 1;
}
map = atoi(argv[1]);
sprintf(in_name, "C%d.DTA", map);
if (!load_dta(in_name, &color)) {
printf("Could not read %s\n", in_name);
return 1;
}
sprintf(out_name, "map%d_color.bmp", map);
save_bmp(out_name, &color);
sprintf(in_name, "D%d.DTA", map);
if (!load_dta(in_name, &height)) {
printf("Could not read %s\n", in_name);
return 1;
}
for (i = 0; i < 256; i++) { /* gray ramp: index = height */
height.palette[i * 3 + 0] = (unsigned char)i;
height.palette[i * 3 + 1] = (unsigned char)i;
height.palette[i * 3 + 2] = (unsigned char)i;
}
sprintf(out_name, "map%d_height.bmp", map);
save_bmp(out_name, &height);
printf("map %d: %dx%d color + height maps saved\n", map, color.width, color.height);
free(color.pixels);
free(height.pixels);
return 0;
}
整个程序大约 150 行标准 ANSI C。
下面是第一张地图的结果,左边是颜色图,右边是高度图:

Comanche 地图 1:颜色图(左)和高度图(右)
两张图如何对应一目了然:河流是高度图里最暗的谷地,最亮的区域则是积雪的山顶。再看左上角。那块方形结构是直接画进两张地图里的基地,高度也相应抬高了。在 Voxel Space 里,建筑是地形的一部分。
下面是原版游戏里全部四张颜色图地形:

1992 年原版 Comanche 的四张颜色图
核对结果
怎么知道我们做对了?可以拿网上流传多年的那套地图来对比,它们从 map0 开始编号。我们的地图 1 对应 map0,地图 2 对应 map1,依此类推。
- 高度图
D1.DTA与map0逐字节一致。两个文件的 MD5 哈希相同。 - 颜色图
C4.DTA在每个像素上都给出与map3完全相同的 RGB 颜色。
其余地图几乎相同。差异出在水面上:网上流传的版本把每个湖泊和河流都涂成调色板条目 251 到 254,这几个条目大概用于水面颜色循环动画。它们有些高度也差一个单位。我们的文件来自 1992 年的原版发行,所以网上的版本多半取自后来某个版本的游戏。这也提醒我们,游戏数据会随版本变化,哪怕文件名一直没变。
地图文件是怎么做出来的?
那么 Kyle Freeman 当初是怎么做出这些地图的?1994 年 7 月一篇关于他的杂志专访讲了一部分经过。他最初想用 3D 建模软件搭建自己的世界,但即便把细节级别设到 500 万到 600 万面,结果也达不到他想要的真实感。于是他做了任何一个像样的黑客都会做的事:自己写工具。Freeman 用自己写的程序手工雕琢地形,“把形状推来推去,像捏黏土一样塑造地貌。”
即便如此,他还是觉得结果太软、太假。他的解决办法是再写几段例程,模拟几百万年的侵蚀、几次地震灾难,以及天气的持续摧残,让体素世界发生形变。据文章所述,他工具里的每个体素存储的不只是高度和颜色,还存有该点的物质密度——岩石、土壤类型、水各自不同。侵蚀算法会考虑这些密度,于是松软的土壤被冲走,坚硬的岩石留在原地,凿出我们至今仍能在这些地图上看到的裂隙和山谷。不过,这些额外信息并没有进入我们解码出的 .DTA 文件。等到地图正式发布时,那些地质运算早已被烘焙成两张简单的 8 位图像:每个点一个字节的颜色,一个字节的高度。

据 Kyle Freeman 本人说,这个地形工具大概和体素空间引擎本身一样复杂。运行时渲染地形所用的技术,同样被用来生成海报和其他宣传物料。
结论与下一步
刚开始翻这些文件时,我多少以为会碰上某种巧妙的定制压缩——毕竟这家工作室刚刚发明了一种绘制地形的新方法。结果地图不过是普通的 PCX 图像,只是在前 8 个字节上盖了个签名。
我觉得这才是真正的教训。90 年代初的程序员不会什么都自己重造。他们用美术手头已有的格式和工具,比如画图程序直接导出的 PCX 文件,把聪明才智留给真正需要的地方:渲染器。而逆向一个老文件格式时,思路几乎总是一样的。看开头的字节,找出任何像尺寸或数量的东西,然后问自己:它像那个年代的哪种常见格式?
既然地图已经是普通图像,好玩的部分就开始了。把它们载入你自己的 Voxel Space 渲染器,或者干脆回到 DOS,用 VGA mode 13h 和原始的 256 色调色板把它们画出来,就像 1992 年那样。

我自己写的 VGA mode 13h 体素空间渲染器
剧透:我在考虑把目前讲过的内容整合起来,录一门小课程,讲怎么用 VGA mode 13h 实现一个 DOS 下的体素空间渲染器。能不能真做出来,再说吧。🤞
Comanche 地图文件的快速探秘就到这里。如果你对这篇文章有什么建议,可以在 Twitter 上骂我。另外,别忘了去课程页面看看我的复古编程讲座。
下次见!
[