├── README.md └── ark_assets.py /README.md: -------------------------------------------------------------------------------- 1 | # ArkAssetsTool 2 | 方舟资源自动下载解包 3 | 基于Python3 (version>=3.5) 4 | 需要的库: 5 | - requests 6 | - pycryptodome 7 | - tqdm 8 | - keyboard 9 | - UnityPy 10 | - bson 11 | ``` 12 | pip install requests pycryptodome tqdm keyboard UnityPy bson -i https://pypi.tuna.tsinghua.edu.cn/simple 13 | ``` 14 | > 仅供参考学习,不得用于其他商业用途 15 | -------------------------------------------------------------------------------- /ark_assets.py: -------------------------------------------------------------------------------- 1 | import bson 2 | import json 3 | import keyboard 4 | import os 5 | import random 6 | import re 7 | import requests 8 | import sys 9 | import shutil 10 | import threading 11 | import time 12 | import UnityPy 13 | import zipfile 14 | from Crypto.Cipher import AES 15 | from enum import Enum 16 | from io import BytesIO 17 | from pathlib import Path 18 | from tqdm import tqdm 19 | 20 | 21 | def printc(*string: str, color: list[int] or list[list[int]] = list(), sep: str = ' ', start: str = '', end: str = '\n', show_time: bool = True, log = print) -> None: 22 | log(start + ('\033[1;30m[{}]\033[0m '.format(time.strftime('%H:%M:%S')) if show_time else '') + 23 | sep.join('\033[{}m{}\033[0m'.format( 24 | ';'.join(str(c) for c in (color if len(color) == 0 else (color[i] if isinstance(color[0], list) else color))), 25 | string[i]) for i in range(len(string))), end=end) 26 | 27 | back = lambda n = 1, log = print: log('\r\033[{}A'.format(n), end='\r') 28 | next = lambda n = 1, log = print: log(n * '\n', end='') 29 | clear = lambda log = print: log('\r\033[K', end='\r') 30 | save = lambda log = print: log('\r\033[s', end='') 31 | recover = lambda log = print: log('\r\033[u', end='') 32 | 33 | def scale(n, size: int or float = 1024, digit: int = 2, unit: list[str] = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']): 34 | count = 0 35 | l = len(unit) 36 | def _scale(n, s) -> float: 37 | nonlocal count 38 | if n > s and count < l: 39 | count += 1 40 | return _scale(n / s, s) 41 | else: 42 | return n 43 | return '{{:.{}f}}{{}}'.format(digit).format(_scale(n, size), unit[count]) 44 | 45 | 46 | class ArkAssets: 47 | 48 | CHAT_MASK = bytes.fromhex('554954704169383270484157776e7a7148524d4377506f6e4a4c49423357436c').decode() 49 | 50 | class Servers(Enum): 51 | OFFICAL = 0 52 | BILIBILI = 1 53 | 54 | @staticmethod 55 | def text_asset_decrypt(stream, has_rsa: bool = True) -> bytes: 56 | aes_key = ArkAssets.CHAT_MASK[:16].encode() 57 | aes_iv = bytearray(16) 58 | data = stream[128:] if has_rsa else stream 59 | buf = data[:16] 60 | mask = ArkAssets.CHAT_MASK[16:].encode() 61 | for i in range(len(buf)): 62 | aes_iv[i] = buf[i] ^ mask[i] 63 | aes_obj = AES.new(aes_key, AES.MODE_CBC, aes_iv) 64 | decrypt_buf = aes_obj.decrypt(data[16:]) 65 | unpad = lambda s: s[0:(len(s) - s[-1])] 66 | return unpad(decrypt_buf) 67 | 68 | @staticmethod 69 | def get_version(server: Servers = Servers.OFFICAL) -> tuple[str, str]: 70 | js = requests.get('https://ak-conf.hypergryph.com/config/prod/{}/Android/version'.format( 71 | 'official' if server == ArkAssets.Servers.OFFICAL else 'b')).json() 72 | return js['resVersion'], js['clientVersion'] 73 | 74 | def __init__(self, server: Servers = Servers.OFFICAL) -> None: 75 | self.server = server 76 | self.asset_version, self.client_Version = ArkAssets.get_version(self.server) 77 | printc('游戏版本: {} 素材版本: {}'.format(self.client_Version, self.asset_version), color=[1, 32]) 78 | self.hot_update_list, self.total_size, self.ab_size = self.get_hot_update_list() 79 | printc('总资源大小: {} 解压后大小: {}'.format(scale(self.total_size), scale(self.ab_size)), color=[1, 32]) 80 | 81 | def get_hot_update_list(self) -> tuple[dict, int, int]: 82 | js = requests.get('https://ak.hycdn.cn/assetbundle/{}/Android/assets/{}/hot_update_list.json'.format( 83 | 'official' if self.server == ArkAssets.Servers.OFFICAL else 'bilibili', 84 | self.asset_version)).json() 85 | out = {'other': {'totalSize': 0, 'files': dict()}} 86 | total_size = 0 87 | ab_size = 0 88 | for item in js['packInfos']: 89 | k = item['name'].replace('_', '/') 90 | out[k] = {'totalSize': 0} 91 | out[k]['files'] = dict() 92 | 93 | def add_other(_item: dict): 94 | _size = _item['totalSize'] 95 | out['other']['totalSize'] += _size 96 | out['other']['files'][_item['name']] = { 97 | 'totalSize': _size, 98 | 'abSize': _item['abSize'], 99 | 'md5': _item['md5'] 100 | } 101 | 102 | for item in js['abInfos']: 103 | _size = item['totalSize'] 104 | total_size += _size 105 | _ab_size = item['abSize'] 106 | ab_size += _ab_size 107 | if 'pid' in item: 108 | pid = item['pid'].replace('_', '/') 109 | if pid in out: 110 | out[pid]['totalSize'] += _size 111 | out[pid]['files'][item['name']] = { 112 | 'totalSize': _size, 113 | 'abSize': _ab_size, 114 | 'md5': item['md5'] 115 | } 116 | else: 117 | add_other(item) 118 | else: 119 | add_other(item) 120 | return out, total_size, ab_size 121 | 122 | def download(self, savedir: str) -> None: 123 | options = list() 124 | _i = 0 125 | for item in self.hot_update_list: 126 | _size = self.hot_update_list[item]['totalSize'] 127 | _per = _size / self.total_size 128 | options.append((item, '{:<35}{:<7}{}'.format('{:<15} 包大小: {}'.format(item, scale(_size)), '{:.2f}%'.format(_per * 100), int(_per * (os.get_terminal_size().columns - 46)) *'█'), [1, 34])) 129 | printc(options[_i][1], color=options[_i][2]) 130 | _i += 1 131 | del _i 132 | printc('下载选项 [全部下载=A,选择下载=C,取消=Other]: ', color=[1, 36]) 133 | inp = sys.stdin.readline().strip('\n') 134 | back() 135 | if inp == 'A' or inp == 'a': 136 | back() 137 | clear() 138 | printc('开始全部下载', color=[1, 36]) 139 | self.download_fromlist([item for item in self.hot_update_list], savedir) 140 | elif inp == 'C' or inp == 'c': 141 | choosen = list() 142 | _pos = 0 143 | back() 144 | clear() 145 | printc('总数量: {} 预计大小: {} 已选择: {}'.format(sum(len(self.hot_update_list[key]['files']) for key in choosen), scale(sum(self.hot_update_list[key]['totalSize'] for key in choosen)), [key for key in choosen]), color=[1, 36]) 146 | printc('[上下箭头=选择, 左箭头=添加, 右箭头=删除, ESC键=确认]', color=[1, 33], end=' ') 147 | def on_up(): 148 | nonlocal _pos 149 | if _pos - 1 >= 0: 150 | clear() 151 | printc(options[_pos][1], color=options[_pos][2], end='\r') 152 | back() 153 | _pos -= 1 154 | printc(options[_pos][1], color=options[_pos][2] + [100], end='\r') 155 | def on_down(): 156 | nonlocal _pos 157 | if _pos + 1 <= len(options) - 1: 158 | clear() 159 | printc(options[_pos][1], color=options[_pos][2], end='\r') 160 | next() 161 | _pos += 1 162 | printc(options[_pos][1], color=options[_pos][2] + [100], end='\r') 163 | def on_right(): 164 | nonlocal _pos 165 | if not options[_pos][0] in choosen: 166 | choosen.append(options[_pos][0]) 167 | clear() 168 | printc(options[_pos][1], color=[1, 33, 100], end='\r') 169 | options[_pos] = options[_pos][0], options[_pos][1], [1, 33] 170 | next(len(options) - _pos) 171 | clear() 172 | printc('总数量: {} 预计大小: {} 已选择: {}'.format(sum(len(self.hot_update_list[key]['files']) for key in choosen), scale(sum(self.hot_update_list[key]['totalSize'] for key in choosen)), [key for key in choosen]), color=[1, 36], end='') 173 | back(len(options) - _pos) 174 | def on_left(): 175 | nonlocal _pos 176 | if options[_pos][0] in choosen: 177 | choosen.remove(options[_pos][0]) 178 | clear() 179 | printc(options[_pos][1], color=[1, 34, 100], end='\r') 180 | options[_pos] = options[_pos][0], options[_pos][1], [1, 34] 181 | next(len(options) - _pos) 182 | clear() 183 | printc('总数量: {} 预计大小: {} 已选择: {}'.format(sum(len(self.hot_update_list[key]['files']) for key in choosen), scale(sum(self.hot_update_list[key]['totalSize'] for key in choosen)), [key for key in choosen]), color=[1, 36], end='') 184 | back(len(options) - _pos) 185 | keyboard.add_hotkey('up', on_up) 186 | keyboard.add_hotkey('down', on_down) 187 | keyboard.add_hotkey('right', on_right) 188 | keyboard.add_hotkey('left', on_left) 189 | back(len(options) + 1) 190 | printc(options[_pos][1], color=options[_pos][2] + [100], end='\r') 191 | keyboard.wait('esc') 192 | keyboard.clear_all_hotkeys() 193 | next(len(options) + 1 - _pos) 194 | clear() 195 | printc('开始下载', color=[1, 36]) 196 | self.download_fromlist(choosen, savedir) 197 | else: 198 | printc('下载被取消', color=[1, 33]) 199 | 200 | def download_fromlist(self, keys: list[str], savedir: str, threading_count: int = 6): 201 | _count = sum(len(self.hot_update_list[key]['files']) for key in keys) 202 | _lock = threading.Lock() 203 | _read = threading.Lock() 204 | _size = threading.Lock() 205 | _write = threading.Lock() 206 | per_path = str(Path(savedir) / 'persistent_res_list.json') 207 | if not (Path(savedir) / 'persistent_res_list.json').is_file(): 208 | Path(savedir).mkdir(parents=True, exist_ok=True) 209 | with open(per_path, 'w') as f: 210 | f.write(r'{}') 211 | f.close() 212 | per = {} 213 | else: 214 | with open(per_path, 'r') as f: 215 | try: 216 | per = json.loads(f.read()) 217 | except: 218 | with open(per_path, 'w') as f: 219 | f.write(r'{}') 220 | f.close() 221 | per = {} 222 | f.close() 223 | files = dict() 224 | _unpack_count = 0 225 | for l in [self.hot_update_list[key]['files'] for key in keys]: 226 | for i in l: 227 | if (i in per): 228 | if (per[i] == l[i]['md5']): 229 | printc('{:<80}'.format('[{}]'.format(i)), '已经存在最新版本', color=[[36], [1, 32]]) 230 | _count -= 1 231 | continue 232 | else: 233 | printc('[{}]将进行更新'.format(i), color=[1, 33]) 234 | _file_path: Path = Path(savedir) / i 235 | if _file_path.is_file(): 236 | os.remove(str(_file_path)) 237 | if _file_path.with_name(_file_path.stem).is_dir(): 238 | shutil.rmtree(str(_file_path.with_name('[unpack]' + _file_path.stem)), ignore_errors=True) 239 | files[i] = l[i] 240 | else: 241 | files[i] = l[i] 242 | with tqdm(total=_count, desc='\033[1;33m总进度 已下载0B\033[m', unit='个', position=threading_count, leave=False) as pbar,\ 243 | tqdm(total=_count, desc='\033[1;33m解压进度\033[m', unit='个', position=threading_count + 1, leave=False) as zipbar,\ 244 | tqdm(total=_count, desc='\033[1;34m解包进度\033[m', unit='个', position=threading_count + 2, leave=False) as unpackbar: 245 | res_size = 0 246 | ts = time.time() 247 | 248 | def down(files, n): 249 | nonlocal _count, _unpack_count, res_size 250 | s = requests.Session() 251 | while(len(list(files.keys())) > 0): 252 | _read.acquire() 253 | file = random.choice(list(files.keys())) 254 | md5 = files[file]['md5'] 255 | files.pop(file) 256 | _read.release() 257 | try: 258 | stream = self.download_asset(file, s, lock=_lock, bar_position=n, thread_num=n) 259 | _size.acquire() 260 | res_size += len(stream) 261 | _lock.acquire() 262 | pbar.set_description('\033[1;33m总进度 已下载{} 平均速度{}\033[m'.format(scale(res_size), scale(res_size / (time.time() - ts)) + '/s')) 263 | _lock.release() 264 | _size.release() 265 | 266 | def unzip(stream, name, md5, path): 267 | nonlocal _unpack_count 268 | with zipfile.ZipFile(file=BytesIO(stream)) as f: 269 | del stream 270 | f.extractall(path) 271 | _write.acquire() 272 | with open(per_path, 'r') as f: 273 | per = json.loads(f.read()) 274 | f.close() 275 | per[name] = md5 276 | with open(per_path, 'w') as f: 277 | f.write(json.dumps(per)) 278 | f.close() 279 | _write.release() 280 | _lock.acquire() 281 | zipbar.update(1) 282 | _lock.release() 283 | 284 | def unpack(file_path: Path): 285 | nonlocal _unpack_count 286 | try: 287 | env = UnityPy.load(str(file_path)) 288 | images = dict() 289 | for obj in env.objects: 290 | data = obj.read() 291 | if obj.type.name in ['Texture2D', 'Sprite']: 292 | images['({})'.format(obj.type.name) + data.name] = data.image 293 | _p = file_path.with_name('[unpack]' + file_path.stem) 294 | for name in images: 295 | if not name.endswith('[alpha]'): 296 | if name + '[alpha]' in images: 297 | r, g, b = images[name].split()[:3] 298 | a = images[name + '[alpha]'].split()[0] 299 | if a.size != r.size: 300 | a = a.resize(r.size) 301 | image = PIL.Image.merge('RGBA', (r, g, b, a)) 302 | else: 303 | image = images[name] 304 | _p.mkdir(parents=True, exist_ok=True) 305 | image.save(str(_p / (name + '.png'))) 306 | for obj in env.objects: 307 | data = obj.read() 308 | name = obj.type.name 309 | if name == 'TextAsset': 310 | _p.mkdir(parents=True, exist_ok=True) 311 | try: 312 | fpath = str(file_path) 313 | if ('gamedata\levels' in fpath or 'gamedata/levels' in fpath) and 'enemydata' not in fpath: 314 | de = bytes(data.script)[128:] 315 | else: 316 | de = ArkAssets.text_asset_decrypt(bytes(data.script), 'gamedata/levels' not in fpath) 317 | except: 318 | de = bytes(data.script) 319 | try: 320 | json_data = json.dumps(json.loads(de), ensure_ascii=False, indent=2) 321 | with open(str(_p / (data.name + '.json')), 'w', encoding='utf-8') as f: 322 | f.write(json_data) 323 | f.close() 324 | except: 325 | try: 326 | dics = bson.decode_all(de) 327 | for i in range(len(dics)): 328 | json_data = json.dumps(dics[i], ensure_ascii=False, indent=2) 329 | with open(str(_p / (data.name + ('_{}'.format(i) if i >= 1 else '') + '.json')), 'w', encoding='utf-8') as f: 330 | f.write(json_data) 331 | f.close() 332 | except: 333 | with open(str(_p / (data.name + ('.txt' if re.search('(?<=\.)(lua|atlas|skel)$', data.name) == None else ''))), 'wb') as f: 334 | f.write(de) 335 | f.close() 336 | elif name == 'AudioClip': 337 | for aname, adata in data.samples.items(): 338 | _p.mkdir(parents=True, exist_ok=True) 339 | with open(str(_p / aname), "wb") as f: 340 | f.write(adata) 341 | f.close() 342 | elif name == 'Mesh': 343 | _p.mkdir(parents=True, exist_ok=True) 344 | with open(str(_p / (data.name + '.obj')), "wt", newline = "") as f: 345 | f.write(data.export()) 346 | f.close() 347 | elif name == 'Font': 348 | if data.m_FontData: 349 | extension = ".ttf" 350 | if data.m_FontData[0 : 4] == b"OTTO": 351 | extension = ".otf" 352 | _p.mkdir(parents=True, exist_ok=True) 353 | with open(str(_p / (data.name + extension)), "wb") as f: 354 | f.write(data.m_FontData) 355 | f.close() 356 | except Exception as e: 357 | pass#print(e) 358 | finally: 359 | _unpack_count += 1 360 | unpackbar.update(1) 361 | 362 | threading.Thread(target=unpack, args=(Path(savedir) / name,)).start() 363 | threading.Thread(target=unzip, args=(stream, file, md5, savedir), daemon=True).start() 364 | del stream 365 | except Exception as e: 366 | _lock.acquire() 367 | printc(e, color=[31]) 368 | _lock.release() 369 | finally: 370 | _lock.acquire() 371 | pbar.update(1) 372 | _lock.release() 373 | 374 | for i in range(threading_count): 375 | threading.Thread(target=down, args=(files, i), daemon=True).start() 376 | while(_unpack_count < _count): 377 | time.sleep(0.1) 378 | pbar.close() 379 | zipbar.close() 380 | unpackbar.close() 381 | 382 | def download_asset(self, path: str, session: requests.Session, bar_position: int = 0, lock: threading.Lock = None, thread_num: int = 0) -> bytes: 383 | global pos 384 | url = 'https://ak.hycdn.cn/assetbundle/{}/Android/assets/{}/{}'.format( 385 | 'official' if self.server == ArkAssets.Servers.OFFICAL else 'bilibili', 386 | self.asset_version, 387 | re.sub('(?<=\.)((?!\.).)*$', 'dat', path).replace('/', '_').replace('#', '__')) 388 | headers = { "User-Agent": "BestHTTP" } 389 | if lock != None: 390 | lock.acquire() 391 | b = tqdm(unit_scale=True, desc='\033[33m线程{} [{}]:读取返回头中\033[m'.format(thread_num, path), unit='B', position=bar_position, leave=False) 392 | if lock != None: 393 | lock.release() 394 | req = session.get(url, stream=True, headers=headers) 395 | length = int(req.headers['content-length']) 396 | if lock != None: 397 | lock.acquire() 398 | b.close() 399 | pbar = tqdm(total=length, unit_scale=True, desc='\033[33m线程{} [{}]:下载中\033[m'.format(thread_num, path), unit='B', position=bar_position, leave=False) 400 | if lock != None: 401 | lock.release() 402 | res= bytes() 403 | st: float = time.time() 404 | chunk_size: int = length // 24 405 | if chunk_size <= 0: 406 | chunk_size = 1 407 | elif chunk_size >= 10485760: 408 | chunk_size = 10485760 409 | for chuck in req.iter_content(chunk_size=chunk_size): 410 | res += chuck 411 | if (len(res) != length): 412 | if lock != None: 413 | lock.acquire() 414 | pbar.update(len(chuck)) 415 | pbar.refresh() 416 | if lock != None: 417 | lock.release() 418 | st = time.time() - st 419 | if lock != None: 420 | lock.acquire() 421 | printc('{:<80}'.format('[{}]'.format(path)), '下载完毕 耗时{:.3f}s 平均速度{}'.format(st, scale(length / st) + '/s'), 422 | color=[[36], [1, 32]], log=tqdm.write) 423 | _per = length / 10485760 424 | if _per > 1: 425 | _per = 1 426 | printc('{:<16} {}'.format('dat包大小: ' + scale(length), int(_per * (os.get_terminal_size().columns - 33)) * '█'), 427 | color=[1, 34], log=tqdm.write) 428 | pbar.close() 429 | if lock != None: 430 | lock.release() 431 | return res 432 | 433 | a = ArkAssets() 434 | a.download('D:/ArkAssets') 435 | # a.download_fromlist(['lpack/lcom'], 'D:/ArkAssets') 436 | --------------------------------------------------------------------------------