资讯详情

neox-tools NPK 解包与回包:Python 解析索引、偏移与资源替换

📅 2026/11/2 2:28:14 | 华诺云谱 👁 阅读
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_size16结果样本是 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(ffile{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( fendian{endian} magic{magic!r} version{version} fcount{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如果是bNXPK或bNPK0说明方向对了如果四个字节都是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 字节。文件名都读错后面的偏移一定全错。字段常见宽度读法出错表现magic4 字节可读串或二进制标记识别不了文件类型version4 字节无符号整数字段错位后变成超大数count4 字节资源条目数量数量离谱循环直接卡死index_off4 字节索引表起始位置索引越界或读出乱码offset4 字节数据区切片起点导出文件全 0 字节或碎片packed_size4 字节压缩后长度切片过短解压报错raw_size4 字节解压后长度解压成功但文件不完整flags4 字节压缩/加密标记贴图花屏、模型破面读索引时加两个硬检查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 47JPG 开头是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(fcount 异常: {count}) entries read_index(data, index_off, count, endian) base Path(out_dir) base.mkdir(parentsTrue, exist_okTrue) 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(parentsTrue, exist_okTrue) target.write_bytes(raw) ok 1 print(funpack 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, bNXPK, 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 更小但不要盲目追体积。有些引擎对压缩块大小有上限太大可能加载失败有些项目要求固定压缩级别否则回包后的哈希对不上。flags1只是示例表示 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:offsetpacked_size]如果全是 0再试data[data_startoffset:data_startoffsetpacked_size]。如果后者能解出正常文件头就说明偏移是相对数据区。字段顺序反了也好判断packed_size通常小于等于raw_size如果读出来前者远大于后者先把两个字段对调再试。5.2 贴图花屏、模型破面压缩标记位读反了现象文件大小对得上解压也不报错但 PNG 打开是花屏模型加载破面配置文件里夹杂乱码。原因flags的位定义读错或者把 zlib 和 lzma 的分支搞反了。也有可能是raw_size和packed_size用反切片长度不对解压器仍然能吐出一段字节但内容已经错位。解决挑一个已知类型的资源做单文件验证不要一次跑全量。解压后看前 8 字节PNG 是89 50 4E 47 0D 0A 1A 0AJPG 是FF D8 FFDDS 是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 都先跑一遍只读探测和往返校验再谈替换样本副本、清单、校验报告三件套不齐就不动原包。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑