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 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
@@ -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