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·SS = diag(-1,1,1,1)
UV v → 1-v(Unity 原点左下,glTF 左上)
三角形环绕 反转(行列式变号)

四元数那条的推导:R' = M R MM 为非正常变换(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

AnimationClipgenericBindings 只存哈希:

{'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


(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            重力
                                   之后:骨长硬约束,再转成父骨的旋转

CalcStiffnessPendulumdynamicType == 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 资产 UnityPyFALLBACK_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> 就能看出它不是在忙、是在等。改用更轻量的输出选项可以绕过。

References