NEKOPARA Vol.1 (Steam) 加密 XP3 离线解包:分析与解决方案
一、问题
游戏目录下 9 个 .xp3(data / patch / patch2 / voice / adultsonly / emote /
emotewin / emotewin_low / evimage)全部加密,现有工具无法离线提取:
-
KrkrExtract:可解,但是是运行时
-
GARbro:能识别魔数、能解析索引,但解密方案表(
KnownSchemes)为空,选不到 任何方案;且内置 SmileCrypt 与本作有三处差异(见 2.3),手动指定也解不对。
二、分析
2.1 封包结构
-
头部为标准 Krkrz 11 字节魔数
XP3\r\n \n\x1a\x8b\x67\x01+ 8 字节索引偏移; -
索引为链式块:1 字节 flag,
flag&7==1为 zlib 压缩块,flag&7==0为原始块,flag&0x80置位则块尾跟下一个索引链接; -
索引条目分两类:
File(内含adlr内容哈希、segm分段、info加密标志) 与eliF(hash → 游戏内路径的映射); -
分段读取:
bZlib&1置位先 zlib 解压,再对完整流解密; -
关键发现:
adlr就是明文内容的 Adler-32,因此解密是否正确可以逐文件硬校验。
2.2 加密算法
内容为整文件单字节 XOR 流,密钥由 adlr 派生:
fold4 = (adlr ^ (adlr >> 8) ^ (adlr >> 16) ^ (adlr >> 24)) & 0xFF
key = fold4 ^ 0x1A
if key == 0:
key = 0x23
要点:
-
首字节无额外处理;
-
key==0时回退0x23(与 GARbro SmileCrypt 的 zero_xor 同款规则); -
个别条目带加密标志但内容为明文(如部分
startup.tjs),脚本先比原始数据 Adler-32 是否等于adlr,不等才解密,无需硬编码文件名; -
解密后
zlib.adler32(明文) & 0xFFFFFFFF == adlr对全部文件成立。
2.3 证据与 GARbro 差异
索引读取与标准 Krkrz 源码(krkrz/base/XP3Archive.cpp)一致:
// flag&7==1: I64 压缩大小 + I64 原始大小 + zlib 数据
// flag&7==0: I64 大小 + 原始数据
// flag&0x80: 块尾跟下一个索引链接
if (!(index_flag & TVP_XP3_INDEX_CONTINUE)) break;
GARbro 的 SmileCrypt(ArcFormats/KiriKiri/CryptAlgorithms.cs)核心:
uint hash = entry.Hash ^ m_key_xor;
byte key = (byte)(hash ^ (hash >> 8) ^ (hash >> 16) ^ (hash >> 24));
if (0 == key) key = m_zero_xor;
if (0 == offset && count > 0) {
if (0 == (hash & 0xFF)) hash = m_first_xor;
values[pos] ^= (byte)hash; // 首字节额外异或
}
for (int i = 0; i < count; ++i) values[pos+i] ^= key;
与本作的差异就三条:流密钥多异或 0x1A、没有首字节额外异或、zero 回退为
0x23。这就是"手动指定 SmileCrypt 也解不对"的原因。
另外已在脱壳 exe 的 .text 中确认容器层代码(0x00435d10 魔数校验:按 4+4+2+1
读取 58 50 33 0D | 0A 20 0A 1A | 8B 67 | 01,与 2.1 一致);过滤器本体位于
运行时解密的 .bind 打包区(熵≈8),无法静态提取,但算法正确性由全量校验独立
证明,不依赖该段代码。
三、解决方案
脚本 nekopara_xp3_unpack.py(见附录)纯 Python 标准库,零依赖。用法:
python nekopara_xp3_unpack.py "<NEKOPARA Vol.1 游戏安装目录>" <输出目录> --verify
-
--verify:逐文件 Adler-32 复核(>64MB 条目跳过校验但仍写出); -
--no-write:只校验不落盘; -
输出按游戏内路径还原,如
asset/bgm/bgm_01.ogg、asset/image/*.png; -
超长文件名自动截断,Windows 下兼容长路径;
-
实测:data.xp3 全量解包(约 1.2GB)数秒,全部封包校验约 1 分钟内。
验证结果(2026-08 实测):9 个封包 8051 个文件全部通过 adler32(明文)==adlr,
零失败;OGG/PNG/WMV 均为标准格式可直接打开。
注意:
-
每个封包带
.sig(SHA256/PSS/RSA)完整性签名,提取不受影响 -
封包用kirikiritools,封包请用此工具,常规封包会破坏sig
-
本方案针对 Vol.1;0 / 2 / Extra 同为 SmileCrypt 家族但参数不同(存在首字节 特判),需按同法重新拟合常量。
附录:完整脚本
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
NEKOPARA Vol.1 (Steam) 加密 XP3 离线解包器
逆向结论(全部通过 adler32 全量校验):
* 9 个 .xp3 全部使用 wamsoft 魔改 Krkrz 的 XP3 变体:
11 字节标准魔数 "XP3\\r\\n \\n\\x1a\\x8b\\x67\\x01" + 8 字节小端 IndexOffset,
魔改体现在链式索引编排与内容加密过滤器
* 索引: 1 字节 flag + 大小字段 + 索引数据, 按 flag 位解释:
flag&7 == 1 (ZLIB): I64 压缩大小 + I64 原始大小 + zlib 数据
flag&7 == 0 (RAW) : I64 大小 + 原始数据
flag&0x80 置位 : 本块后面跟下一个索引链接, 链式继续读
索引解压后按 "File"/"eliF" 大块解析:
- File: 内嵌 adlr(hash), segm(每段 28 字节: bZlib:4+Offset:8+OrigSize:8+ArcSize:8), info
- eliF: Magic+Size+Hash(4)+NameLen(2)+UTF-16 虚拟路径, 与 File.adlr 对应
* 内容加密: 单字节 XOR 流, 密钥由内容 adler32(即索引里的 adlr) 派生:
key = (adlr ^ (adlr>>8) ^ (adlr>>16) ^ (adlr>>24)) & 0xFF ^ 0x1A
if key == 0: key = 0x23 # 空密钥回退, 与 GARbro SmileCrypt 同款规则
首字节无额外处理; startup.tjs 按明文处理(引擎惯例)。
* 解密后 adler32(明文) == 索引中的 adlr, 本脚本 --verify 模式逐文件复核。
用法:
python nekopara_xp3_unpack.py <game目录或.xp3路径> <输出目录> [--verify] [--no-write]
"""
import hashlib, os, re, struct, sys, zlib
MAGIC = b"XP3\r\n \n\x1a\x8b\x67\x01"
MAX_VERIFY = 64 * 1024 * 1024 # 校验模式跳过超大文件的字节数上限
XOR_TABLES = [bytes(b ^ k for b in range(256)) for k in range(256)]
def r64(b, o):
return struct.unpack_from("<Q", b, o)[0]
def read_index_blob(path, base):
"""返回解压后的索引字节串"""
with open(path, "rb") as f:
f.seek(base + len(MAGIC))
link = struct.unpack("<Q", f.read(8))[0]
f.seek(base + link)
chunks = []
while True:
flag = f.read(1)
if len(flag) < 1:
break
flag = flag[0]
method = flag & 7
if method == 1: # ZLIB: 压缩大小 + 原始大小 + 数据
comp_size, orig_size = struct.unpack("<QQ", f.read(16))
chunks.append(zlib.decompress(f.read(comp_size)))
elif method == 0: # RAW: 大小 + 数据
size = struct.unpack("<Q", f.read(8))[0]
chunks.append(f.read(size))
else:
raise ValueError(f"未知索引编码方法 flag={flag:#x}")
if not (flag & 0x80): # 无 CONTINUE 位则结束
break
link = struct.unpack("<Q", f.read(8))[0] # 链式: 块尾的下一个索引链接
f.seek(base + link)
return b"".join(chunks)
def parse_index(index):
entries, name_map = [], {}
pos = 0
while pos + 12 <= len(index):
magic = index[pos:pos+4]
size = r64(index, pos+4)
payload = index[pos+12:pos+12+size]
pos += 12 + size
if magic == b"File":
q, adlr, segs, enc = 0, None, [], None
while q + 12 <= len(payload):
m = payload[q:q+4]
s = r64(payload, q+4)
p = payload[q+12:q+12+s]
q += 12 + s
if m == b"adlr":
adlr = struct.unpack_from("<I", p)[0]
elif m == b"segm":
for off in range(0, s, 28):
bz, ofs, osize, asize = struct.unpack_from("<IQQQ", p, off)
segs.append((bz, ofs, osize, asize))
elif m == b"info" and s >= 22:
enc = struct.unpack_from("<I", p)[0]
nlen = struct.unpack_from("<H", p, 20)[0]
nm = p[22:22+nlen*2].decode("utf-16-le", "replace")
if adlr is not None and segs:
entries.append([adlr, segs, nm or "", enc])
elif magic == b"eliF" and size >= 6:
h = struct.unpack_from("<I", payload, 0)[0]
nlen = struct.unpack_from("<H", payload, 4)[0]
name_map[h] = payload[6:6+nlen*2].decode("utf-16-le", "replace")
return entries, name_map
def stream_key(adlr):
k = (adlr ^ (adlr >> 8) ^ (adlr >> 16) ^ (adlr >> 24)) & 0xFF
k ^= 0x1A
return k if k else 0x23
def safe_name(name, root="asset"):
name = name.replace("\\", "/").replace("%", "_")
parts = []
for x in name.split("/"):
x = re.sub(r'[<>:"|?*]', "_", x)
if len(x) > 180: # 规避 Windows 255 字符组件上限
stem, ext = os.path.splitext(x)
x = stem[:150] + "_" + hashlib.md5(x.encode("utf-8")).hexdigest()[:8] + ext[:20]
parts.append(x)
return os.path.join(root, *parts) if parts else os.path.join(root, "_")
def extract(path, outdir, verify=False, write=True):
with open(path, "rb") as f:
head = f.read(len(MAGIC))
if head != MAGIC:
raise ValueError(f"{path}: 不是 NEKOPARA 自定义 XP3 (魔数不符)")
entries, name_map = parse_index(read_index_blob(path, 0))
print(f"[{os.path.basename(path)}] 条目 {len(entries)} 个")
ok = bad = skipped = 0
for i, (adlr, segs, nm, enc) in enumerate(entries):
real = name_map.get(adlr, nm)
if not real:
real = f"unnamed_{i:04d}"
total = sum(a for (_, _, _, a) in segs)
verify_entry = verify and total <= MAX_VERIFY
if verify and total > MAX_VERIFY:
skipped += 1
chunks = []
for (bz, ofs, osize, asize) in segs:
f.seek(ofs)
raw = f.read(asize)
if bz & 1:
raw = zlib.decompress(raw)
chunks.append(raw)
data = b"".join(chunks)
if enc:
# 先试原始数据(引擎有明文特例, 如部分 startup.tjs),
# adler32 不等于 adlr 才按公式解密
if zlib.adler32(data) & 0xFFFFFFFF != adlr:
data = data.translate(XOR_TABLES[stream_key(adlr)])
if verify_entry and enc:
if zlib.adler32(data) & 0xFFFFFFFF == adlr:
ok += 1
else:
bad += 1
print(f" FAIL adler32: {real} (hash={adlr:08X})")
if write:
out = os.path.join(outdir, safe_name(real))
if os.name == "nt":
out = "\\\\?\\" + os.path.abspath(out)
os.makedirs(os.path.dirname(out), exist_ok=True)
with open(out, "wb") as o:
o.write(data)
mode = "校验通过 %d, 失败 %d, 跳过 %d" % (ok, bad, skipped) if verify else "提取完成"
print(f"[{os.path.basename(path)}] {mode}")
def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")]
verify = "--verify" in sys.argv
write = "--no-write" not in sys.argv
if len(args) < 2:
print(__doc__)
sys.exit(1)
src, out = args[0], args[1]
os.makedirs(out, exist_ok=True)
if os.path.isdir(src):
for name in sorted(os.listdir(src)):
if name.lower().endswith(".xp3"):
try:
extract(os.path.join(src, name), out, verify, write)
except Exception as e:
print(f"[{name}] 错误: {e}")
else:
extract(src, out, verify, write)
if __name__ == "__main__":
main()
本文仅作格式研究与学习交流用途
