☰
NeoX NPK解包实战:从二进制结构到Python工具链
2026/10/14 13:30:34 网站建设 项目流程

简介:这份资源是面向游戏逆向与资源提取爱好者的 NeoX NPK 工具集,主要解决网易 NeoX 引擎中 NPK 打包文件的解析与解压问题,目前支持 2020 年初版本 Eve Echoes 所使用的格式。包内共 15 个文件,以 Python 脚本为主,辅以 Rust 源码、Cargo 配置、Shell 脚本及说明文档,压缩包约 24KB,体量轻便。工具能够列出 NPK 内部文件清单,并借助 LZ4 完成解压缩,同时会为每个文件推测 MIME 类型并补全合适的扩展名,映射虽有限但基本覆盖 Eve Echoes 中的常见资源。作者还规划了 RC4 加密、简单加密、ZLib 支持以及 NPK 重新打包等后续功能。目前已有 5063 人学习下载,适合想研究 NeoX 资源结构、需要批量提取素材或为后续加解密扩展打基础的开发者参考,也可作为理解 NPK 容器格式与脚本解包流程的实践样本。

1. 拆包之前:NeoX NPK 到底是个什么东西

如果你手里有一份从某款基于 NeoX 引擎的游戏里扒出来的资源目录,里面躺着一堆.npk后缀的文件,双击打不开、改后缀也没用,那你大概率正卡在同一个问题上:这些 NPK 不是压缩包,而是 NeoX 引擎自己定义的一套资源归档格式。它把贴图、模型、音频、脚本按索引表塞进一个大文件里,头部有魔数、有版本号、有文件计数,尾部或者头部某处还挂着一张偏移表。想拿到里面的东西,要么用引擎自己的加载器,要么自己按格式把索引读出来。

neox-tools 这个方向,说白了就是围绕这套 NPK 归档做读、解、改、回写的一整套工具链。它解决的不是「怎么玩游戏」,而是「怎么把游戏资源变成我能用的素材」——做二次创作的要贴图,做数据分析的要配置表,做本地化的要文本。适合谁?适合已经会一点 Python、能看懂二进制结构、愿意对着十六进制编辑器啃格式的人。新手也能跟,但前提是你得接受「先看懂字节,再谈工具」这个顺序。

我见过太多人一上来就找现成的一键解包器,结果版本一对不上就翻车。NPK 的格式在不同引擎版本之间有差异,索引表的字段顺序、是否加密、是否压缩,都会变。所以这篇不给你一个「万能 exe」,而是把结构讲清楚,再给你能自己改的脚本。这才是能复现的路子。

2. 把 NPK 的二进制结构读明白:从魔数到索引表

2.1 先用十六进制看清头部布局

任何二进制格式的第一步都是看头。拿一个 NPK 文件,用xxd或者 HxD 打开前 256 个字节,你会看到类似这样的东西:开头几个字节是固定的魔数,接着是版本号,然后是一个表示文件数量的整数,再往后是索引区的偏移和长度。不同版本魔数可能不同,但「魔数 + 版本 + 计数 + 偏移」这个骨架基本稳定。

我一般会先写一个最小的探测脚本,把头部字段按小端序读出来,确认这个文件到底属于哪个版本分支。不要急着解全部,先把头部读对,后面才有意义。

import struct def probe_npk_header(path): with open(path, "rb") as f: head = f.read(64) # 前4字节魔数,常见为 b'NPK\x00' 或类似变体,按实际文件调整 magic = head[0:4] # 版本号,小端无符号整型 version = struct.unpack_from("<I", head, 4)[0] # 文件条目数量 file_count = struct.unpack_from("<I", head, 8)[0] # 索引区起始偏移 index_offset = struct.unpack_from("<I", head, 12)[0] # 索引区长度 index_size = struct.unpack_from("<I", head, 16)[0] print(f"magic={magic}, version={version}, count={file_count}") print(f"index_offset={index_offset}, index_size={index_size}") return version, file_count, index_offset, index_size probe_npk_header("sample.npk")

这段代码的逻辑很直白:按固定偏移把头部字段抠出来。参数上要注意两点,一是字节序,NeoX 系资源大多是小端,但个别平台版本可能不同,读出来是天文数字就说明字节序反了;二是魔数不要写死,先打印出来看,再决定匹配规则。index_offset和index_size是后面定位索引表的关键,读错这两个,后面全错。

2.2 索引表里每条记录都存了什么

头部读完,真正的信息在索引表。索引表本质是一个数组,每个元素对应归档里的一个文件。常见字段包括:文件名的哈希或者偏移、文件在数据区的偏移、压缩后大小、原始大小、压缩标志、加密标志。注意,很多 NPK 不直接存明文文件名,而是存哈希,这就意味着你解出来的文件可能没有名字,只有一串 ID。

这一步的选型理由很简单:你是要「按名字找文件」还是「全量导出」?如果要按名字,就得先有一张哈希到名字的映射表,这张表通常来自游戏客户端里的另一份清单文件。如果没有,那就只能全量导出再靠内容识别。我一般建议先全量导出,别在名字上死磕。

def read_index(path, index_offset, file_count): entries = [] with open(path, "rb") as f: f.seek(index_offset) for i in range(file_count): raw = f.read(32) # 每条记录按32字节估,实际按版本调整 if len(raw) < 32: break name_hash = struct.unpack_from("<I", raw, 0)[0] data_offset = struct.unpack_from("<I", raw, 4)[0] comp_size = struct.unpack_from("<I", raw, 8)[0] orig_size = struct.unpack_from("<I", raw, 12)[0] flags = struct.unpack_from("<I", raw, 16)[0] entries.append({ "hash": name_hash, "offset": data_offset, "comp_size": comp_size, "orig_size": orig_size, "flags": flags, }) return entries

每条记录 32 字节只是常见情况,实际要以你探测到的版本为准。flags是重点,它往往用位来表示是否压缩、是否加密。比如某一位为 1 表示 zlib 压缩,另一位为 1 表示做了异或。读索引的时候先把 flags 原样存下来,别急着判断,等导出时再按位解析。这样即使遇到没见过的标志位,也不会直接崩掉。

2.3 数据区读取与解压的最小闭环

有了索引,导出就是「按 offset 读 comp_size 字节,再按 flags 决定要不要解压」。这一步最容易踩的坑是:comp_size和orig_size相等时不一定没压缩,有些版本即使相等也带压缩头。稳妥做法是先看 flags,再尝试解压,解压失败就按原始数据写出。

import zlib def extract_entry(path, entry, out_dir): with open(path, "rb") as f: f.seek(entry["offset"]) blob = f.read(entry["comp_size"]) # 按 flags 判断压缩,这里假设 bit0 为 zlib 压缩标志 if entry["flags"] & 0x1: try: blob = zlib.decompress(blob) except zlib.error: # 解压失败说明标志判断有误,保留原始数据 pass out_name = f"{entry['hash']:08x}.bin" with open(f"{out_dir}/{out_name}", "wb") as w: w.write(blob)

逻辑说明:先按偏移和长度精确读取,避免多读或少读;解压用 try 包住,是因为 flags 的位定义可能和你的假设不一致,硬解会直接抛异常中断整个导出。参数上,out_name用哈希命名只是权宜之计,后面可以靠文件头特征(比如 PNG 的\x89PNG、DDS 的DDS)批量改回正确后缀。这一步跑通,你就已经能拿到大部分资源了。

3. 用 Python 写一个能复用的 NPK 解包器

3.1 工程结构怎么分才不乱

一次性脚本能跑,但版本一多就失控。我一般会把解包器拆成三层:格式层负责读头部和索引,解码层负责解压和解密,导出层负责落盘和命名。这样换一个版本,只需要改格式层的字段偏移,解码和导出不用动。目录上就是npk/format.py、npk/codec.py、npk/export.py,再加一个cli.py做入口。

为什么不写成一个大文件?因为 NPK 的变体太多,你迟早会遇到「同一个游戏不同版本字段不一样」的情况。分层之后,你可以为每个版本写一个 format 子类,用版本号做分发。这是能长期用的做法,不是一次性玩具。

3.2 版本分发与字段偏移的配置化

把字段偏移写成配置,而不是硬编码在代码里,是减少翻车的关键。我一般用一个字典描述每个版本的头部布局和索引记录布局,读的时候按配置取。

VERSION_LAYOUTS = { 1: { "header": {"magic": (0, 4), "version": (4, 4), "count": (8, 4), "index_offset": (12, 4), "index_size": (16, 4)}, "entry_size": 32, "entry": {"hash": (0, 4), "offset": (4, 4), "comp_size": (8, 4), "orig_size": (12, 4), "flags": (16, 4)}, }, 2: { "header": {"magic": (0, 4), "version": (4, 4), "count": (8, 4), "index_offset": (12, 4), "index_size": (16, 4)}, "entry_size": 40, # 新版本多了字段 "entry": {"hash": (0, 4), "offset": (8, 4), "comp_size": (12, 4), "orig_size": (16, 4), "flags": (20, 4)}, }, }

这样写的好处是,遇到新版本你只需要加一条配置,而不是去改一堆struct.unpack_from的偏移数字。参数说明:entry_size决定每次读多少字节,entry里的元组是「字段起始偏移, 长度」。注意不同版本字段之间可能有保留位,不要假设字段是紧挨着的,按实际探测结果填。

3.3 批量导出时的命名与去重策略

全量导出最烦的是命名。哈希命名可读性差,但至少不冲突。我的做法是两遍:第一遍按哈希导出到临时目录,同时记录每个文件的头部特征;第二遍按特征映射后缀,并把哈希重命名成hash.ext。如果游戏客户端里有清单文件,就再叠加一层哈希到真实路径的映射。

去重方面,NPK 里可能存在内容相同但哈希不同的条目,直接按哈希导出会有重复文件。可以用内容摘要做二次去重,但要注意,有些资源虽然内容相同,路径不同,游戏里是两份用途,去重前先确认你的目标是不是「只要一份」。做素材库可以去重,做完整性校验就不能去。

import hashlib def dedup_by_content(files): seen = {} for p in files: with open(p, "rb") as f: digest = hashlib.md5(f.read()).hexdigest() if digest in seen: print(f"duplicate: {p} == {seen[digest]}") else: seen[digest] = p return seen

这段只是检测,不自动删。参数上,摘要算法用 md5 够快,做去重判断足够;如果你要防碰撞,换 sha256,但大文件全量读会慢,可以只读前 1MB 加文件大小做近似判断。这一步的取舍取决于你是要速度还是要绝对准确。

4. 避坑与排查:NPK 解包最常见的 5 个翻车现场

4.1 解出来的文件全是乱码

现象:导出后打开是乱码,文件头也不对。原因通常是压缩标志判断错了,或者数据区偏移整体偏了一个头部长度。解决:先用十六进制看导出文件的前 16 字节,如果既不是常见图片头也不是文本,回到索引表核对offset是否包含了头部长度。有些版本的数据偏移是相对文件开头的绝对值,有些是相对数据区起点的相对值,差一个index_offset + index_size就会全错。

4.2 索引读出来的数量对不上

现象:file_count显示几千,实际只读出几十条就断了。原因多半是entry_size估错,读少了或读多了导致越界。解决:用index_size / file_count反推每条记录的真实大小,如果除不尽,说明索引区里还有额外的表头或对齐填充。先把这个除法结果打印出来,再调整entry_size。

4.3 部分文件解压报错但大部分正常

现象:批量解压时个别条目抛zlib.error。原因可能是这些条目根本没压缩,但 flags 位被误判;也可能是加密条目被当成压缩处理。解决:把报错条目的 flags 单独打印出来,和正常条目对比,找出差异位。对加密条目,先确认加密算法(常见是简单异或或按块异或),不要硬解。

4.4 回写后游戏读不出来

现象:改完资源重新打包,游戏加载失败或崩溃。原因通常是回写时没有更新索引里的comp_size和orig_size,或者数据区对齐没保持。解决:回写必须重算每条记录的偏移和大小,并且保持和原格式一致的对齐规则。如果原格式要求数据区按 16 字节对齐,你写的时候也要补零对齐,否则引擎按对齐读就会错位。

4.5 不同版本混用同一套偏移

现象:A 版本能解,B 版本解出来全是垃圾。原因就是字段偏移不同,却用了同一套硬编码。解决:回到 3.2 的配置化思路,先探测版本号,再按版本取布局。探测不准的时候,用「魔数 + 头部几个整数字段是否落在合理范围」做交叉验证,比如index_offset应该小于文件大小,file_count不应该超过一个合理上限。

5. 进阶:把解包器变成可回写的资源管线

走到能稳定导出,其实只完成了一半。真正有价值的是回写——改一张贴图、换一段文本,再塞回去让游戏能读。回写的核心是「重建索引 + 重算偏移 + 保持对齐」。我一般会先把所有条目读进内存,替换掉目标条目,然后按顺序重新计算每个条目的数据偏移,最后一次性写出头部、索引和数据区。

这里有个具体技巧:不要在原文件上原地改,先生成一个新文件,校验通过再替换。因为回写一旦偏移算错,原文件也废了,没有后悔药。校验方法很简单,用你自己的解包器再解一遍新文件,对比条目数量和关键文件的内容摘要,一致才算过。

另一个技巧是保留原始 flags。很多人回写时把 flags 清零,结果原本压缩的条目变成未压缩,引擎按压缩标志去读就崩了。正确做法是:如果替换后的数据你重新压缩了,就保持压缩标志并更新comp_size;如果没压缩,就把压缩标志清掉并让comp_size等于orig_size。这两者必须自洽。

我自己的习惯是,每支持一个新版本,先写一个「只读校验」脚本,把头部、索引、抽样数据三层都打印出来,确认无误再写导出。这个习惯帮我省了无数次返工。NPK 这类格式没有官方文档,全靠一点点试,慢就是快。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询