ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Python解析NeoX引擎NPK包:从解包到重打包实战

Python解析NeoX引擎NPK包:从解包到重打包实战

简介:neox-tools 是一套面向游戏资源逆向与解包爱好者的 Python 工具集,专门处理网易 NeoX 引擎使用的 NPK 归档文件,当前支持 2020 年初版《Eve Echoes》所采用的格式,可完成文件列表读取与 LZ4 解压,并依据 MIME 类型猜测为每个文件补全扩展名。资源包共 15 个文件,以 7 个 py 脚本为主体,辅以 rs 源码、toml 与 lock 构建配置、sh 批处理脚本及 md 说明文档,压缩后仅约 24KB,体量轻巧却覆盖解包、pyc 反编译与解密等环节。已有 5086 人学习下载,说明其在 NeoX 资源研究圈内具备一定参考价值。读者可从中获得可直接运行的 NPK 解包脚本、基于 Rust 的构建与安装方式,以及针对 RC4 加密、简单加密与重新打包等后续功能的规划线索,适合希望理解 NeoX 归档结构、动手提取游戏资源的中高级开发者研读。

1. 拿到一个 NPK 包,先别急着解压

第一次接触 NeoX 引擎的资源包,是在一个二次元项目的外包收尾阶段。美术给过来一堆.npk文件,双击打不开,改后缀成 zip 也解不出东西,扔进常规解包工具直接报错退出。当时第一反应是文件损坏,后来才知道这是 NeoX 引擎自己的打包格式,头部有私有魔数,内部还有索引表和分块压缩,跟 zip、pak 完全不是一回事。neox-tools就是冲着这个场景来的:一套用 Python 写的 NeoX NPK 处理工具,能读包、列目录、按需导出资源,也能把改过的文件重新塞回去。适合两类人——手上有一堆 NPK 想看看里面到底是什么的开发者,以及要做资源替换、汉化、MOD 这类二次加工的人。它不依赖引擎本体,纯 Python 跑,这点对只想快速验证的人很关键。

2. 环境与目录:先把工具跑起来再谈原理

2.1 依赖与运行环境

这类工具对环境的胃口通常不大,neox-tools也是。核心依赖基本围绕标准库展开:struct负责二进制读写,zlib处理压缩块,os/pathlib管路径,命令行入口一般用argparse。如果项目里带了批量导出或进度显示,可能还会用到tqdm。Python 版本建议 3.8 以上,3.6/3.7 在老系统上能跑但pathlib的一些行为和类型注解会别扭。

我一般会先建虚拟环境再装依赖,避免污染系统 Python:

# 建独立环境,Python 3.8+ python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate # 装依赖(以 requirements.txt 为准) pip install -r requirements.txt

装完先跑--help确认入口正常,这一步能提前暴露依赖缺失或脚本路径问题:

python neox_tools.py --help # 或模块方式 python -m neox_tools --help

如果--help都报ModuleNotFoundError,别怀疑代码,先看是不是在错误的目录下执行、虚拟环境没激活,或者requirements.txt里某个包在镜像源里拉不到。这类问题占了新手翻车的一大半。

2.2 目录结构长什么样

工具类项目的目录一般不会太深,按职责拆成几块就够。下面是我按这类项目常见组织方式还原的结构,实际以你拿到的包为准:

neox-tools/ ├── neox_tools.py # 命令行入口,参数解析与子命令分发 ├── npk/ │ ├── __init__.py │ ├── reader.py # NPK 头部解析、索引表读取 │ ├── writer.py # 重新打包、索引重建 │ └── format.py # 魔数、结构体定义、常量 ├── utils/ │ ├── binary.py # struct 读写封装 │ └── path.py # 路径规范化、安全校验 ├── requirements.txt └── README.md

format.py是理解整个项目的钥匙,魔数、版本号、索引项结构体都在这;reader.py和writer.py是一对,读和写必须共用同一套结构定义,否则改完的包引擎不认。入口脚本只做参数解析和调度,真正的逻辑都在npk/下,这种分层对二次开发很友好——你要加新命令,改入口;要改格式解析,动format.py和reader.py,互不牵连。

2.3 第一次读包:从头部到索引表

跑通工具的第一步不是导出,是「看清楚」。先列目录,确认工具能正确识别包结构:

# 列出 NPK 内所有条目 python neox_tools.py list ./assets/characters.npk # 只看某个前缀下的文件 python neox_tools.py list ./assets/characters.npk --filter "model/"

list能正常输出文件名和大小,说明头部魔数校验、索引表偏移、条目数这些基础字段都读对了。如果这里就报「invalid magic」或条目数为 0,后面导出必然失败,先解决读的问题。

读包的核心逻辑大致是这样,逐段看:

import struct # NPK 头部常见布局:魔数 + 版本 + 条目数 + 索引表偏移 HEADER_FMT = "<4sIII" # 小端:4字节魔数、3个无符号整型 HEADER_SIZE = struct.calcsize(HEADER_FMT) def read_header(fp): raw = fp.read(HEADER_SIZE) if len(raw) != HEADER_SIZE: raise ValueError("文件过短,不是完整 NPK") magic, version, count, index_offset = struct.unpack(HEADER_FMT, raw) if magic != b"NPK\x00": # 魔数按实际格式替换 raise ValueError(f"魔数不匹配: {magic!r}") return version, count, index_offset

HEADER_FMT里的<表示小端,这是绝大多数引擎资源包的约定;4s是 4 字节定长字符串,用来放魔数。count是条目总数,index_offset指向索引表起始位置。校验魔数这一步不能省,很多「解包失败」其实是把别的格式当 NPK 传进来了。索引表本身通常是定长结构体数组,每项包含文件名偏移、文件偏移、压缩前后大小、压缩标志:

INDEX_FMT = "<IIIIB" # 名称偏移、数据偏移、原始大小、压缩后大小、压缩标志 INDEX_SIZE = struct.calcsize(INDEX_FMT) def read_index(fp, count, index_offset): fp.seek(index_offset) entries = [] for _ in range(count): name_off, data_off, raw_size, comp_size, flag = struct.unpack( INDEX_FMT, fp.read(INDEX_SIZE)) entries.append({ "name_off": name_off, "data_off": data_off, "raw_size": raw_size, "comp_size": comp_size, "compressed": bool(flag), }) return entries

这里raw_size和comp_size的关系决定了要不要解压:两者相等且flag为 0,说明是存储态,直接读;comp_size更小且flag为 1,说明是 zlib 压缩块,读出来还得zlib.decompress。文件名一般集中放在索引表之后的一段字符串区,按name_off去取,读到\x00截断。这套「头部 + 索引 + 字符串区 + 数据区」的布局在资源包里非常典型,理解了它,后面导出和重打包都是水到渠成。

3. 核心模块拆解:导出、解压与重打包的完整链路

3.1 按需导出:单文件与批量两条路

导出是使用频率最高的功能。单文件导出适合定位问题,批量导出适合整包迁移:

# 导出单个文件到指定目录 python neox_tools.py extract ./assets/characters.npk \ --file "model/hero_01.mesh" --out ./out # 批量导出全部,保留内部目录结构 python neox_tools.py extract ./assets/characters.npk --all --out ./out

--file走的是精确匹配,内部会先建一个name -> entry的字典,O(1) 命中;--all则遍历索引表逐项写出。导出时最容易忽略的是路径安全:包内文件名可能带../,直接拼到输出目录会写到目录外面去。稳妥做法是先规范化再校验:

import os def safe_join(base, name): # 统一分隔符并去掉开头的斜杠,防止绝对路径逃逸 name = name.replace("\\", "/").lstrip("/") target = os.path.normpath(os.path.join(base, name)) base_abs = os.path.abspath(base) if not os.path.abspath(target).startswith(base_abs + os.sep): raise ValueError(f"非法路径: {name}") return target

safe_join先做分隔符归一,再normpath消掉..,最后用绝对路径前缀比对兜底。这一步在批量导出时尤其重要,一个恶意或损坏的包就能把文件写到意想不到的位置。导出单个条目时,读取逻辑要按压缩标志分流:

import zlib def read_entry(fp, entry): fp.seek(entry["data_off"]) blob = fp.read(entry["comp_size"]) if entry["compressed"]: data = zlib.decompress(blob) if len(data) != entry["raw_size"]: raise ValueError("解压后大小与索引不符") return data return blob

comp_size是磁盘上实际占用的字节数,raw_size是解压后的期望大小。解压后做一次长度比对,能及早发现索引损坏或压缩流截断,比等到引擎加载时报错要好排查得多。

3.2 重打包:索引重建才是难点

导出只是读,重打包才是真正考验对格式理解的地方。改完资源要塞回去,不能简单覆盖数据区——文件大小变了,后面所有条目的偏移都得跟着挪,索引表也得重写。常见做法是两遍写:第一遍算出新布局,第二遍落盘。

def repack(entries, out_path): # 第一遍:计算每个条目的新偏移,数据区从头部+索引之后开始 header_size = HEADER_SIZE index_size = INDEX_SIZE * len(entries) data_start = header_size + index_size cursor = data_start for e in entries: e["new_off"] = cursor cursor += e["comp_size"] # 第二遍:写头部、索引、数据 with open(out_path, "wb") as fp: fp.write(struct.pack(HEADER_FMT, b"NPK\x00", 1, len(entries), header_size)) for e in entries: fp.write(struct.pack(INDEX_FMT, e["name_off"], e["new_off"], e["raw_size"], e["comp_size"], 1 if e["compressed"] else 0)) for e in entries: fp.write(e["blob"])

这段代码省略了字符串区的处理,实际实现里文件名区要么放在索引之后、数据之前,要么单独一段,name_off要相应重算。关键点是:所有偏移都基于新布局重新计算,不能沿用旧值。我见过有人改完文件直接原地覆盖,结果包能打开但部分资源加载失败,原因就是后面的条目偏移全错位了。重打包后一定要用list再读一遍新包,条目数、大小对得上,才算初步通过。

3.3 压缩策略:什么时候压、什么时候别压

NPK 里不是所有文件都值得压缩。文本、配置、小体积 JSON 压了收益明显;已经压缩过的图片(jpg、png)再 zlib 一遍,体积几乎不变还多耗 CPU。工具里一般会给一个阈值或按扩展名判断:

COMPRESSIBLE_EXT = {".json", ".txt", ".xml", ".lua", ".csv"} def should_compress(name, data): ext = os.path.splitext(name)[1].lower() # 小于 1KB 的不压,压缩头开销可能比省下的还多 return ext in COMPRESSIBLE_EXT and len(data) > 1024

这个策略不是死的,具体项目可以调。判断依据就一条:压缩后comp_size明显小于raw_size才值得标记为压缩态,否则按存储态写,读的时候还省一次解压。二次开发时如果发现导出再导入后包体积暴涨,先查这里——大概率是把不该压的压了,或者压缩标志写反了。

4. 避坑与排查:那些让包「看起来正常却用不了」的细节

4.1 现象:list 正常,extract 报解压错误

原因通常是索引里的comp_size和实际数据对不上,多见于包被截断或传输损坏。解决:先用list看目标条目的comp_size是否大于 0 且不超过文件总长,再单独导出该条目验证;如果只有个别条目坏,说明是局部损坏,整包重导不划算,跳过该条目继续即可。

4.2 现象:重打包后引擎加载崩溃

原因多半是偏移或字符串区没重算,或者头部版本号、条目数写错。解决:拿一个原始未修改的包做「读进来再原样写出去」的往返测试,往返后的包和原包逐字节比对(允许字符串区顺序差异),对不上就说明写入逻辑有问题,先修往返再谈改内容。

4.3 现象:导出的文件名全是乱码或空

原因是文件名编码不是 UTF-8,可能是 GBK 或引擎自定义编码。解决:读字符串区时先按字节取出,再尝试utf-8、gbk依次解码,失败则保留原始字节并记录偏移,别直接decode崩掉整个流程。

4.4 现象:批量导出到一半卡死

原因通常是某个条目raw_size异常大(损坏或恶意构造),一次性read把内存吃满。解决:读取前校验raw_size上限,超过阈值(比如 512MB)的条目单独标记并跳过,同时打印条目名和偏移,方便人工判断。

提示:任何对包的写操作之前,先备份原始 NPK。这类格式没有「后悔药」,改坏了只能从备份恢复。

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

工具跑通之后,真正有价值的是把它嵌进自己的流程。我一般不从改命令行参数入手,而是先动npk/下的读写层,因为那才是复用价值最高的部分。比如你要做资源替换,不需要每次手动 extract 再 repack,可以直接在内存里完成「读包 → 替换指定条目 → 重打包」:

from npk.reader import load_npk from npk.writer import repack def replace_entry(npk_path, inner_name, new_data, out_path): entries = load_npk(npk_path) # 读出全部条目及数据 for e in entries: if e["name"] == inner_name: e["blob"] = new_data e["raw_size"] = len(new_data) e["comp_size"] = len(new_data) e["compressed"] = False # 替换内容先按存储态写,简单可靠 break else: raise KeyError(f"包内不存在: {inner_name}") repack(entries, out_path)

这段的关键是替换后同步更新raw_size和comp_size,并把压缩标志置 0。先按存储态写、验证通过后再考虑压缩,是稳妥的迭代顺序。验证方法也简单:替换后重新list新包,确认条目还在、大小对得上,再导出该条目和new_data做字节比对。

再往上一层,可以把这套逻辑包成批处理脚本,按目录约定自动替换:

改造方向改动位置验证方式
批量替换资源新增pipeline.py调replace_entry替换后逐条导出比对
支持新压缩算法reader.py的read_entry分流用已知样本往返测试
增加格式校验命令入口加verify子命令对损坏包应报错而非崩溃
输出资源清单list增加--json输出清单条目数与索引一致

改造时有个习惯我一直保持:任何写路径的改动,都先跑一遍「原样往返」测试。读进来不改任何内容,直接写出去,再和原包比对。往返通过,才说明你的写入逻辑是自洽的,这时候再改内容才有意义。早期我不信这个邪,觉得改个偏移而已,结果一个下午都在追「为什么引擎只加载出一半资源」,最后发现是字符串区偏移少加了头部长度。从那以后我每次动写入逻辑,都强制先走一遍往返比对,再谈功能。希望这个习惯能帮你少走点弯路,工具本身不大,但把它用顺了,NPK 就不再是黑匣子。

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

RELATED READING

延伸阅读

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