In Falsus 谱面文件解密与容器格式

免责声明

本文档仅供个人学习、研究与互操作目的使用,请勿用于商业用途或未经授权的传播。

文档中描述的游戏资源(包括但不限于谱面文件、容器格式与密钥派生算法),其所有权与著作权均归属于游戏原始开发者/发行方,请遵守相关法律法规与用户协议。

因使用本文档所述方法、代码或结论所引发的任何法律后果、数据丢失,或对游戏文件造成的损坏,作者概不负责

在读取、备份或修改任何游戏文件前,请确认您有权这样做!

本文档描述《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 字节,明文,小端)

偏移类型含义取值
0x00char[4]魔数ICP1
0x04u16格式版本恒为 1
0x06u16头部大小恒为 28
0x08u16音符记录大小恒为 80
0x0Au16事件记录大小恒为 32
0x0Cu32音符记录数 N≤ 1,000,000
0x10u32事件记录数 M(.summary.json 里叫 u2)≤ 100,000
0x14f32基准 BPM如 140.0
0x18f32拍号(每小节拍数)如 4.0

音符记录(80 字节 × N,小端)

偏移类型加密字段
0x00u64是音符序号(恒等于记录下标)
0x08u64是线 ID
0x10u32是段起始毫秒
0x14u32是段结束毫秒
0x18u32是标志 A(轨道组 + 类型)
0x1Cu32是标志 B(方向 / 缓动)
0x20u32是有理数 1 的刻度系数
0x24u32是有理数 2 的刻度系数
0x28u32是有理数 3 的刻度系数
0x2Cu32是有理数 4 的刻度系数
0x30u32否起点位置的分子
0x34u32否起点位置的分母(=细分格数)
0x38u32否终点位置的分子
0x3Cu32否终点位置的分母
0x40u32否起点宽度的分子
0x44u32否起点宽度的分母
0x48u32否终点宽度的分子
0x4Cu32否终点宽度的分母

后 8 个 u32 是四个有理数对:位置/宽度 = 分子 ÷ 分母。文件里两个数会同乘一个系数
(例如 170/680 其实表示 1/4),游戏读入时先按最大公约数约分——第二元即细分格数

事件记录(32 字节 × M,明文,小端)

偏移类型含义
0x00u64事件序号
0x08u32时刻(毫秒)
0x0Cu32类型(0–3)
0x10u64载荷 1(按类型解释为 double / 整数位组)
0x18u64载荷 2(低 32 位按 IEEE754 float 解释)

三、加密与密钥

音符记录的 0x00–0x2F 存放的是 明文 + 密钥流(按字段位宽取模);
解析器读入后减去密钥流得到明文

下文 3.3 与第四节都按这个方向写

3.1 密钥种子

Python
seed = 该谱面在 StreamingAssetsMapping 中的 FullLookupPath,含扩展名
  例:"alamode0.spc"     ← 不是 SongData 里的 chartId "alamode0"

这是整个格式里最容易踩的坑:游戏传给解密函数的字符串是资源路径(带扩展名),而不是谱面 ID

3.2 密钥派生

deriveKey(seed, noteCount, v) → 40 字节状态(4 × u64 + u32 计数器)

第三个参数 v 在当前游戏版本恒为 1。下文保留 v

初始化:

Python
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 为字符码点):

Python
对 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),参数为:

Python
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):

Python
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]

字段号与记录偏移的对应:

Python
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):

Python
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 次:

Python
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 作缩放系数:

Python
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 字节,适合逆向校验,不是普通使用的入口:

Python
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)全部一致

最终定位到两处转写错误,都在”看起来没问题”的地方:

  1. 字符循环 variant 1 的最后一步是 rol(..., 31)(shl 31 / shr 33),曾误写成 33
  2. 收尾 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)

评论

No comments yet. Why don’t you start the discussion?

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注