ClaudeDocs/RE_NOTES_LIVE2D.md
Hololive Dreams — Live2D模型逆向紀錄 (第二階段)
延續 RE_NOTES.md 音效/影片破解後,針對Live2D角色模型(.moc3)的獨立逆向工程。
這是與音效加密完全不同的第二套機制,最終繞過加密演算法本身,改用「攔截原生API」直接取得解密後模型。
背景
- 掃描3.4GB
octo快取(音效解密後),用UnityPy檢查1098個Unity AssetBundle,未發現任何.moc3/角色貼圖 - 用二進位層級全文搜尋(
moc3/MOC3字串特徵),在136個檔案中找到疑似命中- 統計驗證: 純隨機情況下整個3.3GB應只會出現約0.77次巧合命中,實際136次遠超隨機值 → 確認是真內容
- 但這136個檔案開頭即為高熵亂數(無
QUAVMAGIC等任何明文前綴),證實與音效那套XOR演算法不是同一套機制
嘗試路徑與踩坑
1. 猜測是LZ4壓縮 (失敗)
Unity官方AssetBundle常用LZ4壓縮,高熵特徵符合,但 lz4.frame.decompress() 直接報錯
ERROR_frameType_unknown,排除。
2. 尋找Octo.dll/Octo/Loader/OctoAPI.DecryptAes (誤導性成功)
IL2CPP method map中找到明確的AES解密函式 OctoAPI_DecryptAes (0x08EBDE2C),
Frida hook成功攔截到輸入輸出:
輸入: 80 bytes 密文
輸出: 53 bytes → "https://asset.game-hololive-dreams.com/{o}..."
這其實是CDN資源URL範本的解密,不是模型檔案本身——是完全不同用途的AES呼叫, 容易誤以為找對了函式,實際上文不對題。教訓: 抓到「有輸出」不代表抓到「對的東西」, 要驗證輸出內容語意是否符合預期。
3. 嘗試Hook IL2CPP層的Cubism SDK API (失敗,找到根因)
在map中找到標準Cubism Unity SDK的5個相關方法並全部Hook:
CubismMoc.CreateFrom 0x08AFD25C
CubismMoc.get_Bytes 0x08AFD4F8
CubismMoc.get_UnmanagedMoc 0x08AFD508
CubismMoc.AcquireUnmanagedMoc 0x08AFD538
CubismModel.InstantiateFrom 0x08AFD890
玩家實際操作、角色確實有渲染,但5個Hook全部零觸發。 根因: 這款遊戲的Cubism整合方式繞過了標準C# Wrapper層的這幾個入口, 或是這些方法在IL2CPP編譯後因為shared generic合併/inline,實際執行路徑不經過這裡。
4. 改攻原生Cubism Core函式庫 (成功)
Live2D Cubism Core本身是一個原生C函式庫,直接靜態連結進UnityFramework(沒有獨立.framework)。
用Frida 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: function(args) {
const addr = args[0], size = args[1].toInt32();
const data = addr.readByteArray(size); // 直接就是完整明文.moc3內容
send({event: "csmReviveMocInPlace", size: size}, data);
}
});
用Module.findExportByName而非硬編位址offset,天生免疫ASLR/重啟位址偏移問題,
比之前手動計算uf.base.add(offset)的方式更穩定可靠。
成果
Hook掛上後,玩家實際操作App(切換角色頁、進劇情觸發演出),每次載入新模型就直接攔截到完整明文moc3:
| 檔案 | 大小 | Magic | Version |
|---|---|---|---|
001.moc3 |
1,361,408 bytes | MOC3 |
5 |
002.moc3 |
2,060,992 bytes | MOC3 |
5 |
003.moc3 |
2,088,704 bytes | MOC3 |
5 |
004.moc3 |
1,849,984 bytes | MOC3 |
5 |
4個模型全部驗證通過標準Live2D Cubism 4/5 MOC3檔頭,可直接被官方Cubism Viewer/SDK讀取。
存放於 live2d_models/。
尚未完成: 貼圖(texture atlas)提取
moc3只有骨骼/形變資料,角色外觀貼圖(texture atlas)是分開儲存的,目前尚未成功攔截。
已嘗試且失敗的路徑(依序,全部靠Frida hook + 玩家配合觸發)
- UnityPy掃描1098個Unity AssetBundle — 只找到UI圖示/特效貼圖(511個Texture2D),
完全沒有角色本體貼圖。這批bundle開頭都是明文
UnityFS,代表角色貼圖走的是別的、 仍加密/隱藏的資源桶。 OctoAPI.DecryptAeshook — 抓到的是CDN網址解密,跟貼圖無關(詳見上方第2節)。- IL2CPP層
Texture2D.LoadRawTextureData/LoadImageInjected方法 hook (0x09E93694/0x09F44A0C) — 掛上後零觸發。Unity的AssetBundle反序列化管線 對Texture2D不走這兩個公開Scripting API,是引擎內部C++直接建構,繞過腳本層。 UNITY_LZ4_decompress_safehook — 函式本身被大量呼叫(每次128KB固定分塊), 但這是整個引擎共用的LZ4,訊號太雜;且bundle是跨多個chunk拼接,單一次呼叫抓不到完整內容。- 記憶體暴力掃描
UnityFS字串 — 兩次嘗試:- 第一次(嚴格條件: magic在buffer開頭64bytes內) → 0命中
- 第二次(放寬條件、含唯讀區段) → 654命中,但集中在同一塊29MB區域, dump後檢查header全部是0——證實這只是引擎內部拿"UnityFS"當格式辨識用的字串常數 到處複製,不是真正的bundle資料。教訓: 命中不代表是對的,一定要驗證後續header/內容是否合理。
- Objective-C層Metal texture上傳函式 hook(
MTKTextureUploader.copyBytes:toTexture:...、AGXTextureLayout.copyFromLinearBytes:...) — 兩個都掛上成功(hooked: true), 但玩家實際觸發角色渲染後零命中。 這一步被使用者明確指出是在瞎猜——純粹憑函式名稱關鍵字搜尋、沒有真正驗證這是否為 遊戲實際使用的呼叫路徑,也沒有工具能證實。已停止繼續盲猜這個方向。
7. 停止瞎猜,改用反編譯追真實呼叫鏈 (成功找到正確路徑)
被使用者指出前面幾次是盲猜函式名稱後,改變方法論:從已知的C# Cubism SDK入口, 用rz-ghidra反編譯實際程式碼,追蹤真正的資料流向,而非憑名稱猜測。
- 在map中找到
Live2D.Cubism.Rendering.CubismRenderer的貼圖相關方法:CubismRenderer.set_MainTexture(Texture2D) 0x08AE1838 CubismRenderer.get_MainTexture() (get) CubismRenderer.ApplyMainTexture() 0x08AE1994 CubismRenderer.TryInitializeMainTexture() 0x08AE2C70 - 反編譯
TryInitializeMainTexture(0x08AE2C70),證實(非猜測):- 該函式讀取
this+0x80欄位(即已儲存的Texture2D參考) - 若欄位是null,會呼叫
sym.func.08ae1838(this, 0)即set_MainTexture - 證明
set_MainTexture就是Texture2D物件被賦值進CubismRenderer的那個確切時刻, 第二個參數(args[1])就是一個已經完全載入好、可直接使用的Texture2D物件參考
- 該函式讀取
- 因為拿到的是活的Texture2D物件參考(不是原始bytes),不需要再去猜「像素資料在哪裡」——
直接主動呼叫Unity自己的
UnityEngine.ImageConversion.EncodeToPNG(Texture2D)(0x09F44568), 把這個物件參考丟進去,引擎自己吐出PNG bytes。完全不需要理解貼圖格式(ASTC/ETC2/壓縮與否)、 不需要碰GPU層、不需要猜測native函式——直接借用官方API的既有功能。
const setMainTexAddr = uf.base.add(0x08AE1838);
const encodeToPng = new NativeFunction(uf.base.add(0x09F44568), "pointer", ["pointer"]);
Interceptor.attach(setMainTexAddr, {
onEnter: function(args) {
const texPtr = args[1]; // Texture2D* value,已完全載入好的物件
const byteArrPtr = encodeToPng(texPtr); // 主動呼叫IL2CPP方法,取得PNG bytes
const length = byteArrPtr.add(0x18).readU64().toNumber();
const data = byteArrPtr.add(0x20).readByteArray(length);
send({event: "texture_png", len: length}, data); // 直接就是可開啟的PNG檔案
}
});
方法論教訓: 遇到「不知道資料在哪一層被處理」的情況時,與其猜測native/GPU層的函式,
不如往回找該資料最終會被賦值給哪個「已知、公開、有文件」的高階物件(這裡是Texture2D),
一旦抓到該物件的參考,直接呼叫它自己公開API(EncodeToPNG)即可,
比起去猜測底層native實作細節可靠得多、也更好維護。
腳本: hook_texture_encodepng.py
7.1 修正: set_MainTexture假設證實錯誤 (繼續反編譯後推翻)
實際掛上診斷Hook(純計數,不呼叫EncodeToPNG)驗證 set_MainTexture 在完整180秒操作視窗內
零次呼叫。回頭重新逐行檢視 TryInitializeMainTexture 反編譯結果,發現先前解讀有誤:
set_MainTexture唯一被呼叫的分支,第二參數傳的是常數0(null)——是清空欄位的 防呆/重置邏輯,不是真正賦值texture的路徑- 真正的貼圖套用邏輯在另一個分支,直接呼叫
Material.SetTextureImpl_Injected, 完全繞過CubismRenderer.set_MainTexture這個C# property setter - 追查該分支提供texture參考的函式
sym.func.09e5096c,反編譯後發現它其實只是呼叫Shader.PropertyToID——把"_MainTex"這類屬性名稱字串轉成整數ID,根本不是在取得texture本體 - 真正的texture物件來自
*(this+0x80)欄位的讀取(不是寫入),證實這個欄位早在 函式執行前就已經被填好——代表texture是透過Unity引擎自己的原生反序列化流程, 在GameObject/prefab實例化當下直接寫入欄位的,完全不經過任何C# accessor
結論: 已達到單純反編譯法的實務極限。 Unity引擎核心的原生反序列化程式碼沒有匯出符號、 高度inline/優化,繼續往下追的成本效益已經不合理。建議在此改用Xcode Metal Frame Capture (ground truth,非猜測/反編譯)作為下一步。
8. 改用IL2CPP原生嵌入API主動查詢物件 (管線驗證成功)
被問「真的沒辦法了嗎」後重新思考:不需要攔截「texture欄位被寫入」那個瞬間,
可以改成主動查詢當下記憶體裡所有已存在的CubismRenderer物件,直接讀取它們的texture。
這需要用到Unity/IL2CPP官方公開的embedding API(il2cpp_*系列函式,設計給原生外掛使用):
Module.findExportByName()(動態匯出表)找不到這些函式 — 因為release建置把它們從 dyld export trie裡隱藏了- 但改用
rizin(is指令)讀取完整Mach-O符號表(LC_SYMTAB,19584筆本地符號), 發現這些函式仍然存在、只是沒有被匯出成動態符號,可以直接用檔案offset當虛擬位址呼叫 (前提:__TEXTsegmentvmaddr==fileoff==0,跟本專案先前確認過的規則一致)
用到的位址:
il2cpp_domain_get 0x002d4e18
il2cpp_domain_get_assemblies 0x002d4e24
il2cpp_assembly_get_image 0x002d487c
il2cpp_class_from_name 0x002d48b4
il2cpp_class_get_type 0x002d495c
il2cpp_type_get_object 0x002d53cc
il2cpp_array_length 0x002d4860
Object.FindObjectsOfType(Type) 0x09ED7AC8
CubismRenderer.get_MainTexture 0x08AE1830
ImageConversion.EncodeToPNG 0x09F44568
完整查詢鏈(全部用NativeFunction直接呼叫,不透過Hook攔截,隨時可主動呼叫):
il2cpp_domain_get()
-> il2cpp_domain_get_assemblies(domain, &count)
-> 逐一 il2cpp_assembly_get_image(assembly)
-> il2cpp_class_from_name(image, "Live2D.Cubism.Rendering", "CubismRenderer") (找到就停)
-> il2cpp_class_get_type(cubismClass)
-> il2cpp_type_get_object(il2cppType) // 組出 System.Type 物件
-> FindObjectsOfType(typeObj) // 拿到目前記憶體裡所有CubismRenderer實例陣列
-> 對每個實例呼叫 get_MainTexture(instance) // 拿Texture2D參考
-> 對每個Texture2D呼叫 EncodeToPNG(texture) // 直接編碼成PNG bytes
實測結果(2026-07-23): 完整跑過一次,零錯誤:
assemblies: 262
found CubismRenderer class: 0x13b271770
found CubismRenderer instances: 0 (當下title畫面沒有角色,屬預期)
成功解析262個assembly、正確拿到CubismRenderer的IL2CPP Class物件、FindObjectsOfType呼叫
無crash無錯誤返回。整條pipeline技術上完全驗證可行,只差「呼叫當下記憶體裡要有角色實例」
這個時機條件——下次有角色畫面開著時執行 dump_all_textures.py,
理論上會一次抓出當下所有角色的完整貼圖atlas,不需要精準卡在某個瞬間攔截。
這條路徑比前面所有Hook猜測都更可靠: 不是在賭「猜對了某個函式會被呼叫」, 而是「主動去問IL2CPP執行環境:現在記憶體裡有哪些CubismRenderer物件」, 是查詢而非攔截,時機掌握在自己手上。
腳本: dump_all_textures.py (可獨立執行,不需要respawn重啟遊戲,但目前為求開機穩定性
仍先kill+spawn+resume+等15秒;若改成直接attach現有已在角色畫面的進程,理論上也可行,
但attach既有繁忙進程偶爾會遇到frida timeout,需要多試幾次)
9. IL2CPP查詢管線實測 — 找出真正的根本原因 (最終結論)
被問「真的沒辦法了嗎」後,把第8節的查詢pipeline接上真實觸發的角色場景實測:
- 成功找到 660個
CubismRenderer實例(多個角色、每個角色數十個部位各一個renderer元件) - 逐欄位程式化掃描(而非猜offset),確認texture欄位在
this+0x80,660個實例cNullTex: 0——全部成功取得Texture2D物件參考,證實查詢管線完全正確 - 660個實例中
cDup: 658——代表其實只有2張獨立的atlas貼圖(2個角色各一張, 部位共用同一張atlas完全合理) - 對這2張唯一的貼圖呼叫
EncodeToPNG,兩次都crash(access violation accessing 0x0), 且無論傳NULL或用il2cpp_class_get_method_from_name動態解析出真正的MethodInfo指標 結果完全相同
根本原因確認: 呼叫EncodeToPNG本身在丟一個managed例外,不是呼叫方式錯誤。
最合理且有技術根據的解釋:手機遊戲的AssetBundle貼圖幾乎必定關閉「Read/Write Enabled」
(業界標準記憶體優化),這種貼圖上傳GPU後,CPU端完全不會保留像素副本,
呼叫Texture2D.EncodeToPNG()對不可讀貼圖必定丟UnityException: Texture is not readable。
這也回頭解釋了本文件第5節「記憶體掃描找UnityFS」、第6節「LZ4/Metal native hook」
全部找不到解碼後像素資料的原因——根本沒有CPU端的像素副本存在,不是找不到,是真的不存在。
最終結論: 像素資料只存在GPU顯存(VRAM)裡,唯一能拿到的方法是直接讀GPU顯存內容 ——也就是Xcode Metal Frame Capture。這不再是「保守備案」,而是基於實測排除所有 CPU端路徑後,唯一technically correct的方法。
10. 純靜態定位載入路徑 → 找到真正的資料來源 (Opus重做,大突破)
換Opus後、依使用者要求「先定位再Hook、別亂槍」,改用嚴謹靜態分析:
步驟1: 靜態找AssetBundle載入入口 — 在map中找到AssetBundleLoadInterceptor(3種多載: byte[]/Stream/path)、
OctoAssetBundleStream(自訂解密Stream)。反編譯OctoAssetBundleStream.Read(0x09B3848C)確認它
helper->vtable[0x338](buffer,offset,count)即時解密填入buffer——這是「解密後UnityFS交給Unity」的交接點。
步驟2: 一次性診斷12個載入入口 — 掛上所有Load路徑,實測發現角色資源只走AssetBundle.LoadFromFile(Async),
從兩個位置:
- App內建
hololiveDreams.app/Data/Raw/aa/iOS/*.bundle(1757個, 未加密UnityFS, 一直在解壓的IPA裡) - octo下載快取
Library/octo/v1/...(存取時原地解密到磁碟成UnityFS,跟音效同機制)
步驟3: moc3當時間錨點 — 同時HookcsmReviveMocInPlace(每次模型載入觸發)+全部file路徑,
精準定位Pekora(角色ID 00019)模型bundle的實際路徑。確認裝置上該檔案已是UnityFS(解密)。
步驟4: 讀出runtime貼圖名字 — 用IL2CPP embedding API(il2cpp_class_from_name等,從Mach-O
本地符號表挖出未匯出的位址)+ Object.GetName,查詢執行中660個CubismRenderer,
得知Pekora的Live2D atlas名為 t_live2d_00019-nrml-0016-00_texture_00(327個部位共用同一張)。
步驟5: 全面掃描磁碟 — 重新從裝置拉取當前完整octo快取(4942檔),用UnityPy掃描:
- ✅ 找到並抽出 19個角色的
img_chr_full_2d_XXXXX全身立繪(全部4096×4096無損PNG),含Pekora - ❌ 但runtime那張
t_live2d_00019-...atlas 完全不在磁碟(octo新快取+1757內建aa全掃過)
最終結論(Live2D)
| 資產 | 狀態 | 位置/方法 |
|---|---|---|
| moc3模型 ×4 | ✅ 已取得 | native csmReviveMocInPlace hook |
| 角色全身立繪 ×19 (4096²) | ✅ 已取得 | octo快取解密後UnityPy抽出 img_chr_full_2d |
Live2D runtime atlas t_live2d_* |
⚠️ 僅存GPU | 來源bundle只在記憶體解密、從不落地 |
關鍵技術發現: 這遊戲的資源分兩種落地策略:
- 大部分資源(音效/影片/角色立繪/UI): 存取時原地解密到磁碟→可直接從octo快取UnityPy抽取
- Live2D模型專用atlas(
t_live2d_*): 走純記憶體解密路徑,解密後直接上傳GPU, CPU端副本立即釋放,磁碟上永遠是加密態→標準逆向拿不到,只能GPU readback或Metal Frame Capture
img_chr_full_2d_00019(已取得)是Pekora的完整角色美術;t_live2d_00019(GPU-only)是moc3模型
實際綁定、UV對應的atlas版本。要pixel-perfect還原可動的Live2D模型需要後者,但前者已是完整可用的角色圖。
產出:
character_art/— 19個角色4096×4096全身立繪 (40MB)live2d_models/— 4個moc3模型
剩餘唯一選項: Xcode Metal Frame Capture (取得moc3專用atlas)
放棄繼續猜測native hook點,改用Apple官方GPU除錯工具,是ground truth而非猜測:
- 在Mac的Xcode裡對連接中的裝置做GPU Frame Capture,可以逐一檢視該幀所有draw call 實際綁定的輸入貼圖(Resources/Textures面板)
- 關鍵優勢: 抓到的是「輸入端的原始貼圖atlas」,不是「最終合成好的角色畫面」—— Live2D渲染是多個部位的draw call共用同一張atlas貼圖、靠UV座標裁切拼接, Frame Capture能拿到這張共用的原始atlas,語意上等同於資源包裡的原始貼圖檔案
- 缺點: 需要真人操作Xcode + 實機连接,無法透過Frida/命令列自動化完成, 且只能拿到「解碼後的最終像素」,不是加密資源包裡那個原始壓縮格式的位元組(但對實際用途來說夠用)
角色名稱對應關係未知
目前4個moc3檔案只用流水號命名,不知道哪個對應哪個角色/服裝/表情狀態, 需要在Hook的同時額外記錄當下畫面的角色資訊,或在native call前後找出附近的角色ID/名稱字串。
觸發覆蓋率限制
目前只能觸發使用者「已解鎖」的少數角色與劇情點,覆蓋率有限, 之後要抓特定角色需玩家實際導覽到該角色出現的畫面觸發。
核心方法論總結(可複用到其他遊戲/其他資源類型)
當「先找出加密演算法再解密」這條路遇到瓶頸(多層加密、原生層混淆、無法定位確切函式)時, 可以改用「找到資料被正確使用的那一刻,直接在那裡攔截明文」的策略:
- 找出目標資料格式本身的公開/開源函式庫(這裡是Live2D Cubism Core SDK)
- 該函式庫必定有「反序列化/初始化」的入口函式,且該函式收到的參數一定是已完全解密的原始資料
- 原生C函式庫通常會在二進位中保留清楚的匯出符號名稱(即使上層C#/IL2CPP呼叫路徑複雜難追),
直接用
Module.enumerateExports()枚舉搜尋比追IL2CPP呼叫鏈更有效率 - Hook該入口函式即可攔截明文,完全不需要理解中間經過了幾層加密、用了什麼演算法
11. 通用Bundle解密 — 最終完全破解 (2026-07-24)
使用者質疑「檔案有存手機、只是沒解密落地,能不能反推加密方式做通用解密」——方向完全正確。
已知明文攻擊: 所有UnityFS bundle前30 bytes固定(UnityFS\0\0\0\0\x08 + 5.x.x\0 + 0.0.0\0+nulls)。
對加密bundle做 密文 XOR 已知明文 還原keystream,分析發現:
- keystream偶數bytes在某K值下解出可讀ASCII資產名(如
fbx_mdl_env_par) - 確認就是音效那套演算法(char/~char交錯 + ROR-hash求K),只是無QUAVMAGIC前綴、從byte0起
取得hash→address對照: 重新解析先前dump的octo catalog記憶體(protobuf), 這次抽出全部36184筆(不只音效),涵蓋 img/mdl/fbx/live2d 等所有bundle類型。
通用解密驗證: 對加密bundle用 octo_decrypt.decrypt_bundle(cipher, address) → 200/200 解出合法UnityFS。
最終成果(Live2D完整破解):
all_live2d_atlas/— 80個角色的Live2D貼圖atlas,全部4096×4096無損PNG (392MB) 含Pekorat_live2d_00019-nrml-0016-00_texture_00(正是moc3綁定的那張)live2d_models/— 4個moc3 (native hook)- 解密後的完整模型bundle含 ArtMesh網格/Param參數/HitArea
結論: octo_decrypt.py 現在是完整通用解密器——音效(decrypt_bytes)+ bundle(decrypt_bundle),
配合catalog的hash→address對照,可離線解密任何octo資源,不需要Frida/GPU/實機。
之前「只在記憶體/GPU」的判斷是被「解密不落地」誤導——資料一直加密躺在磁碟,只是需要正確的key。
12. Physics 還原 (physics3.json) — 完成
物理被 Cubism Unity SDK 烘焙成 CubismPhysicsController._rig(CubismPhysicsRig) 序列化資料。
IL2CPP build 無 typetree, 但 Cubism SDK 開源, 依其欄位佈局手動反序列化即可:
- 用 WebFetch 抓 Live2D/CubismUnityComponents 原始碼確認每個類的
[SerializeField]順序 - 寫二進位 reader (處理 Unity 對齊: string/bool 後 align 到 4)
- 驗證: 解析完 N 個 SubRig 後應剛好剩 Gravity(Vec2)+Wind(Vec2)+Fps(float)。 Pekora 實測 Gravity=(0,-1) Wind=(0,0) Fps=30 —— 標準 Cubism 預設值,證明 byte-perfect
結果: 40 physics settings / 107 input / 57 output / 97 vertices,
Input/Output 語意合理 (ParamEyeLOpen→ParamHighlightRSwing)。
80 個模型全數還原成標準 physics3.json。實作見 hohohololive/live2d_physics.py。
13. Motion 還原 (motion3.json) — 待實作
動作曲線在 AnimationClip 的 Unity 壓縮串流格式 (m_MuscleClip: StreamedClip/DenseClip/ConstantClip),
m_FloatCurves 為空; 綁定 (m_ClipBindingConstant.genericBindings, 35條) 用 CRC32 hash 標識參數。
還原需實作 Unity 動畫解壓器 (AssetStudio 等級 ~300行) + hash→ParamId 反查
(可借 Live2DMotionDefine MonoBehaviour 內的可讀 Parameters/ParamXXX 字串)。
資料已解密保存, 列為後續。
13.1 Motion 還原 (motion3.json) — 完成
自行實作 Unity 壓縮 AnimationClip 解碼器:
- StreamedClip: uint32[] 串流, 每 key = index + cubic coeff[4], 關鍵幀值 = coeff[3]; 首尾為 ±FLT_MAX 邊界幀(切線用), 需跳過
- ConstantClip: 常數曲線; DenseClip: 此遊戲未用
- curve 全域索引 [0,streamed)+dense+constant 對應 genericBindings
- binding path = CRC32("Parameters/") — 用參數名(取自 bundle 內 Live2DMotionDefine 的 可讀字串)建 hash→id 反查表
驗證: Pekora anger-01 → Duration 1.967s, 35 curves, 時間全落 [0,1.967],
ParamAngleY∈-11.52,6.02、ParamEyeOpen∈[0,1] — 語意正確。
104 個情緒動作(anger/cry/happy/doya…)全轉出標準 motion3.json。實作見 hohohololive/live2d_motion.py。
最終狀態
| 資產 | 狀態 |
|---|---|
| moc3 + 貼圖 atlas | ✅ |
| physics3.json | ✅ (byte-perfect, 80模型) |
| motion3.json | ✅ (104動作) |
| expression (exp3.json) | ✅ (412個) |
13.2 Expression 還原 (exp3.json) — 完成
同 physics 手法 (Cubism SDK 佈局手動反序列化):
CubismExpressionData: Type(str), FadeInTime(f), FadeOutTime(f), Parameters[]
SerializableExpressionParameter: Id(str), Value(f), Blend(int: 0=Overwrite/1=Add/2=Multiply)
驗證: anger 表情 24 參數 (ParamEyeOpen/Smile…) Value/Blend 語意正確, 解析落點準確。
412 個表情全轉出。實作見 hohohololive/live2d_expression.py。
✅ Live2D 完全破解
moc3 + 4096貼圖 + physics3(80) + motion3(104) + exp3(412) —— 官方 Cubism 完整可動模型全要素到齊。
補記:資料來源與完整性(手機快取 vs CDN)
最初的 Live2D 抽取是在做出 CDN 下載器之前跑的,所以那批資料 100% 來自手機的 octo 快取 —— 也就是「這台裝置實際下載過的部分」。 之後雖然從 CDN 補下載了缺的一半,卻一直沒有重跑抽取。
| 類別 | catalog | 手機快取 | CDN 下載 | 舊抽取 | 重跑後 |
|---|---|---|---|---|---|
live2d_mdl |
170 | 80 | 90 | 80 | 170 |
live2d_mot |
160 | 104 | 56 | 104 | 159 |
live2d_exp |
844 | 412 | 432 | 412 | 844 |
動作差的那 1 個是 live2d_mot_idle-01_lv03 —— 該 bundle 裡只有
MonoBehaviour,根本沒有 AnimationClip,不是抽取失敗。
教訓:資料的「完整度」取決於當時的來源,補了來源就要重跑產物。 這批舊資料看起來一切正常(80 個模型全部可用、路徑驗證全過), 只是少了一半——沒有任何錯誤訊息會提醒你這件事。
為此改動的工具
catalog.load()現在同時吃舊版{hash: address}與新版parse_entries的條目陣列- 新增
catalog.build_file_index(*roots):可同時索引多個快取來源 (手機 octo 與 CDN 目錄的檔名規則不同,統一以 md5 當 key) - CLI 的
octo_dir參數改成可用逗號分隔多個目錄:
python -m hohohololive build-live2d "octo_now/v1,_cdn_cache" \
--catalog catalog_full.json -o _extracted_live2d_full
重新部署到 KKKanade 後:170 個模型、170/170 對到角色名、路徑零缺檔。 新增的 7 位角色(00009 紫咲シオン、00038 沙花叉クロヱ、04004 Gawr Gura、 04005 Watson Amelia、04009 Ceres Fauna、04011 Nanashi Mumei、 06001 火威青)以貼圖圖集逐一辨識並用編號序列交叉驗證。