muvluv girlsgarden解包求教

我现在在解包muv的资源,让ai写了个脚本但只能读取catalog中的包名,解包时如下图所示没有文件路径的相关信息解包后整理起来很不方便,请问大手子们有没有方法查看catalog的原数据看看有没有路径信息

image

ui_catalog.zip (333.9 KB)

顶一下

这仅仅只是一个测试,并没有仔细检查,库要求py版本>=3.12。

Test
import sys
import json
from pathlib import Path
import AddressablesTools

def parse_and_export_to_json(file_path):
    """解析Addressables目录文件,关联资源与bundle,并导出为JSON文件"""
    try:
        # 1. 读取并解析文件
        print(f"=" * 100)
        print(f"正在读取文件: {file_path}")
        print(f"=" * 100)
        data = Path(file_path).read_bytes()
        catalog = AddressablesTools.parse_binary(data)
        
        if not hasattr(catalog, 'Resources'):
            print("错误: 解析结果缺少Resources属性")
            return
        
        resources = catalog.Resources
        print(f"成功解析,Resources字典共 {len(resources)} 个条目")

        # 2. 收集bundle信息
        bundle_map = {}  # key: bundle名称, value: bundle详情
        bundle_names = set()
        
        print(f"\n步骤1:收集所有bundle信息...")
        for res_key, locators in resources.items():
            if isinstance(res_key, str) and res_key.endswith(".bundle") and locators:
                loc = locators[0]
                bundle_info = {
                    "name": res_key,
                    "internal_id": getattr(loc, 'InternalId', ''),
                    "primary_key": getattr(loc, 'PrimaryKey', res_key),
                    "crc": getattr(getattr(loc.Data, 'Object', None), 'Crc', None),
                    "size": getattr(getattr(loc.Data, 'Object', None), 'BundleSize', None),
                    "assets": set()  # 用set去重
                }
                bundle_map[res_key] = bundle_info
                bundle_names.add(res_key)
        
        print(f"步骤1完成:共收集 {len(bundle_map)} 个bundle")

        # 3. 关联资源与bundle
        unassociated_assets = set()
        processed_assets = set()
        
        print(f"\n步骤2:通过Dependencies关联资源与bundle...")
        for res_key, locators in resources.items():
            if (isinstance(res_key, str) and res_key.endswith(".bundle")) or res_key in processed_assets:
                continue
            processed_assets.add(res_key)
            asset_associated = False
            
            for loc in locators:
                if hasattr(loc, 'Dependencies') and loc.Dependencies:
                    for dep in loc.Dependencies:
                        dep_primary_key = getattr(dep, 'PrimaryKey', None)
                        if dep_primary_key and dep_primary_key in bundle_names:
                            bundle_map[dep_primary_key]["assets"].add(res_key)
                            asset_associated = True
            
            if not asset_associated:
                unassociated_assets.add(res_key)
        
        print(f"步骤2完成:")
        print(f"  - 成功关联资源数: {len(processed_assets) - len(unassociated_assets)}")
        print(f"  - 未关联资源数: {len(unassociated_assets)}")

        # 4. 整理JSON数据结构(转换为可序列化类型)
        json_data = {
            "metadata": {
                "total_bundles": len(bundle_map),
                "total_assets": len(processed_assets),
                "associated_assets": len(processed_assets) - len(unassociated_assets),
                "unassociated_assets": len(unassociated_assets),
                "source_file": str(file_path),
            },
            "bundles": [],  # 包含资源的bundle
            "empty_bundles": [],  # 无资源的bundle
            "unassociated_assets": sorted(list(unassociated_assets))  # 未关联的资源
        }

        # 填充有资源的bundle
        for bundle in bundle_map.values():
            # 转换set为sorted list(JSON不支持set)
            assets_list = sorted(list(bundle["assets"]))
            bundle_dict = {
                "name": bundle["name"],
                "internal_id": bundle["internal_id"],
                "primary_key": bundle["primary_key"],
                "crc": bundle["crc"],
                "size": bundle["size"],
                "asset_count": len(assets_list),
                "assets": assets_list
            }
            
            if assets_list:
                json_data["bundles"].append(bundle_dict)
            else:
                json_data["empty_bundles"].append(bundle_dict)

        # 5. 生成JSON文件名(与输入bin文件同名)
        input_path = Path(file_path)
        json_file_name = f"{input_path.stem}.json"  # 例如 catalog.bin → catalog.json
        json_file_path = input_path.parent / json_file_name

        # 6. 保存JSON文件
        with open(json_file_path, "w", encoding="utf-8") as f:
            json.dump(json_data, f, ensure_ascii=False, indent=2)
        
        print(f"\n" + "=" * 100)
        print(f"JSON文件已保存: {json_file_path}")
        print(f"  - 包含资源的bundle数: {len(json_data['bundles'])}")
        print(f"  - 空bundle数: {len(json_data['empty_bundles'])}")
        print(f"  - 未关联资源数: {len(json_data['unassociated_assets'])}")
        print("=" * 100)

    except FileNotFoundError:
        print(f"错误: 文件 '{file_path}' 不存在,请检查路径!")
    except Exception as e:
        print(f"解析过程中发生错误:{str(e)}")
        import traceback
        print("详细错误信息:")
        traceback.print_exc()

if __name__ == "__main__":
    if len(sys.argv) > 1:
        bin_file_path = sys.argv[1]
    else:
        bin_file_path = input("请输入Addressables二进制目录文件(.bin)的路径: ").strip()
    
    parse_and_export_to_json(bin_file_path)

1 个赞

我试了一下可用,接下来就是如何在解包时利用了

让ai写的unitypy脚本没办法解包,查了一下ai会将某个属性写错,我没学过这方面的知识只好慢慢看一下了

教程已寫好 : )
教程圖片和教程詳細文字敘述
需要的工具軟件與python腳本都放在懶人包內

準備工具 - Fiddler

Fiddler
解包必備工具

Fiddler能捕獲HTTP和HTTPS流量
並將其記錄下來供用戶查看
它通過使用自簽名證書實現中間人攻擊來進行日誌記錄

這個安裝程式是舊版的
因為用習慣了

主要用來看遊戲數據包的網址
可以全選URL再滑鼠右鍵複製URL
將URL貼到純文字文本文件上
建議搭配EmEditor v21.2.1 文字編輯器使用

安裝完後設定
Options —> HTTPS —> Capture HTTPS CONNECTs (勾選) 擷取HTTPS加密連線
Options —> HTTPS —> Decrypt HTTPS traffic (勾選) 解密HTTPS流量
Options —> HTTPS —> Ignore server certificate errors (勾選) 忽略自我簽署憑證錯誤
Options —> HTTPS —> Check for certificate revocation (勾選) 檢查憑證是否已被撤銷
Options —> HTTPS —> Actions —>Trust Root Certificate 信任根憑證
信任根憑證: 指的是一套預先儲存在作業系統或瀏覽器中的根憑證
這些憑證是由夠有公信力的機構(如 Google, DigiCert 等)所簽署
Fiddler是用自己建立的憑證
有些遊戲不吃Fiddler憑證

以上設定
記得要按OK才會生效

記得Fiddler的憑證一定要安裝在電腦內
這樣才能正常解包 解密HTTPS流量

Fiddler的憑證沒安裝情況
無法解密HTTPS流量

Fiddler的憑證有安裝情況
可以解密HTTPS流量

安裝Fiddler的憑證 按OK
信任Fiddler的憑證 按OK

Tunnel to 字樣消失
正常解密HTTPS流量

進入遊戲後開始擷取網址
將擷取到的網址全選
滑鼠右鍵 Copy —> Just Url
滑鼠右鍵 新增一個純文字文本文件
在純文字文本文件內容貼上url
再使用EmEditor
抽取出想要的網址

搜尋(S) —> 尋找(F) 貼上想要找的網址 —> 抽出
這樣就可以列出特定網址
然後再另存新檔
繼續使用EmEditor編輯
或拿去餵wget下載資料
或拿去製作python腳本用

所以Fiddler + EmEditor 解包必備

目前有些遊戲
無法使用Fiddler解包獲取遊戲資源URL
只能用海底撈月法
使用DiskGenius 撈取遊戲資料

準備工具 - Python

python 官網

論壇大部份的解密、解包、批次處理文件
腳本都是使用python
因此需要安裝python

從官網下載python安裝程式
安裝過程這兩個選項一定要勾
Use admin privileges when installing py.exe (勾選安裝py.exe 時使用管理者權限)
Add python.exe to PATH (把Python加到路徑裡)

Add python.exe to PATH 非常重要定一要勾選
把程式加到系統路徑基本上就是讓系統認得這個程式和它的指令
所以舉例來說,如果你沒把Python加到路徑裡
你就不能在終端機打python 空格 指令
因為系統認不出來,但如果Python加到路徑裡了就能用
如果沒有把Python加到路徑裡
你在cmd.exe 輸入python 是沒有反應的
反之會出現目前python版本等一堆文字

另外順便做一件事
把C:\Windows\System32\cmd.exe 複製一份到桌面
是複製一份不是建立捷徑
這樣方便你測試python腳本

看到論壇有人貼python腳本文字時
將文字全部選擇後複製
滑鼠右鍵新增一個純文字文本文件(*.txt)
然後貼上
將純文字文本文件修改名與後綴(副檔名)
比如 : 新增 Text Document.txt 改成 png_resize.py
(資料夾選項記得設定成顯示文件的副檔名)

執行cmd.exe
輸入命令 python 腳本名稱
比如 python png_resize.py
這樣就執行python腳本

python腳本可以搭配bat批次檔
一樣滑鼠右鍵新增一個純文字文本文件(*.txt)
內文輸入

@echo off
python png_resize.py

將純文字文本文件修改名與後綴(副檔名)
比如 : 新增 Text Document.txt 改成 png_resize.bat
運行png_resize.bat後 可自動運行png_resize.py腳本
不用手動cmd輸入命令

另外python腳本運行時會常常缺套件
pip install 是用於在 Python 中安裝套件的指令
只需打開終端機或命令提示字元
輸入 pip install 套件名稱
即可從 Python 套件索引 (PyPI)
下載並安裝該套件及其依賴項
例如 : 若要安裝 requests 套件
則cmd命令輸入 pip install requests
有缺的話會告訴你缺哪個套件英文名稱
缺什麼補什麼就是了

準備工具 - EmEditor

EmEditor v21.2.1 - 解包必備文字編輯工具
內附安裝程式與序號
解包必備文字編輯工具
查看文件HEX好用
批次取代字串
多檔尋找字串

準備工具 - AnimeStudio

AnimeStudio - 針對AB包HEX開頭 - 6000.0.0f1

AB包最新版解包工具
針對 6000.0.0f1 文件HEX開頭
AB包 = Asset Bundle (*.unity3d *.bundle 或其他後綴)

unity3d version 手動帶入參考
6000.0.0f1
2022.3.5f1
2021.3.5f1
2020.3.5f1
2019.3.5f1

AnimeStudio下載

nightly.link
Repository Escartem/AnimeStudio
Workflow build.yml | Branch master
Choose one of the artifacts:

AnimeStudio-net8
https://nightly.link/Escartem/AnimeStudio/workflows/build/master/AnimeStudio-net8.zip

AnimeStudio-net9
https://nightly.link/Escartem/AnimeStudio/workflows/build/master/AnimeStudio-net9.zip

AnimeStudio所需Runtime下載

https://dotnet.microsoft.com/zh-tw/download/dotnet/thank-you/runtime-desktop-8.0.20-windows-x64-installer?cid=getdotnetcore

https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/8.0.20/windowsdesktop-runtime-8.0.20-win-x64.exe

https://dotnet.microsoft.com/zh-tw/download/dotnet/thank-you/runtime-desktop-9.0.9-windows-x64-installer?cid=getdotnetcore

https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/9.0.9/windowsdesktop-runtime-9.0.9-win-x64.exe

準備工具 - Python 腳本 : 自動檢查Spine PNG圖片尺寸是否與atlas文件記載一致

Python 腳本 - 自動檢查Spine PNG圖片尺寸是否與atlas文件記載一致

修改自yjzyl9008的腳本
不用手動指定目錄

讀取子目錄下 *.atlas 檔案
讀取atlas檔案中記載png圖檔長與寬尺寸大小資訊
再去比對同檔名png圖檔的長與寬尺寸資訊
是否與atlas檔案中的長與寬記載一致
不一致的話則修改png圖檔的長與寬

python腳本
直接放在
spine圖片最外圍的目錄
運行png_resize.bat
自動運行png_resize.py
自動開始檢查png圖片長與寬尺寸大小資訊是否與atlas文件記載一致
不一致的話則修改png圖檔的長與寬

準備工具 - skeletonViewer

SPINE官方出的 skeletonViewer
只支援PNG圖檔 不支援WEBP圖檔

要支援WEBP圖檔
只能使用網友做的skeletonViewer才行

安裝完這兩個
skeletonViewer全部版本都可以正常運行
jre-8u461-windows-x64.exe (開啟jar文件)
jdk-25_windows-x64_bin.msi (方便開啟各種版本skeletonViewer)

使用瀏覽器開啟遊戲網址 + Fiddler 解包網址

運行Fiddler

使用瀏覽器 Google Chrome 和 Microsoft Edge開啟遊戲網址

進入遊戲後 Fiddler 左側會一直有網址刷新
遊戲隨便玩幾下後
差不多得到遊戲資源包網址
然後將遊戲URL全部選擇
滑鼠右鍵 → Copy → Just Url
貼到新的純文字文本檔案內
再使用EmEditor整理網址

整理遊戲資源包清單URL

整理遊戲資源包清單URL

整理後看到遊戲資源包清單bin文件

https://assets.muvluv-girls-garden.com/production-public/660691da-a7cd-47ad-a254-2ce39f3e4352/WebGL/Desktop/StreamingAssetsMobile/aa/catalog.hash

https://assets.muvluv-girls-garden.com/production-public/660691da-a7cd-47ad-a254-2ce39f3e4352/WebGL/Desktop/StreamingAssetsMobile/aa/catalog.bin

https://assets.muvluv-girls-garden.com/production-public/660691da-a7cd-47ad-a254-2ce39f3e4352/BundleAssets/X/WebGL/ASTC/catalog.hash

https://assets.muvluv-girls-garden.com/production-public/660691da-a7cd-47ad-a254-2ce39f3e4352/BundleAssets/X/WebGL/ASTC/catalog.bin

https://assets.muvluv-girls-garden.com/production-public/660691da-a7cd-47ad-a254-2ce39f3e4352/BundleAssets/X/WebGL/ASTC/ui_catalog.hash

https://assets.muvluv-girls-garden.com/production-public/660691da-a7cd-47ad-a254-2ce39f3e4352/BundleAssets/X/WebGL/ASTC/spine_catalog.hash

https://assets.muvluv-girls-garden.com/production-public/660691da-a7cd-47ad-a254-2ce39f3e4352/BundleAssets/X/WebGL/ASTC/vfx_catalog.hash

https://assets.muvluv-girls-garden.com/production-public/660691da-a7cd-47ad-a254-2ce39f3e4352/BundleAssets/X/WebGL/ASTC/sound_catalog.hash

https://assets.muvluv-girls-garden.com/production-public/660691da-a7cd-47ad-a254-2ce39f3e4352/BundleAssets/X/WebGL/ASTC/ui_catalog.bin

https://assets.muvluv-girls-garden.com/production-public/660691da-a7cd-47ad-a254-2ce39f3e4352/BundleAssets/X/WebGL/ASTC/spine_catalog.bin

https://assets.muvluv-girls-garden.com/production-public/660691da-a7cd-47ad-a254-2ce39f3e4352/BundleAssets/X/WebGL/ASTC/vfx_catalog.bin

https://assets.muvluv-girls-garden.com/production-public/660691da-a7cd-47ad-a254-2ce39f3e4352/BundleAssets/X/WebGL/ASTC/sound_catalog.bin

可以先下載bin文件
由bin文件可知遊戲資源有5種類別 : catalog、spine、sound、ui、vfx

其中catalog.bin有兩種url
但其實是一樣的文件
連hash都一樣

這邊可以由Fiddler解包知道url的規律
方便等下使用python腳本
生成遊戲資源包下載url清單

資源包文件名要加上兩種前綴

https://assets.muvluv-girls-garden.com/production-public/660691da-a7cd-47ad-a254-2ce39f3e4352/WebGL/Desktop/StreamingAssetsMobile/aa/

https://assets.muvluv-girls-garden.com/production-public/660691da-a7cd-47ad-a254-2ce39f3e4352/BundleAssets/X/WebGL/ASTC/

python腳本 - 解密bin文件並生成json

python腳本 - 解密bin文件並生成json

修改自luojun1大的腳本
讀取目錄下所有bin文件
bin解密並還原json
並自動生成json文件

運行bin2json.bat
自動運行bin2json.py

catalog.bin 生成 catalog.json
sound_catalog.bin 生成 sound_catalog.json
spine_catalog.bin 生成 spine_catalog.json
ui_catalog.bin 生成 ui_catalog.json
vfx_catalog.bin 生成 vfx_catalog.json

python腳本 - json轉換成url下載清單

python腳本 - json轉換成url下載清單

讀取目錄下catalog.json檔案
讀取目錄下sound_catalog.json檔案
讀取目錄下spine_catalog.json檔案
讀取目錄下ui_catalog.json檔案
讀取目錄下vfx_catalog.json檔案

這些json檔案 由一連串很長的字串組成
字串頭為 {
字串尾為 }

python腳本功能
讀取"name": " 與 ", 之間的字串
並逐行重新生成

狀況 1
讀取到catalog.json 這個檔案時
比如
“name”: “021bcfd876bf790621550b8b8a94d264.bundle”,
取中間字串 021bcfd876bf790621550b8b8a94d264.bundle
並逐行重新生成

最後每行字串前面統一加上新字串https://assets.muvluv-girls-garden.com/production-public/660691da-a7cd-47ad-a254-2ce39f3e4352/WebGL/Desktop/StreamingAssetsMobile/aa/WebGL/
並生成catalog_url.txt 文件

狀況 2
讀取到sound_catalog.json、spine_catalog.json、ui_catalog.json、vfx_catalog.json這些檔案時
比如
“name”: “001d17b0dd44a64eba4bf49b837b5c14.bundle”,
取中間字串 001d17b0dd44a64eba4bf49b837b5c14.bundle
並逐行重新生成

最後每行字串前面統一加上新字串https://assets.muvluv-girls-garden.com/production-public/660691da-a7cd-47ad-a254-2ce39f3e4352/BundleAssets/X/WebGL/ASTC/
並生成sound_catalog_url.txt、spine_catalog_url.txt、ui_catalog_url.txt、vfx_catalog_url.txt 文件

運行json2url.bat
自動運行json2url.py

catalog.json 生成 catalog_url.txt
sound_catalog.json 生成 sound_catalog_url.txt
spine_catalog.json 生成 spine_catalog_url.txt
ui_catalog.json 生成 ui_catalog_url.txt
vfx_catalog.json 生成 vfx_catalog_url.txt

使用wget下載遊戲資料

使用wget下載遊戲資料

wget.bat 本文內容
wget -i catalog_url.txt -c -r -P ./DL/catalog
wget -i sound_catalog_url.txt -c -r -P ./DL/sound
wget -i spine_catalog_url.txt -c -r -P ./DL/spine
wget -i ui_catalog_url.txt -c -r -P ./DL/ui
wget -i vfx_catalog_url.txt -c -r -P ./DL/vfx

因為一開始沒有分類直接解包
然後發現AB包 container path是一堆hash
造成AnimeStudio輸出分類目錄名稱也是hash

然後各種類別AB包(*.bundle) 都混在一起
於是才想到在一開始下載遊戲資源AB時
wget下載時就依照類別生成不同目錄

運行wget.bat
自動開始依序下載url清單
並自動生成目錄
catalog_url.txt 下載到目錄/DL/catalog
sound_catalog_url.txt 下載到目錄/DL/sound
spine_catalog_url.txt 下載到目錄/DL/spine
ui_catalog_url.txt 下載到目錄/DL/ui
vfx_catalog_url.txt 下載到目錄/DL/vfx

AnimeStudio unity版本手動輸入6000.0.0f1

AnimeStudio unity版本手動輸入6000.0.0f1

下載下來的AB包
滑鼠右鍵使用EmEditor開啟
查看AB包的HEX
發現文件頭是 UnityFS5.x.x0.0.0

使用RAZ版AssetStudio
2022.3.5 f1
2021.3.5 f1
2020.3.5 f1
2019.3.5 f1
用4個版本去try
都無法正常解包PNG圖檔

於是大膽猜想應該是最新的unity版本
換使用AnimeStudio
unity版本手動輸入6000.0.0f1
可以正常解包PNG圖檔
因此確定這是檔頭簡單的偽裝

AnimeStudio 輸出設定

AnimeStudio 輸出設定

因為AB包
container path 是hash
file name 也是 hash
造成輸出不美觀
但還是要想辦法輸出文件

輸出分類方式 (Group exported assets by)
catalog : container path
sound : souce file name
spine : container path
ui : container path
vfx : container path

結論 :
只有sound的AB包要先使用python腳本重新命名
再使用AnimeStudio開啟並輸出檔案

spine、ui、vfx都是先使用AnimeStudio開啟
再使用python腳本整理檔案

catalog輸出設定以及檔案整理

catalog輸出設定以及檔案整理

catalog : container path (group png files manually after extracting)
輸出分類設定選擇container path分類
輸出檔案後簡單手動分類即可

sound輸出設定以及檔案整理

sound輸出設定以及檔案整理

sound : souce file name (rename bundle files using python before extracting)
輸出檔案前 建議先使用python腳本對AB包重新命名
然後輸出分類設定選擇依照bundle文件檔名分類(非依照container path分類)
輸出後的同一位角色語音才會在同一個文件夾內
(若選擇container path分類的話 , 多位角色語音會混在一起)

使用pthon腳本 rename_sound_files.py (運行rename_sound_files.bat)
讀取子目錄下 *.bundle 檔案
並依照原始檔名順序重新命名每個bundle檔案
以sound001-999重新命名
如sound001.bundle到sound999.bundle
並生成一個txt文件
記錄原始檔名對應新的檔名
如 0b7e1f135006127a3951fc952f3bbbc0.bundle —> sound001.bundle
逐行列出

spine輸出設定以及檔案整理

spine輸出設定以及檔案整理

spine : container path (rename folders using python after extracting)
輸出分類設定選擇container path分類
同一位角色spine文件剛好都在同一個目錄裡

使用pthon腳本 rename_spine_files.py (運行rename_spine_files.bat)
讀取子目錄下 . 檔案
這個是沒有副檔名的檔案
每一個目錄裡都有一個這樣沒有副檔名的檔案
將這個沒有副檔名的檔案新增副檔名.json
如 e113101_F 改成 e113101_F.json
並記錄這個檔案的檔名 e113101_F
將原本所在的目錄改成這個檔名
如 0a2617b3c793f9f4587fd46c6a7d64dd 改成 e113101_F

ui輸出設定以及檔案整理

ui輸出設定以及檔案整理

ui : container path (collect and rename png files using python after extracting)
輸出分類設定選擇container path分類
使用python腳本將png圖檔自動統一抽出放入同一個目錄內
並對重複檔名png圖檔自動重新命名

使用pthon腳本 move_png_files.py (運行move_png_files.bat)
讀取子目錄下 *.png 檔案
將所有png檔案從各子目錄中移動出來
移動到最外層目錄中
這個最外層目錄名稱叫png
若移動檔案時遇到重複的檔名
依序在檔名加上02到99之間的數字
檔案移動完之後
若原目錄已沒有任何檔案
則刪除這個空目錄

vfx輸出設定以及檔案整理

vfx輸出設定以及檔案整理

vfx : container path (collect and rename png files using python after extracting)
輸出分類設定選擇container path分類
使用python腳本將png圖檔自動統一抽出放入同一個目錄內
並對重複檔名png圖檔自動重新命名

使用pthon腳本 move_png_files.py (運行move_png_files.bat)
讀取子目錄下 *.png 檔案
將所有png檔案從各子目錄中移動出來
移動到最外層目錄中
這個最外層目錄名稱叫png
若移動檔案時遇到重複的檔名
依序在檔名加上02到99之間的數字
檔案移動完之後
若原目錄已沒有任何檔案
則刪除這個空目錄

遊戲資源AB包與輸出資料懶人包載點詳情

遊戲資源AB包與輸出資料 (含解包工具及教程)
assets.muvluv-girls-garden.com_20251016.rar (8.09GB)

Google Drive載點

Mega.nz載點

百度盤載點

5 个赞

可以试试用unitypy,解析后自动提取,下载加提取差不多两步就可以了。

不过我不怎么喜欢写详细教程,一般会给出关键解析。

test
import sys
import os
import json
import shutil
import UnityPy
import AddressablesTools
from pathlib import Path
from sys import argv

# 配置Unity版本(可根据实际情况调整)
UnityPy.config.FALLBACK_UNITY_VERSION = "2022.3.44f1"

# -------------------------- 辅助函数 --------------------------
def remove_quotes(s):
    """去除字符串前后的单引号/双引号,处理输入路径含引号的问题"""
    return s.strip('\'"') if isinstance(s, str) else s

def get_core_filename(filename):
    """处理多后缀文件名,提取核心文件名(如e107302_F.atlas.txt → e107302_F)"""
    while '.' in filename:
        filename = os.path.splitext(filename)[0]
    return filename

def process_audio_core_name(original_name):
    """正确提取AudioClip核心名:识别“ABAB”重复结构(AB为核心名)"""
    without_m4a = original_name.replace(".m4a", "")
    if not without_m4a:
        return "unknown_audio"
    parts = without_m4a.split("_")
    parts_len = len(parts)
    if parts_len == 1:
        return without_m4a
    if parts_len % 2 == 0:
        half_len = parts_len // 2
        first_half = parts[:half_len]
        second_half = parts[half_len:]
        if first_half == second_half:
            return "_".join(first_half)
    return without_m4a

# -------------------------- 解析bin文件相关函数 --------------------------
def parse_and_get_catalog(file_path):
    """解析Addressables目录文件,关联资源与bundle,返回解析结果"""
    try:
        print(f"=" * 100)
        print(f"正在读取文件: {file_path}")
        print(f"=" * 100)
        data = Path(file_path).read_bytes()
        catalog = AddressablesTools.parse_binary(data)
        
        if not hasattr(catalog, 'Resources'):
            print("错误: 解析结果缺少Resources属性")
            return None
        
        resources = catalog.Resources
        print(f"成功解析,Resources字典共 {len(resources)} 个条目")

        # 收集bundle信息
        bundle_map = {}  # key: bundle名称, value: bundle详情
        bundle_names = set()
        
        print(f"\n步骤1:收集所有bundle信息...")
        for res_key, locators in resources.items():
            if isinstance(res_key, str) and res_key.endswith(".bundle") and locators:
                loc = locators[0]
                bundle_info = {
                    "name": res_key,
                    "internal_id": getattr(loc, 'InternalId', ''),
                    "primary_key": getattr(loc, 'PrimaryKey', res_key),
                    "crc": getattr(getattr(loc.Data, 'Object', None), 'Crc', None),
                    "size": getattr(getattr(loc.Data, 'Object', None), 'BundleSize', None),
                    "assets": set()  # 用set去重
                }
                bundle_map[res_key] = bundle_info
                bundle_names.add(res_key)
        
        print(f"步骤1完成:共收集 {len(bundle_map)} 个bundle")

        # 关联资源与bundle
        unassociated_assets = set()
        processed_assets = set()
        
        print(f"\n步骤2:通过Dependencies关联资源与bundle...")
        for res_key, locators in resources.items():
            if (isinstance(res_key, str) and res_key.endswith(".bundle")) or res_key in processed_assets:
                continue
            processed_assets.add(res_key)
            asset_associated = False
            
            for loc in locators:
                if hasattr(loc, 'Dependencies') and loc.Dependencies:
                    for dep in loc.Dependencies:
                        dep_primary_key = getattr(dep, 'PrimaryKey', None)
                        if dep_primary_key and dep_primary_key in bundle_names:
                            bundle_map[dep_primary_key]["assets"].add(res_key)
                            asset_associated = True
            
            if not asset_associated:
                unassociated_assets.add(res_key)
        
        print(f"步骤2完成:")
        print(f"  - 成功关联资源数: {len(processed_assets) - len(unassociated_assets)}")
        print(f"  - 未关联资源数: {len(unassociated_assets)}")

        # 整理JSON数据结构
        json_data = {
            "metadata": {
                "total_bundles": len(bundle_map),
                "total_assets": len(processed_assets),
                "associated_assets": len(processed_assets) - len(unassociated_assets),
                "unassociated_assets": len(unassociated_assets),
                "source_file": str(file_path),
            },
            "bundles": [],  # 包含资源的bundle
            "empty_bundles": [],  # 无资源的bundle
            "unassociated_assets": sorted(list(unassociated_assets))  # 未关联的资源
        }

        # 填充有资源的bundle
        for bundle in bundle_map.values():
            assets_list = sorted(list(bundle["assets"]))
            bundle_dict = {
                "name": bundle["name"],
                "internal_id": bundle["internal_id"],
                "primary_key": bundle["primary_key"],
                "crc": bundle["crc"],
                "size": bundle["size"],
                "asset_count": len(assets_list),
                "assets": assets_list
            }
            
            if assets_list:
                json_data["bundles"].append(bundle_dict)
            else:
                json_data["empty_bundles"].append(bundle_dict)

        return json_data
        
    except FileNotFoundError:
        print(f"错误: 文件 '{file_path}' 不存在,请检查路径!")
        return None
    except Exception as e:
        print(f"解析过程中发生错误:{str(e)}")
        import traceback
        traceback.print_exc()
        return None

# -------------------------- 提取资源相关函数 --------------------------
def find_bundle_files(root_dir):
    """递归查找目录下所有bundle文件,返回{文件名: 完整路径}字典"""
    bundle_files = {}
    for dirpath, _, filenames in os.walk(root_dir):
        for filename in filenames:
            if filename.endswith('.bundle'):
                bundle_files[filename] = os.path.join(dirpath, filename)
    return bundle_files

def extract_bundle(bundle_path, temp_dir, replace_dir):
    """提取bundle中目标资源,保留完整文件名(含多后缀)"""
    extracted_assets = []
    try:
        env = UnityPy.load(bundle_path)
        for obj in env.objects:
            try:
                asset_type = obj.type.name
                if asset_type not in ['Texture2D', 'TextAsset', 'AudioClip']:
                    continue
                data = obj.read()
                # 提取完整资产名并过滤非法字符
                if hasattr(data, 'm_Name') and data.m_Name:
                    asset_full_name = data.m_Name.replace('\\', '/')
                    asset_name = asset_full_name.replace(':', '_').replace('*', '_').replace('?', '_')
                else:
                    asset_name = f"unknown_{asset_type}_{obj.path_id}"
                # 处理Texture2D
                if asset_type == 'Texture2D':
                    filename = f"{get_core_filename(asset_name)}.png"
                    save_path = os.path.join(temp_dir, filename)
                    os.makedirs(os.path.dirname(save_path), exist_ok=True)
                    if hasattr(data, 'image') and data.image:
                        data.image.save(save_path)
                        print(f"  ✅ 导出Texture2D: {filename}")
                        extracted_assets.append((get_core_filename(asset_name), save_path, asset_type))
                        replace_path = os.path.join(replace_dir, filename)
                        if os.path.exists(replace_path):
                            from PIL import Image
                            pil_img = Image.open(replace_path).convert('RGBA')
                            data.image = pil_img
                            data.save()
                            print(f"  🔄 替换Texture2D: {filename}")
                        else:
                            print(f"  ⚠️  未找到替换资源: {filename}(仅导出不替换)")
                    else:
                        print(f"  ❌ 跳过Texture2D {asset_name}: 无有效图像数据")
                    continue
                # 处理TextAsset
                elif asset_type == 'TextAsset':
                    core_name = get_core_filename(asset_name)
                    if asset_name.endswith('.atlas'):
                        filename = f"{core_name}.atlas.txt"
                    else:
                        filename = f"{core_name}.txt"
                    save_path = os.path.join(temp_dir, filename)
                    os.makedirs(os.path.dirname(save_path), exist_ok=True)
                    if hasattr(data, 'm_Script'):
                        content = data.m_Script.encode("utf-8", "surrogateescape")
                        with open(save_path, 'wb') as f:
                            f.write(content)
                        print(f"  ✅ 导出TextAsset: {filename}")
                        extracted_assets.append((core_name, save_path, asset_type))
                        replace_path = os.path.join(replace_dir, filename)
                        if os.path.exists(replace_path):
                            with open(replace_path, 'rb') as f:
                                new_content = f.read().decode("utf-8", "surrogateescape")
                            data.m_Script = new_content
                            data.save()
                            print(f"  🔄 替换TextAsset: {filename}")
                        else:
                            print(f"  ⚠️  未找到替换资源: {filename}(仅导出不替换)")
                    else:
                        print(f"  ❌ 跳过TextAsset {asset_name}: 无m_Script属性")
                    continue
                # 处理AudioClip
                elif asset_type == 'AudioClip':
                    original_core_name = get_core_filename(asset_name)
                    processed_core_name = process_audio_core_name(original_core_name)
                    
                    if hasattr(data, 'samples') and isinstance(data.samples, dict):
                        for sample_name, sample_data in data.samples.items():
                            sample_filename = f"{processed_core_name}.wav"
                            save_path = os.path.join(temp_dir, sample_filename)
                            os.makedirs(os.path.dirname(save_path), exist_ok=True)
                            with open(save_path, 'wb') as f:
                                f.write(sample_data)
                            print(f"  ✅ 导出AudioClip: {sample_filename}")
                            extracted_assets.append((processed_core_name, save_path, asset_type))
                            
                            replace_path = os.path.join(replace_dir, sample_filename)
                            if os.path.exists(replace_path):
                                with open(replace_path, 'rb') as f:
                                    new_sample_data = f.read()
                                data.samples[sample_name] = new_sample_data
                                data.save()
                                print(f"  🔄 替换AudioClip: {sample_filename}")
                            else:
                                print(f"  ⚠️  未找到替换资源: {sample_filename}(仅导出不替换)")
                    else:
                        print(f"  ❌ 跳过AudioClip {asset_name}: 无有效samples数据")
                    continue
            except Exception as inner_e:
                err_obj_name = asset_name if 'asset_name' in locals() else f"path_id_{obj.path_id}"
                print(f"  ⚠️  处理资源 {err_obj_name} 失败: {str(inner_e)[:80]}...")
                continue
        return extracted_assets
    except Exception as e:
        print(f"❌ 提取bundle {os.path.basename(bundle_path)} 失败: {e}")
        return []

def copy_matched_assets(temp_dir, target_assets, output_dir, is_spine_catalog=False):
    """核心匹配逻辑:多后缀兼容+原始路径保存,支持spine_catalog特殊处理"""
    temp_file_map = {}
    for dirpath, _, filenames in os.walk(temp_dir):
        for filename in filenames:
            file_path = os.path.join(dirpath, filename)
            core_filename = get_core_filename(filename)
            if core_filename not in temp_file_map:
                temp_file_map[core_filename] = []
            temp_file_map[core_filename].append(file_path)
    matched_files = []
    print(f"  🎯 开始匹配(多后缀兼容+原始路径保存):")
    for target_full_name in target_assets:
        target_full_name = remove_quotes(target_full_name)
        target_basename = os.path.basename(target_full_name)
        # 判断资源路径是否有后缀
        has_extension = '.' in target_basename
        target_core_name = get_core_filename(target_basename)
        
        # 针对spine_catalog的特殊路径处理
        if is_spine_catalog and not has_extension:
            # 无后缀时,最后一个/后的字符串视为文件夹名,目标路径为target_full_name/文件名
            target_dir = target_full_name
            dest_filename_base = target_basename  # 文件夹名(仅用于日志)
        else:
            # 有后缀或非spine_catalog,使用原逻辑:目标路径为target_dir/文件名
            target_dir = os.path.dirname(target_full_name)
            dest_filename_base = target_basename
        
        print(f"    - JSON目标: {target_full_name}")
        print(f"      → 核心匹配名: {target_core_name} | 输出目录: {target_dir}")
        
        if target_core_name in temp_file_map:
            for file_path in temp_file_map[target_core_name]:
                dest_filename = os.path.basename(file_path)
                dest_path = os.path.join(output_dir, target_dir, dest_filename)
                os.makedirs(os.path.dirname(dest_path), exist_ok=True)
                shutil.copy2(file_path, dest_path)
                matched_files.append(dest_path)
                print(f"      ✅ 保存: {os.path.relpath(dest_path, output_dir)}")
        else:
            print(f"      ❌ 未匹配到任何文件(核心名: {target_core_name})")
    
    # 打印未匹配文件
    all_temp_files = [os.path.basename(p) for paths in temp_file_map.values() for p in paths]
    matched_filenames = [os.path.basename(p) for p in matched_files]
    unmatched_filenames = [f for f in all_temp_files if f not in matched_filenames]
    if unmatched_filenames:
        print(f"  🗑️  未匹配的资源(将删除): {len(unmatched_filenames)} 个")
        for f in unmatched_filenames[:3]:
            print(f"    - {f}")
        if len(unmatched_filenames) > 3:
            print(f"    - ...(省略{len(unmatched_filenames)-3}个)")
    return matched_files

# -------------------------- 主函数 --------------------------
def process_bin_file(bin_file_path, bundle_dir, output_root):
    """处理单个bin文件:解析、提取资源并保存到对应目录"""
    # 判断是否为spine_catalog.bin
    bin_file_basename = os.path.basename(bin_file_path)
    is_spine_catalog = (bin_file_basename == "spine_catalog.bin")
    
    # 创建与bin文件同名的输出目录
    bin_file_name = os.path.splitext(bin_file_basename)[0]
    output_dir = os.path.join(output_root, bin_file_name)
    os.makedirs(output_dir, exist_ok=True)
    
    # 解析bin文件获取目录信息
    catalog = parse_and_get_catalog(bin_file_path)
    if not catalog:
        print(f"❌ 无法解析bin文件: {bin_file_path}")
        return False
    
    # 保存JSON目录到输出目录
    json_file_path = os.path.join(output_dir, f"{bin_file_name}.json")
    with open(json_file_path, "w", encoding="utf-8") as f:
        json.dump(catalog, f, ensure_ascii=False, indent=2)
    print(f"\nJSON文件已保存: {json_file_path}")
    
    # 查找bundle文件
    print("\n🔍 正在查找bundle文件...")
    bundle_files = find_bundle_files(bundle_dir)
    if not bundle_files:
        print(f"❌ 未在 {bundle_dir} 找到任何.bundle文件")
        return False
    print(f"✅ 找到 {len(bundle_files)} 个bundle文件,开始处理...\n")
    
    # 初始化临时目录
    temp_dir = os.path.join(output_dir, 'temp')
    replace_dir = os.path.join(output_dir, 'replace')
    shutil.rmtree(temp_dir, ignore_errors=True)
    shutil.rmtree(replace_dir, ignore_errors=True)
    os.makedirs(temp_dir, exist_ok=True)
    os.makedirs(replace_dir, exist_ok=True)
    
    # 处理每个bundle
    total_bundles = len(catalog.get('bundles', []))
    processed_count = 0
    total_matched = 0
    
    for bundle_info in catalog.get('bundles', []):
        processed_count += 1
        bundle_name = bundle_info.get('name')
        target_assets = bundle_info.get('assets', [])
        bundle_crc = bundle_info.get('crc', '未知')
        
        # 支持去除路径前缀匹配
        bundle_basename = os.path.basename(bundle_name)  # 提取纯文件名(去除路径前缀)
        # 优先用纯文件名匹配,再用原名称匹配
        if bundle_basename in bundle_files:
            bundle_path = bundle_files[bundle_basename]
        elif bundle_name in bundle_files:
            bundle_path = bundle_files[bundle_name]
        else:
            print(f"📦 进度: {processed_count}/{total_bundles} | ❌ 跳过未找到的bundle: {bundle_name}(CRC: {bundle_crc})\n")
            continue
        
        print(f"📦 进度: {processed_count}/{total_bundles} | 处理bundle: {bundle_name}(CRC: {bundle_crc})")
        print(f"  📄 bundle路径: {os.path.relpath(bundle_path, bundle_dir)}")
        print(f"  🎯 JSON目标资产数: {len(target_assets)}")
        
        # 提取资源
        extracted_assets = extract_bundle(bundle_path, temp_dir, replace_dir)
        if not extracted_assets:
            print(f"❌ bundle {bundle_name} 无有效资源,跳过\n")
            shutil.rmtree(temp_dir)
            os.makedirs(temp_dir, exist_ok=True)
            continue
        
        # 匹配并保存资源(传递spine_catalog标志)
        matched_files = copy_matched_assets(temp_dir, target_assets, output_dir, is_spine_catalog)
        total_matched += len(matched_files)
        print(f"  ✅ 本bundle匹配完成: {len(matched_files)} 个资产\n")
        
        # 清空临时目录
        shutil.rmtree(temp_dir)
        os.makedirs(temp_dir, exist_ok=True)
    
    # 清理临时目录
    shutil.rmtree(temp_dir, ignore_errors=True)
    shutil.rmtree(replace_dir, ignore_errors=True)
    
    # 输出统计信息
    print(f"🎉 {bin_file_name} 处理完成!")
    print(f"📊 处理统计:")
    print(f"  - 总bundle数(JSON配置): {total_bundles}")
    print(f"  - 成功处理bundle数: {processed_count - len([b for b in catalog.get('bundles', []) if b.get('name') not in bundle_files and os.path.basename(b.get('name')) not in bundle_files])}")
    print(f"  - 总匹配资产数: {total_matched}")
    print(f"  - 输出目录: {output_dir}\n")
    return True

def find_all_bin_files(path):
    """查找路径下所有bin文件,如果是文件则直接返回列表,如果是目录则递归查找"""
    bin_files = []
    path = remove_quotes(path)
    if os.path.isfile(path) and path.endswith('.bin'):
        bin_files.append(path)
    elif os.path.isdir(path):
        for root, _, files in os.walk(path):
            for file in files:
                if file.endswith('.bin'):
                    bin_files.append(os.path.join(root, file))
    return bin_files

def main():
    try:
        # 获取输入的bin文件/文件夹路径和bundle文件夹路径
        if len(sys.argv) >= 3:
            bin_path = sys.argv[1]
            bundle_dir = sys.argv[2]
        else:
            bin_path = input("请输入bin文件或包含bin文件的文件夹路径: ").strip()
            bundle_dir = input("请输入bundle文件夹路径: ").strip()
        
        # 处理路径中的引号
        bin_path = remove_quotes(bin_path)
        bundle_dir = remove_quotes(bundle_dir)
        
        # 验证bundle目录是否有效
        if not os.path.isdir(bundle_dir):
            print(f"❌ 无效的bundle文件夹路径:{bundle_dir} 不是目录")
            return
        
        # 查找所有bin文件
        bin_files = find_all_bin_files(bin_path)
        if not bin_files:
            print(f"❌ 未找到任何bin文件:{bin_path}")
            return
        
        print(f"✅ 找到 {len(bin_files)} 个bin文件,准备处理...\n")
        
        # 确定输出根目录(脚本所在目录)
        script_dir = os.path.dirname(os.path.abspath(__file__))
        output_root = script_dir
        
        # 处理每个bin文件
        for i, bin_file in enumerate(bin_files, 1):
            print(f"\n" + "=" * 50)
            print(f"开始处理第 {i}/{len(bin_files)} 个bin文件")
            print(f"文件路径: {bin_file}")
            print("=" * 50 + "\n")
            process_bin_file(bin_file, bundle_dir, output_root)
        
        print(f"\n" + "=" * 100)
        print(f"所有bin文件处理完成!")
        print(f"总处理bin文件数: {len(bin_files)}")
        print(f"输出根目录: {output_root}")
        print("=" * 100)
        
    except KeyboardInterrupt:
        print(f"\n🔌 程序被手动中断")
    except Exception as e:
        print(f"\n❌ 程序出错: {str(e)[:120]}...")
        import traceback
        traceback.print_exc()
        print("💡 建议检查:1.bundle文件是否损坏 2.依赖库是否安装(执行:pip install UnityPy pillow AddressablesTools)")

if __name__ == "__main__":
    main()
1 个赞

谢谢大佬,试过了能成功解包,但spine的解包命名把骨骼文件导出成txt了我自己改了下命名成skel

那个应该是json后缀,和skel二进制结构不同。

感谢大佬的教程。我在下载了您的懒人包后,发现在“wget下载资料”这一步骤中,运行的程序全部显示“ERROR 404: Not Found.”,我自己打开网址后开头显示“This XML file does not appear to have any style information associated with it.”。我无法解决这个问题,所以想请教一下您问题出在什么地方呢?

我好像发现问题所在了。将字典中的“660691da-a7cd-47ad-a254-2ce39f3e4352”替换成“cdc38111-6419-4686-b7f0-45a32e2ad7a9”又可以下载了。

單純遊戲更新後換目錄而已 : )

看來這遊戲不會保留舊資料

只能定期手動更新URL

把可愛的Fiddlerちゃん拿出來動態調適URL

https://assets.muvluv-girls-garden.com/production-public/cdc38111-6419-4686-b7f0-45a32e2ad7a9/BundleAssets/X/WebGL/DXT/catalog.hash

https://assets.muvluv-girls-garden.com/production-public/cdc38111-6419-4686-b7f0-45a32e2ad7a9/BundleAssets/X/WebGL/DXT/ui_catalog.hash

https://assets.muvluv-girls-garden.com/production-public/cdc38111-6419-4686-b7f0-45a32e2ad7a9/BundleAssets/X/WebGL/DXT/spine_catalog.hash

https://assets.muvluv-girls-garden.com/production-public/cdc38111-6419-4686-b7f0-45a32e2ad7a9/BundleAssets/X/WebGL/DXT/vfx_catalog.hash

https://assets.muvluv-girls-garden.com/production-public/cdc38111-6419-4686-b7f0-45a32e2ad7a9/BundleAssets/X/WebGL/DXT/sound_catalog.hash

https://assets.muvluv-girls-garden.com/production-public/cdc38111-6419-4686-b7f0-45a32e2ad7a9/BundleAssets/X/WebGL/DXT/ui_catalog.bin

https://assets.muvluv-girls-garden.com/production-public/cdc38111-6419-4686-b7f0-45a32e2ad7a9/BundleAssets/X/WebGL/DXT/catalog.bin

https://assets.muvluv-girls-garden.com/production-public/cdc38111-6419-4686-b7f0-45a32e2ad7a9/BundleAssets/X/WebGL/DXT/spine_catalog.bin

https://assets.muvluv-girls-garden.com/production-public/cdc38111-6419-4686-b7f0-45a32e2ad7a9/BundleAssets/X/WebGL/DXT/vfx_catalog.bin

https://assets.muvluv-girls-garden.com/production-public/cdc38111-6419-4686-b7f0-45a32e2ad7a9/BundleAssets/X/WebGL/DXT/sound_catalog.bin

https://api-game.muvluv-girls-garden.com/api/Environment/EnvConfiguration

cyb464b6 大 提供的api url
生成對應的python腳本

api_get_manifest.rar (9.4 KB)

運行 get_manifest.bat
自動運行 get_manifest.py
自動擷取API參數
生成對應URL
後面依序拿去餵 bin2json.py , json2url.py , wget

或一條龍下載 : )
合併 get_manifest.py , bin2json.py , json2url.py , wget

前提 : 各 python 腳本依賴的模組都裝好了
不然就會中突卡住不動 : )
如果跳出 ModuleNotFoundError: No module named ‘XXX’ ,就把 XXX 裝上去
缺什麼補什麼
pip intall XXX (直接呼叫)
python -m pip install XXX (模組化呼叫) (系統有多個python版本使用此命令)

python腳本所在目錄路徑不能太長 , 以免無法下載檔案 : )

因為原始的URL就已經很長了

download.rar (1.9 MB)

運行dl.bat
自動運行dl.py
自動擷取API參數
自動下載資產清單
自動解密資產清單
自動下載遊戲數據 (我這邊還是使用wget) (沒使用pyhon下載)

1 个赞

大佬牛逼

大佬,少数下载链接报错404,开VPN也下载不了