☰
GDF文件资源解包打包与版本差分实战
2026/10/29 11:43:19 网站建设 项目流程

简介:面向想深入研究《梦幻古龙》游戏数据格式与资源管理的开发者,这份源代码包聚焦 GDF 文件的解包、打包与流式读写,适合具备 C++ 与面向对象基础、希望理解游戏资源压缩存储机制的读者参考。压缩包共 8 个文件,由 5 个头文件和 3 个 cpp 源文件组成,整体约 21KB,涵盖文件对象封装、批量资源管理及数据流处理等核心模块,便于按类梳理接口与调用关系。已有 585 人学习,说明该主题在游戏资源修改与工具开发圈中具备一定关注度。通过阅读可实现单个 GDF 文件的打开、数据块读取与解压,也能借助管理类完成多文件批量处理,并从中体会压缩算法与 I/O 设计的结合方式;代码未附图形界面,更适合作为二次开发与自制解包工具的基础素材。

1. 一个几百 MB 的资源包,为什么不能直接改里面的图

拿到梦幻古龙的客户端,资源目录里躺着一堆.gdf,贴图、模型、配置表、脚本全被塞进同一个文件,文件名躲在索引里,数据区是压缩过的二进制。想换一张 UI 图或者改一条数值,在十六进制编辑器里动一个字节,后面所有偏移就全错位,客户端轻则白屏重则闪退。GDF 文件资源解包打包这套源码要解决的就是这件事:按索引把 GDF 解包成正常的目录树,改完再按原格式打包回去,客户端照样认。适合做资源替换、界面汉化、数值调试、格式研究的人,也适合想弄明白“打包文件到底怎么组织”的初学者。门槛不高,会一点 Python、能看懂struct和文件偏移就行。

2. 摸清 GDF 的三段式结构:文件头、索引表、数据区

在写循环之前,先干一件看起来很笨但省时间的事:把.gdf拖进 HxD 或者 010 Editor,看前 64 个字节。GDF 这类打包格式绝大多数是同一个套路——定长文件头、变长索引表、连续数据区,三段首尾相接。你把这三种段的边界找对了,解包脚本半小时能跑通;边界找错,后面调一天都是乱码。我一般会先看头部四个字节是不是可打印字符,再看第 5 到第 8 字节像不像一个小整数(版本号通常是个位数到几十),第 9 到第 12 字节往往是条目数量,紧跟着一个偏移值指向索引起点。这几个数能对上,格式基本就摸到了。

2.1 文件头里哪几个字段必须先确认

下面这套头部布局在同类 GDF 资源里出现频率很高,我手上这份源码也是按它实现的。字段顺序不一定完全一致,但四个核心信息跑不掉:魔数、版本、条目数、索引偏移。

偏移长度字段说明
0x004magic常见为GDF\0或GDF1,用来快速识别格式
0x044version版本号,决定索引项是否带额外字段
0x084count文件条目总数
0x0C4index_offset索引表在文件中的起始位置

先用一段最小的代码把头部读出来,确认字段是否讲得通:

import struct # 小端序:魔数4字节、版本4字节、条目数4字节、索引偏移4字节 HEADER_FMT = "<4sIII" HEADER_SIZE = struct.calcsize(HEADER_FMT) # 16 def peek_header(path): with open(path, "rb") as f: raw = f.read(HEADER_SIZE) magic, version, count, index_off = struct.unpack(HEADER_FMT, raw) print("magic =", magic) print("version =", version) print("count =", count) print("index_offset =", index_off) return magic, version, count, index_off peek_header("sample.gdf")

如果打印出来的count是几万甚至上百万,或者index_offset大于文件本身大小,说明字段顺序或者字节序不对,先把"<"换成">"再试一次。大端序在老资源里不是没有。这里的每个常量都不是拍脑袋定的,HEADER_FMT一旦确认下来,后面解包和打包必须共用同一份定义,不要在两处各写一遍,改了一处忘了另一处是最常见的事故来源。

2.2 索引项是变长的,文件名长度决定了怎么跳

索引表不是定长数组,绝大部分 GDF 的索引项是“长度 + 内容”的变长结构:先一个 2 字节的文件名长度,接着是文件名本身,然后是数据偏移、压缩后大小、原始大小、压缩标志。因为文件名长度不固定,你不能用count * 固定长度去算索引总长,只能一条一条顺序读。

# 索引项:文件名长度(H) + 数据偏移(I) + 压缩后大小(I) + 原始大小(I) + 标志位(B) ENTRY_FMT = "<HIIIB" ENTRY_SIZE = struct.calcsize(ENTRY_FMT) # 15

这里最容易翻车的是文件名编码。国产老资源十有八九是 GBK,直接decode("utf-8")会在某个带汉字的路径上抛异常,而且往往不是第一条,是跑到几千条的时候突然炸。稳妥写法是给一个编码回退链,优先 GBK,失败再试 UTF-8,最后用 latin-1 兜底保证不中断:

def decode_name(raw: bytes) -> str: for enc in ("gbk", "utf-8", "latin-1"): try: return raw.decode(enc) except UnicodeDecodeError: continue return raw.decode("latin-1", errors="replace")

还有一类 GDF 不用变长文件名,而是每条索引固定留 64 字节或 128 字节的文件名区,不足补\0。这种结构读起来更简单,但写回去时必须把整块清零再写,否则残留的旧字符会串到新文件名后面。判断方法很直接:看索引区总长度是不是count的整数倍。

2.3 数据偏移的基准点,决定了你解出来的是图还是乱码

索引里那个偏移值,可能是相对于文件开头的绝对偏移,也可能是相对于数据区起点的相对偏移。这两种情况在十六进制里长得一模一样,但解包结果天差地别。判断办法是拿第一条记录的偏移和index_offset比一下:如果偏移值明显大于索引起点,基本是绝对偏移;如果偏移值从 0 或几十开始,那就是相对数据区起点的。

def resolve_base(first_offset, index_offset, header_size): # 偏移大于索引起点,视为绝对偏移,基准为 0 if first_offset >= index_offset: return 0 # 否则视为相对数据区起点 return index_offset # 也有格式以 header_size 为基准,需实测

对齐也值得看一眼。有些包要求每个文件的数据区起点按 4 字节或 16 字节对齐,中间填\0。解包时对齐无所谓,打包时不补对齐,客户端读出来的贴图会花屏——它按对齐后的偏移去取,你少填了几个字节,后面全错位。判断有没有对齐,看相邻两条记录的offset + packed_size和下一个offset之间有没有固定的小间隙,有就是对齐填充。

3. 解包脚本落地:从索引还原出完整目录树

结构确认完,解包脚本本身不复杂,核心就三步:读头、遍历索引、按偏移取数据写盘。但要把这三步写扎实,得处理几个边界:索引读完之后文件指针在哪、压缩标志为 0 时要不要跳过解压、路径里带..或者绝对路径时怎么清洗。下面这份是能直接跑的实现,把前面确认过的常量接进去即可。

3.1 读头部并做一次基本校验

校验不是形式主义。资源包被截断、下载不完整、或者你拿错了版本,都会在这里暴露,而不是等到写了几千个文件之后才发现全是坏的。

import os import struct import zlib HEADER_FMT = "<4sIII" HEADER_SIZE = struct.calcsize(HEADER_FMT) ENTRY_FMT = "<HIIIB" ENTRY_SIZE = struct.calcsize(ENTRY_FMT) def read_header(f): raw = f.read(HEADER_SIZE) if len(raw) != HEADER_SIZE: raise ValueError("文件头不完整,可能被截断") magic, version, count, index_off = struct.unpack(HEADER_FMT, raw) if magic[:3] != b"GDF": raise ValueError(f"魔数不匹配: {magic!r}") return version, count, index_off

magic[:3] != b"GDF"这里只比前三个字节,是因为第四位在不同版本里可能是\0、1、2。如果你手上的包魔数完全不是这几个字符,那就把这行判断去掉,改成打印出来人工确认,别硬套。

3.2 遍历索引,逐条读出元信息

索引区一次性读进内存比逐条seek快得多,几万条记录的索引通常也就几 MB。读进来之后用一个游标pos往前走,每次读完固定部分再按name_len跳过文件名。

def read_index(f, count, index_off): f.seek(index_off) buf = f.read() entries = [] pos = 0 for _ in range(count): name_len, offset, packed_size, raw_size, flag = struct.unpack_from( ENTRY_FMT, buf, pos ) pos += ENTRY_SIZE name = decode_name(buf[pos:pos + name_len]) pos += name_len entries.append({ "name": name, "offset": offset, "packed_size": packed_size, "raw_size": raw_size, "flag": flag, }) return entries

packed_size和raw_size的关系能帮你判断压缩标志是否可信:两者相等时大概率没压缩,packed_size明显小于raw_size才是压缩过的。有些包的flag字段根本不准,这时候以大小关系为准更稳。

3.3 写盘:路径清洗、目录创建、重名处理

写盘这一步看着简单,坑都在路径上。资源包里可能混着\和/两种分隔符,可能有大小写只差一个字母的两个文件(在 Windows 上会互相覆盖),也可能有..这种试图跳出输出目录的路径。统一处理成/、按段拆分、过滤掉..和空段,是必须做的。

def safe_join(out_dir, name): parts = [p for p in name.replace("\\", "/").split("/") if p and p != ".."] return os.path.join(out_dir, *parts) def unpack(src_path, out_dir): with open(src_path, "rb") as f: version, count, index_off = read_header(f) entries = read_index(f, count, index_off) # 用第一条记录判断偏移基准 base = resolve_base(entries[0]["offset"], index_off, HEADER_SIZE) for e in entries: f.seek(base + e["offset"]) data = f.read(e["packed_size"]) if e["flag"] == 1 and e["packed_size"] != e["raw_size"]: try: data = zlib.decompress(data) except zlib.error: pass # 标志位不准时保留原始数据,避免整批失败 dst = safe_join(out_dir, e["name"]) os.makedirs(os.path.dirname(dst), exist_ok=True) with open(dst, "wb") as w: w.write(data) print(f"解包完成: {count} 个条目 -> {out_dir}")

resolve_base用的是 2.3 节的判断逻辑。try/except zlib.error那层兜底很关键——我遇到过整包里flag字段全是 1、但实际只有一半数据压缩过的情况,没有这层兜底,脚本跑到一半就停了。

3.4 解包完做一次完整性核对

解包脚本跑完不报错,不代表解出来的东西是对的。核对三件事:文件数量是否等于count、每个文件的实际大小是否等于raw_size、随机抽几个文件看文件头是不是正常的图片或文本格式。

def verify(out_dir, entries): bad = [] for e in entries: p = safe_join(out_dir, e["name"]) if not os.path.exists(p): bad.append((e["name"], "missing")) continue size = os.path.getsize(p) if e["raw_size"] and size != e["raw_size"]: bad.append((e["name"], f"size {size} != {e['raw_size']}")) print(f"异常条目: {len(bad)}") for name, reason in bad[:20]: print(" ", name, reason)

raw_size为 0 的条目要跳过大小比较,有些包对空文件不写这个字段。这一步跑完还是零异常,基本可以认为解包是干净的,可以进入改资源、替换、再打包的流程了。

4. 打包回写:偏移计算、压缩策略和头部回填

打包比解包难,难在顺序。文件头里要写索引偏移,索引里要写每个文件的数据偏移,而这两个值都得等数据写完才知道。常见做法是两遍写:第一遍先占位写头部、再写数据区、最后写索引;第二遍seek(0)回去把头部真实值填上。这套流程只要偏移算对,一次就能成功。

4.1 先收集再排序,保证偏移可复现

打包前先把源目录里所有文件收集成一个列表,按相对路径排序。排序不是为了好看,是为了让同样的输入每次都产出字节完全一致的包,方便做版本对比和差分。

def collect_files(src_dir): items = [] for root, _, files in os.walk(src_dir): for fn in files: full = os.path.join(root, fn) rel = os.path.relpath(full, src_dir).replace(os.sep, "/") items.append((rel, full)) items.sort(key=lambda x: x[0]) return items

os.path.relpath拿到的分隔符在 Windows 上是\,统一替换成/存进索引,跟原包保持一致。这一步不做,打出来的包在客户端里可能整个目录树都找不到。

4.2 压缩策略:什么时候压、级别定多少

不是所有文件都值得压缩。贴图、音频这类本身已经压过的格式,再用 zlib 压一遍,体积可能不降反升,还白搭 CPU。常见做法是设一个阈值,原始大小小于 1KB 的直接不压,压完比原文件还大的也回退成不压。

def maybe_compress(raw: bytes, level=6, min_size=1024): if len(raw) < min_size: return raw, 0 packed = zlib.compress(raw, level) if len(packed) >= len(raw): return raw, 0 return packed, 1

level=6是速度和压缩比的平衡点。如果你只是改了几个文件做测试,用level=1更快;如果是发布用的完整包,level=9能再省一点体积,但打包时间会翻倍。返回值第二个元素直接就是索引里的flag,压缩标志和数据必须同源产生,不能一处判断一处写死。

4.3 先写数据、再写索引、最后回填头部

这是整个打包流程的主干。注意文件指针的位置:数据区从HEADER_SIZE开始,写每个文件前用f.tell()取当前偏移,写完后这个偏移就是索引里要填的值。

def pack(src_dir, dst_path, level=6): items = collect_files(src_dir) entries = [] with open(dst_path, "wb") as f: f.write(b"\x00" * HEADER_SIZE) # 头部先占位 for rel, full in items: with open(full, "rb") as r: raw = r.read() packed, flag = maybe_compress(raw, level) offset = f.tell() f.write(packed) entries.append({ "name": rel, "offset": offset, "packed_size": len(packed), "raw_size": len(raw), "flag": flag, }) index_off = f.tell() for e in entries: name_bytes = e["name"].encode("gbk") f.write(struct.pack( ENTRY_FMT, len(name_bytes), e["offset"], e["packed_size"], e["raw_size"], e["flag"], )) f.write(name_bytes) f.seek(0) f.write(struct.pack( HEADER_FMT, b"GDF\x00", 1, len(entries), index_off )) print(f"打包完成: {len(entries)} 个条目 -> {dst_path}")

几个参数要盯住:b"GDF\x00"必须跟原包一致,客户端可能校验魔数;版本号1如果原包是别的值,照抄原包的值;文件名编码gbk要跟解包时探测到的编码一致。这三处任何一个对不上,客户端都会判定文件损坏。

4.4 拿解包脚本当反向测试用例

打包完最省事的验证方式,就是用第 3 章的unpack把刚打出来的包重新解开,再跟源目录逐文件比对哈希。这一步跑通,说明头部、索引、数据区三段自洽。

import hashlib def sha1(path): h = hashlib.sha1() with open(path, "rb") as f: for chunk in iter(lambda: f.read(65536), b""): h.update(chunk) return h.hexdigest() def diff_dirs(a, b): bad = [] for rel, full in collect_files(a): other = safe_join(b, rel) if not os.path.exists(other) or sha1(full) != sha1(other): bad.append(rel) return bad

先unpack("new.gdf", "verify_out"),再diff_dirs("src_dir", "verify_out"),返回空列表才算通过。这个测试用例的价值在于:它不依赖客户端,纯本地就能判断包结构是否正确,改代码之后的回归成本几乎为零。

5. 避坑与排查:五个让脚本当场翻车的细节

5.1 解出来全是乱码,多半是偏移基准搞反了

现象很典型:文件数量对得上,文件名也对得上,但打开每个文件都是乱码,或者图片头PNG变成了别的字符。原因基本是索引里的偏移是相对数据区起点的,脚本却按绝对偏移去seek,读到的位置整体偏了index_offset那么多字节。解决办法是把resolve_base的两个分支各跑一次,看哪一次解出来的文件头正常。判断依据很直接:正常 PNG 前四字节是\x89PNG,正常 XML 开头是<?xml,一眼就能认出来。

5.2 中文路径在几千条之后才炸

现象是脚本前面跑得好好的,突然在某一条上抛UnicodeDecodeError,而且每次炸的位置还不一样。原因是包里文件名编码混用,大部分是 GBK,个别是 UTF-8,甚至同一个包里两种都有。硬编码一个编码必然翻车。解决办法是用 2.2 节的decode_name回退链,先把所有文件解出来,再统计哪些名字命中了 UTF-8 分支,心里有个数。打包回去时如果改了文件名,编码也要跟着原包走,别解包用 GBK、打包用 UTF-8。

5.3 压缩标志位不可信,解压失败要能降级

现象是zlib.decompress抛Error -3 while decompressing data,脚本中断,后面的文件一个都没解出来。原因是索引里的压缩标志写的是 1,但数据区那一段其实没压。这类包通常是工具链不同版本混着打出来的。解决办法是解压外面包一层try/except,失败就原样输出,同时在日志里记一笔,解完之后再人工看那几个文件是不是正常。整批任务不能因为一条记录异常就全停,这是批量处理的基本原则。

5.4 包能打出来,客户端就是不认

现象是自制的包用解包脚本能完整解开,但客户端加载时直接报资源损坏或者黑屏。原因通常有三个:魔数或版本号写错、索引项没有按原包顺序排列、文件名大小写跟原包不一致。前两个查头部和索引就能发现,第三个最隐蔽——Windows 上不区分大小写,解包时文件名被规范化了,打包回去就变了,而客户端在别的平台上按精确匹配找文件。解决办法是解包时保留原始字节,别经过任何大小写转换,打包时用的就是解出来的原始名字。

5.5 覆盖原包之前没留后悔药

现象是打包脚本参数写反,输出路径指向了源包,几百 MB 的原文件瞬间被覆盖,客户端直接报废。原因不是技术问题,是流程问题,但没有这个习惯迟早出事。我的做法是打包函数入口强制检查:目标文件已存在时先重命名成.bak再写,或者干脆要求输出目录必须跟输入目录不同。多花两行代码,省一次重下客户端的麻烦。

6. 批量解包加版本差分:找出两个包里到底改了什么

单包解包跑通之后,真正省时间的用法是做版本对比。手上同时有新老两个客户端是常事,想知道某次更新动了哪些资源,靠人工翻目录不现实。把两个包分别解到old/和new/,按相对路径做并集,逐个算 SHA1,输出一张变更表就行。

状态含义后续动作
added只在 new 里存在新增资源,重点关注配置和脚本
removed只在 old 里存在被删资源,检查是否有引用残留
modified两边都有但哈希不同改动内容,优先看配置表和 UI 图
same哈希一致忽略
def diff_packs(old_dir, new_dir, out_csv="diff.csv"): old_map = {rel: full for rel, full in collect_files(old_dir)} new_map = {rel: full for rel, full in collect_files(new_dir)} names = sorted(set(old_map) | set(new_map)) rows = [] for rel in names: if rel not in old_map: rows.append((rel, "added")) elif rel not in new_map: rows.append((rel, "removed")) elif sha1(old_map[rel]) != sha1(new_map[rel]): rows.append((rel, "modified")) else: rows.append((rel, "same")) with open(out_csv, "w", encoding="utf-8-sig") as f: f.write("path,status\n") for rel, status in rows: f.write(f"{rel},{status}\n") return rows

encoding="utf-8-sig"是为了让表格软件直接打开不乱码,这点小细节在交付给别人时很加分。collect_files复用第 4 章那份,路径分隔符已经统一过,两边能直接对上。跑完之后按status过滤,modified里优先看.ini、.txt、.lua这类文本资源,改动往往就藏在那里;added里如果有新目录,基本能推断出这次更新加了什么系统。

更进一步的做法是增量打包:只把modified和added的条目重新压进去,其余条目从原包里按偏移直接拷贝字节,不做解压再压缩。这样打一个几百 MB 的包只需要几秒,而不是几分钟。实现上就是在写数据区时判断该条目是否在变更集合里,在的话走maybe_compress,不在的话seek到原包对应偏移读packed_size字节原样写出,索引里的字段也照抄原值。这套逻辑我第一次写的时候没做偏移基准的统一,结果拷过来的数据整体错位,解出来一半文件是坏的——从那以后我每次写增量打包,都强制先用差分脚本跑一遍全量对比,确认变更集合和字节偏移都对得上,再动手改包。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询