ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Python解析NPK游戏资源包:从索引解析到重新打包的完整指南

Python解析NPK游戏资源包:从索引解析到重新打包的完整指南

简介:neox-tools 是一套面向游戏资源逆向与解包爱好者的 Python 工具集,专门用于处理网易 NeoX 引擎中使用的 NPK 文件,当前支持 2020 年初版本《Eve Echoes》所采用的格式,可完成文件列表读取与 LZ4 解压,并依据 MIME 类型为每个文件自动补全扩展名。资源包共 15 个文件,以 7 个 py 脚本为主体,辅以 rs 源码、toml 与 lock 构建配置、sh 脚本、md 说明文档及 license 等,压缩后仅 24KB,体量轻巧、结构清晰。已有 5086 人学习下载,说明其在 NPK 解包场景中具备一定参考价值。读者可从中获得可直接运行的解包脚本、基于 Rust 的构建方式,以及脚本反编译与 pyc 解密相关的辅助模块,便于理解 NPK 内部组织方式并据此扩展加密与重打包能力,适合具备一定 Python 与逆向基础、希望深入 NeoX 资源格式的开发者研读。

1. 拆开一个 NPK 之前,先想清楚它到底是什么

第一次拿到一个几十 GB 的游戏客户端目录,看到里面一堆.npk文件,我的第一反应是「这不就是个压缩包吗,解压就完事了」。真动手才发现不是——NPK 是某款自研引擎使用的资源打包格式,里面塞着模型、贴图、音频、配置表,索引结构和普通 zip 完全不是一回事,直接改后缀解压只会得到一堆乱码。neox-tools 就是冲着这个痛点来的:一套用 Python 写的 NPK 读写工具,能把包拆开、把里面的资源导出来、改完再打回去。它适合两类人:想扒素材做二次创作的美术/策划,以及想研究资源加载链路的客户端开发。下面我按「它怎么组织 → 怎么跑起来 → 怎么改」的顺序,把这份源码拆给你看。

2. 工程结构与技术选型:为什么是 Python 而不是 C++

2.1 目录骨架与模块职责

拿到源码包,先别急着pip install,花五分钟把目录扫一遍,后面改代码时能少走很多弯路。这类工具型项目的目录通常不会太深,核心逻辑集中在一两个包里,外围是脚本和配置。按常见组织方式,结构大致是这样:

neox-tools/ ├── neox_tools/ # 核心包 │ ├── npk/ # NPK 格式读写 │ │ ├── reader.py # 索引解析、条目读取 │ │ ├── writer.py # 重新打包、索引重建 │ │ └── structs.py # 头部/索引项的二进制结构定义 │ ├── codec/ # 压缩与加密处理 │ │ ├── zlib_codec.py │ │ └── xor_codec.py │ ├── resource/ # 资源类型识别与导出 │ │ ├── detect.py # 魔数嗅探 │ │ └── exporters.py # 按类型落盘 │ └── cli.py # 命令行入口 ├── scripts/ # 批量处理脚本 ├── tests/ # 样本与单元测试 ├── requirements.txt └── README.md

这个划分的关键在于把「格式解析」和「资源处理」解耦。npk/只关心二进制怎么读怎么写,不关心读出来的是贴图还是音频;resource/只关心拿到一段字节后怎么判断类型、怎么存盘。这样你以后遇到新的资源类型,只需要在detect.py里加一条魔数规则,不用碰打包逻辑。我见过不少同类工具把这两层揉在一起,结果加个新格式就得改半个仓库,血泪经验。

2.2 为什么选 Python:开发效率压过运行速度

NPK 解析本质是「按偏移量读结构体 + 解压 + 写文件」,是典型的 IO 密集型而不是计算密集型任务。用 C++ 写当然快,但每次调格式都要重新编译,调试成本高;Python 的struct模块处理二进制结构足够用,改一行跑一行,验证格式假设的效率高一个数量级。代价是处理超大包时慢一些,常见做法是用mmap把文件映射进内存,避免一次性read()把几十 GB 读进来。

import mmap from pathlib import Path def open_npk(path: Path): # 用只读 mmap 映射,避免大文件全量读入内存 f = open(path, "rb") mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) return f, mm

access=mmap.ACCESS_READ表示只读映射,操作系统按页加载,只有真正访问到的部分才会进内存。length=0表示映射整个文件。用完记得mm.close()和f.close(),否则在 Windows 上文件句柄不释放,删都删不掉——这个坑我踩过不止一次。

2.3 依赖与运行环境

requirements.txt里通常只有寥寥几项:struct是标准库不用装,真正的外部依赖一般是命令行解析(click或argparse就够)、可选的图像处理库(导出贴图时用)、以及测试用的pytest。Python 版本建议 3.8 以上,因为用到了pathlib的一些新方法和f-string的调试语法。

python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -r requirements.txt python -m neox_tools.cli --help

虚拟环境这一步别省。这类工具经常要装图像库,版本冲突起来很麻烦,隔离环境是最省心的做法。跑通--help能看到子命令列表,说明包结构和入口没问题,可以进入下一步了。

3. 核心模块拆解:一个 NPK 条目从索引到落盘的完整链路

3.1 头部与索引解析:先搞清楚文件长什么样

NPK 的二进制布局,抽象出来无非三段:文件头(魔数、版本、条目数、索引偏移)、索引区(每个条目的名字哈希、偏移、压缩后大小、原始大小、压缩标志)、数据区(真正的资源字节)。解析的第一步是把头部读出来,验证魔数,拿到索引区的位置和条目数量。

import struct # 假设头部结构:魔数(4s) + 版本(I) + 条目数(I) + 索引偏移(Q) HEADER_FMT = "<4sIIQ" HEADER_SIZE = struct.calcsize(HEADER_FMT) def parse_header(mm): magic, version, count, index_offset = struct.unpack_from(HEADER_FMT, mm, 0) if magic != b"NPK\x00": raise ValueError(f"不是有效的 NPK 文件,魔数={magic!r}") return { "version": version, "count": count, "index_offset": index_offset, }

"<4sIIQ"里的<表示小端序,4s是 4 字节定长字符串,I是无符号 32 位整数,Q是无符号 64 位整数。struct.calcsize算出头部固定长度,unpack_from从 mmap 的指定偏移读取,不复制数据。魔数校验是必须的——用户很可能把别的格式文件拖进来,早点报错比后面读到一半崩掉强。版本号要留着,不同版本的索引项长度可能不同,后面解析索引时得按版本分支。

3.2 索引项遍历:把条目表读成 Python 对象

拿到index_offset和count之后,从索引区连续读count个索引项。每个索引项记录了一个资源的定位信息。常见字段包括名字哈希、数据偏移、压缩大小、原始大小、压缩算法标志。

# 索引项:名字哈希(Q) + 偏移(Q) + 压缩大小(I) + 原始大小(I) + 标志(I) ENTRY_FMT = "<QQIII" ENTRY_SIZE = struct.calcsize(ENTRY_FMT) def iter_entries(mm, header): base = header["index_offset"] for i in range(header["count"]): off = base + i * ENTRY_SIZE name_hash, data_off, comp_size, raw_size, flags = struct.unpack_from( ENTRY_FMT, mm, off ) yield { "index": i, "name_hash": name_hash, "offset": data_off, "compressed_size": comp_size, "raw_size": raw_size, "flags": flags, }

这里用生成器yield而不是一次性构建列表,是为了处理条目数上万的包时省内存。flags是个位标志,常见约定是第 0 位表示是否压缩、第 1 位表示是否做了简单异或处理。注意offset是相对文件开头的绝对偏移,不是相对数据区,读的时候直接用。名字哈希意味着原始文件名可能不在包里,导出时只能按哈希或序号命名,这是很多人第一次导出后看到一堆数字文件名的原因。

3.3 解压与解码:zlib 与异或的组合拳

读到条目后,按flags决定处理链路。压缩的走 zlib 解压,异或的走字节还原,两者可能叠加。顺序不能错——通常是先解异或再解压,因为压缩是在异或之后做的。

import zlib def decode_entry(mm, entry, xor_key=0x5A): raw = mm[entry["offset"]: entry["offset"] + entry["compressed_size"]] data = bytes(raw) if entry["flags"] & 0x2: # 第 1 位:异或处理 data = bytes(b ^ xor_key for b in data) if entry["flags"] & 0x1: # 第 0 位:zlib 压缩 data = zlib.decompress(data) if len(data) != entry["raw_size"]: raise ValueError( f"条目 {entry['index']} 大小不符:期望 {entry['raw_size']},实际 {len(data)}" ) return data

xor_key这种固定密钥在真实格式里可能是从头部某个字段推导出来的,这里用常量占位,改的时候去structs.py里找实际来源。大小校验是最后一道保险:解压出来的长度和索引里记录的raw_size对不上,说明密钥错了、标志位理解错了、或者索引本身损坏。别跳过这个校验,否则导出的文件看着有内容,打开全是坏的,排查起来更费劲。

3.4 资源类型嗅探与落盘

拿到原始字节后,靠魔数判断类型:PNG 是\x89PNG,JPEG 是\xFF\xD8\xFF,OGG 是OggS,其余按未知类型存.bin。这一步决定了导出目录的组织方式。

MAGIC_TABLE = [ (b"\x89PNG\r\n\x1a\n", ".png"), (b"\xff\xd8\xff", ".jpg"), (b"OggS", ".ogg"), (b"DDS ", ".dds"), ] def detect_ext(data: bytes) -> str: for magic, ext in MAGIC_TABLE: if data.startswith(magic): return ext return ".bin" def export_entry(data: bytes, out_dir, name: str): ext = detect_ext(data) target = out_dir / f"{name}{ext}" target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(data)

MAGIC_TABLE按「越具体越靠前」排列,避免短魔数误匹配。mkdir(parents=True, exist_ok=True)保证多级目录自动创建且重复运行不报错。导出文件名用哈希或序号,是因为包里通常不存原始路径——想要还原目录结构,得靠额外的映射表,这属于二次开发范畴,后面会讲。

4. 跑起来:从命令行到批量导出

4.1 单包导出:先拿一个小包试水

别一上来就对着几十 GB 的主包开刀。先找一个体积小的 NPK(比如只含配置表或音效的),验证整条链路通不通。

# 列出包内条目,不落盘,先看结构对不对 python -m neox_tools.cli list ./sample.npk # 导出全部条目到指定目录 python -m neox_tools.cli extract ./sample.npk -o ./out # 只导出指定序号的条目,调试时很有用 python -m neox_tools.cli extract ./sample.npk -o ./out --index 0,3,7

list子命令只解析头部和索引,不碰数据区,速度极快,用来确认魔数、版本、条目数是否符合预期。extract的-o指定输出目录,--index支持按序号筛选,调试单个条目时不用等全量导出。如果list就报魔数错误,说明文件根本不是 NPK,或者头部结构假设错了,先回去核对structs.py。

4.2 批量处理:脚本层做并发

一个客户端目录下往往有几十上百个 NPK,逐个手敲命令不现实。scripts/下一般会有批量脚本,核心是用进程池把多个包分给多核。

from concurrent.futures import ProcessPoolExecutor from pathlib import Path def batch_extract(npk_dir: Path, out_root: Path, workers: int = 4): tasks = list(npk_dir.glob("*.npk")) with ProcessPoolExecutor(max_workers=workers) as pool: futures = { pool.submit(extract_one, p, out_root / p.stem): p for p in tasks } for fut in futures: try: fut.result() except Exception as e: print(f"[失败] {futures[fut].name}: {e}")

用ProcessPoolExecutor而不是线程池,是因为解压是 CPU 密集操作,Python 的 GIL 会让多线程几乎没收益。workers别开太大,一般设成 CPU 核数的一半到全部,开太多反而因为磁盘 IO 争抢变慢。每个包输出到以包名命名的子目录,避免不同包里的同名哈希文件互相覆盖——这个细节不注意,导完发现文件少了一半,还以为工具坏了。

4.3 重新打包:改完资源怎么塞回去

导出只是前半程,改完贴图要打回去才闭环。打包的核心是重建索引:先写占位的头部,逐个写入数据区并记录偏移和大小,最后回填头部和索引区。

def repack(entries, out_path): with open(out_path, "wb") as f: f.write(b"\x00" * HEADER_SIZE) # 头部占位 index_records = [] for name_hash, data, flags in entries: offset = f.tell() payload = zlib.compress(data) if flags & 0x1 else data f.write(payload) index_records.append((name_hash, offset, len(payload), len(data), flags)) index_offset = f.tell() for rec in index_records: f.write(struct.pack(ENTRY_FMT, *rec)) f.seek(0) f.write(struct.pack(HEADER_FMT, b"NPK\x00", 1, len(index_records), index_offset))

先占位再回填,是因为头部里的index_offset和条目数要等数据区写完才知道。f.tell()在写之前取偏移,保证记录的是数据起始位置。回填时seek(0)回到文件头覆写。注意flags要和原始条目保持一致,否则游戏加载时按错误的标志位解码,轻则资源显示异常,重则客户端直接崩——改包有风险,操作前务必备份原文件。

5. 避坑与排查:那些让我熬夜的坑

5.1 导出全是 .bin:魔数表不全

现象:导出目录里清一色.bin,用十六进制编辑器打开能看到像图片的内容。原因:MAGIC_TABLE没覆盖该引擎的私有格式,比如自定义的纹理容器或加密过的配置表。解决:挑几个.bin看头部字节,把稳定的前 4~8 字节加进MAGIC_TABLE。私有格式没法自动识别扩展名,就按内容特征归类,比如统一叫.tex,后续再单独处理。

5.2 解压报 zlib.error:标志位或密钥错了

现象:zlib.decompress抛Error -3 while decompressing data: incorrect header check。原因:要么flags判断反了(把未压缩的当压缩的解),要么异或密钥不对导致解压前数据就是错的。解决:先打印条目的flags和compressed_size/raw_size比值。压缩过的条目compressed_size通常明显小于raw_size;如果两者相等却标了压缩位,说明标志位含义理解错了。再单独验证异或密钥:拿一个已知是纯文本的配置条目,试不同密钥看解出来是不是可读字符。

5.3 Windows 上文件删不掉:mmap 没关

现象:脚本跑完想删临时文件,提示「文件正被另一进程使用」。原因:mmap对象和文件句柄没显式关闭,引用计数没归零。解决:用with上下文或try/finally保证mm.close()、f.close()一定执行。批量脚本里尤其要注意,异常路径漏关一个句柄,后面整个目录都动不了。

5.4 打包后客户端崩溃:索引偏移算错

现象:重新打包的文件能被工具自己读出来,但游戏加载时闪退。原因:offset记录的是相对数据区还是相对文件头的偏移,两种约定混用了;或者索引区对齐要求没满足。解决:拿原始包做对照,解析出前几个条目的offset,和实际数据位置比对,确认基准点。对齐问题看索引区起始偏移是否是某个值的整数倍(常见 16 或 512),打包时在数据区和索引区之间补零对齐。

5.5 批量导出文件名冲突:哈希碰撞或跨包重名

现象:导出文件数量比条目数少。原因:不同包里的条目哈希相同,或者同一包内哈希本身就重复(引擎允许)。解决:输出路径带上包名和条目序号,比如out/<包名>/<序号>_<哈希>.<ext>,彻底避免覆盖。别图省事只用哈希命名。

6. 二次开发:把工具改成你自己的流水线

工具跑通只是起点,真正省时间的是把它嵌进自己的流程。我一般会做三件事。

第一件是补全文件名映射。包里只有哈希,但引擎运行时总得知道资源路径,映射关系通常藏在某个配置表或单独的索引文件里。找到它之后,在export_entry里把哈希换成真实路径,导出目录就和引擎的资源树一一对应了,改起来直观得多。映射表可能是加密的,那就先解映射表再解资源,顺序别搞反。

第二件是加一个 diff 模式。二次开发经常要对比两个版本的包,看哪些资源变了。思路很简单:分别解析两个包的索引,按name_hash求差集,再对交集里raw_size或内容哈希不同的条目做标记。核心逻辑不到三十行:

def diff_npk(old_entries, new_entries): old_map = {e["name_hash"]: e for e in old_entries} new_map = {e["name_hash"]: e for e in new_entries} added = new_map.keys() - old_map.keys() removed = old_map.keys() - new_map.keys() changed = [ h for h in old_map.keys() & new_map.keys() if old_map[h]["raw_size"] != new_map[h]["raw_size"] ] return {"added": added, "removed": removed, "changed": changed}

只比大小是粗筛,要精确就再比内容哈希,代价是要把两边数据都解出来,大包上会慢。日常用大小筛足够定位大部分改动。

第三件是把导出和打包串成一条命令。改一张贴图的完整流程是「导出 → 编辑 → 打包 → 验证」,中间任何一步手动敲都容易漏。写个make目标或 shell 脚本,把四步串起来,验证那步至少做到「重新解析打出来的包,条目数和大小全部对得上」再算通过。

改造点改动位置收益
文件名映射resource/exporters.py导出目录可读,便于定位
diff 模式新增npk/diff.py版本对比从小时级降到分钟级
流水线脚本scripts/减少手动步骤遗漏

有个教训值得说:我早期改包时图快,直接覆盖原文件,结果一次标志位写错,整个包废了,只能重新下载几十 GB。从那以后我每次打包都强制走一遍「输出到新文件 → 用工具重新解析校验 → 通过才替换」,宁可多花几十秒,也不给自己留后悔药。希望这些能帮到你,少走点我走过的弯路。

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

RELATED READING

延伸阅读

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