2026-03-10 | 研究与探索 | UNLOCK

植物大战僵尸的 PAK 文件格式

PVZ 的 main.pak 文件包含了游戏的贴图、动画和字符串等资源,它使用了宝开自定义的一种简单格式,似乎宝石迷阵也使用了一样的格式。互联网上没有 简明的文档,因此记录如下。

异或加密

文件中所有字节都经过了 0xF7 异或加密。以下描述的是经过 0xF7 异或解密的内容,按顺序排列。

魔法数字

长度: 8 字节。

文件的前 8 字节是固定的内容,内容如下(经过 0xF7 异或解密后):

1
2
3
+------+------+------+------+------+------+------+------+
| 0xBA | 0xC0 | 0x4A | 0xC0 | 0x00 | 0x00 | 0x00 | 0x00 |
+------+------+------+------+------+------+------+------+

前四个字节经过 0xF7 异或之后是 CP1252 编码的 7½7M

后四字节似乎是保留字段,恒为 0。

文件描述区

长度: 所有文件描述数据结构之和

文件描述区由以下结构重复多遍组成,直到读到 0x80 为止。

offset size description
0 1 恒为0,用来判断文件描述区是否结束
1 1 文件名字符串长度
2 N 文件名字符串
N+2 4 文件大小
N+6 8 文件时间

文件名字符串中没有结尾空字符,包含子目录前缀,路径分隔符为反斜杠 \

文件大小为小端序,表示字节数。

文件时间见 FILETIME,是小端序的 64 位整数,表示自 1601-01-01 00:00:00 UTC 以来的 100ns 数。

文件描述区结束标记

长度: 1 字节。

内容: 0x80

用来判断文件描述区结束,从下个字节开始是文件数据区。

文件数据区

长度: 所有文件长度之和。

文件数据区中内容与文件描述区中每一记录一一对应。

参考

解码脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
#!/usr/bin/env python3

import sys
import os
import time

def main():
filename = sys.argv[1]

with open(filename, 'rb') as f:
data = f.read()

print(f'magic number: 0x{int.from_bytes(data[0:4]):08X} ({data[0:4].decode('latin-1')})')

assert data[0:4] == b'\x37\xBD\x37\x4D'

data = bytes(b ^ 0xf7 for b in data)

assert data[4:8] == b'\x00\x00\x00\x00'

print('-----')

i = 8
cnt = 0
files: list[tuple[str, int, float]] = []
while data[i] == 0:
# file name length
fnl = data[i + 1]
file_name = data[i+2:i+2+fnl].decode()
file_size = int.from_bytes(data[i+2+fnl:i+2+fnl+4], 'little')
file_time = ms_filetime_to_unix(int.from_bytes(data[i+2+fnl+4:i+2+fnl+12], 'little'))
files.append((file_name, file_size, file_time))
if len(sys.argv) == 2:
print(f'name: {file_name}')
print(f'size: {file_size} ({format_size(file_size)})')
utc_time = time.gmtime(file_time)
print(f'time: {time.strftime("%Y-%m-%d %H:%M:%S UTC", utc_time)}')
print('-----')
i += fnl + 14
cnt += 1
print(f'total: {cnt} files')

assert data[i] == 0x80

i += 1
if len(sys.argv) > 2:
dst_dir = sys.argv[2]
os.makedirs(dst_dir, exist_ok=True)
for name, size, file_time in files:
name = name.replace('\\', '/')
file_path = os.path.join(dst_dir, name)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, 'wb') as fout:
fout.write(data[i:i + size])
i += size
os.utime(file_path, (file_time, file_time))
print(f'write {file_path}')

# FILETIME use 1601-01-01 as epoch
def ms_filetime_to_unix(ft):
return ft/1e7 - 11644473600

def format_size(size) -> str:
if size < 1024:
return f'{size}B'
elif size < 1024 ** 2:
return f'{size/1024:0.1f}K'
elif size < 1024 ** 3:
return f'{size/1024**2:0.1f}M'
else:
return f'{size/1024**3:0.1f}G'

main()

用法:保存为 pak.py,在命令行中运行 python3 pak.py <path-to-pak> [dst_dir],如果提供了 dst_dir 参数,则会将文件提取到指定目录, 否则只会打印文件列表。