
简介面向需要解析网易 NeoX 引擎 NPK 包的玩家、逆向学习者与工具开发者这套 neox-tools 提供可编译运行的命令行方案重点处理 2020 年初版 Eve Echoes 所用格式支持文件列表读取与 LZ4 解压并会按 MIME 猜测为导出文件补全扩展名后续还计划加入 RC4、简单加密及重新打包能力。压缩包共 15 个文件、约 24KB以 Python 脚本为主体配合 Rust 源码与 Cargo 配置、Shell 脚本、README 说明及许可证等组成目录紧凑便于从命令行构建后直接执行 npktool 解包。已有 5094 人学习下载适合拿来研究 NPK 结构、素材提取流程与脚本化处理思路也能作为继续扩展加密支持和重打包功能的起点。1. 一个 30 GB 的 NPK 摆在面前先搞清楚能不能动拿到一个体积几十 GB 的 NPK 资源包第一反应往往不是打开看看而是动了会不会把包搞坏。这类包把模型、贴图、动画、脚本文本全部塞进一个二进制文件里索引藏在文件头之后偏移量一旦算错解出来的东西不是缺字节就是全零。neox-tools 就是冲着这个痛点做的一套 Python 命令行工具链读文件头、解索引表、按目录解包、改完再打包回去全流程都能脚本化。它适合两类人——一类是想批量提取资源做分析、做替换的从业者另一类是正在研究二进制容器格式、想拿真实样本练手的人。前提是你清楚自己在动什么以及动完之后怎么验证。2. 拆开 NPK 头部用 Python 把二进制黑匣子读成索引表NPK 这类容器格式的通用套路是头 索引 数据块三段式。头部给魔数、版本号、条目数和索引偏移索引区是一条条记录每条记录告诉你文件名存在哪、数据在文件的第几个字节、有多大、有没有压缩。真正麻烦的地方在于同一个引擎的不同版本会调整字段顺序和条目长度你拿到的包未必是文档里写的那一种。所以第一步不是写解析器是写探针。2.1 先做十六进制探针别急着相信任何字段我一般会先用一个十几行的脚本把文件前 256 字节打出来肉眼确认魔数位置和几个疑似整数的字节序。这一步看似原始但它能帮你避掉后面 80% 的翻车——很多解析失败根本不是代码写错而是你假设的字段偏移在这个版本里已经变了。# probe.py —— 打印文件头部的十六进制与 ASCII 对照 import sys from pathlib import Path def dump_head(path: str, n: int 256) - bytes: data Path(path).read_bytes()[:n] # 只读前 n 字节几十 GB 的包也不会吃内存 for i in range(0, len(data), 16): chunk data[i:i 16] hex_part .join(f{b:02x} for b in chunk) asc_part .join(chr(b) if 32 b 127 else . for b in chunk) print(f{i:08x} {hex_part:47} {asc_part}) return data if __name__ __main__: dump_head(sys.argv[1], int(sys.argv[2]) if len(sys.argv) 2 else 256)这里的参数只有一个半文件路径是必需的长度n默认 256遇到头部特别长的变体可以调大到 1024。read_bytes()[:n]这个写法是关键它先把整个文件读进内存再切片对超大文件并不友好生产环境我一般换成open(..., rb).read(n)只做一次定长读取。输出里你会看到开头是不是4e 58 50 4b也就是 ASCII 的 NXPK紧接着的四个字节如果按小端解释是个很合理的版本号比如 1、2、3那基本可以确认头部结构。2.2 索引表定位用自洽性检查代替硬编码偏移确认了魔数之后接下来的问题是怎么把条目数、索引偏移、条目结构这三件事同时猜对。我的做法是列几组候选布局逐组套上去跑自洽性检查解析出来的每条记录必须满足文件名偏移和长度落在字符串区内数据偏移加数据大小不超过文件总长文件名全部是可打印字符这三条。全部通过的布局才算数。# index.py —— 用候选布局试探 NPK 索引结构 import struct from dataclasses import dataclass from pathlib import Path MAGIC bNXPK # 候选布局stride 是单条记录字节数其余是字段相对记录起点的偏移 # 不同引擎版本顺序不同先全列出来让数据自己说话 LAYOUTS [ {stride: 20, name_off: 0, name_len: 4, data_off: 8, data_size: 12, flags: 16}, {stride: 16, name_off: 0, data_off: 8, data_size: 12}, {stride: 24, name_off: 0, name_len: 4, data_off: 12, data_size: 16, flags: 20}, ] dataclass class Entry: name: str offset: int size: int flags: int def u32(buf: bytes, off: int) - int: return struct.unpack_from(I, buf, off)[0] def try_layout(head: bytes, layout: dict, file_size: int) - list[Entry] | None: count, index_off u32(head, 8), u32(head, 12) if not (0 count 2_000_000) or not (0 index_off file_size): return None stride layout[stride] if index_off count * stride file_size: return None entries, seen [], set() for i in range(count): base index_off i * stride rec head[base:base stride] if len(rec) stride: return None d_off, d_size u32(rec, layout[data_off]), u32(rec, layout[data_size]) if d_off d_size file_size: return None name fentry_{i:05d} if name_off in layout: n_off, n_len u32(rec, layout[name_off]), u32(rec, layout[name_len]) if not (0 n_len 512) or n_off n_len file_size: return None raw head[n_off:n_off n_len] if not raw or not all(32 b 127 for b in raw.rstrip(b\x00)): return None name raw.rstrip(b\x00).decode(ascii, replace) if name in seen: # 重名说明布局猜错了 return None seen.add(name) entries.append(Entry(name, d_off, d_size, u32(rec, layout[flags]) if flags in layout else 0)) return entries这段代码最值得说的是try_layout的返回约定要么返回一个完整的条目列表要么返回None。任何一项自洽性检查不通过就直接放弃这组布局不做局部修复——二进制格式里差不多对等于完全错局部放宽条件只会让你在后面解包时收到一堆损坏文件。u32统一按小端解如果你在探针阶段发现版本号按小端解释成大得离谱的数字就把I改成I再跑一遍。count的上限设到 200 万是防着把某个垃圾字段当成条目数导致后面循环跑死。提示如果所有候选布局都不通过别急着加新布局先把index_off和count的取值打印出来看一眼。很多时候问题出在头部字段本身就不在 8 和 12 这两个位置而不是索引结构不对。2.3 把索引表落成 TSV先用肉眼过一遍解析成功的索引不要直接拿去解包先导出成文本看一眼。这一步是廉价的后悔药几十万条记录里只要有一两条的偏移明显异常比如数据区起点是 0或者大小是个天文数字就说明布局蒙对了但边界没处理好。# list_index.py —— 导出索引清单便于人工抽查 import sys from pathlib import Path from index import try_layout, u32 def build_index(path: str): fsize Path(path).stat().st_size with open(path, rb) as f: head f.read(64 * 1024 * 1024) # 索引区通常远小于这个量 if not head.startswith(bNXPK): raise SystemExit(magic 不匹配先跑 probe.py 确认) for layout in __import__(index).LAYOUTS: entries try_layout(head, layout, fsize) if entries: print(f# 命中布局 stride{layout[stride]}共 {len(entries)} 条, filesys.stderr) return entries raise SystemExit(所有候选布局均不自洽需要人工介入) if __name__ __main__: entries build_index(sys.argv[1]) out Path(sys.argv[2] if len(sys.argv) 2 else index.tsv) with out.open(w, encodingutf-8, newline) as f: f.write(name\toffset\tsize\tflags\n) for e in entries: f.write(f{e.name}\t{e.offset}\t{e.size}\t{e.flags}\n) print(f已写出 {len(entries)} 条到 {out})head一次读 64 MB 是个经验值索引区很少超过这个量级如果你的包条目数特别多把这个值调到 256 MB 也不会有什么代价因为 NPK 的索引是连续存放的读多了只是浪费一次内存拷贝。导出的 TSV 可以直接丢进表格工具排序按size倒序看最大的几个文件是谁按offset正序检查有没有重叠区间——重叠基本等于布局解析错了。3. 解包落地路径重建、分块读取与资源类型判定索引拿到手解包本身是件体力活。但恰恰是这一步最容易出事故路径拼接不当会被写穿到工作目录之外一次性read()会把内存打满同名文件会互相覆盖。这一章把这几件事一次性处理干净。3.1 路径安全别让包里的文件名决定你磁盘上的位置NPK 里的文件名可能带../、可能以/开头、可能含有 Windows 不允许的字符。直接root / name拼路径就等于把写盘位置的控制权交给了一个你不认识的二进制文件。# paths.py —— 把包内路径安全地映射到本地目录 import re from pathlib import Path INVALID re.compile(r[:|?*\x00-\x1f]) def safe_rel(name: str) - Path: 把包内文件名规范化成安全的相对路径 p name.replace(\\, /).lstrip(/) parts [seg for seg in p.split(/) if seg not in (, ., ..)] if not parts: raise ValueError(f非法路径: {name!r}) parts [INVALID.sub(_, seg) for seg in parts] return Path(*parts) def safe_join(root: Path, name: str) - Path: 拼出绝对路径并做一次归属校验防止路径穿越 root root.resolve() target (root / safe_rel(name)).resolve() if root not in target.parents and target ! root: raise ValueError(f路径穿越: {name!r}) return targetsafe_rel做了三件事统一分隔符、丢掉.和..段、把非法字符替换成下划线。safe_join再做一次resolve()之后的父目录校验这是防止符号链接和编码花样绕过拼接逻辑的最后一道关卡。参数上没什么可调的唯一值得说的一句是INVALID这个正则——如果你在 Linux 上解包、后面又要挪到 Windows 上处理这一步的字符替换最好提前做否则跨平台搬文件的时候会报一堆文件名错误。3.2 解包主循环分块读取 原子写入解包脚本的性能瓶颈几乎全在 IO 上把整块数据读进内存再write是新手写法遇到单文件几个 GB 的包会直接崩掉。分块拷贝是标准解法块大小我一般给 4 MB这个值在机械盘和固态盘上差别不大但太小会让系统调用次数暴涨。# unpack.py —— 按索引把 NPK 内的资源解到输出目录 import hashlib import os import sys from pathlib import Path from list_index import build_index from paths import safe_join CHUNK 4 * 1024 * 1024 # 单次拷贝字节数 def unpack(npk: str, out_dir: str, overwrite: bool False) - None: entries build_index(npk) root Path(out_dir) root.mkdir(parentsTrue, exist_okTrue) manifest [] with open(npk, rb) as src: for e in entries: dst safe_join(root, e.name) dst.parent.mkdir(parentsTrue, exist_okTrue) if dst.exists() and not overwrite: continue digest hashlib.md5() tmp dst.with_suffix(dst.suffix .part) src.seek(e.offset) remain e.size with tmp.open(wb) as fp: while remain 0: buf src.read(min(CHUNK, remain)) if not buf: break fp.write(buf) digest.update(buf) remain - len(buf) os.replace(tmp, dst) # 原子替换中途失败不留半截文件 manifest.append((e.name, e.size, digest.hexdigest())) with (root / _manifest.tsv).open(w, encodingutf-8) as f: for name, size, md5 in manifest: f.write(f{name}\t{size}\t{md5}\n) print(f解包完成 {len(manifest)} 个文件) if __name__ __main__: unpack(sys.argv[1], sys.argv[2], --force in sys.argv)几个参数值得单独讲。CHUNK是拷贝粒度4 MB 是个折中如果你解的是纯小文件可以降到 256 KB 减少末块浪费解大文件升到 8 MB 收益明显。overwrite默认关闭这让脚本天然支持断点续解——中断之后重跑会跳过已经存在的文件。os.replace是原子操作配合.part临时后缀可以保证你永远不会看到一个写到一半的文件出现这个细节在解大包时非常重要。最后写出的_manifest.tsv记录了每个文件的 md5它是后面回包校验的基准别删。注意src.seek(e.offset)里的 offset 是绝对文件偏移。如果你的包是数据区起点 相对偏移的结构这里要加上数据区基址这是解包翻车率最高的一处下一章会专门讲。3.3 资源类型判定别信扩展名看魔数很多 NPK 里同一个文件既可能是文本也可能是二进制扩展名靠不住。我在解包之后会再加一道类型嗅探用文件头几个字节判断真实类型顺便把加密或压缩过的文件单独挑出来。# sniff.py —— 用魔数判断解包出来的文件真实类型 from pathlib import Path SIGNS [ (b\x89PNG, png), (bDDS , dds), (bKTX , ktx), (bOggS, ogg), (b\x00\x00\x00\x01, mp4_or_atlas), (bPK\x03\x04, zip), (b--, lua_or_text), ] def sniff(path: Path) - str: with path.open(rb) as f: head f.read(8) for sig, kind in SIGNS: if head.startswith(sig): return kind try: head.decode(utf-8) return text except UnicodeDecodeError: return binary_unknownsig列表按从长到短、从特殊到一般排列顺序不能乱——\x00\x00\x00\x01这种短签名如果排在前面会把一堆其他格式误判成 mp4。最后一个lua_or_text只是个粗略兜底真正的文本判定靠decode(utf-8)那一行。统计一下各类别的数量你会立刻发现哪些文件是意外的存在一个号称只有美术资源的包里冒出大量zip通常意味着有二次打包这部分需要单独解。4. 回包重建偏移对齐、索引重排与写回校验改完资源要往回塞才是真正考验对格式理解深度的地方。解包只要读对就行回包要求你把头部、索引、数据三块重新排一遍并且保证排出来的东西和原始包在结构上等价。这里出错的典型症状是文件能解出来但塞回游戏就崩。4.1 偏移对齐与索引重排绝大多数引擎在读取资源时用内存映射或者要求块对齐所以数据区每个条目的起始偏移通常要满足 4 字节或 16 字节对齐。不对齐不会立刻炸但会在某些设备上表现出玄学般的随机崩溃。# pack.py —— 从头构建一个 NPK含对齐与索引重排 import struct from pathlib import Path MAGIC bNXPK ALIGN 4 # 数据块对齐字节数按目标引擎要求调整 HEADER 16 # 魔数4 版本4 条数4 索引偏移4 STRIDE 20 # 单条索引记录长度 def pad4(n: int, align: int ALIGN) - int: return (align - (n % align)) % align def build(src_dir: Path, out_path: Path, version: int 1) - None: files sorted(p for p in src_dir.rglob(*) if p.is_file()) names [p.relative_to(src_dir).as_posix() for p in files] # 字符串区所有文件名 UTF-8 拼接记录各自的相对偏移 str_blob, name_offsets b, [] for n in names: name_offsets.append(len(str_blob)) str_blob n.encode(utf-8) b\x00 index_size len(files) * STRIDE str_base HEADER index_size # 字符串区起点 data_base str_base len(str_blob) pad4(str_base len(str_blob)) records, cursor, payload [], data_base, bytearray() for p, n_off, name in zip(files, name_offsets, names): blob p.read_bytes() records.append((str_base n_off, len(name.encode(utf-8)), cursor, len(blob))) payload blob payload b\x00 * pad4(len(blob)) cursor data_base len(payload) with out_path.open(wb) as f: f.write(MAGIC) f.write(struct.pack(III, version, len(files), HEADER 0)) for n_off, n_len, d_off, d_size in records: f.write(struct.pack(IIIII, n_off, n_len, d_off, d_size, 0)) f.write(str_blob) f.write(b\x00 * (data_base - str_base - len(str_blob))) f.write(bytes(payload)) print(f写出 {out_path}{len(files)} 条{out_path.stat().st_size} 字节)三个常量决定了整个包的骨架。ALIGN是最容易出错的一个4 是保守选择但如果目标引擎读贴图时要求 16 字节对齐就得改成 16否则贴图在某些分辨率下会花屏。HEADER和STRIDE这两个值必须和你解包时命中的布局严格一致改一个另一个必须跟着改。records是按文件名排序后写入的很多引擎的索引查找依赖有序性排序这步不能省。payload用bytearray累积再一次性写出避免几万次小write调用。4.2 用原始清单做差分校验回包之后最该做的事不是进游戏试而是拿解包时生成的_manifest.tsv做一次结构层面的比对。这一步能在 10 秒内告诉你回包是不是形状对但内容偏。# verify.py —— 回包后的结构等价性校验 import sys from pathlib import Path from list_index import build_index def verify(npk: str, manifest: str) - int: expect {} for line in Path(manifest).read_text(encodingutf-8).splitlines(): name, size, md5 line.split(\t) expect[name] (int(size), md5) entries build_index(npk) got {e.name: e.size for e in entries} missing set(expect) - set(got) extra set(got) - set(expect) size_bad [n for n in expect if n in got and expect[n][0] ! got[n]] overlaps [] ordered sorted(entries, keylambda e: e.offset) for a, b in zip(ordered, ordered[1:]): if a.offset a.size b.offset: overlaps.append((a.name, b.name)) print(f条目数 期望{len(expect)} 实际{len(got)}) print(f缺失{len(missing)} 多余{len(extra)} 大小不符{len(size_bad)} 区间重叠{len(overlaps)}) for label, items in ((缺失, missing), (多余, extra), (大小不符, size_bad)): for n in list(items)[:10]: print(f {label}: {n}) return 1 if (missing or extra or size_bad or overlaps) else 0 if __name__ __main__: raise SystemExit(verify(sys.argv[1], sys.argv[2]))用退出码表达结果是为了让这个脚本能直接串进流水线set -e之下校验不过就停不会带着坏包继续往下跑。overlaps那一项是很多人忽略的——大小和条目数都对但两个条目的数据区互相咬住了引擎读到后面就会拿到错位的数据。这种情况只在排序后比对相邻区间才看得出来。提示先拿解包再原样打包跑一遍校验。这一步不涉及任何修改如果都过不了说明打包逻辑有问题跟你的资源改动无关别浪费时间往游戏里试。5. 避坑与排查五条从崩溃日志里换来的血泪经验下面这几条都是实际拆包时会撞上的每条按现象 → 原因 → 解决给。看完再去动手能省掉大半天。5.1 解出来的文件全是 0 字节或者内容整体错位现象目录结构完整文件名也对但每个文件打开都是空的或者内容和文件名完全对不上图片能看出是别的图的片段。原因偏移基准搞错了。索引里记录的offset有两种可能的语义——相对于整个文件开头或者相对于数据区起点。前者直接seek后者必须加一个基址通常是data_base或者index_offset index_size。两种都可能出现在真实包里取决于版本。解决拿最小的一个条目做实验分别按两种语义seek过去读 16 字节看哪一边的内容像有效数据PNG 会有\x89PNG文本会是可打印字符。确认之后把基址写成常量别在代码里散落加减。5.2 中文文件名解出来是乱码现象大部分文件名正常少数带中文或者特殊符号的变成一堆问号或者干脆报UnicodeDecodeError。原因字符串区不是 UTF-8可能是 GBK、Latin-1或者是被引擎自己做过一次异或处理。很多包在不同版本之间换过编码。解决拿到字符串区的原始字节后先枚举几种编码试解哪一种能全部解通就用哪一种。我的习惯是先试 UTF-8失败的段落再用 GBK 兜底两者都不行就说明有额外变换直接看十六进制找规律别硬猜。5.3 回包后进游戏直接闪退现象校验脚本全绿条目数、大小、区间都一致但游戏加载到一半崩掉。原因索引没有排序或者对齐没做。引擎在加载时可能对索引做二分查找乱序索引会让它拿到错误条目数据块起始地址不对齐则会在某些平台上触发未对齐访问异常。解决打包时按文件名或按引擎约定的键排序写入每个数据块前面补pad4计算出的填充字节填充内容用\x00。改完先跑一遍原样解包再原样打包确认校验通过再改资源。5.4 解包跑到一半进程被杀内存飙到几十 GB现象解包进度条走到某个大文件就卡住系统内存监控直接冲顶最后 OOM。原因用f.read(e.size)一次性把整个条目读进内存。一个 2 GB 的过场动画文件足以让 16 GB 的机器直接躺平。解决改成分块循环读块大小 4 MB 起步。同时把写入改成先写.part临时文件完成后os.replace原子改名这样中断后重跑会自动跳过已完成的文件。5.5 解出来的文件数量比预期多出一大截现象索引条目数正常但解包目录里的文件数翻了好几倍还出现了奇怪的目录层级。原因条目名里有大小写不同的重复项或者带\和/混用。在不区分大小写的文件系统上比如 Windows 默认行为Texture/A.png和texture/a.png会被写成同一个文件后写的覆盖前写的看起来就像少了或者多了。解决解包前先做一次名字冲突检测把所有条目名统一小写后统计重复数。有冲突就在文件名后面追加一个四位序号同时把映射关系写进 manifest方便回包时还原。6. 批量流水线与差分验证把一次性解包变成可重复流程单个包跑通之后真正省时间的是把它变成一条流水线给一批包自动探测布局、解包、记录哈希、回包、校验最后只输出一个哪几个包有问题的清单。我一般的做法是把前面几个脚本串成一段 shell输入目录、输出目录、工作目录三个参数就够了。#!/usr/bin/env bash # batch.sh —— 批量处理目录下的所有 NPK set -euo pipefail IN_DIR${1:?用法: batch.sh 输入目录 输出目录} OUT_DIR${2:?用法: batch.sh 输入目录 输出目录} mkdir -p $OUT_DIR for npk in $IN_DIR/*.npk; do base$(basename $npk .npk) work$OUT_DIR/$base mkdir -p $work/src python3 probe.py $npk 64 $work/head.txt python3 list_index.py $npk $work/index.tsv 2$work/err.log python3 unpack.py $npk $work/src $work/err.log 21 python3 pack.py $work/src $work/repacked.npk $work/err.log 21 if python3 verify.py $work/repacked.npk $work/src/_manifest.tsv; then echo OK $base else echo FAIL $base - $work/err.log fi done用repacked.npk和原始包做差分验证比拿回包去游戏里试要快一个数量级。验证分三层我按成本从低到高排层级检查项耗时量级能抓到的错误L1 结构条目数、名字集合、大小一致秒级索引重建错误、漏文件L2 布局偏移区间不重叠、对齐满足秒级对齐缺失、偏移基准错L3 内容逐条 md5 与 manifest 比对分钟级取决于体积数据块复制错位、截断实际执行时 L1 和 L2 必须每次跑L3 只在改动过资源的包上跑——原样解包再打包的包L3 应该是全绿的一旦有红就说明拷贝逻辑有 bug。这条原样往返必须全绿的规矩我后来做任何二进制容器的改写都强制走一遍它等价于给你一个不需要理解全部格式细节的正确性下限。有位同事曾经跳过这步直接改贴图回包结果花了两个晚上在追一个其实是拷贝函数少读了一个字节的问题最后用往返测试三分钟就定位了。还有一个提高效率的小技巧把index.tsv按size排序只对前 5% 的大文件做 L3 校验。大文件才是偏移出错的高发区小文件几乎不会出问题这样能把校验时间压到原来的十分之一同时覆盖住绝大多数风险。等流水线跑顺了你会发现自己更多时间花在判断这些资源值不值得动上而不是为什么解不开。希望帮到你。本文还有配套的精品资源点击获取