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 並沒有加密——這兩者要分清楚,我一開始沒分(見(7)踩坑)。

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 查表邏輯,一度以為某個關鍵值是查表得來而非算出來的。

狀態:完成。

本節來源:OVERVIEW.md

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)。真正的突破是回到基本功做已知明文攻擊——兩者其實共用同一套,只差前綴與起點。教訓:表面差異不等於機制差異,而「看起來不一樣」很容易變成停止驗證的理由。

狀態:完成。

本節來源:OVERVIEW.md


(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 在函式返回後很快被回收挪作他用(見(7))。

改成:等 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 下載 → 校驗 → 解密 → 抽取。

狀態:完成。

本節來源:RE_NOTES_3D.md §2、§5

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

狀態:完成。

本節來源:RE_NOTES_3D.md §6、§7


(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」上的時間,遠多於花在「路徑前綴會不會不一樣」上的時間,而答案是後者。

狀態:完成。

本節來源:RE_NOTES_3D.md §8


(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。

本節來源:RE_NOTES_LIVE2D.md

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 的模型上做的判斷,該重測。 在錯誤的基線上做的排除,不算排除。

狀態:未完成。 這是整個專案唯一還開著的東西。

本節來源:RE_NOTES_AVATAR.md · 現況見 HANDOFF.mdNEXT_STEPS.md


(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