Hololive Dreams 逆向 - 筆記歸檔
Preface
Cutoff: 26/08/23 Revision: 26/08/29
一款 Unity 6 手遊資源系統的逆向紀錄。目前狀態:資產、動畫、臉部、頭髮、相機、Live2D 全部可交付,彈簧骨物理是唯一還沒解完的東西。
工具與完整逐章日誌在 https://git.siao.ai/siao/hohohololive(不含解密,理由見(10))。本文的原始日誌在「原始日誌」面板裡,展開可以直接在這一頁讀完,不用跳出去。寬螢幕上它跟著文章捲動在右側;窄螢幕上它排在文末,從上面的目次最後一項可以直接跳過去。每一節結尾都有一行「本節來源」,點下去會跳到面板並直接展開那一份——不用先捲到文末找。
本文的程式碼片段是建置時從 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 查表邏輯,一度以為某個關鍵值是查表得來而非算出來的。
狀態:完成。
本節來源: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 在函式返回後很快被回收挪作他用(見(附)踩坑)。
改成:等 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
mot_define1. 突破口是 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
restore = (restPos - pos) × (1 - damping)² 拉回動畫算出的靜止位置,不是慣性
force = CalcStiffnessPendulum(...) + childSpeed × spring
newPos = pos + restore + 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
現況(26/08/29 更新)
驗證方式是拿遊戲真值逐幀比對——bake_swing_truth.py 從遊戲錄下同一個 clip 的 _sim 骨 local rotation,逐幀算夾角:
角度 = 2 · arccos(|dot(q_sim, q_truth)|)
四元數的正負號不影響姿勢,所以取絕對值。整段動作的總誤差一度量到「110%(100% = 彈簧骨完全不動)」,但這個指標後來發現會被單一根骨的偏移放大,不是量整體品質本身——換成逐骨、逐格的 swing-twist 分解(把每根骨的旋轉拆成沿骨軸的「扭轉」與垂直骨軸的「彎曲」)重新量之後,誤差的形狀清楚了:
扭轉 兩邊都 ≤1.5°(跟角度限制的設定值吻合,這條可能本來就該是零)
彎曲 量對,方位弱相關(方位差標準差 63~93°,完全隨機是 104°)
也就是說模擬「擺動的力道」大致抓對了,「往哪擺」只抓到微弱的相關。目前最乾淨的一條線索是一個單節、輸入幾乎完美的案例:角色站著不動、姿勢已沉降時,鏈根量到 0.98°,緊接著的第一節模擬骨卻放大到 5.73°——中間沒有沿鏈累積(累積是另一個獨立現象,長鏈才會看到),是目前唯一「輸入乾淨、輸出可量、只差一個節點」的地方。
上面積分器裡拉回靜止位置的那一項,先前一直是兩個假設在打架:讀「上一格位置」(慣性/verlet)還是讀「動畫算出的靜止位置」(restore)。逐字元讀完子步迴圈唯一會寫那個暫存器的路徑,確認是後者——模擬本來就寫對,不用改,這條懸念關掉了,但也代表誤差不是這裡漏東西。
同一段時間另外挖出一條獨立的線索,目前只解到一半:把「每股裙擺/緞帶自己的殘差」(不是單一節點,是整條鏈一起偏)拆開看,發現它有一半直接可以歸因到一個資產欄位——每股鏈的第一根骨的質量標成 0(不受重力),但模擬讀到的其實是它子骨的質量 1。這不是模擬讀錯,二進位親自確認就是讀子骨;順著這條往下量,遊戲對這批「本不該受重力卻受了」的骨有一個專門的抵消機制,方向對、但確切在抵消什麼、由誰觸發還沒抓到——往上追那個觸發旗標是怎麼被寫入的,追到一個只能靠間接呼叫(不是一般的 BL 直接呼叫)才會執行到的路徑,static 分析在這裡到頂,但也確認了它不影響前面單節誤差那條線的結論。
已實作但目前看來會被下游缺陷放大、預設關掉的幾項(風、QuartzDriverSkirtBone 驅動鏈根、碰撞)仍待在排除掉這個單節誤差之後重測——在錯誤的基線上做的排除,不算排除。
狀態:未完成。 這是整個專案唯一還開著的東西——但逆向本身(讀懂遊戲怎麼算)已經做完了,卡住的是模擬還原不出同一個數字。
本節來源:RE_NOTES_AVATAR.md · 現況見 HANDOFF.md 與 NEXT_STEPS.md
(8)AI 動作決策:公園是劇本,賽道才是決策樹
資產、動畫、物理之外,另一條線是「遊戲跑起來之後的邏輯」。先從 AI 講。
1. 公園 NPC:沒有決策
公園場景(Vision.Park)的 NPC,角色頂層狀態只有五個(None / Normal / Move / Action / AutoLogic),移動由一個三態狀態機驅動:待命、走 NavMesh 到一個目的地、朝一個方向推進。
逐一讀過三個狀態的每幀更新後,結論是它們不做決策——目的地與方向都是從外部傳進來的,狀態自己不產生它們,沒有亂數、沒有自我轉場。NPC 的「行為」其實是對話劇本 + 一連串演出單元:一個單元一個動作(播動作、移動、轉身、表情),串起來播。人格型別只有三種,看來只影響待機動作的挑選。
公園 NPC 不「自己決定」任何複雜的事,它按 spawn data 觸發固定序列。 追問「那目的地是誰產生的」——這個問題把我帶到了另一個地方。
2. 真正的決策樹在賽道小遊戲
追「誰產生目的地」時,撞出一整個先前沒注意到的命名空間 Vision.Circuit(一款馬車競速小遊戲)。它的對手 NPC 才是這遊戲裡真正有 AI 決策的地方,用的是分層有限狀態機(有狀態堆疊:等待開賽 / 比賽中 / 抵達終點 / 重生 / 退賽)。
比賽主體跑一條動作鏈——一個動作完成回傳下一個:找路(走 NavMesh 或 waypoint)、跳(過障礙)、以及一個反射層「卡住就跳」疊在上面兜底。
3. 決策核心:用「冒險傾向」對候選路徑點打分
找下一個路徑點時,對每個候選 waypoint 呼叫一個評分函式,選分數最佳者。人格結構 NpcPersonalty 的關鍵欄位是 RiskTaking(冒險傾向)。評分把兩個正規化因子(一個來自幾何、一個來自候選序)用 RiskTaking 加權:冒險值高的對手更敢選「近但風險高」的線,低的更保守。
這是一個單層效用評分決策,不是多層行為樹——「分層」體現在狀態堆疊,不在動作選擇本身。
一個工具教訓:要回答「這個狀態是誰切換的、目的地是誰給的」,需要一個「反向索引」——給一個函式,找出誰呼叫它。我寫了一支掃 ARM64
BL指令的小工具,第一版掃錯了區段:IL2CPP 的方法碼幾乎全在一個獨立的可執行區段裡,不在一般的__text,而我只掃了__text。結果每一個查詢都回「零呼叫」——看起來像「這函式沒人用」,其實是沒掃到。修正後拿一個「已知一定有呼叫者」的函式交叉驗證,它正確回報了呼叫點,那些「零」才可信。一個回傳空結果的分析工具,和一個壞掉的分析工具,長得一模一樣。
狀態:機制讀通。
本節來源:RE_NOTES_GAMEPLAY.md
(9)判定與反作弊:用戶端算什麼、伺服器負責什麼
音遊的判定與成績,這一節只界定設計上的責任分工,不涉及任何繞過的做法。
1. 判定是純用戶端、即時、無網路
判定邏輯在 Vision.LiveCore。一次判定產出「判定等級(Miss/Bad/Good/Great/Perfect/PerfectPlus)+ 快慢 + 差了幾幀」。判定窗不是硬編碼,是每個 note 型別 × 每個等級一組,存在隨遊戲附帶的 master 資料表裡;判定時查表拿到一組幀數做區間判斷。CalcJudgeType 由寬到嚴逐級試,第一個落在窗內的就是結果。時間解析度是幀(1/60 秒),常數 1/60 直接寫在碼裡。
判定完在同一幀更新分數、combo、血量——沒有任何一次網路往返。分數同理:逐幀把數個係數相乘累加(perfect 係數、判定係數、combo 係數、deck 戰力、樂曲係數、技能加成),全程在本地。也就是說用戶端手上握有一份算好的最終成績。
2. 送出的是「結論」,不是「過程」
單人演唱會結束,用戶端以 gRPC 送出一個 LiveBaseResult——整場的最終統計(總分、各判定等級的數量、最大 combo、剩餘血量…)。伺服器收到的是結論,不是逐 note 的過程。這條資料的可信度取決於伺服器端是否重算/自洽檢查(用 deck 戰力 × 譜面理論上限驗分數上界、或檢查分數與各判定數是否自洽)。那部分邏輯在伺服器,不在用戶端二進位裡,本文看不到,也不推測其強度。
網路層有一套請求簽章(HMAC-SHA256,key 在用戶端),但由逐 API 的開關決定要不要加簽,不是全域強制。簽章的作用是完整性/防中間人竄改——它不是、也無法是「防止持有 key 的用戶端自身」的機制,這是 HMAC 的一般性質。
3. 反作弊:一套 SDK 觀察不到接線,一套自製的掛在賽道成績上
App 裡打包了一套完整的第三方反作弊 SDK(CodeStage Anti-Cheat Toolkit):注入偵測、加速偵測、改時間偵測、穿牆偵測、app 完整性校驗、以及一整套記憶體混淆值型別。但三個獨立觀察都指向它沒有被遊戲碼接線:各偵測器的啟動進入點在用戶端碼裡找不到任何呼叫、自動啟動進入點同樣找不到、而且沒有任何遊戲型別把欄位換成混淆型別(若反作弊真在保護分數或座標,一定會用這些型別,實測是零)。
能斷言的是:用戶端碼裡沒有主動啟用點,也沒有任何具體遊戲數值受混淆保護。不能斷言「完全沒跑」——這些偵測器是 MonoBehaviour,理論上可由場景資料經 Unity 生命週期啟動,那條路徑靜態分析抓不到。綜合研判:這套 SDK 大機率是依賴或範本殘留,實質防護趨近於零。這是研判,不是鐵證。
對照之下,賽道小遊戲有一套自己寫的、確實接進成績流程的作弊偵測:逐段賽道記進出時間、在賽道外的時間、dash 次數,依門檻算出一個作弊嚴重度(三級),並把它寫進送伺服器的名次結果。接線的關鍵證據是這條路徑上的方法都帶 OnlyServer 後綴——設計者明確標示「決定名次與獎勵的這份成績是伺服器權威的那一份」。
把兩條放在一起:用戶端這側(音遊判定、算分)在設計上不設防,但同一個團隊在賽道模式明確做了「伺服器權威重算 + 門檻式作弊分級」。一個會為賽道小遊戲寫伺服器權威路徑的團隊,音遊主玩法的成績驗證大機率也在伺服器——這是研判,不是證明,因為那條的伺服器邏輯不在用戶端二進位裡。
狀態:就用戶端可觀察的範圍讀通了;伺服器端的驗證強度按定義不在用戶端二進位內。
本節來源:RE_NOTES_GAMEPLAY.md
(10)公開範圍
解密演算法解出來了,也寫成了完整的數學規格和可執行工具。那部分不在這裡,也不在公開 repo 裡。
理由是法律。台灣著作權法 §80-2 禁止提供「主要用於規避防盜拷措施之設備、器材、零件、技術或資訊」——「資訊」二字涵蓋的不只是程式碼,也涵蓋一份寫得夠清楚、讀完就能自己實作的規格文件。同條第三項有「為達成資訊間之相互操作性所為之還原工程」的例外,但一般理解是涵蓋做還原工程,不是公開發佈規避方法。灰色地帶,而我不是律師。
(所以本文與 sssekai 的筆記歸檔在這一點上不同——那邊會貼出完整的 key table 與 AES key/iv,這邊不會。這是我對自己所在法域的保守判斷,不是對別人做法的評價。)
工具切開發佈:格式解析那一半(AssetBundle 抽取、mesh/骨架、glTF 轉檔、Live2D 與 3D 動作解碼)是互通性工作,公開在 https://git.siao.ai/siao/hohohololive;解密那一半沒有。
切法本身有個轉折值得記。原本打算做常見的「拿掉金鑰、留下演算法」,但那不成立——金鑰是從 address 推導出來的,推導程序本身就是金鑰,沒有獨立的秘密可以拿掉。而實作裡唯一的魔術常數是單一位元組,已知明文就寫在檔頭,256 種可能是微秒級的窮舉。只遮那個常數,降低的工作量是零,卻會做出一個「看起來遮過、其實沒有」的東西。所以整層一起拿掉。
素材完全沒有公開,遊戲資源的版權屬於發行商,一個 byte 都不在任何我發佈的地方。
(8)(9)套用的是同一套判斷、但理由不同:不是著作權法的規避措施,是這款遊戲目前仍在正式營運。逆向出「哪裡沒設防」是一回事,公開到讀完就能照著改的程度是另一回事——後者對一個活著的服務就是一份現成的攻擊指南,不是互通性工作。所以公開版只留機制與責任邊界(判定怎麼算、成績誰驗、反作弊接了沒),確切函式位址、可直接照做的修改點、以及任何「所以可不可行」的操作性結論一律移除。完整版留在我自己這裡。
(附)踩過的坑
用小檔測傳輸速度
換完 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>就能看出它不是在忙、是在等。改用更輕量的輸出選項可以繞過。