逆向工程NovaLogic的Comanche地形图

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

中文
复制
NovaLogic Comanche 游戏里的地形画面,文章要逆向的就是它的地图格式

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

Gustavo Pezzi

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

Comanche 的 color map 与 height map

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

campos de cima da serra brasil rio grande do sul

Campos de Cima da Serra。巴西 Rio Grande do Sul。

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

opengl terrain

使用旧式(固定管线)OpenGL 1.4 的 3D 地形示例

话虽如此,我并不是从用 OpenGL 画漂亮的 3D 地形起步的。90 年代初没有硬件加速,也没有高分辨率显示器。那时候每个像素都得靠软件(CPU)手工绘制。那是 MS-DOS 游戏、VGA 与 SVGA 图形、低多边形、平面着色和抖动 3D 世界的黄金年代。

当时的飞行模拟游戏大多用多边形渲染地形。每个 3D 物体都由顶点构成,这些顶点拼出多边形场景,再经过投影和光栅化,逐扫描线、逐像素地画出来。

F29 Retaliator by Digital Image Design

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 地形只需要两样数据:

  • 高度图:一张网格,每个字节是该点处的地形高度。
  • 颜色图:一张同样大小的网格,每个字节是该点处地面的颜色,光照和阴影已经烘焙进去了。

Color map & Height map

两张网格都是 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 文件。其中八个文件光看体积就很扎眼:

Comanche C and D files

这些文件似乎是成对出现的,编号 1 到 4,前缀分别是 C 和 D。

我第一个猜测是 C 代表 "color",D 代表 "depth"(高度图)。它们全都不到 1 MB,所以里面装的东西一定是压缩过的。

逆向一个文件格式时,我做的第一件事永远是用十六进制编辑器看文件开头几个字节。下面是 C1.DTA 的开头:

Hex view of Comanche 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

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 中的值
0x008厂商、版本、编码、每像素位数、xmin、ymin被替换为 Kyle DTA
0x082xmax1023
0x0A2ymax1023
0x0C4水平和垂直 DPI1024、768(被忽略)
0x104816 色 EGA 调色板未使用
0x401保留0
0x411颜色平面数1
0x422每扫描行字节数1024
0x4460调色板信息与填充全为零
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 颜色表

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 FILEHEADER14 字节BM 签名、文件大小、像素数据偏移量
BITMAP INFOHEADER40 字节宽度、高度、1 个平面、每像素 8 位、无压缩
颜色表256 × 4 字节每个颜色按蓝、绿、红、0 排列
像素数据高度 × 行大小每像素一字节,行填充到 4 字节的倍数

有三个坑,第一次写的人都会踩:

  1. BMP 存储颜色用的是 BGR,不是 RGB,而且每个颜色占 4 字节而不是 3 字节。
  2. 高度为正时,行是自下而上存储的:图像的最后一行在文件里排在最前面。
  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 的颜色图和高度图

Comanche 地图 1:颜色图(左)和高度图(右)

两张图如何对应一目了然:河流是高度图里最暗的谷地,最亮的区域则是积雪的山顶。再看左上角。那块方形结构是直接画进两张地图里的基地,高度也相应抬高了。在 Voxel Space 里,建筑是地形的一部分。

下面是原版游戏里全部四张颜色图地形:

Comanche 的四张颜色图

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 位图像:每个点一个字节的颜色,一个字节的高度。

Comanche Maximum Overkill image

据 Kyle Freeman 本人说,这个地形工具大概和体素空间引擎本身一样复杂。运行时渲染地形所用的技术,同样被用来生成海报和其他宣传物料。

结论与下一步

刚开始翻这些文件时,我多少以为会碰上某种巧妙的定制压缩——毕竟这家工作室刚刚发明了一种绘制地形的新方法。结果地图不过是普通的 PCX 图像,只是在前 8 个字节上盖了个签名。

我觉得这才是真正的教训。90 年代初的程序员不会什么都自己重造。他们用美术手头已有的格式和工具,比如画图程序直接导出的 PCX 文件,把聪明才智留给真正需要的地方:渲染器。而逆向一个老文件格式时,思路几乎总是一样的。看开头的字节,找出任何像尺寸或数量的东西,然后问自己:它像那个年代的哪种常见格式?

既然地图已经是普通图像,好玩的部分就开始了。把它们载入你自己的 Voxel Space 渲染器,或者干脆回到 DOS,用 VGA mode 13h 和原始的 256 色调色板把它们画出来,就像 1992 年那样。

Voxel Space Renderer VGA mode 13h

我自己写的 VGA mode 13h 体素空间渲染器

剧透:我在考虑把目前讲过的内容整合起来,录一门小课程,讲怎么用 VGA mode 13h 实现一个 DOS 下的体素空间渲染器。能不能真做出来,再说吧。🤞

Comanche 地图文件的快速探秘就到这里。如果你对这篇文章有什么建议,可以在 Twitter 上骂我。另外,别忘了去课程页面看看我的复古编程讲座。

下次见!


[

来源: Pikuma← 返回首页