Add support for hdiffpatch files

Game now apply update properly (hopefully)

Signed-off-by: tretrauit <tretrauit@gmail.com>
This commit is contained in:
tretrauit 2022-03-31 02:09:33 +07:00
parent c7918f8a20
commit fd00e8b51d
Signed by: tretrauit
GPG Key ID: 862760FF1903319E
6 changed files with 116 additions and 10 deletions

View File

@ -9,7 +9,7 @@ README = (HERE / "README.md").read_text()
setup( setup(
name='worthless', name='worthless',
version='1.2.9-4', version='1.3.0',
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.froggi.es/tretrauit/worthless-launcher',
license='MIT License', license='MIT License',

View File

@ -2,3 +2,4 @@ from worthless import launcher, installer
Launcher = launcher.Launcher Launcher = launcher.Launcher
Installer = installer.Installer Installer = installer.Installer

View File

@ -0,0 +1 @@

View File

@ -59,11 +59,13 @@ class UI:
print("Reverting patches if patched...") print("Reverting patches if patched...")
self._patcher.revert_patch(True) self._patcher.revert_patch(True)
print("Updating game from archive (this may takes some time)...") print("Updating game from archive (this may takes some time)...")
self._installer.update_game(filepath) asyncio.run(self._installer.update_game(filepath))
self._installer.set_version_config()
def _install_from_archive(self, filepath, force_reinstall): def _install_from_archive(self, filepath, force_reinstall):
print("Installing game from archive (this may takes some time)...") print("Installing game from archive (this may takes some time)...")
self._installer.install_game(filepath, force_reinstall) self._installer.install_game(filepath, force_reinstall)
self._installer.set_version_config()
def _apply_voiceover_from_archive(self, filepath): def _apply_voiceover_from_archive(self, filepath):
print("Applying voiceover from archive (this may takes some time)...") print("Applying voiceover from archive (this may takes some time)...")
@ -311,6 +313,9 @@ def main():
if args.install: if args.install:
ui.install_game() ui.install_game()
if args.update_all:
raise NotImplementedError() # TODO
if args.update: if args.update:
ui.update_game_voiceover(args.update) ui.update_game_voiceover(args.update)

View File

@ -1,3 +1,4 @@
import asyncio
import re import re
import shutil import shutil
import platform import platform
@ -11,9 +12,11 @@ from configparser import ConfigParser
from aiopath import AsyncPath from aiopath import AsyncPath
from worthless import constants from worthless import constants
from worthless.launcher import Launcher from worthless.launcher import Launcher
from worthless.launcherconfig import LauncherConfig
async def _download_file(file_url: str, file_name: str, file_path: Path | str, file_len: int = None, overwrite=False, chunks=8192): async def _download_file(file_url: str, file_name: str, file_path: Path | str, file_len: int = None, overwrite=False,
chunks=8192):
""" """
Download file name to temporary directory, Download file name to temporary directory,
:param file_url: :param file_url:
@ -45,7 +48,7 @@ async def _download_file(file_url: str, file_name: str, file_path: Path | str, f
class HDiffPatch: class HDiffPatch:
def __init__(self, git_url=None, data_dir=None): def __init__(self, git_url=None, data_dir=None):
if not git_url: if not git_url:
repo_url = constants.HDIFFPATCH_GIT_URL git_url = constants.HDIFFPATCH_GIT_URL
self._git_url = git_url self._git_url = git_url
if not data_dir: if not data_dir:
self._appdirs = appdirs.AppDirs(constants.APP_NAME, constants.APP_AUTHOR) self._appdirs = appdirs.AppDirs(constants.APP_NAME, constants.APP_AUTHOR)
@ -82,6 +85,8 @@ class HDiffPatch:
def _get_hdiffpatch_exec(self, exec_name): def _get_hdiffpatch_exec(self, exec_name):
if shutil.which(exec_name): if shutil.which(exec_name):
return exec_name return exec_name
if not self.data_path.exists():
return None
if not any(self.data_path.iterdir()): if not any(self.data_path.iterdir()):
return None return None
platform_arch_path = self.data_path.joinpath(self._get_platform_arch()) platform_arch_path = self.data_path.joinpath(self._get_platform_arch())
@ -92,6 +97,12 @@ class HDiffPatch:
def get_hpatchz_executable(self): def get_hpatchz_executable(self):
return self._get_hdiffpatch_exec("hpatchz") return self._get_hdiffpatch_exec("hpatchz")
async def patch_file(self, in_file, out_file, patch_file):
hpatchz = self.get_hpatchz_executable()
if not hpatchz:
raise RuntimeError("hpatchz executable not found")
return await asyncio.create_subprocess_exec(hpatchz, "-f", in_file, patch_file, out_file)
def get_hdiffz_executable(self): def get_hdiffz_executable(self):
return self._get_hdiffpatch_exec("hdiffz") return self._get_hdiffpatch_exec("hdiffz")
@ -103,9 +114,9 @@ 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()
for asset in await rsp.json()["assets"]: for asset in (await rsp.json())["assets"]:
if asset["name"].endswith(".zip") and not "linux" in asset["name"] and not "windows" in asset["name"] \ if asset["name"].endswith(".zip") and "linux" not in asset["name"] and "windows" not in asset["name"] \
and not "macos" in asset["name"] and not "android" in asset["name"]: and "macos" not in asset["name"] and "android" not in asset["name"]:
return asset return asset
async def get_latest_release_url(self): async def get_latest_release_url(self):
@ -201,11 +212,11 @@ class Installer:
config_file = self._gamedir.joinpath("config.ini") config_file = self._gamedir.joinpath("config.ini")
self._config_file = config_file.resolve() self._config_file = config_file.resolve()
self._download_chunk = 8192 self._download_chunk = 8192
self._version = None
self._overseas = overseas self._overseas = overseas
self._version = self.get_game_version()
self._launcher = Launcher(self._gamedir, overseas=self._overseas) self._launcher = Launcher(self._gamedir, overseas=self._overseas)
self._hdiffpatch = HDiffPatch(data_dir=data_dir) self._hdiffpatch = HDiffPatch(data_dir=data_dir)
self._version = self.get_game_version() self._config = LauncherConfig(self._config_file, self._version)
def set_download_chunk(self, chunk: int): def set_download_chunk(self, chunk: int):
self._download_chunk = chunk self._download_chunk = chunk
@ -280,7 +291,7 @@ class Installer:
archive.extractall(self._gamedir) archive.extractall(self._gamedir)
archive.close() archive.close()
def update_game(self, game_archive: str | Path): async def update_game(self, game_archive: str | Path):
if not 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}")
if isinstance(game_archive, str): if isinstance(game_archive, str):
@ -288,6 +299,10 @@ class Installer:
if not game_archive.exists(): if not game_archive.exists():
raise FileNotFoundError(f"Update archive {game_archive} not found") 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():
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)
@ -297,6 +312,35 @@ class Installer:
except ValueError: except ValueError:
pass pass
# hdiffpatch implementation
hdifffiles = []
for x in archive.read("hdifffiles.txt").decode().split("\n"):
if x:
hdifffiles.append(json.loads(x)["remoteName"])
patch_jobs = []
for file in hdifffiles:
current_game_file = self._gamedir.joinpath(file)
if not current_game_file.exists():
# Not patching since we don't have the file
continue
patch_file = str(file) + ".hdiff"
async def extract_and_patch(old_file, diff_file):
await asyncio.to_thread(archive.extract, diff_file, self.temp_path)
patch_path = self.temp_path.joinpath(diff_file)
old_suffix = old_file.suffix
old_file = old_file.rename(old_file.with_suffix(".bak"))
proc = await self._hdiffpatch.patch_file(old_file, old_file.with_suffix(old_suffix),
patch_path)
await proc.wait()
patch_path.unlink()
old_file.unlink()
files.remove(patch_file)
patch_jobs.append(extract_and_patch(current_game_file, patch_file))
await asyncio.gather(*patch_jobs)
deletefiles = archive.read("deletefiles.txt").decode().split("\n") deletefiles = archive.read("deletefiles.txt").decode().split("\n")
for file in deletefiles: for file in deletefiles:
current_game_file = self._gamedir.joinpath(file) current_game_file = self._gamedir.joinpath(file)
@ -310,6 +354,12 @@ class Installer:
# Update game version on local variable. # Update game version on local variable.
self._version = self.get_game_version() self._version = self.get_game_version()
def set_version_config(self, version: str = None):
if not version:
version = self._version
self._config.set_game_version(version)
self._config.save()
async def download_full_game(self, overwrite: bool = False): async def download_full_game(self, overwrite: bool = False):
if self._version and not overwrite: if self._version and not overwrite:
raise ValueError("Game already exists") raise ValueError("Game already exists")

View File

@ -0,0 +1,49 @@
from configparser import ConfigParser
from pathlib import Path
class LauncherConfig:
"""
Provides config.ini for official launcher compatibility
"""
@staticmethod
def create_config(game_version, overseas=True):
"""
Creates config.ini
"""
sub_channel = "0" if overseas else "1"
config = ConfigParser()
config.add_section("General")
config.set("General", "channel", "1")
config.set("General", "cps", "mihoyo")
config.set("General", "game_version", game_version)
config.set("General", "sdk_version", "")
config.set("General", "sub_channel", sub_channel)
return config
def __init__(self, config_path, game_version=None, overseas=True):
if isinstance(config_path, str):
self.config_path = Path(config_path)
if not game_version:
game_version = "0.0.0"
self.config_path = config_path
self.config = ConfigParser()
if self.config_path.exists():
self.config.read(self.config_path)
else:
self.config = self.create_config(game_version, overseas)
def set_game_version(self, game_version):
self.config.set("General", "game_version", game_version)
def set_overseas(self, overseas=True):
sub_channel = "0" if overseas else "1"
self.config.set("General", "sub_channel", sub_channel)
def save(self):
"""
Saves config.ini
"""
with self.config_path.open("w") as config_file:
self.config.write(config_file)