免责声明
本文档仅供个人学习、研究与互操作目的使用,请勿用于商业用途或未经授权的传播。
文档中描述的游戏资源(包括但不限于谱面文件、容器格式与密钥派生算法),其所有权与著作权均归属于游戏原始开发者/发行方,请遵守相关法律法规与用户协议。
因使用本文档所述方法、代码或结论所引发的任何法律后果、数据丢失,或对游戏文件造成的损坏,作者概不负责
在读取、备份或修改任何游戏文件前,请确认您有权这样做!
本文档描述《In Falsus》谱面文件的二进制容器结构与自定义流加密,供需要自己读写 .spc 的工具开发者参考
文中所有结论来自对游戏本体 GameAssembly.dll 的反汇编,并用平坦内存 x86 模拟器直接执行
原函数逐条校验过转写
数据结论的验证规模为 283 张谱面、185,531 条音符记录、5,109 条事件记录
凡属推测而非实测的结论都会明确标注
一、概览
- 文件扩展名为
.spc,容器魔数为ICP1 - 文件由三段构成:头部(28 字节)+ 音符记录(80 字节 × N)+ 事件记录(32 字节 × M)
- 头部与事件记录是明文;音符记录的前 48 字节(
0x00–0x2F)被自定义流密码加密,后 32 字节(0x30–0x4F)是明文 - 加密是流密码:密钥由「资源路径字符串 + 音符数」派生,密钥流按记录序号推进,每条记录解完后再用已解出的字段推进状态
- 文件长度必须恰好等于
28 + N × 80 + M × 32,游戏自身会校验这一点
二、容器结构
头部(28 字节,明文,小端)
| 偏移 | 类型 | 含义 | 取值 |
|---|---|---|---|
0x00 | char[4] | 魔数 | ICP1 |
0x04 | u16 | 格式版本 | 恒为 1 |
0x06 | u16 | 头部大小 | 恒为 28 |
0x08 | u16 | 音符记录大小 | 恒为 80 |
0x0A | u16 | 事件记录大小 | 恒为 32 |
0x0C | u32 | 音符记录数 N | ≤ 1,000,000 |
0x10 | u32 | 事件记录数 M(.summary.json 里叫 u2) | ≤ 100,000 |
0x14 | f32 | 基准 BPM | 如 140.0 |
0x18 | f32 | 拍号(每小节拍数) | 如 4.0 |
音符记录(80 字节 × N,小端)
| 偏移 | 类型 | 加密 | 字段 |
|---|---|---|---|
0x00 | u64 | 是 | 音符序号(恒等于记录下标) |
0x08 | u64 | 是 | 线 ID |
0x10 | u32 | 是 | 段起始毫秒 |
0x14 | u32 | 是 | 段结束毫秒 |
0x18 | u32 | 是 | 标志 A(轨道组 + 类型) |
0x1C | u32 | 是 | 标志 B(方向 / 缓动) |
0x20 | u32 | 是 | 有理数 1 的刻度系数 |
0x24 | u32 | 是 | 有理数 2 的刻度系数 |
0x28 | u32 | 是 | 有理数 3 的刻度系数 |
0x2C | u32 | 是 | 有理数 4 的刻度系数 |
0x30 | u32 | 否 | 起点位置的分子 |
0x34 | u32 | 否 | 起点位置的分母(=细分格数) |
0x38 | u32 | 否 | 终点位置的分子 |
0x3C | u32 | 否 | 终点位置的分母 |
0x40 | u32 | 否 | 起点宽度的分子 |
0x44 | u32 | 否 | 起点宽度的分母 |
0x48 | u32 | 否 | 终点宽度的分子 |
0x4C | u32 | 否 | 终点宽度的分母 |
后 8 个 u32 是四个有理数对:位置/宽度 = 分子 ÷ 分母。文件里两个数会同乘一个系数
(例如 170/680 其实表示 1/4),游戏读入时先按最大公约数约分——第二元即细分格数
事件记录(32 字节 × M,明文,小端)
| 偏移 | 类型 | 含义 |
|---|---|---|
0x00 | u64 | 事件序号 |
0x08 | u32 | 时刻(毫秒) |
0x0C | u32 | 类型(0–3) |
0x10 | u64 | 载荷 1(按类型解释为 double / 整数位组) |
0x18 | u64 | 载荷 2(低 32 位按 IEEE754 float 解释) |
三、加密与密钥
音符记录的 0x00–0x2F 存放的是 明文 + 密钥流(按字段位宽取模);
解析器读入后减去密钥流得到明文
下文 3.3 与第四节都按这个方向写
3.1 密钥种子
seed = 该谱面在 StreamingAssetsMapping 中的 FullLookupPath,含扩展名
例:"alamode0.spc" ← 不是 SongData 里的 chartId "alamode0"这是整个格式里最容易踩的坑:游戏传给解密函数的字符串是资源路径(带扩展名),而不是谱面 ID
3.2 密钥派生
deriveKey(seed, noteCount, v) → 40 字节状态(4 × u64 + u32 计数器)
第三个参数
v在当前游戏版本恒为 1。下文保留v
初始化:
v = 第三个实参,当前恒为 1
st[0] = 0xC4CEB9FE1A85EC53 + noteCount
st[1] = (noteCount << 32) ^ 0x9E3779B185EBCA87
st[2] = 0xD1B54A32D192ED03 + v
st[3] = v ^ 0x94D049BB133111EB
st[4] = 0 # 计数器逐字符混合(rol 为 64 位循环左移,ch 为字符码点):
对 i, ch in enumerate(seed):
rdx = (i << 32) | ch
rdi = 1 ^ rdx
r8 = rdx + noteCount
按 (st[4] & 3) 选下列之一:
0: st[0] += rol(st[3], 7) + rdx
rax = rol(st[0] + r8, 13) ^ st[1]; st[1] = rax
rdi ^= rax; st[2] -= rdi
st[3] = rol(st[3] + st[2], 27) ^ st[0]
1: st[1] = rol(st[0], 9) + st[1] + rdi
rcx = rol(st[1] + rdx, 19) ^ st[2]; st[2] = rcx
r8 ^= rcx; st[3] -= r8
st[0] = rol(st[0] + st[3], 31) ^ st[1]
2: st[2] = rol(st[1], 15) + r8 + st[2]
rcx = rol(st[2] + rdi, 23) ^ st[3]; rdx ^= rcx; st[3] = rcx
st[0] -= rdx
st[1] = rol(st[1] + st[0], 39) ^ st[2]
3: keep = st[2]
st[3] = rol(st[2], 21) + st[3] + rdx
rcx = rol(st[3] + r8, 29); st[0] ^= rcx; rdi ^= st[0]
st[1] -= rdi
st[2] = rol(keep + st[1], 43) ^ st[3]
st[4] += 1注意:3.2 这段是
deriveKey内部的逐字符混合,与 3.4 的advance/finalize不是同一个函数
两者分支结构相似,但输入变量不同(这里是rdx / rdi / r8,3.4 是x / y / z)
不要复用同一段伪代码
收尾:再调用一次状态推进(见 3.4),参数为:
advance(st, x=noteCount, y=v, z=len(seed))易错点 1:收尾这一步的
y是第三个实参v(当前为 1),不是循环最后一次迭代的rdi。原因见下条易错点 2:循环头是「先把
rdi重置为v,再判断长度」,所以退出循环时rdi已被重置。同理,字符循环 variant 1 的最后一步旋转是rol(..., 31)(shl 31/shr 33),不是 33
这两处写错都会让所有非空种子的结果全错,且只表现为”解出来是乱码”
3.3 密钥流生成
GEN(state, idx, k),idx = 记录序号,k = 字段号(0–9):
r9 = (idx << 32) | k
按 (k & 3):
0: rol(r9 ^ st[2], 11) + st[0] ^ st[3]
1: st[1] - rol(st[3] + r9, 17) ^ st[0]
2: rol(r9 ^ st[0], 29) + st[2] ^ st[1]
3: rol(st[2] + st[1] + r9, 37) ^ st[3]字段号与记录偏移的对应:
k=0 → 0x00 (u64) k=1 → 0x08 (u64)
k=2 → 0x10 k=3 → 0x14 k=4 → 0x18 k=5 → 0x1C
k=6 → 0x20 k=7 → 0x24 k=8 → 0x28 k=9 → 0x2C解密:明文 = 小端存储值 − GEN(state, 记录序号, k)(按字段位宽取模)
3.4 状态推进
每解完一条记录,用已解出的字段推进状态(同一函数,4 分支,按计数器低 2 位选择)
为便于阅读,下面把三个实参统一记作 x(原 rdx)、y(原 r8)、z(原 r9):
advance(S, x, y, z):
0: S[0] += rol(S[3], 7) + x
t = rol(S[0] + y, 13) ^ S[1]; S[1] = t
z ^= S[1]; S[2] -= z
S[3] = rol(S[3] + S[2], 27) ^ S[0]
1: S[1] = rol(S[0], 9) + S[1] + y
t = rol(S[1] + x, 19) ^ S[2]; S[2] = t
y ^= S[2]; S[3] -= y
S[0] = rol(S[0] + S[3], 31) ^ S[1]
2: S[2] = rol(S[1], 15) + S[2] + y
t = rol(S[2] + x, 23) ^ S[3]; S[3] = t
x ^= S[3]; S[0] -= x
S[1] = rol(S[1] + S[0], 39) ^ S[2]
3: keep = S[2]
S[3] = rol(S[2], 21) + S[3] + x
t = rol(S[3] + y, 29); S[0] ^= t
t2 = S[0] ^ z; S[1] -= t2
S[2] = rol(keep + S[1], 43) ^ S[3]
S[4] += 1注意:分支 3 里
z只被读、不被写;x/y会被原地修改
这点与 3.2 的 variant 3 略有差异,以 3.4 为准
每条记录调用 3 次:
advance(S, x=字段0, y=字段1, z=(字段3 << 32) | 字段2)
advance(S, x=字段4, y=字段5, z=(b1b << 32) | b1a)
advance(S, x=(b2b<<32)|b2a, y=(b3b<<32)|b3a, z=(b4b<<32)|b4a)其中 b1..b4 由明文四个有理数与刻度系数算出(gcd = 最大公约数)
四组有理数顺序为起点位置、终点位置、起点宽度、终点宽度,分别用字段 6/7/8/9 作缩放系数:
g1 = gcd(分子1, 分母1)
b1a = (分子1 / g1) * 字段6
b1b = (分母1 / g1) * 字段6
g2 = gcd(分子2, 分母2)
b2a = (分子2 / g2) * 字段7
b2b = (分母2 / g2) * 字段7
g3 = gcd(分子3, 分母3)
b3a = (分子3 / g3) * 字段8
b3b = (分母3 / g3) * 字段8
g4 = gcd(分子4, 分母4)
b4a = (分子4 / g4) * 字段9
b4b = (分母4 / g4) * 字段9这一步的用途:把「位置 / 细分格数」归一化成游戏内部用的整数刻度,并把它混进密钥流状态
也就是说每条记录的密钥流都依赖前面所有记录的明文,必须顺序解码,不能并行
四、解析流程
1. 读入整个文件 → data
2. 校验 data[0:4] == "ICP1"; 读头部得到 N、M、bpm、meter
3. 校验 len(data) == 28 + N*80 + M*32
4. seed = 该资源的 FullLookupPath(含扩展名,如 "alamode0.spc")
5. state = deriveKey(seed, N, 1)
6. for i in 0..N-1:
rec = data[28 + i*80 : 28 + (i+1)*80]
for k, (偏移, 位宽) in 字段表:
明文[k] = 小端整数(rec[偏移 : 偏移+位宽]) - GEN(state, i, k)
# 明文[0..9] 为 序号/线ID/起止时间/标志A/标志B/四个刻度系数
# rec[0x30:0x50] 直接读四个有理数, 不经解密
state = advance(state, ...) # 3 次
7. 解 M 条事件(明文,直接读 32 字节 × M)
8. 用头部 bpm 与 type 1 事件构建 BPM 时间轴,折算各音符拍号五、校验与已知边界
5.1 可用的自校验
- 音符序号恒等于记录下标:全量 185,531 条 0 例外。密钥或转写有误时该字段会变成随机值,这是最灵敏的判据
- 头部四个常量字段(版本 1 / 头部 28 / 记录 80 / 事件 32)与文件长度关系
- 事件时间字段完全单调
5.2 用 x86 模拟器校验转写
直接在平坦内存上执行 GameAssembly.dll 里那几个函数(deriveKey / GEN / finalize),String.get_Chars 用回调桩接出
它需要 capstone 与 DLL 字节,适合逆向校验,不是普通使用的入口:
from icp_emu import Emu
emu = Emu(open(dll_path, 'rb').read())
state = emu.derive_key('alamode0.spc', 132, 1) # 种子带 .spc
ks = emu.gen(state, 0, 0)用它逐函数比对的结论:
GEN:40 组(idx, k)全部一致finalize(即本文的 advance):12 组(计数器 0–3 × 3 组参数)全部一致deriveKey:33 组(种子长度 0–10 × noteCount 132/307/1)全部一致
最终定位到两处转写错误,都在”看起来没问题”的地方:
- 字符循环 variant 1 的最后一步是
rol(..., 31)(shl 31/shr 33),曾误写成 33 - 收尾
advance的y是第三个实参v(当前为 1),不是循环最后一次迭代的rdi
5.3 逆向时的坑
- PE 段映射:il2cpp 段(含本格式的解析器)的「文件偏移 → VA」与
.text段不同,且段偏移会随.text大小变化
换算必须查 PE 段表:fo = VA - ImageBase - 段.VirtualAddress + 段.PointerToRawData手算极易出错,建议直接用verify_re.py里的pe_layout() - 反汇编起点:从函数中间开始反汇编会得到看似合理但错误的指令(例如把
49 83 c6 18(add r14, 0x18)的尾部读成add esi, 0x18)
务必用.pdata确认函数边界 - 别靠目视核对转写:
rol位数、状态读写顺序这类错误只表现为”解出来是乱码”写一个只解释所需指令的平坦内存 x86 解释器直接执行原函数,再逐函数比对,一次就能定位
注意解释器自身要正确处理标志位(and/sub等必须更新 ZF/OF/CF),否则分支判断全错 - VA 更新会漂移:本版本对应游戏 1.0.4b / build 25416896,该区段整体 −0x1000:
ICP1 读取0x1805337E0(魔数校验在0x180533D2B)、记录解码0x1805329E0、密钥流0x180524470、状态推进0x1805242E0、密钥派生0x180535320、谱面加载0x180537680;String.get_Chars是0x181860D70(本次位移 −0x67F0,与主区段不同)
5.4 尚未确定
- 事件 type 2 的确切语义(载荷形状已确定:一个滚动位置 + 一个倍率)。
- 尾部事件类型与游戏内部事件名的完整对应(type 0/1/3 已可对应到
track/bpm/lane)