neox-tools NPK 解包与回包:Python 解析索引、偏移与资源替换
简介:neox-tools 是一套面向网易 NeoX 引擎游戏资源研究与解包场景的实用工具,重点处理《Eve Echoes》早期版本使用的 NPK 文件格式,适合具备一定 Python 与命令行基础的玩家、Mod 研究者或逆向分析学习者使用。压缩包共 15 个文件,以 7 个 Python 脚本为主,辅以 Rust 源码、Cargo 构建配置、Shell 脚本、README 说明与许可证等,整体仅 24KB,体量轻便,便于快速查阅和二次开发。工具可读取 NPK 文件列表并借助 LZ4 完成解压,还会根据 MIME 类型作出推测,并为导出的文件补充扩展名;配套脚本涉及 pyc 反编译、解密与脚本重定向等处理思路,对理解 NeoX 资源结构较有帮助。已有 5094 人学习或下载。当前版本尚未覆盖 RC4 加密、简单加密与 ZLib 文件,重打包功能也在计划中,读者可据此了解其能力边界、构建方式与后续扩展方向。
1. 从一堆打不开的 NPK 说起:neox-tools 到底解决哪几步
拿到一个 NeoX 系项目的资源目录,里面散着一堆.npk,双击打不开,改完贴图又塞不回去,很多人第一次搜到 neox-tools 就是在这个场景。它是一套 Python 脚本,不是“一键解包神器”:先把 NPK 的头部、索引表、数据区读明白,再做批量导出、资源替换和回包校验。适合手里有合法样本、需要定位资源、做本地化替换或资源流水线的人;只想点开看图的,先别急着上批量脚本。
2. NPK 文件结构先摸清:从魔数、索引表到数据区
NPK 本质是一个容器,和 ZIP 的思路接近,但字段布局、偏移基准、压缩标记都可能随项目版本变化。把它当 ZIP 直接改后缀,通常会得到“能看见文件名、打不开内容”的结果。真正要落地,先把三件事分开:文件头告诉你索引在哪,索引表告诉你每个资源从哪开始、有多长、压没压缩,数据区才是真正的贴图、模型、配置和音频。neox-tools 的价值不在于替你猜格式,而在于把这段解析过程摊开,让你能对着样本逐字段验证。
2.1 为什么先做只读解析,不要一上来就解包
我见过最常见的翻车方式,是拿生产包直接跑批量解包脚本,脚本里写死了一个header_size=16,结果样本是 32 字节头,第一批文件全部偏移错位,导出目录里全是几 KB 的碎片。更麻烦的是,有人解包失败后顺手点了“覆盖原包”,样本没了,后面连对照都做不了。血泪经验就一句:先复制一份样本,所有探测都只在副本上做,只读脚本跑通之前不要碰写文件。
只读解析要回答四个问题。第一,魔数是不是稳定的四字节标记,常见会是NXPK、NPK0这类可读串,也可能是纯二进制;第二,版本字段是不是合理,太小或太大都说明字段位置错了;第三,文件数量和索引偏移能不能落在文件长度以内;第四,索引项是定长还是变长,文件名长度用几个字节表示。把这四个问题写进探测脚本,比一上来写“完整解包器”靠谱得多。
还有一个容易忽略的点:偏移基准。索引里的offset可能是从文件开头算的绝对偏移,也可能是从数据区开头算的相对偏移。判断方法不玄学,拿第一条记录做加法:如果offset + packed_size明显小于文件长度,但加上头部和索引区长度后刚好落在数据区,基本就是相对偏移。这个判断不要靠猜,打印前三条记录的offset、packed_size、raw_size,再和十六进制窗口对照,错位会非常明显。
2.2 用 Python 写一个头部与索引探测脚本
先写一个最小探测脚本,只读前 64 字节,不做任何解包。它不追求“支持所有版本”,只负责把可疑字段打印出来,方便你拿样本对照。
from pathlib import Path import struct def probe_npk(path: str) -> None: p = Path(path) with p.open("rb") as f: head = f.read(64) print(f"file={p.name} size={p.stat().st_size}") print("head64=", head[:64].hex(" ")) # 常见候选布局:magic(4) + version(4) + count(4) + index_offset(4) # 这里只做探测,不能把结果当成唯一真相 for endian in ("<", ">"): magic, version, count, index_off = struct.unpack_from( endian + "4sIII", head, 0 ) print( f"endian={endian} magic={magic!r} version={version} " f"count={count} index_off={index_off}" ) if __name__ == "__main__": probe_npk("sample.npk")逻辑上,这个脚本只做三件事:读文件头、按小端和大端各解一次、把结果并排打印。NPK 这类容器经常出现“字段顺序对了但端序不对”的情况,小端读出来count是几百万,大端读出来是几百,基本就能排除一半错误。脚本里没有直接read_bytes()整个文件,是为了避免几 GB 的包一上来就把内存打满;探测阶段只需要头部,没必要读全量。
参数上,path指向样本副本,别指向唯一原包;head固定读 64 字节,够覆盖大多数头部字段。struct.unpack_from的第二个参数是偏移,第三个参数是格式串,4sIII表示四字节字符串加三个无符号整数。读出来的magic如果是b'NXPK'或b'NPK0',说明方向对了;如果四个字节都是00,要么文件被加密,要么头部不在文件开头,先别继续硬解。
探测结果要过一遍常识检查:count不能大于文件长度除以最小索引项长度;index_off不能大于文件长度;version不应该是一个看起来像 ASCII 的奇怪大数。只要有一项离谱,优先怀疑字段宽度或端序,而不是怀疑样本坏了。
2.3 索引表字段怎么读:偏移、长度、压缩标记
索引项常见形态是“文件名长度 + 文件名 + 偏移 + 压缩长度 + 原始长度 + 标记位”。不同项目可能把文件名长度换成 1 字节、2 字节或 4 字节,也可能把标记位塞进偏移的高位。下面这段是按 2 字节文件名长度、4 个 4 字节字段写的骨架,字段顺序必须用样本验证,不能直接当标准。
import struct def read_index(data: bytes, index_off: int, count: int, endian: str = "<"): entries = [] pos = index_off for i in range(count): if pos + 2 > len(data): raise ValueError("索引越界,先检查 index_off 或 count") name_len, = struct.unpack_from(endian + "H", data, pos) pos += 2 if name_len == 0 or name_len > 512: raise ValueError(f"可疑文件名长度: {name_len}") name = data[pos:pos + name_len].decode("utf-8", "replace") pos += name_len # 常见字段:offset, packed_size, raw_size, flags offset, packed_size, raw_size, flags = struct.unpack_from( endian + "IIII", data, pos ) pos += 16 entries.append({ "name": name, "offset": offset, "packed_size": packed_size, "raw_size": raw_size, "flags": flags, }) return entries这段代码的关键不是“读出来”,而是“读出来之后能自洽”。offset和packed_size决定数据切片范围,raw_size用来校验解压结果,flags决定要不要走 zlib、lzma 或别的解码分支。文件名长度用H只是常见做法,如果解出来一堆乱码,不要先改utf-8,先确认长度字段是不是 1 字节或 4 字节。文件名都读错,后面的偏移一定全错。
| 字段 | 常见宽度 | 读法 | 出错表现 |
|---|---|---|---|
| magic | 4 字节 | 可读串或二进制标记 | 识别不了文件类型 |
| version | 4 字节 | 无符号整数 | 字段错位后变成超大数 |
| count | 4 字节 | 资源条目数量 | 数量离谱,循环直接卡死 |
| index_off | 4 字节 | 索引表起始位置 | 索引越界或读出乱码 |
| offset | 4 字节 | 数据区切片起点 | 导出文件全 0 字节或碎片 |
| packed_size | 4 字节 | 压缩后长度 | 切片过短,解压报错 |
| raw_size | 4 字节 | 解压后长度 | 解压成功但文件不完整 |
| flags | 4 字节 | 压缩/加密标记 | 贴图花屏、模型破面 |
读索引时加两个硬检查:count超过 20 万先停,name_len超过 512 先停。这不是说大包不存在,而是探测阶段出现这种值,大概率是字段错位。真正的大包可以后面用分页或流式处理,没必要在还没确认格式时就把内存和 CPU 打满。
3. 解包链路落地:批量导出、目录还原与资源命名
索引读通之后,解包本身不复杂,难的是批量处理时不把目录写乱、不把同名资源覆盖、不把路径穿越带进输出目录。一个能用的解包链路应该拆成四步:扫描条目、校验范围、按标记解码、落盘并记录结果。每一步都留下可检查的中间信息,出问题时才知道是偏移错了、压缩标记错了,还是文件名映射错了。
3.1 解包流程拆成四步:扫描、校验、解码、落盘
扫描阶段只读索引,不碰数据区;校验阶段检查offset + packed_size是否落在文件长度内,检查raw_size是否大得离谱;解码阶段按flags走对应分支;落盘阶段先做路径安全处理,再创建父目录。很多脚本把四步揉成一个循环,结果一个文件报错就中断全量任务,连哪一条记录出错都不知道。
import zlib import lzma from pathlib import Path def decode_payload(buf: bytes, flags: int) -> bytes: # 这里只演示两种常见压缩标记,真实位定义要跟样本对齐 if flags & 0x01: return zlib.decompress(buf) if flags & 0x02: return lzma.decompress(buf) return buf def safe_out(base: Path, name: str) -> Path: # 防止索引里的 ../ 把文件写到目录外 base = base.resolve() target = (base / name).resolve() if target != base and base not in target.parents: raise ValueError(f"非法路径: {name}") return targetdecode_payload里用位与判断压缩标记,是因为很多容器把多个状态压进同一个整数:低位表示压缩算法,高位可能表示加密、对齐或资源类型。flags & 0x01命中 zlib、flags & 0x02命中 lzma,只是常见映射,不能当铁律。实际调试时先挑一个已知 PNG 或 JPG,解压后看文件头:PNG 开头是89 50 4E 47,JPG 开头是FF D8 FF,对不上就说明标记位或长度字段读错了。
safe_out不是多余的安全表演。NPK 索引里的文件名来自资源包,不能假设它一定干净;一旦出现../../或绝对路径,直接拼base / name会把文件写到输出目录外面。这个函数先resolve()再判断父目录关系,命中非法路径就抛错,批量任务里可以捕获后跳过并记录,不要默默写入。
3.2 批量导出脚本与参数说明
把上一节的read_index、decode_payload、safe_out放进同一个文件,再加一个批量入口。下面这段的重点是“先校验后解码”,任何一条记录越界都跳过并打印,不让整个任务崩掉。
from pathlib import Path import struct def unpack_npk(npk_path: str, out_dir: str, endian: str = "<") -> None: data = Path(npk_path).read_bytes() magic, version, count, index_off = struct.unpack_from( endian + "4sIII", data, 0 ) if count > 200_000: raise ValueError(f"count 异常: {count}") entries = read_index(data, index_off, count, endian) base = Path(out_dir) base.mkdir(parents=True, exist_ok=True) ok = 0 for e in entries: start = e["offset"] end = start + e["packed_size"] if start < 0 or end > len(data): print("skip out of range", e["name"], start, end, len(data)) continue raw = decode_payload(data[start:end], e["flags"]) if len(raw) != e["raw_size"]: print("size mismatch", e["name"], len(raw), e["raw_size"]) target = safe_out(base, e["name"]) target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(raw) ok += 1 print(f"unpack done: {ok}/{len(entries)}")npk_path指向样本副本,out_dir指向空目录,endian默认小端,探测阶段如果发现大端更合理就传">"。count > 200_000是保护阈值,不是格式标准;小包正常跑,大包可以改成流式索引读取,但不要在字段还没确认时放开限制。raw_size不一致时先打印,不要立刻抛异常,因为有些资源可能带额外填充或二次编码,先看是少量还是全量。
这段脚本跑完后,重点看两个输出:跳过数量和大小不一致数量。如果跳过很多,优先查offset基准;如果大小不一致很多,优先查flags和packed_size。不要只看“导出了多少个文件”,导出成功但内容错了,比直接报错更难排查。
3.3 文件名映射和重复资源处理
NPK 里常见同名不同内容的资源,尤其是多语言贴图、不同分辨率的 UI 图、同一路径下的旧版本缓存。直接按文件名落盘,后一个会覆盖前一个,最后你看到的是“解包成功但资源少了”。我一般先用内容哈希做去重,再决定要不要加后缀。同名同内容的跳过,同名不同内容的加短哈希后缀,原文件名保留在清单里,方便回包时映射。
import hashlib from pathlib import Path def content_hash(buf: bytes) -> str: return hashlib.sha1(buf).hexdigest()[:12] def unique_target(base: Path, name: str, buf: bytes) -> Path: target = safe_out(base, name) if target.exists(): target = target.with_name( target.stem + "_" + content_hash(buf) + target.suffix ) return targetcontent_hash只取前 12 位,够区分同一目录下的重复资源,又不会把文件名拉得太长。unique_target先走safe_out,再做存在性判断;如果目标已存在,就用“原名 + 哈希 + 原扩展名”生成新路径。哈希基于解压后的raw,不是压缩后的body,因为同一份内容用不同压缩级别打出来,压缩字节不同,但资源本身一样。
| 情况 | 处理 | 原因 |
|---|---|---|
| 同名同内容 | 跳过或只记清单 | 省空间,避免重复 |
| 同名不同内容 | 加内容哈希后缀 | 防止覆盖 |
| 无扩展名 | 读文件头猜类型 | 不靠猜改原文件 |
路径带.. | 拒绝写入 | 防止目录穿越 |
| 文件名超长 | 截断并加哈希 | 兼容文件系统限制 |
扩展名缺失时,不要凭资源目录猜“这应该是贴图”就直接改成.png。先读前 16 字节,看是不是 PNG、JPG、DDS、KTX 或自定义头,再决定是否加后缀。改后缀不会改变文件内容,但会让后续工具链误判格式。更稳的做法是保留原文件名,在清单里记录探测到的类型,回包时仍按原名写回。
4. 回包与重打包:改完资源怎么塞回去还能被引擎认
解包只是前半程,真正容易出问题的是回包。很多人改完一张贴图,直接把原包解开、替换文件、再按文件名排序打回去,结果引擎加载闪退。原因通常不是贴图坏了,而是索引偏移、对齐、压缩标记或头部校验没有按原包规则重算。回包的目标不是“生成一个看起来像 NPK 的文件”,而是“生成一个引擎愿意按原逻辑读取的容器”。
4.1 重打包前先做可逆性验证
在改任何资源之前,先做一次原样往返:原始包解包到目录 A,不改任何文件,按你的打包脚本回包成 B,再把 B 解包到目录 C,逐文件比较 A 和 C 的哈希。这个验证能提前暴露索引偏移、文件名编码、压缩标记和头部字段的问题。不要比较原始包和回包 B 的整包二进制,因为压缩器版本、压缩级别、填充字节都可能不同,整包哈希不一致不代表内容错了。
验证时重点看三类差异:文件数量是否一致、每个文件的raw_size是否一致、解压后内容哈希是否一致。数量少了,说明索引写漏;大小不一致,说明压缩标记或长度字段写错;哈希不一致,说明数据区切片或写入顺序错。这个流程听起来笨,但比“改完直接进引擎试”省时间,尤其是样本包很大时,一次闪退可能让你排查半天。
4.2 Python 重打包脚本骨架
下面这段只演示偏移计算和索引写回,magic、version、头部长度、字段顺序都要换成你样本里的值。直接拿它回写线上包是不负责任的,但拿它理解“为什么偏移要重算”很合适。
import struct import zlib from pathlib import Path def pack_npk(src_dir: str, out_path: str, endian: str = "<") -> None: files = sorted(p for p in Path(src_dir).rglob("*") if p.is_file()) payloads = [] for p in files: name = p.relative_to(src_dir).as_posix() raw = p.read_bytes() body = zlib.compress(raw, 6) payloads.append((name, body, len(raw), 1)) # 先假设头部 16 字节,索引紧随其后;真实布局可能不同 header_size = 16 index_size = 0 for name, body, raw_size, flags in payloads: nb = name.encode("utf-8") index_size += 2 + len(nb) + 16 data_start = header_size + index_size index = bytearray() data_area = bytearray() for name, body, raw_size, flags in payloads: nb = name.encode("utf-8") offset = data_start + len(data_area) index += struct.pack(endian + "H", len(nb)) index += nb index += struct.pack( endian + "IIII", offset, len(body), raw_size, flags ) data_area += body header = struct.pack( endian + "4sIII", b"NXPK", 1, len(payloads), header_size ) Path(out_path).write_bytes(header + index + data_area)逻辑上,先收集所有文件并压缩,算出索引区总长度,再得到数据区起点data_start,最后逐条写入偏移和长度。offset必须是绝对偏移,因为解包脚本按文件开头切片;如果你的样本用相对偏移,就把offset改成len(data_area),并在读取时加data_start。header_size不能硬抄 16,样本头部如果有校验和、索引长度、保留字段,就要按实际长度调整。
参数上,zlib.compress(raw, 6)的 6 是压缩级别,1 更快、9 更小,但不要盲目追体积。有些引擎对压缩块大小有上限,太大可能加载失败;有些项目要求固定压缩级别,否则回包后的哈希对不上。flags=1只是示例,表示 zlib;如果原包用 lzma 或自定义编码,必须按原标记写,不能看哪个压缩率高就用哪个。
4.3 对齐、校验和与压缩参数
回包后引擎闪退,很多问题出在对齐和校验和。索引偏移不是随便写的,某些引擎会按 4、8、16 字节对齐做内存映射,数据块起点不对齐,读出来就是错位。校验和也一样,头部如果有 CRC32,重打包后不重算,引擎可能直接拒绝加载,或者加载到一半崩溃。
| 参数 | 常见取值 | 影响 | 检查方法 |
|---|---|---|---|
| 数据对齐 | 4 / 8 / 16 字节 | 内存映射和读取兼容 | offset % align == 0 |
| 压缩级别 | 1 / 6 / 9 | 体积与加载 CPU | 解包后raw_size一致 |
| 压缩算法 | zlib / lzma / 自定义 | 引擎能否解码 | 看flags和原包样本 |
| 校验和 | CRC32 / 自定义 | 拒绝加载或闪退 | 重算后与头部比对 |
| 索引顺序 | 原顺序 / 排序 | 资源查找和覆盖 | 与原始索引对照 |
对齐处理可以在写每个body前补零:
def pad_to(buf: bytearray, align: int = 16) -> None: while len(buf) % align: buf.append(0)这段函数只做一件事:把数据区长度补到align的整数倍。补零不会改变资源内容,但会改变后续偏移,所以必须在写索引之前调用,不能先写索引再补数据。补多少、补不补,要看样本;如果原包数据区没有填充,你强行补零可能让引擎按旧偏移读取时错位。最稳的办法是拿原包对照:看前三个资源的offset差值,是否总是某个对齐值的倍数。
5. 常见问题与排查:五个翻车点逐条对
NPK 处理最耗时间的不是写脚本,而是排查“看起来成功了但结果不对”。下面五类问题基本覆盖了大多数翻车场景,每一条都按“现象、原因、解决”拆开。遇到新样本时,先按顺序排除这五项,再怀疑更复杂的加密或自定义编码。
5.1 解包后文件全是 0 字节:先看 offset 基准
现象:脚本显示导出成功,但打开目录发现大量文件是 0 字节,或者只有几字节的碎片。原因:最常见的是offset基准读错。索引里的偏移可能是相对数据区,不是相对文件开头;也可能packed_size和raw_size字段顺序反了,导致切片长度变成 0 或极小值。另一种情况是头部长度猜错,索引整体往前或往后偏了几个字节。
解决:先打印前三条记录的offset、packed_size、raw_size,再和十六进制窗口对照。取第一条记录,尝试data[offset:offset+packed_size],如果全是 0,再试data[data_start+offset:data_start+offset+packed_size]。如果后者能解出正常文件头,就说明偏移是相对数据区。字段顺序反了也好判断:packed_size通常小于等于raw_size,如果读出来前者远大于后者,先把两个字段对调再试。
5.2 贴图花屏、模型破面:压缩标记位读反了
现象:文件大小对得上,解压也不报错,但 PNG 打开是花屏,模型加载破面,配置文件里夹杂乱码。原因:flags的位定义读错,或者把 zlib 和 lzma 的分支搞反了。也有可能是raw_size和packed_size用反,切片长度不对,解压器仍然能吐出一段字节,但内容已经错位。
解决:挑一个已知类型的资源做单文件验证,不要一次跑全量。解压后看前 8 字节:PNG 是89 50 4E 47 0D 0A 1A 0A,JPG 是FF D8 FF,DDS 是44 44 53 20。对不上就换压缩分支,别急着改资源本身。还要检查raw_size是否等于解压后长度,如果不等,先修长度字段,再看内容。单文件跑通后再批量,能省掉大量“全量导出后才发现全错”的时间。
5.3 回包后引擎闪退:索引偏移没有重算
现象:只替换了一个贴图,回包后引擎启动到加载界面就闪退,日志里没有明确报错,或者只报资源读取失败。原因:替换后的文件长度变了,但索引里的offset、packed_size、raw_size还是旧值;或者索引区整体变长,数据区起点没有跟着后移;再或者头部里的索引偏移、校验和没有更新。
解决:回包后先跑只读探测,确认count、index_off、每条记录的offset + packed_size <= 文件长度都成立,再进引擎。只替换一个文件做最小验证,不要一上来改几十个。如果引擎仍然闪退,检查对齐:把数据区起点补到 4 或 16 字节的倍数,再试一次。校验和如果有,必须重算;没有样本依据时,不要随便往头部塞新字段。
5.4 批量处理到一半内存爆掉:整包 read_bytes 是隐患
现象:小样本跑得很顺,换成一个几 GB 的包,脚本跑到一半被系统杀掉,或者机器开始频繁换页。原因:Path.read_bytes()把整个 NPK 读进内存,后面又复制切片、解压、写文件,峰值内存可能是文件大小的好几倍。批量并发开太多也会放大这个问题。
解决:探测阶段只读头部,解包阶段用mmap或按条目流式读取,不要整包复制。并发数控制在 2 到 4 个,先跑通再谈提速。如果必须整包读,至少把解压后的raw及时释放,不要在列表里长期保存所有文件内容。内存问题通常不是脚本逻辑错,而是处理方式太“一次性”。
5.5 文件名带路径穿越或重名:导出目录被覆盖
现象:解包后某些文件不见了,或者输出目录外多出奇怪文件;日志里没有报错,但资源数量对不上。原因:索引里的文件名包含../、绝对路径或重复名称,脚本直接base / name拼接并写入。同名不同内容的资源互相覆盖,最后只剩一个版本。
解决:所有落盘路径先走safe_out,拒绝越界路径;同名文件用内容哈希加后缀,不要直接覆盖。批量任务里记录“写入、跳过、重命名”三类计数,跑完后对账。文件名编码不确定时,用utf-8加replace先读出来,再人工抽查几条,不要因为一条乱码就全量放弃。
6. 进阶用法:把 NPK 处理接进日常资源流水线
单次解包和回包跑通后,下一步不是写更复杂的 GUI,而是把几个检查点固化成流水线。我一般会保留三个产物:只读探测日志、解包清单manifest.json、往返校验报告。探测日志记录magic、version、count、index_off和端序;清单记录每个资源的原文件名、哈希、压缩标记、原始大小;校验报告记录“原包解包、原样回包、再解包”的逐文件哈希对比。这样换一个样本时,先跑探测,再跑往返,最后才做单文件替换。
| 检查项 | 做法 | 通过标准 |
|---|---|---|
| 只读探测 | 跑probe_npk,看头部字段 | count、index_off落在合理范围 |
| 原样往返 | 解包后不改,直接回包再解包 | 文件数量和逐文件哈希一致 |
| 单文件替换 | 只改一个贴图或配置 | 引擎能加载,日志无资源错误 |
| 批量替换 | 按清单分批写入 | 每批都有跳过和失败记录 |
| 回包对齐 | 检查数据区起点和条目偏移 | 偏移满足样本对齐规则 |
一个具体技巧:不要按文件名排序后直接打包,按原索引顺序写回。很多项目的资源查找逻辑依赖索引顺序,排序后虽然文件都能读,但覆盖优先级变了,UI 可能显示旧贴图。清单里保留原始索引序号,回包时按序号恢复,能避免这类“资源都在但表现不对”的玄学问题。
从那以后我每次改 NPK 都先跑一遍只读探测和往返校验,再谈替换;样本副本、清单、校验报告三件套不齐,就不动原包。希望帮到你。
本文还有配套的精品资源,点击获取