ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

梦幻古龙GDF资源解包打包与二进制格式解析工具链

梦幻古龙GDF资源解包打包与二进制格式解析工具链

简介:梦幻古龙GDF文件资源解包打包源代码面向游戏资源逆向与客户端改造的学习者,聚焦GDF这一游戏专用数据存储格式的解包与重打包问题。代码以面向对象方式组织:GDFFile封装单个文件的打开、数据块读取与解压,GDFManager负责多文件批量解包打包及依赖关系,GDFStream系列抽象连续数据流的读写与结构解析,压缩环节涉及LZ77、Huffman等常见思路。压缩包共8个文件,含5个.h头文件与3个.cpp实现文件,整体约21KB,体量轻但类结构完整,便于逐行阅读理清调用链。目前已有585人学习。读者可据此弄清GDF的字段布局与压缩流程,理解资源批量处理的接口设计,并以此为底座自行封装命令行或图形界面工具,弥补原代码缺少可视化操作的短板,也为同类游戏资源格式分析提供可复用参考。

1. 改一张界面贴图,客户端直接黑屏

改一张界面贴图,结果客户端启动后直接黑屏——这种事故在折腾端游资源时一点都不稀奇。梦幻古龙GDF文件资源这套源码干的正是这件事:把客户端里打包成 GDF 归档的贴图、模型、配置、音效拆出来,改完再按原格式塞回去,让客户端认。它不是什么资源编辑器,也不是可视化浏览器,而是一套围绕 GDF 容器格式做读写的最小工具链:GDF解包、GDF打包、索引重建、校验回写,全在命令行完成。适合两类人:一类是想替换素材做单机改动或本地测试的开发者,另一类是手上有一堆私有归档格式、想照着它的思路写自己解包器的人。它不依赖游戏本体就能跑,喂一个 GDF 文件进去,吐一棵目录树出来,反过来也一样。真正有价值的是它对二进制布局的处理方式,那套「读头 → 读索引 → 按偏移取数据」的骨架,换成任何归档格式都能套。

2. 工具链拆解:解包器、打包器、校验器各管什么

先看清楚这堆源码在工程上是怎么组织的。很多人拿到资源工具第一反应是找 main 函数直接跑,结果发现里面一堆 .cpp 互相引用,跑起来缺 zlib、缺头文件,折腾半天。这项目的结构其实很朴素,按职责切成三块:格式层负责把一个 GDF 文件翻译成内存里的索引表,操作层负责把索引表变成磁盘上的文件或者反过来,入口层负责命令行参数和批量调度。三层之间只通过索引表这个结构通信,所以你想换语言重写其中一层,只要保持索引表的字段语义不变,另外两层能原样复用。

2.1 源码目录长什么样

按这类工具的常见组织方式,源码包大体是这么分布的:

GDFTool/ ├── src/ │ ├── GdfFormat.h # 魔数、头部结构、索引项结构、标志位常量 │ ├── GdfArchive.cpp # 打开归档、解析索引、按条目读取 │ ├── GdfPacker.cpp # 从目录回封成 GDF │ ├── GdfVerify.cpp # CRC 校验、round-trip 对比 │ └── main.cpp # 命令行入口,list / unpack / pack / verify ├── third_party/ │ └── zlib/ # 解压依赖,静态编译进去省得运行时找不到 dll ├── samples/ │ └── readme.txt # 放几个小尺寸样本包,方便对拍 └── CMakeLists.txt

GdfFormat.h是整个项目的地基,里面只有结构体定义和常量,没有一行逻辑代码,改格式的时候只动这一个文件。GdfArchive.cpp负责只读路径,GdfPacker.cpp负责写路径,两边共享同一份结构体定义,这是保证「拆出来的东西能原样塞回去」的前提。third_party/zlib直接源码内嵌是血泪经验——这类小工具经常被人拷到没装运行库的机器上跑,动态链接一崩就是「找不到 zlib1.dll」,静态编进去省掉一整轮沟通。

2.2 三条主流程:list、unpack、pack

工具对外只有三个核心动作,第四个 verify 是辅助。list 只读头部和索引表,不碰数据区,所以哪怕包有几百兆也是瞬间出结果,用来快速确认条目数、偏移范围、有没有压缩标志。unpack 按索引逐条 seek 过去读、按需解压、落盘。pack 是反过程,先扫目录收集文件内容和大小,再算偏移、写数据、回填索引和头部。

值得说清楚的是 pack 的顺序问题。很多人想当然地「先把索引写好再写数据」,结果写完数据发现索引里的偏移全错了,又得回头改。正确顺序是:先占位写一个空头部,然后依次写数据区并记录每条的真实偏移,最后写索引表拿到索引偏移,再把头部 seek 回 0 位置回填。中间任何一步的顺序颠倒,都会得到「能打开但条目全错位」的包——这是最常见的翻车点。

编译没什么花样,装了 CMake 和任意一个 C++17 编译器就行:

# 生成构建目录,Release 模式跑得快一些 cmake -S . -B build -DCMAKE_BUILD_TYPE=Release # 编译,并行数按自己机器核数给 cmake --build build -j 8 # 看帮助,确认子命令都编出来了 ./build/gdftool --help

-DCMAKE_BUILD_TYPE=Release不是可选项,Debug 模式下解一个几百兆的包能慢到让你怀疑算法有问题,其实是 zlib 没开优化。-j 8换成自己机器的核数,别硬抄。编完之后build/目录下会有一个可执行文件,所有子命令都挂在它下面,不需要额外装什么。

2.3 运行环境与输入输出约定

输入就是一个 GDF 文件路径,输出是你指定的目录。工具不假设包在哪个位置,也不去扫游戏安装目录,路径全靠参数给。这一点很重要:它让整条链路变成可脚本化的,你可以写个 for 循环把整个资源目录批量跑一遍,而不用一个个点按钮。

常见做法是建一个工作目录,把原始包拷一份进去,解包输出到work/unpacked/,改完之后回封到work/repacked/,再用 verify 跟原始包对拍,确认没改的条目字节完全一致。这套流程跑顺了之后,每次改动只影响你动过的那几个文件,风险可控。别直接在原始包上原地回封,一旦中途崩了,原始包就废了——工具本身不给你后悔药。

3. 拆开 GDF 头部:二进制结构逐字段读一遍

GDF 说到底就是一个自定义归档容器,套路和 pak、dat 那类东西没本质区别:一个固定长度的头,一张索引表,后面跟着一堆数据块。看懂格式不需要什么玄学,拿十六进制编辑器打开一个样本,对着结构体一个个字段对,二十分钟就能摸清。下面这套布局是按源码里的结构定义还原的,字段名和标志位含义以GdfFormat.h为准。

3.1 文件头与索引表布局

头部固定 24 字节,小端序,字段顺序如下:

偏移长度字段说明
0x004magic固定为GDF1,用来快速判断是不是这个格式
0x044version格式版本,一般直接透传回写
0x084count索引项数量,等于归档里的文件条数
0x0C4indexOffset索引表在文件中的绝对偏移
0x104dataOffset数据区起始偏移,通常是 24 或对齐后的值
0x144flags全局标志位,目前基本填 0

索引项固定 96 字节,名字占前 64 字节用\0补齐,后面跟五个 32 位整数和 12 字节保留位:

偏移长度字段说明
0x0064name条目路径,ASCII/GBK,\0补齐,分隔符常见是\
0x404offset数据块在文件中的绝对偏移
0x444packedSize落盘大小,压缩后或未压缩的真实字节数
0x484rawSize解压后大小,用来校验解压结果
0x4C4entryFlagsbit0 为 1 表示该条目经过 zlib 压缩
0x504crc32原始数据的 CRC32,用于回封和校验
0x5412reserved保留,回封时原样填 0

对应到 C 结构体就是下面这样,#pragma pack(1)是必须的,否则编译器会自动补对齐,sizeof出来 100 多,读出来的字段全乱:

#pragma pack(push, 1) struct GdfHeader { char magic[4]; // "GDF1" uint32_t version; uint32_t count; uint32_t indexOffset; uint32_t dataOffset; uint32_t flags; }; // 24 字节 struct GdfEntry { char name[64]; // 以 \0 结尾的路径,可能是 GBK uint32_t offset; uint32_t packedSize; uint32_t rawSize; uint32_t flags; // bit0 = zlib 压缩 uint32_t crc32; uint8_t reserved[12]; }; // 96 字节 #pragma pack(pop)

#pragma pack(1)关掉对齐填充,这是二进制格式解析的第一条铁律。少了这一句,GdfEntry在 64 位编译器下会因为uint32_t的对齐要求被撑到 100 字节,count条读下去直接跑偏,症状是「前几个文件正常,越往后越乱」——很多人以为是索引坏了,其实是结构体对齐在捣鬼。

3.2 用 Python 把索引读出来

不开 IDE 也能验证你对格式的理解对不对,几十行 Python 就够。用struct模块按同样的字节布局解析,好处是改字段随时能试,不用重编译:

import struct from pathlib import Path # 小端序:< 4s 魔数 + 5 个 uint32,共 24 字节 HEADER_FMT = "<4sIIIII" # < 64s 名字 + 5 个 uint32 + 12 字节保留,共 96 字节 ENTRY_FMT = "<64sIIIII12x" HEADER_SIZE = struct.calcsize(HEADER_FMT) # 24 ENTRY_SIZE = struct.calcsize(ENTRY_FMT) # 96 FLAG_ZLIB = 0x01 def read_index(gdf_path): fp = open(gdf_path, "rb") magic, ver, count, idx_off, data_off, flags = struct.unpack( HEADER_FMT, fp.read(HEADER_SIZE)) # 魔数不对就直接退出,别继续往下读出一堆垃圾 if magic != b"GDF1": raise ValueError("not a GDF1 archive: %r" % magic) print(f"version={ver} count={count} index@{idx_off:#x} data@{data_off:#x}") fp.seek(idx_off) entries = [] for _ in range(count): name, off, psize, rsize, eflags, crc = struct.unpack( ENTRY_FMT, fp.read(ENTRY_SIZE)) entries.append({ "name": name.split(b"\0", 1)[0], # 去掉 \0 补齐 "offset": off, "packed": psize, "raw": rsize, "flags": eflags, "crc": crc, }) fp.close() return entries if __name__ == "__main__": for e in read_index("sample.gdf")[:10]: z = "zlib" if e["flags"] & FLAG_ZLIB else "raw " print(f"{e['offset']:#010x} {e['packed']:>8} -> {e['raw']:>8} {z} {e['name']}")

HEADER_FMT里的<是小端,少了它在大端机器上会读出天文数字;12x表示跳过 12 个字节的保留位不解析,保证每条正好推进 96 字节。name.split(b"\0", 1)[0]把补齐的空字节切掉,剩下的才是真名字,注意这里拿到的是 bytes,解码时机放到后面单独处理。跑通之后打印出来的偏移量应该是单调递增的,如果出现offset比文件长度还大,八成是indexOffset读错了位置。

3.3 名字编码与路径分隔符

名字这 64 字节是整个格式里最容易出问题的地方。老端游资源基本是 Windows 工具打出来的,文件名用 GBK 编码,路径分隔符是反斜杠而不是正斜杠。你在 Linux 或 macOS 上直接decode("utf-8")会炸,UnicodeDecodeError一出整个解包循环就断了。

稳妥写法是带 fallback:先按 GBK 试,失败再按 UTF-8,最后兜底用errors="replace"保证流程不断。

def decode_name(raw: bytes) -> str: for enc in ("gbk", "utf-8"): try: return raw.decode(enc) except UnicodeDecodeError: continue return raw.decode("gbk", errors="replace") # 兜底,别让一条坏名字干掉整批

for enc in (...)的顺序不能反。GBK 双字节范围覆盖很广,一段 UTF-8 字节用 GBK 解往往不报错但出来是乱码,反过来 UTF-8 遇到 GBK 字节大概率直接抛异常。所以先试 GBK 再试 UTF-8,异常驱动的回退比猜编码靠谱。落盘的时候记得把\换成/,不然在 Unix 系上你会得到一个名字里带反斜杠的文件,而不是一层目录。

4. 解包与回封:一次完整的 round-trip 怎么做

格式摸清之后,剩下的就是把「读」和「写」两条路径都走通,并且验证它俩是互逆的。这一步比想象中重要:单看解包成功没意义,很多包解出来能打开,回封之后客户端却读不到,问题往往出在偏移对齐或者索引顺序上,而这些问题只有跑一次完整 round-trip 才暴露得出来。

4.1 解包:按偏移取数据、解压、双校验

解包循环本身很短,关键在取完数据之后的两道校验:rawSize对不对,crc32对不对。这两道校验是排错的分水岭——大小对但 CRC 错,说明数据本身有问题(偏移错了、包被截断了);大小就不对,说明解压标志判断错了。

import zlib from pathlib import Path def unpack(gdf_path, out_dir): out_dir = Path(out_dir) with open(gdf_path, "rb") as fp: magic, ver, count, idx_off, data_off, hflags = struct.unpack( HEADER_FMT, fp.read(HEADER_SIZE)) if magic != b"GDF1": raise ValueError("bad magic") # 先把整张索引一次读进内存,避免在数据区来回 seek fp.seek(idx_off) index = [struct.unpack(ENTRY_FMT, fp.read(ENTRY_SIZE)) for _ in range(count)] bad = 0 for name, off, psize, rsize, eflags, crc in index: fp.seek(off) blob = fp.read(psize) if len(blob) != psize: # 包被截断 print("truncated:", name); bad += 1; continue if eflags & FLAG_ZLIB: blob = zlib.decompress(blob) if len(blob) != rsize: print("size mismatch:", name, len(blob), rsize); bad += 1 elif (zlib.crc32(blob) & 0xFFFFFFFF) != crc: print("crc mismatch:", name); bad += 1 rel = decode_name(name).replace("\\", "/") target = out_dir / rel target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(blob) print(f"done, {count} entries, {bad} bad") return bad

fp.read(psize)之后再比一次长度,是为了防截断——有些包在传输或拷贝过程中被砍掉了尾巴,seek到超出文件末尾不会报错,只会返回不足的字节数。zlib.decompress不加参数,是因为 zlib 自带长度信息,不像 raw deflate 需要手动给wbits。mkdir(parents=True)保证多级目录一次建好,exist_ok=True让你重复解同一个包不会因为目录已存在而中断。

4.2 回封:算偏移、对齐、回填头部

回封是三段式:收集阶段把文件内容、压缩结果、CRC 都算好;布局阶段算每条数据的绝对偏移;写入阶段先写数据、再写索引、最后回填头部。对齐一般按 16 字节,主要是照顾某些客户端的读取实现,它们会按块读,不给你对齐就容易读到半个块。

ALIGN = 16 def align(n): return (n + ALIGN - 1) // ALIGN * ALIGN def pack(src_dir, out_path, compress=True, name_enc="gbk"): src_dir = Path(src_dir) items = [] for p in sorted(src_dir.rglob("*")): if not p.is_file(): continue rel = p.relative_to(src_dir).as_posix().replace("/", "\\") data = p.read_bytes() crc = zlib.crc32(data) & 0xFFFFFFFF if compress: packed, eflags = zlib.compress(data, 6), FLAG_ZLIB else: packed, eflags = data, 0 items.append({"name": rel.encode(name_enc), "data": data, "packed": packed, "crc": crc, "flags": eflags}) # 布局:从头部之后开始,每条按 ALIGN 对齐 cur = align(HEADER_SIZE) for it in items: it["offset"] = cur cur = align(cur + len(it["packed"])) index_off = cur with open(out_path, "wb") as fp: fp.write(b"\0" * HEADER_SIZE) # 头部先占位,最后回填 for it in items: # ① 数据区 fp.seek(it["offset"]) fp.write(it["packed"]) fp.seek(index_off) # ② 索引表 for it in items: fp.write(struct.pack( ENTRY_FMT, it["name"].ljust(64, b"\0"), it["offset"], len(it["packed"]), len(it["data"]), it["flags"], it["crc"])) fp.seek(0) # ③ 回填头部 fp.write(struct.pack(HEADER_FMT, b"GDF1", 1, len(items), index_off, HEADER_SIZE, 0))

cur = align(cur + len(packed))这句决定了对齐粒度,改成 1 就是紧凑排布、改成 512 就是扇区对齐,具体用哪个得看目标客户端的读取实现,别凭感觉。zlib.compress(data, 6)里的 6 是压缩级别,1 最快但体积大,9 最慢,6 是通用折中;如果你只是替换几张图,级别高低对总体积影响很小,对耗时影响却很直接。name.encode(name_enc)默认 GBK,是为了跟原包保持一致——你用 UTF-8 写回去,客户端按 GBK 读,名字就是乱码,资源直接加载失败。

还有一个细节:sorted(src_dir.rglob("*"))排序之后再写,是为了让同一个目录两次打包产出的字节完全一致。不排序的话文件系统返回顺序不固定,每次打出来的包 CRC 都不一样,做增量对比的时候会疯掉。

4.3 round-trip 验证:解出来再打回去,逐字节对拍

真正可靠的验证方式不是「客户端能启动」,而是「解包 → 回封 → 再解包,两次结果一致」。写个几十行的对比脚本,把所有条目的 CRC 拉出来比一遍:

def verify(original_gdf, repacked_gdf): a = {e["name"]: e for e in read_index(original_gdf)} b = {e["name"]: e for e in read_index(repacked_gdf)} only_a = set(a) - set(b) only_b = set(b) - set(a) diff = [n for n in set(a) & set(b) if a[n]["crc"] != b[n]["crc"] or a[n]["raw"] != b[n]["raw"]] print(f"missing={len(only_a)} extra={len(only_b)} changed={len(diff)}") for n in list(only_a)[:5]: print(" lost:", n) return not (only_a or only_b or diff)

only_a是原始包里有、回封后丢了的条目,一般是文件名编码不一致导致 key 对不上;diff是 CRC 或原始大小变了的条目,说明内容确实被改了。这两类问题的排查方向完全不同:前者看名字编码和路径分隔符,后者看压缩标志和文件内容本身。把这两个集合分开打印,比笼统说一句「校验失败」有用得多。

5. 避坑与排查:GDF 工具最容易翻车的几个地方

下面这几条基本是我在类似格式上踩过的坑,症状和原因都写清楚,遇到了对着查。

5.1 解出来的文件名全是问号和方块

现象:解包目录里出现一堆名字像??????.dds的文件,内容大小正常,但没法按名字对应回去。

原因:名字字段是 GBK 编码,代码里按 UTF-8 解,解不出来就走了errors="replace",全变成替换字符。

解决:按 3.3 的 fallback 顺序解,先 GBK 后 UTF-8;同时把原始 bytes 打日志留档,别在解码阶段就把信息丢掉——一旦replace过,原始字节就找不回来了,后面想反向对应只能重新解包。

5.2 文件解出来大小对,打开却是花屏

现象:贴图文件大小和索引里的rawSize完全一致,但用看图工具打开是花屏或纯色。

原因:该条目entryFlags的 bit0 是 1,实际是 zlib 压缩过的,代码没判断标志位,直接把压缩流当原始数据写盘了。巧的是压缩流长度和rawSize偶尔能撞上,或者你只比了大小没比 CRC。

解决:判断标志位再决定要不要zlib.decompress,并且一定加 CRC 校验。CRC 对不上就是数据不对,别心存侥幸。

5.3 回封后客户端读不到资源

现象:包能打开、索引条数对、用工具再解一遍内容也正常,但客户端就是加载不出资源。

原因:偏移没做对齐,或者索引里的名字写成了正斜杠/,客户端按\匹配失败;还有一种情况是索引表放在数据区前面,而客户端固定从indexOffset读,位置对不上就整表作废。

解决:拿原始包的头部字段当基准——dataOffset、indexOffset相对文件末尾的位置关系照抄,别自作聪明「优化」布局。对齐粒度和分隔符也照原包来,先用一个只改一个字节的包做实验,能过再批量。

5.4 大于某个体积的包写出来就崩

现象:小包正常,大包写到一半进程退出或者文件只有一部分。

原因:偏移和大小字段都是 32 位无符号整数,单个包或单个文件超过 4GB 就溢出了,seek到负数位置直接异常。

解决:这类格式天生不支持超大文件,务实做法是控制单个包的体积,把资源按目录拆成多个包;真要支持大文件得动格式定义、加 64 位字段,那就不是「兼容原格式」了,客户端也得跟着改。动手之前先看清楚边界在哪,别硬撑。

5.5 同名文件互相覆盖,解完少了几百个

现象:索引里count是 5000,但落盘只有 4200 个文件。

原因:原始包里存在大小写不同或路径分隔符不同的同名条目(Textures\UI\Btn.dds和textures\ui\btn.dds),在 Windows 上不分大小写,解到同一路径互相覆盖了。

解决:解包时维护一个已写路径的集合,遇到重复就加后缀区分并打日志。真要做修改的话,先确认客户端到底按哪个 key 匹配,改错了那个文件等于白改。

6. 二次开发:把它改成你自己的资源管线

工具跑通只是起点,真正省时间的是把它接进自己的管线上。三个方向值得动手:按类型过滤只处理关心的资源、增量回封避免每次全量重打、以及一套可自动化的验证脚本。

6.1 按扩展名过滤,只拆你要改的那部分

全量解一个几百兆的包,磁盘和时间都浪费。加一个扩展名过滤,只落盘你关心的类型:

def unpack_filtered(gdf_path, out_dir, suffixes=(".dds", ".tga")): """只解指定后缀,其余条目跳过但保留索引信息""" keep = tuple(s.lower() for s in suffixes) n = 0 with open(gdf_path, "rb") as fp: magic, ver, count, idx_off, data_off, hflags = struct.unpack( HEADER_FMT, fp.read(HEADER_SIZE)) fp.seek(idx_off) for _ in range(count): name, off, psize, rsize, eflags, crc = struct.unpack( ENTRY_FMT, fp.read(ENTRY_SIZE)) rel = decode_name(name).replace("\\", "/") if not rel.lower().endswith(keep): continue # 不符合后缀直接跳过,不读数据 fp.seek(off) blob = fp.read(psize) if eflags & FLAG_ZLIB: blob = zlib.decompress(blob) target = Path(out_dir) / rel target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(blob) n += 1 print(f"extracted {n} files with {suffixes}")

suffixes传元组,大小写统一转小写后比较,避免.DDS漏掉。注意fp.seek(off)放在判断之后,不符合的条目连数据区都不用读,大包上能省掉大量 IO。这个函数不写索引、不记录偏移,纯粹是「挑着捡」,适合改贴图这种只动一类资源的场景。

6.2 增量回封:只重写变更过的条目

全量重打的痛点是慢,而且没改的条目也重新压缩一遍,压缩级别不同还可能引入差异。做法是解包时生成一份 manifest,记录每条的名字、CRC、原始偏移;下次回封前对比当前目录的 CRC,只把变了的条目重新压缩,其余从原包里直接按偏移搬过来。

场景全量回封增量回封
改 1 张图,包内 5000 条重压缩 5000 条,分钟级重压缩 1 条,其余按偏移 memcpy,秒级
字节一致性每次输出可能不同未改条目与原包逐字节一致
风险一处出错全包重来未改部分保持原样,影响面小

实现上核心就一句:fp_out.write(fp_in.read(psize)),把未变条目的原始压缩流原样复制,不做解压再压缩。这样既快又保证字节一致——做完增量回封之后跑一遍 verify,changed数量应该正好等于你改动的文件数,多一个都是错的。

6.3 改完之后怎么确认没搞坏

我给这类工具定的验证顺序是:先 verify 对比 CRC 统计,再抽查几个关键条目的十六进制,最后才拿客户端试。前两步纯脚本、秒级出结果,能挡掉九成问题;直接上客户端试是最慢的反馈回路,一旦黑屏还得回头猜是哪一个文件的问题。

ok = verify("orig.gdf", "repacked.gdf") print("round-trip ok" if ok else "round-trip FAILED")

verify返回 True 才往下走。如果changed数量比你实际改的文件多,先查压缩级别是不是变了——同一份数据用级别 6 和级别 9 压出来的字节不一样,CRC 也不同,客户端虽然能读,但你的增量对比就失效了。统一固定压缩级别是这条管线的基本纪律。

从那以后我每次动这类归档格式,都强制先跑一遍「解包 → 回封 → 再解包」的逐字节对拍,哪怕这次只改了一个字节,也走一遍。看起来多此一举,但省下来的排查时间远超过那几十秒。希望这套流程帮到你,少在黑屏上浪费一个下午。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进