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·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。
狀態:完成。
本節來源:RE_NOTES_3D.md §6、§7
(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」上的時間,遠多於花在「路徑前綴會不會不一樣」上的時間,而答案是後者。
狀態:完成。
本節來源: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
- 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 的模型上做的判斷,該重測。 在錯誤的基線上做的排除,不算排除。
狀態:未完成。 這是整個專案唯一還開著的東西。
本節來源:RE_NOTES_AVATAR.md · 現況見 HANDOFF.md 與 NEXT_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 資產 | 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>就能看出它不是在忙、是在等。改用更輕量的輸出選項可以繞過。