简介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.mdformat.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 ! bNPK\x00: # 魔数按实际格式替换 raise ValueError(f魔数不匹配: {magic!r}) return version, count, index_offsetHEADER_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 targetsafe_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 blobcomp_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, bNPK\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 就不再是黑匣子。本文还有配套的精品资源点击获取