azlassets 3.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- azlassets/__init__.py +1 -0
- azlassets/classes.py +191 -0
- azlassets/config/client_config.json +27 -0
- azlassets/config/user_config_template.yml +61 -0
- azlassets/config.py +62 -0
- azlassets/downloader.py +61 -0
- azlassets/imgrecon.py +38 -0
- azlassets/proto/__init__.py +0 -0
- azlassets/proto/p10min_pb_pb2.py +28 -0
- azlassets/protobuf.py +116 -0
- azlassets/repair.py +98 -0
- azlassets/updater.py +137 -0
- azlassets/versioncontrol.py +131 -0
- azlassets-3.3.0.dist-info/METADATA +108 -0
- azlassets-3.3.0.dist-info/RECORD +21 -0
- azlassets-3.3.0.dist-info/WHEEL +5 -0
- azlassets-3.3.0.dist-info/licenses/LICENSE +21 -0
- azlassets-3.3.0.dist-info/top_level.txt +4 -0
- downloader.py +61 -0
- extractor.py +161 -0
- obb_apk_import.py +192 -0
azlassets/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "3.3.0" # x-release-please-version
|
azlassets/classes.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Self
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
CompareType = Enum('CompareType', 'New Changed Unchanged Deleted')
|
|
8
|
+
DownloadType = Enum('DownloadType', 'NoChange Removed Success Failed ForDeletionNoChange')
|
|
9
|
+
|
|
10
|
+
class VersionType(Enum):
|
|
11
|
+
__hash2member_map__: dict[str, Self] = {}
|
|
12
|
+
hashname: str
|
|
13
|
+
"""Hash name used on the version result returned by the game server."""
|
|
14
|
+
suffix: str
|
|
15
|
+
"""Suffix used on version and hash files."""
|
|
16
|
+
|
|
17
|
+
AZL = (1, "azhash", "")
|
|
18
|
+
CV = (2, "cvhash", "cv")
|
|
19
|
+
L2D = (3, "l2dhash", "live2d")
|
|
20
|
+
PIC = (4, "pichash", "pic")
|
|
21
|
+
BGM = (5, "bgmhash", "bgm")
|
|
22
|
+
CIPHER = (6, "cipherhash", "cipher")
|
|
23
|
+
MANGA = (7, "mangahash", "manga")
|
|
24
|
+
PAINTING = (8, "paintinghash", "painting")
|
|
25
|
+
DORM = (9, "dormhash", "dorm")
|
|
26
|
+
MAP = (10, "maphash", "map")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def __init__(self, _, hashname, suffix) -> None:
|
|
30
|
+
# add attributes to enum objects
|
|
31
|
+
self.hashname = hashname
|
|
32
|
+
self.suffix = suffix
|
|
33
|
+
# add enum objects to member maps
|
|
34
|
+
self.__hash2member_map__[hashname] = self
|
|
35
|
+
|
|
36
|
+
def __str__(self) -> str:
|
|
37
|
+
return self.name.lower()
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def version_filename(self) -> str:
|
|
41
|
+
"""
|
|
42
|
+
Full version filename using the suffix.
|
|
43
|
+
"""
|
|
44
|
+
suffix = self.suffix
|
|
45
|
+
if suffix: suffix = "-"+suffix
|
|
46
|
+
return f"version{suffix}.txt"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def hashes_filename(self) -> str:
|
|
50
|
+
"""
|
|
51
|
+
Full hashes filename using the suffix.
|
|
52
|
+
"""
|
|
53
|
+
suffix = self.suffix
|
|
54
|
+
if suffix: suffix = "-"+suffix
|
|
55
|
+
return f"hashes{suffix}.csv"
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_hashname(cls, hashname: str) -> Self | None:
|
|
59
|
+
"""
|
|
60
|
+
Returns a VersionType member with matching *hashname* if match exists, otherwise None.
|
|
61
|
+
"""
|
|
62
|
+
return cls.__hash2member_map__.get(hashname)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class AbstractClient(Enum):
|
|
66
|
+
active: bool
|
|
67
|
+
locale_code: str
|
|
68
|
+
package_name: str
|
|
69
|
+
|
|
70
|
+
def __new__(cls, value, active, locale, package_name):
|
|
71
|
+
# this should be done differently, but i am too lazy to do that now
|
|
72
|
+
# TODO: change it
|
|
73
|
+
if not hasattr(cls, "package_names"):
|
|
74
|
+
cls.package_names = {}
|
|
75
|
+
|
|
76
|
+
obj = object.__new__(cls)
|
|
77
|
+
obj._value_ = value
|
|
78
|
+
obj.active = active
|
|
79
|
+
obj.locale_code = locale
|
|
80
|
+
obj.package_name = package_name
|
|
81
|
+
cls.package_names[package_name] = obj
|
|
82
|
+
return obj
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_package_name(cls, package_name) -> Self | None:
|
|
86
|
+
return cls.package_names.get(package_name)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Client(AbstractClient):
|
|
90
|
+
EN = (1, True, 'en-US', 'com.YoStarEN.AzurLane')
|
|
91
|
+
JP = (2, True, 'ja-JP', 'com.YoStarJP.AzurLane')
|
|
92
|
+
CN = (3, True, 'zh-CN', '')
|
|
93
|
+
KR = (4, True, 'ko-KR', 'kr.txwy.and.blhx')
|
|
94
|
+
TW = (5, True, 'zh-TW', 'com.hkmanjuu.azurlane.gp')
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class HashRow:
|
|
99
|
+
filepath: str
|
|
100
|
+
size: int
|
|
101
|
+
md5hash: str
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class CompareResult:
|
|
105
|
+
current_hash: HashRow | None
|
|
106
|
+
new_hash: HashRow | None
|
|
107
|
+
compare_type: CompareType
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class VersionResult:
|
|
111
|
+
version: str
|
|
112
|
+
vhash: str
|
|
113
|
+
rawstring: str
|
|
114
|
+
version_type: VersionType
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class BundlePath:
|
|
118
|
+
full: Path
|
|
119
|
+
inner: str
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def construct(parentdir: Path, inner: Path | str) -> "BundlePath":
|
|
123
|
+
fullpath = Path(parentdir, inner)
|
|
124
|
+
return BundlePath(fullpath, str(inner))
|
|
125
|
+
|
|
126
|
+
def __hash__(self):
|
|
127
|
+
return hash(self.inner)
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class UpdateResult:
|
|
131
|
+
compare_result: CompareResult
|
|
132
|
+
download_type: DownloadType
|
|
133
|
+
path: BundlePath
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class UserConfig:
|
|
137
|
+
useragent: str
|
|
138
|
+
download_isblacklist: bool
|
|
139
|
+
download_filter: list
|
|
140
|
+
extract_isblacklist: bool
|
|
141
|
+
extract_filter: list
|
|
142
|
+
asset_directory: Path
|
|
143
|
+
extract_directory: Path
|
|
144
|
+
|
|
145
|
+
@dataclass
|
|
146
|
+
class ClientConfig:
|
|
147
|
+
gateip: str
|
|
148
|
+
gateport: int
|
|
149
|
+
cdnurl: str
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# stolen from https://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console
|
|
153
|
+
def printProgressBar(iteration, total, prefix = '', suffix = 'Complete', decimals = 1, length = 50, fill = '█', printEnd = "\r", details_unit = None):
|
|
154
|
+
"""
|
|
155
|
+
Call in a loop to create terminal progress bar
|
|
156
|
+
@params:
|
|
157
|
+
iteration - Required : current iteration (Int)
|
|
158
|
+
total - Required : total iterations (Int)
|
|
159
|
+
prefix - Optional : prefix string (Str)
|
|
160
|
+
suffix - Optional : suffix string (Str)
|
|
161
|
+
decimals - Optional : positive number of decimals in percent complete (Int)
|
|
162
|
+
length - Optional : character length of bar (Int)
|
|
163
|
+
fill - Optional : bar fill character (Str)
|
|
164
|
+
printEnd - Optional : end character (e.g. "\r", "\r\n") (Str)
|
|
165
|
+
"""
|
|
166
|
+
percent = ("{0:." + str(decimals) + "f}").format(100 * (iteration / float(total)))
|
|
167
|
+
filledLength = int(length * iteration // total)
|
|
168
|
+
progress = fill * filledLength + '-' * (length - filledLength)
|
|
169
|
+
details = f" [{iteration}/{total} {details_unit}]" if details_unit else ""
|
|
170
|
+
print(f'\r{prefix} |{progress}| {percent}% {suffix}{details}', end = printEnd)
|
|
171
|
+
# Print New Line on Complete
|
|
172
|
+
if iteration == total:
|
|
173
|
+
print()
|
|
174
|
+
|
|
175
|
+
# simplified progress bar class with only the useful stuff i use
|
|
176
|
+
class ProgressBar():
|
|
177
|
+
def __init__(self, total: int, prefix: str, suffix: str = "Complete", iterstart: int = 0, details_unit: str | None = None, print_on_init: bool = True):
|
|
178
|
+
self.iteration = iterstart
|
|
179
|
+
self.total = total
|
|
180
|
+
self.prefix = prefix
|
|
181
|
+
self.suffix = suffix
|
|
182
|
+
self.details_unit = details_unit
|
|
183
|
+
if print_on_init:
|
|
184
|
+
printProgressBar(iterstart, total, prefix, suffix, details_unit=details_unit)
|
|
185
|
+
|
|
186
|
+
def update(self, iteration: int | None = None):
|
|
187
|
+
if iteration:
|
|
188
|
+
self.iteration = iteration
|
|
189
|
+
else:
|
|
190
|
+
self.iteration += 1
|
|
191
|
+
printProgressBar(self.iteration, self.total, self.prefix, self.suffix, details_unit=self.details_unit)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"EN": {
|
|
3
|
+
"gateip": "blhxusgate.yo-star.com",
|
|
4
|
+
"gateport": 80,
|
|
5
|
+
"cdnurl": "https://blhxusstatic.yo-star.com"
|
|
6
|
+
},
|
|
7
|
+
"CN": {
|
|
8
|
+
"gateip": "line1-login-bili-blhx.bilibiligame.net",
|
|
9
|
+
"gateport": 80,
|
|
10
|
+
"cdnurl": "https://line3-patch-blhx.bilibiligame.net"
|
|
11
|
+
},
|
|
12
|
+
"JP": {
|
|
13
|
+
"gateip": "blhxjploginapi.azurlane.jp",
|
|
14
|
+
"gateport": 80,
|
|
15
|
+
"cdnurl": "https://blhxstatic.yo-star.com"
|
|
16
|
+
},
|
|
17
|
+
"KR": {
|
|
18
|
+
"gateip": "bl-kr-gate.xdg.com",
|
|
19
|
+
"gateport": 80,
|
|
20
|
+
"cdnurl": "http://blcdn.imtxwy.com"
|
|
21
|
+
},
|
|
22
|
+
"TW": {
|
|
23
|
+
"gateip": "prod-all-login.azurlane.tw",
|
|
24
|
+
"gateport": 10080,
|
|
25
|
+
"cdnurl": "http://blhxstatic.azurlane.tw"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
asset-directory: ClientAssets
|
|
2
|
+
extract-directory: ClientExtract
|
|
3
|
+
# set to blacklist or whitelist
|
|
4
|
+
download-folder-listtype: blacklist
|
|
5
|
+
extract-folder-listtype: whitelist
|
|
6
|
+
download-folder-list: []
|
|
7
|
+
extract-folder-list:
|
|
8
|
+
- activitybanner
|
|
9
|
+
- aircrafticon
|
|
10
|
+
- backyardtheme
|
|
11
|
+
- battlescore
|
|
12
|
+
- bg
|
|
13
|
+
- boxprefab
|
|
14
|
+
- chargeicon
|
|
15
|
+
- clutter
|
|
16
|
+
- collectionfileillustration
|
|
17
|
+
- collectionfiletitle
|
|
18
|
+
- commanderhrz
|
|
19
|
+
- commandericon
|
|
20
|
+
- commanderskillicon
|
|
21
|
+
- commandertalenticon
|
|
22
|
+
- commonbg
|
|
23
|
+
- crusingwindow
|
|
24
|
+
- emblem
|
|
25
|
+
- enemies
|
|
26
|
+
- equips
|
|
27
|
+
- eventtype
|
|
28
|
+
- extra_page
|
|
29
|
+
- furnitureicon
|
|
30
|
+
- gallerycard
|
|
31
|
+
- guildnode
|
|
32
|
+
- guildboss
|
|
33
|
+
- guildevent
|
|
34
|
+
- guildmission
|
|
35
|
+
- helpbg
|
|
36
|
+
- herohrzicon
|
|
37
|
+
- icondesc
|
|
38
|
+
- iconframe
|
|
39
|
+
- independenttex
|
|
40
|
+
- levelmap
|
|
41
|
+
- loadingbg
|
|
42
|
+
- lotterybg
|
|
43
|
+
- mangapic
|
|
44
|
+
- medal
|
|
45
|
+
- memoryicon
|
|
46
|
+
- metaship
|
|
47
|
+
- musiccover
|
|
48
|
+
- newshipbg
|
|
49
|
+
- newyearskinshowpage
|
|
50
|
+
- painting
|
|
51
|
+
- prints
|
|
52
|
+
- props
|
|
53
|
+
- qicon
|
|
54
|
+
- shipmodels
|
|
55
|
+
- shiprarity
|
|
56
|
+
- shipyardicon
|
|
57
|
+
- skillicon
|
|
58
|
+
- squareicon
|
|
59
|
+
- strategyicon
|
|
60
|
+
- updatebg
|
|
61
|
+
useragent: ''
|
azlassets/config.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import json
|
|
3
|
+
import yaml
|
|
4
|
+
from shutil import copy
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from importlib.resources import files
|
|
7
|
+
|
|
8
|
+
from .classes import Client, UserConfig, ClientConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# package-incuded filepaths
|
|
12
|
+
CONFIG_DATA_PATH = files("azlassets").joinpath("config")
|
|
13
|
+
YAML_TEMPLATE_PATH = CONFIG_DATA_PATH.joinpath("user_config_template.yml")
|
|
14
|
+
CLIENT_CONFIG_PATH = CONFIG_DATA_PATH.joinpath("client_config.json")
|
|
15
|
+
|
|
16
|
+
# cwd-relative filepaths
|
|
17
|
+
YAML_CONFIG_PATH = Path("config") / "user_config.yml"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_user_config() -> UserConfig:
|
|
21
|
+
if not YAML_CONFIG_PATH.exists():
|
|
22
|
+
print("Userconfig does not exist. A new one will be created.")
|
|
23
|
+
print("Note that the useragent is empty and it is advised to set one.")
|
|
24
|
+
YAML_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
copy(YAML_TEMPLATE_PATH, YAML_CONFIG_PATH)
|
|
26
|
+
return load_user_config()
|
|
27
|
+
|
|
28
|
+
with open(YAML_CONFIG_PATH, 'r', encoding='utf8') as file:
|
|
29
|
+
yamlconfig = yaml.safe_load(file)
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
userconfig = UserConfig(
|
|
33
|
+
useragent=yamlconfig['useragent'],
|
|
34
|
+
download_isblacklist=yamlconfig['download-folder-listtype'] == 'blacklist',
|
|
35
|
+
download_filter=yamlconfig['download-folder-list'],
|
|
36
|
+
extract_isblacklist=yamlconfig['extract-folder-listtype'] == 'blacklist',
|
|
37
|
+
extract_filter=yamlconfig['extract-folder-list'],
|
|
38
|
+
asset_directory=yamlconfig['asset-directory'],
|
|
39
|
+
extract_directory=yamlconfig['extract-directory'],
|
|
40
|
+
)
|
|
41
|
+
except KeyError:
|
|
42
|
+
print("There is an error inside the userconfig file. Delete it or change the wrong values.")
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
|
|
45
|
+
return userconfig
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_client_config(client: Client) -> ClientConfig:
|
|
49
|
+
with open(CLIENT_CONFIG_PATH, 'r', encoding='utf8') as f:
|
|
50
|
+
configdata = json.load(f)
|
|
51
|
+
|
|
52
|
+
if not client.name in configdata:
|
|
53
|
+
raise NotImplementedError(f'Client {client.name} has not been configured yet.')
|
|
54
|
+
|
|
55
|
+
config = configdata[client.name]
|
|
56
|
+
try:
|
|
57
|
+
clientconfig = ClientConfig(config['gateip'], config['gateport'], config['cdnurl'])
|
|
58
|
+
except KeyError:
|
|
59
|
+
print("The clientconfig has been wrongly configured.")
|
|
60
|
+
sys.exit(1)
|
|
61
|
+
|
|
62
|
+
return clientconfig
|
azlassets/downloader.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import aiohttp
|
|
2
|
+
import aiofile
|
|
3
|
+
import traceback
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .classes import VersionResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AzurlaneAsyncDownloader(aiohttp.ClientSession):
|
|
10
|
+
def __init__(self, cdn_url: str, useragent: str):
|
|
11
|
+
base_url = f"{cdn_url}/android/"
|
|
12
|
+
limited_tcp_connector = aiohttp.TCPConnector(limit_per_host=6)
|
|
13
|
+
super().__init__(base_url=base_url, headers={"user-agent": useragent}, connector=limited_tcp_connector)
|
|
14
|
+
|
|
15
|
+
async def get_hashes(self, versionhash: str) -> aiohttp.ClientResponse:
|
|
16
|
+
return await self.get(f"hash/{versionhash}")
|
|
17
|
+
|
|
18
|
+
async def get_asset(self, filehash: str) -> aiohttp.ClientResponse:
|
|
19
|
+
return await self.get(f"resource/{filehash}")
|
|
20
|
+
|
|
21
|
+
async def download_hashes(self, version_result: VersionResult) -> str | None:
|
|
22
|
+
try:
|
|
23
|
+
async with await self.get_hashes(version_result.rawstring) as response:
|
|
24
|
+
response: aiohttp.ClientResponse
|
|
25
|
+
response.raise_for_status() # raises error on bad HTTP status
|
|
26
|
+
|
|
27
|
+
hashes = await response.text()
|
|
28
|
+
return hashes
|
|
29
|
+
|
|
30
|
+
except Exception as e:
|
|
31
|
+
print(f"ERROR: An unexpected error occured while downloading '{version_result.version_type.name}' hashfile.")
|
|
32
|
+
traceback.print_exception(type(e), e, e.__traceback__)
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
async def download_asset(self, filehash: str, save_destination: Path, expected_file_size: int) -> bool:
|
|
36
|
+
"""
|
|
37
|
+
Downloads the requested file using the session and saves it to 'save_destination' on disk.
|
|
38
|
+
|
|
39
|
+
Returns `True` if the operation was successful, otherwise `False`.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
async with await self.get_asset(filehash) as response:
|
|
43
|
+
response: aiohttp.ClientResponse
|
|
44
|
+
response.raise_for_status() # raises error on bad HTTP status
|
|
45
|
+
|
|
46
|
+
# reject response if response size doesn't match expected size
|
|
47
|
+
response_size = response.content_length
|
|
48
|
+
if expected_file_size != response_size:
|
|
49
|
+
print(f"ERROR: Received asset '{filehash}' with target '{save_destination}' has wrong size ({response_size}/{expected_file_size}).")
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
save_destination.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
async with aiofile.async_open(save_destination, "wb") as file:
|
|
54
|
+
async for chunk in response.content.iter_chunked(1024*16): # no idea what chuck size is best
|
|
55
|
+
await file.write(chunk)
|
|
56
|
+
|
|
57
|
+
return True
|
|
58
|
+
except Exception as e:
|
|
59
|
+
print(f"ERROR: An unexpected error occured while downloading '{filehash}' to '{save_destination}'.")
|
|
60
|
+
traceback.print_exception(type(e), e, e.__traceback__)
|
|
61
|
+
return False
|
azlassets/imgrecon.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from UnityPy import AssetsManager
|
|
3
|
+
from UnityPy.enums import ClassIDType
|
|
4
|
+
from PIL import Image
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
VR = re.compile(r'v ')
|
|
8
|
+
TR = re.compile(r'vt ')
|
|
9
|
+
SR = re.compile(r' ')
|
|
10
|
+
def recon(src, mesh):
|
|
11
|
+
sx, sy = src.size
|
|
12
|
+
c = map(SR.split, list(filter(TR.match, mesh))[1::2])
|
|
13
|
+
p = map(SR.split, list(filter(VR.match, mesh))[1::2])
|
|
14
|
+
c = [(round(float(a[1])*sx), round((1-float(a[2]))*sy)) for a in c]
|
|
15
|
+
p = [(-int(float(a[1])), int(float(a[2]))) for a in p]
|
|
16
|
+
my = max(y for x, y in p)
|
|
17
|
+
p = [(x, my-y) for x, y in p[::2]]
|
|
18
|
+
cp = [(l+r, p) for l, r, p in zip(c[::2], c[1::2], p)]
|
|
19
|
+
ox, oy = zip(*[(r-l+p, b-t+q) for (l, t, r, b), (p, q) in cp])
|
|
20
|
+
out = Image.new('RGBA', (max(ox), max(oy)))
|
|
21
|
+
for c, p in cp: out.paste(src.crop(c), p)
|
|
22
|
+
return out
|
|
23
|
+
|
|
24
|
+
def load_mesh(filepath, require_name=None):
|
|
25
|
+
am = AssetsManager(filepath)
|
|
26
|
+
for obj in am.objects:
|
|
27
|
+
if obj.type == ClassIDType.Mesh:
|
|
28
|
+
objdata = obj.read()
|
|
29
|
+
if require_name and require_name != objdata.m_Name:
|
|
30
|
+
continue
|
|
31
|
+
data = objdata.export().splitlines()
|
|
32
|
+
return data
|
|
33
|
+
|
|
34
|
+
def load_images(filepath: str):
|
|
35
|
+
am = AssetsManager(filepath)
|
|
36
|
+
for obj in am.objects:
|
|
37
|
+
if obj.type == ClassIDType.Texture2D:
|
|
38
|
+
yield obj, obj.read()
|
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
|
3
|
+
# source: p10min_pb.proto
|
|
4
|
+
"""Generated protocol buffer code."""
|
|
5
|
+
from google.protobuf import descriptor as _descriptor
|
|
6
|
+
from google.protobuf import descriptor_pool as _descriptor_pool
|
|
7
|
+
from google.protobuf import symbol_database as _symbol_database
|
|
8
|
+
from google.protobuf.internal import builder as _builder
|
|
9
|
+
# @@protoc_insertion_point(imports)
|
|
10
|
+
|
|
11
|
+
_sym_db = _symbol_database.Default()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0fp10min_pb.proto\x12\x0f\x61zlassets.proto\"+\n\x08\x63s_10800\x12\r\n\x05state\x18\x01 \x02(\r\x12\x10\n\x08platform\x18\x02 \x02(\t\"\xce\x01\n\x08sc_10801\x12\x12\n\ngateway_ip\x18\x01 \x02(\t\x12\x14\n\x0cgateway_port\x18\x02 \x02(\r\x12\x0b\n\x03url\x18\x03 \x02(\t\x12\x0f\n\x07version\x18\x04 \x03(\t\x12\x10\n\x08proxy_ip\x18\x05 \x01(\t\x12\x12\n\nproxy_port\x18\x06 \x01(\r\x12\r\n\x05is_ts\x18\x07 \x02(\r\x12\x11\n\ttimestamp\x18\x08 \x02(\r\x12 \n\x18monday_0oclock_timestamp\x18\t \x02(\r\x12\x10\n\x08\x63\x64n_list\x18\n \x03(\t')
|
|
17
|
+
|
|
18
|
+
_globals = globals()
|
|
19
|
+
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
|
20
|
+
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'p10min_pb_pb2', _globals)
|
|
21
|
+
if _descriptor._USE_C_DESCRIPTORS == False:
|
|
22
|
+
|
|
23
|
+
DESCRIPTOR._options = None
|
|
24
|
+
_globals['_CS_10800']._serialized_start=36
|
|
25
|
+
_globals['_CS_10800']._serialized_end=79
|
|
26
|
+
_globals['_SC_10801']._serialized_start=82
|
|
27
|
+
_globals['_SC_10801']._serialized_end=288
|
|
28
|
+
# @@protoc_insertion_point(module_scope)
|
azlassets/protobuf.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import socket
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
PROTOBUFS = {}
|
|
6
|
+
def import_pb(pb: int, addname: str = ""):
|
|
7
|
+
module = importlib.import_module(f".p{pb}{addname}_pb_pb2", "azlassets.proto")
|
|
8
|
+
PROTOBUFS[pb] = module
|
|
9
|
+
|
|
10
|
+
def import_pb_with_retry(pb: int):
|
|
11
|
+
try:
|
|
12
|
+
import_pb(pb)
|
|
13
|
+
except ModuleNotFoundError:
|
|
14
|
+
import_pb(pb, "min")
|
|
15
|
+
|
|
16
|
+
class InvalidHeaderError(Exception): pass
|
|
17
|
+
|
|
18
|
+
class BasicCommand:
|
|
19
|
+
def __init__(self, command_id: int, index: int=0):
|
|
20
|
+
pbpackage = command_id//1000
|
|
21
|
+
if pbpackage not in PROTOBUFS:
|
|
22
|
+
try:
|
|
23
|
+
import_pb_with_retry(pbpackage)
|
|
24
|
+
except ModuleNotFoundError:
|
|
25
|
+
print(f'Command Package {pbpackage} does not exist (From cmd={command_id}).')
|
|
26
|
+
|
|
27
|
+
pbpak = PROTOBUFS[pbpackage]
|
|
28
|
+
if hasattr(pbpak, 'cs_'+str(command_id)):
|
|
29
|
+
self.command_id = command_id
|
|
30
|
+
self.index = index
|
|
31
|
+
self.pb = getattr(pbpak, 'cs_'+str(command_id))()
|
|
32
|
+
elif hasattr(pbpak, 'sc_'+str(command_id)):
|
|
33
|
+
self.command_id = command_id
|
|
34
|
+
self.index = index
|
|
35
|
+
self.pb = getattr(pbpak, 'sc_'+str(command_id))()
|
|
36
|
+
else:
|
|
37
|
+
print(f'Command {command_id} is not registered.')
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
ADV_HEADER_LEN = 7
|
|
41
|
+
HEADER_LEN = 2
|
|
42
|
+
HEADER_NOID_LEN = ADV_HEADER_LEN - HEADER_LEN
|
|
43
|
+
|
|
44
|
+
def serialize_pb(basic_pb_command):
|
|
45
|
+
payload = bytearray(basic_pb_command.pb.SerializeToString())
|
|
46
|
+
|
|
47
|
+
header_bytes = ((len(payload) or 1) + HEADER_NOID_LEN).to_bytes(2, byteorder='big')
|
|
48
|
+
command_id_bytes = basic_pb_command.command_id.to_bytes(2, byteorder='big')
|
|
49
|
+
index_bytes = basic_pb_command.index.to_bytes(2, byteorder='big')
|
|
50
|
+
|
|
51
|
+
command_bytes = bytearray(ADV_HEADER_LEN)
|
|
52
|
+
command_bytes[0] = header_bytes[0]
|
|
53
|
+
command_bytes[1] = header_bytes[1]
|
|
54
|
+
command_bytes[3] = command_id_bytes[0]
|
|
55
|
+
command_bytes[4] = command_id_bytes[1]
|
|
56
|
+
command_bytes[5] = index_bytes[0]
|
|
57
|
+
command_bytes[6] = index_bytes[1]
|
|
58
|
+
command_bytes.extend(payload)
|
|
59
|
+
return command_bytes
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def deserialize_header(header_bytes):
|
|
63
|
+
if not header_bytes[2] == 0:
|
|
64
|
+
raise InvalidHeaderError('Received invalid header.')
|
|
65
|
+
payload_size = (header_bytes[0] << 8 | header_bytes[1]) - HEADER_NOID_LEN
|
|
66
|
+
command_id = header_bytes[3] << 8 | header_bytes[4]
|
|
67
|
+
index = header_bytes[5] << 8 | header_bytes[6]
|
|
68
|
+
return payload_size, command_id, index
|
|
69
|
+
|
|
70
|
+
def deserialize_pb(command_id, index, payload_bytes):
|
|
71
|
+
basiccmd = BasicCommand(command_id)
|
|
72
|
+
if hasattr(basiccmd, 'pb'):
|
|
73
|
+
basiccmd.pb.ParseFromString(payload_bytes)
|
|
74
|
+
print(f'Successfully received cmd={command_id} with idx={index}.')
|
|
75
|
+
return basiccmd
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# PROTOBUF HELPER METHODS
|
|
79
|
+
class ConnectionSocket(socket.socket):
|
|
80
|
+
def __init__(self, blocking=True):
|
|
81
|
+
super().__init__(socket.AF_INET, socket.SOCK_STREAM)
|
|
82
|
+
self.setblocking(blocking)
|
|
83
|
+
|
|
84
|
+
def disconnect(self):
|
|
85
|
+
self.close()
|
|
86
|
+
|
|
87
|
+
def send_command(self, commandid, index, **kwargs):
|
|
88
|
+
print(f"Sending Command {commandid}.")
|
|
89
|
+
command = BasicCommand(commandid, index)
|
|
90
|
+
for k, v in kwargs.items():
|
|
91
|
+
setattr(command.pb, k, v)
|
|
92
|
+
self.send(serialize_pb(command))
|
|
93
|
+
|
|
94
|
+
def recv_command(self):
|
|
95
|
+
header = self.recv_bytes(ADV_HEADER_LEN)
|
|
96
|
+
payload_size, command_id, index = deserialize_header(header)
|
|
97
|
+
payload = self.recv_bytes(payload_size)
|
|
98
|
+
return deserialize_pb(command_id, index, payload)
|
|
99
|
+
|
|
100
|
+
def recv_bytes(self, bytesize:int):
|
|
101
|
+
data = b''
|
|
102
|
+
while len(data) < bytesize:
|
|
103
|
+
data += self.recv(bytesize - len(data))
|
|
104
|
+
return data
|
|
105
|
+
|
|
106
|
+
def get_version_response(gateip, gateport):
|
|
107
|
+
with ConnectionSocket() as socket:
|
|
108
|
+
socket.connect((gateip, gateport))
|
|
109
|
+
socket.send_command(
|
|
110
|
+
10800,
|
|
111
|
+
0,
|
|
112
|
+
state=21,
|
|
113
|
+
platform='0'
|
|
114
|
+
)
|
|
115
|
+
result = socket.recv_command()
|
|
116
|
+
return result
|
azlassets/repair.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import itertools
|
|
3
|
+
import aiofile
|
|
4
|
+
import asyncio
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from . import downloader, updater, versioncontrol
|
|
8
|
+
from .classes import HashRow, UserConfig, VersionResult, VersionType, UpdateResult, CompareType, DownloadType, ProgressBar
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def execute_coro_with_progressbar(coro, progressbar: ProgressBar):
|
|
12
|
+
r = await coro
|
|
13
|
+
progressbar.update()
|
|
14
|
+
return r
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
semaphore_concurrent_files = asyncio.Semaphore(5)
|
|
18
|
+
|
|
19
|
+
async def calc_md5hash(filepath: Path, chunk_size: int = 65536) -> str:
|
|
20
|
+
md5 = hashlib.md5()
|
|
21
|
+
async with semaphore_concurrent_files:
|
|
22
|
+
async with aiofile.async_open(filepath, "rb") as f:
|
|
23
|
+
async for chunk in f.iter_chunked(chunk_size):
|
|
24
|
+
if chunk:
|
|
25
|
+
md5.update(chunk)
|
|
26
|
+
return md5.hexdigest()
|
|
27
|
+
|
|
28
|
+
async def get_filedata(filepath: Path) -> tuple[str, int]:
|
|
29
|
+
if filepath.exists():
|
|
30
|
+
current_md5 = await calc_md5hash(filepath)
|
|
31
|
+
current_size = filepath.stat().st_size
|
|
32
|
+
return current_md5, current_size
|
|
33
|
+
else:
|
|
34
|
+
return "", 0
|
|
35
|
+
|
|
36
|
+
async def hashrow_from_file(assetbasepath: Path, filepath: Path) -> HashRow:
|
|
37
|
+
current_md5, current_size = await get_filedata(filepath)
|
|
38
|
+
clean_filepath = str(filepath.relative_to(assetbasepath)).replace("\\", "/")
|
|
39
|
+
return HashRow(clean_filepath, current_size, current_md5)
|
|
40
|
+
|
|
41
|
+
async def hashrow_from_relative_file(assetbasepath: Path, relative_filepath: Path) -> HashRow:
|
|
42
|
+
current_md5, current_size = await get_filedata(assetbasepath / relative_filepath)
|
|
43
|
+
return HashRow(relative_filepath, current_size, current_md5)
|
|
44
|
+
|
|
45
|
+
async def hashrows_from_files(client_directory: Path) -> list[HashRow]:
|
|
46
|
+
assetbasepath = client_directory / "AssetBundles"
|
|
47
|
+
progressbar = ProgressBar(0, "File Progress", details_unit="files", print_on_init=False)
|
|
48
|
+
print("Loading list of all files... ", end="")
|
|
49
|
+
tasks = [execute_coro_with_progressbar(hashrow_from_file(assetbasepath, fp), progressbar) for fp in assetbasepath.rglob("*") if not fp.is_dir()]
|
|
50
|
+
progressbar.total = len(tasks)
|
|
51
|
+
print("Done")
|
|
52
|
+
print("Checking all files...")
|
|
53
|
+
return await asyncio.gather(*tasks)
|
|
54
|
+
|
|
55
|
+
async def repair(cdnurl: str, userconfig: UserConfig, client_directory: Path) -> list[UpdateResult]:
|
|
56
|
+
current_hashes = await hashrows_from_files(client_directory)
|
|
57
|
+
expected_hashes = itertools.chain(*filter(lambda x: x is not None, [versioncontrol.load_hash_file(vtype, client_directory) for vtype in VersionType]))
|
|
58
|
+
comparison_results = updater.compare_hashes(current_hashes, expected_hashes)
|
|
59
|
+
async with downloader.AzurlaneAsyncDownloader(cdnurl, useragent=userconfig.useragent) as downloader_session:
|
|
60
|
+
update_results = await updater.update_assets(downloader_session, comparison_results, client_directory)
|
|
61
|
+
return update_results
|
|
62
|
+
|
|
63
|
+
async def repair_hashfile(version_result: VersionResult, cdnurl: str, userconfig: UserConfig, client_directory: Path) -> list[UpdateResult]:
|
|
64
|
+
# read hashes that are stored in the local hash file
|
|
65
|
+
localhashes = versioncontrol.load_hash_file(version_result.version_type, client_directory)
|
|
66
|
+
|
|
67
|
+
async with downloader.AzurlaneAsyncDownloader(cdnurl, useragent=userconfig.useragent) as downloader_session:
|
|
68
|
+
# load newest hashes from the game server
|
|
69
|
+
serverhashes = await updater.download_and_parse_hashes(version_result, downloader_session, userconfig) or []
|
|
70
|
+
assetbasepath = client_directory / "AssetBundles"
|
|
71
|
+
|
|
72
|
+
# parse hashes from all files stored on disk, but only check files that are expected based on the new hashes
|
|
73
|
+
# this skips deletion on unneeded files
|
|
74
|
+
print("Generating hashes for all files on disk...")
|
|
75
|
+
progressbar = ProgressBar(len(serverhashes), "File Progress", details_unit="files")
|
|
76
|
+
diskhashes_tasks = [execute_coro_with_progressbar(hashrow_from_relative_file(assetbasepath, hrow.filepath), progressbar) for hrow in serverhashes]
|
|
77
|
+
diskhashes = await asyncio.gather(*diskhashes_tasks)
|
|
78
|
+
|
|
79
|
+
# compare localhashes to diskhashes to determine which files have already been successfully downloaded
|
|
80
|
+
compare_results_disk = updater.compare_hashes(localhashes, diskhashes)
|
|
81
|
+
update_results_disk = {comp_result.new_hash.filepath: UpdateResult(comp_result, DownloadType.Success, comp_result.new_hash.filepath) for comp_result in compare_results_disk[CompareType.Changed]}
|
|
82
|
+
update_results_disk.update({comp_result.new_hash.filepath: UpdateResult(comp_result, DownloadType.Success, comp_result.new_hash.filepath) for comp_result in compare_results_disk[CompareType.New]})
|
|
83
|
+
update_results_disk.update({comp_result.current_hash.filepath: UpdateResult(comp_result, DownloadType.Removed, comp_result.current_hash.filepath) for comp_result in compare_results_disk[CompareType.Deleted]})
|
|
84
|
+
|
|
85
|
+
# download remaining files
|
|
86
|
+
update_results_server = await updater._update_from_hashes(version_result, downloader_session, client_directory, diskhashes, serverhashes, allow_deletion=False)
|
|
87
|
+
|
|
88
|
+
# add old update results to new update results list
|
|
89
|
+
update_results = []
|
|
90
|
+
for upres_server in update_results_server:
|
|
91
|
+
update_result = upres_server
|
|
92
|
+
# try to retrieve from old list only if there was no further change to the file
|
|
93
|
+
if upres_server.download_type == DownloadType.NoChange:
|
|
94
|
+
if upres_disk := update_results_disk.get(upres_server.path):
|
|
95
|
+
update_result = upres_disk
|
|
96
|
+
update_results.append(update_result)
|
|
97
|
+
|
|
98
|
+
return update_results
|