所有格式页面 ↓
容器与封装格式

HPI / UFO / CCX / GP3——HAPI 压缩包文件

来自 coreprime/kbot @ 0ab97bf · 在 GitHub 上编辑

HAPI(“HPI”)是 Cavedog 用来发布全部游戏资源的加密、压缩容器。同一种磁盘布局以不同的扩展名重复使用:

扩展名 用途 TA 3.1c 挂载顺序
.gp3 3.1 补丁覆盖层(rev31.gp3) 最先挂载;其他 *.gp3 会被忽略
.ccx Core Contingency / Battle Tactics 数据(btdata.ccx、ccdata.ccx) 在 rev31.gp3 之后
.ufo 单位/地图物件附加包(AFark.ufo) 在所有 .ccx 之后;没有数量限制
.hpi 基础游戏压缩包(totala1.hpi、tactics1.hpi) 最后;只挂载能打开的前十个

这四种格式逐字节相同——区别只在挂载顺序。游戏在启动时把它们合并成一个虚拟文件系统,最先挂载的、包含某路径的压缩包胜出。

Important

两种磁盘方言。 《横扫千军》写的是 HPI v1(Version == 0x00010000),即本页从头到尾记述的加密多块布局。《横扫千军:王国》写的是 HPI v2(Version == 0x00020000)——一种不同的、更简单的布局,没有 XOR 加密,每个文件或目录块只有一个 SQSH 块。v2 的具体细节在本页底部附近的独立一节:横扫千军:王国 HPI v2。TA 3.1c 只挂载 v1 压缩包。

加载顺序与分层叠加

TA 3.1c 只扫描游戏目录的顶层(Backup/ 之类子文件夹中的压缩包永远不会被挂载),并按以下顺序挂载:

  1. rev31.gp3;
  2. 所有 *.ccx;
  3. 所有 *.ufo;
  4. 能打开的前十个 *.hpi;
  5. 游戏光盘顶层的 *.hpi 文件,没有数量限制。

在每一组内,游戏按 ASCII 大写字母顺序取文件名(即其目录列表返回的顺序:_ 排在字母之后,AFark.ufo 排在 zeta.ufo 之前)。解析 units/ARMCOM.fbi 这样的路径时,安装目录中的散文件(loose file)优先,其次是第一个包含该路径的已挂载压缩包。只有版本 1、以 Cavedog 版权尾部标记结尾(见尾部标记)且目录可读的压缩包才算挂载成功;其他任何文件都会被跳过,也不会占用十个 *.hpi 名额中的一个。

   Query: units/ARMCOM.fbi

   priority ↑   loose files in the install root       if found → use this
           │   ┌─────────────────────────────────────┐
           │   │ rev31.gp3 (3.1 patch)               │  else fall through
           │   ├─────────────────────────────────────┤
           │   │ *.ccx in name order                 │  else fall through
           │   ├─────────────────────────────────────┤
           │   │ *.ufo in name order                 │  else fall through
           │   ├─────────────────────────────────────┤
           │   │ first ten *.hpi in name order       │  else fall through
           │   ├─────────────────────────────────────┤
           │   │ *.hpi on the disc (no limit)        │  else fall through
           │   └─────────────────────────────────────┘
   priority ↓   "not found"

在 GOG 版中,十个 *.hpi 名额被 tactics1–8.hpi、totala1.hpi 和 totala2.hpi 占用。totala3.hpi、totala4.hpi(零售版地图)和 worlds.hpi(地图区块)属于光盘压缩包:GOG 版把它们放在游戏目录中,由游戏目录充当光盘,因此游戏会在所有其他压缩包之后才挂载它们。模组添加的任何 *.hpi 也是如此:名字排在第十个名额之后的压缩包,只能通过那次光盘扫描被找到,优先级低于其他一切。要避免这种情况,请以 .ufo 发布模组(没有数量限制,优先级高于所有 .hpi)。

kbot 在所有地方都以同样的方式挂载《横扫千军》上下文:studio、kbot mount、kbot mount flatten、kbot pack、kbot tnt 以及 MCP 服务器。Files 标签页的 Home 页面和 Layering 标签页、kbot mount 的 archives 和 stats 命令,以及 MCP 的 vfs_game_data 工具,都会列出挂载顺序和每个未被挂载的压缩包及其原因。《横扫千军:王国》上下文保留 kbot 自己的覆盖顺序(所有压缩包按扩展名和名称排列,后面的覆盖前面的),因为该游戏中 IPData.hpi 覆盖 data.hpi 的分层方式需要这样。

“散文件优先”这条规则,正是 kbot mount flatten 生成的目录与分层压缩包集表现完全相同的原因:引擎本会经由压缩包栈解析到的每个文件,都以散文件的形式出现在相同路径上,因此解析在栈顶就直接结束了。

哪个定义胜出

地图物件、武器和单位通过列出目录来加载:features/**/*.tdf(包括子文件夹)、weapons/*.tdf 和 units/*.fbi(仅顶层——weapons/old/ 子文件夹永远不会被读取)。列表先列散文件,然后按挂载顺序列出每个已挂载的压缩包,压缩包内的条目按存储顺序排列,同名定义中第一个胜出;后面的都会被忽略。零售版的 CarScar05 在 features/urban/cars2.tdf 中定义,又在 cars.tdf 中定义了一次,而 cars2.tdf 存储在前,所以游戏从 anims/cars2.gaf 绘制它。studio(地图物件面板、沙盒、包、单位和武器列表)、kbot tnt preview 和 kbot tnt lint,以及 MCP 工具都采用同样的规则。


概览

   ┌─── 20-byte header (clear) ───┐ ┌─── encrypted directory ───┐ ┌─── encrypted file chunks ───┐
   │ "HAPI"  version  dirSize ... │ │ tree of names + offsets   │ │ SQSH chunks, ≤64 KiB each   │
   └──────────────────────────────┘ └───────────────────────────┘ └─────────────────────────────┘
                                                                                      ▲
                                                              trailer (required by TA 3.1c):
                                                            "Copyright 1997 Cavedog Entertainment"

读取 HPI 的流程是:

  1. 读取 20 字节的文件头,校验 "HAPI" 魔数。
  2. 从 DecryptKey 推导出该文件的 XOR 密钥。
  3. 解密并读取目录树。
  4. 对每个需要的文件,定位到它的压缩块,解密,然后按 CompMethod(LZ77、ZLib 或不压缩)分别处理。

Tip

无需编写解析器即可查看任何 HPI。

kbot hpi info  totala1.hpi          # header, counts, "TA 3.1c would mount: yes/no"
kbot hpi list  totala1.hpi -v       # files, sizes, compression
kbot hpi list  totala1.hpi -p "*.bos"
kbot hpi extract totala1.hpi -p "units/ARMCOM.fbi" -t ./out

所有参数见 CLI 的 kbot hpi 参考。

在 Go 中调用。 使用 formats/hpi:

import (
    "io"
    "github.com/coreprime/kbot/formats/hpi"
)

r, _ := hpi.OpenReader("totala1.hpi")
defer r.Close()
_ = r.Walk(func(e *hpi.Entry) error {
    if !e.IsDir { fmt.Println(e.FullPath(), e.Size) }
    return nil
})
rc, _ := r.Open("units/ARMCOM.fbi")
defer rc.Close()
data, _ := io.ReadAll(rc)                     // decompressed bytes

文件头(20 字节,明文)

typedef struct {
    uint32 Marker;         // 'HAPI' = 0x49504148, "BANK" if save-game
    uint32 Version;        // Usually 0x00010000
    uint32 DirectorySize;  // Total directory size in bytes (includes header)
    uint32 DecryptKey;     // Seed for the XOR cipher (0 = no encryption)
    uint32 Start;          // File offset of the directory (almost always 0x14)
} HPIHeader;
字段 说明
Marker 小端序的 'HAPI'。'BANK' 变体用于游戏存档(save game),本文不涉及。
Version 零售版《横扫千军》的压缩包使用 0x00010000(v1,即本节)。《横扫千军:王国》的压缩包使用 0x00020000(v2);见横扫千军:王国 HPI v2。
DirectorySize 包括文件头的 20 字节,因此实际的目录数据块从 Start 开始,占 DirectorySize − Start 字节。
DecryptKey 经过下面的变换后,得到用作 XOR 密钥的字节。对 TA 3.1c 而言,密钥字节 0 和 0xFF 都表示“未加密”。Cavedog 的零售版压缩包使用 0xBF。
Start 目录的起始位置。在观察到的文件中始终是 0x14。

推导解密密钥

key = (DecryptKey << 2) | (DecryptKey >> 6)   // rotate-left by 2 bits

然后,每个加密字节都结合其文件绝对位置进行解扰:

plain[i] = cipher[i] ^ (uint8(pos + i) ^ key)

其中 pos 是 cipher[0] 在文件中的绝对偏移量。没错——种子会随文件位置推进,所以你无法孤立地解密一段缓冲区;你必须知道它来自哪里。

Important

如果密钥字节是 0 或 0xFF,就完全不要应用解密。 TA 3.1c 把这两者都当作明文读取(对 0xFF 而言,上面的循环移位结果仍是 0xFF,但游戏并不解密)。有些自制压缩包(以及少数社区模组)是以不加密方式写出的——这种情况下应用 XOR 解密,会把本来就是明文的内容打乱。出于这个原因,kbot hpi pack 拒绝 --key 255;请使用 --key 0。


目录树

一个目录由条目数和指向第一个条目的指针组成:

typedef struct { uint32 EntryCount; uint32 EntriesOffset; } HPIDir;

每个条目正好 9 字节:

typedef struct {
    uint32 NameOffset;    // → NUL-terminated string
    uint32 DataOffset;    // → HPIDir (subdir) or HPIFile (leaf)
    uint8  Flag;          // 1 = subdirectory, 0 = file
} HPIEntry;

对于文件,DataOffset 指向:

typedef struct {
    uint32 DataOffset;    // Absolute file offset of the first compressed chunk
    uint32 FileSize;      // **Decompressed** size in bytes
    uint8  CompMethod;    // 0 = stored, 1 = LZ77, 2 = ZLib
} HPIFile;

所有偏移量都是文件内的绝对偏移量。这就是为什么规范的解析器会分配一个 DirectorySize 字节的缓冲区,并把目录写到 Start 位置——这样指针就可以直接索引缓冲区,无需额外计算。

读取目录

buf = bytearray(header.DirectorySize)
file.seek(header.Start)
encrypted = file.read(header.DirectorySize - header.Start)
buf[header.Start:] = decrypt(encrypted, key, seed=header.Start)
walk(buf, header.Start)   # recurses on Flag == 1 entries

Note

文件名在写入时保留大小写,读取时则不区分大小写进行匹配。 《横扫千军》诞生于 Windows 9x 时代;模组经常混用 armcom.fbi 和 ARMCOM.FBI,即使在同一个压缩包中也是如此。游戏只折叠 ASCII 字母的大小写,以 \ 和 / 两者分割路径;当同一目录中存有多个仅大小写不同的条目时,它读取其中最后一个,其余的都无法访问。它还把 . 和 .. 当作普通名称。因此 kbot hpi extract 对这样的一组条目只写出可访问的那一个,并跳过(同时给出提示)任何按存储的路径无法写在目标文件夹内的条目,例如 ../../x.txt;kbot mount flatten 也绝不会写到其目标之外。


压缩块(SQSH)

文件的数据被切成若干个块(chunk),每块解压后最多 64 KiB。每个块都有自己的 19 字节块头:

typedef struct {
    uint32 Marker;            // 'SQSH' = 0x48535153
    uint8  Version;           // Always 0x02 in observed files
    uint8  CompMethod;        // 1 = LZ77, 2 = ZLib
    uint8  Encrypted;         // 1 = chunk body is XOR-scrambled, 0 = plaintext
    uint32 CompressedSize;    // Length of the encrypted+compressed payload
    uint32 DecompressedSize;  // Length after CompMethod is undone
    uint32 Checksum;          // Sum of the encrypted payload bytes (uint32)
    uint8  data[CompressedSize];
} HPIChunk;

块数为 ceil(FileSize / 65536)。紧接在文件的第一个 HPIFile.DataOffset 之后,是一个 uint32 压缩大小数组,每块一个,随后是首尾相接的各个块本身。

第二层 XOR

如果 Encrypted == 1,每个块体都要再做第二遍 XOR:

for (i = 0; i < CompressedSize; i++)
    data[i] = (data[i] - i) ^ i;

注意,校验和是针对仍处于加密状态的字节计算的——请在撤销加密之前校验。

LZ77(CompMethod = 1)

教科书式的滑动窗口 LZ77,历史窗口为 4 KiB:

  • 标记字节——8 个标志位,从最低位开始。0 ⇒ 字面量,1 ⇒ 回溯引用。
  • 字面量——把 1 个字节原样复制到输出和历史窗口中。
  • 匹配——读取一个 uint16。高 12 位是窗口偏移量(1–4095),低 4 位以 len − 2 的形式编码长度(因此匹配长度为 2–17 字节)。
  • 终止符——windowOffset == 0 的匹配表示数据流结束。

ZLib(CompMethod = 2)

标准的 zlib 原始数据流。去掉加密层之后,把块体送入 inflate();DecompressedSize 是预期长度。

不压缩存储(CompMethod = 0)

字节原样写入——不分块、没有校验和、没有第二遍 XOR。适用于无法再压缩的文件(已经压缩过的 PCX/GAF),也是旧版“unit viewer”唯一能理解的模式。


尾部标记

游戏挂载的每个压缩包都以一行 36 字节的 ASCII 版权声明结尾:

Copyright 1997 Cavedog Entertainment

文件头中没有任何字段指向它,但它并非可有可无:挂载压缩包之前,TA 3.1c 会检查其最后 36 字节是否为 Copyright ____ Cavedog Entertainment(四个年份字符不做比较)。没有这一行、或在其后追加了任何字节的压缩包都会被跳过,也不会占用十个 *.hpi 名额中的一个——打包时缺少尾部标记的模组,会在游戏中悄无声息地消失。

kbot 写出的每个压缩包都保留它:kbot hpi pack 以及 studio 的导出功能(工作区的 Export mod 和地图编辑器的压缩包下载)。kbot hpi info、kbot hpi list -v、MCP 的 hpi_info 和 hpi_list 工具,以及 Files 标签页中压缩包的元数据,都会打印一行 TA 3.1c would mount: yes / no, <reason>。


横扫千军:王国——HPI v2

Note

本节只讲 TA:K 的差异。 上面的一切都适用于《横扫千军》(Version == 0x00010000)。《横扫千军:王国》发布的文件同样使用 .hpi 扩展名,但磁盘布局不同——在动用 v1 解析器之前,请完整阅读本节。

横扫千军:王国保留了 'HAPI' 魔数和目录树的概念,但除此之外彻底改造了这层封装。差异一览:

方面 v1(TA) v2(横扫千军:王国)
版本字段 0x00010000 0x00020000
文件头大小 20 字节(5 × uint32) 32 字节(8 字节魔数+版本 + 24 字节子文件头)
加密 以 DecryptKey 为种子的 XOR 加密,随文件位置推进 无——全部为明文
目录 + 名称池位置 紧跟在文件头之后(加密) 位于任意偏移量处(通常在文件末尾附近),各自可选地包裹在单个 SQSH 块中
单个文件的分块 切成 ≤64 KiB 的块并带长度表;每块有自己的 SQSH 块头 + 校验和 + 可选的第二遍 XOR 整个载荷放在单个 SQSH 块中,或者在 CompressedSize == 0 时直接存储字节;没有 XOR 处理,没有长度表
逐块加密(SQSH 中的 Encrypted 字节) 使用 不使用(如果该字节被置位,我们仍会遵从,但我们抽样过的每个 TAK 文件中它都是 0)

v2 文件头

在 8 字节的前导('HAPI' + 0x00020000)之后,是六个 32 位小端序字段——磁盘上的文件头共 32 字节:

// At offset 0 — same as v1.
typedef struct { uint32 Marker; uint32 Version; } HPIPrologue;

// At offset 8 — TA: Kingdoms only.
typedef struct {
    int32 DirectoryBlock;          // Absolute offset of the directory blob
    int32 DirectorySize;           // Length of the directory blob on disk
    int32 NameBlock;               // Absolute offset of the string pool
    int32 NameSize;                // Length of the string pool on disk
    int32 Data;                    // Offset of the first file payload (typically 0x20)
    int32 Last78;                  // 0, or an offset to a trailing 78-byte block
} HPIHeaderV2;

目录数据块和名称数据块都可能经过 SQSH 压缩——如果 DirectoryBlock / NameBlock 处的前四个字节是 'SQSH',就把该块当作单个块(块头 + 载荷)并解压。否则,给定偏移量处的 Size 个字节就是未压缩的原始内容。

v2 目录条目

解压后的目录数据块是由两种记录类型紧密排列而成的数组(没有单独的偏移表,也没有 EntryCount 前缀——子项通过父项的 FirstSubDirectory / FirstFile 指针定位):

typedef struct {                   // 20 bytes — HpiDir2
    int32 NamePtr;                 // Offset into NameBlock (NUL-terminated string)
    int32 FirstSubDirectory;       // Byte offset into the directory blob
    int32 SubCount;                // Number of subdirectories
    int32 FirstFile;               // Byte offset into the directory blob
    int32 FileCount;               // Number of files
} HpiDir2;

typedef struct {                   // 24 bytes — HpiEntry2
    int32 NamePtr;                 // Offset into NameBlock
    int32 Start;                   // Absolute file offset of the payload
    int32 DecompressedSize;
    int32 CompressedSize;          // 0 ⇒ payload is stored verbatim, no SQSH wrapper
    int32 Date;                    // time_t when packed (informational)
    int32 Checksum;                // Informational — the SQSH chunk carries its own
} HpiEntry2;

单个文件的载荷

对于偏移量为 Start 的文件条目:

  • 如果 CompressedSize == 0:原样读取 DecompressedSize 个字节。
  • 否则:接下来的 CompressedSize 个字节构成单个 SQSH 块(块头与 v1 一样是 19 字节)——读取它,用载荷的字节和校验它的校验和,再按该块的 CompMethod(1 = LZ77,2 = ZLib)解码,就得到了文件。

v1 的多块方案(大小表、≤64 KiB 的块、加密处理)不复存在。v2 以牺牲紧凑性换来了简单得多的读取器。

kbot 的处理

kbot hpi list、extract 和 info 都透明地支持两种方言——它们按文件头中的 Version 字分派。往返写入(kbot hpi pack)目前只输出 v1;我们还不能制作 v2 压缩包。

内部的 v2 读取器位于 formats/hpi/hpi_v2.go。hpi.Reader.Version() 会报告你加载的文件是 v1 还是 v2。

Tip

确认 TAK 的游戏文件可以加载。

kbot hpi list ~/games/takingdoms/data.hpi -v | head
kbot ctx add ~/games/takingdoms --alias tak --game takingdoms
kbot mount  # walks every TAK .hpi as a single VFS

实例解析——aflakker.ufo

Joe D. 最初的详解使用的是 Arm Flakker 单位附加包;我们在这里重现他拆解的文件头。前 32 字节的十六进制转储:

00000000  48 41 50 49 00 00 01 00 24 02 00 00 7D 00 00 00   HAPI....$...}...
00000010  14 00 00 00 ...                                   ...

逐字段解读:

偏移量 字节 字段 值
0x00 48 41 50 49 Marker 'HAPI' ✓
0x04 00 00 01 00 Version 0x00010000 ✓
0x08 24 02 00 00 DirectorySize 0x224 = 548 字节
0x0C 7D 00 00 00 DecryptKey 0x7D → key = (0x7D << 2) | (0x7D >> 6) = 0xF5
0x10 14 00 00 00 Start 0x14

解密 [0x14, 0x224) 并遍历目录树,得到:

.
├── anims/
│   └── armflak_gadget.gaf
├── download/
│   └── ARMFLAK.TDF
├── features/corpses/
│   └── armflak_dead.tdf
├── objects3d/
│   ├── armflak.3do
│   └── armflak_dead.3do
├── scripts/
│   └── ARMFLAK.COB
├── unitpics/
│   └── ARMFLAK.PCX
├── units/
│   └── ARMFLAK.FBI
└── weapons/
    └── armflak_weapon.tdf

这种九个文件的布局,是单一单位 .ufo 附加包的标准形态;基础的 totala*.hpi 压缩包在更大规模上(约 3 万个文件)使用同样的方案。

Tip

在你自己的机器上重现这次拆解。

kbot hpi info path/to/aflakker.ufo
kbot hpi list path/to/aflakker.ufo -v
kbot hpi extract path/to/aflakker.ufo -t ./aflakker-out

重新打包

从一个目录树构建新压缩包:

kbot hpi pack ./aflakker-out --target aflakker-new.ufo

kbot 默认使用 LZ77 分块压缩,配合标准的 0xBF 文件头密钥和 Cavedog 尾部标记——这样生成的二进制文件能被零售版引擎接受。以下参数可以保持这一点:

参数 效果
--method lz77|zlib|none 块压缩方式;none 写出不压缩存储的条目。
--key N 文件头密钥字节;0 写出未加密的压缩包。255 会被拒绝:游戏把它读作“未加密”。
--trailer "Copyright 1998 Cavedog Entertainment" 在尾部标记中使用另一个年份;任何其他文本都会被拒绝。
--no-trailer 省略尾部标记——游戏不会挂载生成的结果(会打印一条警告)。
--format v2 写出横扫千军:王国压缩包(--key、--trailer、--no-trailer 和 --encode-chunks 不适用)。

仅字母大小写不同的目录名(在区分大小写的磁盘上的 Units/ 和 units/)会按游戏比较名称的方式合并;路径仅大小写不同的两个文件都会被存储,游戏读取最后打包的那一个,pack 会打印一条警告。


坑点

Warning

指针是相对于文件起始处的绝对偏移量,而不是相对于目录起始处。 不需要减去任何东西。标准的实现技巧是分配一个 DirectorySize 字节的缓冲区,把解密后的目录读入其中偏移量 Start 处,这样指针就能直接索引缓冲区。

  • 块数公式——ceil(FileSize / 65536)。没有显式的计数字段;必须从 FileSize 推导出来。

典型大小

压缩包 大小 文件数 说明
totala1.hpi(音效、动画) 约 30 MB 约 1900 基础游戏,最大的单个压缩包。
totala2.hpi(单位) 约 13 MB 约 600 大部分单位元数据 + 脚本。
totala4.hpi(地图) 约 50 MB 约 300 所有零售版地图。
worlds.hpi(地图区块) 约 20 MB 约 5000 编辑器用的 SCT 库。
ccdata.ccx(Core Contingency) 约 25 MB 约 1500 资料片的单位/地图。
典型的单一单位 .ufo 10–150 KB 8–12 大多数第三方单位附加包。
只含地图的 .ufo(带 .tnt + .ota) 0.5–5 MB 2–4 按地图分发。
压缩率 35–55% — 大多数文件用 LZ77;最大的文件用 ZLib。
  • 有些块压缩后反而变大。 如果 LZ77 没有找到任何匹配,“压缩”后的块可能超过 64 KiB。请以 CompressedSize 作为要复制的权威长度。
  • 校验和针对的是加密后的字节。 如果先解密,校验和就会失败。
  • 游戏存档(BANK 标记)完全是另一回事——它们沿用 HPI 封装,但编码的内容是序列化的游戏状态,而不是文件系统。kbot 不解码它们。
  • .gp3 需要 rev3.1 补丁才能加载。 它内部仍然是 HPI,但引擎在未打补丁的游戏上拒绝挂载它。

附录——AFark.ufo 完整十六进制解析

AFark.ufo 是 Arm Fark 附加包——一个只有 9 个文件的小压缩包,非常适合做端到端演示。我们将解析文件头,解密目录,拆解其中几个条目,然后看一看压缩块的开头几个字节。

1. 文件头(明文)

原始字节 0x00..0x14:

00000000: 48 41 50 49 00 00 01 00 24 02 00 00 a6 00 00 00  HAPI....$.......
00000010: 14 00 00 00                                      ....

解码:

偏移量 字节 字段 值
0x00 48 41 50 49 Marker 'HAPI' ✓
0x04 00 00 01 00 Version 0x00010000
0x08 24 02 00 00 DirectorySize 0x224 = 548 字节
0x0C a6 00 00 00 DecryptKey 字节 0xA6
0x10 14 00 00 00 Start 0x14

推导解密密钥:

key = (0xA6 << 2) | (0xA6 >> 6)
    = 0x98 | 0x02
    = 0x9A

2. 解密后的目录(偏移量 0x14..0x224)

对目录数据块执行 plain[i] = cipher[i] ^ (uint8(0x14 + i) ^ 0x9A)——并把结果插入 548 字节缓冲区的 0x14 偏移处,使指针能正确索引——之后,前 96 个字节如下:

00000010                          08 00 00 00  1c 00 00 00 64 00 00 00
                                  ^^^^^^^^^^^  ^^^^^^^^^^^
00000020  6a 00 00 00 01 97 00 00  00 a0 00 00 00 01 c6 00
00000030  00 00 cf 00 00 00 01 13  01 00 00 1d 01 00 00 01
00000040  66 01 00 00 6e 01 00 00  01 94 01 00 00 9d 01 00
00000050  00 01 c3 01 00 00 c9 01  00 00 01 ef 01 00 00 f7
00000060  01 00 00 01 61 6e 69 6d  73 00 01 00 00 00 72 00
                      ^^^^^^^^^^^^^^^^^                     "anims"

在 0x14 处是 0x00000008——根条目数:8 个顶层子目录。在 0x18 处是 0x0000001C——第一个目录条目的偏移量。

3. 第一个条目

0x1C 处的 9 个字节解码为:

字段 字节 值
NameOffset 64 00 00 00 0x64 → “anims”字符串
DataOffset 6a 00 00 00 0x6A → 子目录描述符
Flag 01 1 ⇒ 子目录

在 0x64 处:

00000060  ....  61 6e 69 6d 73 00     ....anims.
                ^^^^^^^^^^^^^^^^^

字符串 "anims\0"——确认了目录名。

在 0x6A 处:

00000060  ....                  01 00 00 00 72 00      ........r.
                                ^^^^^^^^^^^ ^^^^
00000070  00 00 7b 00 00 00 ...                          ..{...
          ^^^^^
字段 字节 值
子目录的 EntryCount 01 00 00 00 1
子目录的 EntriesOffset 72 00 00 00 0x72

所以 anims/ 目录包含 1 个文件,其描述从 0x72 开始:

字段 字节 值
NameOffset 7b 00 00 00 0x7B → 文件名
DataOffset 8e 00 00 00 0x8E → 文件描述符
Flag 00 0 ⇒ 文件

0x7B 处的名称:

00000070               .. .. .. 61 72 6d 66 61                armfa
00000080  72 6b 5f 67 61 64 67 65  74 2e 67 61 66 00         rk_gadget.gaf.

→ "armfark_gadget.gaf"。

0x8E 处的文件描述符是一个 9 字节的 HPIFile:

00000080                                              24 02              $.
00000090  00 00 48 48 00 00 01                                ..HH...
字段 字节 值
DataOffset 24 02 00 00 0x0224——目录之后的第一个字节!
FileSize 48 48 00 00 0x4848 = 18504 字节
CompMethod 01 1 ⇒ LZ77

这与 kbot hpi list -v 的输出一致:

anims/armfark_gadget.gaf    18504

4. 读取完整的目录树

继续处理其余 7 个顶层条目,得到:

.
├── anims/
│   └── armfark_gadget.gaf       (18504 B, LZ77)
├── download/
│   └── ARMFARK.TDF              (84 B, LZ77)
├── features/corpses/
│   └── armfark_dead.tdf         (619 B, LZ77)
├── objects3d/
│   ├── armfark.3do              (7337 B, LZ77)
│   └── armfark_dead.3do         (3551 B, LZ77)
├── scripts/
│   └── ARMFARK.COB              (18988 B, LZ77)
├── unitpics/
│   └── ARMFARK.PCX              (8728 B, LZ77)
├── units/
│   └── ARMFARK.FBI              (1466 B, LZ77)
└── weapons/
    └── armfark_weapon.tdf       (430 B, LZ77)

九个文件,单一单位 .ufo 的标准形态。

5. 一个压缩块

548 字节目录之后的第一个字节位于 0x224。解密这一区域(块头同样经过目录所用的加密)后得到:

00000220                          .. 53 51 53 48 02 01 01 cc      .SQSH....
偏移量(从 0x224 起) 字节 字段 值
+0 53 51 53 48 Marker 'SQSH' ✓
+4 02 Version 0x02(始终如此)
+5 01 CompMethod 1 ⇒ LZ77
+6 01 Encrypted 1 ⇒ 需要执行第二遍 XOR
+7.. … CompressedSize、DecompressedSize、Checksum,然后是 data[] …

用 data[i] = (data[i] - i) ^ i 解扰块体,再把得到的字节送入上面描述的 LZ77 解码器,就得到解压后的 GAF——可以直接交给 GAF 解析器。

Tip

在你自己的机器上重现这次解析。

# Show counts + key
kbot hpi info  AFark.ufo

# Walk the tree
kbot hpi list  AFark.ufo -v

# Pull a single file out, decompressed and ready to inspect
kbot hpi extract AFark.ufo -p "anims/armfark_gadget.gaf" -t ./out
kbot gaf list ./out/anims/armfark_gadget.gaf

附录——Jersey.hpi 完整十六进制解析(HPI v2)

这是 AFark 解析在横扫千军:王国中的对应版本。Jersey.hpi 是 GoG 版 TA:K 中附带的一个 67 KB 语音包 HPI——只有六个音效 WAV,别无他物。它小到可以从头到尾解析一遍,又大到足以涵盖 v2 的所有机制(32 字节文件头、名称池、SQSH 包裹的目录数据块、单块文件载荷)。上面的 v1 解析已经涵盖了所有相同的部分;本节只指出 v2 的差异。

1. 32 字节的文件头

00000000: 48 41 50 49 00 00 02 00 92 06 01 00 ad 00 00 00   HAPI............
00000010: 40 06 01 00 5b 00 00 00 20 00 00 00 3f 07 01 00   @...[... ...?...
偏移量 字节 字段 值
0x00 48 41 50 49 Marker 'HAPI' ✓
0x04 00 00 02 00 Version 0x00020000——v2
0x08 92 06 01 00 DirectoryBlock 0x00010692——目录数据块的偏移量
0x0C ad 00 00 00 DirectorySize 0xAD = 173 字节
0x10 40 06 01 00 NameBlock 0x00010640——名称池的偏移量
0x14 5b 00 00 00 NameSize 0x5B = 91 字节
0x18 20 00 00 00 Data 0x20——文件载荷从这里开始,紧跟在文件头之后
0x1C 3f 07 01 00 Last78 0x0001073F——压缩包末尾标记(0x1073F = 67391 = 文件大小 − 78 个尾部字节)

没有 XOR 加密,没有解密变换,没有按位置推进的种子。文件头中的值指向文件绝对偏移量,可以直接 seek 过去。

2. 名称池(原始文本,位于文件末尾附近)

在 DirectoryBlock 往前几百字节处(引擎先写名称池,再写目录),就是字符串表:

00010640: 00 73 6f 75 6e 64 73 00 57 68 6f 64 69 65 64 2e   .sounds.Whodied.
00010650: 7761 7600 53 75 72 65 2e 77 61 76 00 57 68 6f   wav.Sure.wav.Who
00010660: 6168 5061 6c 6c 79 2e 77 61 76 00 46 6f 72 67   ahPally.wav.Forg
00010670: 6574 6162 6f 75 74 69 74 2e 77 61 76 00 59 6f   etaboutit.wav.Yo
00010680: 2e77 6176 00 49 67 6f 74 63 68 65 72 2e 77 61   .wav.Igotcher.wa
00010690: 76 00                                            v.

每个目录条目和文件条目都存有一个指向此块的 NamePtr 字节偏移量;沿着指针读到下一个 \0 为止,就得到名称。逐字节解析:

偏移量(相对于 NameBlock) 以 NUL 结尾的字符串
0x00 "" (根目录——名称为空)
0x01 "sounds" (唯一的子目录)
0x08 "Whodied.wav"
0x14 "Sure.wav"
0x1D "WhoahPally.wav"
0x2C "Forgetaboutit.wav"
0x3E "Yo.wav"
0x45 "Igotcher.wav"

Note

名称池的标称大小包含末尾几个未使用的字节。 NameSize=0x5B 一直延伸到偏移量 0x1069B,但最后一个有意义的字节(Igotcher.wav 后的 NUL)位于 0x10691。末尾的 9 个字节与 SQSH 目录块的开头重叠,没有任何 NamePtr 会索引到它们——读取器只要从不去看它们,就自然容忍了它们。

3. 目录数据块(SQSH 包裹)

在 0x10692 处,目录数据块以一个 SQSH 块头开始——v2 沿用了 v1 的块格式:

00010692: 53 51 53 48 02 02 01 9a 00 00 00 b8 00 00 00 9b   SQSH............
000106a2: 4f 00 00 78 9e 63 …                              O..x.c…
偏移量 字节 字段 值
+0 53 51 53 48 Marker 'SQSH' ✓
+4 02 Version 2
+5 02 CompMethod 2 ⇒ ZLib
+6 01 Encrypted 1——块体经过了第二遍 XOR(在 TAK 中很少见;这个压缩包恰好有)
+7 9a 00 00 00 CompressedSize 0x9A = 154 字节
+11 b8 00 00 00 DecompressedSize 0xB8 = 184 字节
+15 9b 4f 00 00 Checksum 0x4F9B
+19..+172 … data[] 154 字节经过 XOR 扰乱的 ZLib 数据流

19 + 154 = 173 = 0xAD = DirectorySize,因此整个块与文件头声明的大小相符。撤销 XOR 处理(算法与 v1 相同)并经过 inflate 后,得到 184 字节紧密排列的目录记录。

4. 解码目录记录

解压后的数据块是由两种记录类型紧密排列而成的数组:20 字节的目录条目(HpiDir2)和 24 字节的文件条目(HpiEntry2)。没有计数前缀,也没有偏移表——根目录总是位于数据块的偏移量 0 处,它的 FirstSubDirectory / FirstFile 字段指向子项。

根 HpiDir2(位于解压后偏移量 0 处,20 字节):

偏移量 字节(解压后) 字段 值
+0 00 00 00 00 NamePtr 0 → ""(根)
+4 14 00 00 00 FirstSubDirectory 0x14——此偏移处有一个 20 字节的子目录记录
+8 01 00 00 00 SubCount 1(sounds/ 目录)
+12 00 00 00 00 FirstFile 0——根目录下没有文件
+16 00 00 00 00 FileCount 0

沿指针到 0x14,就是 sounds 的 HpiDir2:

  • NamePtr = 0x01 → "sounds"
  • FirstSubDirectory = 0,SubCount = 0——没有嵌套的子目录
  • FirstFile = 0x28——六条 HpiEntry2 记录中的第一条(每条 24 字节)
  • FileCount = 6

位于 0x28、0x40、0x58……的每条文件记录包含:

NamePtr           // → name pool offset
Start             // → absolute file offset of the SQSH chunk (or stored bytes)
DecompressedSize
CompressedSize    // 0 ⇒ stored verbatim (no SQSH wrapper)
Date              // time_t, informational
Checksum          // informational

解码后,这六个条目与 kbot hpi list 报告的内容完全对应:

sounds/Whodied.wav        56474 bytes
sounds/Sure.wav           15248 bytes
sounds/WhoahPally.wav     24826 bytes
sounds/Forgetaboutit.wav  24826 bytes
sounds/Yo.wav             19368 bytes
sounds/Igotcher.wav       35980 bytes

5. 这里没有的东西(与 v1 相比)

如果你对 v1 的解析记忆犹新,就会发现缺失的这些部分恰恰是 v2 简化的意义所在:

  • 没有 XOR 密钥推导——对 v2 而言 DecryptKey 总是 0。即使某个 SQSH 块的 Encrypted == 1,块内的第二遍 XOR 仍然要执行,但目录/载荷这层外壳是明文。
  • 没有按位置推进的种子——让 v1 读取器变得复杂的逐字节加密(解密依赖于文件绝对偏移量)在 v2 的外壳层面并不存在。
  • 没有块大小表——v1 文件被切成 ≤64 KiB 的块,前面带一个 uint32 大小数组。v2 把每个文件存为单个 SQSH 块(或直接存储的字节),因此没有什么需要大小表的。
  • 没有“目录紧跟在文件头之后的 0x14 处”的约定——目录块和名称块放在写入器安排的任何位置(通常在文件末尾附近),由文件头指向它们。

Tip

在你自己的机器上重现这次解析。

kbot ctx add /path/to/tak --alias tak --game takingdoms
kbot hpi info  /path/to/tak/Jersey.hpi
kbot hpi list  /path/to/tak/Jersey.hpi -v
kbot hpi extract /path/to/tak/Jersey.hpi -t ./jersey-out
# And to peek at the raw bytes:
xxd -l 64 /path/to/tak/Jersey.hpi          # 32-byte v2 header
xxd -s 0x10640 -l 96 /path/to/tak/Jersey.hpi   # name pool
xxd -s 0x10692 -l 32 /path/to/tak/Jersey.hpi   # SQSH directory chunk header

另见

  • GAF、PCX、3DO、TNT、COB、TDF——HPI 压缩包中所包含的各类文件。
  • 术语表——虚拟文件系统、分层叠加及其他与 HPI 相关的术语的定义。