NPK资源包解析与封包:基于Python的neox-tools实践
发布时间:2026/11/2 16:45:57 锦皓数字建站

简介一套面向游戏资源解包与逆向分析爱好者的 NeoX NPK 处理工具聚焦网易 NeoX 引擎在《Eve Echoes》2020 年初版本中使用的 NPK 格式可列出压缩包内文件并用 LZ4 解压同时根据猜测的 MIME 类型为导出内容补充扩展名适合研究资源归档结构、脚本提取与格式兼容性。压缩包共 15 个文件、约 24KB以 7 个 Python 脚本和 1 个 Rust 主程序源码为核心辅以依赖配置、模块声明、Shell 脚本、说明文档与许可证兼顾命令行工具构建以及辅助解密、反编译流程的参考。已有 5094 人学习下载说明其在同类工具中具备一定关注度。读者可从中获得 NPK 文件列表与解包思路、LZ4 处理实现、MIME 映射策略以及后续 RC4 加密、简单加密、ZLib 压缩和重新打包等扩展方向的代码线索目录中另含脚本反编译与解密相关辅助代码能帮助理解工具链各环节的衔接方式便于开展二次开发、兼容性验证与问题排查。1. 一个几百兆的 NPK 包摆在面前这工具解决的是「看得见摸不着」美术同学甩过来一个压缩包解压完是十几个.npk文件双击没反应用压缩软件打开一片乱码改名成.zip也照样打不开。这类由 NeoX 系引擎产出的 NPK 资源包把模型、贴图、配置、脚本全塞进一个容器里外面再套一层索引和压缩直接拿十六进制编辑器看过去只能看到开头四个字节NXPK和后面一串偏移量剩下全是黑匣子。neox-tools 干的就是把这只黑匣子撬开按索引拆包、还原原始目录结构、改完之后再打回去顺带提供一套 Python 接口给自己写批量脚本用。它适合两类人——想快速把素材捞出来看效果的美术和策划以及要基于文件格式做二次开发的程序。纯 Python 实现不依赖引擎本体Windows 和 Linux 都能跑遇到索引布局不一样的版本改两行结构体定义就能适配。2. 工程结构拆解Python 三分层怎么切才不返工拿到一个格式解析类项目我最怕的是所有逻辑糊在一个main.py里——读头、解压、写文件、打印进度、拼路径全搅在一起等到某个版本的头部字段改了你得在一千行里翻半天。neox-tools 的组织方式值得抄它把「跟二进制打交道」和「跟用户打交道」彻底分开了。2.1 目录布局把格式相关的代码单独关进 npk 包我一般会按下面这种结构去梳理一个格式工具你可以照着对照自己手上的源码neox-tools/ ├── neox_tools/ │ ├── __init__.py │ ├── __main__.py # python -m neox_tools 的入口 │ ├── cli.py # argparse 子命令list / extract / pack / info │ ├── codec.py # 压缩类型 - 具体解压实现的分发 │ ├── util.py # 路径清洗、CRC32、日志、进度 │ └── npk/ │ ├── __init__.py │ ├── header.py # 魔数、版本、索引表定位 │ ├── entry.py # 单条索引项的字段与序列化 │ ├── reader.py # 打开、遍历、按项读取 │ └── writer.py # 重建包、原地补丁 ├── tests/ │ └── fixtures/ # 几十 KB 的小样本包用于回归 ├── pyproject.toml └── README.md这种切法的核心收益是「格式知识只有一个出处」。header.py里定义了包长什么样entry.py定义了索引项长什么样其它模块只能消费这两个对象不能自己去seekunpack。后面遇到新版本你改的是这两个文件而不是全局搜struct.unpack。cli.py里则完全不该出现struct它只负责把命令行参数翻译成对NpkReader/NpkWriter的调用。这条边界守住了加子命令、加 GUI、包一层 HTTP 服务都是顺手的事。2.2 技术选型能用标准库就不引第三方一堆人写二进制解析第一反应是pip install一堆东西其实这个场景下标准库够用得离谱。struct管定长字段、zlib管 deflate、hashlib/zlib.crc32管校验、pathlib管路径、argparse管子命令全是自带的。真正可能需要外挂的只有两样如果索引里有 LZ4 压缩块得装lz4如果要预览解出来的图片装Pillow。这两个都做成可选依赖缺了就在对应分支抛一个能读懂的异常而不是让整个工具import阶段就崩掉。运行环境我一般卡在 Python 3.9 以上原因是Path.is_relative_to()这个防路径穿越的方法 3.9 才有自己写字符串前缀比较容易在 Windows 的反斜杠上翻车。安装方式走标准的可编辑安装改完源码立刻生效不用反复卸载重装# 在项目根目录执行装成开发模式 python -m pip install -e . # 装完就有命令行入口 neox-tools --help # 不装也能跑模块方式启动 python -m neox_tools list ./res/patch.npkpyproject.toml里把入口点写成neox-tools neox_tools.cli:main这样pip install -e .之后neox-tools就是个全局命令比每次敲python xxx.py舒服得多。可选依赖用extras_require拆成[lz4]和[image]两组别人装的时候心里有数。2.3 对外接口一个 NpkReader 只暴露四个方法架构上有一件事必须提前定死外部拿到的NpkReader到底提供什么。我的习惯是只暴露open/entries/read/close四个配合上下文管理器其余的细节全部收进私有方法。成员类型作用备注NpkReader.open(path)类方法打开文件、解析头部和索引表返回实例失败抛NpkFormatErrorreader.entries属性索引项列表只读含 name / offset / size / ctypereader.read(entry)方法读取并解压单个条目返回bytesreader.close()方法释放文件句柄支持with语句自动关闭entries之所以返回列表而不是字典是被重名文件教育出来的——打包工具偶尔会在不同目录塞同名文件用文件名做 key 直接互相覆盖解出来少一堆东西还不报错。索引里天然唯一的只有offset需要按名字查的时候在调用侧自己建dict[name] - list[entry]。3. 核心解析链路从 NXPK 魔数走到能打开的贴图拆包这件事说到底是三步确认这是不是 NPK、把索引表读进内存、按索引偏移去捞数据。每一步都有一两个容易想当然的地方下面按代码走一遍。3.1 读头部先看那四个字节别急着解后面的头部解析是整个流程的第一道闸门。写错一个字节序后面全部字段都是垃圾值而垃圾值往往不会立刻报错只会让你在几万公里外的地方看到一个莫名其妙的异常。import struct from dataclasses import dataclass MAGIC bNXPK class NpkFormatError(Exception): 格式不匹配时统一抛这个方便上层决定是跳过还是终止 dataclass class NpkHeader: version: int file_count: int index_offset: int # 索引表在文件中的绝对偏移 index_size: int # 索引表总字节数 def parse_header(fp) - NpkHeader: # 文件指针此时必须停在 0 位置 magic fp.read(4) if magic ! MAGIC: # 常见情况文件整体加了壳或者根本不是这个容器 raise NpkFormatError(fmagic mismatch: {magic!r}) # 表示小端4I 表示四个无符号 32 位整数共 16 字节 version, file_count, index_offset, index_size struct.unpack(4I, fp.read(16)) return NpkHeader(version, file_count, index_offset, index_size)4I里的不能省。不写前缀时struct用的是本机字节序你在 x86 上跑得好好的代码换到某些 ARM 环境或者被交叉编译的工具链里就会读出天文数字。这个坑不常踩踩一次能查一整天。index_size这个字段别只当摆设。它给了你一个非常廉价的自检机会拿file_count乘上「单条索引的平均长度」再跟index_size比一比如果差了十倍以上大概率是头部前面还有一段你没跳过的数据比如某些发行版本会在NXPK之前加一段自定义头。这种情况下硬解下去读出来的文件名会是一堆控制字符。提示file_count不要无条件信任先跟文件实际大小做个乘法校验。有些版本这个值是占位符 0真正的条数要靠遍历索引区到末尾来数。3.2 索引项变长文件名 定长字段的经典组合索引项是整份格式里最容易出岔子的地方因为它不是定长的——文件名长度可变后面的偏移、大小、压缩类型就得跟着浮动。读的时候必须严格按「读长度 → 读名字 → 读定长区」的顺序走中间少读一个字节后面全错位。dataclass class NpkEntry: name: str offset: int # 数据区绝对偏移 size: int # 压缩后落盘大小 orig_size: int # 解压后原始大小未压缩时两者相等 crc32: int # 原始数据的 CRC32可能为 0 ctype: int # 0store, 1zlib/deflate, 2lz4 def parse_entry(fp) - NpkEntry: # 先读 2 字节文件名长度 (name_len,) struct.unpack(H, fp.read(2)) raw_name fp.read(name_len) # 有的版本用 \0 补齐到偶数长度rstrip 顺手处理掉 name raw_name.rstrip(b\x00).decode(utf-8, replace) offset, size, orig_size, crc struct.unpack(4I, fp.read(16)) (ctype,) struct.unpack(H, fp.read(2)) return NpkEntry(name, offset, size, orig_size, crc, ctype)decode(utf-8, replace)是故意的。索引里混进 GBK 编码的中文文件名非常常见用strict会让整次解包直接中断用replace至少能解出其余百分之九十九的文件坏掉的那几个你事后单独处理。ctype的枚举值不是我凭空定的也不一定跟你手上的包一致。真正稳妥的做法是先 dump 一段原始字节出来肉眼看import binascii with open(./res/patch.npk, rb) as fp: header parse_header(fp) fp.seek(header.index_offset) # 只 dump 前 64 字节对照着看名字长度和偏移像不像人话 print(binascii.hexlify(fp.read(64)).decode())如果 dump 出来在文件名字段的位置是一片可读 ASCII说明你的字段顺序基本对了如果在应该出现偏移量的地方读出了一个超过文件大小的数那就是前面多了或少了一个字段按 2 字节、4 字节为单位挪一挪重试。这种「先看十六进制再定结构」的手法比对着文档猜快得多。3.3 按索引取数据压缩类型分发要留后路拿到索引之后读单个文件就是seek过去read一段再解压。真正的坑在于压缩类型不同版本、不同资源类型用的压缩算法可能不一样硬编码一种必然会在某类资源上翻车。import zlib def read_entry_data(fp, entry: NpkEntry) - bytes: fp.seek(entry.offset) raw fp.read(entry.size) if entry.ctype 0: data raw elif entry.ctype 1: data zlib.decompress(raw) elif entry.ctype 2: # lz4 是可选依赖用到才 import避免装不上就整个工具不可用 import lz4.block data lz4.block.decompress(raw, uncompressed_sizeentry.orig_size) else: raise NpkFormatError(f未知压缩类型 {entry.ctype}: {entry.name}) # 大小自检解出来对不上八成是压缩类型判断错了 if entry.orig_size and len(data) ! entry.orig_size: raise NpkFormatError( f{entry.name} 解压后 {len(data)} 字节索引声明 {entry.orig_size} 字节 ) return data那段大小自检是整个读取流程里性价比最高的几行。它把「错误数据静默地写进磁盘、三天后美术说贴图是花的」这种事故提前到了读取阶段就炸出来。crc32字段同理可以做成--verify开关大批量导出时默认关闭省时间排查问题时打开。3.4 批量导出路径处理不当会写到你项目目录外面导出逻辑本身很短真正要写仔细的是路径拼装。索引里的name是打包工具写进去的你没法保证它不带../也没法保证它在 Windows 上不是反斜杠。from pathlib import Path def safe_target(root: Path, name: str) - Path: # 统一分隔符再把 .. 之类的东西规整掉 rel name.replace(\\, /).lstrip(/) target (root / rel).resolve() root root.resolve() if not target.is_relative_to(root): raise ValueError(f非法路径拒绝写出: {name}) target.parent.mkdir(parentsTrue, exist_okTrue) return targetis_relative_to比字符串startswith靠谱因为后者在root/data/a、target/data/ab/x这种前缀巧合上会误判成合法。导出主循环则可以加上进度和错误容忍def extract_all(reader, out_dir: Path, skip_brokenTrue): ok fail 0 for entry in reader.entries: try: data reader.read(entry) safe_target(out_dir, entry.name).write_bytes(data) ok 1 except Exception as exc: fail 1 if not skip_broken: raise print(f[skip] {entry.name}: {exc}) print(f完成 {ok} 个跳过 {fail} 个)大包解到一半因为一个坏条目全盘重来是很消耗耐心的事skip_broken默认开着把失败清单打出来事后针对性处理。至于性能纯 Python 读几百兆的包瓶颈几乎总在zlib.decompress上而不是 IO 上想再快就走多进程把条目分片seek是各自持有文件句柄的天然无锁。4. 反向封包改完的资源怎么塞回去才不把游戏搞崩解包只是半程真正让人头皮发麻的是「改完打回去」。游戏对资源包的容忍度很低偏移错一位、长度差一字节、对齐没做表现出来往往不是报错而是黑屏、模型消失、加载卡住日志里什么都没有。4.1 两种策略原地补丁和全量重建这两种做法没有绝对优劣取决于你改了多少。策略做法优点代价原地补丁只覆写数据区索引里的 offset 不动只改 size / crc快几十兆的包秒改新数据不能超过原长度超了会覆盖到下一个条目全量重建重排所有数据重写索引表任意大小都能塞慢且有对齐要求我的经验是微调数值、换一张尺寸相近的贴图走原地补丁换模型、加新资源老老实实全量重建。很多人在小改动上也用全量重建然后因为某个对齐细节没处理好反而不如原地补丁稳。原地补丁的核心就几行但要处理好「新数据比旧数据短」的情况def patch_in_place(fp, entry: NpkEntry, new_data: bytes, compressTrue): import zlib payload zlib.compress(new_data, 6) if compress else new_data if len(payload) entry.size: raise ValueError( f{entry.name} 新数据 {len(payload)} 字节超过原槽位 {entry.size} 字节改用全量重建 ) fp.seek(entry.offset) fp.write(payload) if len(payload) entry.size: # 剩余空间填 0避免留下上一版的残渣被误读 fp.write(b\x00 * (entry.size - len(payload))) # 只更新长度和校验offset 保持不动 entry.orig_size len(new_data) entry.crc32 zlib.crc32(new_data) 0xFFFFFFFF注意最后两行orig_size和crc32必须同步更新索引表还得在收尾时整体回写一遍。只改数据不改索引是「改完没效果」的头号原因——引擎读的是索引里的大小它压根不看你数据区写了多少。4.2 全量重建对齐和头部回填是两个必查点全量重建的流程是先算好每条数据落盘后的位置再依次写数据最后写索引表最后回头把头部的index_offset/index_size/file_count填回去。import struct, zlib from pathlib import Path def rebuild(entries, out_path: Path, align16): entries: [(name, data_bytes, compress_bool), ...] data_blob bytearray() index_blob bytearray() # 数据区从头部之后开始4 字节魔数 16 字节定长字段 20 cursor 20 for name, data, do_compress in entries: payload zlib.compress(data, 6) if do_compress else data offset cursor len(data_blob) data_blob payload # 每条数据尾部补零对齐很多引擎按 16 字节读取 pad (-len(data_blob)) % align data_blob b\x00 * pad raw_name name.encode(utf-8) index_blob struct.pack(H, len(raw_name)) raw_name index_blob struct.pack( 4I, offset, len(payload), len(data), zlib.crc32(data) 0xFFFFFFFF ) index_blob struct.pack(H, 1 if do_compress else 0) index_offset 20 len(data_blob) with open(out_path, wb) as fp: fp.write(MAGIC) fp.write(struct.pack(4I, 1, len(entries), index_offset, len(index_blob))) fp.write(data_blob) fp.write(index_blob)cursor 20这个常量是硬编码的头部长度看着别扭但没办法——头部本身是定长的把这 20 拆成4 struct.calcsize(4I)更好读。pad (-len(data_blob)) % align是 Python 里算「补到下一个对齐边界需要多少字节」的常用写法比(align - n % align) % align简洁。这里有个容易忽略的点对齐是在数据区内部做的offset必须基于「已经加了填充之后」的长度来算。如果先算所有 offset 再统一补零每条数据的偏移都会偏游戏读到的就是别的资源的前半截。4.3 回读验证写完必须自己再拆一遍重建完直接丢进游戏里测是最贵的验证方式。我一般会强制走一遍「写完立刻用 Reader 重新打开」的流程def verify(npk_path: Path, expect: dict[str, bytes]): from neox_tools.npk.reader import NpkReader with NpkReader.open(npk_path) as reader: names {e.name for e in reader.entries} missing set(expect) - names if missing: raise AssertionError(f回读缺文件: {missing}) for entry in reader.entries: # 逐条比对原始内容能把对齐、压缩、CRC 的问题一网打尽 if entry.name in expect and reader.read(entry) ! expect[entry.name]: raise AssertionError(f内容不一致: {entry.name})这一步能拦掉九成以上的「打包成功但游戏不认」。剩下的那一成通常是引擎特有的额外约束——比如某些资源要求 4 字节对齐、某些配置文件尾部必须留一个\0这类只能靠对照原始包里的条目逐个比对发现。5. 避坑与排查五个真实翻车现场5.1 解出来的文件名全是乱码现象目录树能建出来但文件名是一堆\ufffd和不可打印字符文件数量还是对的。原因索引里的文件名不是 UTF-8。这类包里混用 GBK 甚至 Latin-1 编码的名字很普遍尤其早期版本的打包工具直接按系统本地编码写进去。解决把解码逻辑抽成一个可配置参数--encoding gbk试一遍。判断依据很简单——用replace解出来之后如果替换字符占比超过某个阈值就换编码重来。另外rstrip(b\x00)别忘有的包把名字补零到固定长度不去掉的话文件名后面会拖一串看不见的空字符Windows 上创建文件直接报错。5.2 文件大小对了但图片打不开、模型加载不出来现象导出的.png大小跟索引里的orig_size完全一致但双击打不开十六进制看头部也不是\x89PNG。原因压缩类型判断错了或者这类资源外套了一层自定义头。有些引擎会把图片再包一层几字节的元信息宽高、格式标记解包工具如果不剥掉这一层导出的就是「假图片」。解决先看解出来数据的前 16 字节。是\x89PNG/DDS/PK\x03\x04就说明数据本身没问题是后续处理没做如果开头是几个看着像整数的小端值那多半是自定义头长度一般在头部字段里写着往后跳掉再存。这类「套娃头」没有通用解法只能对着同类资源做对比。5.3 重打包后游戏黑屏日志里什么都没有现象包大小跟原来接近索引也能正常读出来进游戏直接黑屏或者卡在加载条。原因八成是对齐问题或头部回填没做全。头部里的index_offset算错、file_count用了旧值、数据区没按引擎要求的边界对齐这三种都会导致引擎读到半截数据然后静默放弃。解决拿原始包当对照组。用工具分别打出原始包和新包的头部十六进制逐字段比对再随机抽三条索引项把offset和size落到文件上看这段区间是不是恰好覆盖了某条完整数据。这一步做下来问题基本就定位了。另外别忘了某些引擎会把整个包的 CRC 记在别的地方改完包之后那个值也得同步。5.4 索引里有重名文件导出来少了一大堆现象file_count说有 3000 条导出目录里只数出 2800 个文件但工具显示成功 3000 个。原因用文件名当字典的 key 遍历重名条目互相覆盖写的时候后一个直接把前一个盖掉了。解决所有内部遍历一律以offset作为唯一标识文件名只用来决定落盘路径。.name列表里重名条目可以加个后缀比如xxx.png、xxx.1.png区分别图省事直接丢。5.5 解包速度慢到怀疑人生现象几百兆的包解了十几分钟CPU 只占一个核。原因串行读取 每条都重新open文件或者每条数据都做一次完整的 CRC 校验。解决整个解包过程只开一次文件句柄seek复用CRC 校验做成--verify开关默认关闭条目数量上千时上进程池按索引区间切片分给多个进程每个进程独立打开文件读自己那一段。IO 层面其实不是瓶颈卡在zlib.decompress上多进程基本能线性加速。6. 二次开发加一个 diff 子命令把「版本对比」变成日常工具用顺了之后最常见的需求不是解包本身而是「这两个版本的包里到底改了什么」。做补丁分析、查资源被谁覆盖的时候一个个解出来再diff -r太慢。给 neox-tools 加一个diff子命令是投入产出比最高的一次改造。思路很直接两个包各自读出索引按offset建立name - entry的映射然后对同名条目比内容哈希。这里不要图省事比size压缩率一变大小就可能相同但内容不同。核心逻辑不到三十行import hashlib from neox_tools.npk.reader import NpkReader def diff_pack(old_path, new_path, outNone): with NpkReader.open(old_path) as a, NpkReader.open(new_path) as b: old_map {e.name: e for e in a.entries} new_map {e.name: e for e in b.entries} added new_map.keys() - old_map.keys() removed old_map.keys() - new_map.keys() changed [] for name in new_map.keys() old_map.keys(): # 比哈希而不是比大小压缩率变化会骗过 size 比较 ha hashlib.sha1(a.read(old_map[name])).hexdigest() hb hashlib.sha1(b.read(new_map[name])).hexdigest() if ha ! hb: changed.append((name, old_map[name].orig_size, new_map[name].orig_size)) print(f新增 {len(added)} / 删除 {len(removed)} / 变更 {len(changed)}) for name, s1, s2 in sorted(changed, keylambda x: abs(x[2] - x[1]), reverseTrue)[:20]: print(f {name}: {s1} - {s2})几处值得说的地方。用keys() keys()求交集、keys() - keys()求差集比双重循环快而且读起来清楚。哈希用 sha1 不用 md5不是为了安全纯粹是因为hashlib.sha1在几百兆数据上速度足够且不触发某些静态扫描告警。按大小差异倒序排前 20 条是因为实际排查里「变化最大的」通常就是你想找的那个资源全量打出来反而看不过来。要把它挂到命令行上在cli.py里加一个子解析器即可parser_diff sub.add_parser(diff, help比较两个 NPK 包的差异) parser_diff.add_argument(old, typePath) parser_diff.add_argument(new, typePath) parser_diff.add_argument(--json, actionstore_true, help输出机器可读结果) parser_diff.set_defaults(funclambda args: diff_pack(args.old, args.new))顺手再加个--json输出结果就能直接喂给 CI 或者别的脚本做变更告警比人眼看输出靠谱得多。改造完之后我一般的验证套路是拿原始包自己跟自己 diff应该输出「新增 0 / 删除 0 / 变更 0」然后手工改一个已知文件的字节再 diff 一次应该精确报出那一条。这两个用例都过了这个子命令才算能用。这也是血泪教训——早年间我加子命令从不写自测某次差集用反了added和removed对调排查了半天才反应过来是代码问题不是数据问题。新增功能最忌讳的是直接改reader.py已有的方法签名。read(entry)这种被全项目调用的接口你为了拿个哈希顺手加个参数回头所有调用点都得跟着改而其中一处忘了改就是线上事故。正确姿势是新功能全部在cli.py或独立的diff.py里实现底层模块只加不改。从那以后我每次给解析类项目加子命令都强制先跑一遍「自比应全 0、改一字节应命中一条」这两个用例再谈功能。仓库我整理成了一份可直接跑的结构源码、样本包和命令示例都在里面拿去对着改两行就能适配你自己手上的包。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。