worthless-launcher/worthless/patcher.py

310 lines
13 KiB
Python
Raw Normal View History

import os
import platform
import tarfile
2022-01-29 16:32:38 +00:00
from pathlib import Path
import shutil
import aiohttp
import asyncio
from aiopath import AsyncPath
from worthless import constants
from worthless.launcher import Launcher
from worthless.installer import Installer
2022-01-29 16:32:38 +00:00
2022-02-27 11:22:21 +00:00
match platform.system():
case "Linux":
from worthless import linux
case "Windows":
pass # TODO
case "Darwin":
pass # TODO
NO_XDELTA3_MODULE = False
try:
import xdelta3
except ImportError:
NO_XDELTA3_MODULE = True
2022-01-29 16:32:38 +00:00
class Patcher:
def __init__(self, gamedir: Path | AsyncPath | str = AsyncPath.cwd(), data_dir: str | Path | AsyncPath = None,
patch_url: str = None, overseas=True):
if isinstance(gamedir, str | Path):
gamedir = AsyncPath(gamedir)
2022-01-29 16:32:38 +00:00
self._gamedir = gamedir
self._patch_url = (patch_url if patch_url else constants.PATCH_GIT_URL).replace('http://', 'https://')
if not data_dir:
self._appdirs = constants.APPDIRS
self._patch_path = AsyncPath(self._appdirs.user_data_dir).joinpath("Patch")
self._temp_path = AsyncPath(self._appdirs.user_cache_dir).joinpath("Patcher")
else:
if isinstance(data_dir, str | Path):
data_dir = AsyncPath(data_dir)
self._patch_path = data_dir.joinpath("Patch")
self._temp_path = data_dir.joinpath("Temp/Patcher")
self._overseas = overseas
self._installer = Installer(self._gamedir, overseas=overseas, data_dir=data_dir)
self._launcher = Launcher(self._gamedir, overseas=overseas)
match platform.system():
case "Linux":
self._linux = linux.LinuxUtils()
@staticmethod
async def _get(url, **kwargs) -> aiohttp.ClientResponse:
async with aiohttp.ClientSession() as session:
rsp = await session.get(url, **kwargs)
rsp.raise_for_status()
return rsp
async def _get_git_archive(self, archive_format="tar.gz", branch="master"):
"""
Get the git archive of the patch repository.
This supports Gitea API and also introduce workaround for https://notabug.org
:return: Archive file in bytes
"""
# Replace http with https
if self._patch_url.startswith('https://notabug.org'):
archive_url = self._patch_url + '/archive/master.{}'.format(archive_format)
return await (await self._get(archive_url)).read()
try:
url_split = self._patch_url.split('//')
git_server = url_split[0]
git_owner, git_repo = url_split[1].split('/')
archive_url = git_server + '/api/v1/repos/{}/{}/archive/{}.{}'.format(
git_owner, git_repo, branch, archive_format
)
archive = await self._get(archive_url)
except aiohttp.ClientResponseError:
pass
else:
return await archive.read()
async def _download_repo(self, fallback=False):
if shutil.which("git") and not fallback:
if not await self._patch_path.is_dir() or not await self._patch_path.joinpath(".git").exists():
proc = await asyncio.create_subprocess_exec("git", "clone", self._patch_url, str(self._patch_path))
else:
proc = await asyncio.create_subprocess_exec("git", "pull", cwd=str(self._patch_path))
await proc.wait()
if proc.returncode != 0:
raise RuntimeError("Cannot download patch repository through git.")
else:
archive = await self._get_git_archive()
if not archive:
raise RuntimeError("Cannot download patch repository")
with tarfile.open(archive) as tar:
tar.extractall(self._patch_path)
2022-01-29 16:32:38 +00:00
def override_patch_url(self, url) -> None:
"""
Override the patch url.
2022-01-29 16:32:38 +00:00
:param url: Patch repository url, the url must be a valid git repository.
:return: None
"""
self._patch_url = url
async def download_patch(self) -> None:
2022-01-29 16:32:38 +00:00
"""
If `git` exists, this will clone the patch git url and save it to a temporary directory.
Else, this will download the patch from the patch url and save it to a temporary directory. (Not reliable)
2022-01-29 16:32:38 +00:00
:return: None
"""
await self._download_repo()
2022-01-29 16:32:38 +00:00
2022-02-27 11:22:21 +00:00
async def is_telemetry_blocked(self, optional=False):
"""
Check if the telemetry is blocked.
"""
if self._overseas:
telemetry_url = constants.TELEMETRY_URL_LIST
else:
telemetry_url = constants.TELEMETRY_URL_CN_LIST
if optional:
telemetry_url |= constants.TELEMETRY_OPTIONAL_URL_LIST
unblocked_list = []
async with aiohttp.ClientSession() as session:
for url in telemetry_url:
try:
await session.get("https://" + url, timeout=15)
except (aiohttp.ClientResponseError, aiohttp.ClientConnectorError, asyncio.exceptions.TimeoutError):
continue
else:
unblocked_list.append(url)
return None if not unblocked_list else unblocked_list
2022-02-27 11:22:21 +00:00
async def block_telemetry(self, optional=False):
telemetry = await self.is_telemetry_blocked(optional)
if not telemetry:
raise ValueError("All telemetry are blocked")
telemetry_hosts = "\n"
for url in telemetry:
telemetry_hosts += "0.0.0.0 " + url + "\n"
match platform.system():
case "Linux":
await self._linux.append_text_to_file(telemetry_hosts, "/etc/hosts")
return
# TODO: Windows and macOS
raise NotImplementedError("Platform not implemented.")
async def _patch_unityplayer_fallback(self, patch):
gamever = "".join((await self._installer.get_game_version()).split("."))
unity_path = self._gamedir.joinpath("UnityPlayer.dll")
await unity_path.rename(self._gamedir.joinpath("UnityPlayer.dll.bak"))
proc = await asyncio.create_subprocess_exec("xdelta3", "-d", "-s",
str(self._gamedir.joinpath("UnityPlayer.dll.bak")),
str(self._patch_path.joinpath(
"{}/patch_files/{}".format(gamever, patch))),
str(self._gamedir.joinpath("UnityPlayer.dll")), cwd=self._gamedir)
await proc.wait()
async def _patch_xlua_fallback(self, patch):
gamever = "".join((await self._installer.get_game_version()).split("."))
data_name = self._installer.get_game_data_name()
xlua_path = self._gamedir.joinpath("{}/Plugins/xlua.dll".format(data_name))
await xlua_path.rename(self._gamedir.joinpath("{}/Plugins/xlua.dll.bak".format(data_name)))
proc = await asyncio.create_subprocess_exec("xdelta3", "-d", "-s",
str(self._gamedir.joinpath(
"{}/Plugins/xlua.dll.bak".format(data_name))),
str(self._patch_path.joinpath(
"{}/patch_files/{}".format(gamever, patch))),
str(self._gamedir.joinpath(
"{}/Plugins/xlua.dll".format(data_name))),
cwd=self._gamedir)
await proc.wait()
async def _patch_unityplayer(self, patch):
gamever = "".join((await self._installer.get_game_version()).split("."))
unity_path = self._gamedir.joinpath("UnityPlayer.dll")
patch_bytes = self._patch_path.joinpath("{}/patch_files/{}".format(gamever, patch)).read_bytes()
patched_unity_bytes = xdelta3.decode(unity_path.read_bytes(), patch_bytes)
unity_path.rename(self._gamedir.joinpath("UnityPlayer.dll.bak"))
with Path(self._gamedir.joinpath("UnityPlayer.dll")).open("wb") as f:
f.write(patched_unity_bytes)
async def _patch_xlua(self, patch):
gamever = "".join((await self._installer.get_game_version()).split("."))
data_name = self._installer.get_game_data_name()
xlua_path = self._gamedir.joinpath("{}/Plugins/xlua.dll".format(data_name))
patch_bytes = self._patch_path.joinpath("{}/patch_files/{}".format(gamever, patch)).read_bytes()
patched_xlua_bytes = xdelta3.decode(xlua_path.read_bytes(), patch_bytes)
xlua_path.rename(self._gamedir.joinpath("{}/Plugins/xlua.dll.bak".format(data_name)))
2022-02-18 13:15:29 +00:00
with Path(self._gamedir.joinpath("{}/Plugins/xlua.dll".format(data_name))).open("wb") as f:
f.write(patched_xlua_bytes)
async def apply_xlua_patch(self, fallback=True):
if self._overseas:
patch = "xlua_patch_os.vcdiff"
else:
patch = "xlua_patch_cn.vcdiff"
if NO_XDELTA3_MODULE or fallback:
await self._patch_xlua_fallback(patch)
return
await self._patch_xlua(patch)
async def apply_patch(self, crash_fix=False, fallback=True) -> None:
2022-01-29 16:32:38 +00:00
"""
Patch the game (and optionally patch xLua if specified)
:param fallback:
:param crash_fix: Whether to patch xLua or not
2022-01-29 16:32:38 +00:00
:return: None
"""
# Patch UnityPlayer.dll
# xdelta3-python doesn't work because it's outdated.
if self._overseas:
patch = "unityplayer_patch_os.vcdiff"
else:
patch = "unityplayer_patch_cn.vcdiff"
patch_jobs = []
if NO_XDELTA3_MODULE or fallback:
patch_jobs.append(self._patch_unityplayer_fallback(patch))
else:
patch_jobs.append(self._patch_unityplayer(patch))
# Patch xLua.dll
if crash_fix:
patch_jobs.append(self.apply_xlua_patch(fallback=fallback))
# Disable crash reporters
async def disable_crashreporters():
disable_files = [
self._installer.get_game_data_name() + "upload_crash.exe",
self._installer.get_game_data_name() + "Plugins/crashreport.exe",
]
for file in disable_files:
file_path = Path(file).resolve()
if file_path.exists():
file_path.rename(str(file_path) + ".bak")
patch_jobs.append(disable_crashreporters())
await asyncio.gather(*patch_jobs)
2022-01-29 16:32:38 +00:00
@staticmethod
async def _creation_date(file_path: AsyncPath):
"""
Try to get the date that a file was created, falling back to when it was
last modified if that isn't possible.
See http://stackoverflow.com/a/39501288/1709587 for explanation.
"""
if platform.system() == 'Windows':
return os.path.getctime(file_path)
else:
stat = await file_path.stat()
try:
return stat.st_birthtime
except AttributeError:
# We're probably on Linux. No easy way to get creation dates here,
# so we'll settle for when its content was last modified.
return stat.st_mtime
async def _revert_file(self, original_file: str, base_file: AsyncPath, ignore_error=False):
original_path = await self._gamedir.joinpath(original_file + ".bak").resolve()
target_file = await self._gamedir.joinpath(original_file).resolve()
if await original_path.exists():
if abs(await self._creation_date(base_file) - await self._creation_date(original_path)) > 86400: # 24 hours
if not ignore_error:
raise RuntimeError("{} is not for this game version.".format(original_path.name))
original_path.unlink(missing_ok=True)
else:
target_file.unlink(missing_ok=True)
original_path.rename(target_file)
async def revert_patch(self, ignore_errors=True) -> None:
2022-01-29 16:32:38 +00:00
"""
Revert the patch (and revert the xLua patch if patched)
2022-01-29 16:32:38 +00:00
:return: None
"""
game_exec = self._gamedir.joinpath((await self._launcher.get_resource_info()).game.latest.entry)
revert_files = [
"UnityPlayer.dll",
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/xlua.dll",
]
revert_job = []
for file in revert_files:
revert_job.append(self._revert_file(file, game_exec, ignore_errors))
for file in ["launcher.bat", "mhyprot2_running.reg"]:
revert_job.append(self._gamedir.joinpath(file).unlink(missing_ok=True))
async def get_files(extensions):
all_files = []
for ext in extensions:
all_files.extend(await self._gamedir.glob(ext))
return all_files
files = await get_files(('*.dxvk-cache', '*_d3d9.log', '*_d3d11.log', '*_dxgi.log'))
for file in files:
revert_job.append(file.unlink(missing_ok=True))
await asyncio.gather(*revert_job)
async def clear_cache(self):
await asyncio.to_thread(shutil.rmtree, self._temp_path, ignore_errors=True)