Hololive Dreams 逆向 - 笔记归档
Preface
Cutoff: 26/08/23 Revision: 26/08/23
一款 Unity 6 手游资源系统的逆向记录。目前状态:资产、动画、脸部、头发、相机、Live2D 全部可交付,弹簧骨物理是唯一还没解完的东西。
工具与完整逐章日志在 https://git.siao.ai/siao/hohohololive(不含解密,理由见(8))。
本文的代码片段是构建时从 SiaoHub 直接嵌入的,不是手贴,所以不会跟源代码脱节;SiaoHub 那边的文件页也会反向标示「被这篇文章引用」。
命名致敬 sssekai —— 那个项目与它的笔记归档是这次逆向路上最有用的参考。
分析目标:game.qualiarts.hololive.dreams.com 1.0.0(iOS,已解密 IPA),Unity 6000.3.0b1,IL2CPP metadata v39。
(1)设备连接与 IL2CPP metadata v39
1. 传输:先看天花板
app 容器下 Library/octo/ 是资源下载缓存,3.4GB。子目录 v1/ 有 178 个 .awb、1240 个 .acb、176 个 .usm,全是 CRIWARE 音效/视频格式。
WiFi SSH 下 scp 传 12MB 花 11 分钟。第一反应是换 cipher(默认那组没有硬件加速),换完看起来瞬间变快——那是本地磁盘缓冲的假象,大文件就原形毕露。
但更重要的是即使 cipher 真的有效也没意义:3.4GB 在 WiFi 上再怎么调都是几十分钟等级。改走 USB:
iproxy 2222:22 & # SSH
iproxy 27042:27042 & # Frida| 路径 | 实测 | 3.4GB 估时 |
|---|---|---|
| WiFi SSH(默认 cipher) | ~18 KB/s | 约 2 天 |
| WiFi SSH(gcm) | 小文件看似极快,大文件仍慢 | — |
USB(iproxy) |
40–70 MB/s | 约 1 分钟 |
整包 tar 在动任何东西之前先拉回本机。后面有好几次需要清掉设备端缓存观察下载时机,没有备份的话每次清除都不可逆。
2. Metadata 是加密的,binary 不是
$ xxd -l 8 global-metadata.dat
00000000: 8f2b 0d1c ... # 预期 AF 1B B1 FA
magic 对不上。但磁盘上的 UnityFramework 并没有加密——这两者要分清楚,我一开始没分(见(附)踩坑)。
metadata 在执行时必然是解开的,扫内存找 magic:
import frida
MAGIC = b"\xaf\x1b\xb1\xfa"
def on_message(msg, data):
if msg["payload"].get("event") == "metadata":
open("global-metadata-decrypted.dat", "wb").write(data)
dev = frida.get_usb_device()
pid = dev.spawn(["game.qualiarts.hololive.dreams.com"])
ses = dev.attach(pid)
scr = ses.create_script(open("dump.js").read())
scr.on("message", on_message)
scr.load()
dev.resume(pid)Process.enumerateRanges("r--") 逐段扫,单一地址命中:
magic = AF 1B B1 FA
version = 39
version = 39 是合法的 IL2CPP 版本号,代表这是解密后的真身而不是巧合命中。
3. 版本号伪装的失败模式才是答案
Il2CppDumper 最新版只支持到 31。标准做法是改版本号骗过去,因为格式常常没变、只是跳号。
| 伪装成 | 结果 |
|---|---|
| 27 | 失败 —— key 冲突 |
| 29 | 失败 —— 同一个 key 冲突 |
| 31 | 失败 —— 同一个 key 冲突 |
三次挂在同一个位置。如果只是跳号,改成不同的旧版本应该在不同地方坏掉;三次一模一样,代表解析器读到的结构在那个点就跟预期分岔了——这是真正更新过的格式。
这个判断值钱的地方在于它一次关掉整条分支。「再试一个版本号」每次只花三十秒,所以很容易一直试下去。
改用 Il2CppInspectorRedux(LukeFZ fork),产出 56 万个方法名称对虚拟地址的完整 map。
4. 反编译环境:绕开 JVM
Ghidra 需要 JVM,而这台机器的 AppleSystemPolicy 核心模块挡掉未签名的 java——不是工具沙盒的限制,是系统本身,在自己的终端机直接跑也一样 Kill: 9。
不跟系统打。rz-ghidra 把 Ghidra 反编译引擎的 C++ 核心(SLEIGH + decompiler)抽出来编成 rizin 插件,执行时不需要 JVM:
git clone --recurse-submodules https://github.com/rizinorg/rz-ghidra.git
cd rz-ghidra && mkdir build && cd build
cmake -DCMAKE_BUILD_TYPE=Release .. && make -j$(sysctl -n hw.ncpu)
cp *.dylib /opt/homebrew/lib/rizin/plugins/坑一:光复制 .dylib 不够,还要把 build 出的 .sla(编译后的 sleigh 语言规格)放回源代码树里跟 .slaspec 同一层,并设 SLEIGHHOME。错误信息不会提到少了数据文件。
坑二:rizin -q -c "s <addr>; af; pdg" 的 af 若在某地址落进另一个更早、更大的函数范围内,会沿用既有边界,反编译出不相关的函数内容。我曾因此误判某地址是 IL2CPP 共用的泛型 Dictionary 查表逻辑,一度以为某个关键值是查表得来而非算出来的。
状态:完成。
References
(2)保护层定位:官方加密根本没开
这款游戏用 CRIWARE 音效中间件,而 CRIWARE 有官方加密。method map 里确实有:
Vision.Sound.CriWareDecrypter.Initialize(string key, bool enableAtom, bool enableMana)
看起来就是它。hook 上去看实际调用参数:
[+] CriWareDecrypter.Initialize
key = "..." (非空)
enableAtom = false
enableMana = false
两个开关都是 false,官方加密根本没启用——key 传进去了但没人用。真正在保护资源的是游戏自制的另一层(Vision.Octo.ResourceDecrypter)。
我在这之前对着 CRIWARE 的文档研究了几个小时。hook 一下看实际值花不到十分钟。
更正(分析当时):一开始看表面(bundle 没有音效那组的明文前缀、开头高熵)就断定音效与 bundle 是两套不同机制,绕了很大一圈(试过 LZ4、AES、GPU hook、Metal capture)。真正的突破是回到基本功做已知明文攻击——两者其实共用同一套,只差前缀与起点。教训:表面差异不等于机制差异,而「看起来不一样」很容易变成停止验证的理由。
状态:完成。
(3)资源目录与免越狱离线链
1. 规模论证:为什么不用被动 hook
解密需要每个文件的原始文件名(address)。文件名不在加密文件里,在游戏的资源目录中。
被动做法:hook 住解密函数,玩游戏,加载什么记录什么。一定会成功,技术上毫无风险。
问题是规模:
加密文件总数 1467
被动 hook 覆盖率 取决于玩到哪
估计时数 上百小时
覆盖率保证 无(限时活动资源可能永远触发不了)
当一条路的成本是「时间 × 运气」而且没有覆盖率保证时,它不是一条路,是一个斜坡。
2. 先量,再决定值不值得
octo/pdb/5/100001/octocacheevai,4.4MB。先算熵:
from collections import Counter
import math
d = open(path, "rb").read()
c = Counter(d)
H = -sum((n / len(d)) * math.log2(n / len(d)) for n in c.values())
# -> 8.0 bits/byte满分 8.0,确认是真加密而不只是压缩或序列化格式——值得花力气,也代表没有静态解析的可能。
3. 定位解开后的形式
索引在执行时一定会被解开来用,所以解开后的形式必然在 process 内存里。不用解它的加密,只要找到它解开后的样子。
第一版想抓「原地解密」:hook read(),记下 buffer 地址,延迟几秒后回头读同一块。全错——native buffer 在函数返回后很快被回收挪作他用(见(附)踩坑)。
改成:等 60 秒让索引完全加载,然后对整个 process 内存扫一个已知会出现在里面的文件名字符串。
命中区块 16 MB
命中次数 3563
那块就是解开后的完整资源目录,protobuf 格式。
4. 条目结构
1a <len> # 条目 (length-delimited submessage)
08 <varint> # 1 id -> 缓存目录名 = ("A"|"R") + id,再 hex 编码
12 <len> <bytes> # 2 name -> address(原始文件名)
18 <varint> # 3 size -> 明文字节数
2a 20 <32 bytes> # 5 md5 -> 缓存文件名
3a <len> <bytes> # 7 objectName -> CDN 对象键,6 字符随机字符串
实例(VisionProject.acf,整条长度 0x42 = 66 bytes,逐栏相加刚好吻合):
1a 42 08 01 12 11 "VisionProject.acf" 18 89 77 2a 20 "bd19...8afb" 3a 06 "VQHQAP"
id=1 name(17) size=15241 md5(32) objectName(6)
5. 验证:对账,不是「看起来对」
octo 缓存的目录名是 hex 编码的 ASCII——413138363439 解出来是 A18649。所以 ("A"|"R") + id 可以拿本地 4942 个缓存文件逐一对账:
id 相符 4929
id 不符 13
不在 catalog 0
-> 99.74%
13 条不符全部是内存区块边界截断:address 开头混进 *、# 等噪声字符(例:*vo_live_cmn_chr_0)。那是 dump 的边界问题不是解析逻辑问题,用「address 首字符须为英数」滤掉即可。
这一步是整段最重要的。一个解析器输出「看起来很像文件名的字符串」时,它可能只是在读噪声。要有一个独立来源能对账——这里是本地缓存的目录名。
最终 16823 条 hash 对 address 的完整对照表,批次覆盖率 99.93%,1595 个文件、1.5GB。
6. objectName → CDN
先前逆 OctoAPI.DecryptAes 时解出过一个 CDN 模板:
https://asset.game-hololive-dreams.com/{o}
当时不知道 {o} 是什么,只当红鲱鱼。解完条目结构才知道:{o} 就是 field 7 的 objectName。
GET https://asset.game-hololive-dreams.com/UFfHjj
User-Agent: UnityPlayer/6000.3.0b1
-> 1462529 bytes, md5 = 94bb0bfa4f2a82415c87fff62763ef6a
catalog 里的 md5 算的是密文,所以下载后可以直接校验完整性,不需要先解密。
代表越狱只剩「获取一次 catalog」这一个用途。
DEFAULT_URL_FORMAT = "https://asset.game-hololive-dreams.com/{o}"
USER_AGENT = "UnityPlayer/6000.3.0b1"
def url_for(entry: dict, url_format: str = DEFAULT_URL_FORMAT) -> str:
return url_format.replace("{o}", entry["object_name"])在 SiaoHub 檢視 siao/hohohololive/hohohololive/cdn.py L21-26
7. 字段前进解析:一个假设造成 94% 漏抓
第一版解析器假设 objectName(field 7)紧接在 md5(field 5)之后。结果 608 条 mdl_chr 只有 2 条抓到。
中间隔着重复的 field 6:
2a 20 <md5> 30 ca8402 30 e19402 30 809502 ... 3a 06 "SswxO0" 42 23 <address>
\____ 6 = 依赖资产 id (repeated varint) ____/ \_ 7 = objectName
3D 模型每个都依赖贴图与材质,所以全部有 field 6,全被跳过。改成正规的字段前进解析——依 wire type 逐字段推进 tag → 长度 → 值:
while p < n:
tag = blob[p]
field, wire = tag >> 3, tag & 7
p += 1
if wire == 0: # varint
v = shift = 0
while p < n:
b = blob[p]
v |= (b & 0x7F) << shift
p += 1
if not (b & 0x80):
break
shift += 7
if field == 6:
deps.append(v)
else:
break
elif wire == 2: # length-delimited
if p >= n:
break
ln2 = blob[p]
p += 1
if ln2 & 0x80: # 兩位元組長度, 已超出本筆範圍
break
val = blob[p:p + ln2]
p += ln2
if field == 7 and len(val) == ln2 and all(0x20 <= c < 0x7F for c in val):
obj = val.decode("ascii")
break
else:
break
out.append({"id": oid, "address": address, "size": size,
"md5": md5.decode(), "object_name": obj,
"dependencies": deps})在 SiaoHub 檢視 siao/hohohololive/hohohololive/catalog.py L112-145
field == 7 那条还多要求「长度吻合且全为可打印 ASCII」,因为内存 dump 的边界截断会让最后一条条目读出半个字段。修正后:
修正前 33799 / 36119 条有 objectName
修正后 36119 / 36119
顺带把依赖清单(field 6)也解了出来,之后做「连依赖一起抓」很方便。
protobuf 的字段顺序没有保证,repeated 字段更是任意长。用「偏移量」定位字段是在赌序列化器的实现细节,赌输的时候它不会报错,只会少抓——而 2/608 这种比例很明显,33799/36119 就不一定会被发现。
8. 实测
待下载 944 条 (3D + 缺的 Live2D + mot_define), 1.01 GB
成功 944
跳过 0
失败 0
完整离线链:CDN 下载 → 校验 → 解密 → 抽取。
状态:完成。
References
(4)3D 模型:带骨架绑定的 glTF
1. 顶点流布局
stride(s) = Σ dimension × sizeof(format)
offset(0) = 0
offset(s) = align16(offset(s-1) + vertexCount × stride(s-1))
角色 body 实测:
stream0 stride 40 ch0 Position(3f) ch1 Normal(3f) ch2 Tangent(4f)
stream1 stride 16 ch3 Color(4×UNorm8) ch4 UV0(2×half) ch7 UV3(2f)
stream2 stride 32 ch12 BlendWeight(4f) ch13 BlendIndices(4×uint32)
验证方式:对每个网格算「计算总长 vs 实际 m_DataSize」,全部逐字节吻合。
2. 两个真实的 bug
(1) 骨影响数不是固定 4。 我原本假设 ch12/ch13 恒为 dim4,直接 [:, :4]:
| 网格 | ch12 | ch13 | 实际 |
|---|---|---|---|
Geo_Body_LOD0 |
dim4 | dim4 | 4 骨混合 |
Geo_Eye_LOD0 |
dim2 | dim2 | 2 骨混合 |
Geo_Brow_LOD0 / Geo_Iris_LOD0 |
无 | dim1 | 刚性单骨,权重恒 1 |
对 dim2 的网格,[:, :4] 只拿到 2 宽数组却声明成 VEC4 → 权重和变成 −2.37 ~ 3.02、joint 索引 63471。眉毛与虹膜则因为「没有 ch12」被整个当成无绑定跳过。修正:一律补零到 4 宽;缺 ch12 时视为刚性绑定,第 0 槽权重设 1.0。
(2) 顶点数据有两种存法。 角色模型内嵌在 m_VertexData.m_DataSize,但场景零件多半放在外部流文件(.resS),由 mesh.m_StreamData 指出 path/offset/size。没处理时 fbx_mdl_env_* 整批解出 0 个网格。改用 UnityPy.helpers.ResourceReader.get_resource_data() 取回。
3. 坐标系转换
Unity 左手系 → glTF 右手系,以镜像 X 轴 M = diag(-1,1,1):
| 对象 | 转换 |
|---|---|
| 位置 / 法线 / 切线 | (x,y,z) → (-x,y,z) |
| 旋转四元数 | (x,y,z,w) → (x,-y,-z,w) |
| 逆绑定矩阵 | M' = S·M·S,S = diag(-1,1,1,1) |
| UV | v → 1-v(Unity 原点左下,glTF 左上) |
| 三角形环绕 | 反转(行列式变号) |
四元数那条的推导:R' = M R M,M 为非正常变换(det = −1),把「绕轴 a 转 θ」变成「绕 (aₓ, -a_y, -a_z) 转 θ」,代入 q = (sin(θ/2)·a, cos(θ/2)) 即得。
这几条写在实现的 docstring 里而不是只写在日志里,因为它们是每次改动这个文件都要重新确认的前提:
## 座標系轉換
Unity 是左手系 (Y-up, Z-forward),glTF 是右手系 (Y-up, Z-back)。
以鏡射 X 軸 M = diag(-1,1,1) 轉換:
位置/法線/切線 (x,y,z) -> (-x, y, z)
旋轉四元數 (x,y,z,w) -> (x, -y, -z, w)
推導: R' = M R M, M 為非正常變換 (det=-1),
使繞軸 a 轉 θ 變成繞 (ax,-ay,-az) 轉 θ
逆綁定矩陣 M' = S · M · S (S = diag(-1,1,1,1))
三角形環繞順序 必須反轉 (行列式變號)在 SiaoHub 檢視 siao/hohohololive/hohohololive/gltf.py L27-37
4. 一个推论错了两次的地方:描边壳
Geo_Body_LOD0 有 3 个子网格,面数 13932 / 240 / 13932——第 0 与第 2 完全一样。索引缓冲 41796 + 720 + 41796 = 84312 刚好把 168624 bytes(uint16)填满,所以那不是解析错误,是真实数据。
第一次推论(错):这些重复子网格在 renderer.m_Materials 里没有对应材质,所以判定「Unity 因此不渲染它」,用「无材质」当剔除条件。
真相:材质一直都在,只是放在依赖 bundle 里的共用材质,没加载依赖时 PPtr 解不开而已。加上 load_bundle_with_deps() 之后名字直接浮出来:
材质: ['m_eye', 'm_bdy', 'm_bdyco', 'SubMeshOutlineMaterial', 'm_fef']
Geo_Body_LOD0 sub0 面=13932 材质=m_bdy
Geo_Body_LOD0 sub1 面= 240 材质=m_bdyco
Geo_Body_LOD0 sub2 面=13932 材质=SubMeshOutlineMaterial <- 描边壳
卡通渲染的 inverted-hull 描边:同一份几何,法线外推后翻面只画背面。
「这个东西没有 X 所以引擎不用它」——当 X 是跨 bundle 解析出来的东西时,这句话的前提可能只是你没加载依赖。
5. BlendShape → glTF morph target
脸部表情不靠骨架,走 blendshape,而且只在脸部网格上:
| 网格 | 通道数 |
|---|---|
Geo_Eye_LOD0 |
16 |
Geo_Brow_LOD0 |
14 |
Geo_Iris_LOD0 |
2 |
Geo_Body_LOD0/1 |
0(身体变形全靠骨架) |
合计 32 —— 与动作 clip 内 typeID 137 的曲线数完全相同,互为佐证。
Unity 以稀疏方式储存:channels[] 指向 shapes[] 的一段,shapes[i] 再指向 vertices[] 的一段,每个顶点自带原网格索引。glTF 的 morph target 需要与网格等长的稠密位移数组,所以逐一展开回去(位移同样要套 X 镜像)。
channel.frameCount > 1 表示渐进形变,glTF 一个 target 只能表示一个形状,取最后一帧。实测本作全部 frameCount = 1。
状态:完成。
(5)3D 动作:CRC32 反查与 mot_define
1. 突破口是 MonoBehaviour,不是 AnimationClip
AnimationClip 的 genericBindings 只存哈希:
{'path': 1182008026, 'attribute': 1661978518, 'typeID': 137}一开始拿角色 skeleton.json 的骨骼路径算 CRC32 去比对,608 个骨架 × 所有路径形式,0 命中。
真正的答案在同一个 bundle 的 MonoBehaviour(VisionActorMotionDefine):它的 baseAnimation.bindings 以明文列出每条绑定:
{"name": "Geo_Eye_LOD0", "path": "Root_Body/Geo_Eye_LOD0",
"type": "UnityEngine.SkinnedMeshRenderer",
"properties": ["blendShape.b_eye.eye_001", "..."]}Animator 根节点叫 Root_Body,不是 bundle 名称——这就是对不上的原因。
哈希函数确认为 CRC32(明文):
| 字符串 | CRC32 | 在 clip 内出现 |
|---|---|---|
b_eye.eye_001 |
1661978518 | blendshape 首条(不带 blendShape. 前缀) |
m_FadeFactor |
682354173 | 6 次 = 6 个 DecalProjector |
bakeAnimationWeight |
3202011236 | 44 次 = 44 根 swing bone |
把 637 个 bundle 的 bindings 全部收集成全局反查表(单一 bundle 的字典只够解自己那几条),得到 520 个路径 / 198 个属性名。
这是这一段最可复用的一点:当 hash 对不上时,先怀疑输入字符串的形式,不要先怀疑 hash 函数。 我花在「会不会其实是别的 hash」上的时间,远多于花在「路径前缀会不会不一样」上的时间,而答案是后者。
状态:完成。
(6)Live2D:攻原生层,不攻上层
1. 三条走不通的路
依序试过、全部失败:猜测是 LZ4 压缩;找 Octo.dll/Octo/Loader/OctoAPI.DecryptAes(误导性成功——它解的是 API 封包不是资源);hook IL2CPP 层的 Cubism SDK API。
第三条失败的方式指出了根因:Cubism Core 是原生 C 函数库,直接静态链接进 UnityFramework,没有独立 .framework,所以 IL2CPP 层根本没有可以 hook 的东西。
2. 改攻原生导出符号
Module.enumerateExports() 列出所有以 csm 开头的原生导出(共 44 个):
csmGetVersion
csmGetMocVersion / csmGetLatestMocVersion
csmHasMocConsistency
csmReviveMocInPlace ← 关键函数
csmInitializeModelInPlace
csmUpdateModel
csmGetDrawable* 系列
csmReviveMocInPlace(void* address, unsigned int mocSize) 吃「已经解密好、可解析的 moc3 数据所在内存地址 + 大小」两个参数。不需要理解上层是 C# 还是 IL2CPP,也不需要碰加密算法本身——只要这个原生函数被调用,当下内存里的数据就是 100% 正确可用的明文 moc3。
const target = Module.findExportByName("UnityFramework", "csmReviveMocInPlace");
Interceptor.attach(target, {
onEnter(args) {
const addr = args[0], size = args[1].toInt32();
send({event: "csmReviveMocInPlace", size}, addr.readByteArray(size));
}
});用 Module.findExportByName 而非硬编码地址 offset,天生免疫 ASLR 与重启地址偏移。
3. 通用形状
这一段的可复用形状值得单独写出来:找到那个「拿明文当参数」的原生函数,在它身上等。 不管上层包了几层加密、混淆、托管运行时,数据终究要以引擎看得懂的形式交给引擎。那个交接点就是最低成本的位置,而且它天然不随加密方案改版。
.moc3 / .model3 / .physics3 获取后可直接用 Cubism Editor 打开,材质需依 BuildModelData 的信息更名。
状态:完成(贴图 atlas 另计,见下)。
4. 没解完的:贴图 atlas
moc3 专用的 texture atlas 至今没拿到。依序试过七条路全部失败(都是 Frida hook + 玩家配合触发),最后改用纯静态定位加载路径才找到真正的数据来源。中间有一次把 set_MainTexture 当成正确挂点,继续反编译之后推翻。
更正:
set_MainTexture假设证实错误。当时它「看起来就是那个」,而且 hook 上去也真的有东西——只是那个东西不是我要的。hook 到东西不等于 hook 对地方。
状态:未完成。剩余选项是 Xcode Metal Frame Capture。
References
- https://github.com/OpenL2D/moc3ingbird(moc3 的 ImHex pattern)
(7)弹簧骨物理(未完成)
裙摆、缎带、头发不随动画出货——烘焙只涵盖 51 根 humanoid 骨,模型实际有 126 根,少的 75 根(裙摆 31、脸颊 10、缎带 8…)由游戏的 Swing/Quartz 在运行时算。
但参数有出货,内嵌在每个模型 bundle 的 MonoBehaviour 里:
ActorSwingDynamicBone 挂在 _sim 骨 被模拟的骨
ActorSwingStaticBone 挂在身体骨 碰撞体
ActorSwingChain 挂在 hips 链结构
QuartzDriverSkirtBone 挂在 _ast 骨 程序式辅助骨
(catalog 的 address 搜不到这些,是因为 address 不含 MonoBehaviour 类别名——跟当初搜 Avatar 犯的是同一个错。)
离线重现的积分器:
step = min(dt, 1/60) × 40
inertia = (pos - prevPos) × (1 - damping)²
force = CalcStiffnessPendulum(...) + childSpeed × spring
newPos = pos + inertia + force × step
newPos.y -= mass × 0.01 重力
之后:骨长硬约束,再转成父骨的旋转
CalcStiffnessPendulum(dynamicType == 0,占 6900/6940 根骨):
delta = rotate(parent.worldRot, boneAxis) 骨的静止方向
cos = |dot(cur, rest)| / (|cur|·|rest|) 两个位置向量的夹角
p = max(0, cos - (1 - range)) / range × pendulum
return delta × (stiffness - p) × 0.01
坐标是 animator root space 不是世界坐标,所以直接用 GLB 的骨架层级算就对。
实现里的积分主循环:
for _ in range(n_sub + warm):
cur, pv = pos[ni], prev[ni]
inertia = (cur - pv) * (1.0 - damping) ** 2
# cos 是 prevPos 與 pos 的夾角 (呼叫端 childTx=-0xf0=prevPos,
# childDefaultTx=(s13,s11,s12)=pos)。0x02793a04 把 -0xf0
# 寫回 child.selfTx.translation, 證實它就是子骨的位置。
if pen <= 1e-5 or rng <= 1e-5:
p_term = 0.0
else:
na, nb = np.linalg.norm(pv), np.linalg.norm(cur)
cosv = abs(float(np.dot(pv, cur))) / max(na * nb, 1e-9)
p_term = max(0.0, cosv - (1.0 - rng)) / rng * pen
# delta = rotate(**當前**的 selfTx.rotation, boneAxis) —— §35 釘死:
# selfTx 是迴圈攜帶狀態, 每個子步讀到的是上一子步的模擬結果,
# 不是動畫給的靜止姿勢。骨的當前方向就是 (pos - 骨位置) 正規化。
# 用動畫的 rest_dir 等於憑空多給一個遊戲裡沒有的角度回復力。
if args.delta_current:
cd = cur - anchor # §39: 原本用 WT[ni], 與骨長約束的基準不一致
cn = float(np.linalg.norm(cd))
dvec = cd / cn if cn > 1e-9 else rest_dir
else:
dvec = rest_dir
force = dvec * (stiff - p_term) * 0.01 + csp
if args.vel == "off":
new = cur + inertia + force * step
else:
# §36: 0x02793408/0x02793410 讀寫 [x20+0x54] 這個累加器 ——
# fmul s1, s0, s5 力 × step
# fmul v13.2s, v0.2s, v2.s[0] × swingPowerWeight (實測 1.0)
# fadd v4.2s, v13.2s, v0.2s 累加進狀態
# 力不是加到位置, 是加進狀態; 狀態才改位置。
vel[ni] = vel[ni] * (1.0 - args.vel_damp) + force * step
if args.vel == "add":
new = cur + inertia + vel[ni] * step
else:
new = cur + vel[ni] * step
new[1] -= mass * 0.01 * args.gravity_scale # 重力 (每個子步)在 SiaoHub 檢視 siao/hohohololive/sim_swing.py L705-742
现况
验证方式是拿游戏真值逐帧比对——bake_swing_truth.py 从游戏录下同一个 clip 的 _sim 骨 local rotation,逐帧算夹角:
角度 = 2 · arccos(|dot(q_sim, q_truth)|)
四元数的正负号不影响姿势,所以取绝对值。
误差 / 真值动作幅度 110% (100% = 弹簧骨完全不动)
还是略差于「完全不做」。 症状收敛到单一项:过摆 1.50 倍。已实现但默认关掉的四项(各自因为下游还缺阻尼而被惩罚):
--quartz QuartzDriverSkirtBone 驱动 _ast 链根 142%
--delta-current delta 用当前方向而非静止方向 153%
--collision 球 vs 锥形胶囊 202%
--vel 力累加进速度状态 147%
仍未实现:风(CalcWindPower,实测 windPower = 0.7 是开着的)、链骨平滑的第四趟、多变体的 _ast 驱动。
更正:当初判断「风会增加摆动、方向不对」而搁置——那是在还有四个 bug 的模型上做的判断,该重测。 在错误的基线上做的排除,不算排除。
状态:未完成。 这是整个项目唯一还开着的东西。
(8)公开范围
解密算法解出来了,也写成了完整的数学规格和可执行工具。那部分不在这里,也不在公开 repo 里。
理由是法律。台湾《著作权法》§80-2 禁止提供「主要用于规避防盗拷措施之设备、器材、零件、技术或资讯」——「资讯」二字涵盖的不只是代码,也涵盖一份写得够清楚、读完就能自己实现的规格文件。同条第三项有「为达成资讯间之相互操作性所为之还原工程」的例外,但一般理解是涵盖做还原工程,不是公开发布规避方法。灰色地带,而我不是律师。
(所以本文与 sssekai 的笔记归档在这一点上不同——那边会贴出完整的 key table 与 AES key/iv,这边不会。这是我对自己所在法域的保守判断,不是对别人做法的评价。)
工具切开发布:格式解析那一半(AssetBundle 抽取、mesh/骨架、glTF 转档、Live2D 与 3D 动作解码)是互通性工作,公开在 https://git.siao.ai/siao/hohohololive;解密那一半没有。
切法本身有个转折值得记。原本打算做常见的「拿掉密钥、留下算法」,但那不成立——密钥是从 address 推导出来的,推导程序本身就是密钥,没有独立的秘密可以拿掉。而实现里唯一的魔术常数是单一字节,已知明文就写在文件头,256 种可能是微秒级的穷举。只遮那个常数,降低的工作量是零,却会做出一个「看起来遮过、其实没有」的东西。所以整层一起拿掉。
素材完全没有公开,游戏资源的版权属于发行商,一个 byte 都不在任何我发布的地方。
(附)踩过的坑
用小文件测传输速度。 换完 cipher 后测了一个小文件,它在写进磁盘缓存的瞬间就「传完」了。一个改动之后看到改善,不代表是那个改动造成的。
Dump 了 234MB 不需要的内存。 磁盘上的 UnityFramework 本来就没加密。更糟的是内存版本反而不能用:
磁盘 __TEXT 紧密排列,file offset == vaddr - base
内存 __TEXT page 对齐,两者相差各段的对齐 padding
地址对不起来,解析工具直接坏掉。加密的是 metadata 不是 binary,我没分清楚就对两者都用了对付加密的手段。
延后读取 native buffer。 native read() 的缓冲区在返回后很快被回收挪作他用,延迟读到的都是不相关的残留——一度误判成查表逻辑、UTF16 字符串、bplist。必须在 onLeave 当下同步 dump:
Interceptor.attach(Module.findExportByName(null, "read"), {
onEnter(args) { this.buf = args[1]; },
onLeave(ret) {
const n = ret.toInt32();
if (n > 0) send({tag: "read", n}, this.buf.readByteArray(n)); // 当下,不能延后
}
});fd 重用污染追踪表。 hook open() 记下有兴趣的 fd 却没在 close() 清掉。系统把同一个 fd 数字重新分配给别的文件之后,会把不相关的内容(UnityFS 文件头、bplist00)当成目标文件的内容:
Interceptor.attach(Module.findExportByName(null, "close"), {
onEnter(args) { tracked.delete(args[0].toInt32()); }
});后面两个最值得记,因为它们不是「我推论错了」,是观测方法本身在制造假数据。假数据看起来跟真数据一模一样,我还为它构建了好几个解释。
工具链
| 用途 | 工具 |
|---|---|
| USB 端口转发 | libimobiledevice / iproxy |
| 动态 instrumentation | Frida(Python API,不是 CLI) |
| IL2CPP 解析 | Il2CppInspectorRedux(LukeFZ fork) |
| 反编译 | rizin + rz-ghidra |
| Unity 资产 | UnityPy(FALLBACK_UNITY_VERSION = "6000.3.0b1") |
- Frida 用 Python API,不要用 CLI。 CLI 有 attach timeout 的问题,
device.spawn() → attach() → resume()稳定得多。 Il2CppInspectorRedux的 CLI 会假死。 内建的 SignalR web 服务有机会让整个 process 卡住,所有线程 idle 在__psynch_cvwait。用sample <pid>就能看出它不是在忙、是在等。改用更轻量的输出选项可以绕过。