资讯动态

NeoX NPK解包实战:从二进制结构到Python工具链

发布时间:2026/10/14 13:30:44 来源:尧图企业网站定制
简介这份资源是面向游戏逆向与资源提取爱好者的 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字节魔数常见为 bNPK\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(fmagic{magic}, version{version}, count{file_count}) print(findex_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(fduplicate: {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 这类格式没有官方文档全靠一点点试慢就是快。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑