360 lines
14 KiB
Python
360 lines
14 KiB
Python
import asyncio
|
|
import os
|
|
import platform
|
|
import shutil
|
|
import tarfile
|
|
from pathlib import Path
|
|
|
|
import aiohttp
|
|
|
|
from worthless import constants
|
|
from worthless.game import Game as Installer
|
|
from worthless.launcher import Launcher
|
|
|
|
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
|
|
|
|
|
|
class Patcher:
|
|
def __init__(
|
|
self,
|
|
gamedir: Path | str = None,
|
|
data_dir: str | Path = None,
|
|
patch_url: str = None,
|
|
overseas=True,
|
|
patch_provider="Krock",
|
|
):
|
|
if isinstance(gamedir, str | Path):
|
|
gamedir = Path(gamedir)
|
|
self._gamedir = gamedir
|
|
if not patch_url:
|
|
patch_url = constants.PATCH_LIST[patch_provider].replace(
|
|
"http://", "https://"
|
|
)
|
|
self._patch_url = patch_url
|
|
if not data_dir:
|
|
self._appdirs = constants.APPDIRS
|
|
self._patch_path = Path(self._appdirs.user_data_dir).joinpath("Patch")
|
|
self._temp_path = Path(self._appdirs.user_cache_dir).joinpath("Patcher")
|
|
else:
|
|
if isinstance(data_dir, str | Path):
|
|
data_dir = Path(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")
|
|
|
|
async with tarfile.open(archive) as tar:
|
|
await asyncio.to_thread(tar.extractall, self._patch_path)
|
|
|
|
def override_patch_url(self, url) -> None:
|
|
"""
|
|
Override the patch url.
|
|
|
|
: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:
|
|
"""
|
|
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)
|
|
|
|
:return: None
|
|
"""
|
|
await self._download_repo()
|
|
|
|
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
|
|
|
|
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))
|
|
)
|
|
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:
|
|
"""
|
|
Patch the game (and optionally patch xLua if specified)
|
|
|
|
:param fallback:
|
|
:param crash_fix: Whether to patch xLua or not
|
|
: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(self._gamedir.joinpath(file)).resolve()
|
|
if file_path.exists():
|
|
await Path(file_path).rename(str(file_path) + ".bak")
|
|
|
|
patch_jobs.append(disable_crashreporters())
|
|
await asyncio.gather(*patch_jobs)
|
|
|
|
@staticmethod
|
|
async def _creation_date(file_path: str | Path | Path):
|
|
"""
|
|
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 isinstance(file_path, str | Path):
|
|
file_path = Path(file_path)
|
|
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: Path, ignore_error=False
|
|
):
|
|
original_path = Path(self._gamedir.joinpath(original_file + ".bak")).resolve()
|
|
target_file = Path(self._gamedir.joinpath(original_file)).resolve()
|
|
if 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:
|
|
"""
|
|
Revert the patch (and revert the xLua patch if patched)
|
|
|
|
: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))
|
|
|
|
def get_files(extensions):
|
|
all_files = []
|
|
for ext in extensions:
|
|
all_files.extend(Path(self._gamedir).glob(ext))
|
|
return all_files
|
|
|
|
files = get_files(("*.dxvk-cache", "*_d3d9.log", "*_d3d11.log", "*_dxgi.log"))
|
|
for file in files:
|
|
revert_job.append(Path(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)
|
|
await asyncio.to_thread(shutil.rmtree, self._patch_path, ignore_errors=True)
|