NPK资源包解析与重打包:neox-tools实战避坑
简介:面向需要解析网易 NeoX 引擎资源包的游戏逆向与 Mod 爱好者,这套工具聚焦 Eve Echoes(2020 年初版本)所用 NPK 格式,可读取包内文件列表并用 LZ4 解压,为每个文件猜测 MIME 类型并补全扩展名,方便后续查看与再处理。压缩包共 15 个文件、约 24KB,以 Python 脚本为主体,承担 pyc 反编译、解密与 pymarshal 解析等辅助流程;Rust 源码与 Cargo 配置构成主程序,配合 README 说明构建方式,shell 脚本和许可证等杂项文件使工程结构完整。目前已有 5094 人学习下载。读者可直接获得一套可编译运行的 NPK 解包工具链,以及脚本重定向、资源解包与反编译的参考实现;作者还列出了 RC4 加密、简单加密与 NPK 重打包的后续计划,便于按需扩展或二次开发。
1. 从打不开的 .npk 说起:neox-tools 能替你省掉哪些手工活
neox-tools 是处理 NeoX 引擎 NPK 资源包的 Python 工具,核心就两件事:把 .npk 拆开、把改过的目录重新封回去。你拿到一个几十兆的 NPK,双击打不开,拖进常见压缩软件也只看到一坨二进制,里面其实是贴图、配置、脚本和索引表,用十六进制看像黑匣子。
它解决的是“能看见、能改、能回读”这三个动作:先列出包内文件,再按目录解包,改完资源后按原索引重打包,最后用解包结果和哈希做回读验证。适合做资源分析、Mod 验证、资源管线排错的人;新手可以按命令跑通,熟手会关注索引偏移、压缩标志和对齐填充。
如果只是想把一张图换掉,用不到完整逆向;但只要碰到“解包正常、重打包后不认”的情况,就得回到 NPK 的二进制结构里看字段。下面按“先读懂头、再跑工具、最后避坑”的顺序拆。
2. 拆开 NPK 的二进制骨架:魔数、索引表与压缩标志
NPK 不是普通压缩包,常见结构是“文件头 + 索引表 + 数据块”。文件头告诉你包内有多少文件、索引表从哪个偏移开始、索引表有多长;索引表里每一项再指向具体数据块,并记录原始大小、压缩后大小和压缩类型。不同版本、不同项目的 NPK 字段顺序可能有差异,所以第一步不是硬改,而是先做只读解析,把手上的样本包认清楚。
2.1 先看文件头:20 字节里藏着什么
很多 NPK 样本的前 4 个字节是NXPK魔数,后面跟着版本、文件数量、索引偏移和索引长度。下面这个脚本只读文件头,不碰数据区,适合在动手解包前确认样本是不是同一类结构。
import struct from pathlib import Path def read_npk_header(path: str) -> dict: p = Path(path) with p.open("rb") as f: head = f.read(20) # 先读 20 字节,常见头长度;样本不同就继续往后读 if len(head) < 20: raise ValueError("文件太小,连文件头都不完整") magic = head[0:4] version, file_count, index_offset, index_size = struct.unpack("<IIII", head[4:20]) return { "magic": magic, "version": version, "file_count": file_count, "index_offset": index_offset, "index_size": index_size, "file_size": p.stat().st_size, } if __name__ == "__main__": info = read_npk_header("./sample.npk") for k, v in info.items(): print(f"{k}: {v}")这段脚本做三件事:读前 20 字节、按小端无符号整数解出四个字段、把文件总大小一并打印。magic不是NXPK时不要急着改脚本,先把前 32 字节用十六进制打印出来,确认是不是变体头;index_offset是索引表起始位置,index_size是索引表字节数,这两个值决定了后面按偏移读索引时会不会越界。version只用来辅助判断字段布局,不要拿它当唯一依据。
常见头字段可以按下面这张表对照,偏移量以 20 字节头为例,样本不一致时以实际十六进制为准:
| 偏移 | 长度 | 字段 | 用途 |
|---|---|---|---|
| 0x00 | 4 | magic | 识别包类型,常见为NXPK |
| 0x04 | 4 | version | 辅助判断索引项布局 |
| 0x08 | 4 | file_count | 包内文件数量 |
| 0x0C | 4 | index_offset | 索引表起始偏移 |
| 0x10 | 4 | index_size | 索引表字节长度 |
注意:先复制一份样本包,所有写操作都在副本上做,原包只读。
2.2 索引项怎么读:文件名偏移、数据偏移与压缩类型
文件头只是目录封面,真正决定文件能不能提出来的是索引项。常见索引项会包含文件名偏移、文件数据偏移、原始大小、压缩后大小、压缩类型和标志位,有的版本还会多一个哈希或对齐字段。索引项长度可能是 0x18 或 0x20,不要凭感觉写死,先用工具的文件列表功能打印几条,再对照十六进制看字段跨度。
压缩标志是最容易看反的地方。0 通常表示不压缩,1 常见是 zlib,2 可能是 lz4,3 可能是 zstd,但不同版本枚举值不一定一致。工具里一般会有映射表,遇到解出来是乱码时,先怀疑压缩类型而不是文件损坏。下面这张表只作为排查顺序,不作为唯一标准:
| 压缩标志 | 常见含义 | 排查动作 |
|---|---|---|
| 0 | 原始数据 | 直接按大小读取,检查文件头是否符合格式 |
| 1 | zlib/deflate | 用 zlib 解压后看前几个字节 |
| 2 | lz4 | 确认工具是否带 lz4 依赖 |
| 3 | zstd | 确认 zstd 解压参数和窗口大小 |
读索引时不要一次性把整个索引表读进内存再切片,先按file_count算一下“索引项长度 × 文件数量”是否接近index_size。如果差得很远,说明索引项长度或字段布局判断错了,继续硬读只会得到一堆越界偏移。比较稳的做法是:先用工具的list子命令打印前 10 个文件名和偏移,再拿其中一条去十六进制里定位,确认文件名是紧跟在索引表后面,还是散落在数据区。
2.3 为什么先用只读解析:选型理由与边界
用 Python 做只读解析的理由很实际:改一个字段、打一条日志、对比两个偏移,几行代码就能跑,不需要编译。NPK 处理里最贵的不是解压算法,而是“字段布局判断错”带来的反复试错;先把头、索引、压缩标志打印出来,比一上来重打包省时间。Python 的struct、zlib、hashlib足够覆盖大部分验证场景,lz4 和 zstd 再按需装依赖。
边界也要提前说清:只读解析能确认魔数、数量、索引偏移和压缩标志,但不能替代完整格式文档。遇到加密 NPK、文件名哈希表、二次压缩或自定义混淆,工具可能只解出一部分,剩下的要靠样本对比。另一个边界是“能解包不等于能重打包”,重打包还要处理索引回填、数据对齐和哈希校验,这些放到第 4 章。只读阶段的目标只有一个:让样本包在工具里稳定列出文件,并且解出来的前几个文件能打开。
3. 把 neox-tools 跑起来:Python 环境、解包命令与输出目录
工具跑不起来,后面所有分析都是空谈。常见翻车点不是代码多难,而是 Python 版本不对、依赖没装全、命令行入口找错。neox-tools 是 Python 工具,先确认解释器版本,再进仓库目录看入口是python -m还是脚本文件;不同仓库组织方式不一样,先跑--help比猜命令可靠。
3.1 环境准备与依赖安装
建议用独立虚拟环境,避免把系统的 Python 包搅乱。下面命令在仓库根目录执行,requirements.txt不存在时再看pyproject.toml或setup.py,不要跳过依赖直接跑主程序。
python3 -m venv .venv source .venv/bin/activate python -m pip install --upgrade pip pip install -r requirements.txt python -m neox_tools --help这段命令做四件事:建虚拟环境、激活、升级 pip、安装依赖并打印帮助。python3 -m venv .venv里的.venv是目录名,可以换成你习惯的名字;source在 Windows 下换成对应激活脚本。python -m neox_tools --help如果报“No module named neox_tools”,说明当前目录不是仓库根目录,或者入口是neox_tools.py,这时改用python neox_tools.py --help。依赖里如果出现 lz4、zstd、pycryptodome,安装失败先看编译工具和 wheel 是否匹配当前平台,不要硬改代码绕过。
提示:先把
--help的输出保存到文本里,后面写批量脚本时直接对照子命令和参数名。
3.2 解包一个样本包:命令、参数与日志
确认帮助信息后,先列文件再解包。列文件是只读操作,能快速判断索引是否正常;解包时先只解前几个文件,确认输出目录结构和文件名没有异常,再全量解。
# 列出包内文件,先确认索引能读 python -m neox_tools list ./sample.npk # 只解前 20 个文件到 out_preview,避免一次写太多 python -m neox_tools unpack ./sample.npk -o ./out_preview --limit 20 # 确认预览目录正常后再全量解包 python -m neox_tools unpack ./sample.npk -o ./out_fulllist子命令通常只读索引,不写磁盘;unpack的-o指定输出目录,--limit是预览限制,具体参数名以--help为准,没有这个参数就先用list看前几条再全量。输出目录不要选在系统临时目录里,后面重打包要按原目录结构回填;解包日志里重点看“跳过文件”“解压失败”“文件名冲突”三类信息,出现跳过时不要直接全量覆盖,先看跳过原因。
如果工具没有--limit,可以用list的输出重定向到文件,再按文件名单独解。解包后的目录一般会保留包内路径,路径分隔符可能被统一成当前系统格式,重打包前要确认工具是否要求使用原始分隔符。文件名大小写也要留意,某些包内同时存在Tex/a.png和tex/A.png,在大小写不敏感的文件系统上会互相覆盖。
3.3 用脚本验证索引与文件完整性
解包完成后不要只看“命令没报错”,用脚本对输出目录做一次清单和大小核对。下面脚本统计文件数量、总大小,并抽查前 10 个文件是否非空;它不依赖工具内部结构,适合作为解包后的第一道自检。
from pathlib import Path import hashlib root = Path("./out_full") files = [p for p in root.rglob("*") if p.is_file()] total = sum(p.stat().st_size for p in files) print("files:", len(files)) print("total_bytes:", total) for p in files[:10]: data = p.read_bytes() print(p.relative_to(root), len(data), hashlib.md5(data).hexdigest()[:8])脚本先递归收集文件,再统计数量和总字节数,最后对前 10 个文件打印相对路径、大小和 MD5 前 8 位。root换成你的解包目录;files[:10]只是抽查,不要拿它当完整校验。总大小和包内索引记录的原始大小可以对一下,差得太多说明有文件没解出来或解压后大小不对。MD5 只用于快速比对,不用于安全场景;重打包前后对比同一文件的哈希,比肉眼看目录可靠。
注意:解包目录里不要混入你自己新建的说明文件,重打包时可能被一起封进去。
4. 改完资源再塞回去:重打包流程、对齐与校验
重打包比解包更容易翻车,因为解包只要读对索引,重打包还要把新数据偏移、压缩后大小、索引顺序和文件对齐全部写回去。常见做法是保持原目录结构,只替换目标文件,不改文件名和路径;工具负责重新计算索引,但压缩参数和对齐策略要自己确认。下面按“目录约定、打包命令、回读验证”三步走。
4.1 替换文件前的目录约定
先复制一份解包目录作为工作目录,保留原解包结果不动。替换文件时只动目标文件,不要顺手改目录名、不要增删空目录、不要把备份文件留在目录里。很多重打包失败不是算法问题,而是目录里多了一个.bak或编辑器临时文件,被工具当成新资源写进索引,导致包内文件数量和原包对不上。
目录约定可以按下面几条执行:包内路径保持原样;文件名大小写不变;替换文件的扩展名和原文件一致;需要新增文件时先确认工具支持新增索引,不支持就只做替换。工作目录建议命名为work_replace,和out_full分开,出问题可以直接删掉重来。
cp -r ./out_full ./work_replace # 只替换目标文件,例如把一张贴图换成修改后的版本 cp ./mods/new_icon.png ./work_replace/path/in/package/icon.png这两条命令先复制目录,再覆盖单个文件。cp -r保留目录结构;第二条命令的目标路径要按解包后的真实相对路径写,不要自己拼一个不存在的目录。替换前用file或图片查看器确认新文件能正常打开,坏文件封进去后游戏侧只会表现为资源加载失败,排查成本更高。
4.2 重打包命令与压缩参数
重打包命令通常接收目录和输出文件,压缩参数按原包样本选择。原包大量文件是 zstd 就优先用 zstd,原包是不压缩或 zlib 就不要擅自换算法;算法换了工具可能能写出来,但目标程序读不读是另一回事。
# 先打一个只包含少量文件的小包做验证,确认命令和参数可用 python -m neox_tools pack ./work_replace -o ./repacked_test.npk --compress zstd # 小包验证通过后,再全量重打包 python -m neox_tools pack ./work_replace -o ./repacked_full.npk --compress zstdpack的第一个参数是工作目录,-o是输出文件,--compress指定压缩算法。参数名以--help为准;没有--compress就不要硬加,先看工具默认策略。小包验证的意义是缩短反馈时间:如果小包都读不回来,全量包不用试。输出文件不要覆盖原包,命名带_test、_full这类后缀,方便回滚。压缩级别如果有--level,先从默认值开始,不要一上来拉满,压缩级别变化会影响数据大小和对齐。
提示:重打包日志里重点看“索引回填”“对齐填充”“压缩失败”三类行,出现跳过文件时先停下来。
4.3 回读验证:用解包结果对比哈希
重打包完成后,必须用工具再解一次新包,和work_replace做文件级对比。不要只看文件数量,数量对不代表内容对;压缩标志写错时,文件数量可能完全一致,但解出来是乱码。
from pathlib import Path import hashlib def digest(p: Path) -> str: return hashlib.sha256(p.read_bytes()).hexdigest() left = Path("./work_replace") right = Path("./out_verify") left_files = {p.relative_to(left): digest(p) for p in left.rglob("*") if p.is_file()} right_files = {p.relative_to(right): digest(p) for p in right.rglob("*") if p.is_file()} only_left = sorted(set(left_files) - set(right_files)) only_right = sorted(set(right_files) - set(left_files)) diff = sorted(k for k in set(left_files) & set(right_files) if left_files[k] != right_files[k]) print("only_left:", only_left[:10]) print("only_right:", only_right[:10]) print("diff:", diff[:10])这段脚本先对两个目录做相对路径到 SHA-256 的映射,再分别求“只在左边”“只在右边”“两边都有但哈希不同”的集合。left是重打包前的工作目录,right是新包解包后的目录;only_left不为空说明有文件没封进去,only_right不为空说明多封了文件,diff不为空说明内容或压缩回读不一致。输出只打印前 10 条,避免大包刷屏;真正排查时把完整列表写到文本里再逐条看。
5. 避坑与排查:NPK 处理里最容易翻车的五个点
NPK 的坑大多不在“代码会不会写”,而在“样本和假设对不对”。下面五条是处理 NeoX NPK 时比较常见的翻车记录,每条按现象、原因、解决写,方便直接对照。
5.1 解包报 magic 不匹配:先别改代码,先看文件头
现象是工具一打开就报魔数不对,或者list直接退出。原因通常不是工具坏了,而是样本包有变体头、外层还有一层封装,或者文件在传输时被截断。解决时先用十六进制看前 32 字节,确认前 4 字节到底是NXPK还是别的标记;再看文件大小是否和来源记录一致。如果前 4 字节是别的标记,不要强行把代码里的NXPK改掉,先把十六进制贴到样本对比里,确认是不是同一种 NPK 的变体。传输截断也会导致头能读、索引越界,重新复制一份样本再试,比改代码快。
5.2 文件列表能读但提取出来是乱码:压缩类型和加密标志看反了
现象是list能列出文件名,unpack也不报错,但解出来的图片打不开、配置全是乱码。原因多半是压缩标志映射错了,或者把加密数据当成了普通压缩数据。解决时先挑一个解出来的文件看前几个字节:PNG 应该以\x89PNG开头,文本应该是可读字符;对不上就回到索引项,确认压缩标志和压缩后大小。工具如果有--raw之类的原始提取参数,先用它把数据原样提出来,再手动试 zlib、lz4、zstd。不要一次改多个参数,每次只改压缩类型,改完只解一个文件验证。
5.3 重打包后目标程序不认:对齐和索引偏移没回填
现象是重打包命令成功,输出文件大小也正常,但目标程序加载失败,或者只加载出一部分资源。原因常见于数据块对齐、索引偏移回填、文件顺序变化。解决时先用工具解新包,确认能完整解回;如果能解回但目标程序不认,重点看对齐参数和索引顺序。把原包和新包的文件头、索引表前几项做十六进制对比,确认index_offset、index_size和每个数据块偏移是否符合原包规律。重打包时不要随意改变文件顺序,替换文件优先保持原索引位置;工具支持--align就从原包样本推断对齐值,不要默认 0。
5.4 文件名大小写或路径分隔符导致覆盖:目录约定不一致
现象是解包目录里文件数量比包内索引少,或者重打包后某些文件变成另一个文件的内容。原因是包内路径大小写不一致,落到大小写不敏感的文件系统上互相覆盖;路径分隔符被工具统一后,重打包时又按当前系统分隔符写回。解决时在解包后先统计索引里的文件名和磁盘上的文件名,发现数量不一致就开大小写敏感目录,或者先把冲突文件改名保存。重打包前确认工具是否要求使用/作为分隔符,不要手工把路径改成反斜杠。目录里不要留编辑器临时文件,临时文件被当成资源封进去也会造成“多文件”。
5.5 覆盖原包后无法回滚:没有做哈希和备份
现象是改完直接覆盖原包,目标程序不认,手里没有可回退的版本。原因是写操作前没有备份,也没有记录原包哈希。解决时在第一次解包前就把原包复制到backup/,并记录 SHA-256;每次重打包输出新文件,不覆盖原包。下面命令可以做最小备份和哈希记录:
mkdir -p backup cp ./sample.npk ./backup/sample.npk.bak sha256sum ./sample.npk > ./backup/sample.sha256backup/放在工作目录外层,避免被重打包工具扫进去;sha256sum在部分平台命令名不同,换成对应哈希工具即可。回滚时先比对哈希,再替换文件,不要凭文件名判断是不是原包。
6. 进阶用法:批量扫描、差异对比与一条自检习惯
批量处理时,单包命令不够用,得先把目录里的 NPK 扫成清单,再挑样本做差异对比。下面脚本只读文件头,把路径、魔数、文件数量、索引偏移打到 CSV,方便你在几十个包里先筛出异常样本。
import csv, struct from pathlib import Path rows = [] for p in Path("./npk_dir").rglob("*.npk"): with p.open("rb") as f: head = f.read(20) if len(head) < 20: rows.append([str(p), "too_small", "", "", ""]) continue magic = head[:4] version, count, idx_off, idx_size = struct.unpack("<IIII", head[4:20]) rows.append([str(p), magic.hex(), version, count, f"{idx_off}:{idx_size}"]) with open("./npk_scan.csv", "w", newline="", encoding="utf-8") as f: csv.writer(f).writerows(rows)npk_dir换成你的样本目录,*.npk按实际扩展名调整;输出 CSV 里魔数用十六进制,方便和十六进制编辑器对照。差异对比用cmp或哈希清单都行,重点不是工具多高级,而是每次重打包后固定跑一遍“解包—清单—哈希”三步。从那以后我每次重打包前都会先跑清单和哈希自检,宁可多花两分钟,也不吃二进制的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取