Koikake PAC 资源包格式逆向分析
其实Garbro已经有现成了,这里重复造轮子学习一下吧,多学也不是坏事
摘要
本文整理 Koikake 使用的 .pac 资源包格式、逆向分析路径、核心数据结构、关键伪汇编、核心伪 C 逻辑,以及解包器实现时需要注意的边界检查。
内容尽量兼顾可读性和技术细节。即使刚接触资源包逆向,也可以顺着分析过程理解:
-
为什么不能只根据十六进制窗口里的明文字符串直接推断文件格式。
-
如何从
Game.exe追踪到真正处理资源包的PAL.dll。 -
PalFileCreate/PalFileCreateEx在资源系统中的作用。 -
PAC 文件头、桶表、目录项、文件数据之间的关系。
-
游戏如何根据文件名在 PAC 中定位资源。
-
如何根据程序读取逻辑写出等价解包器。
分析结论
Koikake 的 PAC 是一种索引式资源容器。它不是单纯的“文件名表 + 数据区”线性结构,而是在主头后放置一张桶表,游戏运行时根据请求文件名的首字节进入对应桶,再在该桶对应的目录项范围中查找目标文件。
PAC 总体结构如下:
offset size description
0x00000000 4 magic, 固定为 "PAC "
0x00000004 4 reserved / unknown
0x00000008 4 entry_count, 目录项数量
0x0000000C 255 * 8 bucket table
0x00000804 entry_count * 40 directory entries
... variable file data
关键常量:
#define PAC_MAGIC "PAC "
#define PAC_HEADER_SIZE 12
#define PAC_BUCKET_COUNT 255
#define PAC_BUCKET_SIZE 8
#define PAC_BUCKET_TABLE_OFF 0x0C
#define PAC_DIRECTORY_OFF 0x804
#define PAC_ENTRY_SIZE 40
#define PAC_NAME_SIZE 32
桶表项结构:
typedef struct PacBucket {
uint32_t count;
uint32_t start_index;
} PacBucket;
目录项结构:
typedef struct PacEntry {
char name[32];
uint32_t size;
uint32_t offset;
} PacEntry;
所有整数均为 little-endian。文件数据在 PAC 层没有发现压缩或加密,按目录项中的 offset 和 size 直接读取即可。
逆向目标
目标是还原 .pac 容器格式,并写出通用解包器,使其可以完成以下操作:
python koikake_pac_extract.py bgm.pac
以及批量处理:
python koikake_pac_extract.py pac_folder
由于 PAC 层只是容器层,解包后得到的 .ogg、.csv、.bmp 等文件通常可以直接识别;.pgd、.ani、.mix 等扩展名属于下一层私有格式,不在本文主要讨论范围内。
样本初步观察
对 PAC 文件开头进行十六进制观察,可以看到类似内容:
50 41 43 20 00 00 00 00 1E 00 00 00 ...
前四字节为:
50 41 43 20 = "PAC "
这里需要注意第四字节是空格字符 0x20,因此魔数不是 "PAC",而是 "PAC "。
接下来的四字节通常为零或未知保留字段;再后面的四字节是一个小端整数。以 1E 00 00 00 为例,值为 0x1E,十进制是 30。结合 bgm.pac 样本可以验证该值对应目录项数量。
初步可以提出假设:
0x00 magic
0x04 unknown
0x08 entry_count
但此时还不能确定目录在哪里,也不能确定目录项大小。
初期误判与修正
在某些 PAC 中,可以在 0x1000 附近看到明文文件名,例如:
ANI_BK_SCROLL010L.ANI
ANI_BK_SCROLL010R.ANI
ARCHIVE.DAT
这很容易诱导出一个初步假设:
目录表从 0x1000 附近开始,每个目录项连续保存文件名、大小、偏移。
该假设只解释了部分现象,但无法解释另一些 PAC,例如音频包在相同区域看起来像随机数据。最初也曾尝试把目录起点设置为 0x1000 或 0x0FFC,再用不同目录项大小进行解析,结果会出现以下问题:
-
解析数项后文件名错位。
-
offset + size超出 PAC 文件末尾。 -
目录项数量与头部
entry_count不一致。 -
某些包完全无法得到合理文件名。
这些现象说明:单靠“看到明文字符串的位置”推断目录起点是不可靠的。必须回到程序本身,看它实际如何 seek 和 read。
从 Game.exe 追踪到 PAL.dll
在主程序反编译结果中搜索以下关键词:
.pac
archive.dat
PalFileCreate
ReadFile
SetFilePointer
可以发现游戏读取资源时大量调用:
PalFileCreate("Script.src", ...);
PalFileCreate("Text.dat", ...);
PalFileCreate("File.dat", ...);
这说明游戏上层代码并不直接用 Windows CreateFileA 打开 PAC 内文件,而是通过 PalFileCreate 这个抽象接口读取资源。
从导入表或导出表可知:
PalFileCreate
PalFileCreateEx
PalFileSetFilePointer
PalFileGetFullPath
这些函数由 PAL.dll 提供。因此 PAC 容器的核心读取算法不在主程序本身,而在 PAL.dll。
这是资源包逆向中非常重要的判断:当主程序通过引擎 DLL 或资源系统 DLL 读取文件时,真正的格式解析函数通常在该 DLL 里。
PalFileCreate 与 PalFileCreateEx
PalFileCreate 是一个薄封装。它把默认参数压栈后调用 PalFileCreateEx。
伪汇编可表达为:
PalFileCreate:
push out_info
push flags
push creation_disposition
push share_mode
push desired_access
push filename
call PalFileCreateEx
ret
PalFileCreateEx 的职责更完整。它大致执行以下流程:
输入一个逻辑文件名,例如 "BGM00.OGG"
先尝试按普通文件路径打开
如果普通文件存在,直接返回普通文件句柄
如果普通文件不存在,拼接或遍历已挂载的 PAC 包
进入 PAC 内部目录查找文件名
找到后把文件指针设置到资源数据位置
返回 PAC 句柄和资源大小信息
用伪 C 表示:
HANDLE PalFileCreateEx(
const char *logical_name,
DWORD desired_access,
DWORD share_mode,
DWORD creation_disposition,
DWORD flags,
PalFileInfo *out_info
) {
char normalized_name[260];
normalize_to_uppercase(normalized_name, logical_name);
HANDLE h = CreateFileA(
normalized_name,
desired_access,
share_mode,
NULL,
creation_disposition,
flags,
NULL
);
if (h != INVALID_HANDLE_VALUE) {
DWORD file_size = GetFileSize(h, NULL);
out_info->base_offset = 0;
out_info->size = file_size;
out_info->end_offset = file_size;
return h;
}
return open_from_pac_archives(normalized_name, out_info);
}
PalFileCreateEx 由此证明:PAC 内文件对上层而言被伪装成普通文件。游戏脚本或资源加载器只需要请求逻辑文件名,底层自动在普通目录和 PAC 容器中查找。
核心函数:PAC 内文件查找
PalFileCreateEx 内部会调用一个更底层的查找函数。根据反汇编行为,可以将其抽象为:
HANDLE OpenFileFromPac(
const char *pac_base_name,
const char *request_name,
DWORD desired_access,
DWORD share_mode,
DWORD creation_disposition,
DWORD flags,
PalFileInfo *out_info
);
该函数负责:
-
拼接
.pac扩展名。 -
打开 PAC 文件。
-
读取桶表。
-
根据请求文件名首字节定位桶。
-
在桶范围内查找目录项。
-
读取命中的
size和offset。 -
将 PAC 文件指针移动到数据偏移。
软件运行时如何读取资源
从软件行为角度看,PAC 资源系统并不是“先把整个包解开到内存”,而是按需查找、按需定位、按需读取。
上层代码通常只知道逻辑文件名,例如:
PalFileCreate("BGM00.OGG", &info);
PalFileCreate("Text.dat", &info);
PalFileCreate("Script.src", &info);
上层并不关心这些文件到底是磁盘上的独立文件,还是 PAC 容器里的条目。这个抽象由 PAL.dll 完成。
运行时读取流程可以整理为:
游戏逻辑请求文件名
|
v
PalFileCreate
|
v
PalFileCreateEx
|
+--> 先尝试按普通文件打开
|
+--> 普通文件不存在时,进入 PAC 搜索流程
|
v
构造或选择 pac 文件
|
v
读取 PAC 桶表
|
v
根据文件名首字节选择桶
|
v
在桶范围内比较目录项文件名
|
v
找到目录项后读取 size / offset
|
v
SetFilePointer 到 offset
|
v
返回 PAC 文件句柄
也就是说,PalFileCreate 返回的句柄有时是真正独立文件的句柄,有时是 PAC 文件本身的句柄。区别由附带的文件信息结构记录,例如当前资源的起始偏移、长度、结束偏移等。
简化后的运行时读取模型如下:
typedef struct PalFileInfo {
uint32_t offset; // 当前资源在真实文件中的起始偏移
uint32_t end_offset; // offset + size
uint32_t size; // 当前资源大小
} PalFileInfo;
当资源来自普通文件时:
offset = 0
size = GetFileSize(file)
end_offset = size
当资源来自 PAC 时:
offset = entry.offset
size = entry.size
end_offset = entry.offset + entry.size
随后上层调用 ReadFile 或引擎自己的读取封装时,只需要从当前句柄读取数据。底层已经把 PAC 文件指针移动到了资源数据起点。
这个设计的好处是:上层资源加载器可以用同一套读取接口处理普通文件和打包文件。
PAC 本体是否存在解密
结论:PAC 容器层没有发现实际数据加密,也没有发现目录项需要解密。
容易产生误解的原因有两个。
第一,某些 PAC 在 0x1000 附近看起来像乱码。这个现象不是加密导致的,而是因为初期观察的位置并不是目录起点。PAC 的真实目录起点是:
0x0C + 255 * 8 = 0x804
如果从错误位置切目录项,字段自然会错位,看起来就像“加密数据”。
第二,Game.exe 中确实存在一段 ROL/ROR/XOR 混淆逻辑。该逻辑容易被误认为是 PAC 解密算法,但经过样本验证,它不适用于 PAC 主体目录,也不能把 PAC 数据区还原成合理文件名。
因此需要区分两层概念:
PAC 容器层:
负责文件名、大小、偏移、文件数据。
当前分析未发现加密。
access.dat / 缓存索引层:
游戏可能生成或读取的访问缓存。
发现 ROL/ROR/XOR 混淆逻辑。
该逻辑不是 PAC 本体解包所必需。
PAC 解包器只需要:
读取桶表
读取目录项
按 offset / size 复制数据
不需要对 PAC 文件内容做解密。
access.dat 混淆算法说明
虽然 PAC 解包不需要该算法,但为了说明“软件中确实存在混淆逻辑”,这里把相关算法整理出来。
写入方向的伪 C:
void encode_access_data(uint8_t *buf, uint32_t size) {
uint32_t aligned = size;
if ((aligned & 3) != 0) {
aligned -= (aligned & 3);
}
uint8_t rotate = 4;
for (uint32_t i = 0; i < aligned; i += 4) {
uint32_t *p32 = (uint32_t *)(buf + i);
*p32 ^= 0xFF987DEE;
*p32 ^= 0x084DF873;
buf[i] = ror8(buf[i], rotate);
rotate++;
}
}
读取方向的伪 C:
void decode_access_data(uint8_t *buf, uint32_t size) {
uint32_t aligned = size;
if ((aligned & 3) != 0) {
aligned -= (aligned & 3);
}
uint8_t rotate = 4;
for (uint32_t i = 0; i < aligned; i += 4) {
uint32_t *p32 = (uint32_t *)(buf + i);
buf[i] = rol8(buf[i], rotate);
*p32 ^= 0x084DF873;
*p32 ^= 0xFF987DEE;
rotate++;
}
}
对应伪汇编:
; decode
mov rotate, 4
loop:
rol byte ptr [buf], rotate
xor dword ptr [buf], 084DF873h
xor dword ptr [buf], FF987DEEh
add buf, 4
inc rotate
sub remain, 4
jne loop
这段算法的特征:
-
每次处理 4 字节。
-
只对每个 4 字节块的第一个字节执行 8-bit rotate。
-
再对整个 32-bit 块做两次 XOR。
-
长度按 4 字节对齐,尾部不足 4 字节的数据不参与该循环。
但再次强调:它不是 .pac 解包的必要步骤。PAC 本体目录和文件数据无需经过这段算法。
关键伪汇编
以下伪汇编不是逐字节原始反汇编,而是保留关键语义后的整理版本,便于理解。
打开 PAC
; 普通文件打开失败后,构造 "<name>.pac"
wsprintfA(local_path, "%s%s", pac_base_name, ".pac")
CreateFileA(
local_path,
GENERIC_READ,
FILE_SHARE_READ,
NULL,
OPEN_EXISTING,
FILE_FLAG_RANDOM_ACCESS,
NULL
)
cmp eax, INVALID_HANDLE_VALUE
je open_failed
mov pac_handle, eax
读取桶表
; 跳过 PAC 主头
SetFilePointer(pac_handle, 0x0C, 0, FILE_BEGIN)
; 读取 0x7F8 字节
; 0x7F8 = 2040
; 2040 / 8 = 255
ReadFile(
pac_handle,
bucket_table_buffer,
0x7F8,
&bytes_read,
NULL
)
这是整个格式分析的关键证据。
它直接说明:
主头长度 = 0x0C
桶表大小 = 0x7F8
桶表项数量 = 0x7F8 / 8 = 255
目录表起点 = 0x0C + 0x7F8 = 0x804
根据文件名首字节选择桶
movsx eax, byte ptr [request_name]
shl eax, 3
lea ecx, [bucket_table + eax]
mov edi, [ecx] ; count
mov eax, [ecx + 4] ; start_index
等价伪 C:
uint8_t bucket_id = (uint8_t)request_name[0];
PacBucket *bucket = &bucket_table[bucket_id];
uint32_t count = bucket->count;
uint32_t start = bucket->start_index;
计算目录项位置
目录项大小是 0x28,即 40 字节。
反汇编中可见类似计算:
lea ecx, [index + index * 4]
shl ecx, 3
数学含义:
(index + index * 4) << 3
= index * 5 * 8
= index * 40
目录项文件偏移:
entry_offset = 0x804 + index * 40
读取并比较文件名
查找时会读取目录项前 0x20 字节,即 32 字节文件名:
SetFilePointer(pac_handle, entry_offset, 0, FILE_BEGIN)
ReadFile(pac_handle, entry_name, 0x20, &bytes_read, NULL)
比较逻辑按 4 字节为单位比较,共比较 0x1C 或 0x20 范围内的内容。整理成伪 C 就是:
int cmp = memcmp(request_name_fixed_32, entry.name, 32);
反汇编中还可以看到折半调整搜索范围的行为,说明同一桶内目录项按文件名排序,程序使用近似二分查找,而不是从头线性扫描。
读取命中项的大小和偏移
命中文件名后,程序继续读取两个 4 字节字段:
ReadFile(pac_handle, &out_info->size, 4, &bytes_read, NULL)
ReadFile(pac_handle, &out_info->offset, 4, &bytes_read, NULL)
然后:
mov eax, out_info->offset
add eax, out_info->size
mov out_info->end_offset, eax
SetFilePointer(pac_handle, out_info->offset, 0, FILE_BEGIN)
return pac_handle
这说明目录项后 8 字节顺序是:
uint32_t size;
uint32_t offset;
而不是 offset, size。
完整伪 C:PAC 数据结构
#include <stdint.h>
#pragma pack(push, 1)
typedef struct PacHeader {
char magic[4]; // "PAC "
uint32_t reserved; // usually 0
uint32_t entry_count; // number of directory entries
} PacHeader;
typedef struct PacBucket {
uint32_t count; // number of entries in this bucket
uint32_t start_index; // first entry index in directory table
} PacBucket;
typedef struct PacEntry {
char name[32]; // uppercase file name, zero-padded
uint32_t size; // file size in bytes
uint32_t offset; // absolute offset in PAC file
} PacEntry;
#pragma pack(pop)
完整伪 C:PAC 查找算法
以下伪 C 尽量贴近 PAL.dll 的行为。
#define PAC_BUCKET_COUNT 255
#define PAC_BUCKET_TABLE_OFF 0x0C
#define PAC_BUCKET_TABLE_LEN 0x7F8
#define PAC_DIRECTORY_OFF 0x804
#define PAC_ENTRY_SIZE 0x28
typedef struct PalFileInfo {
uint32_t offset;
uint32_t end_offset;
uint32_t size;
} PalFileInfo;
static void make_fixed_name(char out[32], const char *name) {
memset(out, 0, 32);
for (int i = 0; i < 32 && name[i] != '\0'; i++) {
unsigned char c = (unsigned char)name[i];
if (c >= 'a' && c <= 'z') {
c -= 0x20;
}
out[i] = (char)c;
}
}
static int compare_entry_name(const char fixed_name[32], const PacEntry *entry) {
return memcmp(fixed_name, entry->name, 32);
}
HANDLE open_from_one_pac(
const char *pac_path,
const char *request_name,
PalFileInfo *out_info
) {
HANDLE h = CreateFileA(
pac_path,
GENERIC_READ,
FILE_SHARE_READ,
NULL,
OPEN_EXISTING,
FILE_FLAG_RANDOM_ACCESS,
NULL
);
if (h == INVALID_HANDLE_VALUE) {
return INVALID_HANDLE_VALUE;
}
PacHeader header;
DWORD read_size;
SetFilePointer(h, 0, NULL, FILE_BEGIN);
ReadFile(h, &header, sizeof(header), &read_size, NULL);
if (memcmp(header.magic, "PAC ", 4) != 0) {
CloseHandle(h);
return INVALID_HANDLE_VALUE;
}
PacBucket buckets[PAC_BUCKET_COUNT];
SetFilePointer(h, PAC_BUCKET_TABLE_OFF, NULL, FILE_BEGIN);
ReadFile(h, buckets, sizeof(buckets), &read_size, NULL);
char fixed_name[32];
make_fixed_name(fixed_name, request_name);
unsigned int bucket_id = (unsigned char)fixed_name[0];
if (bucket_id >= PAC_BUCKET_COUNT) {
CloseHandle(h);
return INVALID_HANDLE_VALUE;
}
PacBucket bucket = buckets[bucket_id];
if (bucket.count == 0) {
CloseHandle(h);
return INVALID_HANDLE_VALUE;
}
uint32_t left = bucket.start_index;
uint32_t right = bucket.start_index + bucket.count;
while (left < right) {
uint32_t mid = left + (right - left) / 2;
uint32_t entry_off = PAC_DIRECTORY_OFF + mid * PAC_ENTRY_SIZE;
PacEntry entry;
SetFilePointer(h, entry_off, NULL, FILE_BEGIN);
ReadFile(h, &entry, sizeof(entry), &read_size, NULL);
int cmp = compare_entry_name(fixed_name, &entry);
if (cmp == 0) {
out_info->offset = entry.offset;
out_info->size = entry.size;
out_info->end_offset = entry.offset + entry.size;
SetFilePointer(h, entry.offset, NULL, FILE_BEGIN);
return h;
}
if (cmp < 0) {
right = mid;
} else {
left = mid + 1;
}
}
CloseHandle(h);
return INVALID_HANDLE_VALUE;
}
说明:
-
真实程序的比较过程不是标准库
memcmp的直接调用,而是展开后的逐字比较/逐 DWORD 比较。 -
真实程序内部的二分边界计算比上面伪 C 更绕,但语义等价:在桶指定范围中查找文件名。
-
解包器不必复刻二分查找。解包器需要列出全部文件,直接读取目录表即可。
完整伪 C:解包器算法
解包器不需要按文件名单独查找,而是需要枚举 PAC 中所有目录项。
bool extract_pac(const char *pac_path, const char *output_dir) {
FILE *fp = fopen(pac_path, "rb");
if (!fp) {
return false;
}
PacHeader header;
fread(&header, 1, sizeof(header), fp);
if (memcmp(header.magic, "PAC ", 4) != 0) {
fclose(fp);
return false;
}
PacBucket buckets[PAC_BUCKET_COUNT];
fseek(fp, PAC_BUCKET_TABLE_OFF, SEEK_SET);
fread(buckets, sizeof(PacBucket), PAC_BUCKET_COUNT, fp);
uint32_t first = UINT32_MAX;
uint32_t last = 0;
for (uint32_t i = 0; i < PAC_BUCKET_COUNT; i++) {
if (buckets[i].count == 0) {
continue;
}
uint32_t start = buckets[i].start_index;
uint32_t end = start + buckets[i].count;
if (start < first) {
first = start;
}
if (end > last) {
last = end;
}
}
if (first == UINT32_MAX) {
fclose(fp);
return true;
}
if (header.entry_count != 0 && last > header.entry_count) {
last = header.entry_count;
}
for (uint32_t index = first; index < last; index++) {
PacEntry entry;
uint32_t entry_off = PAC_DIRECTORY_OFF + index * PAC_ENTRY_SIZE;
fseek(fp, entry_off, SEEK_SET);
fread(&entry, 1, sizeof(entry), fp);
if (!validate_entry(entry, pac_file_size)) {
fclose(fp);
return false;
}
write_file_from_range(
fp,
output_dir,
entry.name,
entry.offset,
entry.size
);
}
fclose(fp);
return true;
}
边界校验非常重要。正确实现至少应检查:
entry.offset + entry.size <= pac_file_size
entry.offset >= PAC_DIRECTORY_OFF + header.entry_count * PAC_ENTRY_SIZE
第一条防止读取越过文件末尾。第二条防止把目录区当作文件数据区读取。
Python 实现中的核心对应关系
Python 脚本中的常量:
BUCKET_TABLE_OFFSET = 12
BUCKET_COUNT = 255
BUCKET_ENTRY_SIZE = 8
DIRECTORY_OFFSET = BUCKET_TABLE_OFFSET + BUCKET_COUNT * BUCKET_ENTRY_SIZE
ENTRY_SIZE = 40
对应 C 结构:
PAC_BUCKET_TABLE_OFF = 0x0C
PAC_BUCKET_COUNT = 255
PAC_BUCKET_SIZE = 8
PAC_DIRECTORY_OFF = 0x804
PAC_ENTRY_SIZE = 40
读取桶表:
f.seek(BUCKET_TABLE_OFFSET)
bucket_raw = f.read(BUCKET_COUNT * BUCKET_ENTRY_SIZE)
解析桶表项:
count, start = struct.unpack_from("<II", bucket_raw, bucket * BUCKET_ENTRY_SIZE)
读取目录项:
raw = f.read(ENTRY_SIZE)
name = read_c_name(raw[:32])
file_size, offset = struct.unpack_from("<II", raw, 32)
这里的 "<II" 表示两个 little-endian uint32_t。
关于文件名编码
目录项中的文件名字段长度固定为 32 字节。大多数资源名是 ASCII,例如:
BGM00.OGG
SE01_A001A.OGG
EV001A01.PGD
VO01_1001.OGG
考虑到日文游戏可能使用 Shift-JIS / CP932 编码,Python 实现中使用:
raw.decode("cp932", errors="replace")
这样既能正确处理 ASCII,也更适合 Windows 日文游戏常见资源名。
关于文件名安全
解包器不能无条件信任资源包里的文件名。如果直接把目录项名字拼接成输出路径,恶意或损坏的包可能造成路径穿越,例如:
..\..\target.txt
因此实现中应处理:
-
去除绝对路径前缀。
-
拒绝或替换
..。 -
替换 Windows 不允许出现在文件名中的字符。
-
将输出限制在用户选择的输出目录下。
这属于工具可靠性问题,不是 PAC 格式本身的一部分,但对解包器很重要。
如何验证格式结论
魔数验证
所有支持的样本都应满足:
file[0:4] == "PAC "
如果魔数不匹配,不能按该格式解析。
数量验证
entry_count 应与桶表覆盖范围一致:
max(bucket.start_index + bucket.count) <= entry_count
如果超出,需要怀疑:
-
字节序错误。
-
桶表起点错误。
-
该文件不是同一格式。
-
文件损坏。
偏移验证
每个目录项必须满足:
offset + size <= pac_file_size
如果大量条目违反该条件,通常说明 size 和 offset 顺序解析错了,或目录项大小推断错了。
本格式中正确顺序为:
name[32]
size
offset
不是:
name[32]
offset
size
数据魔数验证
解出的常见文件可以继续用自身魔数验证:
OGG: 4F 67 67 53 = "OggS"
BMP: 42 4D = "BM"
CSV: 可读文本
如果音频资源解出后能被播放器识别,说明 PAC 层偏移和大小基本正确。
多样本验证
不能只用一个 PAC 样本验证格式。至少应覆盖:
-
音频包
-
图片包
-
语音包
-
系统资源包
-
更新资源包
原因是某些包目录较短,有些包目录较长,有些包首字母分布集中,有些包文件数量巨大。只有跨样本验证通过,格式结论才可靠。
常见误区
误区一:看到明文文件名就认为目录从那里开始
明文字符串可能出现在目录区中间,也可能出现在文件数据中。必须结合程序的 SetFilePointer 和 ReadFile 行为确认结构起点。
误区二:只用单一样本推断格式
小包可能刚好让不完整的假设看起来成立。必须同时测试多个不同类型的 PAC。
误区三:把资源读取封装当成普通文件读取
PalFileCreate("BGM00.OGG") 不代表磁盘上真的存在 BGM00.OGG。它可能由底层资源系统映射到 PAC 内部。
误区四:忽略桶表
如果按线性目录从错误偏移扫描,可能在某些位置看到正确文件名,但整体无法稳定解析。桶表才是这个 PAC 格式的核心。
误区五:忽略字段顺序验证
目录项末尾两个字段都像整数。必须通过边界验证确定顺序。本格式是:
size first, offset second
后续逆向方向
PAC 解包完成后,可以继续分析解出的私有格式。
.PGD 可能是图片或纹理资源。建议从以下方向分析:
-
文件头魔数。
-
宽高字段。
-
像素格式。
-
是否有调色板。
-
是否有压缩块。
-
游戏中负责加载
.PGD的函数。
.ANI 可能是动画描述。建议关注:
-
帧数量。
-
每帧持续时间。
-
坐标或矩形字段。
-
引用的图片资源名。
.MIX 可能是音频或语音混合配置。建议关注:
-
条目数量。
-
音量或声道参数。
-
引用的
.OGG或语音编号。
继续逆向时仍然使用同一方法:
观察样本
搜索扩展名
定位读取函数
记录 read/seek 顺序
还原结构体
写脚本验证
跨样本测试
反汇编证据附录
本节把关键证据整理成可审计的形式。这里不追求逐条还原所有机器指令,而是记录足以证明格式结构的关键指令模式。
PalFileCreate 调用 PalFileCreateEx
PalFileCreate 本身是一个薄封装,主要负责填入默认参数并转入 PalFileCreateEx。
关键语义:
push out_info
push flags
push creation_disposition
push share_mode
push desired_access
push logical_name
call PalFileCreateEx
ret
该证据说明:真正处理文件系统和 PAC 查找的函数不是 PalFileCreate,而是 PalFileCreateEx。
普通文件失败后进入 PAC 查找
PalFileCreateEx 会先尝试普通文件路径:
call CreateFileA
cmp eax, INVALID_HANDLE_VALUE
jne normal_file_ok
如果普通文件打开失败,才会构造 .pac 路径并进入 PAC 查找分支:
wsprintfA(local_path, "%s%s", archive_name, ".pac")
call CreateFileA
cmp eax, INVALID_HANDLE_VALUE
je pac_open_failed
该证据说明:PAC 是普通文件系统的后备来源。上层请求逻辑文件名时,底层先查独立文件,再查资源包。
桶表读取证据
PAC 打开后,关键读取逻辑为:
SetFilePointer(pac_handle, 0x0C, 0, FILE_BEGIN)
ReadFile(pac_handle, bucket_table, 0x7F8, &bytes_read, NULL)
由此可直接推导:
0x0C = bucket table offset
0x7F8 = bucket table size
0x7F8 / 8 = 255 buckets
这是确认 PAC 不是简单线性目录的核心证据。
目录项大小证据
查找过程中存在如下计算:
lea ecx, [index + index * 4]
shl ecx, 3
等价于:
(index + index * 4) << 3 = index * 40
所以目录项大小为 40 字节,即 0x28。
文件名字段长度证据
命中检查前会读取目录项前 0x20 字节:
ReadFile(pac_handle, entry_name, 0x20, &bytes_read, NULL)
因此文件名字段长度为:
0x20 = 32 bytes
结合目录项总大小 0x28,剩余 8 字节自然对应两个 uint32_t 字段。
size / offset 顺序证据
文件名命中后,程序连续读取两个 4 字节字段,并将其中一个作为资源长度,一个作为文件偏移:
ReadFile(pac_handle, &size, 4, &bytes_read, NULL)
ReadFile(pac_handle, &offset, 4, &bytes_read, NULL)
end_offset = offset + size
SetFilePointer(pac_handle, offset, 0, FILE_BEGIN)
因此目录项末尾字段顺序为:
uint32_t size;
uint32_t offset;
不是 offset 在前。
样本验证对照
以下是用最终算法解析不同 PAC 后得到的结果摘要。该表用于证明算法跨样本成立,而不是只适用于单个小文件。
archive entries first_entry last_entry
bgm.pac 30 BGM00.OGG BGM_OP.OGG
bk.pac 107 BK000D.PGD BK099D.PGD
data.pac 323 ANI_BKL_SCROLL000H.ANI VO_MALE.CSV
em.pac 5 EMLA013.MPG EMSA019.PGD
etc.pac 179 BACK_AYANE01.PGD SYM_TATESEN.PGD
etc_cn.pac 36 ED_LOGO_HF.PGD ROLL_SYUSYOU04.PGD
etc_tc.pac 36 ED_LOGO_HF.PGD ROLL_SYUSYOU04.PGD
ev.pac 192 EV001A01.PGD EV418A.PGD
face.pac 626 FA01A_A010.PGD FA14A_A070.PGD
mask.pac 20 MASK00.BMP MASK19.BMP
se.pac 449 SE01_A001A.OGG SE99_T041A.OGG
st.pac 384 ST01A_A010.PGD ST01C_D161.PGD
st2.pac 421 ST02A_A010.PGD ST02C_D191.PGD
st3.pac 384 ST03A_A010.PGD ST03C_D161.PGD
st4.pac 374 ST04A_A010.PGD ST04D_D130.PGD
st5.pac 620 ST05A_A010.PGD ST14A_A151.PGD
system.pac 209 ADVERTISEMENT.PGD VO99_TEST.OGG
system_cn.pac 143 BGM_BASE.PGD TITLE_BTN_SYSTEM.PGD
system_tc.pac 142 BGM_BASE.PGD TITLE_BTN_SYSTEM.PGD
update.pac 52 CGBASE.CSV VOMIX_E_05A.MIX
update2.pac 245 EV108A.PGD EV417E.PGD
update3.pac 3586 VO01_1069.OGG VO99_009.OGG
update4.pac 22 CG_BASE.PGD TITLE_BTN_SCENE.PGD
update_cn.pac 11 REP_BASE.PGD TITLE_BTN_SCENE.PGD
update_tc.pac 11 REP_BASE.PGD TITLE_BTN_SCENE.PGD
voice.pac 11898 VO01_1001.OGG VO99_010.OGG
注意:该表中的 first_entry 和 last_entry 是按目录索引或解析输出顺序观察到的首尾项,不一定代表文件数据在 PAC 中的物理首尾位置。
十六进制布局标注示例
以下是 PAC 头部布局的抽象标注,不依赖具体本地文件路径。
00000000 50 41 43 20 00 00 00 00 1E 00 00 00 ...
|---------| |---------| |---------|
magic reserved entry_count
0000000C [bucket 0: count u32, start_index u32]
00000014 [bucket 1: count u32, start_index u32]
...
00000800 [bucket 254: count u32, start_index u32]
00000804 [entry 0]
name[32]
size u32
offset u32
0000082C [entry 1]
name[32]
size u32
offset u32
如果以 bgm.pac 这类音频包为例,第一条目录项可抽象为:
42 47 4D 30 30 2E 4F 47 47 00 ... 00 [32 bytes]
?? ?? ?? ?? size
?? ?? ?? ?? offset
文件名字段解释为:
BGM00.OGG\0...
后续根据 offset 跳转到数据区,应该能看到 OGG 文件魔数:
4F 67 67 53 = "OggS"
这类“目录项字段”和“目标数据魔数”的对应关系,是验证解析正确的重要证据。
PalFileInfo 结构推测
根据 PalFileCreateEx 命中普通文件和 PAC 文件后的写入行为,可以推测文件信息结构至少包含三个 32-bit 字段。
抽象结构如下:
typedef struct PalFileInfo {
uint32_t offset;
uint32_t end_offset;
uint32_t size;
} PalFileInfo;
普通文件命中时:
info.offset = 0;
info.size = GetFileSize(h, NULL);
info.end_offset = info.size;
PAC 条目命中时:
info.offset = entry.offset;
info.size = entry.size;
info.end_offset = entry.offset + entry.size;
SetFilePointer(h, entry.offset, NULL, FILE_BEGIN);
这个结构的作用不是描述整个 PAC 文件,而是描述“当前逻辑文件”在真实句柄中的可读范围。
进一步逆向 PalFileSetFilePointer、读取封装和 EOF 检查函数,可以继续确认:
读取逻辑是否限制在 end_offset 之前
seek 是否以逻辑文件 offset 为基准
是否允许相对当前逻辑文件起点定位
这部分不是解包器的必要条件,但有助于完整理解引擎文件系统。
解包器验收标准
一个正确的 PAC 解包器至少应通过以下验收项。
格式验收
magic == "PAC "
entry_count 合理
bucket 范围不越界
entry.offset + entry.size 不超过 PAC 文件大小
entry.offset 不落在目录区内部
样本验收
应能解析不同类型资源包:
音频包
图片包
语音包
系统资源包
更新资源包
不能只用 data.pac 或 bgm.pac 单独验证。
文件验收
解出的常见文件应能通过自身魔数检查:
.ogg -> OggS
.bmp -> BM
.csv -> 可读文本或表格数据
工具行为验收
工具层面应满足:
支持单文件解包
支持目录批量解包
支持指定输出目录
支持不覆盖已有文件
输出 manifest,方便审计 offset / size
遇到坏条目时给出明确错误
实现时最好让命令行和图形界面共用同一套核心解析函数。这样后续修正格式解析时,只需要维护一处逻辑。
私有资源格式后续分析计划
PAC 解包只是第一阶段。若继续深入,可按优先级分析以下格式。
PGD 图像资源
重点观察:
文件头魔数
宽度、高度字段
像素格式
调色板
alpha 通道
压缩块或扫描线布局
建议从图片加载函数入手,搜索 .PGD、纹理创建 API、D3D 纹理上传函数。
ANI 动画资源
重点观察:
帧数量
帧时间
坐标矩形
引用图片名
循环标志
建议对比多个 .ANI 文件,找固定字段和变化字段。
MIX 混音或语音配置
重点观察:
条目数量
音量参数
声道参数
资源编号
关联 OGG 文件名或语音 ID
如果 .MIX 文件很小,优先用结构体猜测和多样本 diff。
DAT / SRC 脚本与文本
重点观察:
字符串表
指令表
跳转偏移
文本编码
索引数组
这类文件通常和游戏脚本 VM 或文本系统相关。建议结合 Script.src、Text.dat、File.dat 的读取函数继续逆向。
最终格式速查
PAC 主头:
struct PacHeader {
char magic[4]; // "PAC "
uint32_t reserved;
uint32_t entry_count;
};
桶表:
struct PacBucket {
uint32_t count;
uint32_t start_index;
};
PacBucket buckets[255]; // offset = 0x0C
目录项:
struct PacEntry {
char name[32];
uint32_t size;
uint32_t offset;
};
PacEntry entries[entry_count]; // offset = 0x804
数据定位:
file_data = pac_base + entry.offset;
file_size = entry.size;
目录项偏移:
entry_file_offset = 0x804 + index * 40;
桶范围:
bucket_id = (uint8_t)uppercase_name[0];
start = buckets[bucket_id].start_index;
end = start + buckets[bucket_id].count;
查找方式:
binary_search(entries[start:end], uppercase_name);
解包方式:
for each entry:
seek(entry.offset)
read(entry.size)
write(entry.name)
结语
这次 PAC 逆向的关键不在于发现了复杂加密,而在于从错误的线性目录假设中退出,回到程序真实读取路径,通过 PAL.dll 的 SetFilePointer、ReadFile 和目录项索引计算还原格式。
对于新手来说,最重要的经验是:十六进制观察负责提出假设,反汇编负责验证假设,脚本负责批量检验假设。
