Compare commits

...

38 Commits

Author SHA1 Message Date
tretrauit df00e6b679 chore: update url 2024-04-25 21:46:10 +07:00
tretrauit 07f6ce6317 fix: hdiffpatch download 2024-04-25 21:46:03 +07:00
tretrauit fba2063bce chore: bump to 2.2.21 2023-11-08 16:22:48 +07:00
tretrauit 78df1d242a fix(installer): wrap rename in try/except
So it won't fail I guess, because we failed anyway.
2023-11-08 16:22:30 +07:00
tretrauit f1eb1fe2c6 fix: voicepack detection 2023-06-20 22:35:33 +07:00
tretrauit f549166e2e fix: workaround around merged parts 2023-06-20 22:11:10 +07:00
tretrauit efc7ff2be9 fix: implement segments download
michos don't fucking do breaking changes in the API fuck you.
2023-06-20 18:14:44 +07:00
tretrauit 5e46b23752 chore: delete files first to save space 2023-05-25 13:03:05 +07:00
tretrauit 973ae2a5e6 fix: actually delete files in deletefiles.txt 2023-05-25 13:01:04 +07:00
tretrauit aa4fe4d5ed chore: bump to 2.2.19 2023-05-25 10:42:14 +07:00
tretrauit 836c843b2e fix: str -> Path 2023-05-25 10:41:51 +07:00
tretrauit 0af4c4f2df chore: bump 2023-05-25 10:39:59 +07:00
tretrauit a6600cf573 fix: wrong blueReporter path 2023-05-25 10:39:40 +07:00
tretrauit 305021d8b7 chore: bump to 2.2.17 2023-05-25 10:38:25 +07:00
tretrauit 2d5c75109a fix: add blueReporter to revert list 2023-05-25 10:29:24 +07:00
tretrauit 45006ef4b5 fix: telemetry.dll rename 2023-05-25 10:28:17 +07:00
tretrauit 5a6f8e39af feat: add blueReporter 2023-05-25 10:26:08 +07:00
tretrauit d99760422d fix: voicepack detection & 2.2.16 2023-05-25 10:12:01 +07:00
tretrauit 3c6d44d983 fix: --from-vo-ver doesn't set vo version 2023-03-01 20:22:04 +07:00
tretrauit 1de9c42c1f misc: remove comment 2023-03-01 17:33:48 +07:00
Nguyễn Thế Hưng cc6f3996af fix: only do 8 jobs at once in update & verify
Prevent lag which can lead to unusable system (especially when using hdiffpatch)

Also print more
2023-03-01 17:32:07 +07:00
tretrauit 1957f8265b fix: fix 2023-01-18 13:39:34 +07:00
tretrauit 820bc70e9d feat: hdiff in voiceover update 2023-01-18 12:38:43 +07:00
tretrauit 089b799a5f fix: RuntimeWarning: coroutine 'Installer.get_voiceover_archive_language' was never awaited 2023-01-18 12:25:12 +07:00
tretrauit f46902879a fix: aiohttp.client_exceptions.ClientPayloadError: Response payload is not completed 2023-01-18 11:45:40 +07:00
tretrauit 95dccf7241 feat: --download-chunk
Also bump to 2.2.9
2023-01-18 11:28:33 +07:00
tretrauit 178f513e52
updater: change the returncode check
Signed-off-by: tretrauit <tretrauit@gmail.com>
2022-12-07 13:04:48 +07:00
tretrauit 98898827e9
updater: more logs
Signed-off-by: tretrauit <tretrauit@gmail.com>
2022-12-07 13:03:19 +07:00
tretrauit 95f21e6d2a
updater: do not continue if file extraction failed
Signed-off-by: tretrauit <tretrauit@gmail.com>
2022-12-07 12:55:03 +07:00
tretrauit e55d6cafd6
updater: do not delete directory
I took a look at deletefiles.txt again and realized that they're all files, so we don't need to delete the entire directory

Signed-off-by: tretrauit <tretrauit@gmail.com>
2022-11-03 17:44:54 +07:00
tretrauit 727b7e9b44
updater: fix deleting the game root directory lmao 2022-11-02 12:56:53 +07:00
tretrauit 0b51be1649
updater: remove directory in deletefiles.txt too 2022-11-02 12:26:37 +07:00
tretrauit abdf19bc16
patcher: switch to Krock branch 2022-09-29 12:11:32 +07:00
tretrauit e0f3fadc9d
fix: don't use 7zip
Providing that it doesn't provide any benefits than ZipFile
2022-09-28 22:53:12 +07:00
tretrauit cd2aabea60
hack: workaround over Windows path 2022-09-28 22:00:23 +07:00
tretrauit 1830081cb0
fix: use 7z to extract game file 2022-09-28 21:19:20 +07:00
tretrauit f54c2f432b
fix: handle FileExistsError 2022-09-28 12:51:27 +07:00
tretrauit e3045f5150
fix: do not await is_file() 2022-09-28 12:26:54 +07:00
9 changed files with 235 additions and 123 deletions

View File

@ -2,4 +2,8 @@
A worthless CLI launcher written in Python. A worthless CLI launcher written in Python.
Check out its website at https://tretrauit.gitlab.io/worthless-launcher for more information. > For a nice GUI launcher you should check out [An Anime Game Launcher](https://github.com/an-anime-team/an-anime-game-launcher)
Check out its website at https://tretrauit.gitlab.io/worthless-launcher for more information.
> The current branch will enter maintenance mode, for the latest development please check `refactor` branch

View File

@ -1,3 +1,3 @@
aiohttp==3.8.1 aiohttp==3.8.3
appdirs~=1.4.4 appdirs~=1.4.4
aiopath~=0.6.10 aiopath~=0.6.10

View File

@ -1,4 +1,5 @@
import pathlib import pathlib
from setuptools import setup from setuptools import setup
# The directory containing this file # The directory containing this file
@ -9,12 +10,12 @@ README = (HERE / "README.md").read_text()
setup( setup(
name='worthless', name='worthless',
version='2.2.1', version='2.2.22',
packages=['worthless', 'worthless.classes', 'worthless.classes.launcher', 'worthless.classes.installer'], packages=['worthless', 'worthless.classes', 'worthless.classes.launcher', 'worthless.classes.installer'],
url='https://git.froggi.es/tretrauit/worthless-launcher', url='https://git.tretrauit.me/tretrauit/worthless-launcher',
license='MIT License', license='MIT License',
author='tretrauit', author='tretrauit',
author_email='tretrauit@cachyos.org', author_email='tretrauit@gmail.org',
description='A worthless CLI launcher written in Python.', description='A worthless CLI launcher written in Python.',
long_description=README, long_description=README,
long_description_content_type="text/markdown", long_description_content_type="text/markdown",

View File

@ -3,3 +3,5 @@ from worthless import launcher, installer
Launcher = launcher.Launcher Launcher = launcher.Launcher
Installer = installer.Installer Installer = installer.Installer
__version__ = "2.2.22"

0
worthless/__main__.py Executable file → Normal file
View File

View File

@ -15,7 +15,10 @@ class Latest:
self.raw = raw self.raw = raw
def get_name(self): def get_name(self):
return self.path.split("/")[-1] name = self.path.split("/")[-1]
if name == "":
name = self.segments[0]["path"].split("/")[-1][:-4]
return name
@staticmethod @staticmethod
def from_dict(data): def from_dict(data):

View File

@ -12,7 +12,7 @@ import worthless.constants as constants
class UI: class UI:
def __init__(self, gamedir: str, noconfirm: bool, tempdir: str | Path = None, pre_download=False) -> None: def __init__(self, gamedir: str, noconfirm: bool, tempdir: str | Path = None, pre_download: bool = False, download_chunk: int = 8192) -> None:
self._vo_version = None self._vo_version = None
self._noconfirm = noconfirm self._noconfirm = noconfirm
self._gamedir = gamedir self._gamedir = gamedir
@ -22,6 +22,7 @@ class UI:
self._pre_download = pre_download self._pre_download = pre_download
if self._pre_download: if self._pre_download:
print("Pre-download is enabled, use at your own risk!") print("Pre-download is enabled, use at your own risk!")
self._installer.set_download_chunk(download_chunk)
def _ask(self, question): def _ask(self, question):
if self._noconfirm: if self._noconfirm:
@ -155,7 +156,7 @@ class UI:
if not self._installer.voiceover_lang_translate(lng) == vo.language: if not self._installer.voiceover_lang_translate(lng) == vo.language:
continue continue
print("Downloading voiceover update pack for {} (This will take a long time)...".format(lng)) print("Downloading voiceover update pack for {} (This will take a long time)...".format(lng))
await self._installer.download_voiceover_update(lng, pre_download=self._pre_download) await self._installer.download_voiceover_update(lng, from_version=self._vo_version, pre_download=self._pre_download)
async def install_game(self, forced: bool = False): async def install_game(self, forced: bool = False):
res_info = await self._launcher.get_resource_info() res_info = await self._launcher.get_resource_info()
@ -315,11 +316,12 @@ async def main():
"detection", type=str, default=None) "detection", type=str, default=None)
parser.add_argument("--noconfirm", action="store_true", parser.add_argument("--noconfirm", action="store_true",
help="Do not ask any for confirmation. (Ignored in interactive mode)") help="Do not ask any for confirmation. (Ignored in interactive mode)")
parser.add_argument("--download-chunk", action="store", help="Chunks to download (default 8192 bytes)", type=int, default=8192)
args = parser.parse_args() args = parser.parse_args()
if args.temporary_dir: if args.temporary_dir:
args.temporary_dir.mkdir(parents=True, exist_ok=True) args.temporary_dir.mkdir(parents=True, exist_ok=True)
ui = UI(args.dir, args.noconfirm, args.temporary_dir, args.predownload) ui = UI(args.dir, args.noconfirm, args.temporary_dir, args.predownload, args.download_chunk)
if args.install and args.update: if args.install and args.update:
raise ValueError("Cannot specify both --install and --update arguments.") raise ValueError("Cannot specify both --install and --update arguments.")
@ -336,6 +338,9 @@ async def main():
if args.from_ver: if args.from_ver:
ui.override_game_version(args.from_ver) ui.override_game_version(args.from_ver)
if args.from_vo_ver:
ui.override_voiceover_version(args.from_vo_ver)
if args.get_game_version: if args.get_game_version:
await ui.get_game_version() await ui.get_game_version()

View File

@ -1,14 +1,15 @@
import asyncio import asyncio
import hashlib
import json
import platform
import re import re
import shutil import shutil
import platform
import aiohttp
import zipfile import zipfile
import json
import hashlib
from pathlib import Path
from configparser import ConfigParser from configparser import ConfigParser
from aiopath import AsyncPath from pathlib import Path
import aiohttp
from worthless import constants from worthless import constants
from worthless.launcher import Launcher from worthless.launcher import Launcher
from worthless.launcherconfig import LauncherConfig from worthless.launcherconfig import LauncherConfig
@ -23,27 +24,40 @@ async def _download_file(file_url: str, file_name: str, file_path: Path | str, f
:return: :return:
""" """
headers = {} headers = {}
file_path = AsyncPath(file_path).joinpath(file_name) file_path = Path(file_path).joinpath(file_name)
if overwrite: if overwrite:
await file_path.unlink(missing_ok=True) file_path.unlink(missing_ok=True)
if await file_path.exists(): if file_path.exists():
cur_len = (await file_path.stat()).st_size cur_len = (file_path.stat()).st_size
headers |= { headers |= {
"Range": f"bytes={cur_len}-{file_len if file_len else ''}" "Range": f"bytes={cur_len}-{file_len if file_len else ''}"
} }
else: else:
await file_path.touch() file_path.touch()
async with aiohttp.ClientSession() as session: print(f"Downloading {file_url} to {file_path}...")
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60*60, sock_read=240)) as session:
rsp = await session.get(file_url, headers=headers, timeout=None) rsp = await session.get(file_url, headers=headers, timeout=None)
if rsp.status == 416: if rsp.status == 416:
return return
rsp.raise_for_status() rsp.raise_for_status()
while True: while True:
chunk = await rsp.content.read(chunks) chunk = await rsp.content.read(chunks)
await asyncio.sleep(0)
if not chunk: if not chunk:
break break
async with file_path.open("ab") as f: with file_path.open("ab") as f:
await f.write(chunk) f.write(chunk)
def calculate_md5(file_to_calculate):
file_to_calculate = Path(file_to_calculate)
if not file_to_calculate.exists():
return ""
with file_to_calculate.open("rb") as f:
file_hash = hashlib.md5()
while chunk := f.read(1024 * 1024):
file_hash.update(chunk)
return file_hash.hexdigest()
class HDiffPatch: class HDiffPatch:
@ -60,23 +74,32 @@ class HDiffPatch:
data_dir = Path(data_dir) data_dir = Path(data_dir)
self.data_path = Path(data_dir).joinpath("Tools/HDiffPatch") self.data_path = Path(data_dir).joinpath("Tools/HDiffPatch")
self.temp_path = data_dir.joinpath("Temp/HDiffPatch") self.temp_path = data_dir.joinpath("Temp/HDiffPatch")
self.temp_path.mkdir(parents=True, exist_ok=True) Path(self.temp_path).mkdir(parents=True, exist_ok=True)
@staticmethod @staticmethod
def _get_platform_arch(): def _get_platform_arch():
processor = platform.machine()
match platform.system(): match platform.system():
case "Windows": case "Windows":
match platform.architecture()[0]: match processor:
case "32bit": case "i386":
return "windows32" return "windows32"
case "64bit": case "x86_64":
return "windows64" return "windows64"
case "arm":
return "windows_arm32"
case "arm64":
return "windows_arm64"
case "Linux": case "Linux":
match platform.architecture()[0]: match processor:
case "32bit": case "i386":
return "linux32" return "linux32"
case "64bit": case "x86_64":
return "linux64" return "linux64"
case "arm":
return "linux_arm32"
case "arm64":
return "linux_arm64"
case "Darwin": case "Darwin":
return "macos" return "macos"
@ -102,6 +125,7 @@ class HDiffPatch:
return self._get_hdiffpatch_exec(hpatchz_name) return self._get_hdiffpatch_exec(hpatchz_name)
async def patch_file(self, in_file, out_file, patch_file, error=False, wait=False): async def patch_file(self, in_file, out_file, patch_file, error=False, wait=False):
print("executing hpatchz")
hpatchz = self.get_hpatchz_executable() hpatchz = self.get_hpatchz_executable()
if not hpatchz: if not hpatchz:
raise RuntimeError("hpatchz executable not found") raise RuntimeError("hpatchz executable not found")
@ -125,10 +149,13 @@ class HDiffPatch:
rsp = await session.get("https://api.github.com/repos/{}/{}/releases/latest".format(owner, repo), rsp = await session.get("https://api.github.com/repos/{}/{}/releases/latest".format(owner, repo),
params={"Headers": "Accept: application/vnd.github.v3+json"}) params={"Headers": "Accept: application/vnd.github.v3+json"})
rsp.raise_for_status() rsp.raise_for_status()
archive_processor = self._get_platform_arch()
for asset in (await rsp.json())["assets"]: for asset in (await rsp.json())["assets"]:
if asset["name"].endswith(".zip") and "linux" not in asset["name"] and "windows" not in asset["name"] \ if not asset["name"].endswith(".zip"):
and "macos" not in asset["name"] and "android" not in asset["name"]: continue
return asset if archive_processor not in asset["name"]:
continue
return asset
async def get_latest_release_url(self): async def get_latest_release_url(self):
asset = await self._get_latest_release_info() asset = await self._get_latest_release_info()
@ -151,22 +178,22 @@ class HDiffPatch:
class Installer: class Installer:
def __init__(self, gamedir: str | Path | AsyncPath = AsyncPath.cwd(), def __init__(self, gamedir: str | Path = Path.cwd(),
overseas: bool = True, data_dir: str | Path | AsyncPath = None): overseas: bool = True, data_dir: str | Path = None):
if isinstance(gamedir, str | Path): if isinstance(gamedir, str | Path):
gamedir = AsyncPath(gamedir) gamedir = Path(gamedir)
self._gamedir = gamedir self._gamedir = gamedir
if not data_dir: if not data_dir:
self._appdirs = constants.APPDIRS self._appdirs = constants.APPDIRS
self.temp_path = AsyncPath(self._appdirs.user_cache_dir).joinpath("Installer") self.temp_path = Path(self._appdirs.user_cache_dir).joinpath("Installer")
else: else:
if isinstance(data_dir, str | AsyncPath): if isinstance(data_dir, str | Path):
data_dir = AsyncPath(data_dir) data_dir = Path(data_dir)
self.temp_path = data_dir.joinpath("Temp/Installer/") self.temp_path = data_dir.joinpath("Temp/Installer/")
Path(self.temp_path).mkdir(parents=True, exist_ok=True) Path(self.temp_path).mkdir(parents=True, exist_ok=True)
config_file = self._gamedir.joinpath("config.ini") config_file = self._gamedir.joinpath("config.ini")
self._config_file = config_file self._config_file = config_file
self._download_chunk = 8192 self._download_chunk = 1024 * 1024
self._overseas = overseas self._overseas = overseas
self._version = None self._version = None
self._launcher = Launcher(self._gamedir, overseas=self._overseas) self._launcher = Launcher(self._gamedir, overseas=self._overseas)
@ -185,13 +212,13 @@ class Installer:
chunks=self._download_chunk) chunks=self._download_chunk)
async def read_version_from_config(self): async def read_version_from_config(self):
if not await self._config_file.exists(): if not self._config_file.exists():
raise FileNotFoundError(f"Config file {self._config_file} not found") raise FileNotFoundError(f"Config file {self._config_file} not found")
cfg = ConfigParser() cfg = ConfigParser()
await asyncio.to_thread(cfg.read, str(self._config_file)) await asyncio.to_thread(cfg.read, str(self._config_file))
return cfg.get("General", "game_version") return cfg.get("General", "game_version")
async def read_version_from_game_file(self, globalgamemanagers: AsyncPath | Path | bytes) -> str: async def read_version_from_game_file(self, globalgamemanagers: Path | Path | bytes) -> str:
""" """
Reads the version from the globalgamemanagers file. (Data/globalgamemanagers) Reads the version from the globalgamemanagers file. (Data/globalgamemanagers)
@ -200,9 +227,9 @@ class Installer:
:return: Game version (ex 1.0.0) :return: Game version (ex 1.0.0)
""" """
if isinstance(globalgamemanagers, Path | AsyncPath): if isinstance(globalgamemanagers, Path | Path):
globalgamemanagers = AsyncPath(globalgamemanagers) globalgamemanagers = Path(globalgamemanagers)
data = await globalgamemanagers.read_text("ascii", errors="ignore") data = globalgamemanagers.read_text("ascii", errors="ignore")
else: else:
data = globalgamemanagers.decode("ascii", errors="ignore") data = globalgamemanagers.decode("ascii", errors="ignore")
result = self._game_version_re.search(data) result = self._game_version_re.search(data)
@ -242,7 +269,7 @@ class Installer:
return lang return lang
@staticmethod @staticmethod
async def get_voiceover_archive_language(voiceover_archive: str | Path | AsyncPath) -> str: async def get_voiceover_archive_language(voiceover_archive: str | Path | Path) -> str:
if isinstance(voiceover_archive, str | Path): if isinstance(voiceover_archive, str | Path):
voiceover_archive = Path(voiceover_archive).resolve() voiceover_archive = Path(voiceover_archive).resolve()
if not voiceover_archive.exists(): if not voiceover_archive.exists():
@ -259,7 +286,7 @@ class Installer:
:param voiceover_archive: :param voiceover_archive:
:return: True if this is a full archive, else False. :return: True if this is a full archive, else False.
""" """
vo_lang = Installer.get_voiceover_archive_language(voiceover_archive) vo_lang = await Installer.get_voiceover_archive_language(voiceover_archive)
with zipfile.ZipFile(voiceover_archive, 'r') as f: with zipfile.ZipFile(voiceover_archive, 'r') as f:
archive_path = zipfile.Path(f) archive_path = zipfile.Path(f)
files = (await asyncio.to_thread(f.read, "Audio_{}_pkg_version".format(vo_lang))).decode().split("\n") files = (await asyncio.to_thread(f.read, "Audio_{}_pkg_version".format(vo_lang))).decode().split("\n")
@ -277,7 +304,7 @@ class Installer:
else: else:
return "YuanShen_Data/" return "YuanShen_Data/"
def get_game_data_path(self) -> AsyncPath: def get_game_data_path(self) -> Path:
return self._gamedir.joinpath(self.get_game_data_name()) return self._gamedir.joinpath(self.get_game_data_name())
async def get_game_archive_version(self, game_archive: str | Path): async def get_game_archive_version(self, game_archive: str | Path):
@ -291,7 +318,7 @@ class Installer:
async def get_game_version(self) -> str | None: async def get_game_version(self) -> str | None:
globalgamemanagers = self.get_game_data_path().joinpath("./globalgamemanagers") globalgamemanagers = self.get_game_data_path().joinpath("./globalgamemanagers")
if not await globalgamemanagers.exists(): if not globalgamemanagers.exists():
try: try:
return await self.read_version_from_config() return await self.read_version_from_config()
except FileNotFoundError: except FileNotFoundError:
@ -305,26 +332,20 @@ class Installer:
:return: List of installed voiceovers :return: List of installed voiceovers
""" """
voiceovers = [] voiceovers = []
async for file in self.get_game_data_path()\ for file in self.get_game_data_path()\
.joinpath("StreamingAssets/Audio/GeneratedSoundBanks/Windows").iterdir(): .joinpath("StreamingAssets/AudioAssets/").iterdir():
if await file.is_dir(): if file.is_dir():
voiceovers.append(file.name) voiceovers.append(file.name)
return voiceovers return voiceovers
async def update_game(self, game_archive: str | Path | AsyncPath): async def _update(self, game_archive: str | Path | Path):
if not await self.get_game_data_path().exists():
raise FileNotFoundError(f"Game not found in {self._gamedir}")
if isinstance(game_archive, str | Path):
game_archive = Path(game_archive).resolve()
if not game_archive.exists():
raise FileNotFoundError(f"Update archive {game_archive} not found")
archive = zipfile.ZipFile(game_archive, 'r') archive = zipfile.ZipFile(game_archive, 'r')
if not self._hdiffpatch.get_hpatchz_executable(): if not self._hdiffpatch.get_hpatchz_executable():
await self._hdiffpatch.download_latest_release() await self._hdiffpatch.download_latest_release()
files = archive.namelist() files = archive.namelist()
# Don't extract these files (they're useless and if the game isn't patched then it'll # Don't extract these files (they're useless and if the game isn't patched then it'll
# raise 31-4xxx error ingame) # raise 31-4xxx error ingame)
for file in ["deletefiles.txt", "hdifffiles.txt"]: for file in ["deletefiles.txt", "hdifffiles.txt"]:
@ -332,53 +353,98 @@ class Installer:
files.remove(file) files.remove(file)
except ValueError: except ValueError:
pass pass
try:
deletefiles = archive.read("deletefiles.txt").decode().split("\r\n")
for file in deletefiles:
current_game_file = Path(self._gamedir).joinpath(file)
if current_game_file == Path(self._gamedir):
# Don't delete the game folder
print("Game folder detected, not deleting:", current_game_file)
continue
if not current_game_file.relative_to(Path(self._gamedir)):
print("Not deleting (not relative to game):", current_game_file)
continue
print("Deleting", file)
current_game_file.unlink(missing_ok=True)
except Exception as e:
print(f"Error while reading deletefiles.txt: {e}")
# hdiffpatch implementation # hdiffpatch implementation
hdifffiles = [] try:
for x in (await asyncio.to_thread(archive.read, "hdifffiles.txt")).decode().split("\n"): hdifffiles = []
if x: for x in (await asyncio.to_thread(archive.read, "hdifffiles.txt")).decode().split("\n"):
hdifffiles.append(json.loads(x)["remoteName"]) if x:
patch_jobs = [] hdifffiles.append(json.loads(x.strip())["remoteName"])
for file in hdifffiles: patch_jobs = []
current_game_file = self._gamedir.joinpath(file) cur_jobs = []
if not await current_game_file.exists(): count = 0
# Not patching since we don't have the file for file in hdifffiles:
continue current_game_file = self._gamedir.joinpath(file)
if not current_game_file.exists():
print("File", file, "not found")
# Not patching since we don't have the file
continue
patch_file = str(file) + ".hdiff" patch_file = str(file) + ".hdiff"
async def extract_and_patch(old_file, diff_file): async def extract_and_patch(old_file, diff_file):
diff_path = self.temp_path.joinpath(diff_file) patch_path = self.temp_path.joinpath(diff_file)
if await diff_path.is_file(): patch_path.unlink(missing_ok=True)
await diff_path.unlink(missing_ok=True) try:
await asyncio.to_thread(archive.extract, diff_file, self.temp_path) print(diff_file)
patch_path = self.temp_path.joinpath(diff_file) await asyncio.to_thread(archive.extract, diff_file, self.temp_path)
old_suffix = old_file.suffix except FileExistsError:
old_file = await old_file.rename(old_file.with_suffix(".bak")) print("Failed to extract diff file", diff_file)
proc = await self._hdiffpatch.patch_file(old_file, old_file.with_suffix(old_suffix), return
patch_path, wait=True) old_suffix = old_file.suffix
await patch_path.unlink() old_file = old_file.rename(old_file.with_suffix(".bak"))
if proc.returncode != 0: proc = await self._hdiffpatch.patch_file(old_file, old_file.with_suffix(old_suffix),
patch_path, wait=True)
patch_path.unlink()
if proc.returncode == 0:
old_file.unlink()
return
# Let the game download the file. # Let the game download the file.
await old_file.rename(old_file.with_suffix(old_suffix)) print("Failed to patch {}, reverting and let the in-game updater do the job...".format(
return old_file.with_suffix(old_suffix))
await old_file.unlink() )
try:
old_file.rename(old_file.with_suffix(old_suffix))
except Exception:
pass
files.remove(patch_file) files.remove(patch_file)
patch_jobs.append(extract_and_patch(current_game_file, patch_file)) # Limit to 8 process running so it doesn't hang the PC.
if count == 7:
print("add job")
patch_jobs.append(cur_jobs)
cur_jobs = []
count = 0
cur_jobs.append(extract_and_patch(current_game_file, patch_file))
count += 1
await asyncio.gather(*patch_jobs) # The last list may have count < 7 and the above code will not add them
patch_jobs.append(cur_jobs)
for jobs in patch_jobs:
print("exec jobs", jobs)
await asyncio.gather(*jobs)
deletefiles = archive.read("deletefiles.txt").decode().split("\n") except Exception as e:
for file in deletefiles: print(f"Error while reading hdifffiles.txt: {e}")
current_game_file = Path(self._gamedir.joinpath(file))
if not current_game_file.exists():
continue
if current_game_file.is_file():
current_game_file.unlink(missing_ok=True)
await asyncio.to_thread(archive.extractall, self._gamedir, members=files) await asyncio.to_thread(archive.extractall, self._gamedir, members=files)
archive.close() archive.close()
async def update_game(self, game_archive: str | Path | Path):
if not self.get_game_data_path().exists():
raise FileNotFoundError(f"Game not found in {self._gamedir}")
if isinstance(game_archive, str | Path):
game_archive = Path(game_archive).resolve()
if not game_archive.exists():
raise FileNotFoundError(f"Update archive {game_archive} not found")
await self._update(game_archive=game_archive)
# Update game version on local variable. # Update game version on local variable.
self._version = await self.get_game_version() self._version = await self.get_game_version()
self.set_version_config() self.set_version_config()
@ -391,8 +457,27 @@ class Installer:
async def download_full_game(self, pre_download=False): async def download_full_game(self, pre_download=False):
game = await self._get_game(pre_download) game = await self._get_game(pre_download)
archive_name = game.latest.path.split("/")[-1] if not game.latest.path == "":
await self._download_file(game.latest.path, archive_name, game.latest.size) archive_name = game.latest.path.split("/")[-1]
if calculate_md5(self.temp_path.joinpath(archive_name)) != game.latest.md5:
raise RuntimeError("mismatch md5 for downloaded game archive")
return
# Segment download
base_archive = None
for i, segment in enumerate(game.latest.segments):
archive_name = segment["path"].split("/")[-1]
if i == 0:
base_archive = archive_name = Path(archive_name).stem # Remove .001
if self.temp_path.joinpath(archive_name + ".downloaded").exists():
continue
await self._download_file(segment["path"], archive_name)
if i != 0:
with open(self.temp_path.joinpath(base_archive), 'ab') as f:
with open(self.temp_path.joinpath(archive_name), 'rb') as f2:
f.write(f2.read())
self.temp_path.joinpath(archive_name).unlink()
self.temp_path.joinpath(archive_name + ".downloaded").touch()
async def download_full_voiceover(self, language: str, pre_download=False): async def download_full_voiceover(self, language: str, pre_download=False):
game = await self._get_game(pre_download) game = await self._get_game(pre_download)
@ -404,8 +489,8 @@ class Installer:
async def uninstall_game(self): async def uninstall_game(self):
await asyncio.to_thread(shutil.rmtree, self._gamedir, ignore_errors=True) await asyncio.to_thread(shutil.rmtree, self._gamedir, ignore_errors=True)
async def _extract_game_file(self, archive: str | Path | AsyncPath): async def _extract_game_file(self, archive: str | Path | Path):
if isinstance(archive, str | AsyncPath): if isinstance(archive, str | Path):
archive = Path(archive).resolve() archive = Path(archive).resolve()
if not archive.exists(): if not archive.exists():
raise FileNotFoundError(f"'{archive}' not found") raise FileNotFoundError(f"'{archive}' not found")
@ -416,21 +501,24 @@ class Installer:
# Since Voiceover packages are unclear about diff package or full package # Since Voiceover packages are unclear about diff package or full package
# we will try to extract the voiceover package and apply it to the game # we will try to extract the voiceover package and apply it to the game
# making this function universal for both cases # making this function universal for both cases
if not await self.get_game_data_path().exists(): if not self.get_game_data_path().exists():
raise FileNotFoundError(f"Game not found in {self._gamedir}") raise FileNotFoundError(f"Game not found in {self._gamedir}")
await self._extract_game_file(voiceover_archive) if isinstance(voiceover_archive, str | Path):
voiceover_archive = Path(voiceover_archive).resolve()
await self._update(voiceover_archive)
# await self._extract_game_file(voiceover_archive)
async def install_game(self, game_archive: str | Path | AsyncPath, force_reinstall: bool = False): async def install_game(self, game_archive: str | Path | Path, force_reinstall: bool = False):
"""Installs the game to the current directory """Installs the game to the current directory
If `force_reinstall` is True, the game will be uninstalled then reinstalled. If `force_reinstall` is True, the game will be uninstalled then reinstalled.
""" """
if await self.get_game_data_path().exists(): if self.get_game_data_path().exists():
if not force_reinstall: if not force_reinstall:
raise ValueError(f"Game is already installed in {self._gamedir}") raise ValueError(f"Game is already installed in {self._gamedir}")
await self.uninstall_game() await self.uninstall_game()
await self._gamedir.mkdir(parents=True, exist_ok=True) self._gamedir.mkdir(parents=True, exist_ok=True)
await self._extract_game_file(game_archive) await self._extract_game_file(game_archive)
self._version = await self.get_game_version() self._version = await self.get_game_version()
self.set_version_config() self.set_version_config()
@ -484,7 +572,7 @@ class Installer:
game = await self._get_game(pre_download=pre_download) game = await self._get_game(pre_download=pre_download)
translated_lang = self.voiceover_lang_translate(lang) translated_lang = self.voiceover_lang_translate(lang)
for v in game.diffs: for v in game.diffs:
if v.version != from_version: if v.version.strip() != from_version.strip():
continue continue
for vo in v.voice_packs: for vo in v.voice_packs:
if vo.language == translated_lang: if vo.language == translated_lang:
@ -502,21 +590,12 @@ class Installer:
if v.version == from_version: if v.version == from_version:
return v return v
async def verify_from_pkg_version(self, pkg_version: AsyncPath, ignore_mismatch=False): async def verify_from_pkg_version(self, pkg_version: Path, ignore_mismatch=False):
contents = await pkg_version.read_text() contents = pkg_version.read_text()
async def calculate_md5(file_to_calculate):
file_to_calculate = AsyncPath(file_to_calculate)
if not await file_to_calculate.exists():
return ""
async with file_to_calculate.open("rb") as f:
file_hash = hashlib.md5()
while chunk := await f.read(self._download_chunk):
file_hash.update(chunk)
return file_hash.hexdigest()
async def verify_file(file_to_verify, md5): async def verify_file(file_to_verify, md5):
file_md5 = await calculate_md5(file_to_verify) print("Verifying file:", file_to_verify)
file_md5 = await asyncio.to_thread(calculate_md5, file_to_verify)
if file_md5 == md5: if file_md5 == md5:
return None return None
if ignore_mismatch: if ignore_mismatch:
@ -524,13 +603,23 @@ class Installer:
raise ValueError(f"MD5 does not match for {file_to_verify}, expected md5: {md5}, actual md5: {file_md5}") raise ValueError(f"MD5 does not match for {file_to_verify}, expected md5: {md5}, actual md5: {file_md5}")
verify_jobs = [] verify_jobs = []
cur_jobs = []
count = 0
for content in contents.split("\r\n"): for content in contents.split("\r\n"):
if not content.strip(): if not content.strip():
continue continue
if count >= 7:
verify_jobs.append(cur_jobs)
cur_jobs = []
count = 0
info = json.loads(content) info = json.loads(content)
verify_jobs.append(verify_file(self._gamedir.joinpath(info["remoteName"]), info["md5"])) cur_jobs.append(verify_file(self._gamedir.joinpath(info["remoteName"]), info["md5"]))
count += 1
verify_result = await asyncio.gather(*verify_jobs) verify_jobs.append(cur_jobs)
verify_result = []
for jobs in verify_jobs:
verify_result.extend(await asyncio.gather(*jobs))
failed_files = [] failed_files = []
for file in verify_result: for file in verify_result:
if file is not None: if file is not None:
@ -538,7 +627,7 @@ class Installer:
return None if not failed_files else failed_files return None if not failed_files else failed_files
async def verify_game(self, pkg_version: str | Path | AsyncPath = None, ignore_mismatch=False): async def verify_game(self, pkg_version: str | Path | Path = None, ignore_mismatch=False):
if pkg_version is None: if pkg_version is None:
pkg_version = self._gamedir.joinpath("pkg_version") pkg_version = self._gamedir.joinpath("pkg_version")
return await self.verify_from_pkg_version(pkg_version, ignore_mismatch) return await self.verify_from_pkg_version(pkg_version, ignore_mismatch)

View File

@ -29,7 +29,7 @@ except ImportError:
class Patcher: class Patcher:
def __init__(self, gamedir: Path | AsyncPath | str = AsyncPath.cwd(), data_dir: str | Path | AsyncPath = None, def __init__(self, gamedir: Path | AsyncPath | str = AsyncPath.cwd(), data_dir: str | Path | AsyncPath = None,
patch_url: str = None, overseas=True, patch_provider="y0soro"): patch_url: str = None, overseas=True, patch_provider="Krock"):
if isinstance(gamedir, str | Path): if isinstance(gamedir, str | Path):
gamedir = AsyncPath(gamedir) gamedir = AsyncPath(gamedir)
self._gamedir = gamedir self._gamedir = gamedir
@ -237,12 +237,19 @@ class Patcher:
disable_files = [ disable_files = [
self._installer.get_game_data_name() + "upload_crash.exe", self._installer.get_game_data_name() + "upload_crash.exe",
self._installer.get_game_data_name() + "Plugins/crashreport.exe", self._installer.get_game_data_name() + "Plugins/crashreport.exe",
self._installer.get_game_data_name() + "blueReporter.exe",
] ]
for file in disable_files: for file in disable_files:
file_path = Path(self._gamedir.joinpath(file)).resolve() file_path = Path(self._gamedir.joinpath(file)).resolve()
if file_path.exists(): if file_path.exists():
await AsyncPath(file_path).rename(str(file_path) + ".bak") await AsyncPath(file_path).rename(str(file_path) + ".bak")
# Delete old Telemetry.dll on Linux (cAsE sEnsItIvE)
if platform.system() == "Linux":
telemetry_path = Path(self._installer.get_game_data_name()).joinpath("Plugins/Telemetry.dll")
if telemetry_path.exists() and Path(self._installer.get_game_data_name()).joinpath("Plugins/telemetry.dll").exists():
await telemetry_path.unlink()
patch_jobs.append(disable_crashreporters()) patch_jobs.append(disable_crashreporters())
await asyncio.gather(*patch_jobs) await asyncio.gather(*patch_jobs)
@ -290,6 +297,7 @@ class Patcher:
self._installer.get_game_data_name() + "upload_crash.exe", self._installer.get_game_data_name() + "upload_crash.exe",
self._installer.get_game_data_name() + "Plugins/crashreport.exe", self._installer.get_game_data_name() + "Plugins/crashreport.exe",
self._installer.get_game_data_name() + "Plugins/xlua.dll", self._installer.get_game_data_name() + "Plugins/xlua.dll",
self._installer.get_game_data_name() + "blueReporter.exe",
] ]
revert_job = [] revert_job = []
for file in revert_files: for file in revert_files: